Skip to content

Commit 61f2cba

Browse files
authored
Merge aa6fa73 into 99a9ea8
2 parents 99a9ea8 + aa6fa73 commit 61f2cba

6 files changed

Lines changed: 150 additions & 40 deletions

File tree

source/extensionPoints/util.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@ class BoundMethodWeakref(object):
2727
To get the actual method, you call an instance as you would a weakref.ref.
2828
"""
2929

30-
def __init__(self, target, onDelete):
31-
def onRefDelete(weak):
32-
"""Calls onDelete for our BoundMethodWeakref when one of the individual weakrefs (instance or function) dies.
33-
"""
34-
onDelete(self)
30+
def __init__(self, target, onDelete=None):
31+
if onDelete:
32+
def onRefDelete(weak):
33+
"""Calls onDelete for our BoundMethodWeakref when one of the individual weakrefs (instance or function) dies.
34+
"""
35+
onDelete(self)
36+
else:
37+
onRefDelete = None
3538
inst = target.__self__
3639
func = target.__func__
3740
self.weakInst = weakref.ref(inst, onRefDelete)

source/hwIo/base.py

Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# A part of NonVisual Desktop Access (NVDA)
22
# This file is covered by the GNU General Public License.
33
# See the file COPYING for more details.
4-
# Copyright (C) 2015-2018 NV Access Limited, Babbage B.V.
4+
# Copyright (C) 2015-2023 NV Access Limited, Babbage B.V., Leonard de Ruijter
55

66

77
"""Raw input/output for braille displays via serial and HID.
@@ -10,37 +10,43 @@
1010
See L{braille.BrailleDisplayDriver.isThreadSafe}.
1111
"""
1212

13+
from __future__ import annotations
1314
import sys
1415
import ctypes
1516
from ctypes import byref
1617
from ctypes.wintypes import DWORD
1718
from typing import Optional, Any, Union, Tuple, Callable
18-
19+
import weakref
1920
import serial
2021
from serial.win32 import OVERLAPPED, FILE_FLAG_OVERLAPPED, INVALID_HANDLE_VALUE, ERROR_IO_PENDING, COMMTIMEOUTS, CreateFile, SetCommTimeouts
2122
import winKernel
2223
import braille
2324
from logHandler import log
2425
import config
2526
import time
27+
from .ioThread import IoThread
28+
# LPOVERLAPPED_COMPLETION_ROUTINE is imported for backwards compatibility.
29+
from .ioThread import LPOVERLAPPED_COMPLETION_ROUTINE # NOQA: F401
2630

27-
LPOVERLAPPED_COMPLETION_ROUTINE = ctypes.WINFUNCTYPE(None, DWORD, DWORD, serial.win32.LPOVERLAPPED)
2831

2932
def _isDebug():
3033
return config.conf["debugLog"]["hwIo"]
3134

35+
3236
class IoBase(object):
3337
"""Base class for raw I/O.
3438
This watches for data of a specified size and calls a callback when it is received.
3539
"""
40+
_ioThreadRef: weakref.ReferenceType[IoThread]
3641

3742
def __init__(
3843
self,
3944
fileHandle: Union[ctypes.wintypes.HANDLE],
4045
onReceive: Callable[[bytes], None],
4146
writeFileHandle: Optional[ctypes.wintypes.HANDLE] = None,
4247
onReceiveSize: int = 1,
43-
onReadError: Optional[Callable[[int], bool]] = None
48+
onReadError: Optional[Callable[[int], bool]] = None,
49+
ioThread: Optional[IoThread] = None,
4450
):
4551
"""Constructor.
4652
@param fileHandle: A handle to an open I/O device opened for overlapped I/O.
@@ -50,8 +56,10 @@ def __init__(
5056
@param writeFileHandle: A handle to an open output device opened for overlapped I/O.
5157
@param onReceiveSize: The size (in bytes) of the data with which to call C{onReceive}.
5258
@param onReadError: If provided, a callback that takes the error code for a failed read
53-
and returns True if the I/O loop should exit cleanly or False if an
54-
exception should be thrown
59+
and returns True if the I/O loop should exit cleanly or False if an
60+
exception should be thrown
61+
@param ioThread: If provided, the I/O thread used for background reads.
62+
if C{None}, defaults to L{hwIo.bgThread}
5563
"""
5664
self._file = fileHandle
5765
self._onReceive = onReceive
@@ -61,18 +69,21 @@ def __init__(
6169
self._readBuf = ctypes.create_string_buffer(onReceiveSize)
6270
self._readOl = OVERLAPPED()
6371
self._recvEvt = winKernel.createEvent()
64-
self._ioDoneInst = LPOVERLAPPED_COMPLETION_ROUTINE(self._ioDone)
6572
self._writeOl = OVERLAPPED()
73+
if ioThread is None:
74+
from . import bgThread as ioThread
75+
self._ioThreadRef = weakref.ref(ioThread)
6676
# Do the initial read.
6777
self._initialRead()
6878

6979
def _initialRead(self):
70-
"""Performs the initial background read by queuing it as an APC to the IO background thread.
71-
This method is tied to the built-in i/o thread.
72-
It can be overridden to do an initial read on a different thread.
80+
"""Performs the initial background read by queuing it as an APC to the IO background thread
81+
provided at initialization time.
7382
"""
74-
from . import bgThread
75-
bgThread.queueAsApc(lambda param: self._asyncRead())
83+
ioThread = self._ioThreadRef()
84+
if not ioThread:
85+
raise RuntimeError("I/O thread is no longer available")
86+
ioThread.queueAsApc(self._asyncRead)
7687

7788
def waitForRead(self, timeout:Union[int, float]) -> bool:
7889
"""Wait for a chunk of data to be received and processed.
@@ -137,11 +148,20 @@ def __del__(self):
137148
if _isDebug():
138149
log.debugWarning("Couldn't delete object gracefully", exc_info=True)
139150

140-
def _asyncRead(self):
151+
def _asyncRead(self, param: Optional[int] = None):
152+
ioThread = self._ioThreadRef()
153+
if not ioThread:
154+
raise RuntimeError("I/O thread is no longer available")
141155
# Wait for _readSize bytes of data.
142156
# _ioDone will call onReceive once it is received.
143157
# onReceive can then optionally read additional bytes if it knows these are coming.
144-
ctypes.windll.kernel32.ReadFileEx(self._file, self._readBuf, self._readSize, byref(self._readOl), self._ioDoneInst)
158+
ctypes.windll.kernel32.ReadFileEx(
159+
self._file,
160+
self._readBuf,
161+
self._readSize,
162+
byref(self._readOl),
163+
ioThread.queueCompletionRoutine(self._ioDone)
164+
)
145165

146166
def _ioDone(self, error, numberOfBytes: int, overlapped):
147167
if not self._onReceive:
@@ -173,21 +193,30 @@ def _notifyReceive(self, data: bytes):
173193
except:
174194
log.error("", exc_info=True)
175195

196+
176197
class Serial(IoBase):
177198
"""Raw I/O for serial devices.
178199
This extends pyserial to call a callback when data is received.
179200
"""
180201

181202
def __init__(
182-
self,
183-
*args,
184-
onReceive: Callable[[bytes], None],
185-
**kwargs):
203+
self,
204+
*args,
205+
onReceive: Callable[[bytes], None],
206+
onReadError: Optional[Callable[[int], bool]] = None,
207+
ioThread: Optional[IoThread] = None,
208+
**kwargs
209+
):
186210
"""Constructor.
187211
Pass the arguments you would normally pass to L{serial.Serial}.
188-
There is also one additional required keyword argument.
212+
There are also some additional keyword arguments ( the first is required).
189213
@param onReceive: A callable taking a byte of received data as its only argument.
190214
This callable can then call C{read} to get additional data if desired.
215+
@param onReadError: If provided, a callback that takes the error code for a failed read
216+
and returns True if the I/O loop should exit cleanly or False if an
217+
exception should be thrown
218+
@param ioThread: If provided, the I/O thread used for background reads.
219+
if C{None}, defaults to L{hwIo.bgThread}
191220
"""
192221
self._ser = None
193222
self.port = args[0] if len(args) >= 1 else kwargs["port"]
@@ -202,7 +231,12 @@ def __init__(
202231
self._origTimeout = self._ser.timeout
203232
# We don't want a timeout while we're waiting for data.
204233
self._setTimeout(None)
205-
super(Serial, self).__init__(self._ser._port_handle, onReceive)
234+
super().__init__(
235+
self._ser._port_handle,
236+
onReceive,
237+
onReadError=onReadError,
238+
ioThread=ioThread
239+
)
206240

207241
def read(self, size=1) -> bytes:
208242
data = self._ser.read(size)
@@ -257,16 +291,19 @@ def __init__(
257291
self, path: str, epIn: int, epOut: int,
258292
onReceive: Callable[[bytes], None],
259293
onReceiveSize: int = 1,
260-
onReadError: Optional[Callable[[int], bool]] = None
294+
onReadError: Optional[Callable[[int], bool]] = None,
295+
ioThread: Optional[IoThread] = None,
261296
):
262297
"""Constructor.
263298
@param path: The device path.
264299
@param epIn: The endpoint to read data from.
265300
@param epOut: The endpoint to write data to.
266301
@param onReceive: A callable taking a received input report as its only argument.
267302
@param onReadError: An optional callable that handles read errors.
268-
It takes an error code and returns True if the error has been handled,
269-
allowing the read loop to exit cleanly, or False if an exception should be thrown.
303+
It takes an error code and returns True if the error has been handled,
304+
allowing the read loop to exit cleanly, or False if an exception should be thrown.
305+
@param ioThread: If provided, the I/O thread used for background reads.
306+
if C{None}, defaults to L{hwIo.bgThread}
270307
"""
271308
if _isDebug():
272309
log.debug("Opening device %s" % path)
@@ -284,9 +321,14 @@ def __init__(
284321
if _isDebug():
285322
log.debug("Open write handle failed: %s" % ctypes.WinError())
286323
raise ctypes.WinError()
287-
super(Bulk, self).__init__(readHandle, onReceive,
288-
writeFileHandle=writeHandle, onReceiveSize=onReceiveSize,
289-
onReadError=onReadError)
324+
super().__init__(
325+
readHandle,
326+
onReceive,
327+
writeFileHandle=writeHandle,
328+
onReceiveSize=onReceiveSize,
329+
onReadError=onReadError,
330+
ioThread=ioThread
331+
)
290332

291333
def close(self):
292334
super(Bulk, self).close()

source/hwIo/hid.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
import ctypes
1313
from ctypes import byref
1414
from ctypes.wintypes import USHORT
15-
from typing import Tuple, Callable
15+
from typing import Tuple, Callable, Optional
16+
from .ioThread import IoThread
1617

1718
from serial.win32 import FILE_FLAG_OVERLAPPED, INVALID_HANDLE_VALUE, CreateFile
1819
import winKernel
@@ -126,12 +127,24 @@ class Hid(IoBase):
126127
"""
127128
_featureSize: int
128129

129-
def __init__(self, path: str, onReceive: Callable[[bytes], None], exclusive: bool = True):
130+
def __init__(
131+
self,
132+
path: str,
133+
onReceive: Callable[[bytes], None],
134+
exclusive: bool = True,
135+
onReadError: Optional[Callable[[int], bool]] = None,
136+
ioThread: Optional[IoThread] = None,
137+
):
130138
"""Constructor.
131139
@param path: The device path.
132140
This can be retrieved using L{hwPortUtils.listHidDevices}.
133141
@param onReceive: A callable taking a received input report as its only argument.
134142
@param exclusive: Whether to block other application's access to this device.
143+
@param onReadError: An optional callable that handles read errors.
144+
It takes an error code and returns True if the error has been handled,
145+
allowing the read loop to exit cleanly, or False if an exception should be thrown.
146+
@param ioThread: If provided, the I/O thread used for background reads.
147+
if C{None}, defaults to L{hwIo.bgThread}
135148
"""
136149
if _isDebug():
137150
log.debug("Opening device %s" % path)
@@ -168,7 +181,11 @@ def __init__(self, path: str, onReceive: Callable[[bytes], None], exclusive: boo
168181
self._readSize = caps.InputReportByteLength
169182
# Reading any less than caps.InputReportByteLength is an error.
170183
super().__init__(
171-
handle, onReceive, onReceiveSize=caps.InputReportByteLength
184+
handle,
185+
onReceive,
186+
onReceiveSize=caps.InputReportByteLength,
187+
onReadError=onReadError,
188+
ioThread=ioThread
172189
)
173190

174191
@property

source/hwIo/ioThread.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,19 @@
1010
import winKernel
1111
import typing
1212
from logHandler import log
13+
import serial.win32
1314
import extensionPoints
1415
import uuid
1516
from contextlib import contextmanager
17+
from extensionPoints.util import AnnotatableWeakref, BoundMethodWeakref
18+
from inspect import ismethod
1619

20+
LPOVERLAPPED_COMPLETION_ROUTINE = ctypes.WINFUNCTYPE(
21+
None,
22+
ctypes.wintypes.DWORD,
23+
ctypes.wintypes.DWORD,
24+
serial.win32.LPOVERLAPPED
25+
)
1726
pre_IoThreadStop = extensionPoints.Action()
1827
"""
1928
Executed when the i/o thread is to be stopped.
@@ -59,28 +68,66 @@ def queueAsApc(
5968

6069
# generate an UUID that will be used to cleanup the APC when it is finished
6170
apcUuid = uuid.uuid4()
71+
# Generate a weak reference to the function
72+
reference = BoundMethodWeakref(func) if ismethod(func) else AnnotatableWeakref(func)
73+
reference.funcName = repr(func)
6274

6375
@winKernel.PAPCFUNC
6476
def apc(param: int):
6577
with self.autoDeleteApcReference(apcUuid):
6678
if self.exit:
6779
return
80+
function = reference()
81+
if not function:
82+
log.debugWarning(f"Not executing queued APC {reference.funcName} because reference died")
83+
return
6884
try:
69-
func(param)
85+
function(param)
7086
except Exception:
71-
log.error("Error in APC function queued to IoThread", exc_info=True)
87+
log.error(f"Error in APC function {function!r} queued to IoThread", exc_info=True)
7288

7389
self._apcReferences[apcUuid] = apc
7490
ctypes.windll.kernel32.QueueUserAPC(apc, self.handle, param)
7591

92+
def queueCompletionRoutine(
93+
self,
94+
func: typing.Callable[[int, int, serial.win32.LPOVERLAPPED], None],
95+
):
96+
if not self.is_alive():
97+
raise RuntimeError("Thread is not running")
98+
99+
# generate an UUID that will be used to cleanup the func when it is finished
100+
ocrUuid = uuid.uuid4()
101+
# Generate a weak reference to the function
102+
reference = BoundMethodWeakref(func) if ismethod(func) else AnnotatableWeakref(func)
103+
reference.funcName = repr(func)
104+
105+
@LPOVERLAPPED_COMPLETION_ROUTINE
106+
def overlappedCompletionRoutine(error: int, numberOfBytes: int, overlapped: serial.win32.LPOVERLAPPED):
107+
with self.autoDeleteApcReference(ocrUuid):
108+
if self.exit:
109+
return
110+
function = reference()
111+
if not function:
112+
log.debugWarning(f"Not executing completion routine {reference.funcName} because reference died")
113+
return
114+
try:
115+
function(error, numberOfBytes, overlapped)
116+
except Exception:
117+
log.error(f"Error in overlapped completion routine {func!r}", exc_info=True)
118+
119+
self._apcReferences[ocrUuid] = overlappedCompletionRoutine
120+
return overlappedCompletionRoutine
121+
76122
def stop(self, timeout: typing.Optional[float] = None):
77123
if not self.is_alive():
78124
raise RuntimeError("Thread is not running")
79125
self.exit = True
80126
# Wake up the thread. It will exit when it sees exit is True.
81127
# We do this by queuing a fake lambda that does nothing.
82128
# L{queueAsApc} will ensure that the APC exits early when the thread is about to exit.
83-
self.queueAsApc(lambda param: None)
129+
fakeApc = lambda param: None
130+
self.queueAsApc(fakeApc)
84131
self.join(timeout)
85132
self.exit = False
86133
winKernel.closeHandle(self.handle)

tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def queueNVDAMainThreadCrash(self):
147147

148148
def queueNVDAIoThreadCrash(self):
149149
from hwIo import bgThread
150-
bgThread.queueAsApc(lambda param: _crashNVDA())
150+
bgThread.queueAsApc(_crashNVDA)
151151

152152
def queueNVDAUIAHandlerThreadCrash(self):
153153
from UIAHandler import handler
@@ -542,7 +542,7 @@ def terminate(self):
542542
self._server.stop()
543543

544544

545-
def _crashNVDA():
545+
def _crashNVDA(param: Optional[int] = None):
546546
# Causes a breakpoint exception to occur in the current process.
547547
# This allows the calling thread to signal the debugger to handle the exception.
548548
#

tests/unit/test_hwIo.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def test_apc(self):
3030
# Initially, our event isn't set
3131
self.assertFalse(self.event.is_set())
3232
# Queue a lambda as APC that sets the event
33-
hwIo.bgThread.queueAsApc(lambda param: self.event.set())
33+
setter = lambda param: self.event.set()
34+
hwIo.bgThread.queueAsApc(setter)
3435
# Wait for atmost 2 seconds for the event to be set
3536
self.assertTrue(self.event.wait(2))

0 commit comments

Comments
 (0)