Repository navigation
fix: harden cross-platform server shutdown and add CI - #8
Merged
Merged
Conversation
Root cause (macOS). _alive() read /proc/<pid>/stat and treated a missing
file as "dead". macOS has no /proc, so stop_server() for a server started
by an earlier kernel stopped waiting immediately after SIGTERM and checked
/health while uvicorn was still shutting down, failing
test_stop_server_started_by_another_kernel. Replacing it with plain
kill(pid, 0) would not work either: that succeeds on zombies, so an exited
but unreaped server would look alive until the deadline.
Further defects found while fixing it:
- The pid file held only a pid, and stop_server() sent killpg() to it
unverified: a stale file whose pid had been reused by another session
leader would signal an unrelated process group (reproduced in a test).
- The port was reported free whenever /health stopped answering, even if
something still listened on it.
- A failed or timed-out start left the pid file, _PROC and the log handle
behind; the log handle was never closed.
benchmarks/colab_helpers.py:
- Liveness: kill(pid, 0) first (ESRCH = gone, EPERM = exists), then the
state from /proc on Linux or `ps -o stat=` elsewhere; zombies count as
exited; an unreadable state counts as alive, so shutdown waits rather
than declaring a live process dead.
- Ownership: the pid file is JSON {pid, port, start}; start is the process
start time (/proc starttime, or `ps -o lstart=`), fixed at fork. A pid is
signalled only if its start time and command line (-m echorelay --port N)
still match; otherwise the file is stale and is removed without
signalling. Legacy pid-only files are accepted when the command line
matches. If ownership cannot be verified at all, stop_server() raises and
keeps the file.
- Shutdown: SIGTERM to the group, bounded wait (monotonic deadlines,
backoff polling), identity re-checked, then SIGKILL; raises if the
process outlives SIGKILL. A child of this kernel is reaped; an already
exited child is never signalled (its pid may be reused).
- Port: after stopping, waits for nothing to accept TCP connections on
127.0.0.1:PORT and raises otherwise, naming whether the listener answers
/health.
- start_server(): cleans up the process, pid file and log on a crash or a
health timeout before raising. Public signatures are unchanged; timeouts
are module constants because start_server(**settings) forwards keywords
to the server.
tests/test_colab_helpers.py: 16 new tests (21 total): another kernel on
the /proc and ps (macOS) paths, graceful SIGTERM only, repeated stop,
already-exited servers, stale legacy/mismatched/garbage pid files, pid
reuse by start time, unverifiable ownership, SIGKILL escalation, an
unkillable process, ps-based and unknown-state liveness, Linux zombies, a
non-HTTP listener holding the port, and cleanup after failed starts.
Spawned processes are killed in teardown even when a test fails.
.github/workflows/ci.yml: offline suite on Linux for Python 3.11 and 3.13
x both scheduling flags (8 jobs), on macOS for Python 3.13 (default
flags), and the benchmark evidence verifier. Real-model tests excluded;
read-only permissions; job timeouts; no secrets or GPU.
README.md: CI badge.
No changes to inference, scheduling, the streaming protocol, benchmark
results or dependencies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DfLG9q5h7eiawGNwaBswq
A pid file without a recorded start time (written by an older version, or when the start time was unreadable at start-up) was accepted as the server on a command-line match alone. Anything started with the same arguments, including another EchoRelay server, would pass. stop_server now refuses to signal it, keeps the pid file, and raises with manual-stop instructions. Stale entries whose process has exited, or whose command line does not match, are still removed without signalling. On Linux, the recorded process is pinned with a pidfd before verification: an identity read after the pinned process exited is not trusted, nothing is signalled once it has exited, and exit is awaited on the pidfd, which a reused pid cannot fool. Falls back to the previous path where pidfd is unavailable (macOS, old kernels, seccomp). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DfLG9q5h7eiawGNwaBswq
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.
Fixes the server-shutdown failure on macOS (
test_stop_server_started_by_another_kernel), hardensstop_server()against stale and reused PIDs (including pid files that cannot prove ownership), and adds GitHub Actions CI. No changes to inference, scheduling, the streaming protocol, benchmark results or dependencies.Root cause
_alive(pid)read/proc/<pid>/statand treated a missing file as "dead". macOS has no/proc. For a server started by an earlier notebook kernel,stop_server()therefore stopped waiting immediately after SIGTERM and checked/healthwhile uvicorn was still shutting down. Reproduced on Linux by making/procreads fail:stop_server()raised "something is still serving" 0.00 s after SIGTERM, although the server then exited normally.Replacing it with plain
os.kill(pid, 0)would not work either: that call succeeds on a zombie, so an exited but unreaped server would look alive until the 20 s deadline (verified).Three further defects found while fixing it:
stop_server()calledkillpg()on it without verification. A new test reproduces it: with a legacy pid file whose PID now belongs to an unrelated session leader, the old code sent it SIGTERM./healthfailing, even if something still listened on the port._PROCand the log handle behind; the log handle was never closed.Changes
benchmarks/colab_helpers.py(public signatures unchanged):kill(pid, 0)first (ESRCH = gone, EPERM = exists), then the process state from/procon Linux orps -o stat=elsewhere. Zombies count as exited. An unreadable state counts as alive, so shutdown waits rather than declaring a live process dead.{pid, port, start}.startis the process start time (/procstarttime orps -o lstart=), fixed at fork. A PID is signalled only if its start time and command line (-m echorelay --port N) still match; otherwise the file is stale and is removed without signalling. A matching command line alone never authorizes a signal. A pid file with no start time (a legacy PID-only file,"start": null, or nostartkey) cannot confirm ownership: if its process is alive and its command line matches,stop_server()sends nothing, keeps the file unchanged and raises with manual-stop instructions; if the process has exited or its command line does not match, the file is removed without signalling. If ownership cannot be verified at all (no/proc, nops),stop_server()raises and keeps the file.os.pidfd_open) before its identity is checked. An identity read after the pinned process exited is not trusted (it may describe a successor with the same PID); nothing is signalled once the pinned process has exited; and exit is awaited on the pidfd withpoll(), which a reused PID cannot fool. Where pidfds are unavailable (macOS, old kernels, seccomp sandboxes that refusepidfd_open), the helpers fall back to the unpinned path. A server started by the current kernel is still handled through itsPopenobject (an unreaped child's PID cannot be reused).127.0.0.1:PORT, and raises otherwise, saying whether the listener answers/health.Timeouts are module constants (
STOP_GRACE_S=20,STOP_KILL_S=10,PORT_RELEASE_S=5,START_TIMEOUT_S=300), becausestart_server(**settings)forwards every keyword to the server. New imports are standard library only (select,socket,typing.IO).tests/test_colab_helpers.py: 33 tests in total (28 new). They cover:/procpath and thepspath (the macOS path, simulated).stop_server(), and an already-exited server."start": nulland a missingstartkey, on both the/procandpspaths; such a file for an exited process is removed. Run against the previous commit (84025d6), all six of these cases fail because it signalled the lookalike process.pidfd_openfalls back cleanly.ps-based path, the unknown-state path, and Linux zombies.Spawned processes are killed in teardown even when a test fails. Waiting uses bounded polling and pipe handshakes, not fixed sleeps.
.github/workflows/ci.yml:ECHORELAY_FINAL_PRIORITY/ECHORELAY_CANCEL_QUEUED_PARTIAL_ON_END(8 jobs,fail-fast: false).-m "not whisper");permissions: contents: read,persist-credentials: false, job timeouts, pip cache keyed onpyproject.toml; no secrets or GPU.README.md: CI badge.Validation
Local runs, Linux, Python 3.11.17 and 3.13.16, at
770e29c:python -m pytest -q -m "not whisper"python -m pytest -q tests/test_colab_helpers.pypython -m benchmarks.verify_evidencegit diff --checkactionlint .github/workflows/ci.ymlruff checkon changed filessave_provenance)The deselected test is the real-model Whisper test. The flag-matrix runs were made just before the final change in
770e29c(pidfd wait switched fromselect()topoll(), helper code only); the full suite and the helper repeats were re-run on both versions after it.GitHub Actions (run 40150692081) on head
770e29c: 10/10 jobs completed with success: 8 Linux matrix jobs (py3.11 and py3.13 × both flags), macOS py3.13, and Benchmark evidence.Not verified locally: macOS itself. The macOS path was exercised on Linux by disabling
/procand usingps; the real macOS run is the CI job above (passed). The pidfd tests are skipped on macOS; I could not retrieve the macOS job log through the API, so its exact skip count is unconfirmed.Remaining risks
killpg()are separate steps. Signalling the wrong process would need the server to exit and its PID to be reused within milliseconds between them.killpg()still addresses the group by number. A group ID cannot be reused while any member is alive, so the window is microseconds wide and needs a full PID wraparound.stop_server()(and thereforestart_server()) refuses until the user stops it (kill -TERM -- -<pid>) and deletes the file. This is the intended fail-safe behaviour, but it is a visible change for anyone upgrading with a server running./procand nops:stop_server()refuses to signal and raises (fail-safe), and liveness falls back tokill(pid, 0), which cannot detect zombies.ps lstarthas one-second resolution. A PID reused within the same second and running-m echorelay --port Nwould be indistinguishable on macOS. Linux uses/procclock ticks.127.0.0.1only, which is where the helpers bind the server.https://claude.ai/code/session_017DfLG9q5h7eiawGNwaBswq
Generated by Claude Code