Repository navigation
Describe rejected values from their structure, never their value (see Consiliency/pmcp#297) - #388
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
…297) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
…me sweep (see #297) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
…sures (see #297) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
…cp refresh tests (see #297) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1b7aDmh5mzuiPQQprmsXN
…the guard (see #297) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiabTgWZRfuqiUQ9siEegF
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiabTgWZRfuqiUQ9siEegF
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiabTgWZRfuqiUQ9siEegF
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiabTgWZRfuqiUQ9siEegF
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiabTgWZRfuqiUQ9siEegF
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiabTgWZRfuqiUQ9siEegF
…eck covers parser submodules (see #297)
…-manifest parse through load_yaml (see #297)
…p PR 321's reader with rev 8's parse split; frame sweep follows a dropped legacy-SSE frame with the real reply (see #297)
…nly up to a frame opener (see #297)
…viving grandchild is ended (round 7; see #297) On 3.11+ Process.wait() waits for every pipe to close, so awaiting it let the grandchild's 60 s sleep run out (60 s per run on 3.11/3.12). The test polls returncode, which is set at the exit. A grandchild that outlives a kill under test is ended once its cmdline confirms the pid is still ours. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGpNeAeiXffeH91mdgpCTQ
…rocess object (round 7; see #297) The leader signal suppressed only ProcessLookupError/OSError: an object without send_signal raised AttributeError out of a function documented never to raise, and M259's kill was that crash rather than its assertion. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGpNeAeiXffeH91mdgpCTQ
…d by pmcp; every spawn owned; retained groups forgotten when empty (round 8; see #297) - codex/grok F001: no POSIX-only name is evaluated at import (SIGKILL was a default argument); a static guard pins it; a windows-smoke CI job imports pmcp, runs the entry point and the Windows-path tests on Windows. - codex F002 = gemini F001 = grok F001: ClientManager teardown takes the Windows branch first and terminates the Job Object (releasing its handle) whether or not the leader already exited. - codex F003: every spawn site's exits are owned (child_output.owned, a finally kill through kill_tree): verify_installation's timeout and a cancelled install_server no longer leave children; the install monitor kills leftovers on every exit but the handoff; the update probe kills in a finally; the CLI's service checks run through run_captured. - codex F004: the Windows drain polls PeekNamedPipe and a stop event, so it ends within a poll of its deadline even if a descendant never closes the write end. - claude N-A: retained group ids are trusted only from spawn (or verified adoption) until first observed empty, and only for the process that owns them; ClientManager watches each server's group and forgets it when empty. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGpNeAeiXffeH91mdgpCTQ
…OG for round 8 (see #297) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGpNeAeiXffeH91mdgpCTQ
…n a real Windows host (round 8; see #297) - conftest: the trust-store redirect sets USERPROFILE too (Windows' Path.home()), and refuses the real store's directory rather than the whole real home (Windows' temp directory is inside it). - POSIX-only tests are marked; the Windows seam tests tolerate a missing os.killpg/os.getpgid. - On Windows for real: a Job Object kill reaches the grandchild; a teardown of a leader that already exited ends its grandchild; the PeekNamedPipe drain reads every line and stops at its deadline with the writer open; the polling reader reads the last bytes at the stop. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGpNeAeiXffeH91mdgpCTQ
…group lookup goes through the retained checks (round 8; see #297) - A server handed over by the installer is as long-lived as a spawned one: its trusted group is watched and forgotten when empty, like _connect_stdio's. - kill_process_tree_now: a leader reaped between the reserved check and the getpgid lookup no longer drops a passed group id; it is signalled through signal_retained_group. - The round-7 Windows arm inside _signal is unreachable (the job is terminated at entry) and is removed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGpNeAeiXffeH91mdgpCTQ
…297) SECURITY.md's claim checker (R22) holds test.yml to exactly one pytest invocation, the suite's; the Windows job's own pytest run broke it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGpNeAeiXffeH91mdgpCTQ
…in a bound of its owner (round 9; see #297) - codex F001: the Windows reader checks its stop on every iteration, data available or not; once stopped it reads at most one 64 KiB budget. - codex F002: watch_group polls the group and never waits on the process (an asyncio wait() also waits for the pipes); the watcher is a ManagedClient task cancelled by both per-client cleanups. - codex F003: kill_tree keeps the spawn contract's group id when the leader is reaped between the reserved check and getpgid; the retained checks decide. - grok F001: an install that reported its server started and exited has its group killed at the handoff; cancel_job kills an exited leader's group; a server_ready server not adopted within 10 minutes is killed; verify_installation requires a zero exit. - The class rule, table-driven (tests/test_child_lifetime.py): every in-process spawn site x hold the pipe / exit early / flood -- after the owner's teardown plus 10 s no process, drain thread or fd remains. - claude N-B: windows.yml runs with contents: read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGpNeAeiXffeH91mdgpCTQ
…; every drop closes the child's pipes (round 10; see #297) - claude F001 = grok F001: _finalize_server_ready cancels the handoff deadline and moves the job to `adopting` (and drops job.process) before any await, so the deadline never kills a server ClientManager is adopting. On failure, the handoff kills only a process nothing tore down (returncode unset); a cancelled handoff records the end and re-raises. `adopting` is reported as `server_ready` in the tool's output. - codex F001: child_output.release() closes a child's pipe transports (or a Popen's streams) at once -- the subprocess transport only after the reap, since closing it earlier reaps the child behind asyncio and reports 255. Called at every drop: owned(), handoff expiry, cancel_job, the monitor's exits, the crash callback, the handoff's failure and cancel arms, and ClientManager's graceful and abandoning teardowns. - gemini: the deadline is cancelled explicitly on the failure branch and by cancel_job. - pmcp refresh: the MCP SDK's child is captured (per task) and, after the SDK's own teardown, what is left of its group is killed through the retained-group rule -- a leader that exited first left its grandchild. - The feedback report's platform line no longer runs `uname -p` (an undetached child, caught by the session spawn audit). - tests/test_child_lifetime.py: rows for the handoff (expiry, adopted, adoption fails, adoption straddling the deadline), run_captured (timeout, Ctrl-C), run_echoed (Ctrl-C), the npm resolver helper and the refresh child; every row checks fds with automatic GC disabled. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGpNeAeiXffeH91mdgpCTQ
… exit; the handoff failure arm kills the group unconditionally (round 10; see #297) - _terminate_process_tree's whole body runs in try/finally that releases the process's pipes (the name the static cancellation guards key on is kept); _kill_process_tree_now releases after its kill. Every ClientManager and update-probe teardown goes through one of the two. - _finalize_server_ready's failure arm kills the group whatever returncode says: a leader that exits mid-handoff (adopt_process refuses it) left its descendants to this path alone. The cancel arm only releases: both of ClientManager's cancellation arms kill synchronously. - Tests: a flooded, unread, paused pipe is closed by each teardown helper with automatic GC off; a leader exiting during the handoff leaves no grandchild. Mutants M284-M286. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…be runners own their child from Popen (round 10; see #297) - A provision_status poll that reads `adopting` waits on the finalize lock and reports the handoff's result, as a server_ready poll did (the full suite's test_concurrent_polls_adopt_once). - run_echoed and run_captured enter owned() before track() and the communicate: an interrupt landing before the communicate still kills the child with its group. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…ods (round 10; see #297) With stdin DEVNULL and stderr folded into stdout, communicate() takes its one-pipe fast path, stdout.read(), which loops in C and never returns to the interpreter while the pipe stays readable: a child writing without pause made `pmcp upgrade` immune to Ctrl-C and held all its output in memory. Found by the lifetime table under load (1 run in 36), pinned deterministically by test_ctrl_c_reaches_run_echoed_while_its_child_floods (M287). run_echoed now streams through bounded_lines (one read1 syscall per chunk), echoing each line as it arrives. The lifetime table's Ctrl-C harness dumps its stack if it does not exit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…dout never kills an installer (round 11; see #297) - codex F001: cancel_job kills, closes the pipes and records the status in a finally that runs on CancelledError; the graceful wait is the caller's to cancel, the kill is not. The handoff deadline is disarmed only after the kill it backs up. The handoff's cancel arm kills synchronously too. Every other teardown already killed synchronously on cancel (audited). - The class rule's new dimension (tests/test_child_lifetime.py, `cancel`): a downstream that ignores SIGTERM, and every await on its exit in a teardown cancelled -- for every owner whose teardown awaits one. The child is gone and its fds released within the bound. - claude N-A: run_echoed stops echoing on BrokenPipeError, drains to the child's own end and returns its exit status; descriptor 1 is pointed at the null device (human CLI stream only) so the exit-time flush is quiet. - claude (pre-existing): adoption under a name already connected tears the old client down instead of overwriting it. - Mutants M288-M291; M287 re-anchored. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…with no await before it (round 12; see #297) - codex F001: _register_or_kill() is the only place a client is registered. It refuses after abandon_all_now() -- checked with no await between the check and the registration -- killing the new process with its group, closing its pipes, and raising _ManagerAbandoned. Used by _connect_stdio (after the spawn await), the remote connect (after `await ready`) and adoption (after the same-name teardown). Connect, reconnect, refresh and lazy start all register through _connect_stdio or the remote connect. - A static test pins the single registration site; tests cover codex's interleaving (disconnect_all cancelled while adoption tears down a same-name client), and connect and reconnect with the shutdown sweep during the spawn. Mutant M292. - claude: cancel_job takes the process from the job before its first await; `pmcp upgrade 2>&1 | head` keeps the installer's exit status (stderr, when it is the same broken pipe, goes to the null device too). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…he instant before it registers (round 12; see #297) The task-record guard requires every function that registers a connection to drop the name's records first; registration now lives in _register_or_kill, so it does (every caller also drops them before its awaits, and nothing is indexed for the new connection yet). The guard's expected sets name the one registration site. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…ip; a server_ready server dies with the gateway (round 13; see #297) - grok F001 = codex F001: child_output.spawn_owned() runs every async spawn shielded. The child already runs while asyncio connects its pipes; cancelled there, asyncio killed the leader alone and a grandchild forked meanwhile survived. Now a caller's cancellation leaves the spawn to finish, and a done-callback owns the child the moment it exists -- tracked, killed with its group, pipes closed. Used by all seven async spawn sites and the refresher's capture of the MCP SDK's spawn (an anyio Process is unwrapped to its asyncio child, so its group is killed while the leader still reserves it). A static test pins it. - claude F001: the handoff deadline kills an unadopted server in a finally, so a cancellation at loop shutdown kills it too; and JobManager.shutdown(), called synchronously from the gateway's teardown, kills every job's process -- installing or server_ready. - claude nit: _register_or_kill's refusal already closes the pipes (through _kill_process_tree_now); now pinned by a test and mutant. - tests/test_child_lifetime.py: a `spawn-cancel` dimension (cancel while the spawn connects its pipes, the child having forked) for every async spawner, and a `the gateway shuts down while server_ready` row. - Mutants M293-M296; M248/M249 re-anchored. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…guard) (round 13; see #297) test_exception_text_sinks forbids `.exception()` used inline; the abandoned-spawn callback now binds it before testing it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…the loop ends; SIGHUP shuts it down (round 14; see #297) - codex F001: a spawn runs in a _SpawnTask registered as pending. Its cancel() records the child's pid first (the transport CPython is still connecting holds it), so when the loop's teardown cancels the spawn and CPython kills the leader alone, the settle callback kills the group. abandon_pending_spawns() (synchronous: abandon_all_now, JobManager.shutdown) marks every spawn in flight abandoned -- owned and killed when it completes -- and the gateway's shutdown awaits them, bounded, with drain_pending_spawns() before returning to asyncio.run. - claude N-A: SIGHUP joins SIGINT and SIGTERM on POSIX (cli.shutdown_signals / install_shutdown_signals), so a closed terminal or dropped SSH session goes through the gateway's shutdown. Windows' CTRL_CLOSE_EVENT is not handled (noted). - claude N-B: the job manager (and the pending-spawn set) stay process-global; making them per-gateway is not cheap. Noted. - Tests: codex's falsifier (asyncio.run ends during a spawn: no leader, no grandchild); the drain (shutdown does not return before a spawn in flight is killed); a real gateway subprocess with a server_ready job, SIGHUP: all gone. Mutants M297-M299. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…onnects (3.11+) (round 14; see #297) On 3.11+ CPython finishes a subprocess transport only once every pipe has connected and disconnected; the patched connect, cancelled with the loop, never connected, so CPython's own cleanup waited forever. It now completes the real connect in a finally, as a real one does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…p still works (round 15; see #297) install_shutdown_signals replaced the SIG_IGN that `nohup` sets for SIGHUP, so `nohup pmcp --transport http &` (README) shut down at the operator's logout. A shutdown signal the process inherited as ignored -- SIGHUP under nohup, SIGINT for a background job of a shell without job control -- is now left ignored; a terminal-attached gateway still shuts down on hangup. The real-gateway SIGHUP test runs both ways (M300). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…GHUP at its default (round 15; see #297) A test process that itself inherited SIGHUP as ignored (a nohup'ed CI or ai run) passed that on to the "terminal" gateway, which -- correctly, by round 15's rule -- kept ignoring it. The case now sets the disposition a terminal-attached launch has. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01562ZfHgu1YLatxGYf826GH
…ed (round 16; see #297) Only the gateway installed shutdown handlers. During `pmcp upgrade` a terminal hangup or SIGTERM killed pmcp without its cleanup, and the detached installer no longer got the group's signal, so it ran on. - run_echoed and run_captured (every synchronous CLI child: upgrade, the service restart, the service checks) install SIGTERM/SIGHUP handlers while the child runs: the signal is forwarded to the child's group, then the rest killed, and pmcp exits 128+signum. Previous dispositions are restored; an inherited SIG_IGN is kept (round 15). - Every async subcommand (refresh, update, doctor, init, secrets, auth...) ends on SIGTERM/SIGHUP by cancelling its task, so each child's owner kills it; spawns in flight are drained; exit 128+signum. The gateway keeps its own shutdown. - Tests: codex's reproduction (real `pmcp upgrade`, fake uv with a grandchild, SIGHUP and SIGTERM to the parent's group), the nohup case, an async subcommand at SIGTERM. Mutants M301, M302; M256 re-anchored. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGFGW54oDcvKsVBX4atZwQ
…._dispatch (round 16; see #297) The chain moved out of async_main, which now wraps it with the subcommands' signal handling; the audit read an async_main with no branches and went blind. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGFGW54oDcvKsVBX4atZwQ
This was referenced Oct 20, 2026
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.
Implements the #297 plan (merged as 9f71141 under #314): a value pmcp rejects is described from its structure and never echoed into a response, a log line, a traceback or an audit record. See #297.
The branch merges the spike (
wip/297-code, ec8dd8e) ontomainat f9f4bd0 (3.0.0), adds the two edits the plan review required in round 29, and brings main's new code under the same rules. The CHANGELOG entry is under[Unreleased]→### Fixed, because 3.0.0 shipped without it.88 commits; squash merge recommended.
Round-29 required edits
tests/test_http_transport.py):Route,WebSocketRoute,Mountandadd_route, with the endpoint's first positional parameter as the request.scope["pmcp.*"]andscope.get("pmcp.*")count as sources.self/clsis tracked as a held attribute, collected to a fixpoint across the module.test_every_reviewed_request_callee_is_usednow searches all ofsrc/pmcp.Logger.handleis wrapped as well asmakeRecord(argument_errors._install_handle_scrubbing), so a directly built record (logging.LogRecord,logging.makeLogRecord) is scrubbed when a logger handles it.msg,argsorexc_infoobject was replaced since the last scrub, so an already-masked SDK record is not masked twice.WeakKeyDictionary, not on the record, so it never appears as anextrafield.Merge onto 3.0.0
There were 14 conflicts. Main's new readers were brought under the sink guard:
identity.py: took main's LF file and re-applied the spike's changes. Lock warnings useexception_text. The mkdir refusal readsos.strerror(errno)or the class name.manifest/loader.py: an overlay is read viaload_yaml(..., source="manifest overlay")and rendered withexception_text. Main's_server_label,_cli_labeland_rejection_reasonare kept.server.py:install_log_scrubber()runs before main'sbind_project_root. Both_manifest_forand_described_errorsare kept.tools/handlers.py: auth_connect usesstore_refusal(...)for OSError/UnicodeDecodeError andexception_textotherwise.env_store.store_refusalno longer quotes a chained value.trust_storeandpackage_approvalsrefusals use errno text or the class name.HomeInsideCheckoutErrorkeeps OSError's*argssignature and exposes.reason.auth.validate_resource_server_tokencomputesmissing_scopesfrom a set comprehension.argument_errors.validation_error_kinds:entry.invalid._EXEMPT_CALLEES:falls_back_to_pathname,store_refusal,_error_kind,_rejection_reason,_log_unusable_manifest_entry._EXEMPTtable now covers theenv_storedotenv readers._NOT_HTTP_CLIENTSaddscontextvars,ctypes,io,secrets,sysconfigandweakref.auth_connecttriage rows were updated.test_catalog_overlay_discovery: a malformed overlay now readscould not parse YAML manifest overlay at line N, column M (<Class>).sourcehit inparsing.pyis allowlisted. Thatsourceis the label pmcp gives the parsed text, not a config'ssourcefield.json-deepdownstream frame is now 100000 levels deep. Python 3.12'sjsonparses 1500 levels, so on 3.12 the frame was valid and 11 stdio cases failed their vacuity check.For the panel
MIGRATING.mdmerged automatically. Its 3.0 column now shows the new-format messages ($.<path>: <reason>). That column describes the release that already shipped, and 3.0.0 still produces the old wording, so please check whether those rows belong under a later column instead.(msg, args, exc_info). A handler that changes one of those objects in place, rather than replacing it, is not scrubbed again.makeRecordhas already scrubbed the record by then.Gates at 76355af (before round 1)
All gates were run on ai under
nicewithuv run --isolated, afteruv sync --all-extras -p 3.10:ruff check src/ tests/ruff format --check src/ tests/mypy srcscripts/check_security_claims.pynot live and not slow)Mutants: 187 in total, 182 numbered mutants plus 5 sink-guard checks. Every one applied, and every lane restored to a clean tree.
scope["pmcp.*"]read, held attributes, propagator bodies and the handle scrub. All killed.Logger.handlewrapped, no test reachedmakeRecord's own extras arm.store_refusaltest checked only the whole sentinel, which pydantic truncates.makeRecordarm. M198, M209 and M211 were rerun at 76355af and are killed in both passes.Round 1 (#388 panel): fixes, at head 482f281
claude F001: names from a checkout's files printed raw
argument_errors.plain_name: shows a name only when it matches[A-Za-z_][A-Za-z0-9_]*. Anything else readsa name that is not a plain identifier.pmcp secrets sync's "Not copying …" line, and in the refusal of a store-setHOME.tests/test_file_content_echo.py::test_a_rejected_repository_store_key_is_not_echoed_to_stderr.tests/test_file_content_echo.py:src/pmcpinterprocedurally, by function name.parse_variables, the JSON, YAML and TOML loaders, and every function that returns their output.print,sys.stderr.write,sys.stdout.write,click.echo,click.secho, and a rich console'sprint. A function whose parameter reaches one of these counts as a sink for that parameter.src/pmcpfunctions that return their input. Building a model or dataclass ends the trace: from there, D9's config-field table governs.raise. Those stay with the record scrubber, the D9 table and the exception-text guard. This guard covers the channel none of those see.printandsys.stderr.writethrough a helper are reported, and so isclick.echoof a derived source.env_store: the four messages above.pmcp doctor:.mcp.jsonserver names, header names, invalid URLs and the local config path go throughoperator_safe(escaped, then shell-quoted); variable names go throughplain_name.pmcp trust list: the approved path goes throughoperator_safe._REVIEWED, each with its reason):run_status,run_auth_connect,run_auth_acknowledgeandrun_updateprint the running gateway's own answer, the same text an MCP client receives._run_trust_listand_run_trust_list_packagesprint the operator's own stores, which are re-validated on read.codex F001: ReDoS in the SDK-rejection classifier
MAX_CLASSIFIED_LENGTH(4096) is never matched against the SDK's templates. It reads as its code's fixed phrase, or a log record is masked whole.LiteralTemplate, matched by prefix, suffix andstr.find. It is linear, and exact, because each placeholder matches anything.Regex audit at 482f281
Method.
src/pmcp, every module-level compiled pattern, and every SDK template and log pattern pmcp builds.searchmode (the harshest).Fixed, all quadratic on peer or caller text:
_downstream_errorauth.parse_url_elicitation_error,(\{.*\})searchgateway.submit_feedbacke-mail scrubFlagged in
searchmode but not reachable that way; times are as actually used:manifest.loader._PARTIAL_VERSION_WORD_REfullmatchof a config selectorvalidation._PACKAGE_VERSION_REfullmatchredaction_additive._STRADDLE_REmatch(value, 1)redaction_additive._JSON_STRING_REfinditer, only afterload_jsonaccepted the text, so no unterminated string reaches itredaction_additive._QUOTED_REfinditerover a list body_LIST_BODYalready matched as whole quoted elementsauth._SCOPE_LISTThe redactor end to end (
redact_additive,sanitize_auth_diagnostic) is linear but costly: 0.3 to 1.5 s per MiB, the worst onAuthorization:-shaped text, andsanitize_auth_diagnosticcuts after redacting. This is an observation, not changed in this round. Every other pattern takes at most 0.8 s at 1 MiB.N-1 and N-2
1 is not of type 'boolean'), and that 3.0.1 and later print the form shown.test_migration_docpasses.Logger.handlescrubs an unmasked logger's record again every time.logging.Filterer.filterscrubs again after a logger's or a handler's filters pass a record, or scrubs the record a filter returns on 3.12+.msga handler filter sets, and a replacement record.For the panel
.mcp.jsonnames: the coordinator's class lists.mcp.jsonas repository-controlled, but main's D9 ruling names.mcp.jsonentries "as before".pmcp doctortherefore escapes them withoperator_safebut does not hide them. A hyphenated server name would not passplain_name._warn_store_refusedprints each distinct line once, so two non-plain keys in one file now share one line.import pmcpnow importsmcp.typesand patches the SDK at once, which costs about 0.45 s per process. Patching on first import of each SDK module, through asys.meta_pathhook, would recover about 30 to 50 s of the suite. It is a new global hook that changes "installed onimport pmcp", so it is left for a decision.Gates at 482f281 (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff check src/ tests/ruff format --check src/ tests/mypy srcscripts/check_security_claims.pyMutants: 198 numbered mutants plus 5 sink-guard checks, each run in two passes (A with all tests, B without the static guards), over 12 lanes; every lane was restored clean.
plain_namepasses any text).printas a sink) and M220 (it ignores derived sources).CI time, measured on ai with
--covas CI runs it, 3.10, the whole suite alone on the hostGap: the head adds +274 s on ai. Using CI/ai ≈ 1.4 to 1.5 (from 76355af's CI jobs against the same pair on ai), that projects to about +6 to 7 min per CI job. The 3-minute target is not met.
Cuts made, without dropping a check:
is_sdk_loggeris memoized.Where the remaining time goes (head minus base, in seconds):
test_store_readers_confined+12;test_migration_doc+11;test_http_transport9;test_cli_project_store_startup+8;import pmcpimporting the SDK.Options (your call):
check_workflows.py's job set would need updating);Round 2 (#388 panel): fixes, at head 7c6628a
The class: repository text reaching a terminal with control sequences (grok F001, codex F001, claude N-A)
pmcp.display.display_safe:operator_safe(provision_gate), consent's_operator_safeand the installer's_operator_safe, each then shell-quoted;pmcp doctor's server, header and URL displays;exception_text's plain path (keeping\nand\t);LogRecord.getMessageandFormatter.formatExceptionare wrapped, keeping line breaks and tabs. A value past any walk still cannot reach a terminal raw.plain_nameneeded no change: it already returns only[A-Za-z_][A-Za-z0-9_]*or a fixed phrase.operator_safe, so U+009B reads\x9b.doctor.pycatchesurlparseand.portfailures at the parse site and reports the URL as invalid.auth.protected_resource_metadata_urlsnow catches too.exception_texttreats an error raised by a text-parsing module (urllib.parse,ipaddress,idna,shlex,_strptime,email,http.cookies) as value-bearing: it readscould not parse the text (<Class>).from Nonekeeps its own message, because no traceback prints a suppressed context. A test pins the cause, context and suppressed shapes.warnings.warnandraisearguments, as its docstring says, and a synthetic-tree unit pins each. It found:.mcp.json"Skipping server" WARNING (the name is escaped);allowPrivateRegistry(named by type:got a string);decision,registry,nameandversionfields (by type);parse_package_spec(no longer echoes the spec)._REVIEWED:_read_config_objecterror halves;methodonly after it matched one of three constants);_EntryRejected(it names schema keys only);_server_label,_cli_label,_rejection_reason,shipped_env_var,validation_error_kinds) count as sanitizers.Claude N-B and the nit
<code's phrase> (the server's message was not shown: over 4096 characters).GatewayServer.initializebuilds the SDK message registries in a daemon thread, off the first error's path.Renderer and parse-site audit
Renderers:
display_safeoperator_safe, consent's and installer's_operator_safedisplay_safe, thenshlex.quoteplain_namedescribe_valueexception_textdisplay_safedisplay_safewith\n\tkepttests/test_display_safe.pyruns every renderer against every character of C0, DEL, C1, bidi, zero-width and separators, and checks plain text is unchanged.Parse sites (
urlparse,urlsplit,.port,ipaddress,idna,parse_qsl,shlex,fromisoformat):ValueErrorand returned fail-closed:auth._url_field,redact_auth_url,is_verified_public_auth_url,sanitize_public_auth_url(from None),registry._is_safe_private_endpoint,transport.http._origin_host_portand_is_loopback_host, andscoped_advisor_audit.doctor's url andprotected_resource_metadata_urls.--oauth-*flags), now backstopped by the funnel:cli._get_gateway_health_url, the auth-metadata parses intransport/http.py, andregistry._with_query.parsing.parse_timestampraisesTimestampParseError.Tests:
urlparse, port,ipaddress,idnaandshlexerrors through the funnel;Gates at 66efa59 (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff check src/ tests/ruff format --check src/ tests/mypy srccheck_security_claims7c6628a adds only the
protected_resource_metadata_urlsparse-site catch and its test. On it: ruff, format and mypy are clean, and the display, auth, file-content and exception-text modules pass (374 on 3.10 and on 3.12).Mutants: 208 numbered mutants plus 5 sink-guard checks, two passes over 12 lanes; every lane was restored clean.
exception_textnot escaped.allowPrivateRegistryback to%r.CI time: round 2 adds 91 targeted tests (
tests/test_display_safe.py, under 1 s in total). The full suite at 66efa59 ran in 1180 s on ai, against 1160 s at 482f281, so the round-1 timing figures still hold.Round 3 (#388 panel): fixes, at head fbd8edc
One choke point for human output (grok F001, codex F001)
cli.mainwrites stderr, from its first line, throughpmcp.display.DisplaySafeStream. After argument parsing it does the same for a subcommand's stdout. Every string goes throughdisplay_safe, keeping\nand\t. Bytes written to.bufferpass through unchanged.cli.human_output(args)leaves untouched:--jsonoutput;pmcp secrets, whose stdout is always ASCII-escapedjson.dumps.display_safe,plain_name,operator_safe) for their wording; the wrapper is the guarantee.pmcp statuslocal fallback (cli.py:1612) andpmcp config status;pmcp logs, plain and follow, reading a checkout's.pmcp/logs/gateway.log;run_config;pmcp auth connect's URL, Elicitation ID and Next step lines.main()keeps its body inline, wrapped intry/finallyso the original streams are restored.test_env_leak_229readsmain's own calls.Auth URLs refuse every non-printable character (grok F001 / claude round-3 N-A)
sanitize_public_auth_urlrefuses any character Python does not count printable, not only C0 and DEL. That covers C1, bidi, zero-width, tag and other format characters, line and paragraph separators, and non-ASCII spaces. The JWKS, metadata and both elicitation-URL provenances all go through it.test_auth_public_url_rule's "control character" claim now matches the rule, so message-truth stays exact. The soft-hyphen host is now a control-character refusal, and a new sweep covers C1, bidi, zero-width, separators, BOM and tags at every position.Subcommands and how each is covered (
tests/test_cli_human_output.py)The subcommand list is derived from the real parser, and a test fails if one is added without coverage.
printto stdout,printto stderr, andsys.stdout.write. The test checks thatmaindelivers all of it escaped and restores the streams. The subcommands are:refresh,update,status,logs,init,capabilities,setup;config status,config startup-policy,config set-startup-policy;guidance,doctor,upgrade;secrets set,secrets sync,secrets check(stderr; stdout is JSON);auth connect,auth acknowledge;trust approve,trust list,trust revoke,trust approve-package,trust list-packages,trust revoke-package.--json(update, status, capabilities, the three config commands, auth connect, auth acknowledge): stdout is exactly what the command wrote.writeand.buffer, and is never the wrapper.statuslocal fallback andconfig status, from an approved.mcp.json;auth connect, from a mocked gateway answer carrying a peer's elicitation;logs, plain and follow, from.pmcp/logs/gateway.log;doctor, from round 2's URL grid.sanitize_public_auth_url; both elicitation provenances; and peer elicitations with a non-printable id or URL.cli.pyrows now also cite the wrapper, which the subcommand sweep checks for those commands.Gates at fbd8edc (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff check src/ tests/ruff format --check src/ tests/mypy srccheck_security_claimsMutants: the full table ran at c421861: 214 numbered mutants plus 5 sink-guard checks, two passes over 12 lanes, every lane restored clean.
--jsonoutput wrapped too.CI time, on ai, the whole suite run alone, with
--cov, on 3.10:Round 3 adds +7 s of wall time and +3 s of summed durations. The 38-minute 3.10 job on 7c6628a looks like runner variance: ai shows no comparable change between 482f281 and 7c6628a.
Round 4 (#388 panel): fixes, at head de9d742
No child process inherits the operator's terminal (grok F001, claude N-B)
pmcp.child_output.drained_errlog(label, logger)gives a child's stderr the write end of a pipe. A thread reads it to the end, as the child writes, and logs each line at DEBUG as[<label>] stderr: …, escaped, asClientManagerdoes for its own children.refresh_serverpasses it to the SDK'sstdio_client. This covers bothpmcp refreshand the gateway's first-start cache generation (refresh_all).run_echoed(command)runspmcp upgrade's installer and the service restart (systemctl, launchctl) with stdout and stderr piped together and stdin set toDEVNULL. Each line is echoed escaped when the child is done; these are short commands. It takes its environment fromchild_process_env(), so the spawn-environment inventory still sees a builder.DisplaySafeStream.filenoraisesio.UnsupportedOperation. Nothing insrcreads the wrapped streams'fileno, and a future inherit-by-descriptor fails loudly.Spawn audit (
src/pmcp)child_output.run_echoed(upgrade, service restart)cli._is_pmcp_system_service_active,_restart_local_pmcp_service's probecapture_output=Trueclient/manager._connect_stdiomanifest/environment.check_cli,get_cli_helpmanifest/installer.start_install,install_server,verify_installationmanifest/npm_resolver._spawnmanifest/refresher.refresh_server(stdio_client)errlog=drained_errlog(...)tools/handlers._run_update_probe_commandThere are no
os.system,os.exec*,os.popenorptycalls. The guard istests/test_child_output.py::test_no_spawn_lets_a_child_write_to_pmcps_streams: everysubprocess.*,asyncio.create_subprocess_*,anyio.open_process/run_process,os.system,os.exec*andpty.spawncall must pipe or discard both streams (or usecapture_output=True), and everystdio_clientmust take a non-terminalerrlog. Its allowlist is empty, and a unit test pins each shape.Claude N-A and the nit
main()catches every exception around its whole body: startup loading, argument parsing, the project choice,secrets set's value and the command. It printsFatal error: …while stderr is still the escaping stream, then restores.SystemExitandKeyboardInterruptbehave as before.Public auth URL contains a non-printable character.MIGRATING notes that 3.0.0 refused only C0 controls and DEL, without quoting a message pmcp no longer produces.Tests (
tests/test_child_output.py, real processes, one per spawn class)Each child writes ESC with a CSI clear, U+009B and RLO to both its streams, and the test reads the parent's own stdout and stderr bytes.
cli.mainrunningpmcp refresh, in a fresh process, against a small MCP stdio server from an exported overlay. The child's line is logged and escaped.GatewayServer.initializewith the server auto-started, which reachesrefresh_allthrough "Auto-generating descriptions cache".pmcp upgrade --method uvwith a fakeuvonPATH; the output is echoed escaped.systemctl.fileno()raises, and N-A (an exception fromserve_projectwith ESC in its message is printed escaped, exit 1).The module takes 3.7 s.
Gates at 203180e (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff check src/ tests/ruff format --check src/ tests/mypy srccheck_security_claimstest_workflow_guards.py::TestMutationHelperContract:booted_gatewayasserts the operator's live gateway on ai's:3344keeps its pid, and that gateway was restarted during the run (it now has a new pid). That module and the frame-sweep module pass on a rerun (302 passed).@pytest.mark.asynciomarker back onto the async frame sweep, from the sync rotation test it had landed on (it triggered a PytestWarning). The full-suite profile at 203180e ran with no failures and no errors.Mutants: the full table ran at 87d6934: 218 numbered mutants plus 5 sink-guard checks, two passes over 12 lanes. Lane 1 stopped at a stale M98 anchor and was re-run (24 mutants, both passes); every lane was restored clean.
errlogback tosys.stderr.filenoforwarded again.run_echoedinheriting the terminal.mainunrendered.withblock: M98 (the refresh unbounded), killed.CI time, on ai, the whole suite run alone, with
--cov, on 3.10:Round 4 adds +7 s, of which
test_child_outputis 3.7 s.Round 5 (#388 panel): fixes, at head 40174a0
Every child is detached from the terminal (grok and codex F001)
child_output.detached():start_new_session=Trueon POSIX, so the child has no controlling terminal and/dev/ttyfails to open;CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOWon Windows;DEVNULL, or a pipe pmcp owns;ClientManager's spawn and the update probe used a literalstart_new_session=True; they now go through the same helper, which produces the same keyword on POSIX, so the process-group reaping is unchanged.Per-spawn audit
manifest/environment.check_cli(overlaycheck_command: catalog_search, request_capability, sync_environment)manifest/environment.get_cli_helpmanifest/installer.JobManager.start_install(gateway.provision)manifest/installer.install_servermanifest/installer.verify_installationtools/handlers._run_update_probe_command(gateway.update_server)child_output.run_echoed(upgrade, service restart)manifest/npm_resolver._spawncli._is_pmcp_system_service_active,_restart_local_pmcp_serviceprobeclient/manager._connect_stdio(gateway servers)manifest/refresher.refresh_server(SDKstdio_client)start_new_session=True)drained_errlogpipetest_no_spawn_lets_a_child_write_to_pmcps_streams) requires all three properties at everysubprocess.*,asyncio.create_subprocess_*andanyio.open_processcall:start_new_session=Trueor**detached(), stdinPIPEorDEVNULL, and stdout/stderr piped or discarded. It refusesos.system,os.popen,os.exec*andpty.spawnoutright. A unit test pins each shape.stdio_clientis pinned from the installed source: itsanyio.open_processpassesstart_new_session=True, and its stdin is anyio's default pipe.Real-pty tests (
test_a_child_reaches_neither_the_controlling_terminal_nor_stdin)/dev/ttyand writes ESC, U+009B and RLO, checks whether fd 0 is a tty, and writes to fd 0. It records what it found in a marker file.check_cli,JobManager.start_install,run_echoed, the update probe and aClientManagerstdio spawn.Claude N-A and N-B (the stderr drain)
drained_errlogis now an async context manager. On exit it waits for the drain thread throughanyio.to_thread.run_sync, off the event loop, for at most 5 s. After that the daemon thread is left to end on its own, once the last write end (the grandchild's) closes. A test holds a grandchild's copy of the write end open and checks that a concurrent ticker on the loop never stalls (max gap under 0.5 s), and that lines are logged escaped.bounded_linesreads in 64 KiB chunks and caps each line at 64 KiB, appending a value-free(truncated)and dropping the rest of the line. It is used by the drain and byrun_echoed. A test sends 32 MiB with no newline and gets one capped line, the next line intact, and a traced peak under 2 MiB (it was 223 MiB). A second test checks lines split across chunks.Gates at 40174a0 (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff check src/ tests/ruff format --check src/ tests/mypy srccheck_security_claimsMutants: the full table ran at 40174a0: 225 numbered mutants plus 5 sink-guard checks, two passes over 12 lanes, all completed, every lane restored clean.
check_clinot detached.detached()returning nothing on POSIX.CI time: see the next comment for the solo
--durationscomparison against de9d742.Round 6 (#388 panel): fixes, at head aa3f459
Codex F001: the drain releases its thread and pipe at the deadline
selectorsselector.sleep) that keeps the write end open past the context. Afterwards there is nopmcp-errlog-*thread and no fd beyond the baseline (/proc/self/fd), while the descendants are still alive.selectcannot wait on pipes there, and the drain is POSIX-tested only (the selector is the default one).Claude N-A: Ctrl-C during
pmcp upgradeor the service restartrun_echoednow spawns withPopen. OnKeyboardInterruptit callsinterrupt_tree: SIGINT to the child's process group (CTRL_BREAK_EVENTto its group on Windows), up to 5 s to exit, then SIGKILL to whatever is left of the group, and then it re-raises.run_echoedruns a child that traps SIGINT and has started a grandchild. The trap runs, and the grandchild is gone.Claude N-B: children are killed with their groups
child_output.kill_treesignals a detached child's whole process group on POSIX, sincedetached()makes its pgid its pid; elsewhere it signals the child alone. It replaces leader-only kills in:JobManager's crash cleanup,_safe_terminate_process(terminate and kill),cancel_joband the handoff failure;install_server's timeout;_terminate;check_cliandget_cli_help, which now kill on timeout or cancellation. Before, a timed-out probe was left running.ClientManageralready killed their groups through_kill_process_tree_now(runtime: handshake-failure teardown swallows the caller's cancellation for one event-loop step #324), and that is unchanged.install_serverthat times out leaves no grandchild.Claude N-C: the spawn backstop
import subprocess as sp,from subprocess import run as r,from asyncio import create_subprocess_exec,asyncio.subprocess.create_subprocess_execandloop.subprocess_exec/subprocess_shell, and it refusesos.spawn*,os.posix_spawnandos.fork. Unit shapes pin each form.tests/conftest.py): onesys.addaudithookfor the whole test session.subprocess.Popenraised under a frame of pmcp's own source. FromPopen._execute_child's resolved arguments, it must have a new session (start_new_session, or aprocess_groupother than the 3.11+ default), stdin not fd 0 or inherited, and stdout and stderr not fds 1 or 2 or inherited.os.fork,forkpty,exec,posix_spawn,spawnandsystemfrom pmcp code.getattr, compiled with a filename insidesrc/pmcp, is caught with all four problems; a detached one passes.CI on a3855bc failed, fixed in 7c074a1 and aa3f459
kill_treecalledos.killpg(pid)for any object with an integerpid. A test double'spid = 4321(test_manifest's_FakeProc, cleaned up by theJobManagersingleton) named a real process group on the runner, and the group SIGKILL ended the test run.subprocess.Popenor asyncioProcess, and only while that child is unreaped and leads its group (os.getpgid(pid) == pid). Anything else gets its ownkill/terminate.interrupt_treewaits for the leader withwaitid(WNOWAIT), so the leader's pid, and the group id it leads, stay reserved until the group SIGKILL is sent. It reaps afterwards. This follows runtime: handshake-failure teardown swallows the caller's cancellation for one event-loop step #324's no-reuse rule.pidis a real group leader gets nokillpg, and a reaped child whose pid is "reused" gets none either. The mutants for each are M259 and M260.src/pmcp, which coverage then could not find. The hook now takes extra probe roots, and the probe uses a temporary one.Gates at aa3f459 (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff check src/ tests/ruff format --check src/ tests/mypy srccheck_security_claimsprocess_group=-1default.selectorswas not yet classified as a non-HTTP import.Mutants at aa3f459: 231 numbered mutants plus 5 sink-guard checks, two passes over 12 lanes, all completed, every lane restored clean.
kill_treesignalling the group of any object with a pid.Round 7 (#388 panel): fixes, at head 747d74c
Codex F001: cancellation no longer skips the drain cleanup
drained_errlogcloses the stop pipe's write end in an innerfinally, so it always runs, cancellation included. Both joins run off the loop under a shielded cancel scope.CancelledErroris injected in turn into the body, the first join and the join after the stop, with a descendant holding the pipe open. Afterwards there is nopmcp-errlog-*thread and no fd beyond the baseline, and the descendant is still alive. The mutant is M261.Codex F002 = grok F001: a timed-out probe whose leader exited first
check_cli,get_cli_help,install_server's timeout handler andJobManager._safe_terminate_process, which is_monitor_install's stall and crash path, all skipped the kill oncereturncodewas set, so the grandchild kept running.child_output.kill_tree, whether or not the leader has exited.kill_treegoes through the one kill rule below. The pid-reuse protection is kept: it isClientManager._kill_retained_group's logic, moved intochild_output.signal_retained_groupand shared.check_cli,get_cli_help,install_serverand a stalled install job, a probe whose leader starts a grandchild and exits at once. In each case no grandchild survives. If one does, the test ends it, after confirming from its cmdline that the pid is still ours. The stalled-install test pollsreturncoderather than awaitingwait(), which on 3.11+ waits for every pipe to close. The mutant is M262.The one kill rule, and the kill-site audit
returncodeis unset andwaitid(WNOWAIT)still finds the leader as our child (claude N-B). The group is signalled before the leader, so asyncio's watcher thread cannot reap the leader in between. Once the leader has been reaped, the group is reached only throughsignal_retained_group, which acts only if a member remains and no process holds the leader's pid.src/pmcp, and the rule each one now goes through:environment.check_cli,get_cli_help(timeout, cancel, error)kill_treeinstaller.install_servertimeoutkill_treeJobManager._safe_terminate_process(terminate, kill, exited leader), crash cleanup,cancel_job, handoff failurekill_treenpm_resolver._terminatekill_treehandlersupdate-probe handoff failurekill_treehandlersupdate probe on cancel_kill_process_tree_now, which iskill_process_tree_nowhandlersupdate probe on timeout;ClientManagerteardown_terminate_process_tree: entry and both_signalarms checkleader_reserved, and after reaping onlysignal_retained_groupis used. On Windows both arms terminate the job.ClientManagercancel and shutdown_kill_process_tree_nowrun_echoedCtrl-Cinterrupt_tree: group SIGINT and SIGKILL only while reserved, otherwisekill_tree. On Windows, a short wait and then the job.server.pyorphan sweep/proccmdline fingerprint, and that pid is not a child pmcp spawned. It signals no group.returncodeby a rawwaitpidis not signalled as a group, and a teardown of a process whosereturncodeis set sends no direct group signal and does not signal the pid. The mutants are M260 and M266.kill_process_tree_nownow suppresses any exception from the leader signal. An object withoutsend_signalused to raiseAttributeError, so M259 died on that crash; it now dies on its own assertion.Claude N-A: no console control events on Windows
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, throughtrack()after every spawn. Jobs of children that have exited are closed, which kills anything left in them.TerminateJobObject.kernel32seam records every call.trackcreates and assigns a job, interrupt and kill callTerminateJobObject, and neitherGenerateConsoleCtrlEventnorsend_signalis ever called. A failed assignment falls back to the child.ClientManager's teardown terminates the job even when the leader exits within the wait. The mutants are M264 and M267.Codex F003: the Windows drain
selectthere accepts a pipe's registration and fails only when it waits, so the choice is made by platform and not by catching a failure._windows()true, and a selector that registers but rejects the wait) drains every line and ends at EOF. Its bounded leak ends when the descendant does. The mutant is M263.Claude nit: the last bytes
Gates at 747d74c (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff check src/ tests/passes,ruff format --checkpasses (224 files),mypy srcfinds no issues (62 files), andcheck_security_claimsexits 0 with 132 cited node ids.test_the_drain_waits_off_the_event_loopassert groups == []waitpidtestfinallycheck_clileader-exits-first testselectGenerateConsoleCtrlEventreinstatedreturncodeguard droppedProcess.wait()waits for the pipes, was found and removed in 8b0190e. With it removed, the targeted set is again about 4 minutes on every Python.Round 8 (#388 panel): fixes, at head 4c681b5
Codex F001 = grok F001:
import pmcpfailed on Windowssignal.SIGKILLsat in two default arguments inchild_output, and those are evaluated at import.None, resolved at call time through_sigkill().test_no_posix_only_name_is_evaluated_when_pmcp_is_importedwalks every module and fails on any POSIX-onlysignal/osname evaluated at import, whether in a module or class body, a default argument or a decorator. A self-test pins the guard. The mutant is M268.windows-smokejob imports the package on Windows (see below).Codex F002 = gemini F001 = grok F001: Windows teardown skipped the Job Object
_terminate_process_treetakes the Windows branch first. It terminates the server's job and releases its handle whether or not the leader is still alive, then waits up to 5 s. The unreachable round-7 Windows arm inside_signalis removed.test_windows_teardown_terminates_job_when_leader_already_exited, uses the kernel32 seam. On a real Windows host, a leader that exited first has its grandchild ended by teardown. The mutants are M269 and M267.Codex F003: every spawn site's exits are owned
child_output.owned(process)is a scope whosefinallycallskill_treesynchronously on every exit: return, error, timeout or cancellation. On the success path it probes the group and kills anything the child left running.child_output.run_capturedis the owned version ofsubprocess.runfor short probes.Spawn-exit audit:
environment.check_cli,get_cli_helpownedownedownedownedinstaller.install_serverownedowned, then reapedownedowned(was missing)installer.verify_installationownedowned(was missing: returned False and left the command running)ownedownedJobManager.start_install(spawn, then handed to the monitor)finallycallskill_treeon every exit except the handoff._safe_terminate_processkill_tree_safe_terminate_process, then thefinally;cancel_job:kill_treeJobManagerhandoff toClientManager.adopt_processkill_treeClientManager._connect_stdio,adopt_process(servers)_terminate_process_tree_terminate_process_tree_terminate_process_tree_kill_process_tree_now, synchronously (#324)GatewayTools._run_update_probe_commandfinally:_kill_process_tree_now_terminate_process_tree, then thefinallyfinallyfinally(replaces the old handler)npm_resolver._spawn(one helper per process)_terminate(kill_tree)_terminateon every failurerun_echoed(pmcp upgrade, service restart)ownedownedinterrupt_tree, thenownedrun_captured(the CLI'ssystemctlchecks; they weresubprocess.run)ownedownedownedownedrefresher.refresh_server(the MCP SDK'sstdio_client)drained_errloginstall_serverleaves nothing running. The round-7 leader-exits-first tests still pass. The mutants are M270 and M273.Codex F004: the Windows drain's lifetime is pmcp's
_PollingReaderreads only whatPeekNamedPipereports as ready, so it never blocks inReadFile, and between looks it checks a stop event thatdrained_errlogsets at its deadline._POLL_SECONDS(0.05 s). The descendant holding the write end makes no difference. Both joins are back on Windows.PeekNamedPipe), the drain ends at its deadline while a descendant holds the pipe, and the descendant is still alive;Claude N-A: a retained pgid is forgotten once its group is empty
child_outputkeeps trusted group ids. Each id is tied, by a weak reference, to the process object that leads it, and is trusted from the spawn (track()) or a verified adoption (_own_group_pgid).signal_retained_group,observe_group, or the teardown's own liveness probe.watch_groupis a background task for every stdio server, spawned or adopted. It waits for the leader, then probes every 0.5 s until the first empty probe.watch_grouponce empty, is never signalled again, even when a foreign group with that id passes the reuse check;Windows CI
windows-smoke, in its own workflow.github/workflows/windows.yml, runs onwindows-latestwith a 20-minute timeout. It runsuv sync --all-extras, imports pmcp, runspmcp --versionandpmcp --help, and runstests/test_child_output.py.check_workflows.pyandtest_workflow_guards.pypass; the pinned-uses count went from 32 to 34, the same two pinned actions.signalmethod needs SIGALRM, so this job runs with-o timeout_method=thread;USERPROFILE, which is what Windows'Path.home()reads;PeekNamedPipedrain reading every line and stopping at its deadline with the writer open;Follow-ups found while verifying
getpgidlookup, a passed group id goes throughsignal_retained_groupinstead of being dropped..github/workflows/windows.yml. The claim checker holdstest.ymlto exactly one pytest invocation, and a second one intest.ymlbroke R22.check_workflows.py --base-ref origin/mainpasses, with drift reporting onlywindows.yml.Gates at 4c681b5 (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff checkandruff format --checkpass onsrc/andtests/,mypy srcfinds no issues, andcheck_security_claimsexits 0 with 132 cited node ids.255 == -SIGKILL) did not recur.windows.yml, frompull_request):import pmcpand the entry point pass (pmcp 3.0.0), and 22 tests pass with 29 POSIX-only ones skipped, in 36 s.windows-smokeamong them. The Linux test jobs took 39m38s (3.10), 38m24s (3.11) and 21m25s (3.12). The 3.10 job took 39m48s at aa3f459 and 25m31s at 747d74c, so the spread is runner variance; the suite time measured on ai is flat.Round 9 (#388 panel): fixes, at head 910bed0
The class rule. No thread, fd, handle or process pmcp creates outlives its owner by more than a fixed bound, whatever the downstream does: keep a pipe open, write without pause, exit at once, or fork a grandchild.
tests/test_child_lifetime.pypins it in one table: every in-process spawn site against each hostile behaviour (hold the pipe, exit early, flood), 30 cases. After the owner's teardown plus 10 s, no process the child or its descendants started remains, no drain thread remains, and the fd count is back at its baseline. The spawn sites are:check_cliandget_cli_help;install_server, timed out and cancelled;verify_installation;ClientManagerserver;The table is POSIX-only. The Windows paths have their own seam tests, and run for real in
windows.yml.Codex F001: continuous output beat the Windows drain's stop
_PollingReaderchecks the stop event first on every iteration, whether or not data is available. Once stopped, it does at most one final read of up to_FINAL_READ_BUDGET(64 KiB), then ends./dev/zero, always reported ready) ends after one bounded read. The table's flood case runs against the Windows reader. The mutant is M274.Codex F002: the group watcher could stall
watch_groupawaitedprocess.wait(), which on 3.11+ also waits for the pipes to close.killpg(pgid, 0)) every 0.5 s until the first empty probe, and never waits on the process or its pipes.ManagedClient.group_watchtask, cancelled in both per-client cleanups, the graceful one (_cleanup_client_io, used on reconnect) and the abandoning one.wait()that never returns does not hold the watcher, on every Python;Codex F003: the retained group was dropped in a reaping race
kill_treekeeps the spawn contract's group id when the leader is reaped betweenleader_reserved()andgetpgid(). The retained-group checks decide: the id must still be trusted for this process and the group must not be empty.Grok F001: an exited install leader left its grandchild
_finalize_server_ready), and its group is now killed. Nothing will adopt it.cancel_job: it now kills an exited leader's group too.server_readyserver is owned by its job untilgateway.provision_statusadopts it. If it is never adopted, it is killed with its group afterHANDOFF_DEADLINE_SECONDS(600 s); adoption cancels the deadline. The ClientManager is the only intended owner of a handed-off server, and nothing pmcp starts is meant to outlive pmcp.verify_installation: it now requires the verification command to exit 0. It was returning True regardless.test_exited_install_leader_does_not_leave_its_grandchild; an unadopted server killed at the deadline; and a failing verification returning False.Claude N-B
windows.ymlruns withpermissions: contents: read. Claude's orphan-scan finding is tracked separately in Gateway startup orphan scan SIGKILLs same-user processes it never started #389.Gates at 910bed0 (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff checkandruff format --checkpass onsrc/andtests/,mypy srcfinds no issues, andcheck_security_claimsexits 0 with 132 cited node ids.tests/test_child_lifetime.py): 1985 passed and 5 skipped on 3.10 and on 3.11; 1986 passed and 4 skipped on 3.12.windows-smoketook 47 s. The Linux test jobs took 39m25s (3.10), 38m10s (3.11) and 33m22s (3.12); the 3.10 job took 39m38s at 4c681b5.Round 10 (#388 panel): fixes, at head 9a7a430
Claude F001 = grok F001: the handoff deadline could kill a server being adopted
_finalize_server_readycancels the job's handoff deadline, moves the job toadoptingand dropsjob.process. From then on the job no longer owns the process, ClientManager does, and its own timeouts govern the handshake.adoptingis internal:gateway.provision_statusstill reports it asserver_ready, so the tool's output schema doesn't change.test_the_handoff_deadline_never_kills_a_server_being_adopted: a 0.3 s deadline, initialize held 1 s, and the server stays alive and registered;Codex F001: a dropped
Processleft its pipe transports openchild_output.release()closes a child's pipes at once: asyncio's pipe transports, including one a flooding downstream left paused and unread, or aPopen's streams.owned(), handoff expiry,cancel_job, the install monitor's exits and its crash callback;adopt_process's cancel-before-lock;_terminate_process_tree's whole body runs in atry/finally(its name, which the static cancellation guards key on, is kept), and_kill_process_tree_nowreleases after its kill.Gemini
cancel_job, not only on success. The mutant is M283.Claude's coverage list: new rows in
tests/test_child_lifetime.pyserver_readyhandoff: deadline expiry, adoption success, adoption failure, and adoption straddling the deadline.run_captured: a timeout and Ctrl-C.run_echoed: Ctrl-C, sent to a real process.pmcp refresh. The refresher now captures the SDK's child for each task (a context variable, through the SDK's process factory). After the SDK's own teardown, whatever is left of the child's group is killed through the retained-group rule.Found along the way
platform.platform()runsuname -p, a child with pmcp's stdin that is not detached. The report line now usesplatform.system(),release()andmachine().Found while verifying: Ctrl-C could not stop
pmcp upgradeagainst a child that floodsrun_echoed (Ctrl-C)-floodrow timed out under mutant load, once in 36 runs. With faulthandler, the harness's stack showed it insidecommunicate().DEVNULLand stderr folded into stdout,communicate()takes its single-pipe fast path,stdout.read(). That read loops in C and never returns to the interpreter while the pipe stays readable, so a child writing without pause madepmcp upgradeimmune to Ctrl-C and held all its output in memory.run_echoednow streams throughbounded_lines, oneread1syscall per chunk, echoing each line as it arrives. Memory stays at one capped line.test_ctrl_c_reaches_run_echoed_while_its_child_floods. Withcommunicate()restored (M287), it hangs.run_echoedandrun_capturedalso take ownership of their child from the momentPopen()returns.provision_statuspoll that readsadoptingwaits on the finalize lock and reports the handoff's result. This was found by the full suite'stest_concurrent_polls_adopt_once.Gates at 9a7a430 (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff checkandruff format --checkpass onsrc/andtests/,mypy srcfinds no issues, andcheck_security_claimsexits 0 with 132 cited node ids.tests/test_child_lifetime.py): 2018 passed and 5 skipped on 3.10 and on 3.11; 2019 passed and 4 skipped on 3.12.windows-smoketook 37 s. The Linux test jobs took 41m15s (3.10), 25m22s (3.11) and 27m26s (3.12). The 3.10 job took 39m25s at 910bed0; that difference is runner variance, since the suite time measured on ai is flat.Round 11 (#388 panel): fixes, at head c9b42b5
Codex F001: a cancelled
cancel_jobleft the server runningcancel_jobnow sends SIGTERM and waits up to 2 s, inside atry. The kill, closing the pipes and the status update all happen in itsfinally, which runs onCancelledError. A caller can cancel the graceful wait, but not the kill. The handoff deadline is disarmed only after the kill it backs up. The handoff's cancel arm also kills synchronously; after ClientManager's own cancellation kills, it only probes an empty group.server_readyserver, whose caller is cancelled during the graceful wait, is killed, its pipes are closed, and its status isfailed;finally).The class rule: a cancelled teardown never loses its kill
cancel_jobwas the only one with a kill behind a cancellable await:_safe_terminate_process's wait: the monitor'sfinallykills synchronously;install_server's post-timeout reap:owned()has already killed;_terminate_process_tree: its cancellation arm kills and itsfinallyreleases the pipes;_cleanup_client: it abandons, which kills synchronously;owned()andrun_captured: their kill is synchronous in afinally;finally;canceldimension intests/test_child_lifetime.py: a downstream that ignores SIGTERM, so every graceful wait really waits, with a grandchild holding the pipes. Everybounded_waiton a process's exit inside a teardown is cancelled instead of awaited.install_server(timeout and cancelled), the update probe, a ClientManager server, a cancelled install job, and the three adoption handoffs.install job (cancelled)row.Claude N-A: a closed stdout killed the installer mid-upgrade
BrokenPipeError,run_echoedstops echoing, keeps draining and discarding until the child exits on its own, and returns the child's exit status. Ctrl-C keeps its own path.pmcp upgrade | head -1simulated. The installer runs to its end,rc=7is returned, and no traceback is printed.Claude (pre-existing): adoption overwrote a connected client of the same name
_adopt_process_lockedassignedself._clients[name]over an existing client, leaving its process running with no owner (e.g. a lazy connect of the same server during a long install). It now tears the old client down first. The fix was cheap, since the lifecycle lock is already held.Claude N-B and its orphan-scan note
windows.ymlalready haspermissions: contents: read(round 9). The orphan-scan finding remains Gateway startup orphan scan SIGKILLs same-user processes it never started #389.Gates at c9b42b5 (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff checkandruff format --checkpass onsrc/andtests/,mypy srcfinds no issues, andcheck_security_claimsexits 0 with 132 cited node ids.canceldimension's rows for owners with no teardown await.canceldimension'sinstall job (cancelled)row, the closed-stdout test and the same-name adoption test.canceldimension adds 5.9 s.windows-smoketook 40 s. The Linux test jobs took 41m39s (3.10), 28m7s (3.11) and 35m14s (3.12); the 3.10 job took 41m15s at 9a7a430.Round 12 (#388 panel): fixes, at head def8722
Codex F001: a same-name adoption could register after terminal shutdown
_abandonedcheck and its registration. Adisconnect_allcancelled while waiting on the lifecycle lock runsabandon_all_now(), and adoption then registered the replacement after the sweep.ClientManager._register_or_kill()is now the only place a client is registered. It checks_abandonedwith no await between the check and the registration. If shutdown has run, it kills the new process with its group, closes its pipes and raises_ManagerAbandoned. The handoff then readsfailed.Registration-site audit. Every path that registers a client, and the await it registers after:
connect_all,connect_server,refresh, lazy start, reconnect (_reconnect_loop→_connect_singleflight)_connect_server→_connect_stdiocreate_subprocess_exec, the spawn semaphore)_connect_server→ the remote connectawait ready(the transport owner); a refusal unwinds the owner in its existingexcept BaseExceptionarmadopt_process)_adopt_process_locked_register_or_killas the only assignment intoClientManager._clients._connect_server's and adoption's existing up-front checks still refuse early.abandon_all_now()finds the client in_clientsand kills it.Tests:
disconnect_allis cancelled while adoption holds the lock to tear down a SIGTERM-ignoring same-name client. The replacement is killed, the handoff and the job readfailed, and no client is registered._ManagerAbandonedis raised, and no client is registered.Mutant: M292 (no abandon check at registration). It is killed by codex's interleaving test, which reads
completeinstead offailed, and by the connect test.Claude's notes
cancel_jobtakes the process from the job before its first await, as the handoff does. Tested.pmcp upgrade 2>&1 | headkeeps the installer's exit status. When stderr is the same broken pipe as stdout, it is pointed at the null device too. Tested: exit status 7 from the installer.Found while verifying
_register_or_kill, so the helper calls_remove_server_indexes(name)the instant before it registers. That is harmless: every caller already dropped the records before its awaits, and nothing is indexed for the new connection yet. The guard's expected sets now name the single site.Gates at def8722 (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff checkandruff format --checkpass onsrc/andtests/,mypy srcfinds no issues, andcheck_security_claimsexits 0 with 132 cited node ids.windows-smoketook 41 s. The Linux test jobs took 40m36s (3.10), 33m54s (3.11) and 30m48s (3.12); the 3.10 job took 41m39s at c9b42b5.Round 13 (#388 panel): fixes, at head c1b294c
Grok F001 = codex F001: a spawn cancelled mid-creation leaked what the child forked
create_subprocess_execawaits its pipe connections. A cancellation there, from a shutdown or a cancelled request, made asyncio kill the leader's pid alone. A grandchild forked meanwhile survived, untracked, with no Job Object on Windows.child_output.spawn_owned(start), runs every async spawn underasyncio.shield, so a caller's cancellation never reaches the spawn.track()ed (its Job Object on Windows, its group trusted on POSIX), killed with its group, and its pipes are closed. Then the cancellation propagates.create_subprocess_execandcreate_subprocess_shellcall insrc/pmcpto be the factory of aspawn_owned.Spawn sites:
ClientManager._connect_stdio(connect, reconnect, refresh, lazy start)spawn_ownedJobManager.start_installspawn_ownedinstall_serverspawn_ownedverify_installationspawn_ownedcheck_cli,get_cli_helpspawn_ownedGatewayTools._run_update_probe_commandspawn_ownedrefresh_server(the MCP SDK'sstdio_client, through its process factory, which the refresher captures)spawn_owned. The SDK's anyioProcessis unwrapped to its asyncio child, so the group is killed while the leader still reserves its id. Before the unwrap, a just-killed, unreaped leader made the group look reused, and a grandchild survived.run_echoed,run_captured, the npm resolver (subprocess.Popen)Popenreturning and ownership. The two runners areowned()from the momentPopenreturns.spawn-canceldimension, for every async spawner:check_cli,get_cli_help,install_server,verify_installation, the update probe, a ClientManager server, an install job, and the refresh child. It patchesloop.subprocess_exec, so the SDK's anyio spawn is covered too. The 8 rows take about 9 s.Claude F001: a provisioned server waiting for adoption survived gateway shutdown
finally. On expiry, or on any other end of the task while the job still owns its server, such as cancellation at loop shutdown, it kills the server with its group and closes its pipes. The handoff andcancel_jobtake the process from the job before they cancel the deadline.JobManager.shutdown()kills every job's process, installing orserver_ready, synchronously and with its group, and cancels the jobs' tasks. The gateway'sshutdown()calls it in itsfinally, on every exit.test_a_server_ready_job_is_killed_when_the_gateway_loop_shuts_down: a realasyncio.runending with aserver_readyjob. The server and its grandchild are gone.shutdown()calls it.handoff: the gateway shuts down while server_ready.finally) and M295 (the job manager not swept at shutdown).Claude nit
_register_or_kill's refusal already closed the pipes: it kills through_kill_process_tree_now, which releases them. This is now pinned by a test (the stdin, stdout and stderr transports are closing) and by mutant M296.Found while verifying
.exception()inline, whichtest_no_exception_reaches_text_except_through_the_rendererforbids. It now binds the result to a name (c1b294c).CancelledErroras well as_ManagerAbandoned. The shutdown sweep cancels the connect task the reconnect waits on, and the shielded spawn's callback kills the new server either way. The test's real assertions, a dead process and no registered client, are unchanged.Gates at c1b294c (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff checkandruff format --checkpass onsrc/andtests/,mypy srcfinds no issues, andcheck_security_claimsexits 0 with 132 cited node ids.spawn-canceldimension;gateway shuts down while server_readyrow;registry.modelcontextprotocol.io, which took 0.3 s per test in the base run and about 6 s in the head run.spawn-canceldimension and the shutdown row, add about 10 s.windows-smoketook 46 s. The Linux test jobs took 32m52s (3.10), 33m24s (3.11) and 24m43s (3.12); the 3.10 job took 40m36s at def8722.Round 14 (#388 panel): fixes, at head ee0444f
Codex F001: loop teardown leaked the descendants of a spawn still in flight
asyncio.runends, it cancels every task, including round 13's shielded spawn. CPython then kills the leader alone, and the old callback had no pid for a cancelled spawn.JobManager.shutdown()didn't cover the window either, becausestart_installsetsjob.processonly after the spawn returns._SpawnTask, registered in a set of pending spawns. Itscancel()records the child's pid before the cancellation lands. CPython's_make_subprocess_transportstill holds the transport (transp._pid) while it connects the pipes, so the pid is read from the coroutine's frames.abandon_pending_spawns()(synchronous) marks every spawn in flight abandoned and records their pids. Each one is owned and killed with its group when it completes.ClientManager.abandon_all_now()andJobManager.shutdown()call it.drain_pending_spawns()(async, bounded at 3 s) runs in the gateway'sshutdown()before it returns toasyncio.run. It marks the spawns abandoned, waits for each to finish (each is then killed with its group), and cancels any still unfinished, whose pid was recorded first. A nestedfinallykeeps the job sweep running even if the drain itself is cancelled.asyncio.runends while a spawn is still connecting its pipes, with CPython's own_connect_pipesheld. No leader and no grandchild remain.shutdown()does not return while an install's spawn is in flight. When it returns, the leader is already dead and the grandchild is gone.spawn-canceldimension still passes.Claude N-A: SIGHUP skipped every teardown
cli.shutdown_signals()now includes SIGHUP on POSIX, andcli.install_shutdown_signals()routes it through the gateway's shutdown, the same path as SIGTERM. A closed terminal or dropped SSH session no longer leaves detached children unowned.CTRL_CLOSE_EVENT) is not handled; noted in the code.server_readyinstall job and its live server, and receives SIGHUP. The gateway shuts down, and the server and its grandchild are gone.Claude N-B: noted, not changed
The job manager, and the pending-spawn set, are process-global, so one gateway's shutdown also stops another gateway's installs in the same interpreter. Only library users and tests can reach that. Making them per-gateway means plumbing the manager through every handler, which is not cheap.
Found while verifying
_connect_pipes, once cancelled with the loop, never connected, so CPython's own cleanup waited forever. The patched connect now completes the real connect in afinally, as a real connect does (ee0444f). The test is a falsifier on 3.12 as well: it fails there under M298.Gates at ee0444f (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff checkandruff format --checkpass onsrc/andtests/,mypy srcfinds no issues, andcheck_security_claimsexits 0 with 132 cited node ids.Left for a filed issue (per the new review bar)
CTRL_CLOSE_EVENT) is not handled.windows-smoketook 44 s. The Linux test jobs took 29m40s (3.10), 39m48s (3.11) and 36m19s (3.12); the 3.10 job took 32m52s at c1b294c.Round 15 (#388 panel): fix, at head a3b5233
Codex, claude and gemini: the SIGHUP handler broke the documented
nohupdeploymentinstall_shutdown_signalsreplaced theSIG_IGNthatnohupsets for SIGHUP, sonohup pmcp --transport http &(README.md) shut down when the operator logged out.signal.getsignal(sig) is signal.SIG_IGNmeans skip it. That covers SIGHUP undernohup, and SIGINT for a background job started by a shell without job control. A terminal-attached gateway, where SIGHUP is not ignored, still shuts down cleanly on hangup.server_readyinstall job and its live server:nohup: SIGHUP leaves the gateway and its server running. A later SIGTERM shuts the gateway down with everything it started.nohuped CI or ai run, would otherwise pass it on.SIG_IGNcheck removed), killed in both passes ("nohup's ignored SIGHUP shut the gateway down"). M299 is still killed.Gates at a3b5233 (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff checkandruff format --checkpass onsrc/andtests/,mypy srcfinds no issues, andcheck_security_claimsexits 0 with 132 cited node ids.Round 16 (#388 panel): fix, at head 3b71ac9
Codex F001: a terminal hangup or SIGTERM during
pmcp upgradeleft the installer runningpmcp upgrade, SIGHUP or SIGTERM killed pmcp without running its cleanup, and the detached installer no longer got the group's signal itself.run_echoedandrun_capturednow install SIGTERM and SIGHUP handlers while their child runs, throughchild_output.terminating_on_signal().128 + signum.SIG_IGNis kept (round 15's rule). Ctrl-C keeps its own path.async_mainhandles SIGTERM and SIGHUP, unless inherited as ignored, for every subcommand except the gateway, which keeps its own shutdown. The handler cancels the command's task, so each child's owner kills it; spawns still in flight are drained; and pmcp exits128 + signum.Subcommand audit. Every CLI path that starts a child:
upgrade(the installer: uv, pip, ...)run_echoedsystemctl/launchctl)run_echoedsystemctl is-active)run_capturedrefresh(the MCP SDK's child, the npm resolver)spawn_ownedupdate,doctor,init,status,logs,secrets,auth,trust,setup,config,guidance,capabilitiesTests:
pmcp upgradewith a fakeuvthat starts a grandchild and runs on. SIGHUP, and separately SIGTERM, is sent to the parent's process group. pmcp exits128 + signum, and the installer and its grandchild are gone.nohup: SIGHUP stays ignored, and the upgrade runs to its end.Mutants:
run_echoednot forwarding): killed. Without the runner's own handler the signal reaches only the async loop's handler, which cannot run whilerun_echoedblocks the loop's thread, so pmcp hangs and the test times out.Found while verifying
cli.async_main, which now wraps the chain (moved intocli._dispatch) with the signal handling. The audit now reads_dispatch.async_main's handler: it drains the spawns in flight, bounded, before exiting with the signal's status.Gates at 3b71ac9 (ai,
nice,uv run --isolated, afteruv sync --all-extras -p 3.10)ruff checkandruff format --checkpass onsrc/andtests/,mypy srcfinds no issues, andcheck_security_claimsexits 0 with 132 cited node ids.windows-smoketook 49 s. The Linux test jobs took 33m56s (3.10), 41m19s (3.11) and 31m47s (3.12).🤖 Generated with Claude Code
https://claude.ai/code/session_01WGFGW54oDcvKsVBX4atZwQ