Files
vLLM-4x-RTX-3090/patches/0058-executor-detect-a-fail-closed-fault-or-death-on-any-.patch
T
MOVZX 777e910dd0 Initial commit
Signed-off-by: MOVZX <movzx@yahoo.com>
2026-10-15 01:01:52 +07:00

2101 lines
82 KiB
Diff

From 36980a4bc6f822c2458301327003cedb7c6d183c Mon Sep 17 00:00:00 2001
From: halt95 <halt95@users.noreply.github.com>
Date: Thu, 24 Sep 2026 07:58:38 -0400
Subject: [PATCH 058/103] executor: detect a fail-closed fault or death on any
rank
A fault on a non-zero TP rank could hang the engine: the worker's sentinel pipe
was inherited by a helper process and stayed open, so the monitor never saw
the exit. Earlier releases carry the same monitor and fail-closed exit path.
- Worker exit is detected by pidfd, with the sentinel as fallback, before and
after READY and for every worker through the whole initialisation window.
- A per-worker fault pipe: ple_fail_closed writes one byte before os._exit and
the monitor starts the failure ladder at once; the fd stays registered
through worker teardown.
- worker_main marks inherited pipes close-on-exec.
- Any unexpected busy-loop failure exits 1; the termination ladder uses
time.monotonic(); forced-exit paths no longer wait on logging.
- Tests on real fork/spawn workers for rank 0 and rank 1, with and without an
fd-inheriting helper.
---
.../test_engine_core_teardown_signal.py | 35 ++
tests/v1/executor/test_executor.py | 2 +
.../test_multiproc_failed_worker_ladder.py | 56 ++-
.../executor/test_multiproc_fatal_any_rank.py | 343 +++++++++++++++
.../test_multiproc_monitor_any_rank.py | 226 ++++++++++
.../test_multiproc_monitor_combined.py | 147 +++++++
.../test_multiproc_ready_and_inherit.py | 355 +++++++++++++++
.../test_multiproc_worker_exit_after_drain.py | 28 +-
vllm/v1/engine/core.py | 53 ++-
vllm/v1/executor/multiproc_executor.py | 410 ++++++++++++++----
vllm/v1/ple_offload/connector.py | 3 +-
vllm/v1/ple_offload/fatal.py | 35 ++
12 files changed, 1587 insertions(+), 106 deletions(-)
create mode 100644 tests/v1/executor/test_multiproc_fatal_any_rank.py
create mode 100644 tests/v1/executor/test_multiproc_monitor_any_rank.py
create mode 100644 tests/v1/executor/test_multiproc_monitor_combined.py
create mode 100644 tests/v1/executor/test_multiproc_ready_and_inherit.py
diff --git a/tests/v1/engine/test_engine_core_teardown_signal.py b/tests/v1/engine/test_engine_core_teardown_signal.py
index 94a5255..822c735 100644
--- a/tests/v1/engine/test_engine_core_teardown_signal.py
+++ b/tests/v1/engine/test_engine_core_teardown_signal.py
@@ -35,6 +35,9 @@ def _child_run(conn, mode: str = "once") -> None:
from vllm.v1.engine import core
+ blocked_log = mode.endswith("-blocked-log")
+ if blocked_log:
+ mode = mode[: -len("-blocked-log")]
if mode == "once-hang":
core._TEARDOWN_SIGNAL_BOUND_S = 1.0
@@ -48,6 +51,17 @@ def _child_run(conn, mode: str = "once") -> None:
core.logger.addHandler(_Capture(level=logging.WARNING))
+ class _Blocking(logging.Handler):
+ """A stderr pipe nobody drains: the forced-exit lines never return."""
+
+ def emit(self, record):
+ message = record.getMessage()
+ if "second late signal" in message or "still running" in message:
+ time.sleep(3600)
+
+ if blocked_log:
+ core.logger.addHandler(_Blocking(level=logging.WARNING))
+
class _FakeEngineCore:
def __init__(self, *args, **kwargs):
import queue
@@ -184,6 +198,27 @@ def test_second_late_signal_ends_a_hung_teardown():
assert "teardown-done" not in events, events
+def test_second_late_signal_is_not_held_by_a_blocked_log():
+ # An earlier second-signal path logged BEFORE restoring the default
+ # disposition and re-delivering; a blocked handler / full pipe held it.
+ alive, exitcode, elapsed, events = _run_hanging_teardown(
+ "twice-hang-blocked-log", 15
+ )
+ assert "second-signal" in events, events
+ assert not alive, f"a blocked log held the second-signal exit ({elapsed:.1f}s)"
+ assert exitcode == -signal.SIGTERM, exitcode
+
+
+def test_teardown_watchdog_is_not_held_by_a_blocked_log():
+ # An earlier watchdog logged and flushed before os._exit(1).
+ alive, exitcode, elapsed, events = _run_hanging_teardown(
+ "once-hang-blocked-log", 15
+ )
+ assert "teardown-start" in events, events
+ assert not alive, f"a blocked log held the watchdog exit ({elapsed:.1f}s)"
+ assert exitcode == 1, exitcode
+
+
def test_teardown_watchdog_bounds_a_single_deferred_signal():
# One deferred SIGTERM, then teardown hangs: the watchdog ends the process
# (exit code 1) once _TEARDOWN_SIGNAL_BOUND_S (1 s here) has passed.
diff --git a/tests/v1/executor/test_executor.py b/tests/v1/executor/test_executor.py
index a21a8da..8e5d9fe 100644
--- a/tests/v1/executor/test_executor.py
+++ b/tests/v1/executor/test_executor.py
@@ -82,6 +82,8 @@ def test_multiproc_executor_worker_termination_timeout(
monkeypatch.setenv("VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS", str(timeout))
clock = _FakeClock()
monkeypatch.setattr(multiproc_executor_module.time, "time", clock.time)
+ # The ladder times its waits with time.monotonic().
+ monkeypatch.setattr(multiproc_executor_module.time, "monotonic", clock.time)
monkeypatch.setattr(multiproc_executor_module.time, "sleep", clock.sleep)
executor = MultiprocExecutor.__new__(MultiprocExecutor)
proc = _FakeProcess(clock, exits_at=exits_at)
diff --git a/tests/v1/executor/test_multiproc_failed_worker_ladder.py b/tests/v1/executor/test_multiproc_failed_worker_ladder.py
index 9c59113..c67d593 100644
--- a/tests/v1/executor/test_multiproc_failed_worker_ladder.py
+++ b/tests/v1/executor/test_multiproc_failed_worker_ladder.py
@@ -133,6 +133,35 @@ def test_failure_shutdown_lets_a_draining_survivor_finish():
assert _DRAINING_S - 0.3 <= elapsed < 3.0, elapsed
+def test_failure_ladder_ignores_a_wall_clock_step(monkeypatch):
+ """An earlier ladder timed its graces with time.time().
+
+ A wall-clock step forward (NTP, maintainer) mid-ladder made every wait
+ expire at once, so a survivor still inside its FINAL DRAIN was SIGTERMed.
+ """
+ import vllm.v1.executor.multiproc_executor as executor_module
+
+ real_time = time.time
+ calls = {"n": 0}
+
+ def stepping_time():
+ # Steps forward an hour between any two reads.
+ calls["n"] += 1
+ return real_time() + 3600.0 * calls["n"]
+
+ monkeypatch.setattr(
+ executor_module,
+ "time",
+ SimpleNamespace(time=stepping_time, monotonic=time.monotonic, sleep=time.sleep),
+ )
+ elapsed, codes = _shutdown([(_DRAINING, 1), (_PARKED, 0)], failed=True)
+ assert codes[0] == 0, (
+ f"a wall-clock step cut the draining survivor (exit {codes[0]})"
+ )
+ assert codes[1] is not None
+ assert _DRAINING_S - 0.3 <= elapsed < 3.0, elapsed
+
+
def test_failure_shutdown_ends_a_survivor_stuck_in_its_shutdown():
# Started its shutdown (state 1), then blocked on the dead rank: ignores
# SIGTERM like a rank inside a device synchronize, never exits by itself.
@@ -195,18 +224,21 @@ def test_worker_main_publishes_shutdown_started_then_done(monkeypatch):
)
monkeypatch.setattr(executor_module.signal, "signal", lambda *a, **k: None)
ready_reader, ready_writer = multiprocessing.Pipe(duplex=False)
- executor_module.WorkerProc.worker_main(
- vllm_config=SimpleNamespace(
- parallel_config=SimpleNamespace(
- assigned_physical_gpu_ids=None, numa_bind=False
- )
- ),
- ready_pipe=ready_writer,
- death_pipe=None,
- shutdown_state=state,
- inherited_fds=[],
- expected_parent_pid=os.getpid(),
- )
+ # An unexpected busy-loop failure ends in exit code 1 after the teardown.
+ with pytest.raises(SystemExit) as exited:
+ executor_module.WorkerProc.worker_main(
+ vllm_config=SimpleNamespace(
+ parallel_config=SimpleNamespace(
+ assigned_physical_gpu_ids=None, numa_bind=False
+ )
+ ),
+ ready_pipe=ready_writer,
+ death_pipe=None,
+ shutdown_state=state,
+ inherited_fds=[],
+ expected_parent_pid=os.getpid(),
+ )
+ assert exited.value.code == 1
ready_reader.close()
assert seen == [("busy", 0), ("drain", 1)], seen
assert state.value == 2
diff --git a/tests/v1/executor/test_multiproc_fatal_any_rank.py b/tests/v1/executor/test_multiproc_fatal_any_rank.py
new file mode 100644
index 0000000..bf1a0ff
--- /dev/null
+++ b/tests/v1/executor/test_multiproc_fatal_any_rank.py
@@ -0,0 +1,343 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""A fail-closed fault on ANY rank reaches the executor before the process is gone.
+
+Rank-1 fault test (earlier build, TP2xPP2): the PLE
+test fault cut=exc_in_release fired on PP0_TP1 (rank 1). ple_fail_closed wrote
+its line and traceback and os._exit(1)ed. The engine then sat in
+mq.dequeue for 300 s ("No available shared memory broadcast block found in 60
+seconds" x4) and was killed by the harness; "Worker proc ... died
+unexpectedly" never appeared. The rank showed Z in ps but was never reaped.
+
+The executor's monitor (start_worker_monitor, stock upstream code) waits on
+the workers' process sentinels only. A sentinel closes when the kernel has
+torn down the WHOLE thread group -- for a worker holding a CUDA context that
+is seconds after os._exit (F1/F2/F3/F5 on rank 0 in the same series: 8.0 s
+between the fatal line and the monitor's log line), and when a thread of the
+dying rank waits in-kernel on a live peer it does not close while the peer
+lives. The fault test's rank 1 became reapable only after rank 0's own 300 s progress
+deadline killed rank 0 (rank 3's gloo recv from rank 1 broke at +2.0 s,
+two seconds after rank 0's fatal at +0.0 s). The same leader-Z /
+thread-in-exit state was sampled on the reference host for the PLE offload child
+(test_multiproc_ple_exit_without_reap.py).
+
+The fix: a per-worker fault pipe. ple_fail_closed writes one byte on it
+right before os._exit; the monitor waits on it next to the sentinels and
+starts the failure ladder at once, which ends the peers -- and that is what
+frees the stuck teardown.
+
+The stuck-teardown state is modelled here without a GPU: the worker's leader
+thread leaves alone (raw SYS_exit) while another thread keeps the thread
+group alive, so its sentinel stays open and it is not reapable -- exactly
+what the executor saw in the rank-1 fault test.
+
+Failing controls on the earlier build: test_fatal_on_any_rank_wakes_the_
+monitor_before_its_teardown (the monitor stays blocked) and
+test_worker_main_registers_the_fault_pipe (nothing registers it).
+"""
+
+import ctypes
+import multiprocessing
+import os
+import platform
+import signal
+import sys
+import threading
+import time
+from types import SimpleNamespace
+
+import pytest
+
+pytestmark = [
+ pytest.mark.skip_global_cleanup,
+ pytest.mark.skipif(
+ sys.platform != "linux" or platform.machine() not in ("x86_64", "aarch64"),
+ reason="needs Linux process sentinels and a raw SYS_exit number",
+ ),
+]
+
+_SYS_EXIT = {"x86_64": 60, "aarch64": 93}.get(platform.machine(), -1)
+_LINGER_S = 30.0
+_MONITOR_WAIT_S = 5.0
+
+
+def _leader_exit(code: int) -> None:
+ """Only the calling thread leaves; the thread group (and its fds) stay."""
+ ctypes.CDLL(None).syscall(_SYS_EXIT, code)
+
+
+def _worker(mode: str, fault_pipe, linger_s: float) -> None:
+ from vllm.v1.ple_offload import fatal
+
+ # Absent on the earlier build: that is the failing control, not an error.
+ register = getattr(fatal, "set_fatal_notify_fd", None)
+ if register is not None and fault_pipe is not None:
+ register(fault_pipe.fileno())
+ if mode == "idle":
+ time.sleep(600)
+ # Non-daemon: keeps the thread group alive after the leader leaves.
+ threading.Thread(target=time.sleep, args=(linger_s,)).start()
+ if mode == "fatal-stuck":
+ # ple_fail_closed ends with os._exit(1); here that exit is the
+ # leader-only one, so the process stays unreapable like the fault test's rank 1.
+ os._exit = _leader_exit # type: ignore[assignment]
+ fatal.ple_fail_closed(
+ where="release_outputs",
+ rank=1,
+ layer="language_model.model.layers.1.ple.ple_embedding",
+ sequence=52,
+ error=RuntimeError("PLE test fault: exc_in_release"),
+ )
+ if mode == "orderly-close":
+ fault_pipe.close()
+ time.sleep(linger_s)
+ os._exit(1 if mode == "crash" else 0)
+
+
+class _Executor:
+ """The surface start_worker_monitor's closure uses."""
+
+ def __init__(self, workers):
+ self.workers = workers
+ self.is_failed = False
+ self.shutting_down = False
+ self.shutdown_calls = 0
+ self.callback_calls = 0
+ self.failure_callback = self._callback
+
+ def _callback(self):
+ self.callback_calls += 1
+
+ def shutdown(self):
+ self.shutdown_calls += 1
+
+
+def _spawn(mode: str, rank: int, linger_s: float = _LINGER_S):
+ ctx = multiprocessing.get_context("spawn")
+ fault_reader, fault_writer = ctx.Pipe(duplex=False)
+ proc = ctx.Process(
+ target=_worker,
+ args=(mode, fault_writer, linger_s),
+ name=f"VllmWorker-{rank}",
+ daemon=True,
+ )
+ proc.start()
+ fault_writer.close()
+ return SimpleNamespace(
+ proc=proc,
+ rank=rank,
+ fault_reader=fault_reader,
+ death_writer=None,
+ worker_response_mq=None,
+ shutdown_state=None,
+ )
+
+
+def _start_monitor(executor) -> threading.Thread:
+ from vllm.v1.executor.multiproc_executor import MultiprocExecutor
+
+ thread = threading.Thread(
+ target=MultiprocExecutor.start_worker_monitor,
+ args=(executor,),
+ kwargs={"inline": True},
+ daemon=True,
+ name="MultiprocWorkerMonitor-test",
+ )
+ thread.start()
+ return thread
+
+
+@pytest.fixture
+def workers():
+ spawned = []
+ yield spawned
+ for w in spawned:
+ # SIGKILL ends the lingering thread too; then the group is reapable.
+ if w.proc.is_alive():
+ os.kill(w.proc.pid, signal.SIGKILL)
+ w.proc.join(10)
+ w.fault_reader.close()
+
+
+def test_fatal_on_any_rank_wakes_the_monitor_before_its_teardown(workers):
+ """Rank-1 fault: rank 1 fails closed, stays unreapable; the monitor must not wait."""
+ workers.append(_spawn("idle", 0))
+ workers.append(_spawn("fatal-stuck", 1))
+ executor = _Executor(workers)
+ thread = _start_monitor(executor)
+ thread.join(_MONITOR_WAIT_S)
+ assert not thread.is_alive(), (
+ f"monitor still blocked {_MONITOR_WAIT_S} s after a fail-closed fault on "
+ "rank 1 whose process teardown has not finished (leader gone, thread "
+ "group alive, sentinel open): this is the rank-1 fault hang"
+ )
+ # It returned while the rank was still not reapable -- the point.
+ assert workers[1].proc.is_alive()
+ assert executor.is_failed
+ assert executor.shutdown_calls == 1
+ assert executor.callback_calls == 1
+ assert executor.failure_callback is None
+
+
+def test_plain_death_is_still_detected_by_the_sentinel(workers):
+ workers.append(_spawn("idle", 0))
+ workers.append(_spawn("crash", 1, linger_s=0.0))
+ executor = _Executor(workers)
+ thread = _start_monitor(executor)
+ thread.join(_MONITOR_WAIT_S)
+ assert not thread.is_alive()
+ assert executor.is_failed
+ assert executor.shutdown_calls == 1
+ assert workers[1].proc.exitcode == 1
+
+
+def test_orderly_close_of_the_fault_pipe_is_not_a_fault(workers):
+ """EOF on the fault pipe (worker_main's finally) must not start the ladder."""
+ orderly_s = 1.5
+ workers.append(_spawn("orderly-close", 0, linger_s=orderly_s))
+ workers.append(_spawn("idle", 1))
+ executor = _Executor(workers)
+ thread = _start_monitor(executor)
+ thread.join(orderly_s / 2)
+ assert thread.is_alive(), "monitor treated the closed fault pipe as a fault"
+ assert executor.shutdown_calls == 0
+ thread.join(_MONITOR_WAIT_S)
+ assert not thread.is_alive(), "sentinel death not seen after the fault-pipe EOF"
+ assert executor.is_failed
+ assert workers[0].proc.exitcode == 0
+
+
+def test_worker_main_registers_the_fault_pipe(monkeypatch):
+ """worker_main hands the fault pipe to the fatal module, and unregisters it."""
+ import vllm.v1.executor.multiproc_executor as executor_module
+ from vllm.v1.ple_offload import fatal
+
+ seen = {}
+
+ class _Inner:
+ def shutdown(self):
+ pass
+
+ def fake_init(self, *args, **kwargs):
+ self.rank = 1
+ self.worker = _Inner()
+ self.rpc_broadcast_mq = None
+ self.worker_response_mq = SimpleNamespace(
+ export_handle=lambda: None,
+ wait_until_ready=lambda: None,
+ shutdown=lambda: None,
+ )
+ self.peer_response_handles = []
+
+ def busy_loop(self):
+ seen["registered_fd"] = getattr(fatal, "_FATAL_NOTIFY_FD", None)
+ raise ValueError("boom")
+
+ monkeypatch.setattr(executor_module.WorkerProc, "__init__", fake_init)
+ monkeypatch.setattr(executor_module.WorkerProc, "worker_busy_loop", busy_loop)
+ monkeypatch.setattr(executor_module, "destroy_model_parallel", lambda: None)
+ monkeypatch.setattr(
+ executor_module, "destroy_distributed_environment", lambda: None
+ )
+ monkeypatch.setattr(executor_module, "maybe_init_worker_tracer", lambda **k: None)
+ monkeypatch.setattr(executor_module, "set_worker_net_device", lambda *a, **k: None)
+ monkeypatch.setattr(
+ executor_module, "_install_worker_parent_death_signal", lambda pid: None
+ )
+ monkeypatch.setattr(executor_module.signal, "signal", lambda *a, **k: None)
+ ready_reader, ready_writer = multiprocessing.Pipe(duplex=False)
+ fault_reader, fault_writer = multiprocessing.Pipe(duplex=False)
+ fault_fd = fault_writer.fileno()
+ # An unexpected busy-loop failure ends in exit code 1 after the teardown
+ # (any-rank fix).
+ with pytest.raises(SystemExit) as exited:
+ executor_module.WorkerProc.worker_main(
+ vllm_config=SimpleNamespace(
+ parallel_config=SimpleNamespace(
+ assigned_physical_gpu_ids=None, numa_bind=False
+ )
+ ),
+ ready_pipe=ready_writer,
+ death_pipe=None,
+ fault_pipe=fault_writer,
+ shutdown_state=None,
+ inherited_fds=[],
+ expected_parent_pid=os.getpid(),
+ )
+ assert exited.value.code == 1
+ ready_reader.close()
+ assert seen.get("registered_fd") == fault_fd, seen
+ assert getattr(fatal, "_FATAL_NOTIFY_FD", "unset") is None
+ # Closed on the way out: EOF, which the monitor does not count as a fault.
+ assert fault_reader.poll(1.0), "worker_main left its fault pipe open"
+ with pytest.raises(EOFError):
+ fault_reader.recv_bytes()
+ fault_reader.close()
+
+
+def test_fail_closed_during_teardown_still_sends_the_fault_byte(monkeypatch):
+ """The fault fd stays registered through worker teardown.
+
+ worker_main's finally unregistered and closed the fault pipe BEFORE
+ worker.shutdown(); a PLE fail-closed inside that teardown (the drain waits
+ on the PLE transport) could not send its byte and the hang window reopened.
+ """
+ import vllm.v1.executor.multiproc_executor as executor_module
+ from vllm.v1.ple_offload import fatal
+
+ class _Inner:
+ def shutdown(self):
+ # ple_fail_closed's notify, from inside the teardown.
+ fatal.notify_parent_fatal()
+
+ def fake_init(self, *args, **kwargs):
+ self.rank = 1
+ self.worker = _Inner()
+ self.rpc_broadcast_mq = None
+ self.worker_response_mq = SimpleNamespace(
+ export_handle=lambda: None,
+ wait_until_ready=lambda: None,
+ shutdown=lambda: None,
+ )
+ self.peer_response_handles = []
+
+ def busy_loop(self):
+ raise ValueError("boom")
+
+ monkeypatch.setattr(executor_module.WorkerProc, "__init__", fake_init)
+ monkeypatch.setattr(executor_module.WorkerProc, "worker_busy_loop", busy_loop)
+ monkeypatch.setattr(executor_module, "destroy_model_parallel", lambda: None)
+ monkeypatch.setattr(
+ executor_module, "destroy_distributed_environment", lambda: None
+ )
+ monkeypatch.setattr(executor_module, "maybe_init_worker_tracer", lambda **k: None)
+ monkeypatch.setattr(executor_module, "set_worker_net_device", lambda *a, **k: None)
+ monkeypatch.setattr(
+ executor_module, "_install_worker_parent_death_signal", lambda pid: None
+ )
+ monkeypatch.setattr(executor_module.signal, "signal", lambda *a, **k: None)
+ ready_reader, ready_writer = multiprocessing.Pipe(duplex=False)
+ fault_reader, fault_writer = multiprocessing.Pipe(duplex=False)
+ with pytest.raises(SystemExit):
+ executor_module.WorkerProc.worker_main(
+ vllm_config=SimpleNamespace(
+ parallel_config=SimpleNamespace(
+ assigned_physical_gpu_ids=None, numa_bind=False
+ )
+ ),
+ ready_pipe=ready_writer,
+ death_pipe=None,
+ fault_pipe=fault_writer,
+ shutdown_state=None,
+ inherited_fds=[],
+ expected_parent_pid=os.getpid(),
+ )
+ ready_reader.close()
+ assert fault_reader.poll(1.0), "no byte and no EOF on the fault pipe"
+ data = os.read(fault_reader.fileno(), 1)
+ fault_reader.close()
+ assert data == b"F", (
+ f"a fail-closed inside worker teardown sent {data!r}: the fault fd was "
+ "already unregistered/closed"
+ )
+ assert getattr(fatal, "_FATAL_NOTIFY_FD", "unset") is None
diff --git a/tests/v1/executor/test_multiproc_monitor_any_rank.py b/tests/v1/executor/test_multiproc_monitor_any_rank.py
new file mode 100644
index 0000000..d03366a
--- /dev/null
+++ b/tests/v1/executor/test_multiproc_monitor_any_rank.py
@@ -0,0 +1,226 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""The worker monitor must see ANY rank's exit, even with an fd inheritor.
+
+The rank-1 fault test (an earlier build): the PLE fault killed PP0_TP1 (rank 1) in
+release_outputs. The worker sat as a zombie, GPU 1 emptied, but the executor
+never logged "died unexpectedly" and EngineCore hung in the RPC broadcast
+("No available shared memory broadcast block"). The monitor waited on
+proc.sentinel, a pipe whose write end the worker holds -- and the worker's
+torch_shm_manager child (started by the PLE host-buffer registration under the
+"file_system" sharing strategy) inherited that write end and outlived it,
+kept alive by rank 0's PLE child mapping rank 1's buffers. Rank-0 faults only
+looked detected because rank 0's death took the PLE child, and so the
+managers, down with it.
+
+The inheritor here is a forked grandchild that sleeps; it holds every fd the
+worker had, the sentinel's write end included.
+"""
+
+import contextlib
+import inspect
+import multiprocessing
+import os
+import signal
+import sys
+import threading
+import time
+from types import SimpleNamespace
+
+import pytest
+
+pytestmark = [
+ pytest.mark.skip_global_cleanup,
+ pytest.mark.skipif(sys.platform != "linux", reason="fork + pidfd"),
+]
+
+# A healthy worker detects a death within this bound on the earlier build's rank-0 path
+# (sentinel fires at exit); a pidfd fires at the exit itself.
+_DETECT_BOUND_S = 5.0
+_INHERITOR_LIFETIME_S = 60.0
+
+
+def _healthy_worker():
+ time.sleep(600)
+
+
+def _dying_worker(inheritor_pid, spawn_inheritor):
+ if spawn_inheritor:
+ pid = os.fork()
+ if pid == 0:
+ # Stand-in for torch_shm_manager: holds the inherited fds.
+ time.sleep(_INHERITOR_LIFETIME_S)
+ os._exit(0)
+ inheritor_pid.value = pid
+ time.sleep(0.5)
+ # Stand-in for ple_fail_closed: no cleanup, straight out.
+ os._exit(1)
+
+
+def _executor(procs):
+ from vllm.v1.executor.multiproc_executor import MultiprocExecutor
+
+ executor = object.__new__(MultiprocExecutor)
+ executor.workers = [
+ SimpleNamespace(
+ proc=p,
+ rank=rank,
+ death_writer=None,
+ worker_response_mq=None,
+ shutdown_state=None,
+ )
+ for rank, p in enumerate(procs)
+ ]
+ executor.is_failed = False
+ executor.rpc_broadcast_mq = None
+ executor.response_mqs = []
+ failed = threading.Event()
+ executor.failure_callback = failed.set
+ return executor, failed
+
+
+@pytest.mark.parametrize("spawn_inheritor", [False, True])
+@pytest.mark.parametrize("dying_rank", [0, 1])
+def test_worker_exit_detected_on_any_rank(dying_rank, spawn_inheritor):
+ ctx = multiprocessing.get_context("fork")
+ inheritor_pid = ctx.Value("i", 0)
+ procs = []
+ for rank in range(2):
+ if rank == dying_rank:
+ proc = ctx.Process(
+ target=_dying_worker,
+ args=(inheritor_pid, spawn_inheritor),
+ name=f"VllmWorker-{rank}",
+ daemon=True,
+ )
+ else:
+ proc = ctx.Process(
+ target=_healthy_worker, name=f"VllmWorker-{rank}", daemon=True
+ )
+ proc.start()
+ procs.append(proc)
+
+ executor, failed = _executor(procs)
+ try:
+ start = time.monotonic()
+ executor.start_worker_monitor()
+ detected = failed.wait(_DETECT_BOUND_S + 5.0)
+ elapsed = time.monotonic() - start
+ assert detected, (
+ f"rank {dying_rank} exited (inheritor={spawn_inheritor}) but the "
+ f"monitor never reported it within {_DETECT_BOUND_S + 5.0:.0f} s; "
+ f"dying proc alive per waitpid: {procs[dying_rank].is_alive()}"
+ )
+ assert elapsed <= _DETECT_BOUND_S, elapsed
+ assert executor.is_failed
+ assert procs[dying_rank].exitcode == 1
+ # The failure ladder ended the healthy survivor too.
+ assert not any(p.is_alive() for p in procs)
+ finally:
+ for p in procs:
+ if p.is_alive():
+ p.kill()
+ p.join(timeout=5)
+ if inheritor_pid.value:
+ with contextlib.suppress(ProcessLookupError):
+ os.kill(inheritor_pid.value, signal.SIGKILL)
+
+
+def test_exit_waitables_close_opened_pidfds_when_a_later_proc_fails(monkeypatch):
+ """_worker_exit_waitables is exception-safe."""
+ import vllm.v1.executor.multiproc_executor as executor_module
+
+ opened: list[int] = []
+ real_open = os.pidfd_open
+
+ def recording_open(pid):
+ fd = real_open(pid)
+ opened.append(fd)
+ return fd
+
+ class _Broken:
+ pid = None # no pidfd: falls back to the sentinel, which raises
+
+ @property
+ def sentinel(self):
+ raise ValueError("process not started")
+
+ class _Self:
+ pid = os.getpid()
+ sentinel = -1
+
+ monkeypatch.setattr(executor_module.os, "pidfd_open", recording_open)
+ with pytest.raises(ValueError):
+ executor_module._worker_exit_waitables([_Self(), _Self(), _Broken()])
+ assert len(opened) == 2, opened
+ leaked = []
+ for fd in opened:
+ try:
+ os.fstat(fd)
+ leaked.append(fd)
+ except OSError:
+ pass
+ for fd in leaked:
+ os.close(fd)
+ assert not leaked, f"pidfds left open after the failure: {leaked}"
+
+
+def test_exit_waitables_close_the_pidfd_of_the_failing_iteration(monkeypatch):
+ """A failure between opening a pidfd and recording it.
+
+ An exception is injected (via a trace function) on the line that records
+ the (waitable, proc, pidfd) tuple, after the pidfd for that proc was
+ opened; that pidfd must be closed too, not only the earlier ones.
+ """
+ import vllm.v1.executor.multiproc_executor as executor_module
+
+ opened: list[int] = []
+ real_open = os.pidfd_open
+
+ def recording_open(pid):
+ fd = real_open(pid)
+ opened.append(fd)
+ return fd
+
+ class _Self:
+ pid = os.getpid()
+ sentinel = -1
+
+ monkeypatch.setattr(executor_module.os, "pidfd_open", recording_open)
+ code = executor_module._worker_exit_waitables.__code__
+ append_lines = {
+ code.co_firstlineno + i
+ for i, line in enumerate(
+ inspect.getsource(executor_module._worker_exit_waitables).splitlines()
+ )
+ if "waitables.append(" in line
+ }
+ assert len(append_lines) == 1, append_lines
+
+ class _Injected(Exception):
+ pass
+
+ def tracer(frame, event, arg):
+ if frame.f_code is not code:
+ return None
+
+ def local(frame, event, arg):
+ if event == "line" and frame.f_lineno in append_lines and opened:
+ raise _Injected
+ return local
+
+ return local
+
+ sys.settrace(tracer)
+ try:
+ with pytest.raises(_Injected):
+ executor_module._worker_exit_waitables([_Self()])
+ finally:
+ sys.settrace(None)
+ assert len(opened) == 1, opened
+ try:
+ os.fstat(opened[0])
+ except OSError:
+ return
+ os.close(opened[0])
+ pytest.fail("the pidfd opened in the failing iteration was left open")
diff --git a/tests/v1/executor/test_multiproc_monitor_combined.py b/tests/v1/executor/test_multiproc_monitor_combined.py
new file mode 100644
index 0000000..d06af4e
--- /dev/null
+++ b/tests/v1/executor/test_multiproc_monitor_combined.py
@@ -0,0 +1,147 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""The worker monitor's two wake sources together: fault byte + process exit.
+
+The rank-1 fault hang had two root causes. First fix: the sentinel's
+write end was inherited by the worker's torch_shm_manager child, so the exit
+never showed on it (fix: wait on a pidfd). Second fix: the dying
+rank's kernel teardown waits on a live peer, so no exit-based signal fires
+(fix: the fail-closed path writes one byte on a fault pipe before os._exit).
+The merged monitor must take whichever arrives first and must not count a
+fault pipe's EOF as a fault. Each case below fails on one of the two parents.
+"""
+
+import contextlib
+import multiprocessing
+import os
+import signal
+import sys
+import threading
+import time
+from types import SimpleNamespace
+
+import pytest
+
+pytestmark = [
+ pytest.mark.skip_global_cleanup,
+ pytest.mark.skipif(sys.platform != "linux", reason="fork + pidfd"),
+]
+
+_BOUND_S = 2.0
+
+
+def _healthy(fault_writer):
+ time.sleep(600)
+
+
+def _fault_byte_then_stuck(fault_writer):
+ # ple_fail_closed's notify, then a teardown that does not finish (the
+ # process stays alive, so neither its pidfd nor its sentinel fires).
+ os.write(fault_writer.fileno(), b"F")
+ time.sleep(600)
+
+
+def _silent_death_with_inheritor(fault_writer, inheritor_pid):
+ # A non-fail-closed death (no byte) whose fd-holding grandchild keeps the
+ # sentinel AND the fault pipe's write end open (no EOF either).
+ pid = os.fork()
+ if pid == 0:
+ time.sleep(60)
+ os._exit(0)
+ inheritor_pid.value = pid
+ time.sleep(0.3)
+ os._exit(1)
+
+
+def _orderly_eof_then_exit(fault_writer):
+ # worker_main's finally closes the fault pipe (EOF, no byte) and the
+ # process exits: a death, not a reported fault.
+ fault_writer.close()
+ time.sleep(0.3)
+ os._exit(0)
+
+
+def _run(dying_target, extra_args=()):
+ import vllm.v1.executor.multiproc_executor as executor_module
+ from vllm.v1.executor.multiproc_executor import MultiprocExecutor
+
+ ctx = multiprocessing.get_context("fork")
+ procs, handles = [], []
+ for rank in range(2):
+ fault_reader, fault_writer = ctx.Pipe(duplex=False)
+ target = dying_target if rank == 1 else _healthy
+ args = (fault_writer, *extra_args) if rank == 1 else (fault_writer,)
+ proc = ctx.Process(
+ target=target, args=args, name=f"VllmWorker-{rank}", daemon=True
+ )
+ proc.start()
+ fault_writer.close()
+ procs.append(proc)
+ handles.append(
+ SimpleNamespace(
+ proc=proc,
+ rank=rank,
+ death_writer=None,
+ worker_response_mq=None,
+ shutdown_state=None,
+ fault_reader=fault_reader,
+ )
+ )
+ executor = object.__new__(MultiprocExecutor)
+ executor.workers = handles
+ executor.is_failed = False
+ executor.rpc_broadcast_mq = None
+ executor.response_mqs = []
+ failed = threading.Event()
+ executor.failure_callback = failed.set
+
+ messages: list[str] = []
+ real_error = executor_module.logger.error
+
+ def capture(msg, *args, **kwargs):
+ messages.append(msg % args if args else msg)
+ return real_error(msg, *args, **kwargs)
+
+ executor_module.logger.error = capture
+ try:
+ start = time.monotonic()
+ executor.start_worker_monitor()
+ detected = failed.wait(_BOUND_S + 8.0)
+ elapsed = time.monotonic() - start
+ finally:
+ executor_module.logger.error = real_error
+ for p in procs:
+ if p.is_alive():
+ p.kill()
+ p.join(5)
+ return detected, elapsed, messages
+
+
+def test_fault_byte_wins_while_the_process_is_still_alive():
+ detected, elapsed, messages = _run(_fault_byte_then_stuck)
+ assert detected, "fault byte written, process alive: monitor never woke"
+ assert elapsed <= _BOUND_S, elapsed
+ assert any("VllmWorker-1 reported a fatal fault" in m for m in messages), messages
+
+
+def test_silent_death_with_an_fd_inheritor_is_seen_by_its_exit():
+ inheritor_pid = multiprocessing.get_context("fork").Value("i", 0)
+ try:
+ detected, elapsed, messages = _run(
+ _silent_death_with_inheritor, (inheritor_pid,)
+ )
+ finally:
+ if inheritor_pid.value:
+ with contextlib.suppress(ProcessLookupError):
+ os.kill(inheritor_pid.value, signal.SIGKILL)
+ assert detected, "worker exited (no fault byte, inherited fds): not seen"
+ assert elapsed <= _BOUND_S + 1.0, elapsed
+ assert any("VllmWorker-1 died unexpectedly" in m for m in messages), messages
+
+
+def test_fault_pipe_eof_is_not_a_fault():
+ detected, elapsed, messages = _run(_orderly_eof_then_exit)
+ assert detected, "worker exited after closing its fault pipe: not seen"
+ assert elapsed <= _BOUND_S + 1.0, elapsed
+ assert any("VllmWorker-1 died unexpectedly" in m for m in messages), messages
+ assert not any("reported a fatal fault" in m for m in messages), messages
diff --git a/tests/v1/executor/test_multiproc_ready_and_inherit.py b/tests/v1/executor/test_multiproc_ready_and_inherit.py
new file mode 100644
index 0000000..2ef20cd
--- /dev/null
+++ b/tests/v1/executor/test_multiproc_ready_and_inherit.py
@@ -0,0 +1,355 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Pre-READY worker death, and the inherited-pipe leak behind the rank-1 fault hang.
+
+The rank-1 fault test: a PLE-owning worker's torch_shm_manager child (fork and
+program load, from the PLE host-buffer registration) inherited the worker's
+end of the sentinel pipe and outlived it, so the executor never saw rank 1
+die. The same inheritance covers the READY pipe: a worker dying between the
+registration and READY left WorkerProc.wait_for_ready waiting for an EOF that
+never came.
+"""
+
+import contextlib
+import multiprocessing
+import multiprocessing.connection
+import os
+import signal
+import subprocess
+import sys
+import threading
+import time
+
+import pytest
+
+pytestmark = [
+ pytest.mark.skip_global_cleanup,
+ pytest.mark.skipif(sys.platform != "linux", reason="fork + pidfd"),
+]
+
+_BOUND_S = 5.0
+
+
+def _die_before_ready(inheritor_pid):
+ # Stand-in for torch_shm_manager: a forked child holding every fd,
+ # the READY pipe's write end included.
+ pid = os.fork()
+ if pid == 0:
+ time.sleep(60)
+ os._exit(0)
+ inheritor_pid.value = pid
+ time.sleep(0.3)
+ os._exit(1) # died during init, READY never sent
+
+
+def test_wait_for_ready_sees_a_worker_die_before_ready():
+ from vllm.v1.executor.multiproc_executor import (
+ UnreadyWorkerProcHandle,
+ WorkerProc,
+ )
+
+ ctx = multiprocessing.get_context("fork")
+ inheritor_pid = ctx.Value("i", 0)
+ ready_reader, ready_writer = ctx.Pipe(duplex=False)
+ proc = ctx.Process(
+ target=_die_before_ready, args=(inheritor_pid,), name="VllmWorker-1"
+ )
+ proc.start()
+ ready_writer.close()
+ handle = UnreadyWorkerProcHandle(proc, 1, ready_reader)
+
+ outcome: dict = {}
+
+ def run():
+ try:
+ WorkerProc.wait_for_ready([handle])
+ outcome["result"] = "returned"
+ except BaseException as exc: # noqa: BLE001
+ outcome["result"] = exc
+
+ waiter = threading.Thread(target=run, daemon=True)
+ try:
+ start = time.monotonic()
+ waiter.start()
+ waiter.join(_BOUND_S + 5.0)
+ elapsed = time.monotonic() - start
+ assert not waiter.is_alive(), (
+ "wait_for_ready still waiting for READY from a worker that exited "
+ f"{elapsed:.1f} s ago (an inheritor holds the pipe's write end)"
+ )
+ assert isinstance(outcome.get("result"), Exception), outcome
+ assert "initialization failed" in str(outcome["result"])
+ assert elapsed <= _BOUND_S, elapsed
+ finally:
+ if inheritor_pid.value:
+ with contextlib.suppress(ProcessLookupError):
+ os.kill(inheritor_pid.value, signal.SIGKILL)
+ waiter.join(10)
+ proc.join(5)
+
+
+def _spawned_worker_starts_a_helper(helper_pid):
+ from vllm.v1.executor import multiproc_executor
+
+ # The worker_main step under test; absent on unfixed code.
+ guard = getattr(
+ multiproc_executor, "_make_inherited_pipes_close_on_exec", lambda: None
+ )
+ guard()
+ # Stand-in for torch_shm_manager: a helper program started with no fd
+ # closing, as libshm does. It outlives every bound below (killed in the
+ # test's cleanup), so a fired sentinel cannot be the helper's own exit.
+ helper = subprocess.Popen(["sleep", "3600"], close_fds=False)
+ helper_pid.value = helper.pid
+ os._exit(1)
+
+
+def _proc_state(pid):
+ """/proc/<pid>/stat state letter, or None if the pid is gone."""
+ try:
+ with open(f"/proc/{pid}/stat") as f:
+ stat = f.read()
+ except OSError:
+ return None
+ return stat[stat.rindex(")") + 2 :].split()[0]
+
+
+def _running(pid):
+ state = _proc_state(pid)
+ return state is not None and state not in ("Z", "X")
+
+
+def test_running_check_rejects_a_zombie():
+ """os.kill(pid, 0) cannot tell a zombie from a live process; _running can."""
+ pid = os.fork()
+ if pid == 0:
+ os._exit(0)
+ try:
+ deadline = time.monotonic() + 5
+ while _proc_state(pid) != "Z" and time.monotonic() < deadline:
+ time.sleep(0.01)
+ assert _proc_state(pid) == "Z"
+ os.kill(pid, 0) # succeeds on the zombie: the old check passed here
+ assert not _running(pid)
+ finally:
+ os.waitpid(pid, 0)
+ assert _running(os.getpid())
+
+
+# The worker exits right after publishing the helper's pid; its sentinel must
+# fire within this bound while the helper is provably still alive.
+_SENTINEL_BOUND_S = 2.0
+
+
+def test_started_helper_does_not_inherit_the_sentinel():
+ ctx = multiprocessing.get_context("spawn")
+ helper_pid = ctx.Value("i", 0)
+ proc = ctx.Process(
+ target=_spawned_worker_starts_a_helper, args=(helper_pid,), daemon=True
+ )
+ proc.start()
+ try:
+ # Spawn + import can take a while; the bound starts at the helper's
+ # start (the worker exits right after it).
+ deadline = time.monotonic() + 120
+ while not helper_pid.value and time.monotonic() < deadline:
+ time.sleep(0.05)
+ assert helper_pid.value, "the helper never started"
+ fired = multiprocessing.connection.wait(
+ [proc.sentinel], timeout=_SENTINEL_BOUND_S
+ )
+ # The helper must still be RUNNING (not gone, and not a zombie --
+ # os.kill(pid, 0) succeeds on a zombie too).
+ assert _running(helper_pid.value), (
+ f"helper {helper_pid.value} is not running: "
+ f"state={_proc_state(helper_pid.value)}"
+ )
+ assert fired, (
+ f"the worker exited but its sentinel did not fire within "
+ f"{_SENTINEL_BOUND_S} s while the helper it started is alive: the "
+ "helper inherited the sentinel pipe's write end"
+ )
+ proc.join(5)
+ assert proc.exitcode == 1
+ finally:
+ if proc.is_alive():
+ proc.kill()
+ if helper_pid.value:
+ with contextlib.suppress(ProcessLookupError):
+ os.kill(helper_pid.value, signal.SIGKILL)
+
+
+def test_guard_marks_inherited_pipes_only():
+ from vllm.v1.executor import multiproc_executor
+
+ guard = multiproc_executor._make_inherited_pipes_close_on_exec
+ stdio = {}
+ for fd in (0, 1, 2):
+ with contextlib.suppress(OSError):
+ stdio[fd] = os.get_inheritable(fd)
+ reader, writer = os.pipe()
+ try:
+ os.set_inheritable(reader, True)
+ os.set_inheritable(writer, True)
+ guard()
+ assert not os.get_inheritable(reader)
+ assert not os.get_inheritable(writer)
+ for fd, before in stdio.items():
+ assert os.get_inheritable(fd) == before, fd
+ finally:
+ os.close(reader)
+ os.close(writer)
+
+
+# ---- Pre-READY waiting keeps the sentinel fallback and the fault pipe
+
+_PRE_READY_BOUND_S = 3.0
+
+
+def _exits_with_ready_writer_held_by_a_helper(ready_writer, helper_pid):
+ # A helper program inherits ONLY the READY pipe's write end (fork's
+ # sentinel end is close-on-exec), so the READY pipe never EOFs while the
+ # sentinel does fire at the exit.
+ helper = subprocess.Popen(["sleep", "60"], pass_fds=(ready_writer.fileno(),))
+ helper_pid.value = helper.pid
+ os._exit(1)
+
+
+def _fault_byte_before_ready(fault_writer):
+ # ple_fail_closed's notify during init; the process then stays alive
+ # (its teardown is pinned), so no exit signal fires either.
+ os.write(fault_writer.fileno(), b"F")
+ time.sleep(60)
+
+
+def _wait_for_ready_in_thread(handles):
+ from vllm.v1.executor.multiproc_executor import WorkerProc
+
+ outcome: dict = {}
+
+ def run():
+ try:
+ WorkerProc.wait_for_ready(handles)
+ outcome["result"] = "returned"
+ except BaseException as exc: # noqa: BLE001
+ outcome["result"] = exc
+
+ waiter = threading.Thread(target=run, daemon=True)
+ start = time.monotonic()
+ waiter.start()
+ waiter.join(_PRE_READY_BOUND_S)
+ return waiter, outcome, time.monotonic() - start
+
+
+def test_pre_ready_death_is_seen_by_the_sentinel_when_no_pidfd(monkeypatch):
+ import vllm.v1.executor.multiproc_executor as executor_module
+ from vllm.v1.executor.multiproc_executor import UnreadyWorkerProcHandle
+
+ def no_pidfd(pid):
+ raise OSError(38, "pidfd_open unavailable")
+
+ monkeypatch.setattr(executor_module.os, "pidfd_open", no_pidfd, raising=False)
+ ctx = multiprocessing.get_context("fork")
+ helper_pid = ctx.Value("i", 0)
+ ready_reader, ready_writer = ctx.Pipe(duplex=False)
+ proc = ctx.Process(
+ target=_exits_with_ready_writer_held_by_a_helper,
+ args=(ready_writer, helper_pid),
+ name="VllmWorker-1",
+ )
+ proc.start()
+ ready_writer.close()
+ handle = UnreadyWorkerProcHandle(proc, 1, ready_reader)
+ try:
+ waiter, outcome, elapsed = _wait_for_ready_in_thread([handle])
+ assert not waiter.is_alive(), (
+ f"no pidfd: wait_for_ready dropped the sentinel fallback and is "
+ f"still waiting {elapsed:.1f} s after the worker exited"
+ )
+ assert isinstance(outcome.get("result"), Exception), outcome
+ finally:
+ if helper_pid.value:
+ with contextlib.suppress(ProcessLookupError):
+ os.kill(helper_pid.value, signal.SIGKILL)
+ proc.join(5)
+
+
+def test_pre_ready_fault_byte_fails_initialization():
+ from vllm.v1.executor.multiproc_executor import UnreadyWorkerProcHandle
+
+ ctx = multiprocessing.get_context("fork")
+ ready_reader, ready_writer = ctx.Pipe(duplex=False)
+ fault_reader, fault_writer = ctx.Pipe(duplex=False)
+ proc = ctx.Process(
+ target=_fault_byte_before_ready, args=(fault_writer,), name="VllmWorker-1"
+ )
+ proc.start()
+ handle = UnreadyWorkerProcHandle(proc, 1, ready_reader, None, None, fault_reader)
+ try:
+ waiter, outcome, elapsed = _wait_for_ready_in_thread([handle])
+ assert not waiter.is_alive(), (
+ "a fault byte arrived before READY but wait_for_ready is still "
+ f"waiting after {elapsed:.1f} s"
+ )
+ assert isinstance(outcome.get("result"), Exception), outcome
+ finally:
+ proc.kill()
+ proc.join(5)
+ # unblock a still-waiting pre-fix thread
+ ready_writer.close()
+ fault_writer.close()
+
+
+# ---- A READY worker that faults or dies while a sibling initialises
+
+
+def _ready_then_fault(ready_writer, fault_writer):
+ ready_writer.send({"status": "READY"})
+ time.sleep(0.2)
+ os.write(fault_writer.fileno(), b"F") # fail-closed after READY
+ time.sleep(60) # teardown pinned: no exit signal
+
+
+def _never_ready(ready_writer):
+ time.sleep(60) # still initialising
+
+
+def test_ready_worker_fault_while_sibling_initialises_fails_init(monkeypatch):
+ from vllm.v1.executor.multiproc_executor import (
+ UnreadyWorkerProcHandle,
+ WorkerProc,
+ )
+
+ # READY handling beyond the status check needs real MQ handles; the
+ # response contents are not what this test is about.
+ monkeypatch.setattr(
+ WorkerProc,
+ "wait_for_response_handle_ready",
+ staticmethod(lambda response, handle: handle),
+ )
+ ctx = multiprocessing.get_context("fork")
+ ra, wa = ctx.Pipe(duplex=False)
+ fa, fwa = ctx.Pipe(duplex=False)
+ rb, wb = ctx.Pipe(duplex=False)
+ fb, fwb = ctx.Pipe(duplex=False)
+ pa = ctx.Process(target=_ready_then_fault, args=(wa, fwa), name="VllmWorker-0")
+ pb = ctx.Process(target=_never_ready, args=(wb,), name="VllmWorker-1")
+ pa.start()
+ pb.start()
+ handles = [
+ UnreadyWorkerProcHandle(pa, 0, ra, None, None, fa),
+ UnreadyWorkerProcHandle(pb, 1, rb, None, None, fb),
+ ]
+ try:
+ waiter, outcome, elapsed = _wait_for_ready_in_thread(handles)
+ assert not waiter.is_alive(), (
+ "worker 0 was READY and then reported a fatal fault while worker 1 "
+ f"was still initialising; wait_for_ready still waiting at {elapsed:.1f} s"
+ )
+ assert isinstance(outcome.get("result"), Exception), outcome
+ finally:
+ for p in (pa, pb):
+ p.kill()
+ p.join(5)
+ for w in (wa, fwa, wb, fwb):
+ w.close()
diff --git a/tests/v1/executor/test_multiproc_worker_exit_after_drain.py b/tests/v1/executor/test_multiproc_worker_exit_after_drain.py
index 7694a62..2881193 100644
--- a/tests/v1/executor/test_multiproc_worker_exit_after_drain.py
+++ b/tests/v1/executor/test_multiproc_worker_exit_after_drain.py
@@ -225,13 +225,18 @@ def test_orderly_shutdown_exits_right_after_drain(monkeypatch):
def test_unexpected_busy_loop_failure_keeps_full_teardown(monkeypatch):
- # Not an orderly shutdown (no shutdown request): unchanged behaviour --
- # full WorkerProc.shutdown incl. distributed destroy, then a normal return.
+ # Not an orderly shutdown: full WorkerProc.shutdown incl. distributed
+ # destroy, then exit 1. An earlier build returned normally (process exit
+ # 0) whenever shutdown_requested was not yet set at the classification --
+ # which a death-pipe EOF landing just after the check makes a race.
events, exit_code, _ = _run_worker_main_in_process(
monkeypatch, busy_loop_error=ValueError("boom"), slow_destroy_s=0.0
)
names = [name for name, _ in events]
- assert exit_code is None, names
+ assert exit_code == 1, (
+ f"an unexpected busy-loop failure exited {exit_code!r}, not 1: {names}"
+ )
+ assert "os._exit" not in names, f"took the orderly exit path: {names}"
assert names.count("drain") == 1, names
assert "destroy_model_parallel" in names, names
assert "destroy_distributed_environment" in names, names
@@ -454,3 +459,20 @@ def test_ladder_bounds_a_worker_stuck_in_its_busy_loop(monkeypatch):
assert any("sending SIGKILL" in w for w in warnings), warnings
assert proc.exitcode == -9, proc.exitcode
assert elapsed <= grace_s + 4 + 2, f"ladder took {elapsed:.1f}s"
+
+
+def test_worker_main_runs_the_inherited_pipe_guard(monkeypatch):
+ # Rank-1 fault: worker_main marks its inherited pipes close-on-exec before
+ # anything can start torch_shm_manager (see
+ # test_multiproc_ready_and_inherit.py for the helper itself).
+ import vllm.v1.executor.multiproc_executor as executor_module
+
+ called = []
+ monkeypatch.setattr(
+ executor_module,
+ "_make_inherited_pipes_close_on_exec",
+ lambda: called.append(True),
+ raising=False,
+ )
+ _run_worker_main_in_process(monkeypatch)
+ assert called, "worker_main never ran the inherited-pipe guard"
diff --git a/vllm/v1/engine/core.py b/vllm/v1/engine/core.py
index 72da32e..dec44e1 100644
--- a/vllm/v1/engine/core.py
+++ b/vllm/v1/engine/core.py
@@ -110,6 +110,24 @@ _R = TypeVar("_R") # Return type for collective_rpc
_TEARDOWN_SIGNAL_BOUND_S = 30.0
+# How long the guard's forced-exit paths wait for their diagnostic line. The
+# line is written from a helper thread; a blocked handler, a held logging lock
+# or a full stderr pipe costs at most this, never the exit.
+_TEARDOWN_EXIT_LOG_BOUND_S = 0.5
+
+
+def _log_bounded(emit: Callable[[], None], bound_s: float) -> None:
+ """Run ``emit`` (logging, flushes) on a daemon thread; wait <= bound_s."""
+ try:
+ thread = threading.Thread(
+ target=emit, daemon=True, name="EngineCoreTeardownLog"
+ )
+ thread.start()
+ thread.join(bound_s)
+ except BaseException: # noqa: BLE001 -- the caller's exit must still run
+ pass
+
+
class _TeardownSignalGuard:
"""EngineCore teardown: defer ONE late SIGTERM/SIGINT, bounded.
@@ -148,25 +166,34 @@ class _TeardownSignalGuard:
)
self._watchdog.start()
return
- logger.warning(
- "[shutdown] EngineCore: second late signal (%s) during resource "
- "teardown; not deferring again",
- name,
- )
+ # Default disposition first, so a third signal also ends the process
+ # while the (bounded) diagnostic line is written.
signal.signal(signum, signal.SIG_DFL)
+ _log_bounded(
+ lambda: logger.warning(
+ "[shutdown] EngineCore: second late signal (%s) during "
+ "resource teardown; not deferring again",
+ name,
+ ),
+ _TEARDOWN_EXIT_LOG_BOUND_S,
+ )
os.kill(os.getpid(), signum)
def _expire(self) -> None:
if self._done.wait(self.bound_s):
return
- logger.error(
- "[shutdown] EngineCore: resource teardown still running %.0f s "
- "after a deferred signal; exiting (code 1)",
- self.bound_s,
- )
- for stream in (sys.stdout, sys.stderr):
- with suppress(Exception):
- stream.flush()
+
+ def emit() -> None:
+ logger.error(
+ "[shutdown] EngineCore: resource teardown still running %.0f s "
+ "after a deferred signal; exiting (code 1)",
+ self.bound_s,
+ )
+ for stream in (sys.stdout, sys.stderr):
+ with suppress(Exception):
+ stream.flush()
+
+ _log_bounded(emit, _TEARDOWN_EXIT_LOG_BOUND_S)
os._exit(1)
def disarm(self) -> None:
diff --git a/vllm/v1/executor/multiproc_executor.py b/vllm/v1/executor/multiproc_executor.py
index 0c5af34..d7beb9a 100644
--- a/vllm/v1/executor/multiproc_executor.py
+++ b/vllm/v1/executor/multiproc_executor.py
@@ -5,6 +5,7 @@ import os
import pickle
import queue
import signal
+import stat
import sys
import threading
import time
@@ -71,6 +72,7 @@ from vllm.v1.core.sched.output import GrammarOutput, SchedulerOutput
from vllm.v1.executor.abstract import Executor, FailureCallback
from vllm.v1.executor.vllm_net_devices import set_worker_net_device
from vllm.v1.outputs import AsyncModelRunnerOutput, DraftTokenIds, ModelRunnerOutput
+from vllm.v1.ple_offload.fatal import set_fatal_notify_fd
from vllm.v1.worker.worker_base import WorkerWrapperBase
logger = init_logger(__name__)
@@ -97,13 +99,16 @@ def _install_worker_parent_death_signal(expected_parent_pid: int) -> None:
ctypes.c_ulong,
]
prctl.restype = ctypes.c_int
- if prctl(
- _PR_SET_PDEATHSIG,
- int(_WORKER_PARENT_DEATH_SIGNAL),
- 0,
- 0,
- 0,
- ) != 0:
+ if (
+ prctl(
+ _PR_SET_PDEATHSIG,
+ int(_WORKER_PARENT_DEATH_SIGNAL),
+ 0,
+ 0,
+ 0,
+ )
+ != 0
+ ):
error_number = ctypes.get_errno()
raise OSError(error_number, os.strerror(error_number))
@@ -173,6 +178,86 @@ def _exit_worker_process(exit_code: int) -> None:
os._exit(exit_code)
+def _worker_exit_waitables(
+ procs: Sequence[BaseProcess],
+) -> list[tuple[Any, BaseProcess, int | None]]:
+ """What the worker monitor waits on to learn that a worker process exited.
+
+ A pidfd (Linux >= 5.3) where possible: it becomes readable when the process
+ itself exits. proc.sentinel is only the read end of a pipe whose write end
+ the worker holds, so it reports the exit of the LAST process holding that
+ write end -- and anything the worker fork+execs inherits it. Every PLE-
+ owning worker does: registering its host buffers under the "file_system"
+ sharing strategy starts a torch_shm_manager child, which keeps the write
+ end, and which lives while any client -- rank 0's PLE child, for a TP1
+ worker's buffers -- still maps them. In a rank-1 fault test PP0_TP1 died
+ in release_outputs, sat as a zombie, its sentinel never fired and the
+ engine hung; rank 0's death only looked detected because its PLE child
+ (and so both managers) died with it, ~8 s later.
+
+ Returns (waitable, proc, pidfd-to-close-or-None) per process, falling back
+ to the sentinel where no pidfd can be opened.
+ """
+ pidfd_open = getattr(os, "pidfd_open", None)
+ waitables: list[tuple[Any, BaseProcess, int | None]] = []
+ try:
+ for proc in procs:
+ pidfd: int | None = None
+ pid = getattr(proc, "pid", None)
+ if pidfd_open is not None and pid is not None:
+ try:
+ pidfd = pidfd_open(pid)
+ except OSError:
+ # ESRCH (already reaped) or no pidfd support: the sentinel.
+ pidfd = None
+ try:
+ waitable = pidfd if pidfd is not None else proc.sentinel
+ waitables.append((waitable, proc, pidfd))
+ except BaseException:
+ # Not yet in `waitables`: the outer handler would miss it.
+ if pidfd is not None:
+ os.close(pidfd)
+ raise
+ except BaseException:
+ # Nothing is handed out on failure: close what was opened.
+ for _, _, opened in waitables:
+ if opened is not None:
+ with suppress(OSError):
+ os.close(opened)
+ raise
+ return waitables
+
+
+def _make_inherited_pipes_close_on_exec() -> None:
+ """Keep this worker's inherited pipe fds out of the programs it execs.
+
+ Under the spawn start method multiprocessing hands the worker its end of
+ the sentinel pipe (and the ready / death pipes) as INHERITABLE fds, so any
+ program the worker fork+execs keeps them open: the torch_shm_manager that
+ the PLE host-buffer registration starts did, and outlived the worker, so
+ neither the sentinel nor the ready pipe reported its death (rank-1 fault injection).
+ Nothing this process execs needs them -- multiprocessing children get
+ their fds passed explicitly, subprocess closes them by default -- so mark
+ every inherited pipe above stderr close-on-exec. The executor's pidfd
+ wait is the primary detection; this only stops the leak at its source.
+ """
+ if not sys.platform.startswith("linux"):
+ return
+ try:
+ fds = [int(name) for name in os.listdir("/proc/self/fd")]
+ except (OSError, ValueError):
+ return
+ for fd in fds:
+ if fd <= 2:
+ continue
+ try:
+ if stat.S_ISFIFO(os.fstat(fd).st_mode) and os.get_inheritable(fd):
+ os.set_inheritable(fd, False)
+ except OSError:
+ # The listing's own directory fd, or one closed since.
+ continue
+
+
class FutureWrapper(Future):
def __init__(
self,
@@ -304,6 +389,7 @@ class MultiprocExecutor(Executor):
if inherited_fds is not None:
inherited_fds.append(unready_worker_handle.death_writer.fileno())
inherited_fds.append(unready_worker_handle.ready_pipe.fileno())
+ inherited_fds.append(unready_worker_handle.fault_reader.fileno())
# Workers must be created before wait_for_ready to avoid
# deadlock, since worker.init_device() does a device sync.
@@ -400,22 +486,78 @@ class MultiprocExecutor(Executor):
# Monitors worker process liveness. If any die unexpectedly,
# logs an error, shuts down the executor and invokes the failure
# callback to inform the engine.
+ #
+ # Two wake sources per worker:
+ # - its exit: a pidfd, readable when the process itself has exited
+ # (fallback: proc.sentinel, which a fork+exec'd child holding the
+ # write end -- torch_shm_manager from the PLE registration -- keeps
+ # silent; see _worker_exit_waitables);
+ # - its fault pipe: one byte from the fail-closed path right before
+ # os._exit, so the failure ladder starts -- and ends the peers --
+ # without waiting for the process teardown, which can itself wait
+ # on a live peer. A worker closing its end on the way out (EOF, no
+ # byte) is not a fault; its exit follows.
+ # In a rank-1 fault test a fault on PP0_TP1 was never detected and the
+ # engine hung on the response queue for 300 s.
+ # Opened here, before the thread starts, so a worker that dies early
+ # is still a zombie (pidfds work on zombies) rather than reaped.
+ exit_waitables = _worker_exit_waitables([h.proc for h in workers])
+
def monitor_workers():
- sentinels = [h.proc.sentinel for h in workers]
- died = multiprocessing.connection.wait(sentinels)
+ exit_to_handle = {
+ w: h for (w, _, _), h in zip(exit_waitables, workers)
+ }
+ fault_to_handle = {
+ h.fault_reader: h
+ for h in workers
+ if getattr(h, "fault_reader", None) is not None
+ }
+ waiting: list[Any] = list(exit_to_handle) + list(fault_to_handle)
+ handle = None
+ reported_fatal = False
+ try:
+ while handle is None:
+ ready = multiprocessing.connection.wait(waiting)
+ for obj in ready:
+ if obj in exit_to_handle:
+ handle = exit_to_handle[obj]
+ break
+ try:
+ data = os.read(obj.fileno(), 1)
+ except OSError:
+ data = b""
+ if data:
+ handle = fault_to_handle[obj]
+ reported_fatal = True
+ break
+ waiting.remove(obj)
+ finally:
+ for _, _, pidfd in exit_waitables:
+ if pidfd is not None:
+ with suppress(OSError):
+ os.close(pidfd)
_self = self_ref()
if not _self or getattr(_self, "shutting_down", False):
logger.debug("MultiprocWorkerMonitor: shutdown already initiated")
return
_self.is_failed = True
- proc = next(h.proc for h in workers if h.proc.sentinel == died[0])
- logger.error(
- "Worker proc %s died unexpectedly (exit code: %s), "
- "shutting down executor. executor_shutdown_monotonic=%.9f",
- proc.name,
- proc.exitcode,
- time.monotonic(),
- )
+ proc = handle.proc
+ if reported_fatal:
+ logger.error(
+ "Worker proc %s reported a fatal fault (fail-closed) and is "
+ "exiting; shutting down executor without waiting for its "
+ "process teardown. executor_shutdown_monotonic=%.9f",
+ proc.name,
+ time.monotonic(),
+ )
+ else:
+ logger.error(
+ "Worker proc %s died unexpectedly (exit code: %s), "
+ "shutting down executor. executor_shutdown_monotonic=%.9f",
+ proc.name,
+ proc.exitcode,
+ time.monotonic(),
+ )
_self.shutdown()
callback = _self.failure_callback
if callback is not None:
@@ -423,9 +565,16 @@ class MultiprocExecutor(Executor):
callback()
if not inline:
- Thread(
- target=monitor_workers, daemon=True, name="MultiprocWorkerMonitor"
- ).start()
+ try:
+ Thread(
+ target=monitor_workers, daemon=True, name="MultiprocWorkerMonitor"
+ ).start()
+ except BaseException:
+ for _, _, pidfd in exit_waitables:
+ if pidfd is not None:
+ with suppress(OSError):
+ os.close(pidfd)
+ raise
return
monitor_workers()
@@ -586,8 +735,9 @@ class MultiprocExecutor(Executor):
# If we are in late stage shutdown, the interpreter may replace
# `time` with `None`.
return all(not proc.is_alive() for proc in procs)
- start_time = time.time()
- while time.time() - start_time < timeout:
+ # Monotonic: a wall-clock step must not cut or stretch the grace.
+ start_time = time.monotonic()
+ while time.monotonic() - start_time < timeout:
if all(not proc.is_alive() for proc in procs):
return True
time.sleep(0.1)
@@ -602,7 +752,7 @@ class MultiprocExecutor(Executor):
initial_count,
)
if after_failure and time:
- start = time.time()
+ start = time.monotonic()
states = list(shutdown_states or [None] * len(worker_procs))
def parked() -> list[BaseProcess]:
@@ -634,7 +784,7 @@ class MultiprocExecutor(Executor):
p.kill()
# Workers that started their shutdown get a BOUNDED grace, counted
# from the start of this ladder, then SIGTERM, then SIGKILL.
- grace_s = max(0.0, _FAILED_DRAIN_GRACE_S - (time.time() - start))
+ grace_s = max(0.0, _FAILED_DRAIN_GRACE_S - (time.monotonic() - start))
term_wait_s = _FAILED_WORKER_WAIT_S
if wait_for_termination(active_procs(), timeout=grace_s):
logger.info_once("[shutdown] Executor: all workers exited gracefully")
@@ -735,6 +885,7 @@ class UnreadyWorkerProcHandle:
ready_pipe: Connection
death_writer: Connection | None = None
shutdown_state: Any = None
+ fault_reader: Connection | None = None
@dataclass
@@ -750,6 +901,8 @@ class WorkerProcHandle:
death_writer: Connection | None = None
# Shared byte: _SHUTDOWN_NOT_STARTED / _STARTED / _DONE (worker writes).
shutdown_state: Any = None
+ # Read end of the worker's fault pipe (see start_worker_monitor).
+ fault_reader: Connection | None = None
@classmethod
def from_unready_handle(
@@ -765,6 +918,7 @@ class WorkerProcHandle:
peer_worker_response_mqs=peer_worker_response_mqs,
death_writer=unready_handle.death_writer,
shutdown_state=unready_handle.shutdown_state,
+ fault_reader=unready_handle.fault_reader,
)
@@ -903,11 +1057,16 @@ class WorkerProc:
ready_reader, ready_writer = context.Pipe(duplex=False)
# Death pipe to let child detect parent process exit
death_reader, death_writer = context.Pipe(duplex=False)
+ # Fault pipe: the worker's fail-closed path writes one byte before
+ # os._exit; the executor's monitor waits on it next to the sentinel.
+ fault_reader, fault_writer = context.Pipe(duplex=False)
# One shared byte: has this worker started / finished its shutdown?
shutdown_state = context.RawValue("b", _SHUTDOWN_NOT_STARTED)
if inherited_fds is not None:
inherited_fds = inherited_fds.copy()
- inherited_fds.extend((ready_reader.fileno(), death_writer.fileno()))
+ inherited_fds.extend(
+ (ready_reader.fileno(), death_writer.fileno(), fault_reader.fileno())
+ )
process_kwargs = {
"expected_parent_pid": os.getpid(),
"vllm_config": vllm_config,
@@ -917,6 +1076,7 @@ class WorkerProc:
"input_shm_handle": input_shm_handle,
"ready_pipe": ready_writer,
"death_pipe": death_reader,
+ "fault_pipe": fault_writer,
"shutdown_state": shutdown_state,
"shared_worker_lock": shared_worker_lock,
"is_driver_worker": is_driver_worker,
@@ -940,10 +1100,11 @@ class WorkerProc:
# Close child ends of pipes here in the parent
ready_writer.close()
death_reader.close()
+ fault_writer.close()
# Keep death_writer open in parent - when parent exits,
# death_reader in child will get EOFError
return UnreadyWorkerProcHandle(
- proc, rank, ready_reader, death_writer, shutdown_state
+ proc, rank, ready_reader, death_writer, shutdown_state, fault_reader
)
@staticmethod
@@ -980,10 +1141,59 @@ class WorkerProc:
ready_proc_handles: list[WorkerProcHandle | None] = [None] * len(
unready_proc_handles
)
- while pipes:
- ready = multiprocessing.connection.wait(pipes.keys())
- for pipe in ready:
- assert isinstance(pipe, Connection)
+ # A worker that dies before READY need not close its ready pipe: a
+ # child it fork+exec'd (torch_shm_manager, started by the PLE
+ # registration) can hold the write end, so no EOF ever comes. Wait on
+ # each pending worker's exit (pidfd, else its sentinel -- see
+ # _worker_exit_waitables) and on its fault pipe (one byte from the
+ # fail-closed path; EOF is not a fault), as the post-READY monitor does.
+ exit_waitables = _worker_exit_waitables(
+ [h.proc for h in unready_proc_handles]
+ )
+ exits = {
+ w: handle
+ for (w, _, _), handle in zip(exit_waitables, unready_proc_handles)
+ }
+ faults = {
+ handle.fault_reader: handle
+ for handle in unready_proc_handles
+ if getattr(handle, "fault_reader", None) is not None
+ }
+ try:
+ while pipes:
+ waiting: list[Any] = [*pipes.keys()]
+ # EVERY worker's exit and fault pipe, READY or not: the live
+ # monitor starts only after all are READY, so a READY worker
+ # that faults or dies while a sibling initialises must fail
+ # initialization here.
+ waiting += list(exits)
+ waiting += list(faults)
+ ready = multiprocessing.connection.wait(waiting)
+ WorkerProc._receive_ready(
+ ready, pipes, exits, faults, ready_proc_handles, e
+ )
+ finally:
+ for _, _, pidfd in exit_waitables:
+ if pidfd is not None:
+ with suppress(OSError):
+ os.close(pidfd)
+
+ return cast(list[WorkerProcHandle], ready_proc_handles)
+
+ @staticmethod
+ def _receive_ready(
+ ready: list[Any],
+ pipes: dict[Connection, UnreadyWorkerProcHandle],
+ exits: dict[Any, UnreadyWorkerProcHandle],
+ faults: dict[Any, UnreadyWorkerProcHandle],
+ ready_proc_handles: list[WorkerProcHandle | None],
+ e: Exception,
+ ) -> None:
+ # Ready pipes first: a READY sent before an exit or fault still counts.
+ ordered = sorted(ready, key=lambda obj: 0 if obj in pipes else 1)
+ for obj in ordered:
+ if obj in pipes:
+ pipe = obj
try:
# Wait until the WorkerProc is ready.
unready_proc_handle = pipes.pop(pipe)
@@ -992,18 +1202,50 @@ class WorkerProc:
raise e
idx = unready_proc_handle.rank % len(ready_proc_handles)
- ready_proc_handles[idx] = WorkerProc.wait_for_response_handle_ready(
- response, unready_proc_handle
+ ready_proc_handles[idx] = (
+ WorkerProc.wait_for_response_handle_ready(
+ response, unready_proc_handle
+ )
)
except EOFError:
e.__suppress_context__ = True
raise e from None
-
finally:
# Close connection.
pipe.close()
-
- return cast(list[WorkerProcHandle], ready_proc_handles)
+ continue
+ handle = exits.get(obj) if obj in exits else faults.get(obj)
+ if handle is None:
+ continue
+ pending = handle.ready_pipe in pipes
+ if pending and handle.ready_pipe.poll(0):
+ continue # its READY is queued: read it next round
+ phase = (
+ "before READY"
+ if pending
+ else "after READY, while other workers were initializing"
+ )
+ if obj in exits:
+ logger.error(
+ "Worker proc %s exited (code %s) %s",
+ handle.proc.name,
+ handle.proc.exitcode,
+ phase,
+ )
+ raise e
+ try:
+ data = os.read(obj.fileno(), 1)
+ except OSError:
+ data = b""
+ if data:
+ logger.error(
+ "Worker proc %s reported a fatal fault (fail-closed) %s",
+ handle.proc.name,
+ phase,
+ )
+ raise e
+ # EOF on the fault pipe is not a fault; its exit follows.
+ del faults[obj]
def shutdown(self, *, destroy_distributed: bool = True):
if not destroy_distributed:
@@ -1096,11 +1338,21 @@ class WorkerProc:
# requested (death pipe EOF or SIGTERM/SIGINT): the process then exits
# straight after WorkerProc.shutdown (see _exit_worker_process).
orderly_shutdown = False
- # Set when a READY worker's busy loop FAILED while a shutdown request
- # was pending: full teardown, then exit code 1 (never the exit-0 path).
- failed_during_shutdown = False
+ # Set when a READY worker's busy loop FAILED (anything but its own
+ # shutdown-request cancellation): full teardown, then exit code 1.
+ # Never keyed to shutdown_requested -- the death-pipe monitor can set
+ # it just after the check, which made a failure exit 0.
+ busy_loop_failed = False
ready_writer = kwargs.pop("ready_pipe")
death_pipe = kwargs.pop("death_pipe", None)
+ # Fault pipe: registered for the PLE fail-closed path
+ # (ple_offload.fatal.notify_parent_fatal); kept open for the life of
+ # the process so the byte written right before os._exit reaches the
+ # executor's monitor. Closed only on the orderly way out (EOF, which
+ # the monitor does not count as a fault).
+ fault_pipe = kwargs.pop("fault_pipe", None)
+ if fault_pipe is not None:
+ set_fatal_notify_fd(fault_pipe.fileno())
shutdown_state = kwargs.pop("shutdown_state", None)
def mark_shutdown(value: int) -> None:
@@ -1116,6 +1368,8 @@ class WorkerProc:
os.close(fd)
except Exception as e:
logger.warning("Error closing inherited connection: %s: %s", type(e), e)
+ # Before anything below can fork+exec a helper (torch_shm_manager).
+ _make_inherited_pipes_close_on_exec()
try:
# Initialize tracer
@@ -1169,13 +1423,14 @@ class WorkerProc:
"[shutdown] WorkerProc: exiting after shutdown request"
)
orderly_shutdown = True
- elif shutdown_requested.is_set():
- # A real failure that raced a shutdown request: keep it a
- # failure -- full teardown, then a non-zero exit.
- logger.exception("WorkerProc failed during shutdown.")
- failed_during_shutdown = True
else:
- logger.exception("WorkerProc failed.")
+ # A real failure, whether or not a shutdown request is pending
+ # (yet): full teardown, then a non-zero exit.
+ if shutdown_requested.is_set():
+ logger.exception("WorkerProc failed during shutdown.")
+ else:
+ logger.exception("WorkerProc failed.")
+ busy_loop_failed = True
# The parent sends a SIGTERM to all worker processes if
# any worker dies. Set this value so we don't re-throw
@@ -1200,36 +1455,43 @@ class WorkerProc:
ready_writer.close()
if death_pipe is not None:
death_pipe.close()
- # Clean up once worker exits busy loop
- if worker is not None:
- # Tells the executor's ladder this rank is draining, not parked
- # in a collective (see _ensure_worker_termination).
- mark_shutdown(_SHUTDOWN_STARTED)
- if orderly_shutdown:
- # Everything that must happen before exit (FINAL DRAIN,
- # instrumentation flushes, PLE cleanup) is in
- # worker.shutdown(); then exit instead of paying the NCCL
- # destroy + finalization past the executor's grace.
- exit_code = 0
- try:
- worker.shutdown(destroy_distributed=False)
- except BaseException:
- logger.exception("WorkerProc shutdown failed")
- exit_code = 1
+ # The fault fd stays registered through the whole teardown: a PLE
+ # fail-closed inside worker.shutdown() must still reach the
+ # executor. Unregistered after it; on the orderly
+ # os._exit path the process exit closes it.
+ try:
+ # Clean up once worker exits busy loop
+ if worker is not None:
+ # Tells the executor's ladder this rank is draining, not parked
+ # in a collective (see _ensure_worker_termination).
+ mark_shutdown(_SHUTDOWN_STARTED)
+ if orderly_shutdown:
+ # Everything that must happen before exit (FINAL DRAIN,
+ # instrumentation flushes, PLE cleanup) is in
+ # worker.shutdown(); then exit instead of paying the NCCL
+ # destroy + finalization past the executor's grace.
+ exit_code = 0
+ try:
+ worker.shutdown(destroy_distributed=False)
+ except BaseException:
+ logger.exception("WorkerProc shutdown failed")
+ exit_code = 1
+ mark_shutdown(_SHUTDOWN_DONE)
+ _exit_worker_process(exit_code)
+ worker.shutdown()
mark_shutdown(_SHUTDOWN_DONE)
- _exit_worker_process(exit_code)
- worker.shutdown()
- mark_shutdown(_SHUTDOWN_DONE)
- if failed_during_shutdown:
- sys.exit(1)
+ if busy_loop_failed:
+ sys.exit(1)
+ finally:
+ if fault_pipe is not None:
+ set_fatal_notify_fd(None)
+ fault_pipe.close()
class ResponseStatus(Enum):
SUCCESS = auto()
FAILURE = auto()
- def _fail_ple_rpc_if_pending(
- self, where: str, error: BaseException
- ) -> None:
+ def _fail_ple_rpc_if_pending(self, where: str, error: BaseException) -> None:
"""Fail closed if RPC recovery would abandon queued PLE GPU work."""
owners = (self.worker, getattr(self.worker, "worker", None))
for owner in owners:
@@ -1242,9 +1504,7 @@ class WorkerProc:
try:
pending = connector.has_pending_completions
except BaseException as state_error:
- connector._fail_closed(
- f"{where}:pending-state", state_error
- )
+ connector._fail_closed(f"{where}:pending-state", state_error)
if pending:
connector._fail_closed(where, error)
return
@@ -1259,9 +1519,7 @@ class WorkerProc:
output = output.get_output()
except Exception as e:
logger.exception("Error getting async model runner output")
- self._fail_ple_rpc_if_pending(
- "async-output-copy", e
- )
+ self._fail_ple_rpc_if_pending("async-output-copy", e)
output = e
if isinstance(output, Exception):
@@ -1334,9 +1592,7 @@ class WorkerProc:
if output_rank is None or self.rank == output_rank:
self.handle_output(output)
except Exception as e:
- self._fail_ple_rpc_if_pending(
- f"worker-rpc:{method}", e
- )
+ self._fail_ple_rpc_if_pending(f"worker-rpc:{method}", e)
# Notes have been introduced in python 3.11
if hasattr(e, "add_note"):
e.add_note(traceback.format_exc())
diff --git a/vllm/v1/ple_offload/connector.py b/vllm/v1/ple_offload/connector.py
index 2cf05bb..6aa4e04 100644
--- a/vllm/v1/ple_offload/connector.py
+++ b/vllm/v1/ple_offload/connector.py
@@ -25,7 +25,7 @@ from vllm.model_executor.layers.ple_offload_layer import (
flag_check_is_authoritative,
stale_offload_flags,
)
-from vllm.v1.ple_offload.fatal import ple_fail_closed
+from vllm.v1.ple_offload.fatal import notify_parent_fatal, ple_fail_closed
from vllm.v1.ple_offload.progress import PleProgressMonitor
from vllm.v1.ple_offload.protocol import (
PleOffloadRegistration,
@@ -469,6 +469,7 @@ class PleOffloadConnector:
self._process_request(request, socket)
except Exception:
logger.exception("PLE request thread failed")
+ notify_parent_fatal()
os._exit(1)
finally:
self._request_thread_ready.set()
diff --git a/vllm/v1/ple_offload/fatal.py b/vllm/v1/ple_offload/fatal.py
index a96515f..32831aa 100644
--- a/vllm/v1/ple_offload/fatal.py
+++ b/vllm/v1/ple_offload/fatal.py
@@ -44,6 +44,40 @@ _TYPE_MODULE = type.__dict__["__module__"].__get__
_MAX_CHAIN = 16
_MAX_ARG_CHARS = 1024
+# Write end of the per-worker fault pipe (WorkerProc.make_worker_process); the
+# executor's worker monitor waits on the read end next to the process
+# sentinels. None outside a worker process (tests, the offload child).
+_FATAL_NOTIFY_FD: int | None = None
+
+
+def set_fatal_notify_fd(fd: int | None) -> None:
+ """Register the fault pipe worker_main received from the executor."""
+ global _FATAL_NOTIFY_FD
+ _FATAL_NOTIFY_FD = fd
+
+
+def notify_parent_fatal() -> None:
+ """Tell the executor this worker is dying, before the process is gone.
+
+ The executor's only other signal is the process sentinel, which the kernel
+ closes when the LAST task of the worker leaves -- after the address-space
+ teardown, not at os._exit. On a rank whose GPU work is paired with a live
+ peer that teardown can wait for the peer's exit (in a rank-1 fault test, a
+ fail-closed fault on PP0_TP1 left its leader Z and unreapable for 300 s
+ while rank 0 waited on it in turn; the engine hung on the response
+ queue). One byte here wakes the monitor at once so the failure ladder ends
+ the peers, which is what frees the teardown. Nonblocking, never raises:
+ the caller os._exits right after, whatever happens here.
+ """
+ fd = _FATAL_NOTIFY_FD
+ if fd is None:
+ return
+ try:
+ os.set_blocking(fd, False)
+ os.write(fd, b"F")
+ except BaseException:
+ pass
+
def _safe_type_label(cls: type) -> str:
"""``module.qualname`` of ``cls`` without running class-defined code."""
@@ -240,6 +274,7 @@ def ple_fail_closed(
# runs a Python __exit__ between here and os._exit below; a bare
# except keeps that window empty.
pass
+ notify_parent_fatal()
os._exit(1)
--
2.43.0