Skip to content

Commit b1867a1

Browse files
[bugfix]: verify signal diagnostics across processes
1 parent 684b889 commit b1867a1

3 files changed

Lines changed: 86 additions & 4 deletions

File tree

docs/getting_started/installation/spark_performance.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,11 @@ is power-cycled. To avoid it:
158158
bare foreground high-parallelism build.
159159
- Leave `*_cpu_offload` at the example defaults — "CPU" offload is the *same*
160160
unified RAM on the GB10, so the win is tiling + sane resolution, not offloading.
161+
- Some Spark images run `earlyoom` with a preference for terminating Python
162+
processes under memory pressure. A worker's SIGTERM log and traceback show
163+
where it was interrupted, not why it was selected; confirm the cause in the
164+
`earlyoom` service or system logs. A later SIGKILL or kernel OOM kill cannot be
165+
caught and reported by Python.
161166

162167
## Gotchas specific to the GB10
163168

fastvideo/tests/worker/test_signal_terminated_worker_is_reported.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
"""
1919
from __future__ import annotations
2020

21+
import multiprocessing as mp
22+
import os
2123
import signal
24+
import sys
2225

2326
import 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()

fastvideo/worker/multiproc_executor.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -599,9 +599,9 @@ def signal_handler(signum, frame):
599599
else:
600600
logger.exception(
601601
"Worker %d received %s (%d) while running. This can come from an external process, "
602-
"such as an out-of-memory daemon, or from the parent forcing shutdown after a worker "
603-
"failed to exit. The stack below is where execution was interrupted, not the cause.", rank,
604-
signal_name, exc.signum)
602+
"such as an out-of-memory daemon, or from the parent cleaning up workers, including "
603+
"after another worker failed. The stack below is where execution was interrupted, not "
604+
"the cause.", rank, signal_name, exc.signum)
605605
raise
606606

607607
except Exception as exc:

0 commit comments

Comments
 (0)