Skip to content

js launch: post-attach 'initialized' race stalls child adoption 3s — CDP function-breakpoint bridge attaches after the entry pause, pre-launch function breakpoints silently never arm #529

Description

@debugmcpdev

Summary

The e2e test mcp-server-smoke-js-function-bp.test.ts > "honors conditions evaluated in the callee scope" failed in a full-suite run with expected null not to be null — no pause was ever observed. Root cause: a child-adoption step can miss the child's post-attach initialized DAP event, stall adoption for its full 3 s timeout, and push the CDP function-breakpoint bridge attach past the (forced) entry pause. The pre-launch function breakpoint then silently never arms and the debuggee runs to completion with zero stops.

This is a long-latent race (present since the JS adapter policy refactor), not a recent regression. The test's assertions match the documented contract and need no changes.

Timeline of the failing run

Session 67ff0834, 2026-08-27T17:41Z. Server log debug-mcp-server-587064.log, proxy per-pid log debug-mcp-server-587243.log (pre-#526, so the [CdpFnBpBridge] / [ChildSessionManager] lines are in the per-pid file).

t (17:41:xx) event
17.954 child attach request sent
18.055 attach response
18.056 child's post-attach initialized event — same TCP chunk as the response
18.056→21.05 handlePostAttachInit waits waitForEvent(child, 'initialized', 3000)misses the event, times out in full
18.094 CDP entry pause hits; bridge: holding 'entry' stop up to 300ms for its CDP pause record
18.394 bridge: no CDP pause record for 'entry' stop — forwarding unchanged (it isn't attached to the CDP proxy yet, so no record can ever arrive)
18.396 server auto-continues the entry stop (stopOnEntry: false)
18.709/18.711 compute called with 3 / compute called with 7 — run through, nothing armed
21.058 adoption finally proceeds: requestCDPProxy sent
21.145 [CdpFnBpBridge] attached to CDP proxy — 3 s too late; target is running, sticky Debugger.paused replay has nothing to replay
21.9 compute called with 99 + FUNCTION_BP_FIXTURE_DONE; exit 22.07 — zero function-breakpoint stops

In the contrasting passing run (session 38b6a2ec, 17:49Z), the initialized event landed in a later chunk, the wait caught it in ~150 ms, the bridge attached at 52.518 — before the entry stop was forwarded at 52.615 — the sticky pause replay provided the record, and compute armed (armed 'compute' as 7:1 at 52.62). Both condition-gated stops then landed exactly as the test asserts.

Mechanism

  1. MinimalDapClient processes a socket chunk synchronously: the attach response resolves its promise (a microtask), then the initialized event in the same chunk is emitted immediately. Only afterwards does attachChild's continuation run and handlePostAttachInit (src/proxy/child-session-manager.ts:874) register its listener — the event is already gone.
  2. waitForEvent (child-session-manager.ts:993) has no latch; it only observes future events. So the wait burns its whole 3 s.
  3. Adoption is strictly sequential (initializeChild → configureChild → attachChild → handlePostAttachInit → cdpBridge.attachToChild → ensureChildStopped, :539-582), so the bridge attach inherits the stall.
  4. The bridge can only resolve an ESM module-scoped name like compute via Debugger.evaluateOnCallFrame at a pause. The one designed pause is the forced entry stop (Forcing stopOnEntry=true in the js-debug launch config (pending CDP function breakpoints, issue #295)). Miss it and the entry is left "will retry at the next pause" — but the only thing that could produce a next pause is the breakpoint that failed to arm. Silent zero-stop run.

Side effects of the same miss

  • When the 3 s wait times out, handlePostAttachInit also skips its mirror re-send of exception breakpoints and stored line breakpoints to the child (child-session-manager.ts:878-895).
  • Residual fragility even on the good path: the held entry stop is released when the CDP pause record arrives, not when arming completes. In the passing run, arming (52.62) beat the resume (52.668) by only ~48 ms. A slow Debugger.setBreakpointOnFunctionCall round trip can still lose to the auto-continue.

Proposed fix

  1. Latch child initialized events, mirroring the existing sawChildStop latch in the same file (:179, set at :947; same shape as the fix(#512): latch a late init ACK instead of losing it between retry windows #515 "latch a late init ACK" fix): count initialized events from wireChildEvents onward; initializeChild consumes the first; handlePostAttachInit checks the latch before falling back to waitForEvent.
  2. Hardening: in cdp-function-breakpoint-bridge.ts processStoppedEvent, when entries are pending and a pause record is present, await the in-flight arming attempt (bounded) before releasing the held stop, so the auto-continue cannot resume V8 before Debugger.setBreakpointOnFunctionCall lands.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions