fix(remote): sync start keeps the record of an engine it did not stop (#831) - #840
Open
fujibee wants to merge 5 commits into
Open
fix(remote): sync start keeps the record of an engine it did not stop (#831)#840fujibee wants to merge 5 commits into
fujibee wants to merge 5 commits into
Conversation
Reported from the field: repeated `sync start` attempts left sync engines running that nothing pointed at. Three invocations, three live engines, and `status` reporting stopped for every one of them. The give-up path is where they came from. When the engine never becomes ready the starter reaps it and clears its records, and `_remote_sync_engine_reap_owned` answered 0 -- "reaped" -- for a pid it had only READ as gone, having signalled nothing. On Windows a live engine reads as dead (#652; #505 gives one way, a DWORD pid above the POSIX ceiling that kill(1) will not parse), so the records of a RUNNING engine were deleted. The pidfile is the only thing naming it, so from then on no `status` could see it and no `stop` could reach it, while it kept retrying on a backoff. So the reap now separates its own act from a reading: 0 it signalled the engine and the engine went 2 the engine read as gone; nothing was signalled 1 it could not stop it and `cmd_sync_start` clears the pidfile and the cycle stamp on 0 only. On 2 it leaves them and says which situation it is in: a stale record is what `status` already knows how to describe, and a live process with no record is not something the operator can act on at all. #832 fixed the probe that misread. This does not stand on that fix, because the harm does not depend on which misreading happens. Two smaller things came with it. Both call sites capture the status with `|| rc=$?` rather than calling bare -- this file runs under `set -e`, where a bare call returning non-zero exits the script instead of reaching the message below it. And the "could not retake the registry lock" line no longer says "the engine is stopped" on a path where nothing was signalled; that is the one thing that path does not establish, and the line is all the operator gets. Six cases in tests/test_sync_start_orphans.bats, driven against a real engine with the local liveness probe forced to the reading Windows produces. Each has its negative control in the file: 2 is answered for a reading, 0 for an act, records survive when blind and are cleared when honest. Closes #831
The previous commit ended with `Closes #831`, and that was wrong. #831 names two independent directions and this branch is the first of them. The second -- every engine appending to one shared log, so lines tear into each other and every tool that reads that log reads fragments -- is untouched, and no case in the file says anything about it. The keyword does not fire from `integration/remote` anyway, so nothing was closed; the claim was still in the record, where the next reader would have believed it. It also does not reduce the number of engines that can exist. Two `sync start` calls that both misread still start two. What it stops is the second half of the harm, where the record of one is deleted and nothing can name it after. The scope now sits in the test file's own header rather than only in a PR body, because that is where someone reads it when they come looking for a case that is not there. Refs #831
The previous two commits on this branch hardened the give-up path -- an engine that could not be stopped kept its record instead of becoming anonymous -- and called that direction 1 of #831. Review was right that it is not. Direction 1 asks that the engine be reaped before failure is reported, and that the reap work on Windows, which is where every measured orphan came from. Keeping a name on an orphan is a smaller claim than not leaving one. Two things were missing, and they are the same shape: a reading was allowed to end the matter. "Gone" was one probe's answer. Under Git Bash `kill -0` cannot see a live engine (#652), so `_remote_sync_engine_status` reported `stale` before it ever reached the check that proves ownership, and the reap declined to signal a pid it could not confirm. `compat_pid_gone` now requires every probe available to agree, and on msys asks the Windows side about the WINPID -- not the MSYS pid, which `tasklist` does not report at all and which answered "dead" for every running codex bridge in #567. And the signal was `kill` alone. The engine runs as `bash remote-sync.sh`, which runs `node`; the MSYS signal reaches the shell and the native node.exe under it survives, which is how the engines in #831 outlived a kill already aimed at them. `compat_signal_pid_tree` sends the POSIX signal first and unconditionally, then on msys ends the tree by pid: `taskkill /PID <winpid> /T`, with `/F` only on the second pass, after the polite attempt has been made and waited on. Ownership is unchanged, and deliberately so. A pid stops naming the same process the moment that process exits, and `/T` on a recycled number ends a stranger's tree; overriding a false "gone" brings more pids to that check, so the check has to be the one that survives reuse. It is the cmdline, re-read every pass, and an unproven pid is still reported as "could not stop" rather than signalled. That leaves the caller simple: the records are cleared when the process is not there, kept when it could not be stopped, and the difference is reported. Two diagnostics said more than was measured. The give-up message asserted the engine could not reach the server and that nothing was syncing for the team -- the engines it was written for were reaching the server and pulling the whole time. And the only manual way out it offered was `kill <pid>`, which #831 measured does not end that process; on msys it now prints the taskkill form with the WINPID, and says why. The readiness poll's ceiling gains a test seam, defaulting to the shipped 1600 turns. Reaching the give-up path costs the whole ceiling, and the regressions that drive it are not about that number; one case leaves the seam unset and asserts the sixteen-second floor, so the default stays on the path of something. Ten cases in tests/test_sync_start_orphans.bats, ten mutations, each landing on its own assertion. The Windows cases drive the Windows route on a POSIX host -- `uname`, `ps`, `tasklist`, `taskkill` and `powershell.exe` stubbed to the shapes Git Bash speaks, with the ownership cmdline coming from the real process. That measures that the route is chosen and that the engine dies through it. It is not a measurement of native node.exe dying on real Windows; the Windows machine is asked for that. MSYSTEM alone does not select the platform -- `_agmsg_detect_platform` asks `uname -s` -- so the first version of these cases passed without ever entering the branch under test. Refs #831
Two defects in the previous commit, both found in review, and both the same shape as the bug this branch exists to fix. `compat_pid_gone` let every way of failing to reach the Windows side fall through to "gone": no WINPID, no `tasklist`, a `tasklist` that ran and exited non-zero. So a single POSIX false-gone was enough to declare a live native engine dead -- which is #652's collapse, rebuilt inside the function written to prevent it. Each of those now answers "not gone", `tasklist`'s exit status is checked and not only its text, and the caller's cmdline check is what still keeps a dead pid from reading as a running engine. `compat_signal_pid_tree` sent the POSIX signal and then looked up the WINPID. `ps -l -p <msys-pid>` stops answering once the MSYS side has exited, so the kill could remove the only means of naming the native process still running underneath -- the reported symptom, "the kill returns and node.exe is still there", produced by the order of two lines. The mapping is now resolved before the signal, and `_remote_sync_engine_reap_owned` resolves it once while the cmdline check still proves the process is its own, then carries that same mapping through the signal, the taskkill and the confirmation that it went. The platform decision in `compat_signal_pid_tree` was made twice -- a guard on the lookup and another before the taskkill -- so either could be removed with nothing to show for it, and no control held it. It is made once now. The fixture hid the ordering defect: its `ps` returned `900000 + pid` whatever the process state, so the mapping was immortal and the code could always find a WINPID no matter when it asked. It answers only for a pid that is still there, which is what MSYS2's `ps` does. Four cases were missing and none of them were reachable through the reap: - The ordering, driven on `compat_signal_pid_tree` directly with no WINPID passed in. Every case that reaches it through the reap is blind to this by construction, because the reap supplies the mapping and thereby defends the function from its own order. Restoring the old order left twelve cases green. - "Cannot ask" split by which line failed: no WINPID, no `tasklist`, and a `tasklist` present that exits non-zero -- that last being the one that most looks like an answer. Fourteen cases, fourteen mutations, every case reddened by at least one and eight landing on exactly one. Refs #831
…ing the test Three CI failures on the previous head, all of this branch's doing, and the first is the one worth reading. Removing the unmeasured cause from the give-up message took a MEASURED fact out with it. The line said the engine could not reach the server -- not established, correctly deleted -- and, in the same breath, that it will keep retrying on a backoff, which is the engine's own documented loop and the thing that turns "a command failed" into "something is still running on this machine". #731's test was pinning exactly that half. A deletion removes somebody's premise, and this one was not swept for readers before it was made; the test found what a grep would have. The consequence is restored, the cause is still gone. The other two were my new cases failing on ubuntu and passing on macOS, which is the shape that should have been suspicious on its own. Forcing the msys branch on a POSIX host sends `compat_get_cmdline` to `/proc/<pid>/cmdline` when one exists. macOS has none, so the stubbed Windows lookup answered. Linux has one, so it short-circuited past every stub and returned the real cmdline with a TRAILING SPACE -- `tr '\0' ' '` converts the final NUL -- and the ownership check's `*" run --team <team>"` suffix stopped matching. Ownership then could not be proven, the reap declined, and the engine survived: exactly the two assertions that failed. Measured directly rather than inferred: that suffix matches the cmdline without the trailing space and does not match it with one. So `_AGMSG_COMPAT_NO_PROC=1` now rides with `MSYSTEM` at all nine stub sites, which is what that variable exists for -- a native Windows process has no MSYS /proc entry either, so this is the fixture modelling the platform rather than the host it happens to run on. 14/14 here, and test_remote_status_liveness 31/31.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
sync startleft running sync engines that nothing could name. This isdirection 1 of #831.
What was happening
Measured on Windows 11: readiness could not be confirmed,
sync startreportedfailure, deleted the pidfile and left. The engine kept running and kept pulling.
Three invocations left three live engines; a follow-up reproduction reached six.
statusreported stopped for every one of them, and the pidfile was the onlything that had ever named them.
Two probes disagree on that platform and the code believed the wrong one.
kill -0cannot see a live engine there (#652), so
_remote_sync_engine_statusansweredstale; the reap refused to signal a pid it could not confirm was ours, returned"nothing to stop", and the caller deleted the record anyway.
And even had it decided to signal, it would not have worked. The engine is
launched as
bash remote-sync.sh, which runsnode. An MSYSkillreaches theMSYS-side process and the native
node.exeunder it survives — #831 measuredexactly that, on engines a
killhad already been aimed at.The change
"Gone" requires every probe to say so —
compat_pid_gone. Under Git Bashthe pid these shells minted is an MSYS pid, which
tasklistdoes not report atall (asking it about one is how #567 lost every codex bridge), so the Windows
side is asked about the WINPID, which is the same process under the name that
subsystem uses.
The asymmetry is the point: a probe that wrongly says alive costs a signal
aimed at a pid whose ownership still has to be proven. A probe that wrongly says
gone leaves a live engine with nobody to stop it.
And "could not ask" is not "gone". The Windows side is reached through a
WINPID lookup and
tasklist, and either can be missing, fail, or answersomething unparseable. The first version of this let all of those fall through
to gone — so a single POSIX false-gone was still enough to declare a live native
engine dead. That is #652's collapse rebuilt inside the function written to
prevent it, and review caught it. Each of them answers not gone;
tasklist's exit status is checked, not only its output.The signal goes through whatever the host uses to end a tree —
compat_signal_pid_tree. POSIX signal, then on msystaskkill /PID <winpid> /T,with
/Fon the second pass after the polite attempt has been made and waitedon. Windows has no signal to deliver, so the tree is ended by pid.
The WINPID is resolved before the signal, and the same mapping is carried
through.
ps -l -p <msys-pid>stops answering once the MSYS side has exited,so a
killsent first can remove the only means of naming the native processstill running underneath. That is not hypothetical — it is the reported symptom,
the kill returns and node.exe is still there, produced by the order of two
lines. The reap resolves it once while the cmdline check still proves the process
is its own, and that one mapping is used for the signal, the
taskkilland theconfirmation that it went.
Ownership is still the cmdline, and never the number. A pid stops naming the
same process the moment that process exits, and
/Ton a recycled number ends astranger's tree. Every pass re-reads
_remote_sync_engine_status, which answersrunningonly while the cmdline names this team's engine; an unproven pid isreported as "could not stop", not signalled. Overriding a false "gone" is what
brings more pids to that check, which is why the check is the one that survives
reuse.
Then the caller is simple again: records are cleared when the process is not
there (reap
0— this call stopped it — or2— every probe says gone), andkept when it could not be stopped (
1), which is reported.Two diagnostics were wrong and are fixed:
nothing was syncing for the team. Neither was measured, and the engines the
text was written for were reaching the server and pulling the whole time. It
now says what is known: it never became ready, and this command did not stop it.
kill <pid>— the thing sync start abandons the engine it spawned: orphans accumulate and tear the shared log #831 measured does not end thenode process. On msys it now prints the
taskkillform, with its WINPID, andsays why.
Controls
tests/test_sync_start_orphans.bats. The end-to-end cases drive the realcmd_sync_startagainst a real engine.The Windows cases drive the Windows route on the CI host:
MSYSTEMset, anda
uname/ps/tasklist/taskkill/powershell.exeon PATH that speakthe shapes Git Bash speaks — the cmdline the ownership check reads comes from the
real process, so that check is the real one and not a stubbed yes.
MSYSTEMalone is not the switch._agmsg_detect_platformasksuname -s,so on a host answering
Darwinthe branch under test never executes and everyWindows assertion passes vacuously. That happened while writing these, and the
unamestub is what makes them mean anything.What they are worth: that the route is chosen and that the process dies
through it. They are not a measurement of native
node.exedying on realWindows. Only the Windows machine closes that, and it is asked to.
Existing-user impact: YES. On Windows this ends a process tree that the
previous behaviour left running, and it changes which records survive a failed
sync startand what the failure prints. Under the current gate that puts it onthe side needing koit's explicit GO, which it waits for after any CLEARED.
Fifteen mutations, each applied alone to a pristine copy,
bash -nchecked, thenthe whole file run. All fifteen were measured against this 14-case file:
compat_pid_goneskips the Windows side entirelytaskkilltasklistfalls through to gonetasklistthat runs and fails falls through to gonekillon Windows tooFifteen rows; eleven land on exactly one case; every one of the fourteen
cases is reddened by at least one row.
An earlier version of this table mixed rows measured against a 12-case file with
rows measured against this one. Cases 1–6 kept their numbers across that change,
so the mismatch was invisible by inspection — and on re-running, two of those
rows in fact redden a case that did not exist when they were first measured. A
row is quoted against the file it ran on.
Two rows exist because the first attempt at them came back green. Restoring
the old signal order left every case passing: the reap supplies the WINPID, which
defends
compat_signal_pid_treefrom its own ordering, so any case reaching itthrough the reap is blind to that bug by construction. The case that sees it
calls the function directly with no mapping passed in. And the platform decision
was made twice — a guard on the lookup and another before the
taskkill— soremoving either alone changed nothing and neither was held; it is made once now.
The fixture hid one of them. Its
psreturned900000 + pidwhatever theprocess state, so the pid-to-WINPID mapping was immortal and the code could
always find one no matter when it asked. It now answers only for a pid that is
still there, which is what MSYS2's
psdoes.Measured on this head: this file 14/14, and
check-enforced-assertions.shfinds nothing in it.
What this does NOT do
#831 stays open. It names two independent directions:
engine still appends to
run/remote-sync.<team>.log, lines still tear intoeach other, and the readiness poll still greps that shared file.
It also does not reduce the number of engines that can be started — two callers
that both misread still start two. What it stops is one of them being left with
nothing pointing at it.
A cost it adds, named: on msys, when the POSIX probe says gone,
compat_pid_gonepays apsand atasklistper call, and the readiness pollcan turn up to its ceiling.
_compat_get_winpidis deliberately not memoised— a cache there would be keyed on a pid, which is the reuse hazard this change is
built to survive. That failure path is the one #779 is already open about.
Refs #831