Skip to content

Describe rejected values from their structure, never their value (see Consiliency/pmcp#297) - #388

Merged
ViperJuice merged 142 commits into
mainfrom
fix/297-validation-echo
Oct 14, 2026
Merged

ViperJuice merged 142 commits into
mainfrom
fix/297-validation-echo

Conversation

@ViperJuice

@ViperJuice ViperJuice commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

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) onto main at 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

  1. Taint-walk sources (tests/test_http_transport.py):
    • Every registered route endpoint counts as a source: Route, WebSocketRoute, Mount and add_route, with the endpoint's first positional parameter as the request.
    • Reads of scope["pmcp.*"] and scope.get("pmcp.*") count as sources.
    • A request stored on self/cls is tracked as a held attribute, collected to a fixpoint across the module.
    • Propagator bodies are scanned with their positional parameters tainted.
    • Container stores taint the container rather than counting as a sink.
    • The propagator and classifier tables were re-derived against current main: dead entries removed, and test_every_reviewed_request_callee_is_used now searches all of src/pmcp.
  2. Logger.handle is wrapped as well as makeRecord (argument_errors._install_handle_scrubbing), so a directly built record (logging.LogRecord, logging.makeLogRecord) is scrubbed when a logger handles it.
    • The wrap is idempotent. A record is scrubbed again only if its msg, args or exc_info object was replaced since the last scrub, so an already-masked SDK record is not masked twice.
    • The scrub state is kept in a WeakKeyDictionary, not on the record, so it never appears as an extra field.

Merge onto 3.0.0

There were 14 conflicts. Main's new readers were brought under the sink guard:

  • Conflict resolutions:
    • identity.py: took main's LF file and re-applied the spike's changes. Lock warnings use exception_text. The mkdir refusal reads os.strerror(errno) or the class name.
    • manifest/loader.py: an overlay is read via load_yaml(..., source="manifest overlay") and rendered with exception_text. Main's _server_label, _cli_label and _rejection_reason are kept.
    • server.py: install_log_scrubber() runs before main's bind_project_root. Both _manifest_for and _described_errors are kept.
    • tools/handlers.py: auth_connect uses store_refusal(...) for OSError/UnicodeDecodeError and exception_text otherwise.
  • Main's new code changed to meet the rules:
    • env_store.store_refusal no longer quotes a chained value.
    • trust_store and package_approvals refusals use errno text or the class name.
    • HomeInsideCheckoutError keeps OSError's *args signature and exposes .reason.
    • auth.validate_resource_server_token computes missing_scopes from a set comprehension.
  • New renderer argument_errors.validation_error_kinds:
    • It names a field only when the model declares that field; otherwise it reads entry.
    • It names an error type only when the type is a known pydantic or pmcp code; otherwise it reads invalid.
    • Main's manifest-entry rejection reasons use it.
  • Guard tables updated for main's new code:
    • _EXEMPT_CALLEES: falls_back_to_pathname, store_refusal, _error_kind, _rejection_reason, _log_unusable_manifest_entry.
    • The parser-site _EXEMPT table now covers the env_store dotenv readers.
    • _NOT_HTTP_CLIENTS adds contextvars, ctypes, io, secrets, sysconfig and weakref.
    • The auth_connect triage rows were updated.
  • Main's tests updated for the merged forms:
    • test_catalog_overlay_discovery: a malformed overlay now reads could not parse YAML manifest overlay at line N, column M (<Class>).
    • The config-field scanner's source hit in parsing.py is allowlisted. That source is the label pmcp gives the parsed text, not a config's source field.
  • The json-deep downstream frame is now 100000 levels deep. Python 3.12's json parses 1500 levels, so on 3.12 the frame was valid and 11 stdio cases failed their vacuity check.

For the panel

  • MIGRATING.md merged 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.
  • The handle wrap's idempotence key is the identity of (msg, args, exc_info). A handler that changes one of those objects in place, rather than replacing it, is not scrubbed again. makeRecord has already scrubbed the record by then.

Gates at 76355af (before round 1)

All gates were run on ai under nice with uv run --isolated, after uv sync --all-extras -p 3.10:

Gate Result
ruff check src/ tests/ All checks passed
ruff format --check src/ tests/ 217 files already formatted
mypy src no issues in 60 source files
scripts/check_security_claims.py exit 0; SECURITY.md has 132 cited node ids
Targeted set, 10 modules, on 3.10 / 3.11 / 3.12 1720 passed on each
Full suite on 3.10 (not live and not slow) 12620 passed, 6 skipped, 1 xfailed, 0 failed

Mutants: 187 in total, 182 numbered mutants plus 5 sink-guard checks. Every one applied, and every lane restored to a clean tree.

  • Pass A (all tests): everything killed except M23 and M25. Those two survived on the spike too: the downstream's own well-formed error message is still shown, by design.
  • Pass B (no static guards): the survivors are M19, M20, M22, M23, M25, M35 and M150. That is the same set as rev 30, and the static sink guard kills each of them in pass A.
  • New rules:
    • M202 to M206: the route-endpoint source, the scope["pmcp.*"] read, held attributes, propagator bodies and the handle scrub. All killed.
    • M207 and M208: an undeclared field or an unknown code named. Both killed.
    • M210: re-scrubbing an unchanged record. Killed.
  • Fixed after the full run: the full run (at 92dc0a8) found two survivors.
    • M198: with Logger.handle wrapped, no test reached makeRecord's own extras arm.
    • M209: the store_refusal test checked only the whole sentinel, which pydantic truncates.
    • Tests were added for both, plus M211 for the other makeRecord arm. 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

  • New renderer, argument_errors.plain_name: shows a name only when it matches [A-Za-z_][A-Za-z0-9_]*. Anything else reads a name that is not a plain identifier.
    • It now renders the key in the "Ignoring … in .env.pmcp" lines (both kinds), in pmcp secrets sync's "Not copying …" line, and in the refusal of a store-set HOME.
    • Claude's falsifier is now in the suite: tests/test_file_content_echo.py::test_a_rejected_repository_store_key_is_not_echoed_to_stderr.
  • The class guard, tests/test_file_content_echo.py:
    • It walks all of src/pmcp interprocedurally, by function name.
    • Sources: the dotenv readers, parse_variables, the JSON, YAML and TOML loaders, and every function that returns their output.
    • Sinks: print, sys.stderr.write, sys.stdout.write, click.echo, click.secho, and a rich console's print. A function whose parameter reaches one of these counts as a sink for that parameter.
    • Taint passes only through string, path and container operations and src/pmcp functions that return their input. Building a model or dataclass ends the trace: from there, D9's config-field table governs.
    • What it does not cover: loggers and raise. Those stay with the record scrubber, the D9 table and the exception-text guard. This guard covers the channel none of those see.
    • A synthetic-tree unit pins the guard itself: print and sys.stderr.write through a helper are reported, and so is click.echo of a derived source.
  • Direct-output audit:
    • Fixed:
      • env_store: the four messages above.
      • pmcp doctor: .mcp.json server names, header names, invalid URLs and the local config path go through operator_safe (escaped, then shell-quoted); variable names go through plain_name.
      • pmcp trust list: the approved path goes through operator_safe.
    • Reviewed and left (6 rows in _REVIEWED, each with its reason):
      • run_status, run_auth_connect, run_auth_acknowledge and run_update print the running gateway's own answer, the same text an MCP client receives.
      • _run_trust_list and _run_trust_list_packages print the operator's own stores, which are re-validated on read.

codex F001: ReDoS in the SDK-rejection classifier

  • Length cap: a message longer than 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.
  • Linear matching: every template is now a LiteralTemplate, matched by prefix, suffix and str.find. It is linear, and exact, because each placeholder matches anything.
    • Codex's input at 1 MiB and 10 MiB classifies in under 0.1 ms; the test's budget is 50 ms.
    • Every template still matches its own message.
    • Each template's prefix repeated without its suffix is matched at 64 KiB, 1 MiB and 10 MiB within the budget.
  • Mutants: removing the cap fails three tests; putting the regex back fails the 64 KiB adversarial case at once.

Regex audit at 482f281

Method.

  • Every literal pattern in src/pmcp, every module-level compiled pattern, and every SDK template and log pattern pmcp builds.
  • Each is screened with adversarial repeats of its own literals plus filler characters, in search mode (the harshest).
  • A superlinear one is then measured in the mode it actually runs in.
  • Some timings were taken while the mutant lanes ran on the same host, so they are upper bounds.

Fixed, all quadratic on peer or caller text:

Pattern Before, at 1 MiB After, at 1 MiB
SDK templates in _downstream_error 11.8 s at 288 KB (codex) < 0.1 ms
auth.parse_url_elicitation_error, (\{.*\}) search ~368 s ≤ 11 ms (line-anchored pattern)
gateway.submit_feedback e-mail scrub ~1000 s ≤ 19 ms (lookbehind on the local part)

Flagged in search mode but not reachable that way; times are as actually used:

Pattern As used 1 MiB
manifest.loader._PARTIAL_VERSION_WORD_RE fullmatch of a config selector 16.6 ms
validation._PACKAGE_VERSION_RE fullmatch 66.8 ms
redaction_additive._STRADDLE_RE match(value, 1) 7.8 ms
redaction_additive._JSON_STRING_RE finditer, only after load_json accepted the text, so no unterminated string reaches it —
redaction_additive._QUOTED_RE finditer over a list body _LIST_BODY already matched as whole quoted elements —
auth._SCOPE_LIST n^1.1 34.7 ms

The redactor end to end (redact_additive, sanitize_auth_diagnostic) is linear but costly: 0.3 to 1.5 s per MiB, the worst on Authorization: -shaped text, and sanitize_auth_diagnostic cuts 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

  • N-1: MIGRATING says that 3.0.0 printed jsonschema's wording (1 is not of type 'boolean'), and that 3.0.1 and later print the form shown. test_migration_doc passes.
  • N-2: Logger.handle scrubs an unmasked logger's record again every time. logging.Filterer.filter scrubs again after a logger's or a handler's filters pass a record, or scrubs the record a filter returns on 3.12+.
    • Covered: arguments changed in place, a field a logger filter adds, a msg a handler filter sets, and a replacement record.
    • A masked (SDK) logger's record keeps the state check, because re-masking masked text is the 65-failure incident.
    • M210 now mutates toward "state recorded and checked for every record" and is killed.
    • Cost per record: 6.9 us, against 4.5 us with no scrubber at all (the 3.0.0 head took 11.9 us).

For the panel

  • Long downstream errors: the 4096 cap means a downstream's own well-formed error longer than 4 KiB now reads as its code's fixed phrase. That narrows the line the panel approved: "a downstream's own well-formed message is still shown" (M23, M25).
  • .mcp.json names: the coordinator's class lists .mcp.json as repository-controlled, but main's D9 ruling names .mcp.json entries "as before". pmcp doctor therefore escapes them with operator_safe but does not hide them. A hyphenated server name would not pass plain_name.
  • Duplicate lines: _warn_store_refused prints each distinct line once, so two non-plain keys in one file now share one line.
  • Lazy SDK patching, not done: import pmcp now imports mcp.types and patches the SDK at once, which costs about 0.45 s per process. Patching on first import of each SDK module, through a sys.meta_path hook, would recover about 30 to 50 s of the suite. It is a new global hook that changes "installed on import pmcp", so it is left for a decision.

Gates at 482f281 (ai, nice, uv run --isolated, after uv sync --all-extras -p 3.10)

Gate Result
ruff check src/ tests/ All checks passed
ruff format --check src/ tests/ 219 files already formatted
mypy src no issues in 60 source files
scripts/check_security_claims.py exit 0, 132 cited node ids
Targeted set, 12 modules, on 3.10 / 3.11 / 3.12 1763 passed, 1 skipped / 1763 passed, 1 skipped / 1764 passed
Full suite on 3.10 12663 passed, 7 skipped, 1 xfailed, 0 failed

Mutants: 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.

  • Pass A: every mutant is killed except M23 and M25, the downstream's own message, by design. M210 is counted as killed: it is redefined above, and the redefined mutant is killed in both passes.
  • Sink-guard checks: all 5 (G1, S5 to S8) are killed in both passes.
  • Pass B: the survivors are M19, M20, M22, M23, M25, M35 and M150, the same set as rev 30, each killed by the static guard in pass A.
  • New this round:
    • claude F001: M214 to M217 (each site reverted to a raw key) and M219 (plain_name passes any text).
    • The file-content guard: M218 (it does not treat print as a sink) and M220 (it ignores derived sources).
    • codex F001: M221 (the cap removed), M222 (the backtracking regex back), M223 (the elicitation regex) and M224 (the e-mail lookbehind).
    • N-2: M211 to M213.
    • The scrub fast path: M225 to M227.
    • All are killed.

CI time, measured on ai with --cov as CI runs it, 3.10, the whole suite alone on the host

Wall time Sum of tests ≥ 0.05 s
Base f9f4bd0 1030 s (17:07) 783 s
Head before round 1 (a768eb7), run concurrently about 2030 to 2285 s —
Head 482f281 1304 s (21:40) 1016 s

Gap: 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:

  • Sentinel families rotated over the sweeps' cases. Every family still runs on every transport, method and shape, and on every pair of them, which tests pin. The two-sentinel pair differential is unchanged.
  • Grids halved or cut to a third by parity rotation, with pair-coverage tests: the frame sweep (235 of its 704 cases), the dropped-hint sweep, the proxy-status grid and the stdout-line streams.
  • Per-process and per-record costs:
    • The scrub has a fast path, and is_sdk_logger is memoized.
    • The SDK logger-name scan parses only modules that name a factory.
    • Parsed SDK trees are not retained: holding 64 MB of nodes slowed every full GC.
  • Shorter waits and smaller inputs:
    • The rejected-endpoint case waits 5 s instead of 25 s, and checks that the SDK did reject.
    • The deep-YAML input is 1000 levels, with the default recursion limit pinned for that test.
    • The doc test caches gateway schemas.

Where the remaining time goes (head minus base, in seconds):

  • frame sweep 45;
  • proxy grid 19;
  • rejected-argument sweep 15;
  • test_store_readers_confined +12;
  • scrubber tests 11;
  • test_migration_doc +11;
  • test_http_transport 9;
  • test_cli_project_store_startup +8;
  • exception-text guard 7;
  • file-content guard 4;
  • about 51 s spread over existing subprocess tests, at about 0.45 s each, from import pmcp importing the SDK.

Options (your call):

  • the lazy SDK patching above (about −45 s);
  • a separate CI job for the sweep modules (check_workflows.py's job set would need updating);
  • accept the gap.

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)

  • One escaping function, pmcp.display.display_safe:
    • It escapes every character Python does not count printable: C0, DEL, C1 (U+0080 to U+009F), format characters (bidi controls, zero-width characters, the soft hyphen), line and paragraph separators, non-ASCII spaces, surrogates, and private-use and unassigned code points.
    • A test checks the whole Basic Multilingual Plane: a character is shown as-is exactly when it is printable.
    • It imports nothing of pmcp's, so the consent modules can use it.
  • Every renderer goes through it:
    • operator_safe (provision_gate), consent's _operator_safe and the installer's _operator_safe, each then shell-quoted;
    • pmcp doctor's server, header and URL displays;
    • exception_text's plain path (keeping \n and \t);
    • every log line where it is rendered: LogRecord.getMessage and Formatter.formatException are wrapped, keeping line breaks and tabs. A value past any walk still cannot reach a terminal raw.
    • plain_name needed no change: it already returns only [A-Za-z_][A-Za-z0-9_]* or a fixed phrase.
  • Grok F001: doctor's metadata URL line now goes through operator_safe, so U+009B reads \x9b.
  • Codex F001, parse-site catches:
    • doctor.py catches urlparse and .port failures at the parse site and reports the URL as invalid.
    • auth.protected_resource_metadata_urls now catches too.
    • As a backstop, exception_text treats an error raised by a text-parsing module (urllib.parse, ipaddress, idna, shlex, _strptime, email, http.cookies) as value-bearing: it reads could not parse the text (<Class>).
    • A refusal pmcp raises in its place with from None keeps its own message, because no traceback prints a suppressed context. A test pins the cause, context and suppressed shapes.
  • Claude N-A: the file-content guard now covers loggers, warnings.warn and raise arguments, as its docstring says, and a synthetic-tree unit pins each. It found:
    • Fixed:
      • the .mcp.json "Skipping server" WARNING (the name is escaped);
      • allowPrivateRegistry (named by type: got a string);
      • the package-approval and trust-store decision, registry, name and version fields (by type);
      • parse_package_spec (no longer echoes the spec).
    • Reviewed, with reasons in _REVIEWED:
      • the three _read_config_object error halves;
      • the frame dispatcher (it logs method only after it matched one of three constants);
      • _EntryRejected (it names schema keys only);
      • the stale-approval warning (pmcp's own store).
    • D9's value-free labels (_server_label, _cli_label, _rejection_reason, shipped_env_var, validation_error_kinds) count as sanitizers.

Claude N-B and the nit

  • N-B: a downstream error over 4096 characters now reads <code's phrase> (the server's message was not shown: over 4096 characters).
  • Nit: GatewayServer.initialize builds the SDK message registries in a daemon thread, off the first error's path.

Renderer and parse-site audit

Renderers:

Renderer Output
display_safe the primitive
operator_safe, consent's and installer's _operator_safe display_safe, then shlex.quote
plain_name identifier or fixed phrase
describe_value type or structure
exception_text registered errors and text-parser errors described; anything else display_safe
log lines display_safe with \n\t kept

tests/test_display_safe.py runs 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):

  • Already caught ValueError and 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_port and _is_loopback_host, and scoped_advisor_audit.
  • Newly caught: doctor's url and protected_resource_metadata_urls.
  • Operator-only input (pmcp's own gateway URL setting, --oauth-* flags), now backstopped by the funnel: cli._get_gateway_health_url, the auth-metadata parses in transport/http.py, and registry._with_query.
  • Already wrapped: parsing.parse_timestamp raises TimestampParseError.

Tests:

  • grok's and codex's falsifiers;
  • every doctor URL field × NFKC, port, IPv6, C1 and idna inputs;
  • urlparse, port, ipaddress, idna and shlex errors through the funnel;
  • both N-A log cases;
  • the N-B note.

Gates at 66efa59 (ai, nice, uv run --isolated, after uv sync --all-extras -p 3.10)

Gate Result
ruff check src/ tests/ All checks passed
ruff format --check src/ tests/ 221 files already formatted
mypy src no issues in 61 source files
check_security_claims exit 0, 132 cited node ids
Targeted set, 13 modules, on 3.10 / 3.11 / 3.12 1854 passed, 1 skipped / 1854 passed, 1 skipped / 1855 passed
Full suite on 3.10 12754 passed, 7 skipped, 1 xfailed, 0 failed

7c6628a adds only the protected_resource_metadata_urls parse-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.

  • Pass A survivors: only M23 and M25, the downstream's own message, by design.
  • Pass B survivors: M19, M20, M22, M23, M25, M35 and M150, unchanged.
  • New this round, all killed in both passes:
    • M228: C1 dropped from the escape set.
    • M229: the doctor parse-site catch removed.
    • M230: the guard not covering loggers.
    • M231: text-parser errors not value-bearing.
    • M232: log lines not escaped.
    • M233: exception_text not escaped.
    • M234: doctor's metadata URL shown raw.
    • M235: allowPrivateRegistry back to %r.
    • M236: a suppressed parser context skipped for any wrapper.
    • M237: the over-cap note dropped.
  • Re-anchored to the changed chain walker: M115 to M119 and M185, all killed.

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)

  • Where: cli.main writes stderr, from its first line, through pmcp.display.DisplaySafeStream. After argument parsing it does the same for a subcommand's stdout. Every string goes through display_safe, keeping \n and \t. Bytes written to .buffer pass through unchanged.
  • What cli.human_output(args) leaves untouched:
    • the gateway's own stdout (no subcommand), which carries MCP frames on stdio;
    • --json output;
    • pmcp secrets, whose stdout is always ASCII-escaped json.dumps.
    • In all three cases stderr is still wrapped.
  • Per-site renderers stay (display_safe, plain_name, operator_safe) for their wording; the wrapper is the guarantee.
  • Now covered by the choke point:
    • grok's pmcp status local fallback (cli.py:1612) and pmcp config status;
    • codex's 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 in try/finally so the original streams are restored. test_env_leak_229 reads main's own calls.

Auth URLs refuse every non-printable character (grok F001 / claude round-3 N-A)

  • The URL check: sanitize_public_auth_url refuses 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.
  • Elicitation ids: a peer's elicitation whose id is not printable is dropped, like one whose URL is refused.
  • Tests: 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.

  • Every subcommand, in human mode: the command's real dispatch target is replaced by one that prints a hostile value (ESC with a CSI clear, BEL, U+009B, RLO, a tag character) through print to stdout, print to stderr, and sys.stdout.write. The test checks that main delivers 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.
  • Every subcommand with --json (update, status, capabilities, the three config commands, auth connect, auth acknowledge): stdout is exactly what the command wrote.
  • The gateway (no subcommand): stdout is byte-identical through both write and .buffer, and is never the wrapper.
  • Real input sources:
    • status local fallback and config 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.
  • URL checks: every non-printable character in the BMP plus the tag block, through sanitize_public_auth_url; both elicitation provenances; and peer elicitations with a non-printable id or URL.
  • File-content guard: its cli.py rows now also cite the wrapper, which the subcommand sweep checks for those commands.
  • Cost: all in-process, no subprocess per case. The module runs 45 tests in under 1 s.

Gates at fbd8edc (ai, nice, uv run --isolated, after uv sync --all-extras -p 3.10)

Gate Result
ruff check src/ tests/ All checks passed
ruff format --check src/ tests/ 222 files already formatted
mypy src no issues in 61 source files
check_security_claims exit 0, 132 cited node ids
Targeted set, 14 modules, on 3.10 / 3.11 / 3.12 1900 passed, 1 skipped / 1900 passed, 1 skipped / 1901 passed
Full suite on 3.10 12844 passed, 7 skipped, 1 xfailed, 0 failed

Mutants: the full table ran at c421861: 214 numbered mutants plus 5 sink-guard checks, two passes over 12 lanes, every lane restored clean.

  • Pass A survivors: only M23 and M25.
  • Pass B survivors: M19, M20, M22, M23, M25, M35 and M150, unchanged.
  • New this round, re-run at fbd8edc and killed in both passes:
    • M238: the human-stdout wrapper not installed.
    • M239: the wrapper installed on the gateway's stdout, killed by the byte-identity test.
    • M240: stderr not wrapped.
    • M241: C1 accepted by the auth-URL check.
    • M242: a peer's non-printable elicitation id kept.
    • M243: --json output wrapped too.

CI time, on ai, the whole suite run alone, with --cov, on 3.10:

Head Wall time Sum of tests ≥ 0.05 s
7c6628a 1315 s (21:55) 1022 s
fbd8edc 1322 s (22:02) 1025 s

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)

  • The fix: a new module, 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, as ClientManager does for its own children.
    • refresh_server passes it to the SDK's stdio_client. This covers both pmcp refresh and the gateway's first-start cache generation (refresh_all).
    • run_echoed(command) runs pmcp upgrade's installer and the service restart (systemctl, launchctl) with stdout and stderr piped together and stdin set to DEVNULL. Each line is echoed escaped when the child is done; these are short commands. It takes its environment from child_process_env(), so the spawn-environment inventory still sees a builder.
  • DisplaySafeStream.fileno raises io.UnsupportedOperation. Nothing in src reads the wrapped streams' fileno, and a future inherit-by-descriptor fails loudly.

Spawn audit (src/pmcp)

Site Child's streams
child_output.run_echoed (upgrade, service restart) stdout PIPE, stderr STDOUT, stdin DEVNULL, echoed escaped
cli._is_pmcp_system_service_active, _restart_local_pmcp_service's probe capture_output=True
client/manager._connect_stdio stdin, stdout and stderr PIPE; stderr logged
manifest/environment.check_cli, get_cli_help stdout and stderr PIPE
manifest/installer.start_install, install_server, verify_installation stdout and stderr PIPE
manifest/npm_resolver._spawn stdin and stdout PIPE, stderr DEVNULL
manifest/refresher.refresh_server (stdio_client) errlog=drained_errlog(...)
tools/handlers._run_update_probe_command stdout and stderr PIPE

There are no os.system, os.exec*, os.popen or pty calls. The guard is tests/test_child_output.py::test_no_spawn_lets_a_child_write_to_pmcps_streams: every subprocess.*, asyncio.create_subprocess_*, anyio.open_process/run_process, os.system, os.exec* and pty.spawn call must pipe or discard both streams (or use capture_output=True), and every stdio_client must take a non-terminal errlog. Its allowlist is empty, and a unit test pins each shape.

Claude N-A and the nit

  • N-A: main() catches every exception around its whole body: startup loading, argument parsing, the project choice, secrets set's value and the command. It prints Fatal error: … while stderr is still the escaping stream, then restores. SystemExit and KeyboardInterrupt behave as before.
  • Nit: the refusal now reads 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.

  • Grok's falsifier: real cli.main running pmcp refresh, in a fresh process, against a small MCP stdio server from an exported overlay. The child's line is logged and escaped.
  • Gateway startup without a cache: GatewayServer.initialize with the server auto-started, which reaches refresh_all through "Auto-generating descriptions cache".
  • pmcp upgrade --method uv with a fake uv on PATH; the output is echoed escaped.
  • The service restart with a fake systemctl.
  • Units: fileno() raises, and N-A (an exception from serve_project with ESC in its message is printed escaped, exit 1).

The module takes 3.7 s.

Gates at 203180e (ai, nice, uv run --isolated, after uv sync --all-extras -p 3.10)

Gate Result
ruff check src/ tests/ All checks passed
ruff format --check src/ tests/ 224 files already formatted
mypy src no issues in 62 source files
check_security_claims exit 0, 132 cited node ids
Targeted set, 15 modules, on 3.10 / 3.11 / 3.12 1908 passed, 1 skipped / 1908 passed, 1 skipped / 1909 passed
Full suite on 3.10 12852 passed, 7 skipped, 1 xfailed, 1 error
  • The error was environmental. It was at teardown of test_workflow_guards.py::TestMutationHelperContract: booted_gateway asserts the operator's live gateway on ai's :3344 keeps 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).
  • de9d742 only moves an @pytest.mark.asyncio marker 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.

  • Pass A survivors: only M23 and M25.
  • Pass B survivors: M19, M20, M22, M23, M25, M35 and M150, unchanged.
  • New this round, killed in both passes:
    • M244: the refresh errlog back to sys.stderr.
    • M245: the wrapper's fileno forwarded again.
    • M246: run_echoed inheriting the terminal.
    • M247: an error before the command escaping main unrendered.
  • Re-anchored to the new with block: M98 (the refresh unbounded), killed.

CI time, on ai, the whole suite run alone, with --cov, on 3.10:

Head Wall time
fbd8edc 1322 s
203180e 1329 s

Round 4 adds +7 s, of which test_child_output is 3.7 s.

Round 5 (#388 panel): fixes, at head 40174a0

Every child is detached from the terminal (grok and codex F001)

  • The rule: every child pmcp spawns now:
    • runs detached via child_output.detached(): start_new_session=True on POSIX, so the child has no controlling terminal and /dev/tty fails to open; CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW on Windows;
    • takes stdin from DEVNULL, or a pipe pmcp owns;
    • has stdout and stderr piped or discarded, as in round 4.
  • ClientManager's spawn and the update probe used a literal start_new_session=True; they now go through the same helper, which produces the same keyword on POSIX, so the process-group reaping is unchanged.
  • No site needs the terminal. The allowlist is empty.

Per-spawn audit

Site Session stdin stdout stderr
manifest/environment.check_cli (overlay check_command: catalog_search, request_capability, sync_environment) detached DEVNULL PIPE PIPE
manifest/environment.get_cli_help detached DEVNULL PIPE PIPE
manifest/installer.JobManager.start_install (gateway.provision) detached PIPE (owned) PIPE PIPE
manifest/installer.install_server detached DEVNULL PIPE PIPE
manifest/installer.verify_installation detached DEVNULL PIPE PIPE
tools/handlers._run_update_probe_command (gateway.update_server) detached DEVNULL (was inherited) PIPE PIPE
child_output.run_echoed (upgrade, service restart) detached DEVNULL PIPE STDOUT
manifest/npm_resolver._spawn detached PIPE (owned) PIPE DEVNULL
cli._is_pmcp_system_service_active, _restart_local_pmcp_service probe detached DEVNULL captured captured
client/manager._connect_stdio (gateway servers) detached PIPE (owned) PIPE PIPE, logged
manifest/refresher.refresh_server (SDK stdio_client) detached (SDK start_new_session=True) PIPE (anyio's default) PIPE drained_errlog pipe
  • The static guard (test_no_spawn_lets_a_child_write_to_pmcps_streams) requires all three properties at every subprocess.*, asyncio.create_subprocess_* and anyio.open_process call: start_new_session=True or **detached(), stdin PIPE or DEVNULL, and stdout/stderr piped or discarded. It refuses os.system, os.popen, os.exec* and pty.spawn outright. A unit test pins each shape.
  • The SDK's stdio_client is pinned from the installed source: its anyio.open_process passes start_new_session=True, and its stdin is anyio's default pipe.

Real-pty tests (test_a_child_reaches_neither_the_controlling_terminal_nor_stdin)

  • Setup: the parent runs in its own session, with a pty as its controlling terminal and its stdin, stdout and stderr. The child opens /dev/tty and 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.
  • Assertion: the child sees no tty and a non-tty fd 0, and nothing reaches the pty master.
  • Sites covered: check_cli, JobManager.start_install, run_echoed, the update probe and a ClientManager stdio spawn.
  • Cost: about 0.6 to 0.9 s each.

Claude N-A and N-B (the stderr drain)

  • N-A: drained_errlog is now an async context manager. On exit it waits for the drain thread through anyio.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.
  • N-B: bounded_lines reads 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 by run_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, after uv sync --all-extras -p 3.10)

Gate Result
ruff check src/ tests/ All checks passed
ruff format --check src/ tests/ 224 files already formatted
mypy src no issues in 62 source files
check_security_claims exit 0, 132 cited node ids
Targeted set on 3.10 / 3.11 / 3.12 1917 passed, 1 skipped / 1917 passed, 1 skipped / 1918 passed
Full suite on 3.10 12861 passed, 7 skipped, 1 xfailed, 0 failed, 0 errors

Mutants: 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.

  • Pass A survivors: only M23 and M25.
  • Pass B survivors: M19, M20, M22, M23, M25, M35 and M150, unchanged.
  • New this round, killed in both passes:
    • M248: check_cli not detached.
    • M249: the update probe inheriting stdin.
    • M250: the guard ignoring the session.
    • M251: the guard ignoring stdin.
    • M252: the drain join back on the loop.
    • M253: the line cap removed.
    • M254: detached() returning nothing on POSIX.
  • Re-anchored: M98 (the refresh unbounded), killed.

CI time: see the next comment for the solo --durations comparison against de9d742.

Round 6 (#388 panel): fixes, at head aa3f459

Codex F001: the drain releases its thread and pipe at the deadline

  • How it stops: the drain thread now waits on its pipe and on a private stop pipe, through a selectors selector.
    • On exit the owner closes its write end and waits off the loop for up to 5 s, as before.
    • The owner then closes the stop pipe's write end. That wakes the reader, which closes both read ends itself and ends.
    • The reader is the only thread that ever closes the read end, so no other thread can close a descriptor number it is still reading.
  • Test: three errlog contexts, each with a descendant (sleep) that keeps the write end open past the context. Afterwards there is no pmcp-errlog-* thread and no fd beyond the baseline (/proc/self/fd), while the descendants are still alive.
  • Windows: select cannot wait on pipes there, and the drain is POSIX-tested only (the selector is the default one).

Claude N-A: Ctrl-C during pmcp upgrade or the service restart

  • The change: run_echoed now spawns with Popen. On KeyboardInterrupt it calls interrupt_tree: SIGINT to the child's process group (CTRL_BREAK_EVENT to its group on Windows), up to 5 s to exit, then SIGKILL to whatever is left of the group, and then it re-raises.
  • Test: a real process is interrupted while run_echoed runs 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

  • The change: child_output.kill_tree signals a detached child's whole process group on POSIX, since detached() 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_job and the handoff failure;
    • install_server's timeout;
    • the npm resolver's _terminate;
    • check_cli and get_cli_help, which now kill on timeout or cancellation. Before, a timed-out probe was left running.
  • The update probe and ClientManager already 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.
  • Test: an install_server that times out leaves no grandchild.

Claude N-C: the spawn backstop

  • Static guard, widened: it now resolves import subprocess as sp, from subprocess import run as r, from asyncio import create_subprocess_exec, asyncio.subprocess.create_subprocess_exec and loop.subprocess_exec/subprocess_shell, and it refuses os.spawn*, os.posix_spawn and os.fork. Unit shapes pin each form.
  • Audit hook (tests/conftest.py): one sys.addaudithook for the whole test session.
    • What it checks: every subprocess.Popen raised under a frame of pmcp's own source. From Popen._execute_child's resolved arguments, it must have a new session (start_new_session, or a process_group other than the 3.11+ default), stdin not fd 0 or inherited, and stdout and stderr not fds 1 or 2 or inherited.
    • What it refuses: os.fork, forkpty, exec, posix_spawn, spawn and system from pmcp code.
    • When it fails: violations fail the session once, at teardown.
  • Hook tests: a spawn behind getattr, compiled with a filename inside src/pmcp, is caught with all four problems; a detached one passes.

CI on a3855bc failed, fixed in 7c074a1 and aa3f459

  • What happened: the test jobs died partway through the suite with no output, at about 56%, and the coverage report then failed on a missing source file.
  • Cause 1, the group kill: kill_tree called os.killpg(pid) for any object with an integer pid. A test double's pid = 4321 (test_manifest's _FakeProc, cleaned up by the JobManager singleton) named a real process group on the runner, and the group SIGKILL ended the test run.
    • Now a group is signalled only for a real subprocess.Popen or asyncio Process, and only while that child is unreaped and leads its group (os.getpgid(pid) == pid). Anything else gets its own kill/terminate.
    • interrupt_tree waits for the leader with waitid(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.
    • New tests: a double whose pid is a real group leader gets no killpg, and a reaped child whose pid is "reused" gets none either. The mutants for each are M259 and M260.
  • Cause 2, coverage: the audit-backstop probe had been compiled with a file name under src/pmcp, which coverage then could not find. The hook now takes extra probe roots, and the probe uses a temporary one.
  • CI on aa3f459: all 13 checks pass. The test jobs took 39m48s (3.10), 33m35s (3.11) and 28m54s (3.12).

Gates at aa3f459 (ai, nice, uv run --isolated, after uv sync --all-extras -p 3.10)

Gate Result
ruff check src/ tests/ All checks passed
ruff format --check src/ tests/ 224 files already formatted
mypy src no issues in 62 source files
check_security_claims exit 0, 132 cited node ids
Targeted set on 3.10 / 3.11 / 3.12 1924 passed, 1 skipped / 1924 passed, 1 skipped / 1925 passed
Full suite on 3.10, with the audit backstop active for the whole session 12868 passed, 7 skipped, 1 xfailed, 0 failed
  • c5dedba failed three tests on ai, fixed in a3855bc:
    • The Ctrl-C test's parent inherited a background run's ignored SIGINT; the test now restores Python's handler.
    • The audit hook misread 3.11's process_group=-1 default.
    • selectors was not yet classified as a non-HTTP import.
  • The c5dedba mutant table was drained and discarded, because its baseline failed, and re-run at a3855bc.

Mutants at aa3f459: 231 numbered mutants plus 5 sink-guard checks, two passes over 12 lanes, all completed, every lane restored clean.

  • Pass A survivors: only M23 and M25.
  • Pass B survivors: M19, M20, M22, M23, M25, M35 and M150, unchanged.
  • New this round, killed in both passes:
    • M255: the deadline leaves the reader alive.
    • M256: no SIGINT forward.
    • M257: leader-only kill.
    • M258: the audit backstop disabled.
    • M259: kill_tree signalling the group of any object with a pid.
    • M260: a reaped child's pid still signalled as a group.
  • Re-anchored to the reworked runner and drain: M246 and M252, killed.

Round 7 (#388 panel): fixes, at head 747d74c

Codex F001: cancellation no longer skips the drain cleanup

  • The change: drained_errlog closes the stop pipe's write end in an inner finally, so it always runs, cancellation included. Both joins run off the loop under a shielded cancel scope.
  • Test: CancelledError is 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 no pmcp-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

  • The bug: a probe's leader exited while a grandchild it started still held the probe's output. check_cli, get_cli_help, install_server's timeout handler and JobManager._safe_terminate_process, which is _monitor_install's stall and crash path, all skipped the kill once returncode was set, so the grandchild kept running.
  • The fix: every one of these sites now calls child_output.kill_tree, whether or not the leader has exited. kill_tree goes through the one kill rule below. The pid-reuse protection is kept: it is ClientManager._kill_retained_group's logic, moved into child_output.signal_retained_group and shared.
  • Tests: for check_cli, get_cli_help, install_server and 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 polls returncode rather than awaiting wait(), which on 3.11+ waits for every pipe to close. The mutant is M262.

The one kill rule, and the kill-site audit

  • The rule: a group is signalled directly only while its leader reserves its id, meaning returncode is unset and waitid(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 through signal_retained_group, which acts only if a member remains and no process holds the leader's pid.
  • Windows: the kill terminates the child's Job Object, or kills the child alone if it has no job.
  • Audit: every kill site in src/pmcp, and the rule each one now goes through:
Site Path
environment.check_cli, get_cli_help (timeout, cancel, error) kill_tree
installer.install_server timeout kill_tree
JobManager._safe_terminate_process (terminate, kill, exited leader), crash cleanup, cancel_job, handoff failure kill_tree
npm_resolver._terminate kill_tree
handlers update-probe handoff failure kill_tree
handlers update probe on cancel _kill_process_tree_now, which is kill_process_tree_now
handlers update probe on timeout; ClientManager teardown _terminate_process_tree: entry and both _signal arms check leader_reserved, and after reaping only signal_retained_group is used. On Windows both arms terminate the job.
ClientManager cancel and shutdown _kill_process_tree_now
run_echoed Ctrl-C interrupt_tree: group SIGINT and SIGKILL only while reserved, otherwise kill_tree. On Windows, a short wait and then the job.
server.py orphan sweep Out of scope for this rule. It sends one SIGKILL to one pid, identified by its /proc cmdline fingerprint, and that pid is not a child pmcp spawned. It signals no group.
  • Tests: a child reaped behind returncode by a raw waitpid is not signalled as a group, and a teardown of a process whose returncode is set sends no direct group signal and does not signal the pid. The mutants are M260 and M266.
  • Never raises: kill_process_tree_now now suppresses any exception from the leader signal. An object without send_signal used to raise AttributeError, so M259 died on that crash; it now dies on its own assertion.

Claude N-A: no console control events on Windows

  • At spawn: each child is put in its own Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, through track() after every spawn. Jobs of children that have exited are closed, which kills anything left in them.
  • Ctrl-C: an interrupt waits 1 s and then calls TerminateJobObject.
  • Kills: a kill on Windows, which is F002's tree kill, terminates the same job. If job assignment fails, the child alone is killed.
  • Tests: a fake kernel32 seam records every call. track creates and assigns a job, interrupt and kill call TerminateJobObject, and neither GenerateConsoleCtrlEvent nor send_signal is 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

  • POSIX: the drain still uses the selector and stop pipe.
  • Windows: it uses a blocking reader. select there accepts a pipe's registration and fails only when it waits, so the choice is made by platform and not by catching a failure.
  • Bounded leak: a Windows drain cannot be stopped early. If a descendant holds the pipe past the context, the thread and its read end last until that descendant exits, and no longer. The context waits at most 5 s.
  • Tests: a Windows-semantics seam (_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

  • When data and the stop are both ready, the drain reads once more before ending. The test is data-plus-stop, and the mutant is M265.

Gates at 747d74c (ai, nice, uv run --isolated, after uv sync --all-extras -p 3.10)

  • Static checks: ruff check src/ tests/ passes, ruff format --check passes (224 files), mypy src finds no issues (62 files), and check_security_claims exits 0 with 132 cited node ids.
  • Targeted set: 3.10 has 1939 passed and 1 skipped (4m21s). 3.11 has 1939 passed and 1 skipped (4m05s). 3.12 has 1940 passed (3m37s).
  • Full suite (3.10): 12883 passed, 7 skipped, 80 deselected and 1 xfailed, in 17m26s.
  • Mutants, full set at 30cb940: 243 mutants in two passes (with and without the static guards). All were applied and all 12 lanes were clean afterwards.
    • Survivors in pass A: M23 and M25. Survivors in pass B: M19, M20, M22, M23, M25, M35 and M150. These are the same survivors as at aa3f459.
    • M266 also survived there. It is killed in both passes from 36fbb41.
  • Mutants, child-output set at 747d74c: M246 to M267, 21 mutants, are each killed by an assertion in both passes:
Mutant Killed by
M252 drain join on the loop test_the_drain_waits_off_the_event_loop
M255 stop pipe never closed the drain-deadline test
M257 group signal dropped the install-timeout grandchild test
M259 a double's pid signalled as a group assert groups == []
M260 a kernel-reaped child counted as reserved the raw-waitpid test
M261 stop writer not in finally the cancellation probe (first join)
M262 retained-group kill skipped the check_cli leader-exits-first test
M263 Windows drain uses select the Windows selector seam
M264 GenerateConsoleCtrlEvent reinstated the Job Object seam
M265 no final read the data-plus-stop test
M266 returncode guard dropped the exited-process teardown test
M267 Windows teardown terminates the job only on kill the Windows teardown test
  • Suite time: measured back to back on ai with coverage, the same way CI runs it. aa3f459 took 1340 s and 30cb940 took 1344 s, which is +0.3%. The new tests add 4.6 s.
    • The base run had one environmental error: another process's live gateway on ai's port 3344 restarted during the run, and a session guard caught it.
    • A 3.11/3.12-only 60 s wait in one new test, where Process.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.
  • CI on 747d74c: all 13 checks pass. The test jobs took 25m31s (3.10), 36m58s (3.11) and 33m0s (3.12). At aa3f459 they took 39m48s, 33m35s and 28m54s; the differences are runner variance, since the measured suite time is flat.

Round 8 (#388 panel): fixes, at head 4c681b5

Codex F001 = grok F001: import pmcp failed on Windows

  • The bug: signal.SIGKILL sat in two default arguments in child_output, and those are evaluated at import.
  • The fix: both defaults are now None, resolved at call time through _sigkill().
  • Static guard: test_no_posix_only_name_is_evaluated_when_pmcp_is_imported walks every module and fails on any POSIX-only signal/os name 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.
  • Real check: the new windows-smoke job imports the package on Windows (see below).

Codex F002 = gemini F001 = grok F001: Windows teardown skipped the Job Object

  • The fix: _terminate_process_tree takes 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 _signal is removed.
  • Tests: gemini's falsifier, 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 whose finally calls kill_tree synchronously 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_captured is the owned version of subprocess.run for short probes.

Spawn-exit audit:

Spawn site Normal exit Timeout Error Cancellation
environment.check_cli, get_cli_help owned owned owned owned
installer.install_server owned owned, then reaped owned owned (was missing)
installer.verify_installation owned owned (was missing: returned False and left the command running) owned owned
JobManager.start_install (spawn, then handed to the monitor) No await sits between the spawn and the monitor task, so there is no cancel point. The monitor's finally calls kill_tree on every exit except the handoff. stall: _safe_terminate_process crash: done-callback kill_tree monitor cancelled: _safe_terminate_process, then the finally; cancel_job: kill_tree
JobManager handoff to ClientManager.adopt_process the server's lifetime (row below) n/a handoff failure: kill_tree before registration: synchronous kill (#324)
ClientManager._connect_stdio, adopt_process (servers) disconnect: _terminate_process_tree idle or ceiling timeout: _terminate_process_tree handshake failure: _terminate_process_tree _kill_process_tree_now, synchronously (#324)
GatewayTools._run_update_probe_command finally: _kill_process_tree_now graceful _terminate_process_tree, then the finally finally finally (replaces the old handler)
npm_resolver._spawn (one helper per process) lives for the gateway's lifetime; exits on stdin EOF if the gateway dies query timeout: _terminate (kill_tree) _terminate on every failure n/a (synchronous)
run_echoed (pmcp upgrade, service restart) owned n/a owned Ctrl-C: interrupt_tree, then owned
run_captured (the CLI's systemctl checks; they were subprocess.run) owned owned owned Ctrl-C: owned
refresher.refresh_server (the MCP SDK's stdio_client) the SDK's context exit terminates its child; stderr is drained through drained_errlog same same same
  • Tests: a verification that times out leaves neither the command nor its grandchild running. A cancelled install_server leaves 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

  • The approach: _PollingReader reads only what PeekNamedPipe reports as ready, so it never blocks in ReadFile, and between looks it checks a stop event that drained_errlog sets at its deadline.
  • The bound: after the stop, one more look and at most one read, within _POLL_SECONDS (0.05 s). The descendant holding the write end makes no difference. Both joins are back on Windows.
  • Tests:
    • a fake pipe whose writer never closes and never writes ends within one look of the stop;
    • under the Windows seam (with a POSIX stand-in for PeekNamedPipe), the drain ends at its deadline while a descendant holds the pipe, and the descendant is still alive;
    • on a real Windows host, the same holds against a real pipe.
  • Mutant: M271.

Claude N-A: a retained pgid is forgotten once its group is empty

  • The registry: child_output keeps 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).
  • When an id is forgotten: at the first probe that finds the group empty. That is signal_retained_group, observe_group, or the teardown's own liveness probe.
  • The watcher: watch_group is 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.
  • What is never signalled: an untrusted id, or an id trusted for a different process object.
    • A passed id is signalled directly only while the leader is reserved and the id is that leader's own group.
    • If the leader is reaped between the reserved check and the lookup, the id goes through the retained-group checks rather than being dropped.
  • Residual window: after the leader exits and before the next 0.5 s probe. A reuse in that window needs a full pid wrap plus a new group leader that exits while its members live.
  • Tests:
    • a real group, forgotten by watch_group once empty, is never signalled again, even when a foreign group with that id passes the reuse check;
    • an id trusted for one process does not vouch for another.
  • Mutant: M272.

Windows CI

  • The job: windows-smoke, in its own workflow .github/workflows/windows.yml, runs on windows-latest with a 20-minute timeout. It runs uv sync --all-extras, imports pmcp, runs pmcp --version and pmcp --help, and runs tests/test_child_output.py. check_workflows.py and test_workflow_guards.py pass; the pinned-uses count went from 32 to 34, the same two pinned actions.
  • First real run: it confirmed F001's fix, since import and the entry point passed. It then exposed three problems in the test setup, all fixed:
    • pytest-timeout's signal method needs SIGALRM, so this job runs with -o timeout_method=thread;
    • the conftest home redirect also sets USERPROFILE, which is what Windows' Path.home() reads;
    • the store guard now refuses the real store's directory rather than the whole real home, because Windows' temp directory is inside it.
  • Result: 22 passed and 29 skipped (the POSIX-only tests) in about 50 s.
  • Ran for real on Windows:
    • a Job Object kill reaching the grandchild;
    • teardown of a leader that already exited ending its grandchild;
    • the PeekNamedPipe drain reading every line and stopping at its deadline with the writer open;
    • the polling reader reading the last bytes at the stop.

Follow-ups found while verifying

  • Adopted servers: a server handed over by the installer gets the same group watcher as a spawned one.
  • Lost lookup: if the leader is reaped between the reserved check and the getpgid lookup, a passed group id goes through signal_retained_group instead of being dropped.
  • SECURITY.md R22: the Windows job moved into its own workflow, .github/workflows/windows.yml. The claim checker holds test.yml to exactly one pytest invocation, and a second one in test.yml broke R22. check_workflows.py --base-ref origin/main passes, with drift reporting only windows.yml.

Gates at 4c681b5 (ai, nice, uv run --isolated, after uv sync --all-extras -p 3.10)

  • Static checks: ruff check and ruff format --check pass on src/ and tests/, mypy src finds no issues, and check_security_claims exits 0 with 132 cited node ids.
  • Targeted set: 1947 passed and 5 skipped on 3.10 (3m28s) and on 3.11 (3m12s); 1948 passed and 4 skipped on 3.12 (3m00s).
  • Full suite (3.10): 12891 passed, 11 skipped, 80 deselected and 1 xfailed, in 16m51s. The adoption-lock flake seen once locally (255 == -SIGKILL) did not recur.
  • Mutants (all 249, two passes) at 67d7d3a: the code under test is the same as 4c681b5, which changes only the workflow. All 498 runs applied and every lane was clean afterwards.
    • Pass A survivors: M23 and M25. Pass B survivors: M19, M20, M22, M23, M25, M35 and M150. These are the carried survivors from earlier rounds.
    • Every child-output mutant from M246 to M273 is killed by an assertion, including this round's M268 to M273. M248, M262, M263 and M267 were re-anchored.
  • Suite time: measured back to back on ai with coverage, the way CI runs it. 747d74c took 1344 s and 67d7d3a took 1361 s, which is +1.3%.
    • Most of the difference is in httpx redirect tests that resolve hostnames. They took under 0.05 s in the base run and up to 5.6 s in the head run, during a DNS outage on the host; this round does not touch them.
    • The new child-output tests add 1.5 s.
  • Windows CI on 4c681b5 (windows.yml, from pull_request): import pmcp and the entry point pass (pmcp 3.0.0), and 22 tests pass with 29 POSIX-only ones skipped, in 36 s.
  • CI on 4c681b5: all 14 checks pass, the new windows-smoke among 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.py pins 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_cli and get_cli_help;
  • install_server, timed out and cancelled;
  • verify_installation;
  • the update probe;
  • a ClientManager server;
  • a cancelled install job;
  • the stderr drain, with both the POSIX reader and the Windows reader.

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

  • The fix: _PollingReader checks 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.
  • Tests: a source that is never empty (/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

  • The bug: watch_group awaited process.wait(), which on 3.11+ also waits for the pipes to close.
  • The fix: it now only polls the group (killpg(pgid, 0)) every 0.5 s until the first empty probe, and never waits on the process or its pipes.
  • Cleanup: the watcher is now a ManagedClient.group_watch task, cancelled in both per-client cleanups, the graceful one (_cleanup_client_io, used on reconnect) and the abandoning one.
  • Tests:
    • a leader whose descendant left the group but kept the pipes has its empty group forgotten;
    • a wait() that never returns does not hold the watcher, on every Python;
    • both cleanups cancel the watcher.
  • Mutants: M275 and M276.

Codex F003: the retained group was dropped in a reaping race

  • The fix: kill_tree keeps the spawn contract's group id when the leader is reaped between leader_reserved() and getpgid(). The retained-group checks decide: the id must still be trusted for this process and the group must not be empty.
  • Test: the race is simulated with a real reaped leader and a live grandchild. The grandchild is killed. The mutant is M277.

Grok F001: an exited install leader left its grandchild

  • The handoff: an install that reports its server started and then exits is found dead at the handoff (_finalize_server_ready), and its group is now killed. Nothing will adopt it.
  • cancel_job: it now kills an exited leader's group too.
  • Unadopted servers: a server_ready server is owned by its job until gateway.provision_status adopts it. If it is never adopted, it is killed with its group after HANDOFF_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.
  • Tests: grok's falsifier, test_exited_install_leader_does_not_leave_its_grandchild; an unadopted server killed at the deadline; and a failing verification returning False.
  • Mutants: M278, M279 and M280.

Claude N-B

Gates at 910bed0 (ai, nice, uv run --isolated, after uv sync --all-extras -p 3.10)

  • Static checks: ruff check and ruff format --check pass on src/ and tests/, mypy src finds no issues, and check_security_claims exits 0 with 132 cited node ids.
  • Targeted set (now including 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.
  • Full suite (3.10): 12929 passed, 11 skipped, 80 deselected and 1 xfailed.
  • Mutants (all 256, two passes): all 512 runs applied and every lane was clean afterwards.
    • Survivors are the carried ones only. Pass A: M23 and M25. Pass B: M19, M20, M22, M23, M25, M35 and M150.
    • M246 to M280 are all killed by assertions. That includes this round's M274 to M280 and the re-anchored M270.
  • Suite time: measured back to back on ai with coverage, the way CI runs it. 4c681b5 took 1371 s and 910bed0 took 1363 s, which is flat. The lifetime table adds 18.5 s.
  • Windows (scratch dispatch of the same tree, before the push): 24 passed and 35 skipped.
  • CI on 910bed0: all 14 checks pass. windows-smoke took 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

  • Ownership moves atomically. Before its first await, _finalize_server_ready cancels the job's handoff deadline, moves the job to adopting and drops job.process. From then on the job no longer owns the process, ClientManager does, and its own timeouts govern the handshake.
  • The tool's output is unchanged. adopting is internal: gateway.provision_status still reports it as server_ready, so the tool's output schema doesn't change.
  • Exactly one owner kills the process at every point:
    • a refusal before registration, e.g. a leader that exited during the handoff, is killed by the handoff's failure arm, unconditionally and with its group;
    • after registration, ClientManager's own teardown kills it. The failure arm's later group kill then finds the group empty, which also forgets the id;
    • a cancelled handoff records the end and re-raises; ClientManager's two cancellation arms kill synchronously.
  • Tests:
    • claude's 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;
    • grok's case of the deadline passing during a held handshake, as a lifetime-table row;
    • a leader that exits mid-handoff leaves no grandchild.
  • Mutants: M281 and M284.

Codex F001: a dropped Process left its pipe transports open

  • One helper: child_output.release() closes a child's pipes at once: asyncio's pipe transports, including one a flooding downstream left paused and unread, or a Popen's streams.
    • The subprocess transport itself is closed only once the child is reaped. Closing it earlier reaps the child behind asyncio's watcher, which then reports exit status 255 instead of the real one; this was measured on 3.12.
  • Called at every drop:
    • owned(), handoff expiry, cancel_job, the install monitor's exits and its crash callback;
    • the handoff's failure and cancel arms, and adopt_process's cancel-before-lock;
    • both shared ClientManager teardowns. _terminate_process_tree's whole body runs in a try/finally (its name, which the static cancellation guards key on, is kept), and _kill_process_tree_now releases after its kill.
  • Tests: a flooded, unread, paused pipe is closed by each teardown helper with automatic garbage collection turned off. Every lifetime-table row now checks fds with GC off. The table's expiry-with-flood row catches a missing release (19 fds against a baseline of 18).
  • Mutants: M282, M285 and M286.

Gemini

  • The handoff deadline is cancelled explicitly on the failure branch and by cancel_job, not only on success. The mutant is M283.

Claude's coverage list: new rows in tests/test_child_lifetime.py

  • The server_ready handoff: 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.
  • The npm resolver's long-lived node helper, against a downstream that never completes the handshake.
  • The refresh child itself. The MCP SDK spawns it and, on exit, looked up the group of a leader that may already have exited, so a grandchild of an early-exiting leader outlived 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.
  • Totals: 19 owners × 3 behaviours = 57 rows. Each checks processes, drain threads and fds with GC off.

Found along the way

  • Session spawn audit: the feedback report's platform.platform() runs uname -p, a child with pmcp's stdin that is not detached. The report line now uses platform.system(), release() and machine().

Found while verifying: Ctrl-C could not stop pmcp upgrade against a child that floods

  • Symptom: the lifetime table's run_echoed (Ctrl-C)-flood row timed out under mutant load, once in 36 runs. With faulthandler, the harness's stack showed it inside communicate().
  • Cause: with stdin DEVNULL and 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 made pmcp upgrade immune to Ctrl-C and held all its output in memory.
  • Fix: run_echoed now streams through bounded_lines, one read1 syscall per chunk, echoing each line as it arrives. Memory stays at one capped line.
  • Falsifier: a flood with no pause between writes, test_ctrl_c_reaches_run_echoed_while_its_child_floods. With communicate() restored (M287), it hangs.
  • Runner ownership: run_echoed and run_captured also take ownership of their child from the moment Popen() returns.
  • Polls during adoption: a provision_status poll that reads adopting waits on the finalize lock and reports the handoff's result. This was found by the full suite's test_concurrent_polls_adopt_once.

Gates at 9a7a430 (ai, nice, uv run --isolated, after uv sync --all-extras -p 3.10)

  • Static checks: ruff check and ruff format --check pass on src/ and tests/, mypy src finds no issues, and check_security_claims exits 0 with 132 cited node ids.
  • Targeted set (with 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.
  • Full suite (3.10): 12962 passed, 11 skipped, 80 deselected and 1 xfailed.
  • Mutants, full set (262, two passes) at aa1397c: all 524 runs applied and every lane was clean afterwards. Survivors are the carried ones only: M25 in pass A; M19, M20, M22, M23, M25, M35 and M150 in pass B. M23's apparent kill in pass A was the Ctrl-C flood timeout described above.
  • Mutants, child-output subset (M23 and M246 to M287, two passes) at 9a7a430: 86 runs, every lane clean. Every mutant is killed except M23, which survives in both passes as a carried survivor. This round's M281 to M287 are all killed by their intended tests.
  • Suite time: measured back to back on ai with coverage, the way CI runs it, with an unrelated job running in the background for both. 910bed0 took 1452 s and 9a7a430 took 1456 s, which is flat. The lifetime table's 57 rows add 33.7 s.
    • The base run had one environmental error: a live gateway on ai's port 3344 restarted during the run.
  • Windows (scratch dispatch of 9a7a430, before the push): 24 passed and 41 skipped.
  • CI on 9a7a430: all 14 checks pass. windows-smoke took 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_job left the server running

  • The fix: cancel_job now sends SIGTERM and waits up to 2 s, inside a try. The kill, closing the pipes and the status update all happen in its finally, which runs on CancelledError. 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.
  • Tests:
    • codex's repro: a SIGTERM-ignoring server_ready server, whose caller is cancelled during the graceful wait, is killed, its pipes are closed, and its status is failed;
    • the backstop: if the kill does not happen (here it raises), the deadline stays armed.
  • Mutants: M288 (deadline disarmed before the kill) and M289 (the kill not in finally).

The class rule: a cancelled teardown never loses its kill

  • Audit: I checked every teardown and cancel path. cancel_job was the only one with a kill behind a cancellable await:
    • _safe_terminate_process's wait: the monitor's finally kills synchronously;
    • install_server's post-timeout reap: owned() has already killed;
    • _terminate_process_tree: its cancellation arm kills and its finally releases the pipes;
    • _cleanup_client: it abandons, which kills synchronously;
    • the update probe, owned() and run_captured: their kill is synchronous in a finally;
    • the handoff expiry: its kill is synchronous after its sleep;
    • the refresh: its kill runs in a finally;
    • the npm resolver: it is synchronous.
  • The new cancel dimension in tests/test_child_lifetime.py: a downstream that ignores SIGTERM, so every graceful wait really waits, with a grandchild holding the pipes. Every bounded_wait on a process's exit inside a teardown is cancelled instead of awaited.
    • It runs for every owner whose teardown awaits the child's exit: install_server (timeout and cancelled), the update probe, a ClientManager server, a cancelled install job, and the three adoption handoffs.
    • The other owners kill synchronously and have nothing to cancel, or they are test harnesses that a cancel would SIGKILL.
    • The child is gone and its fds are released within the bound. The 8 rows add about 5 s.
    • M289 is caught by this dimension's install job (cancelled) row.

Claude N-A: a closed stdout killed the installer mid-upgrade

  • The fix: on BrokenPipeError, run_echoed stops 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.
  • Exit-time flush: descriptor 1 is pointed at the null device so the interpreter's flush at exit stays quiet. This applies only when stdout is the human CLI's display stream, never a gateway's protocol stream.
  • Test: pmcp upgrade | head -1 simulated. The installer runs to its end, rc=7 is returned, and no traceback is printed.
  • Mutant: M290.

Claude (pre-existing): adoption overwrote a connected client of the same name

  • Confirmed and fixed here: _adopt_process_locked assigned self._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.
  • Test: two adoptions under one name. The first process is killed, and the second is the registered client.
  • Mutant: M291.

Claude N-B and its orphan-scan note

Gates at c9b42b5 (ai, nice, uv run --isolated, after uv sync --all-extras -p 3.10)

  • Static checks: ruff check and ruff format --check pass on src/ and tests/, mypy src finds no issues, and check_security_claims exits 0 with 132 cited node ids.
  • Targeted set: 2030 passed and 16 skipped on 3.10 and on 3.11; 2031 passed and 15 skipped on 3.12. The skips are the cancel dimension's rows for owners with no teardown await.
  • Full suite (3.10): 12974 passed, 22 skipped, 80 deselected and 1 xfailed.
  • Mutants (all 267, two passes): all 534 runs applied and every lane was clean afterwards.
    • Survivors are the carried ones only. Pass A: M23 and M25. Pass B: M19, M20, M22, M23, M25, M35 and M150.
    • M288 to M291 are each killed by their intended test: the backstop test, the cancel dimension's install job (cancelled) row, the closed-stdout test and the same-name adoption test.
  • Suite time: measured back to back on ai with coverage, the way CI runs it. 9a7a430 took 1456 s and c9b42b5 took 1421 s, which is flat. The cancel dimension adds 5.9 s.
  • Windows (scratch dispatch of c9b42b5, before the push): 24 passed and 45 skipped.
  • CI on c9b42b5: all 14 checks pass. windows-smoke took 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

  • The bug: round 11's same-name teardown added an await between adoption's _abandoned check and its registration. A disconnect_all cancelled while waiting on the lifecycle lock runs abandon_all_now(), and adoption then registered the replacement after the sweep.
  • The class, fixed at its single point: ClientManager._register_or_kill() is now the only place a client is registered. It checks _abandoned with 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 reads failed.

Registration-site audit. Every path that registers a client, and the await it registers after:

Path Registers through Await before registering
startup / connect_all, connect_server, refresh, lazy start, reconnect (_reconnect_loop → _connect_singleflight) _connect_server → _connect_stdio the spawn (create_subprocess_exec, the spawn semaphore)
the same, for a remote server _connect_server → the remote connect await ready (the transport owner); a refusal unwinds the owner in its existing except BaseException arm
install handoff (adopt_process) _adopt_process_locked the lock, and the same-name client's teardown
  • Static test: an AST test pins _register_or_kill as the only assignment into ClientManager._clients.
  • Unchanged: _connect_server's and adoption's existing up-front checks still refuse early.
  • After registration: if shutdown sweeps later, abandon_all_now() finds the client in _clients and kills it.

Tests:

  • codex's interleaving with real processes: a disconnect_all is cancelled while adoption holds the lock to tear down a SIGTERM-ignoring same-name client. The replacement is killed, the handoff and the job read failed, and no client is registered.
  • connect and reconnect: the shutdown sweep runs during the spawn await. The new server is killed, _ManagerAbandoned is raised, and no client is registered.
  • the static single-site test.

Mutant: M292 (no abandon check at registration). It is killed by codex's interleaving test, which reads complete instead of failed, and by the connect test.

Claude's notes

  • cancel_job takes the process from the job before its first await, as the handoff does. Tested.
  • pmcp upgrade 2>&1 | head keeps 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

  • The task-record guard: this guard requires every function that registers a connection to drop the name's records first. Registration now lives in _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.
  • A test-timing flake: the interleaving test waits for the old process to signal that SIGTERM is ignored before adopting. Otherwise, under load, the old client's teardown could finish before the shutdown's cancel landed.

Gates at def8722 (ai, nice, uv run --isolated, after uv sync --all-extras -p 3.10)

  • Static checks: ruff check and ruff format --check pass on src/ and tests/, mypy src finds no issues, and check_security_claims exits 0 with 132 cited node ids.
  • Targeted set: 2035 passed and 16 skipped on 3.10 and on 3.11; 2036 passed and 15 skipped on 3.12.
  • Full suite (3.10): 12979 passed, 22 skipped, 80 deselected and 1 xfailed.
  • Mutants, full set (268, two passes) at 5bceb81: this differs from def8722 only in the one-line record drop. All 536 runs applied and every lane was clean afterwards. Survivors are the carried ones only: M23 and M25 in pass A; M19, M20, M22, M23, M25, M35 and M150 in pass B.
  • Mutants, child-output subset (M23 and M246 to M292, two passes) at def8722: 96 runs, every lane clean. Every mutant is killed except M23, a carried survivor that survives in both passes.
  • Suite time: measured back to back on ai with coverage, the way CI runs it. c9b42b5 took 1551 s and def8722 took 1489 s, which is flat.
  • Windows (scratch dispatch of def8722, before the push): 25 passed and 49 skipped.
  • CI on def8722: all 14 checks pass. windows-smoke took 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

  • The bug: the child already runs while create_subprocess_exec awaits 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.
  • The fix: one helper, child_output.spawn_owned(start), runs every async spawn under asyncio.shield, so a caller's cancellation never reaches the spawn.
    • If the caller was cancelled meanwhile, a done-callback owns the child the moment it exists: it is 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.
    • Otherwise the child is tracked and returned.
  • A static test requires every create_subprocess_exec and create_subprocess_shell call in src/pmcp to be the factory of a spawn_owned.
  • Residual (documented in the helper): a loop being torn down cancels the shielded spawn itself, and asyncio then kills only the leader. The gateway abandons its servers, and sweeps its install jobs, before that point.

Spawn sites:

Site Path
ClientManager._connect_stdio (connect, reconnect, refresh, lazy start) spawn_owned
JobManager.start_install spawn_owned
install_server spawn_owned
verify_installation spawn_owned
check_cli, get_cli_help spawn_owned
GatewayTools._run_update_probe_command spawn_owned
refresh_server (the MCP SDK's stdio_client, through its process factory, which the refresher captures) spawn_owned. The SDK's anyio Process is 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) synchronous, so no asyncio cancellation can land between Popen returning and ownership. The two runners are owned() from the moment Popen returns.
  • Tests:
    • CPython's leader-only kill reproduced with a child that forks at once, the caller cancelled while the spawn connects its pipes: the grandchild is killed. Without the shield, it survives.
    • The new lifetime-table spawn-cancel dimension, 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 patches loop.subprocess_exec, so the SDK's anyio spawn is covered too. The 8 rows take about 9 s.
  • Mutant: M293 (the spawn not shielded).

Claude F001: a provisioned server waiting for adoption survived gateway shutdown

  • Deadline: the handoff deadline's sleep now has a 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 and cancel_job take the process from the job before they cancel the deadline.
  • Sweep: JobManager.shutdown() kills every job's process, installing or server_ready, synchronously and with its group, and cancels the jobs' tasks. The gateway's shutdown() calls it in its finally, on every exit.
  • Tests:
    • claude's falsifier, test_a_server_ready_job_is_killed_when_the_gateway_loop_shuts_down: a real asyncio.run ending with a server_ready job. The server and its grandchild are gone.
    • the sweep kills a ready job's server, and the gateway's shutdown() calls it.
    • a lifetime-table row, handoff: the gateway shuts down while server_ready.
  • Mutants: M294 (the deadline sleep without 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

  • The exception-text guard: the abandoned-spawn callback used .exception() inline, which test_no_exception_reaches_text_except_through_the_renderer forbids. It now binds the result to a name (c1b294c).
  • Invalid mutant run: that guard failure invalidated the first mutant run, because with the guard failing on the base tree every pass-A mutant looked killed. I stopped that run and redid it in full at c1b294c.
  • The reconnect test: the round-12 reconnect case now accepts CancelledError as 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, after uv sync --all-extras -p 3.10)

  • Static checks: ruff check and ruff format --check pass on src/ and tests/, mypy src finds no issues, and check_security_claims exits 0 with 132 cited node ids.
  • Targeted set: 2051 passed and 29 skipped on 3.10 and on 3.11; 2052 passed and 28 skipped on 3.12.
  • Full suite (3.10): 12995 passed, 35 skipped, 80 deselected and 1 xfailed.
  • Mutants (all 272, two passes): all 544 runs applied and every lane was clean afterwards.
    • Survivors are the carried ones only. Pass A: M23 and M25. Pass B: M19, M20, M22, M23, M25, M35 and M150.
    • Each new mutant is killed by its intended test:
      • M293: the spawn-cancel dimension;
      • M294: the gateway shuts down while server_ready row;
      • M295: the sweep test;
      • M296: the refused-registration pipe test.
  • Suite time: measured back to back on ai with coverage, the way CI runs it. def8722 took 1468 s and c1b294c took 1577 s.
    • The difference is the network, not this round's code. It sits in catalog-search tests that call registry.modelcontextprotocol.io, which took 0.3 s per test in the base run and about 6 s in the head run.
    • Re-timed alternately on ai, those tests took def8722 17.5 s, c1b294c 5.0 s, def8722 5.2 s, c1b294c 8.7 s.
    • The round's own additions, the spawn-cancel dimension and the shutdown row, add about 10 s.
  • Windows (scratch dispatch of c1b294c, before the push): 26 passed and 53 skipped.
  • CI on c1b294c: all 14 checks pass. windows-smoke took 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

  • The bug: when asyncio.run ends, 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, because start_install sets job.process only after the spawn returns.
  • Fix: the gateway finishes owning every spawn in flight before the loop's own cancellation.
    • The spawn task records the pid. Each spawn runs in a _SpawnTask, registered in a set of pending spawns. Its cancel() records the child's pid before the cancellation lands. CPython's _make_subprocess_transport still holds the transport (transp._pid) while it connects the pipes, so the pid is read from the coroutine's frames.
    • The group is killed after CPython's leader-only kill. When the cancelled spawn settles, the group of the recorded pid is killed through the retained-group rule: trusted for that spawn, not empty, its pid not reused. CPython has killed and reaped the leader by then; this reaches what the leader forked.
    • 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() and JobManager.shutdown() call it.
    • drain_pending_spawns() (async, bounded at 3 s) runs in the gateway's shutdown() before it returns to asyncio.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 nested finally keeps the job sweep running even if the drain itself is cancelled.
  • Tests:
    • codex's falsifier: a real asyncio.run ends while a spawn is still connecting its pipes, with CPython's own _connect_pipes held. No leader and no grandchild remain.
    • the drain: the gateway's 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.
    • the round-13 spawn-cancel dimension still passes.
  • Mutants: M297 (the drain skipped) and M298 (no pid recorded before the cancel).
  • Windows: a spawn cancelled by loop teardown before it is tracked has no Job Object yet, so only the leader is killed there. On Windows, the drain is what covers it.

Claude N-A: SIGHUP skipped every teardown

  • The fix: cli.shutdown_signals() now includes SIGHUP on POSIX, and cli.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.
  • Windows: a closed console window (CTRL_CLOSE_EVENT) is not handled; noted in the code.
  • Test: a real gateway subprocess holds a server_ready install job and its live server, and receives SIGHUP. The gateway shuts down, and the server and its grandchild are gone.
  • Mutant: M299.

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

  • The loop-teardown falsifier hung on 3.11 and 3.12. CPython there finishes a subprocess transport only once every pipe has connected and then disconnected. The test's held _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 a finally, 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, after uv sync --all-extras -p 3.10)

  • Static checks: ruff check and ruff format --check pass on src/ and tests/, mypy src finds no issues, and check_security_claims exits 0 with 132 cited node ids.
  • Targeted set: 2054 passed and 29 skipped on 3.10 and on 3.11; 2055 passed and 28 skipped on 3.12.
  • Full suite (3.10): 12998 passed, 35 skipped, 80 deselected and 1 xfailed.
  • Mutants (all 275, two passes) at 38cd8f3: the source is identical at ee0444f, which changes only the test. All 550 runs applied and every lane was clean afterwards.
    • Survivors are the carried ones only. Pass A: M23 and M25. Pass B: M19, M20, M22, M23, M25, M35 and M150.
    • Each new mutant is killed by its intended test:
      • M297: the drain test (shutdown returned with the leader alive);
      • M298: codex's falsifier;
      • M299: the SIGHUP test.
  • Windows (scratch dispatch of ee0444f, before the push): 26 passed and 56 skipped.

Left for a filed issue (per the new review bar)

  • Windows: a spawn cancelled by loop teardown before it is tracked has no Job Object, so only the leader is killed there. The gateway's drain covers normal shutdown.
  • Windows: a closed console window (CTRL_CLOSE_EVENT) is not handled.
  • Process-global state: the job manager and the pending-spawn set are process-global, which only affects several gateways in one interpreter (claude N-B).
  • CI on ee0444f: all 14 checks pass. windows-smoke took 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.
  • Suite time: measured back to back on ai with coverage, the way CI runs it. c1b294c took 1515 s and ee0444f took 1469 s, which is flat. The new tests add about 2 s.

Round 15 (#388 panel): fix, at head a3b5233

Codex, claude and gemini: the SIGHUP handler broke the documented nohup deployment

  • The bug: install_shutdown_signals replaced the SIG_IGN that nohup sets for SIGHUP, so nohup pmcp --transport http & (README.md) shut down when the operator logged out.
  • The fix: a shutdown signal the process inherited as ignored is now left ignored, for every shutdown signal: signal.getsignal(sig) is signal.SIG_IGN means skip it. That covers SIGHUP under nohup, 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.
  • Tests: the real-gateway test now runs both ways, each with a server_ready install job and its live server:
    • under a real nohup: SIGHUP leaves the gateway and its server running. A later SIGTERM shuts the gateway down with everything it started.
    • terminal launch (SIGHUP at its default disposition): SIGHUP shuts the gateway down, and the server and its grandchild are gone. The case sets that disposition explicitly, because a test process that itself inherited SIGHUP as ignored, such as a nohuped CI or ai run, would otherwise pass it on.
  • Mutant: M300 (the SIG_IGN check 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, after uv sync --all-extras -p 3.10)

  • Static checks: ruff check and ruff format --check pass on src/ and tests/, mypy src finds no issues, and check_security_claims exits 0 with 132 cited node ids.
  • Targeted set: 2055 passed and 29 skipped on 3.10 and on 3.11; 2056 passed and 28 skipped on 3.12.
  • Full suite (3.10): 12999 passed, 35 skipped, 80 deselected and 1 xfailed.
  • Mutants: M299 and M300, two passes, at 35a7a68, which has the same source as a3b5233 (only the test changed). Both are killed.
  • Windows (scratch dispatch of 35a7a68, same source): 26 passed and 57 skipped.

Round 16 (#388 panel): fix, at head 3b71ac9

Codex F001: a terminal hangup or SIGTERM during pmcp upgrade left the installer running

  • The bug: only the gateway installed shutdown handlers. During pmcp upgrade, SIGHUP or SIGTERM killed pmcp without running its cleanup, and the detached installer no longer got the group's signal itself.
  • Synchronous runners: run_echoed and run_captured now install SIGTERM and SIGHUP handlers while their child runs, through child_output.terminating_on_signal().
    • The signal raises in the waiting thread. Streaming means it interrupts the wait.
    • It is forwarded to the child's group as received (on Windows the Job Object is terminated). After a moment, whatever is left of the group is killed, and pmcp exits 128 + signum.
    • The previous dispositions are restored afterwards, and an inherited SIG_IGN is kept (round 15's rule). Ctrl-C keeps its own path.
  • Async subcommands: async_main handles 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 exits 128 + signum.

Subcommand audit. Every CLI path that starts a child:

Subcommand How it spawns On SIGTERM / SIGHUP
upgrade (the installer: uv, pip, ...) run_echoed forwarded to the group, then killed; exit 128+n
the service restart (systemctl / launchctl) run_echoed same
the service checks (systemctl is-active) run_captured same
refresh (the MCP SDK's child, the npm resolver) async, spawn_owned the task is cancelled, so the owners kill the children; spawns in flight are drained
update, doctor, init, status, logs, secrets, auth, trust, setup, config, guidance, capabilities async or sync; any child goes through the same owned spawners same
the gateway (no subcommand) its own shutdown (rounds 13-15) unchanged

Tests:

  • codex's reproduction: a real pmcp upgrade with a fake uv that starts a grandchild and runs on. SIGHUP, and separately SIGTERM, is sent to the parent's process group. pmcp exits 128 + signum, and the installer and its grandchild are gone.
  • under nohup: SIGHUP stays ignored, and the upgrade runs to its end.
  • an async subcommand that owns a child, at SIGTERM: exit 143, and the child and its grandchild are gone.

Mutants:

  • M301 (run_echoed not forwarding): killed. Without the runner's own handler the signal reaches only the async loop's handler, which cannot run while run_echoed blocks the loop's thread, so pmcp hangs and the test times out.
  • M302 (async subcommands not handled): killed.
  • M256 (Ctrl-C not forwarded): re-anchored, still killed.

Found while verifying

  • The refusal-remedy audit read its dispatch chain from cli.async_main, which now wraps the chain (moved into cli._dispatch) with the signal handling. The audit now reads _dispatch.
  • Two cancellation guards now classify 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, after uv sync --all-extras -p 3.10)

  • Static checks: ruff check and ruff format --check pass on src/ and tests/, mypy src finds no issues, and check_security_claims exits 0 with 132 cited node ids.
  • Targeted set: 2059 passed and 29 skipped on 3.10 and on 3.11; 2060 passed and 28 skipped on 3.12.
  • Full suite (3.10): 13003 passed, 35 skipped, 80 deselected and 1 xfailed.
  • Mutants, child-output subset (M23 and M246 to M302, two passes) at 402f826: this differs from 3b71ac9 only in the remedies test. 116 runs, every lane clean. Every mutant is killed except M23, a carried survivor.
  • Windows (scratch dispatch of 402f826, same source): 26 passed and 61 skipped.
  • CI on 3b71ac9: all 14 checks pass. windows-smoke took 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

ViperJuice and others added 30 commits September 30, 2026 18:31
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
…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
…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)
ViperJuice and others added 7 commits October 11, 2026 15:38
…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
ViperJuice and others added 12 commits October 11, 2026 22:05
…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
ViperJuice and others added 4 commits October 13, 2026 12:04
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant