Skip to content

fix: recover stuck workers when an MPExecutor subprocess dies#82

Merged
kaiitunnz merged 4 commits into
mainfrom
fix/mp-executor-dead-child-stuck-busy
Jun 22, 2026
Merged

fix: recover stuck workers when an MPExecutor subprocess dies#82
kaiitunnz merged 4 commits into
mainfrom
fix/mp-executor-dead-child-stuck-busy

Conversation

@kaiitunnz

@kaiitunnz kaiitunnz commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator

Purpose

A worker would get stuck BUSY indefinitely when an executor running inside MPExecutor (e.g. vLLM) died hard — an out-of-memory kill, a CUDA abort(), or a native segfault. MPExecutor.run() waited for the subprocess's result with an unbounded res_q.get(), but a child that dies that way never posts a result, so the parent thread blocked forever while holding the executor lock. run() never returned, the runner's finally: set_idle() never ran, and the worker stayed BUSY (with stale=false, so no health/staleness path reclaimed it) — silently unable to accept further work.

Changes

  • src/worker/executors/mp_executor.py — wait for the subprocess result with liveness-checked polling instead of an unbounded blocking get, so a dead child surfaces as a retryable failure rather than a hang; restart the subprocess after an unexpected failure to drop a possibly-corrupted engine; share the teardown logic across the run, await, and cleanup paths.
  • tests/worker/test_mp_executor_lifecycle.py — cover the dead-child, unexpected-failure, and controlled-error paths.

Design

run() now classifies how a task ended and reacts accordingly: a child that died silently is reported as a retryable error (a hard crash is usually transient, e.g. OOM under contention); a controlled ExecutionError keeps the subprocess warm to avoid a needless engine reload; an unexpected exception restarts the subprocess, since a corrupted engine's GPU memory is only reclaimed once the process exits.

Test Plan

uv run pytest tests/ --ignore=tests/worker/test_mp_executor_cleanup_gpu.py
uv run pre-commit run --all-files

End-to-end on a local root + single GPU worker on freshly built images carrying this branch, using a fault-injection repro: submit a vLLM inference task, then kill -9 the MPExecutor child subprocess mid-run to faithfully simulate an OOM kill / CUDA abort, and observe the worker and task state before and after the fix.

Test Result

$ uv run pytest tests/ --ignore=tests/worker/test_mp_executor_cleanup_gpu.py   # All passed
$ uv run pre-commit run --all-files   # isort / black / ruff / mypy / codespell / gitleaks passed

End-to-end: before the fix, killing the child left worker=BUSY and task=DISPATCHED for the full 120s observation window. After the fix, the dead child is reported as mp(vllm) subprocess exited (code -9), the task fails retryably, is re-dispatched, and runs to DONE, and the worker returns to IDLE.


Pre-submission Checklist
  • I have read the contribution guidelines.
  • I have run pre-commit run on the changed files and fixed any issues.
  • I have added or updated tests covering my changes (if applicable).
  • I have verified that the worker test suite passes locally (worker-only change).
  • If I changed shared schemas or proto definitions, I have checked downstream compatibility across Server and Worker. (No schema/proto changes.)
  • If I changed the SDK or CLI, I have verified the affected packages work. (No SDK/CLI changes.)
  • If this is a breaking change, I have prefixed the PR title with [BREAKING] and described migration steps above.
  • I have updated documentation or config examples if user-facing behavior changed. (No user-facing config/behavior change.)

MPExecutor.run() waited for the subprocess result with an unbounded
res_q.get(), so a child that died without replying (OOM kill, CUDA abort)
left the parent blocked forever while holding the executor lock: the task
never reached a terminal state and the worker stayed BUSY. Poll the result
queue with a timeout and check subprocess liveness, surfacing a dead child
as a retryable error so the worker recovers and the task can be retried.

Also restart the subprocess after an unexpected (non-ExecutionError)
failure, since a corrupted engine's GPU memory is only reclaimed once the
owning process exits; controlled errors keep the subprocess warm.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
@kaiitunnz kaiitunnz requested a review from timzsu as a code owner June 21, 2026 19:11
Comment thread src/worker/executors/mp_executor.py
Comment thread src/worker/executors/mp_executor.py Outdated
A controlled ExecutionError raised with retryable=True by an inner executor
was re-raised as non-retryable in the parent, since the flag was never
carried in the subprocess error payload. Record it alongside
is_execution_error and restore it when re-raising, so retry routing sees the
executor's original intent.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Assert the controlled-error path stays non-retryable alongside the existing
retryable-propagation test, and set retryable explicitly in the
executor-init-failure payload so it matches the run-failure payload shape.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
@kaiitunnz kaiitunnz requested a review from timzsu June 22, 2026 06:16

@timzsu timzsu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
@kaiitunnz kaiitunnz merged commit 55e27fb into main Jun 22, 2026
11 checks passed
@kaiitunnz kaiitunnz deleted the fix/mp-executor-dead-child-stuck-busy branch June 22, 2026 07:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants