1818"""
1919from __future__ import annotations
2020
21+ import multiprocessing as mp
22+ import os
2123import signal
24+ import sys
2225
2326import pytest
2427
@@ -45,10 +48,36 @@ def parent(self):
4548 return None
4649
4750
51+ def _run_real_signal_probe (ready_pipe , log_queue , signal_point , entered_signal_point , shutdown_called ) -> None :
52+
53+ class SignalWaitingWorker :
54+
55+ READY_STR = "READY"
56+
57+ def __init__ (self , * args , ** kwargs ) -> None :
58+ if signal_point == "worker_construction" :
59+ entered_signal_point .set ()
60+ signal .pause ()
61+
62+ def worker_busy_loop (self ) -> None :
63+ assert signal_point == "worker_loop"
64+ entered_signal_point .set ()
65+ signal .pause ()
66+
67+ def shutdown (self ) -> None :
68+ shutdown_called .set ()
69+
70+ multiproc_executor .kill_itself_when_parent_died = lambda : None
71+ multiproc_executor .faulthandler .enable = lambda : None
72+ multiproc_executor .psutil .Process = _ParentlessProcess
73+ multiproc_executor .WorkerMultiprocProc = SignalWaitingWorker
74+ WorkerMultiprocProc .worker_main (ready_pipe = ready_pipe , rank = 7 , log_queue = log_queue )
75+
76+
4877@pytest .mark .parametrize (
4978 ("signum" , "expected_reason" , "unexpected_reason" ),
5079 [
51- (signal .SIGTERM , "out-of-memory daemon " , "user interrupted" ),
80+ (signal .SIGTERM , "parent cleaning up workers " , "user interrupted" ),
5281 (signal .SIGINT , "user interrupted" , "out-of-memory daemon" ),
5382 ],
5483)
@@ -130,3 +159,51 @@ def __init__(self, *args, **kwargs) -> None:
130159 assert exc_info .value .code == 23
131160 assert ready_pipe .closed
132161 assert logged_messages == []
162+
163+
164+ @pytest .mark .skipif (sys .platform != "linux" , reason = "POSIX signal delivery is a Linux worker contract" )
165+ @pytest .mark .parametrize ("signum" , [signal .SIGTERM , signal .SIGINT ])
166+ @pytest .mark .parametrize ("signal_point" , ["worker_construction" , "worker_loop" ])
167+ def test_worker_main_forwards_real_signal_traceback_across_processes (signum : int , signal_point : str ) -> None :
168+ context = mp .get_context ("spawn" )
169+ entered_signal_point = context .Event ()
170+ shutdown_called = context .Event ()
171+ parent_ready_pipe , child_ready_pipe = context .Pipe (duplex = False )
172+ log_queue = context .Queue ()
173+
174+ process = context .Process (
175+ target = _run_real_signal_probe ,
176+ args = (child_ready_pipe , log_queue , signal_point , entered_signal_point , shutdown_called ),
177+ )
178+ process .start ()
179+ child_ready_pipe .close ()
180+
181+ try :
182+ assert entered_signal_point .wait (timeout = 5 ), "child did not reach the requested signal point"
183+ if signal_point == "worker_loop" :
184+ assert parent_ready_pipe .recv () == {"status" : "READY" }
185+
186+ os .kill (process .pid , signum )
187+ process .join (timeout = 5 )
188+ assert not process .is_alive (), "signalled worker did not exit"
189+ assert process .exitcode == 0 # Preserve the historical argument-less SystemExit status.
190+
191+ record = log_queue .get (timeout = 5 )
192+ message = record .getMessage ()
193+ assert f"Worker 7 received { signal .Signals (signum ).name } ({ signum } )" in message
194+ assert "Traceback (most recent call last)" in message
195+ assert "The stack below is where execution was interrupted, not the cause." in message
196+
197+ if signal_point == "worker_loop" :
198+ assert shutdown_called .wait (timeout = 1 )
199+ else :
200+ assert not shutdown_called .is_set ()
201+ with pytest .raises (EOFError ):
202+ parent_ready_pipe .recv ()
203+ finally :
204+ if process .is_alive ():
205+ process .kill ()
206+ process .join (timeout = 5 )
207+ parent_ready_pipe .close ()
208+ log_queue .close ()
209+ log_queue .join_thread ()
0 commit comments