fix(ci): close docker_smoke.sh's stdin-before-drain race - #428
Conversation
…container gate docker_smoke.sh drove the container with `printf '%s' "$REQUESTS" | docker run --rm -i ...`: printf closes its end of the pipe (the container's stdin) the instant the batch is written, before any response has been read. That is the exact anti-pattern PR #331 fixed in scripts/mcp_host_client.py for a local subprocess: closing stdin is the MCP shutdown signal (2025-06-18 SS Lifecycle -> Shutdown -> stdio), not an end-of-input marker, and mcp 2.0.0's _handle_request drops an in-flight response write rather than deliver it once EOF fires the cancel scope. Verified against jsonrpc_dispatcher.py that this cancellation path is method-agnostic, so tools/list (id=3) is exactly as vulnerable as any other request — matching this gate's observed signature ("no valid tools/list response (id=3)", empty stderr, no JSON-RPC error frame) and its history of intermittent failures on unrelated PRs and on main itself. Fix: scripts/docker_smoke_client.py drives the container the same way mcp_host_client.py drives a local server — keep stdin open until every expected response id has arrived (mcp_host_client.drain_exchange, a new generic primitive extracted from _exchange, behavior-preserving), close it only then. docker_smoke.sh now builds the image and delegates the run+exchange+assert sequence to this module; the watchdog still docker-kills the container via --cidfile on the same 60s deadline (fires at most once, never retries). Deterministic reproduction (no clock, no docker, no retries): - tests_py/infrastructure/test_stdio_eof_drain.py adds TestDockerSmokeToolsListLostBeforeDrain, using docker_smoke's own id=3/tools/list request against the real mcp 2.0.0 SDK: the write-then-close-before-drain shape drops the response (reproducing the gate's literal historical failure), the drain-then-close shape does not. - tests_py/scripts/test_docker_smoke_client.py pins that docker_smoke_client's exchange never closes stdin before both expected ids are read, for its own three-frame batch. Corroborating measurement (not the gate, since the race is probabilistic): 20/20 real `docker build` + docker_smoke.sh runs passed locally against the fixed image, versus the historical ~1-in-5 failure rate on the old script (main run at 12:29 today, PRs #423-425). Co-Authored-By: Claude <noreply@anthropic.com>
… fix git merge-base HEAD origin/main was 4 commits behind origin/main — rebased first (per PR #423-425's shared cause), then re-ran the CI's exact invocation, `check_craftsmanship.py --base origin/main`, which diffs against the base ref's baseline rather than the working tree's and caught three real violations `check_craftsmanship.py` alone (no --base) does not: - scripts/docker_smoke_client.py::main exceeded the 40-line method cap (CLAUDE.md's local tightening of coding-standards.md §4.2) — split into _build_parser()/_evaluate()/main(), each under the limit. - scripts/docker_smoke_client.py::TOOLS_LIST_ID (value 3) had no `# source:` comment — added one, same source as PROTOCOL_VERSION above it (docker_smoke.sh's original REQUESTS heredoc, where tools/list was request id=3). - tests_py/infrastructure/test_stdio_eof_drain.py grew to 318 lines, over the 300-line file cap, after the prior commit appended the docker-smoke-specific pinning test to it. Split that test class into a new sibling file, test_docker_smoke_stdio_eof_drain.py, which imports (not duplicates) _server/_GatedWriteStream/_collect from the original — both files now under the cap, no test content lost. No production logic changed: pytest (1448 passed / 35 skipped), ruff check/format, and shellcheck all still clean; the deterministic reproduction (real mcp SDK, docker_smoke's own id=3/tools/list request, write-then-close loses it / drain-then-close does not) is unchanged, just relocated. Co-Authored-By: Claude <noreply@anthropic.com>
2c93a2e to
5b0c57e
Compare
|
Rebased onto origin/main (was 4 commits behind — same cause as #423-425) and re-ran CI's exact invocation, Reaffirming for the record: the deterministic reproduction — driving the real |
|
ZETETIC-REVIEW: APPROVE SummaryFixes the docker-smoke gate's stdin-close-before-drain race by draining Move 0 — Ledger reconciliation and seen-defect check
Stakes calibration (Move 7)
1 — Deterministic reproduction, verified for real
The author's own framing in the PR body ("the race is probabilistic, so replay count alone proves nothing") is exactly right and matches the ask: the 20/20 successful local reruns are corroboration, not proof — against a race that fires ~1/5 of the time on 2 —
|
Summary
scripts/docker_smoke.shdrove the bare-container gate withprintf '%s' "$REQUESTS" | docker run --rm -i ..., which closes thecontainer's stdin the instant the batch is written — before any
response has been read. That is the identical anti-pattern PR #331
fixed for a local subprocess in
scripts/mcp_host_client.py: closingstdin is the MCP shutdown signal (2025-06-18 §Lifecycle › Shutdown ›
stdio), not an end-of-input marker, and mcp 2.0.0's
_handle_requestdrops an in-flight response write rather than deliver it once EOF
fires the cancel scope. Verified against
jsonrpc_dispatcher.pythatthis is method-agnostic —
tools/list(id=3) is exactly as vulnerableas any other request, matching this gate's signature failure ("no
valid tools/list response (id=3)", empty stderr, no JSON-RPC error
frame) and its history of intermittent failures (main at 12:29 today,
PRs #423/#424/#425).
scripts/docker_smoke_client.pydrives the container viamcp_host_client.drain_exchange(a new generic primitive extractedfrom
_exchange, behavior-preserving) — stdin stays open until bothexpected response ids arrive, closes only then.
docker_smoke.shnow just builds the image and delegates the run+exchange+assert
sequence to it.
docker kills the container via--cidfileonthe same 60s deadline; fires at most once, never retries — no
sleep, no wall-clock verdict, no retry loop anywhere in this diff.Test plan
tests_py/infrastructure/test_stdio_eof_drain.py::TestDockerSmokeToolsListLostBeforeDraindrives the real
mcp2.0.0 SDK with docker_smoke's own id=3tools/listrequest — the write-then-close-before-drain shape dropsthe response (reproducing the gate's literal historical failure);
the drain-then-close shape does not.
tests_py/scripts/test_docker_smoke_client.pypins that the newclient never closes stdin before both expected ids are read.
pytest tests_py/scripts/ tests_py/infrastructure/— 1448passed, 35 skipped, 0 failed.
ruff check/ruff format --check— clean, repo-wide.python scripts/check_craftsmanship.py— OK.shellcheck scripts/docker_smoke.sh— clean.probabilistic, so replay count alone proves nothing): rebuilt the
production image locally and ran
scripts/docker_smoke.sh --skip-build20/20 times successfully against the fix.