Skip to content

RELEASE-FIX-B: session-reentrant app_lifespan (fixes B1, transport gate genuinely green) - #43

Open
AminDhouib wants to merge 3 commits into
audit/release-1-w1from
audit/release-fix-b
Open

AminDhouib wants to merge 3 commits into
audit/release-1-w1from
audit/release-fix-b

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

RELEASE-FIX-B (stacked on #42 / audit/release-1-w1)

Fixes B1, the release-blocking defect PR #42's transport gate caught: FastMCP runs app_lifespan once per MCP session over streamable HTTP, so the stdio proxy watchdog probe sessions (every 2s) re-ran orphan recovery on entry (killing live/mid-spawn Chrome) and the full destructive teardown on exit - every browser instance died within ~2s over the real user wire path.

C1 (f81ff8f) - embedded/server.py only (+29 net, 3359/3389 LOC): _LIFESPAN_STARTED boolean guard (startup once per process; deliberately not a refcount - an idle backend crossing back to zero sessions must not re-arm orphan recovery); destructive teardown wrapped in if _SERVE_TRANSPORT != "http" (message bytes verbatim; if-guard not return so session exceptions propagate); __main__ stamps _SERVE_TRANSPORT = args.transport. RED-first hermetic pins in tests/test_lifespan_reentrancy.py (pre-fix REDs: close_all_calls == 1, activate_calls == 2; stdio 1.x single-cycle contract preserved).

C2 (585ebf2) - flip the W1 xfail: the full real-stdio journey (installed launcher -> proxy -> HTTP backend -> real headless Chrome) now passes 3/3 locally (9.6-10s) + an independent orchestrator re-run, watchdog fully active and untouched.

Gates: unit 753 passed; ruff / ty (76 baseline) / vulture / budgets / suppression-owners clean. No existing test weakened.

Merge order: merge #42 first (this auto-retargets to main), or merge this into the W1 branch first so #42 lands genuinely green. True merge commits, please.

AminDhouib and others added 3 commits July 24, 2026 15:15
…down to process end

FastMCP serves streamable HTTP by running the low-level MCP Server.run() — and
thus the server lifespan — once PER MCP SESSION, not once per process.
app_lifespan was written for once-per-process semantics, so over HTTP every MCP
session's lifespan ENTRY re-ran orphan recovery (process_cleanup.activate, with a
fresh server-init time that kills live/mid-spawn Chrome) and every EXIT ran the
full destructive teardown (browser_manager.close_all,
process_cleanup._cleanup_all_tracked, in_memory_storage.clear_all). The stdio
proxy's 2s liveness watchdog opens+DELETEs a probe session every 2s, so every
browser instance on the shared backend died within ~2s over the real wire path
(finding B1) — invisible to the in-process .fn E2E seam.

Fix (embedded/server.py only):
- Module globals _LIFESPAN_STARTED (once-per-process startup guard) and
  _SERVE_TRANSPORT (stamped by the __main__ entrypoint from parsed --transport).
- Startup block (asyncio/cookie compat installs, startup log,
  process_cleanup.activate, start_idle_reaper, clone_storage sweep) runs only on
  the FIRST lifespan entry per process. A boolean guard (not a 0->1 refcount) is
  deliberate: an idle HTTP backend crossing back to zero sessions must not
  re-arm orphan recovery.
- The finally destructive teardown runs verbatim (message bytes unchanged) only
  when _SERVE_TRANSPORT != "http". HTTP session exit is a no-op; process
  termination is already reaped by process_cleanup's atexit/signal handlers. An
  if-guard (not an early return in finally) preserves exception propagation from
  the session body.

RED first: tests/test_lifespan_reentrancy.py (hermetic, no Chrome) drives
app_lifespan directly and pins all three behaviors. No existing test changed;
test_mcp_protocol_surface.py (real lifespan via in-memory Client) still passes
unchanged since stdio-mode teardown is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er real stdio

With C1's session-reentrant app_lifespan in place, the real-stdio release-gate
journey (spawn → navigate → interact → oracle → PNG screenshot → clean teardown)
survives the stdio proxy's 2s liveness watchdog. Remove the @pytest.mark.xfail
marker and the KNOWN-RED (finding B1) paragraph from the module docstring so the
gate now asserts a real PASS.

Local: 3/3 consecutive passes over real headless Chrome
(tests/test_e2e_transport.py -m "integration and transport"), ~9.6-10.0s each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AminDhouib added a commit that referenced this pull request Jul 29, 2026
…n (F-780)

#43, #45 and #47 are red on the legacy `Browser Integration Tests
(Chrome + Xvfb)` check while main and #42 are green on the same check.
Read at face value that says FIX-B broke browser integration. It did
not, and the reason is provable without reading a single CI log.

test.yml runs `pytest -m integration --timeout=120`. The harness it
drives declares INIT=60, LIST=130, SPAWN=120, WARMUP=150 with 4 attempts
-- under a comment saying "the pytest --timeout is the outer net". Two
of those single-step bounds are LARGER than the whole job's per-test
budget, and BACKEND_READY_TIMEOUT is exactly equal to it. A 120s outer
net cannot contain a 150s inner step. The job is structurally incapable
of finishing that test; no timing luck is involved.

It only started failing at FIX-B because W1 landed the transport journey
already xfail'd (B1 was open), and an xfail costs nothing on the clock.
FIX-B C2 correctly removed the marker once B1 was fixed, so from that
commit the test actually runs -- and immediately hits the wall.

The controlled comparison is #46: it contains FIX-B transitively, runs
the SAME `-m integration` selection on the SAME OS under the release
gate's 180s budget, and is green 23/23.

Deliberately NOT fixed. W2 deletes this job, so patching a doomed file
on three in-review branches is churn that also mutates PRs under human
review. Recorded instead, because the trap is expensive: the merge queue
will show red at #43 -> #45 -> #47 until W2 lands, and someone will
otherwise go hunting for a regression that was never there.

Not added to RELEASE_CONTRACT.md on purpose -- the contract describes
what a user receives, not our merge plumbing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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