fix(bin): resolve the session lock's harness identity on Windows hosts - #2769
fix(bin): resolve the session lock's harness identity on Windows hosts#2769jhyunann wants to merge 1 commit into
Conversation
On MSYS hosts (Git Bash, MSYS2, Cygwin) the session lock could never be acquired: fm_harness_ancestry_pids found no harness in the ancestry, so every session start refused the fleet lock and stayed read-only, and the Claude Stop auto-arm could never claim the home. Two independent host facts break the POSIX walk there. The bundled ps has no -o, so it answers no part of the question; and MSYS reports PPid 1 for any shell a native harness started, because it tracks parentage only among its own processes. A third fact breaks the obvious fix: MSYS has no execve, so an exec'd child records a Windows parent that has already exited, and walking the Win32 tree alone ends one hop above the caller. Add a platform process-access layer as the single owner of "what is this process, and who is its parent". POSIX keeps using ps and kill unchanged. The Windows branch reads the Win32 process tree once per shell, indexes it by pid, and prefers each MSYS process's own parent link where it has one, so the walk crosses the emulation boundary and reaches the native harness. Windows reuses process ids, so a hop whose claimed parent started after its own child is refused rather than followed into a stranger. Lock claims need the same treatment: every fm_lock_* claim is an atomic ln -s, and Git Bash's default silently substitutes a directory copy while still exiting 0. Request nativestrict so it refuses instead, and report once, rather than leaving every caller waiting forever for a lock that can never be granted. Make the identity matcher and cursor's name check fork-free while here. Both ran basename, grep and command substitutions per hop, which are free on Linux but cost about 0.1s each under emulated process creation, and the walk runs at every turn end. Verdicts are unchanged on every platform. Tests pin the Windows branch from any host through documented seams, including that the MSYS link is load-bearing and that the recycled-parent refusal is not vacuous. The POSIX cases now pin their branch explicitly instead of inheriting the host's.
Confidence Score: 4/5The repeated Windows ownership checks need a fresh process snapshot before merge so a parked Cursor guard cannot act for a session whose harness has exited. The new Windows table accurately supports one-shot ancestry decisions, but retaining it for the lifetime of a long-running turn-end guard makes later ownership and liveness decisions operate on obsolete process state. Files Needing Attention: bin/fm-session-lock-lib.sh and bin/fm-turnend-guard-cursor.sh Reviews (1): Last reviewed commit: "fix(bin): resolve the session lock's har..." | Re-trigger Greptile |
| case "$FM_WIN_PROC_TABLE_STATE" in | ||
| ok) return 0 ;; | ||
| failed) return 1 ;; | ||
| esac |
There was a problem hiding this comment.
If a parked Cursor guard outlives its harness ancestor, FM_WIN_PROC_TABLE_STATE=ok prevents subsequent ownership checks from refreshing the Win32 process table, so the stale ancestry can still identify the departed harness as the lock owner and allow the guard to arm the watcher or emit a follow-up wake for a dead session.
The problem
On an MSYS host (Git Bash, MSYS2, Cygwin) the session lock can never be acquired.
fm_harness_ancestry_pidsfinds no harness in the ancestry, sobin/fm-lock.shexits witherror: cannot locate harness process in ancestry, every session start refuses the fleet lock and stays read-only, and the Claude Stop auto-arm can never claim the home. In practice firstmate cannot spawn, steer, merge, drain the wake queue, or supervise anything on Windows at all.Three host facts are involved, and each one defeats the obvious fix for the previous one.
The bundled
pshas no-o. The POSIX walk asksps -o comm= -p <pid>, which answers nothing here:MSYS reports
PPid: 1for any shell a native harness started, because it tracks parentage only among its own processes. So even with a workingps, the ancestry ends immediately:MSYS has no
execve. It implements exec by starting a fresh Windows process and ending the old one, so an exec'd child records a Windows parent that has already exited. Walking the Win32 tree alone therefore ends one hop above the caller — which is exactly where any firstmate script runs:Note the split: the child's MSYS parent is correct, while its Win32 parent is a corpse.
The fix
A platform process-access layer in
bin/fm-session-lock-lib.shbecomes the single owner of "what is this process, and who is its parent". Everything above it stays pure identity logic and is unchanged.psandkill, same code path, same semantics.fm_session_lock_owned_by_selfprimes it in the caller's own shell so the subshell it forks for the walk inherits the table and one lock decision costs one read.ParentProcessIdis only the number recorded at creation, and Windows may reassign it once that parent exits. If the stranger it names is a harness, the walk would hand the home's lock to an unrelated session. A real parent cannot start after its own child, so hops violating that ordering are refused.Every pid this reports on Windows is a WINPID, including the one written to
state/.lock, because a native harness process has no other identity to record.Lock claims
Every
fm_lock_*claim is an atomicln -s. Git Bash's default silently substitutes a directory copy and still exits 0:Left alone this turns the compare-and-swap into two sessions each holding their own private directory and each believing it won.
fm_lock_try_createnow requestswinsymlinks:nativestrict, and the existingreadlinkverification turns any substitution into a lost race rather than a granted lock. Because the correct outcome is then "refuse forever", it also reports once — previously every caller offm_lock_acquire_waitsimply blocked with no output at all.This needs Windows Developer Mode enabled; the diagnostic says so.
Fork-free hot path
fm_harness_process_matchesandfm_cursor_process_matchesranbasename,grepand command substitutions on every hop. Those are free on Linux and cost ~0.1s each under emulated process creation, and this runs at every turn end. They are now shell builtins.Measured on the host below: ~236ms per matcher call before, ~0ms after; a warm walk went from 1.67s to under 0.01s.
Verdicts are unchanged. I diffed the matcher across 23 process shapes (version-named installs, hook script paths, bare interpreters, pi/pi-signed, cursor's real shapes and lookalikes) before and after: byte-identical on every case.
Tests
tests/fm-session-lock-ancestry.test.shgains a Windows layer that runs from any host through documented seams (FM_PROC_PLATFORM_OVERRIDE,FM_PROC_SELF_PID_OVERRIDE,FM_PROC_MSYS_PROC_ROOT— nothing inbin/sets them), because the platform it describes cannot be reached from CI:kill, using ids that do not exist on the host running the testThe existing POSIX cases now pin their own branch explicitly rather than inheriting the host's, so the file is meaningful on either platform.
docs/verification/runtime-backends.mdgains a dated Windows-host section carrying the evidence above.What was verified, and where
Verified on Windows 11 build 26200, Git for Windows bash (
MINGW64_NT-10.0-26200), bash 5.3.15, Claude Code as primary harness:bin/fm-lock.shacquires the lock andbin/fm-session-start.shcompletes a full digest — both previously impossiblebin/fm-lint.shfully green (ShellCheck 0.11.0 pinned, actionlint 1.7.12 pinned, 3 workflows valid)tests/fm-session-lock-ancestry.test.shunit layer 8/8, including the 4 new Windows casestests/fm-wake-queue.test.shandtests/fm-watcher-lock.test.shproduced no assertion failuresI could not run the full suite on this host. Its e2e layers spawn fixture trees that Windows cannot execute (
CreateProcessW failedon a symlink to/bin/bash), and those same failures reproduce identically on unmodifiedmainhere, so they are a Windows fixture limitation rather than a regression. Linux CI is the authority for the full suite.Two adjacent Windows gaps I did not touch, to keep this focused — both are recorded in the verification doc:
bin/fm-install-shellcheck.shsupports linux/darwin only, so the pinned linter must be fetched by hand on Windows (upstream does publishshellcheck-v0.11.0.zip). Same forbin/fm-install-actionlint.sh.FM_SESSION_START_TIMEOUT, so the digest truncates intermittently until it is raised. Individual tool version probes cost 0.9–3.2s each; that is the tools' own startup, not firstmate's work.🤖 Generated with Claude Code
https://claude.ai/code/session_01Mm7UoJ8Jwspaqcm8iZMo8x