fix(crash-dumps): take the PID-reuse identity from the persisted-identity routine - #8160
Conversation
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've analyzed the single candidate and the full diff. Let me verify the key falsification point about the pre-change vs. post-change robustness and the documented contract. The candidate claims a regression: the diff replaces a byte-based Assessing this against the bar:
That third leg is exactly where it dies. Returning No new grounded defect surfaced in Step 2: the No findings. [OPUS-REVIEWED] f6172d6 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All claims verified against the base tree. Writing the review. First-Principles-Verdict: CONCERNS The fix is aimed at the real cause and every piece earns its place, but the same destroy-on-mismatch What this change shipsIntent: stop the dump sweeper and cron breaker from destroying a live gateway's evidence when the macOS start-identity render drifts — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] f6172d6 |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of All the description's claims check out against the base tree: The only design fragility I found: Design-Verdict: PASS Root-cause fix with the migration hazard it creates explicitly guarded on both destructive readers; every description claim is backed by verified code. Suggestions
[DESIGN-REVIEWED] f6172d6 |
7024052 to
af4aac7
Compare
af4aac7 to
3aa7f83
Compare
b7dd823 to
b53c93d
Compare
…tity routine crash_dump_store._pid_start_id decides whether a dump's recorded owner is still alive. Two readers act on that answer destructively: sweep_stale_dumps UNLINKS a header-only dump whose owner it believes dead, while faulthandler still holds that file's fd, and cron_inflight.RunningMarker.owner_alive reads the same identity to conclude a run was abandoned. kirodotdev#8282 gave the function a fallback to platform_compat.process_start_time so it could answer off Linux. That routine's Windows leg is the creation FILETIME -- a machine integer at 100-ns resolution -- and it is kept here untouched. Its remaining POSIX leg is `ps -o lstart=`: 1-second, locale- and TZ-rendered, and documented as safe precisely because "a format or resolution drift can only make the guard decline to act". That is a KILL-guard contract, where a mismatch means do nothing. Under these two readers a mismatch means act, so a drifted render deletes a live gateway's dump instead of declining -- and two gateways sharing one data home need only differ in TZ or LC_TIME to render the same instant differently. Take the identity from platform_compat.get_process_start_id instead: the routine this repository already uses everywhere a start identity is WRITTEN DOWN and compared back later (mcp_gateway.claim, session_pid, metrics.sessions). It is in-process on every platform it covers, and on macOS it reads libproc's microsecond start instant -- so macOS keeps the coverage kirodotdev#8282 gave it, at a resolution that also separates two processes started in the same second. Both legs still route through platform_compat; neither the procfs read nor the `ps` invocation is re-implemented locally. Changing the representation is itself a hazard, and it is guarded rather than ignored. A macOS gateway that wrote its header under the previous build recorded a `ps` render, which can never equal what is read now; comparing the two as though they were one kind of identity would unlink the dump of a gateway still running across the upgrade -- the overlapping restart that sweep_stale_dumps documents a live PID as protecting. _start_ids_comparable therefore treats a value that is not in the CURRENT representation as unknown rather than different, and both destructive readers consult it. It is an allowlist of what this build writes (digits, optionally one '.'), not a denylist of the retired render, whose text depends on the writer's locale and TZ and so has no reliable property. A legacy token is never converted: the timezone it was rendered under is not recoverable. Only two platform arms change, both toward declining rather than acting: macOS swaps the `ps` render for the libproc instant, and any other POSIX host falls back to plain PID liveness (conservative -- a live PID protects a file). Linux is byte-identical, both paths taking procfs field 22, so headers written before this change still compare equal. Windows is unchanged. Tests stub the two routines apart -- patching the source module and any name the module bound directly, so they name a SOURCE rather than an import style. Five fail on origin/main at this PR's base, where the POSIX arm answers 'Wed_Sep_3_10:00:00_2026' and the guard must answer None. Four more fail on the pre-guard head 3aa7f83 for the migration mechanism itself: the sweep unlinks a live gateway's dump, rotation sacrifices two more, and a live cron run reports abandoned -- on its own PID and, separately, on another gateway's PID so the answer comes from the liveness fallback rather than the own-PID shortcut. The third comparison site, RunningMarker.same_process, deliberately keeps no guard: it JOINS a marker to a dump, and its mismatch answer is "not the same process", so the breaker attributes nothing and pauses nothing. That is already the declining direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b53c93d to
f6172d6
Compare
Problem / Motivation
crash_dump_store._pid_start_idsupplies the start identity that decides whether a crash dump's recorded owner is still the process holding its fd. Two readers act on that answer destructively:sweep_stale_dumpstreats a mismatch as "owner is gone" and unlinks the dump — while the owning gateway is alive andfaulthandlerstill holds that file's fd, so its future stall evidence goes to an unreachable inode. There is no recovery path.cron_inflight.RunningMarker.owner_alivereads the same identity to conclude a run was abandoned, and the breaker then parks the job.#8282 (merged after this PR was opened) gave the function a fallback to
platform_compat.process_start_timeso it could answer off Linux. That routine has two legs, and only one of them is suitable here:FILETIMEread through a query-only handle — a machine integer at 100-ns resolution, no locale or timezone in it. Sound.ps -o lstart=: 1-second, locale- and TZ-rendered. Its own docstring calls it safe precisely because "a format or resolution drift can only make the guard decline to act, never act on the wrong process."That sentence states a kill-guard contract, where a mismatch means do nothing. Both readers above do the opposite — they act on a mismatch — so the same drift deletes a live gateway's dump rather than declining. Two gateways sharing one data home (an overlapping restart, an isolated pod — cases
sweep_stale_dumpsdocuments) need only differ inTZorLC_TIMEto render the same start instant as two different strings. The 1-second granularity is the milder half: it cannot separate two processes that started in the same second.Why it matters
The dump this can delete is the only artifact of a loop-stall wedge, and the failure is silent and self-concealing: the file disappears while the gateway that will need it is still running, so the loss is only discovered when someone goes looking for evidence that no longer exists.
It also lands on #8282's own new feature. That PR built the cron circuit breaker to answer "which job was in flight when the gateway died" from exactly this identity — so a drifted render both destroys the dump the breaker joins against and can mark a live run abandoned.
What changed (motivation → approach → change)
Root cause is the direction of the fail-safe, not the probe. A drift-tolerant helper is safe only where a mismatch means decline to act; routed into a caller that unlinks, the same tolerance becomes a deletion.
1. The source. The identity now comes from
platform_compat.get_process_start_id— the routine this repository already uses everywhere a start identity is written down and compared back later (mcp_gateway.claim,session_pid,metrics.sessions). It is in-process on every platform it covers, and on macOS it readslibproc's microsecond start instant, so macOS keeps the coverage #8282 gave it, at a resolution that also separates two processes started in the same second. Where that routine declines and the host is Windows, #8282'sprocess_start_timefallback is kept exactly as it is, sanitiser included.Both legs still go through
platform_compat; neither the procfs read nor thepsinvocation is re-implemented locally, so the shim-table row inAGENTS.md("Process start time (PID-reuse guard)", whose NOT column names raw/proc/<pid>/statand rawps -o lstart=) is satisfied either way. Its stated concern — that both raw probes answerNoneon Windows — is exactly what the retained Windows leg covers.origin/main(post-#8282)ps -o lstart=, 1 s, locale/TZlibproc, microsecond, machineFILETIMEFILETIME— unchangedps -o lstart=None→ plain PID livenessBecause Linux is byte-identical, Linux headers written before this change still compare equal. A
Noneis "identity unknown", never a mismatch, so the last row degrades to plain PID liveness, which only ever protects a file.2. The migration guard. Changing the representation is itself a hazard, and it is guarded rather than ignored. A macOS gateway that wrote its header under the previous build recorded a
psrender; it can never equal what is read now, and comparing the two as though they were one kind of identity would unlink the dump of a gateway still running across the upgrade — the overlapping restartsweep_stale_dumpsdocuments a live PID as protecting._start_ids_comparabletherefore treats a value that is not in the current representation as unknown rather than different, and both destructive readers consult it —_owner_aliveandpid_identity_alive. Patching only the sweep would leave the cron marker path open.There is a third comparison site,
RunningMarker.same_process, and it deliberately gets no guard: it joins a marker to a dump, so its mismatch answer is "not the same process" and the breaker then attributes nothing and pauses nothing. That is already the declining direction.Two deliberate choices in that predicate:
.— a Linux jiffy count, a WindowsFILETIME, or macOS's<seconds>.<microseconds>), not a denylist of the retired render. Thepstext depends on the writer's locale and TZ, so no property of it — not even theHH:MM:SScolon — is reliable across locales.Everything else #8282 added to this module is untouched: the bounded
_read_dump_bytes/_read_dump_linesreaders, anddump_owner_identity/current_process_identity/pid_identity_alive/dump_wedged_frames.Residual, stated plainly. The guard fixes the direction this build controls: a new gateway reading an old header. The reverse — a gateway still running the previous build reading a header this build wrote — cannot be fixed from here, because that code is already deployed and compares with its own
psrender. It is bounded to the macOS upgrade overlap window and is inherent to any identity-format migration; against it stands a permanent removal of the locale/TZ misjudgement on every subsequent restart.Tests
test/test_crash_dump_store.py. The two platform routines are stubbed apart so each assertion names a source rather than merely observing a value, and the stub also pinsIS_WINDOWSso every case asserts the same contract on every CI host. The stub patches the source module and any name the module bound directly, so the tests pin behaviour rather than import style — re-adding afrom platform_compat import process_start_timeand calling it stays visible.Red on
origin/mainat this PR's base (control = main'scrash_dump_store.pyin-tree with these tests):test_start_id_comes_from_the_persisted_identity_routine—assert 'Wed_Sep_3_10:00:00_2026' == 'stable-id'test_posix_start_id_is_none_rather_than_the_ps_render—assert 'Wed_Sep_3_10:00:00_2026' is Nonetest_header_records_start_id_on_a_non_procfs_platform— the header carries no tokentest_pid_reuse_detected_without_procfs—assert True is Falsetest_rotation_reclaims_recycled_pid_dumps_without_procfs—assert 0 == 2Red on the pre-guard head
3aa7f839a, for the migration mechanism itself (not merely because a helper is absent):test_legacy_ps_header_does_not_sweep_a_live_gateways_dump—assert False is True; the sweep unlinks a live gateway's dump.test_legacy_ps_header_is_not_a_rotation_victim—assert 2 == 0; rotation's never-a-victim rule stops protecting it, so two more live-owner dumps are sacrificed. This consequence is beyond the reported one.test_legacy_cron_marker_is_not_reported_abandoned—assert False is True; a still-executing run reports abandoned.test_legacy_cron_marker_falls_back_to_liveness_not_the_own_pid_shortcut—assert False is True; the same on another gateway's PID, so the green answer comes from the liveness fallback rather thanpid_identity_alive's own-PID shortcut.Two further tests (
test_windows_filetime_is_a_comparable_current_identity,test_comparability_is_an_allowlist_of_the_current_representation) are direct unit tests of the new predicate and fail on that head withAttributeError— helper-absence, listed separately because that is not mechanism evidence.Green on both sides, pinning what is preserved rather than changed:
test_windows_keeps_the_creation_filetime_fallbackandtest_windows_fallback_value_stays_one_header_token— the Windows leg and its single-token sanitiser.test_current_format_mismatch_still_detects_pid_reuseandtest_current_format_mismatch_still_detects_a_recycled_cron_marker— the guard does not blunt the check it protects; two current-format values that differ are still proof of reuse, on both readers.test_legacy_token_does_not_resurrect_a_dead_owner— the guard turns different into unknown, never confirmed-dead into alive; a dead PID's dump is still swept.Three pre-existing tests used placeholder identity strings (
fabricated-mismatch,recycled-token) that no real producer emits; their fixtures now carry values in the real representation, leaving each test's subject unchanged.Local runs (Windows, repo-pinned toolchain):
test_crash_dump_store.py66 passed / 1 skipped;test_stall_attribution.py+test_subagent_stall_attribution.py53 passed / 3 skipped.scripts/check_black_formatting.pyandscripts/check_subprocess_encoding.pyboth pass in scope;flake8clean;mypyreports nothing in this file.Manual verification
N/A — unit coverage sufficient: the change is a source swap plus a comparability predicate inside one module, and every divergence it turns on is between two
platform_compatroutines that the tests stub apart deterministically. The real macOSlibprocand WindowsFILETIMElegs areplatform_compat's own, already covered there.Related Issues
None — found by source review while confirming the PID-reuse guard off Linux.
Interacts with #8282 (merged): this PR is not a revert of it. #8282's Windows leg and all four of its new module APIs are preserved; only its POSIX
psleg is replaced, by a routine that gives macOS strictly better resolution than the one it drops.Pattern harvest
Rule candidate:
review-promptPattern: a drift-tolerant identity/liveness helper is safe only where a mismatch means decline to act; routing one into a caller that deletes, kills, or otherwise acts on a mismatch inverts its documented fail-safe direction. When a helper's docstring justifies its coarseness with "drift can only make the guard decline", that sentence is a contract on the caller, and reusing the helper elsewhere must re-check it. Corollary, and the second half of this PR: changing the representation of a persisted identity is a migration — a recorded value in the retired representation must read as unknown, never as a mismatch, on every reader that acts destructively.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
🤖 Generated with Claude Code