Skip to content

fix(#500): report js attach/child-session breakpoint verification truthfully - #503

Merged
debugmcpdev merged 1 commit into
mainfrom
fix/500-js-attach-unbound-breakpoints
Aug 27, 2026
Merged

fix(#500): report js attach/child-session breakpoint verification truthfully#503
debugmcpdev merged 1 commit into
mainfrom
fix/500-js-attach-unbound-breakpoints

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Root cause

js-debug is the only adapter that binds breakpoints in a child session (reverse startDebugging); the parent session answers setBreakpoints with provisional stubs — verified:false, breakpoint.provisionalBreakpoint, parent-space ids. Three structural facts combined into #500:

  1. breakpoints_synced (list_breakpoints: logpoints (and any never-paused launch) stay verified:false with no adapterId after firing #439) never fires for js — the worker's command-queueing branch skips handleInitializedEvent, launch and attach. What heals js launch is the post-launch belt-and-braces re-sync; attachToProcess had no equivalent, and the js handshake discards its own setBreakpoints responses.
  2. The child mirror was fire-and-forget and fully silentstoreBreakpoints swallowed rejections, emitBreakpointResults returned silently on an empty body, and an unmatched synthesized event was dropped at debug level.
  3. js-debug answers a no-change re-send with an EMPTY echoBreakpointManager.setBreakpoints returns {breakpoints:[]} when the diff is a no-op (verified in the vendored bundle). A pre-attach breakpoint is registered inside the child via js-debug's pending-target queue during adoption, so every later identical re-send echoes [] — and js-debug emits no late bind event for it. Its verified state was unrecoverable by construction.

Diagnosis was run live against the repro in issue #500 with DAP_TRACE=1; the trace shows the child answering the adoption-time replay with its own provisional stub {id:1, verified:false} (which also stamped the parent/stub id as adapterId — the exact poisoning behind #495) and answering the post-attach re-mirror with [].

#495's mechanism (proven from a launch trace): parent and child ids share one integer space (both id:1 observed in one session). The downgrade guard only protected fallback matches, so once a child id was stamped, a late parent stub with the same integer matched by id, bypassed the guard, and permanently downgraded a verified record — verified:false + "Unbound breakpoint" + adapterId present, exactly #495's signature, order-dependent under load.

The fix

All behavior changes are gated on mirrorBreakpointsToChild (js-only); non-mirroring adapters see byte-identical behavior.

  • Child-authoritative response mergeMinimalDapClient.sendRequest('setBreakpoints') awaits the child mirror (bounded 3s; a hung child only delays, never fails) and, when a fully-adopted child echoes the complete set, returns the child's body marked child-sourced (dunder key, survives worker IPC). syncBreakpointsForFile full-stamps from a child-sourced response. set_breakpoint on a live js attach session now returns verified:true in its own response.
  • Attach parity re-syncattachToProcess re-sends stored breakpoints (+ function breakpoints and their launch-style warning) after the state settles, passing forceFreshEcho: on a short no-change echo the mirror clears the path and re-sets, forcing js-debug to answer with real records.
  • Child-origin event tagging (fixes Docker/js: logpoint fires but list_breakpoints reports verified:false + 'Unbound breakpoint' (intermittent, did not reproduce; suspected child-session bind race) #495) — child breakpoint events are marked at the proxy choke point; handleBreakpoint now ignores non-child downgrades of verified records on any match type, and for mirroring policies stamps adapterId only from child-origin verified events — parent-space and provisional-stub ids never enter the store.
  • Observability — every previously-silent mirror failure mode (no active child, child rejection, short/absent echo, unmatched synthesized event with full store context) now logs.
  • Message normalizationsyncBreakpointsForFile runs normalizeBreakpointMessage before storing (the last stamp site missing Verified JS breakpoints keep the raw provisional message 'breakpoint.provisionalBreakpoint' (untranslated key + contradicts verified:true) #471's normalization).

Verification

Case study: mcp-debugger debugging mcp-debugger

The fix was verified by attaching one mcp-debugger to another mcp-debugger's proxy worker while a third client drove the #500 repro through it:

  1. A subject server (node dist/index.js http -p 3111) debugged a node --inspect tick target on behalf of an MCP driver client.
  2. The subject's proxy worker had no inspector — node -e "process._debugProcess(<pid>)" switched one on in the running process.
  3. A second mcp-debugger attached to the worker and set breakpoints inside the new code — both came back verified:true at set time on a js attach session, i.e. the fix vouching for itself.
  4. When the driver called set_breakpoint, the worker froze at ChildSessionManager.storeBreakpoints (stack: handleCommand → handleDapCommand → sendRequest → storeBreakpoints), showing the mirror's live state (mirrorTargetIsChild:true, adoptionInProgress:false), then at the merge, where one evaluate_expression captured both truths side by side:
    { "parentAnswer": { "breakpoints": [{ "id": 1, "verified": false, "message": "Unbound breakpoint" }] },
      "childAnswer":  { "breakpoints": [{ "id": 0, "verified": true, "line": 11 }] } }
    — the js attach: breakpoints that bind and fire are reported "Unbound breakpoint" / verified:false, permanently #500 lie and the child's truth for the same breakpoint, including the id-space collision (1 vs 0) behind Docker/js: logpoint fires but list_breakpoints reports verified:false + 'Unbound breakpoint' (intermittent, did not reproduce; suspected child-session bind race) #495.
  5. Released, the driver received verified:true + adapterId:0 — with an honest timeout warning, since the inspection had held the worker past the 35s DAP deadline.

(Two side findings from the exercise are filed separately: attaching js-debug to a server that forks node children strands those children, and a worker that outlived its reaped HTTP session.)

Fixes #500
Closes #495

🤖 Generated with Claude Code

…thfully

js-debug binds breakpoints in a child session while the parent answers
with provisional stubs (verified:false, "Unbound breakpoint", parent-space
ids). Nothing reconciled the two on the attach path: a breakpoint set
after attach_to_process raced a fire-and-forget child mirror, and one set
before attach stayed unbound-with-no-adapterId forever - even after it
fired - because js-debug answers a no-change re-send with an empty echo
(BreakpointManager returns {breakpoints:[]} when the diff is a no-op) and
emits no late bind event for it.

- MinimalDapClient hands the child's authoritative setBreakpoints
  response back through the sync path (marked child-sourced via a dunder
  key that survives the worker IPC, bounded 3s wait), so
  syncBreakpointsForFile full-stamps verified/adapterId synchronously -
  set_breakpoint on a live js attach session now returns verified:true
  in its own response.
- attachToProcess gains the post-launch belt-and-braces re-sync
  (+ function breakpoints and their launch-style warning); the mirror
  clears and re-sets when asked for a fresh echo (forceFreshEcho, sent
  only by the attach re-sync) so already-registered sets can still be
  verified.
- Child-origin breakpoint events are tagged at the proxy choke point;
  handleBreakpoint ignores non-child downgrades of verified records on
  ANY match type (a late parent stub with a colliding integer id could
  previously downgrade by id-match - the #495 signature) and only stamps
  adapterId from child-origin VERIFIED events for mirroring policies, so
  parent-space and provisional-stub ids never poison the store.
- Every previously-silent mirror failure mode (no active child, child
  rejection, short/absent echo, unmatched synthesized event) now logs.
- syncBreakpointsForFile normalizes raw l10n keys before storing
  (issue #471's last unnormalized stamp site).

Non-mirroring adapters see byte-identical behavior; all changes are
gated on mirrorBreakpointsToChild.

Fixes #500
Closes #495

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.26866% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/proxy/child-session-manager.ts 94.59% 2 Missing ⚠️
src/session/session-manager-operations.ts 88.88% 2 Missing ⚠️
src/proxy/minimal-dap.ts 97.82% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@debugmcpdev
debugmcpdev merged commit 7053260 into main Aug 27, 2026
10 checks passed
@debugmcpdev
debugmcpdev deleted the fix/500-js-attach-unbound-breakpoints branch August 27, 2026 00:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants