fix(platform): give get_process_start_id a Windows arm - #8682
Conversation
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of The CI has a real Design-Verdict: PASS Root-cause fix through the platform's existing identity seams, fail-safe to prior behavior on any read failure, with live Windows coverage in the CI shard. [DESIGN-REVIEWED] 977f361 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsFINDING -- src/kiro_crew/session_pid.py:173 -- “a |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All verification is done. The base tree confirms every load-bearing claim: the missing Windows arm (platform_compat.py:1534), the four-plus consumers that treat First-Principles-Verdict: PASS Only the author's Windows 10 box has run the real seams — confirm the Windows CI lane collects and passes What this change shipsInventory (10 items) — 10 justifiedIntent: make the PID-recycle guard actually fire on Windows by giving the per-process incarnation token a Windows arm — a FIX (linked #8473/#8467/#8343; the base's own docs at
The delegation alternative ( [FIRST-PRINCIPLES-REVIEWED] 977f361 |
59aa6e5 to
bf8958d
Compare
bf8958d to
bd2f789
Compare
|
Heads-up for anyone reading the red checks: the failures in |
bd2f789 to
f45ce75
Compare
|
Rebased onto main Clean rebase: no conflicts. The single commit replayed unchanged and the diff is byte-identical in scope (9 files, +223/-29), so the Windows arm on Gates run locally on the changed files only: black (clean; Please review the rebase. A maintainer push makes the maintainer the last pusher, so under the repo's last-push rule a second approver is needed. Reply if anything looks wrong. |
f45ce75 to
eba209f
Compare
|
@bolichen97 Rebase verified on my side too: range-diff between Two notes on this round's red checks: |
bolichen97
left a comment
There was a problem hiding this comment.
Requesting changes at eba209f — the Windows arm is correct, but the PR leaves the codebase saying two contradictory things about it.
- Spec contradicts the code.
docs/system-specs/modules/instances.md~L1074-1077 still documentsget_process_start_idas Linux/macOS-only withNoneon Windows. AGENTS.md requires the spec change in the same PR. The body says "three specs documented the missing arm" —instances.mdis a fourth and is unchanged. - Stale "Windows → None" comments survive in
instances/run_marker.py~L127,session_pid_sig.py~L342/377,mcp_gateway/claim.py~L77,mcp_gateway/gatewayd.py~L1926,session_pid.py~L170. A reader ofrun_marker.pywill believe Windows still depends on theprocess_start_timefallback leg and may remove or reorder it.run_marker.py~L141 andgatewayd.py~L2615 are also consumers the body's "four consumers" omits. - Missing guard. The new
want_exit_time=Falsebranch of_windows_process_handle_identityomits thecreation_value <= 0 → Noneguard the default branch keeps, so a degenerateGetProcessTimesresult yields"0"whereprocess_start_timeyieldsNone. Two"0"tokens then compare EQUAL ("same process") instead of "unknown", which breaks the body's ownget_process_start_id == process_start_timeinvariant. One-line fix.
What is good: the core arm opens QUERY-LIMITED, reads the creation FILETIME, closes in finally; identity is stable per process and distinct across PID reuse; POSIX untouched; test_windows_process_start_id.py gates on real IS_WINDOWS and the coverage test mocks the seams deterministically. Also note the diff re-signatures _windows_process_handle_identity (adds want_exit_time), which "What changed" does not list.
The arm returns the process creation FILETIME through the same QUERY-ONLY handle seams process_start_time reads, in their creation-only mode, so both identities are the same decimal string by construction and every pid-incarnation consumer (sweep entries, the signed pid mapping, run_marker ownership tokens, gatewayd claims and register-time records, metrics crumbs) picks up the recycle guard with zero changes. Windows was the one platform whose recycled pids kept the misattribution window open. session_pid's spawn-grace age read keeps its Windows gate: the FILETIME token is not epoch seconds, so the epoch arithmetic there must not consume it. A zero creation FILETIME reads as unknown, never as the token "0" -- two "0" tokens would compare equal and pass for the same process. Creation-only is also what keeps the read non-blocking: it skips the exit-status read and the exit-FILETIME publication poll (up to 250ms on a just-exited process), so the identity can be read on the asyncio event loop without stalling it. The exit bound keeps its poll behind the flag for the readers that need it. instances.md, session.md, metrics.md and the consumer disclosures in session_pid, session_pid_sig, run_marker, claim.py and gatewayd now describe the same three-platform contract; the unknown-is-not-a- mismatch rule is unchanged, and every read failure still degrades to "identity unknown" rather than a mismatch. Mirrors the merged corrupt-read refusal readers (kirodotdev#7805 class).
eba209f to
977f361
Compare
|
@bolichen97 The two failing shards trace to |
Problem / Motivation
platform_compat.get_process_start_idis the tree's per-process incarnation identity: a stable string that differs across two processes sharing one PID, so a caller can tell "still the process I spawned" from "this PID was recycled". The docstring promises an arm for every platform, and Linux (field 22 of/proc/<pid>/stat) and macOS (libproc, microsecond resolution) both deliver one. Windows falls through toreturn None.Four consumers read that token, and all four degrade to their token-less legacy behavior on Windows:
session_pid.pywrites sweep entries in the legacy 2-field<gw>:<pid>form, so the sweep cannot prove an entry is stale by token mismatch and falls back to cmdline matching plus spawn-grace heuristics.session_pid_sig.pypublishes the signedsession_pid_<pid>.txtmapping without the token line, so the PID-recycle guard added in fix(session-pid): bind the signed pid mapping to the process incarnation #8467 never fires there.mcp_gateway/claim.pysendspid_start_id: nullin its claim frames, so gatewayd cannot skip a connection whose PID was recycled underneath it.metrics/sessions.pycan refine crumb ownership by PID only, never by incarnation.Windows is the platform where this matters most. Its PID space is small and recycled aggressively, so the misattribution window #8343 describes (a recycled PID keeps answering for the previous owner's session key until the next restart's sweep) is widest there, and the guard that closes it works least there. #8473 was filed as the follow-up, carrying the Design review lane's watch item on #8467.
Why it matters
#8467 bound the signed pid mapping to the process incarnation, but its Windows behavior is "publish the legacy form and let readers treat the token as unknown". That degrade is safe by design, which is also why it sat quietly: nothing errors and nothing logs, the guard just never fires. On Windows the mapping behaves as it did before #8467, and the one platform where a recycled PID is most likely to hand another process's identity to a new owner is the one platform the guard skips.
The fix is also cheap to review, because everything downstream was built to accept a token the day #8467 landed. The readers dual-parse the legacy and token-bearing forms, and every comparison treats None as "identity unknown", never as a mismatch. So this PR changes one function and four consumers pick the guard up with zero code changes; the rest of the diff is tests and disclosures that no longer apply.
What changed (motivation → approach → change)
Symptom:
get_process_start_idreturns None on Windows. Root cause: the function never had anIS_WINDOWSbranch. Fix: give it one, reading the process creation FILETIME through the seams the codebase already trusts.process_start_time(), the PID-reuse guard the kill paths use, already reads that value on Windows through_open_process_query_handle(QUERY_LIMITED_INFORMATION only),_windows_process_handle_identity, and_close_process_handle. The new branch makes the same three calls and returnsstr(identity[1]), the creation FILETIME as a decimal string.The reuse is the main design decision. The issue sketched fresh ctypes code, but two identity APIs on one platform should not drift in how they acquire or release a process handle, and reusing the seams makes
get_process_start_id(pid) == process_start_time(pid)true by construction on Windows. A token persisted by either caller then compares equal to the other's value for the same process, which is a property consumers would otherwise have to test into existence.One consumer deliberately does not pick the token up:
session_pid._pid_age_secondsderives the spawn-grace window by interpreting the darwin token as epoch seconds (time.time() - float(start_id)), and the Windows FILETIME counts 100-ns units since 1601. Feeding it in would compute an absurd age, so that function keeps its existing Windows gate and its docstring now records why. Spawn-grace behavior on Windows is unchanged; what changes there is the sweep entry form.The token stays opaque on purpose. Nothing parses it, and the one numeric consumer is now fenced off, so the tree's existing dual-parse (by form, not by units) keeps working untouched.
The diff is nine files, and only the first three carry behavior:
src/kiro_crew/platform_compat.py— theIS_WINDOWSbranch, plus a docstring that documents the arm and stops listing Windows among the unknown-failure cases.src/kiro_crew/session_pid.py— no code change;_pid_start_tokenno longer lists Windows as a None case, and_pid_age_secondsexplains why its gate must stay.src/kiro_crew/metrics/sessions.py— no code change; the liveness-versus-identity rationale no longer cites Windows as a platform with no token.test/test_windows_process_start_id.py(new) — live tests through the real seams, Windows-only.test_platform_compat_coverage.pydeliberately skips itself on Windows hosts (it simulates both platforms by flipping attributes that do not exist on win32), so real-seam coverage needs its own file.test/test_platform_compat_coverage.py— the old "Windows is unknown" placeholder is replaced by three mocked arm tests for the ubuntu CI lane: the token equals the mocked creation FILETIME with the handle opened once and closed, a failed open is unknown, and an unreadable identity is unknown with the handle still released.test/test_pid_lifecycle.py— one docstring: the legacy-fallback test patches the token to None directly, and Windows is no longer one of the None cases.docs/system-specs/modules/metrics.md,docs/system-specs/modules/session.md,docs/architecture/design-notes/mcp-gateway-claim-push.md— the three specs that documented the missing arm as a permanent state stop describing it that way, since the project's docs rule updates the spec in the same commit as the behavior.Tests
assert None is not None), then added the branch and watched it pass, so the test catches the regression it exists for instead of decorating the fix.test/test_windows_process_start_id.py, 3/3 on my Windows 10 machine (native run): the identity is non-None, digits only, colon-free; it equalsprocess_start_time(os.getpid()); it is stable across calls; an unopenable PID reads as None; andsession_pid._pid_start_tokenreturns a token on Windows, which is what flips sweep entries out of the legacy form.test_pid_lifecycle.py,test_session_pid_sig.py,test_mcp_gateway_claim.py,test/metrics/test_session_duration.py): 286 passed, 33 skipped. The 6 failures are the known Windows-baseline ones (tests that monkeypatchos.getpgrp/os.getuiddirectly, which a frozen Windowsosmodule does not have); I confirmed they fail identically on a clean checkout of this branch's base with the change stashed, so none come from this PR.test_windows_kill_probe_audit.py— 4/4. The new handle is query-only, so the audit's raw-kill-probe allowlist is untouched.Manual verification
The mocked tests prove the seams are called; they cannot prove the token is real on a live OS. On the Windows 10 machine this was written on,
get_process_start_id(os.getpid())returns a decimal string equal toprocess_start_time(os.getpid()), stable across calls within the same process. A reviewer on any Windows box can repeat that in a REPL in under a minute.The user-visible effect on Windows once this lands: the signed
session_pid_<pid>.txtmapping publishes the token-bearing two-line form, so the PID-recycle guard fires there, and sweep entries carry the<gw>:<pid>:<start_token>shape. Before this PR those were the legacy forms with the guard silently inert.Screenshots / video
Nothing visual changes; the observable behavior is an on-disk mapping format and a sweep-entry shape, which the tests pin. If a maintainer wants a capture of the mapping file before and after, I can add one under
temp-screenshots/.Related Issues
Closes #8473. Builds on #8467 (signed pid mapping) and #8343 (the misattribution report that motivated the incarnation guard). The metrics.md, session.md, and mcp-gateway-claim-push.md updates ride along because all three documented the missing arm.
Checklist
Pattern harvest
Rule candidate: review-prompt — a new platform arm of an identity primitive must read through the same handle-acquisition seams as the platform's existing identity readers, so the two APIs cannot drift in how they open or close the handle; reusing
process_start_time's QUERY-ONLY seams here is also what makes the two tokens equal by construction.Rule candidate: testing conventions — when a coverage test file deliberately skips itself on a real platform (it simulates branches via attributes that platform lacks), live-path coverage for that platform's arm needs a dedicated file gated to the real host; a mocked file proves seam wiring, never the OS value.
Not generalizable: the spawn-grace age read keeps its Windows gate because it interprets the darwin token as epoch seconds and the FILETIME is not epoch-based; that is a property of this one numeric consumer, not a pattern other consumers need.