Repository navigation
fix(workers): serialize reaping so a killed worker never looks alive - #55
Merged
Merged
Conversation
The reader thread and close() both join a dying worker. Concurrent waitpid calls race: the loser gets ECHILD, and CPython's Popen.poll reports the process still running until the winner records its exit code. close() could then see a killed worker as alive and skip returning its host reservations; CI hit it as an intermittent failure of test_workers_close_together_within_one_grace_period. Every wait and liveness check now goes through ProcessShard._exited: the process sentinel says whether it exited (waited on outside any lock, so health checks never block behind a wait), and the reap is serialized under _reap_lock so the exit code is recorded once it returns True. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #55 +/- ##
==========================================
+ Coverage 79.36% 79.58% +0.22%
==========================================
Files 41 41
Lines 3193 3209 +16
==========================================
+ Hits 2534 2554 +20
+ Misses 659 655 -4
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…ills - A forked grandchild that inherits the worker's sentinel keeps it open after the worker dies; _exited now falls back to a non-blocking reap under the lock instead of reporting the dead worker alive forever. - The reap after a ready sentinel is bounded (join(5)), and kills go through _kill under the same lock, so a reaped pid is never signalled. - A worker that dies during startup is reaped before its exit code is reported. - The race test now checks the exit code inside each racing thread, so it fails without the lock (11/15); a new test covers a held sentinel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Member
Author
|
Review fixes pushed (independent review agent):
Unit tests: 499 pass, and |
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
PR #53's CI failed
test_workers_close_together_within_one_grace_period(assert not any(s._proc.is_alive() ...)) on a docs-only change. The cause is a real race, not the test.Two threads reap a dying worker: its reader thread (on pipe EOF) and
close(). Bothjoin()/is_alive()callwaitpid. When two threads call it at once, the loser getsECHILD, and CPython'sPopen.pollreturnsNone("still running") until the winner stores the exit code. So:close()could see a worker it just killed as alive and skip_release_host_memory(), leaving that worker's host reservations held until the parent exits.alive,death_reasonand the exit code in log messages can be read wrong in the same window.Fix
Every wait and liveness check in
ProcessShardnow goes through_exited(wait_s):join()) is serialized under_reap_lock, so the exit code is recorded by the time_exitedreturns True._exit_codereads under the same lock.Checking
is_alive()under a lock is not enough. The sentinel can close a moment before the kernel makes the exit status available, so a non-blockingwaitpidstill says "running". That is why CPython's ownjoindoes a blockingwaitpidonce the sentinel is ready, and_exitedrelies on that.Tests
test_concurrent_waits_all_see_a_dead_worker_exit: 7 threads wait on a worker as it is killed, and all must see it exit, with an exit code recorded.is_alive(): fails 12 of 15.tests/unit/test_workers.py: 15 of 15 clean runs. Full unit suite: 498 pass. ruff and pre-commit are clean.No architecture change (internal to
serve/workers.py), so the README and docs are unchanged. The CHANGELOG has an Unreleased/Fixed entry.🤖 Generated with Claude Code