Skip to content

Commit 60d2ef2

Browse files
authored
Workaround asyncio signal handling on Unix (#479)
Unix asyncio loops override existing signal wakeup file descriptors with no regards for existing ones -- set by user code or by another loop Signed-off-by: Michel Hidalgo <[email protected]>
1 parent 3d77b5d commit 60d2ef2

File tree

2 files changed

+94
-19
lines changed

2 files changed

+94
-19
lines changed

launch/launch/utilities/signal_management.py

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,19 @@ class AsyncSafeSignalManager:
6666
:func:`signal.signal`.
6767
All signals received are forwarded to the previously setup file
6868
descriptor, if any.
69+
70+
..warning::
71+
Within (potentially nested) contexts, :func:`signal.set_wakeup_fd`
72+
calls are intercepted such that the given file descriptor overrides
73+
the previously setup file descriptor for the outermost manager.
74+
This ensures the manager's chain of signal wakeup file descriptors
75+
is not broken by third-party code or by asyncio itself in some platforms.
6976
"""
7077

78+
__current = None # type: AsyncSafeSignalManager
79+
80+
__set_wakeup_fd = signal.set_wakeup_fd # type: Callable[[int], int]
81+
7182
def __init__(
7283
self,
7384
loop: asyncio.AbstractEventLoop
@@ -77,6 +88,7 @@ def __init__(
7788
7889
:param loop: event loop that will handle the signals.
7990
"""
91+
self.__parent = None # type: AsyncSafeSignalManager
8092
self.__loop = loop # type: asyncio.AbstractEventLoop
8193
self.__background_loop = None # type: Optional[asyncio.AbstractEventLoop]
8294
self.__handlers = {} # type: dict
@@ -86,12 +98,31 @@ def __init__(
8698
self.__rsock.setblocking(False)
8799

88100
def __enter__(self):
101+
self.__add_signal_readers()
102+
try:
103+
self.__install_signal_writers()
104+
except Exception:
105+
self.__remove_signal_readers()
106+
raise
107+
self.__chain()
108+
return self
109+
110+
def __exit__(self, exc_type, exc_value, exc_traceback):
111+
try:
112+
try:
113+
self.__uninstall_signal_writers()
114+
finally:
115+
self.__remove_signal_readers()
116+
finally:
117+
self.__unchain()
118+
119+
def __add_signal_readers(self):
89120
try:
90121
self.__loop.add_reader(self.__rsock.fileno(), self.__handle_signal)
91122
except NotImplementedError:
92123
# Some event loops, like the asyncio.ProactorEventLoop
93124
# on Windows, do not support asynchronous socket reads.
94-
# So we emulate it.
125+
# Emulate it.
95126
self.__background_loop = asyncio.SelectorEventLoop()
96127
self.__background_loop.add_reader(
97128
self.__rsock.fileno(),
@@ -102,29 +133,65 @@ def run_background_loop():
102133
asyncio.set_event_loop(self.__background_loop)
103134
self.__background_loop.run_forever()
104135

105-
self.__background_thread = threading.Thread(target=run_background_loop)
136+
self.__background_thread = threading.Thread(
137+
target=run_background_loop, daemon=True)
106138
self.__background_thread.start()
107-
self.__prev_wakeup_handle = signal.set_wakeup_fd(self.__wsock.fileno())
108-
if self.__prev_wakeup_handle != -1 and is_winsock_handle(self.__prev_wakeup_handle):
109-
# On Windows, os.write will fail on a WinSock handle. There is no WinSock API
110-
# in the standard library either. Thus we wrap it in a socket.socket instance.
111-
self.__prev_wakeup_handle = socket.socket(fileno=self.__prev_wakeup_handle)
112-
return self
113139

114-
def __exit__(self, type_, value, traceback):
115-
if isinstance(self.__prev_wakeup_handle, socket.socket):
116-
# Detach (Windows) socket and retrieve the raw OS handle.
117-
prev_wakeup_handle = self.__prev_wakeup_handle.fileno()
118-
self.__prev_wakeup_handle.detach()
119-
self.__prev_wakeup_handle = prev_wakeup_handle
120-
assert self.__wsock.fileno() == signal.set_wakeup_fd(self.__prev_wakeup_handle)
140+
def __remove_signal_readers(self):
121141
if self.__background_loop:
122142
self.__background_loop.call_soon_threadsafe(self.__background_loop.stop)
123143
self.__background_thread.join()
124144
self.__background_loop.close()
145+
self.__background_loop = None
125146
else:
126147
self.__loop.remove_reader(self.__rsock.fileno())
127148

149+
def __install_signal_writers(self):
150+
prev_wakeup_handle = self.__set_wakeup_fd(self.__wsock.fileno())
151+
try:
152+
self.__chain_wakeup_handle(prev_wakeup_handle)
153+
except Exception:
154+
own_wakeup_handle = self.__set_wakeup_fd(prev_wakeup_handle)
155+
assert self.__wsock.fileno() == own_wakeup_handle
156+
raise
157+
158+
def __uninstall_signal_writers(self):
159+
prev_wakeup_handle = self.__chain_wakeup_handle(-1)
160+
own_wakeup_handle = self.__set_wakeup_fd(prev_wakeup_handle)
161+
assert self.__wsock.fileno() == own_wakeup_handle
162+
163+
def __chain(self):
164+
self.__parent = AsyncSafeSignalManager.__current
165+
AsyncSafeSignalManager.__current = self
166+
if self.__parent is None:
167+
# Do not trust signal.set_wakeup_fd calls within context.
168+
# Overwrite handle at the start of the managers' chain.
169+
def modified_set_wakeup_fd(signum):
170+
if threading.current_thread() is not threading.main_thread():
171+
raise ValueError(
172+
'set_wakeup_fd only works in main'
173+
' thread of the main interpreter'
174+
)
175+
return self.__chain_wakeup_handle(signum)
176+
signal.set_wakeup_fd = modified_set_wakeup_fd
177+
178+
def __unchain(self):
179+
if self.__parent is None:
180+
signal.set_wakeup_fd = self.__set_wakeup_fd
181+
AsyncSafeSignalManager.__current = self.__parent
182+
183+
def __chain_wakeup_handle(self, wakeup_handle):
184+
prev_wakeup_handle = self.__prev_wakeup_handle
185+
if isinstance(prev_wakeup_handle, socket.socket):
186+
# Detach (Windows) socket and retrieve the raw OS handle.
187+
prev_wakeup_handle = prev_wakeup_handle.detach()
188+
if wakeup_handle != -1 and is_winsock_handle(wakeup_handle):
189+
# On Windows, os.write will fail on a WinSock handle. There is no WinSock API
190+
# in the standard library either. Thus we wrap it in a socket.socket instance.
191+
wakeup_handle = socket.socket(fileno=wakeup_handle)
192+
self.__prev_wakeup_handle = wakeup_handle
193+
return prev_wakeup_handle
194+
128195
def __handle_signal(self):
129196
while True:
130197
try:

launch/test/launch/utilities/test_signal_management.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ def _wrapper(*args, **kwargs):
5151
SIGNAL = signal.SIGUSR1
5252
ANOTHER_SIGNAL = signal.SIGUSR2
5353

54+
if not hasattr(signal, 'raise_signal'):
55+
# Only available for Python 3.8+
56+
def raise_signal(signum):
57+
import os
58+
os.kill(os.getpid(), signum)
59+
else:
60+
raise_signal = signal.raise_signal
61+
5462

5563
@cap_signals(SIGNAL, ANOTHER_SIGNAL)
5664
def test_async_safe_signal_manager():
@@ -70,7 +78,7 @@ def test_async_safe_signal_manager():
7078
manager.handle(ANOTHER_SIGNAL, got_another_signal.set_result)
7179

7280
# Verify signal handling is working
73-
loop.call_soon(signal.raise_signal, SIGNAL)
81+
loop.call_soon(raise_signal, SIGNAL)
7482
loop.run_until_complete(asyncio.wait(
7583
[got_signal, got_another_signal],
7684
return_when=asyncio.FIRST_COMPLETED,
@@ -84,22 +92,22 @@ def test_async_safe_signal_manager():
8492
manager.handle(SIGNAL, None)
8593

8694
# Verify signal handler is no longer there
87-
loop.call_soon(signal.raise_signal, SIGNAL)
95+
loop.call_soon(raise_signal, SIGNAL)
8896
loop.run_until_complete(asyncio.wait(
8997
[got_another_signal], timeout=1.0
9098
))
9199
assert not got_another_signal.done()
92100

93101
# Signal handling is (now) inactive outside context
94-
loop.call_soon(signal.raise_signal, ANOTHER_SIGNAL)
102+
loop.call_soon(raise_signal, ANOTHER_SIGNAL)
95103
loop.run_until_complete(asyncio.wait(
96104
[got_another_signal], timeout=1.0
97105
))
98106
assert not got_another_signal.done()
99107

100108
# Managers' context may be re-entered
101109
with manager:
102-
loop.call_soon(signal.raise_signal, ANOTHER_SIGNAL)
110+
loop.call_soon(raise_signal, ANOTHER_SIGNAL)
103111
loop.run_until_complete(asyncio.wait(
104112
[got_another_signal], timeout=1.0
105113
))

0 commit comments

Comments
 (0)