Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# Changelog

## Unreleased

### Fixed — `status` reported a dead sibling's record as "the backend" (F-868)

On a machine whose `server.json` recorded three display contexts — two dead
(`win-session-2` on 7169, `headless` on 19222) and one healthy backend serving 56
proxies (`win-session-1` on 52554) — `stealth-chrome-devtools status`, run from that
same session-1 shell, printed `backend : not running` and `pid : 89892`, the pid of a
process that had been gone for hours. `singleton._probe_backend_status` selected the
record with `backend_registry.first_backend`, which under schema v2 is dict insertion
order and carries no preference of its own, while discovery had been walking
`adoption_candidates` — the one home for "which backend would THIS client use" — all
along. The probe now walks that same list and reports the first candidate that answers
(wedged over down when none does), which fixes `status`, `doctor`'s summary lines,
`stop` and `kill-orphans`'s live-backend guard at once, since all four already consumed
it. The CLI status block now also selects once and passes the answer down: the pid and
log lines read the entry on the port just reported (`backend_on_port`) instead of making
their own `first_backend` read, and `doctor`'s port-occupant line takes the same port —
two independent record selections deleted rather than a third added. `status` gained one
`others :` line naming the display contexts it is NOT speaking about when the record
holds more than one, so a summary over a multi-context record no longer reads as "this is
all there is".

Two things the same selection bug was hiding are fixed with it. The socket→`initialize`
ladder is now `singleton._probe_port`, one home with three callers, instead of four lines
copied into `cli._probe_recorded_backend` under a comment justifying the copy with a claim
about `_probe_backend_status` that this release makes false. And `restart` now reports
that ladder's verdict for **the port it spawned on**: it took its `status` from the
record-wide walk while its `pid` came from the spawned port, so a responsive sibling could
report "responsive" beside the pid of a backend that had just come up wedged — both halves
of one return describing two processes.

Stale records are still pruned by nobody; see `audit/stage2/finding_F868_cli_status_reports_a_dead_record.md` §6.

## 2.1.5

### Fixed — the backend escapes the MCP client's Job Object (F-867)
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Package root: `src/stealth_chrome_devtools_mcp/`. Two console scripts (`pyprojec
|---|---|
| `server.py` | thin entrypoint — loads `embedded/server.py` as `__main__` via `runpy` (`main()` shim); its stdio branch is also **THE one place the PROXY process bootstraps its own observability** (`configure_logging("proxy")` + `_start_proxy_error_reporting`, F-827) — in the branch, never at the top of `main()`, or the runpy path would double-init |
| `__main__.py` | `python -m stealth_chrome_devtools_mcp` → `server.main()` |
| `cli.py` | the `stealth-chrome-devtools` ops CLI verbs (`status`/`doctor`/`stop`/`restart`/`cleanup`/`kill-orphans`/`serve`) |
| `cli.py` | the `stealth-chrome-devtools` ops CLI verbs (`status`/`doctor`/`stop`/`restart`/`cleanup`/`kill-orphans`/`serve`). The status block **selects the backend ONCE** (F-868): `_probe_backend_status()` is called by the command, and its `(status, port)` is passed down to every line — `_format_backend_status` (now pure formatting, no I/O), `_recorded_backend_pid(port)` and `_doctor_port_occupant_line(port)` (both read the entry on THAT port via `backend_on_port`, never `first_backend`), and `_other_records_note(port)`, the `others :` line that names the display contexts the summary is *not* about (compared on display context, never on port — a hand-edited non-int port reads as `None` and would hide itself). Two independent record reads were deleted to get here; do not re-add one — a per-line read is how a live backend's status came to sit above a dead sibling's pid. `_probe_recorded_backend` is a thin adapter over `singleton._probe_port`, not a second ladder |
| `settings.py` | **the one env home** — pydantic `Settings` + `get_settings()`; every `STEALTH_MCP_*` knob is a typed field here |
| `observability.py` | Sentry error shipping — hardcoded DSN, on by default, never raises (no-op under `STEALTH_MCP_NO_ERROR_REPORTING`); **the one PII scrubber** — `_scrub_event` (Sentry's `before_send`); **the one non-exception report** — `capture_lifecycle` (F-827: proxy transitions that are decisions, not crashes) |

Expand All @@ -57,9 +57,10 @@ Package root: `src/stealth_chrome_devtools_mcp/`. Two console scripts (`pyprojec
| File | Owns |
|---|---|
| `server.py` | the real MCP server — **ZERO tool bodies**; all 94 live in `tool_sections/` (plan_SERVERSPLIT, complete at slice 12). What this file owns, and nothing else: the per-execution FastMCP app (`mcp`/`registry`), `app_lifespan`, the four `@mcp.resource` handlers, the xpool-safe gate, `build_arg_parser`, the `__main__` block, and the **binding loop** that drives registration from THIS file's module body, once per execution of it (so the canonical import, the bare-name spec load and the runpy `__main__` load each get a full 94-tool app — a section module that decorated itself would register into the first execution only). **No migration alias block**: every singleton, knob and guard this file drives is read as `rt.<name>` against `tool_runtime`, the one patchable home, exactly as a tool body does. The lone import left is `clone_storage`, which this file never touches — it is the positive delegation handle `tests/test_clone_storage.py`'s F-201 negative-surface pin needs. At 523 LOC it is governed by the 1000-LOC default; its `GRANDFATHER` row is deleted, not merely satisfied |
| `singleton.py` | **backend lifecycle + the stdio proxy** — liveness probes (`_backend_http_ready`, `_probe_backend_status`), port selection (`_select_backend_port`, `DEFAULT_PORT`), the one identity+readiness reuse gate (`_same_identity_backend_ready`, `_source_fingerprint`, `REUSE_PATIENCE_SECONDS` — which spends its patience through `scheduling_lag.FairWindow`, F-856), cold-start lock (`_start_backend_holding_lock`), `run_stdio_proxy`. The `server.json` record moved to `backend_registry.py` (path names re-exported here for legacy callers); the proxy's *recovery from a dead backend* moved to `proxy_selfheal.py`; the watchdog LOOP moved to `backend_watchdog.py` — `_watch_backend_liveness` stays here as the wiring that knows which probes are ours; and **the spawn itself moved to `backend_launch.py` (F-867)** — `_start_server_process` still owns WHAT to start (`_server_process_cmd`, `_backend_interpreter`'s F-866 base-interpreter choice, the child env, F-830's boot-log roll) and what to RECORD afterwards (`PORT_FILE`, `_write_server_state`), and makes exactly ONE call, `backend_launch.spawn(cmd, child_env, boot_log) -> Launched(pid, rung)`; it no longer imports `subprocess` |
| `singleton.py` | **backend lifecycle + the stdio proxy** — the liveness PRIMITIVES (`_backend_http_ready`, `_server_is_healthy`) and the two thin bindings that hand them to `backend_liveness` (`_probe_port`, `_probe_backend_status` — see that row for the policy; they are wrappers on purpose, because the suite patches these names and a wrapper resolves them at CALL time). `status`, `doctor`, `stop` and `kill-orphans`'s live-backend guard all consume `_probe_backend_status`, so record selection is fixed in one place; `restart_backend` deliberately does NOT — it reports `_probe_port` for the port it spawned on, because the adoption walk answers "is there a backend for me" and a responsive SIBLING must never speak for a restart's own fresh backend. Port selection (`_select_backend_port`, `DEFAULT_PORT`), the one identity+readiness reuse gate (`_same_identity_backend_ready`, `_source_fingerprint`, `REUSE_PATIENCE_SECONDS` — which spends its patience through `scheduling_lag.FairWindow`, F-856), cold-start lock (`_start_backend_holding_lock`), `run_stdio_proxy`. The `server.json` record moved to `backend_registry.py` (path names re-exported here for legacy callers); the proxy's *recovery from a dead backend* moved to `proxy_selfheal.py`; the watchdog LOOP moved to `backend_watchdog.py` — `_watch_backend_liveness` stays here as the wiring that knows which probes are ours; and **the spawn itself moved to `backend_launch.py` (F-867)** — `_start_server_process` still owns WHAT to start (`_server_process_cmd`, `_backend_interpreter`'s F-866 base-interpreter choice, the child env, F-830's boot-log roll) and what to RECORD afterwards (`PORT_FILE`, `_write_server_state`), and makes exactly ONE call, `backend_launch.spawn(cmd, child_env, boot_log) -> Launched(pid, rung)`; it no longer imports `subprocess` |
| `backend_launch.py` | **THE one home for "spawn the backend where no MCP client's Job Object can reach it"** (F-867) — `spawn`, `Launched(pid, rung)` and nothing else in the tree may create a backend. POSIX is one rung (`posix`, `start_new_session=True`, unchanged). Windows climbs three, and the rung that served is logged through the ONE `_log_rung` line on `stealth.proxy` so a post-mortem can read it: (1) `breakaway` — `CREATE_BREAKAWAY_FROM_JOB`, accepted ONLY when `_proven_in_a_job` says the new process is in NO job, because under a nested job chain the flag leaves the innermost job only and a "successful" breakaway can still sit in the client's; `ERROR_ACCESS_DENIED` (what the SDK's job returns) drops a rung while any other `OSError` propagates. A PARTIAL escape is never discarded blindly — rung 2's viability (`_scheduler_plan`) is decided FIRST, and when there is no scheduler rung the child is KEPT as the `breakaway-partial` rung at WARNING, because out of one job beats out of none; conversely, breakaway once proven PERMITTED is re-asked on rung 3. (2) `scheduler` — a one-shot Task Scheduler task through `desktop_launch._schtasks` (the F-810 seam, not a second home) running `pythonw.exe` (no console, so no window can flash) on the BASE interpreter, never a venv's `Scripts\pythonw.exe`, which is F-866's redirector and would rebuild the kill-on-close job; the stdlib-only `_LAUNCHER_SCRIPT` reads argv + the ENTIRE child env + the boot-log path from a JSON spec in `~/.stealth-mcp/backend-launch/` (the command line carries two paths only because **`schtasks` stores at most 253 chars of `/TR` and truncates beyond that with exit 0** — measured, not F-810's documented "~261"; the silent truncation costs a Last-Result-2 task, the whole 20 s pid deadline and a drop to `plain`, which is why the per-attempt token is 12 hex chars, not 32), re-opens `backend-boot.log` itself and gives the backend stdout AND stderr (F-303 survives the hand-off), and returns the SERVING pid through an `os.replace`d pid file. **Gated on `_same_session_as_console`**: taken only when the spawner is already in the logged-on console session, so the backend lands where it would have anyway and the display context `singleton` records stays true (F-808 — the tool still never PICKS a session). Task and scratch files deleted in a `finally`, and the orphan sweep that runs before a scheduler spawn is a FILESYSTEM sweep, not a `/Query`: any `<token>.json` spec (token = 12 hex) older than 2× the pid deadline gets its `stealth-mcp-backend-<token>` task deleted BY NAME and its files removed. Deliberately not "delete tasks whose spec is absent" — `_cleanup` deletes the task first, so a killed spawner leaves task AND spec, which is the only orphan class there is and the one that predicate missed. A live sibling's spec is younger and untouched; a clean dir costs zero `schtasks` calls. (3) `plain` — today's `DETACHED_PROCESS \| CREATE_NEW_PROCESS_GROUP` (plus the breakaway bit when rung 1 proved it permitted), for a runner with no console session; its line names F-867 and says the backend is inside this client's job. A leaf: `backend_registry` for the state dir, `desktop_launch` lazily for the ONE `schtasks` seam, the ONE pid-file reader and the ONE task teardown, so none of them gets a second home. Never raises for a rung's own failure — only a genuine `Popen` error reaches the caller |
| `backend_watchdog.py` | **THE one home for the proxy's mid-session liveness watchdog** — the SLOW witness (`watch_liveness`): F-820's strikes plus the confirmation phase. A leaf: both probes arrive as arguments, so it never imports `singleton` and the dead-vs-busy policy stays single-homed in the reuse gate |
| `backend_liveness.py` | **THE one home for "is the backend on this port alive, and which recorded backend would THIS client be served by"** (F-868) — `probe_port`, the socket→`initialize`→`down`/`wedged`/`responsive` ladder (never copy those four lines again; `cli._probe_recorded_backend` is an adapter that adds only the one word the ladder cannot reach, `"no port recorded"`), and `probe_recorded`, the ADOPTION-order walk that reports the first candidate which answers — wedged over down when none does, `("none", None)` when no adoptable entry names a port at all — never `first_backend`'s "whichever entry the record lists first". A leaf on `backend_watchdog`'s pattern: the two primitives arrive as ARGUMENTS (`is_healthy`/`http_ready`) and the record as a PATH, so it never imports `singleton` and the order itself stays `backend_registry`'s policy. `probe_recorded` takes the per-port probe as an argument too, so `singleton._probe_port` — the name the suite patches — is what the walk actually asks |
| `scheduling_lag.py` | **THE one home for "was this process scheduled fairly, and what does a time budget owe it when it was not"** (F-856) — `FairWindow`, whose budget is charged in fair seconds (elapsed ÷ the lag its own naps measured), plus `MAX_STRETCH`, `REPORT_FACTOR` and the `proxy: patience extended under starvation` lifecycle report. It never decides alive-or-dead: only "has this window been spent", so `proxy_selfheal`'s ONE heal path is untouched. A leaf; the `_now`/`_wait` module functions are its single timing seam |
| `session_hygiene.py` | **THE one home for "this MCP session was abandoned by its client — reap it"** (F-862) — `HygienicSessionManager`, the MCP streamable-HTTP session manager plus a sweep: a session with NO standing GET event stream (a live proxy always holds one) and no request for `ABANDONED_AFTER_SECONDS` is terminated through the transport's own `terminate()`, so a liveness probe whose DELETE was lost or a proxy that died no longer costs the backend ~0.11 MB forever. `install()` binds the class to the name FastMCP constructs by module attribute — the one seam, called from `server.py`'s http branch as `rt.session_hygiene.install()` BEFORE `mcp.run()`. A leaf: imports no other embedded module |
| `serve_startup.py` | **THE one home for "startup work that must not delay the backend's first serve"** (F-856) — `after_serving`, which runs one idempotent, best-effort startup job on a daemon thread. Its docstring carries the safety argument for reaping orphans CONCURRENTLY with serving. Deliberately not a general background-task runner: `clone_storage.spawn_background_sweep` keeps its own asyncio task, dedupe and trigger-time root capture |
Expand Down
16 changes: 14 additions & 2 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,20 @@ browser-session cap : 20.0 GB [STEALTH_MCP_BROWSER_SESSION_STORAGE_CAP_GB]
- **`backend`** is the real liveness state (`singleton._probe_backend_status`):
`responsive` = answers a real MCP `initialize`; `wedged` = socket open but not
answering (→ `restart`); `down` = recorded but nothing there; "not running" = no
recorded backend. The port shown is the **chosen** port, which may differ from
`19222` if that was taken (see "Port already in use" below).
recorded backend this shell could adopt. The port shown is the **chosen** port, which
may differ from `19222` if that was taken (see "Port already in use" below).
- **Which backend is it about?** The one *this shell* would be served by — the same
adoption order discovery uses (F-868), not whichever entry `server.json` lists first.
`pid` and `log` name that same backend, so the four lines can never describe different
processes. `server.json` can hold one entry per display context, and dead ones are
never pruned, so an `others` line appears when there are others:

```
others : 2 backends recorded (win-session-2, headless) — run `doctor` for each one's state
```

`doctor`'s `contexts :` block probes every recorded backend on its own port; that is
the place to look when you want all of them rather than yours.
- **`browser-session root`** and **`browser-session cap`** are about **disk** — the
directory holding named browser-session profiles/clones and the cap that trims idle
ones. They are named "browser-session" deliberately: this cap trims *named
Expand Down
Loading
Loading