Skip to content

fix(F-868): status reports the backend this shell would use, not the first record; liveness ladder + adoption walk extracted to backend_liveness - #104

Merged
AminDhouib merged 4 commits into
mainfrom
fix/F868-cli-status-own-context
Sep 15, 2026
Merged

AminDhouib merged 4 commits into
mainfrom
fix/F868-cli-status-own-context

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

The defect (F-868)

stealth-chrome-devtools status printed backend: not running / pid 89892 from the FIRST server.json record (a dead 2.1.1 backend on port 7169, context win-session-2) while this shell's own context had a healthy 2.1.5 backend on 52554 (pid 53836) serving 56 proxies. The pid/log lines and the doctor port line were two further independent selections, which is how they could disagree with the status line rather than merely inherit its error.

Root cause: singleton._probe_backend_status picked its record with backend_registry.first_backend (dict insertion order, "carries no preference") while discovery already walked backend_registry.adoption_candidates, the one home for "which backend would THIS client use". Full write-up: audit/stage2/finding_F868_cli_status_reports_a_dead_record.md.

The fix (two commits, reviewed separately)

  1. c6d27d0_probe_backend_status walks adoption_candidates for the shell's own display context; one _probe_port(port) ladder serves the walk, restart_backend (which now reports the SPAWNED port's own liveness instead of a responsive sibling's) and the CLI; status names the other records (others: 2 backends recorded (win-session-2, headless) — run doctor for each one's state); stop is per-display-context and pinned as such. Fixture helpers (v2_record, pretend_display_context) live in tests/fakes.py.
  2. df0152f — pure move: the ladder and the walk go to a new leaf embedded/backend_liveness.py (probes, record path and per-port probe arrive as arguments, as backend_watchdog does; the leaf never imports singleton), with thin wrappers left on singleton so every existing patch target still lands. singleton.py 999 → 985.

Evidence

  • RED on main: 6 of 7 status tests (the single-entry no-note case is green on both sides by construction) plus the restart pin (('responsive', 4242) == ('wedged', 4242)); verified by the reviewer against main's source.
  • Verified output for the exact observed record: backend: running (responsive) on port 52554 / pid 53836.
  • Unit lane on the branch: 2408 passed (reviewer, throwaway worktree). Two Opus review rounds: APPROVE on both commits.

Not fixed here (named)

  • Nothing prunes stale records; a killed backend or a logged-out desktop leaves its entry forever. The read-only verbs may not write, and "pid gone" is not "port free"; needs an owner (finding §6).
  • stop on a record whose only entries are foreign proven contexts now says "not running" instead of stopping the first one; deliberate, stated, pinned.

…first record

`~/.stealth-mcp/server.json` holds one entry per display context and nothing
prunes a dead one. On a machine recording a dead `win-session-2` (7169), a dead
`headless` (19222) and a healthy `win-session-1` (52554) serving 56 proxies,
`stealth-chrome-devtools status` run from that same session-1 shell printed
`backend : not running` and `pid : 89892` — the pid of a process gone for hours.

`singleton._probe_backend_status` selected its record with
`backend_registry.first_backend`, which under schema v2 is dict insertion order
and says in its own docstring that it carries no preference. Discovery had been
walking `adoption_candidates` — the one home for "which backend would THIS client
use" — all along, which is why every proxy in that shell was correctly on 52554.
The probe now walks that same list and reports the first candidate that answers
(wedged over down when none does). No second selection policy: it still only says
which of the offered candidates is up. `status`, `doctor`'s summary lines, `stop`
and `kill-orphans`'s live-backend guard all already consumed this ONE function.

The CLI status block now selects once and passes `(status, port)` down, so its
lines cannot describe different backends: `_format_backend_status` is pure
formatting, `_recorded_backend_pid(port)` and `_doctor_port_occupant_line(port)`
read the entry on that port via `backend_on_port` instead of each making its own
`first_backend` read. Two independent selections deleted, none added. `status`
gained one `other records:` line naming the display contexts the summary is not
about, and only when the record holds more than one.

RED first: 7 tests, the CLI ones reproducing the observed output byte-for-byte.
Stale records are still pruned by nobody — the argument for leaving that alone is
in the finding.

Finding: audit/stage2/finding_F868_cli_status_reports_a_dead_record.md
…ned on

Review follow-up on the F-868 branch. Three product changes, all of them
consequences of the same selection bug the first commit fixed.

`singleton._probe_port(port)` is now THE one home for the socket ->
`initialize` -> down/wedged/responsive ladder. It was four lines copied into
`cli._probe_recorded_backend` under a comment justifying the copy with two
claims: that `_probe_backend_status` "reads the FIRST recorded backend" and so
could not answer per-entry, and that singleton.py sat at its budget. The first
stopped being true in the previous commit; the second was never a reason to
have two answers to one question. The CLI form is now a thin adapter keeping
only the one word the ladder cannot reach ("no port recorded").

`restart_backend` reports `_probe_port(port)` for the port it spawned on. It
took `status` from the record-wide adoption walk while `pid` came from
`backend_on_port(state, port)`, so on a multi-context record a responsive
SIBLING reported "responsive" beside the pid of a backend that had just come up
wedged - two processes in one return, contradicting the docstring's own promise
that a wedged restart must be visible. Pinned RED-first: the new test fails
against the old reporter with ('responsive', 4242) == ('wedged', 4242), and
asserts both halves so it cannot pass by the sibling merely being invisible.

`_other_records_note` compares on display context, not port: `backends_in`
stamps a context on every entry, whereas a hand-edited non-int port reads as
None and would have matched a None reported port - hiding itself in exactly the
"nothing is running" case that needs it. Its label is `others      :`, aligned
with every other label in the block, and the RUNBOOK sample is now byte-identical
to what ships.

Also: `stop`'s narrowing to adoptable contexts is pinned (green both sides by
construction - it exists so the behaviour change is not reverted by accident,
and the finding says so rather than claiming a RED); the `_v2` record builder
moved to tests/fakes.py as `v2_record`, where the two test modules that had
copies now import it along with the existing `pretend_display_context`; and the
finding's RED claim is corrected to 6 of 7, the single-entry no-note case being
green on both sides.

singleton.py lands at 999/1000 LOC. Two lines were paid for honestly - the
F-856 paragraph in `_same_identity_backend_ready` was retelling what
`scheduling_lag.FairWindow` is THE one home for and now points at it - but one
line of headroom is the most fragile thing here. The next change to that file
needs the `backend_liveness` extraction (probes handed in as arguments, the way
backend_watchdog already does), not more prose-trimming; recorded in CLAUDE.md
and finding section 6.
…leaf

Pure move, no behaviour change. singleton.py came out of the F-868 fix at
999 of its 1000-LOC default - a gate that passes and a file nobody can edit.

embedded/backend_liveness.py is now THE one home for "is the backend on this
port alive, and which recorded backend would THIS client be served by":
probe_port (the socket -> initialize -> down/wedged/responsive ladder) and
probe_recorded (the adoption-order walk). Both docstrings moved verbatim.

A leaf on backend_watchdog's proven pattern: the two liveness primitives
arrive as ARGUMENTS (is_healthy / http_ready) and the record arrives as a
PATH, so the module never imports singleton and never decides WHICH record
either - the adoption ORDER stays backend_registry's policy, merely consumed
here. Importing the leaf alone does not pull singleton into sys.modules.

singleton keeps thin _probe_port / _probe_backend_status wrappers that bind
OUR probes, OUR record path and OUR display context. That is the point, not a
hop to delete: the suite patches singleton._server_is_healthy,
singleton._backend_http_ready and singleton._probe_port by name, and a wrapper
resolving those module globals at CALL time is what keeps every existing
monkeypatch.setattr(singleton, ...) reaching this code. probe_recorded takes
the per-port probe as a parameter for the same reason, so the walk asks
singleton._probe_port rather than its own module-level function. A direct
import would bind at import time and silently stop seeing such a patch.

singleton.py 999 -> 985; the leaf is 91 lines; no cap ratcheted up.

Also folds in the review nit: _cmd_restart's trailing comment still described
a "none" verdict, which _probe_port cannot return - that word belonged to the
record-wide walk restart stopped using. The branch is unchanged (it is the
"down" arm); only the comment is corrected.
@AminDhouib
AminDhouib merged commit 2f51508 into main Sep 15, 2026
32 checks passed
@AminDhouib AminDhouib mentioned this pull request Sep 15, 2026
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