Skip to content

fix(session): a session whose launch runs with the debugger off says so on every later surface (#749) - #751

Merged
debugmcpdev merged 8 commits into
mainfrom
fix/749-debugger-off-surfaces
Sep 17, 2026
Merged

debugmcpdev merged 8 commits into
mainfrom
fix/749-debugger-off-surfaces

Conversation

@debugmcpdev

@debugmcpdev debugmcpdev commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Closes #749.

What was wrong

#747 decides "this noDebug launch runs with the debugger off" once, for the start_debugging response, and then forgets it. Nothing on the session recorded it, so every later surface explained a debugger-off session in debugger terms. Measured on this branch's base with js-debug and examples/javascript/pause_test.js launched { stopOnEntry: false, noDebug: true }:

  • set_breakpoint on the running session → verified: false, warning: "Unbound breakpoint" — the adapter's answer with no reason;
  • list_breakpoints → every breakpoint unbound, no reason;
  • get_stack_trace → "Session is not paused (state: running); stack traces are only available while paused." — true, but not why;
  • step_over / continue_executionNot paused;
  • pause_execution → and here the measurement corrected the issue: js-debug lands the pause (state: paused, lastStop.reason: 'pause'; Node prints Debugger attached.). Under noDebug js-debug disables its debug domains — breakpoints — but the inspector is attached and a user pause works.

What changes

Record. One raw record, ManagedSession.launchDebuggerOff — "the current launch runs with the debugger off" — projected as DebugSessionInfo.debuggerDisabled by SessionStore.getAll() alone, so no handler can read the ungated value by accident. Written in the launcher right where debuggerOff is decided (real launches only, not dry runs), reset in the launcher's and the attach controller's per-attempt blocks — deliberately not in the core's setupProxyEventHandlers, which runs inside proxyLauncher.start after the launcher wrote it — and cleared in handleStopped by a stop only a live debugger produces (stopProvesDebuggerOn: hitBreakpointIds present, an exception or entry stop, or a breakpoint reason the adapter itself gave — raw and normalized): that proves the adapter build debugs after all. A breakpoint the adapter verified is proof of the same strength and arrives before any hit — measured on the merged build: every honouring adapter refuses or unbinds a live breakpoint under the flag (js-debug "Unbound breakpoint", debugpy "Server is not available", Delve "noDebug mode: unable to process 'setBreakpoints'", CodeLLDB "Not supported in noDebug mode"), so a verified record can only come from a build that ignores it; it is consulted on read (adapterVerifiedABreakpoint, inside isDebuggerOff) since bindings are per-launch state, and the launcher reads the same evidence so "will not fire" is never said of a breakpoint list_breakpoints shows bound. A pause, a step taken from one, or a debugger; statement does not clear it — all measured on js-debug under the flag: the inspector is attached, so they land (js-debug relabels the debugger; stop breakpoint), while line breakpoints still cannot bind and an uncaught throw does not stop. The launcher's stoppedAnyway reads the same record, so the launch response and the record cannot disagree — which also meant retiring #747's "no stop can arrive" wording: the start_debugging warning now says the debugger is off for this launch: N breakpoint(s) will not fire. restart_debugging replays through launch() and recomputes.

One home, src/session/debugger-off.ts: stopProvesDebuggerOn, isDebuggerOff(session) — recorded and the launch is live: INITIALIZING (the proxy is up; a breakpoint set then still reaches the adapter), RUNNING or PAUSED (over, or never launched — a launch refused before the proxy existed leaves the record on a CREATED session — it describes nothing running) — and debuggerOffWhy(session), the gated sentence: on a paused session only the clause still true of it, breakpoints cannot bind. SessionStore.getAll() and every consumer below (via debuggerOffWhyFor(ctx, id) in the handlers) read through them; the composed "not paused" texts live in error-messages.ts.

Consult — never pre-empt the adapter. Every request still goes to the adapter and its own answer is kept; the recorded fact adds one sentence beside it (ErrorMessages.debuggerOffForLaunch: "the debugger is off for this launch (noDebug is true): breakpoints cannot bind and no stop is expected; drop noDebug and launch again to debug" — "no stop is expected" rather than "cannot come", because js-debug lands a pause):

surface before after
set_breakpoint (line + function) warning: "Unbound breakpoint" "Unbound breakpoint; the debugger is off for this launch (noDebug is true): …" — no note on a breakpoint the adapter verified anyway
list_breakpoints per-bp records only + top-level warning with the sentence when at least one listed record is unverified (per-bp message is wiped every launch and re-send, not a durable slot)
pause_execution, accepted but no stop in the grace window #678's "may be blocked in native code or a syscall" guess, and a promise the stop will land ErrorMessages.pausePendingDebuggerOff: one message that promises no stop, keeping the policy's own explanation (on js-debug, #678's advice is what makes the pause land)
pause_execution, refused by the adapter (or no debug target yet) the adapter's error a new error carrying the adapter's message (the sentence), the adapter's own rejection untouched as cause
pause_execution while the launch is still initializing Cannot pause in state: initializing … (the sentence)
step_* / continue_execution Not paused Not paused: the sentence
get_stack_trace note, evaluate_expression error, get_local_variables message "not paused" "not paused; the sentence"
list_debug_sessions debuggerDisabled: true while it holds

Live probe after the change (js-debug, pause_test.js): debuggerDisabled: true in the listing; the live breakpoint answers "Unbound breakpoint; the debugger is off for this launch …"; list_breakpoints and the stack-trace note carry the sentence; pause_execution lands → paused, step_over from it lands → lastStop.reason: 'step', and the listing still carries debuggerDisabled: true after both — neither proves anything about breakpoints, so the surfaces keep explaining.

After #750 landed (merged into this branch), the clean case — python. debugpy attaches no debugger at all under the flag, and its own answer to every later request is Server is not available. Probe on examples/python/pause_test.py: debuggerDisabled: true; the live set_breakpoint"live sync failed: Server is not available; the debugger is off for this launch …"; list_breakpoints warning; get_local_variables message and evaluate_expression error carry the sentence; step_over/continue_executionNot paused: …; pause_execution → debugpy's refusal with the sentence beside it (Failed to pause execution: Server is not available (the debugger is off …)). And one surface the js measurement had hidden: get_stack_trace answered Cannot get stack trace: no active proxy — nothing ever stops, so no thread is current, and debugpy refuses the threads discovery too; the facade then threw ProxyNotRunningError for a proxy that was alive (js-debug's inspector answers threads, so the js case reached the resolver's note). On a session that is not paused no thread is expected, so the facade now hands the answer to the session layer — the not-paused note with the why — without asking the adapter for threads at all (a wasted round trip; the DAP timeout on a wedged adapter); a paused session with no thread to name gets the resolver's "No stopped thread is known for this session." — the "no active proxy" error is kept only for a session with no proxy at all.

Tests

  • session-manager-nodebug-warning.test.ts (fix(launch): a noDebug launch says what it did to the debugger instead of blaming the breakpoints (#710) #747 harness): set for an honoured launch; unset where ignored / without the flag; cleared on the next launch; not set for a dry run; kept by a pause stop and by a step from it, cleared by each of breakpoint / function breakpoint / exception / entry; not projected once the run is over or on a CREATED session; a launch that ends paused on a pause stop never says "no stop can arrive"; recomputed on restart.
  • session-store-projection.test.ts + handlers/session-tools.test.ts: both projections (watched them fail with the projection hunks stashed).
  • session-manager-operations-coverage.test.ts: pause pending is the one no-stop-promised message and does not consult the policy; a refused pause rethrows the adapter's own error object with the why appended (identity and a custom prop asserted); the no-debug-target return carries it; step/continue Not paused: … (and plain Not paused when the debugger is on); evaluate and stack trace carry it; nothing carries it once the session is stopped.
  • server-control-tools.test.ts (set_breakpoint: the DAP send still happens, adapter message + sentence; verified-anyway gets no note), server-breakpoint-management-tools.test.ts (list warning), handlers/inspection-tools.test.ts (locals message).
  • server-coverage.test.ts: a running debugger-off session with no thread whose threads discovery is refused is handed to the session layer (getStackTraceDetailed(id, undefined, …)) instead of throwing; the two existing "no active proxy" throw tests (and server-inspection-tools' missing-thread case) now say paused, which is the case the throw is for.
  • e2e (CI skips the e2e project — Bundled CLI: split main() out of src/index.ts so the bootstrap needs no env flag; e2e project is not run by CI #732 — run locally against a rebuilt dist; watched each fail against the pre-fix build): js pause_test.js under noDebug — the listing flag, the live breakpoint, list_breakpoints, get_stack_trace, step_over, and the pause (via callToolSafely, so a refusal is an answer rather than a thrown MCP error), which asserts the measured outcome (pause lands → decision kept) while accepting a build that refuses or never lands it (the sentence rides on that answer); python pause_test.py under noDebug (after fix(proxy): a noDebug launch completes without waiting on a configuration phase the adapter never opens (#746) #750) — the same surfaces plus get_local_variables and evaluate_expression, debugpy's Server is not available kept beside the sentence, and get_stack_trace answering the note rather than "no active proxy" (watched exactly that assertion fail with the facade fix stashed).
  • typecheck:all, lint, check:docs, changelog:check, unit + integration (5430) and the break-on-exceptions e2e file (23) pass on the merged tree.

Docs

docs/tool-reference.md: the noDebug paragraph gains the later-surfaces sentence; list_debug_sessions field notes gain debuggerDisabled. changelog.d/749.fixed.md. The dapLaunchArgs.noDebug schema text — the one noDebug text the model reads before choosing the flag — stops claiming "no stop ever arrives" too (24 tool-list fences re-recorded).

Notes for the review

🤖 Generated with Claude Code

…so on every later surface (#749)

#747 decided "this noDebug launch runs with the debugger off" once, for the
start_debugging response, and forgot it. Nothing on the session recorded
it, so set_breakpoint on the running session came back verified:false with
no reason, list_breakpoints showed everything unbound with none,
pause_execution ended in the #678 "may be blocked in native code" guess,
and stepping, get_stack_trace, get_local_variables and evaluate_expression
answered "not paused" in debugger terms.

Record: `DebugSessionInfo.debuggerDisabled` (inherited by ManagedSession),
written in the launcher where debuggerOff is decided (real launches, not
dry runs), reset in the launcher's and the attach controller's per-attempt
blocks — not in the core's setupProxyEventHandlers, which runs inside
proxyLauncher.start after the launcher wrote it — and cleared in
handleStopped: a real stop is stronger evidence than the policy's pin.
Projected by SessionStore.getAll() and list_debug_sessions.

Consult, never pre-empting the adapter: every request still goes to the
adapter and its own answer is kept; one sentence
(ErrorMessages.debuggerOffForLaunch) is added beside it — set_breakpoint
(unverified only), list_breakpoints (top-level warning), pause pending
(instead of the policy's guess) and pause refused (appended), "Not paused:"
on step/continue, and the stack-trace note, evaluate error and locals
message. Measured: js-debug still lands a pause under noDebug, so the
sentence says "no stop is expected" rather than "cannot come", and that
stop clears the flag.

Tests: field lifecycle in session-manager-nodebug-warning.test.ts, both
projections, every consumer, and a js e2e over pause_test.js (run locally
— CI does not run the e2e project).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…ated why for every surface (#749 review)

Review of #751 (nine findings, all taken):

- handleStopped cleared debuggerDisabled on ANY stop, but the PR's own
  measurement says js-debug lands a user pause under noDebug with its
  breakpoints still off — so after the first pause every surface lost its
  why again. Only a stop a live debugger produces clears it now
  (DEBUGGER_ON_STOP_REASONS: the breakpoint family, exception, entry,
  step); the launcher's stoppedAnyway reads the same record instead of its
  own firstStopHandled/PAUSED discriminator, so the launch response and
  the record cannot disagree.
- The record was never cleared on STOPPED/ERROR, so a breakpoint queued
  after the run said "cannot bind" and list_debug_sessions kept reporting
  a stopped session as debugger-off. isDebuggerOff() gates the decision on
  the session being running or paused; the projection and every consumer
  read through it.
- One home for the concern: src/session/debugger-off.ts (the reason set,
  isDebuggerOff, debuggerOffWhy) replaces five hand-spliced reads.
- The pending-pause message appended "no stop is expected" to a base text
  that promises one; ErrorMessages.pausePendingDebuggerOff is one message.
- A refused pause threw a fresh Error, dropping the adapter's own error
  object (stack, props); the original is rethrown with the why appended,
  and the no-debug-target return carries the why too.
- list_breakpoints warned even when every record was verified, contradicting
  set_breakpoint's own gate; now only with an unverified record.
- The e2e's refusal branch was unreachable (callTool throws on an MCP
  error); callToolSafely, and the landed-pause branch asserts the decision
  is kept, as measured.
- docs: list_breakpoints documents the top-level warning; the noDebug
  paragraph, the list_debug_sessions field note and the fragment describe
  which stops clear the decision and that it is not reported once the
  session is over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@debugmcpdev

Copy link
Copy Markdown
Collaborator Author

Review round 1 (/code-review 751 high) — nine findings, all taken

# Finding Resolution
1 handleStopped cleared the decision on any stop, but js-debug lands a user pause under noDebug with breakpoints still off — after the first pause the surfaces lost their why Only a stop a live debugger produces clears it: DEBUGGER_ON_STOP_REASONS = breakpoint family ∪ exception, entry, step. The launcher's stoppedAnyway now reads the same record (finalSession.debuggerDisabled !== true) instead of its own discriminator. Tests: a pause stop keeps the flag; each of the five reasons clears it; the e2e asserts the flag survives the landed pause (measured live: "debuggerDisabled":true after paused)
2 Pending-pause message appended "no stop is expected" to a base that promises one ErrorMessages.pausePendingDebuggerOff(n) — one message, no native-code guess, no promise
3 Never cleared on STOPPED/ERROR — a queued breakpoint after the run said "cannot bind"; the listing kept the flag on a stopped session isDebuggerOff(session) gates on running/paused; projection (store + handler) and every consumer go through it; doc comment + tool-reference say so
4 e2e refusal branch unreachable (MCP error throws out of callTool) callToolSafely; the landed-pause branch asserts the decision is kept
5 list_breakpoints warned beside a verified record, contradicting set_breakpoint's gate Warning only when at least one listed record is unverified
6 Refused pause threw a fresh Error, dropping the adapter's own object The original error is rethrown with the why appended to its message (test asserts identity + a preserved prop)
7 NO_DEBUG_TARGET_MARKER return carried no why Carries it
8 Five hand-spliced reads of the sentence/gate src/session/debugger-off.ts (DEBUGGER_ON_STOP_REASONS, isDebuggerOff, debuggerOffWhy) is the one home; joiners stay per site for grammar
9 list_breakpoints reference lacked the top-level warning Documented

Re-verified: session + server suites (1213), unit + integration, typecheck:all, lint, check:docs; js e2e green against the rebuilt dist.

…decision is consulted only while running or paused (#749 review 2)

Re-review of #751 (eight findings, all taken):

- 'step' was in the set of stops that clear the decision, but the js
  pause the PR measured lands because the inspector is attached, and so
  does a step taken from it (measured: reason 'step', breakpoints still
  unbound) — one step_over after the pause put every surface back in the
  pre-#749 state. The set is now the user-asked stops (breakpoint family,
  exception) plus 'entry'; the e2e steps after the pause and checks the
  record survives.
- A launch refused before the proxy existed (the MSVC-toolchain branch)
  moves the session back to CREATED with the record intact, and the gate
  only excluded terminal states. isDebuggerOff() now means running or
  paused, exactly as the docs said.
- stoppedAnyway had dropped the PAUSED clause, so a launch ending paused on
  a 'pause' stop could say no stop can arrive. Restored: a paused launch
  never claims that, whatever the record says about breakpoints.
- withDebuggerOffWhy appends to the adapter's own error; when the message
  will not take the append (getter-only), it wraps with the original as
  the cause instead of throwing from inside the pause path.
- The tool-reference bullet said "no stop ever arrives" and, four sentences
  on, that js-debug lands a pause: "no breakpoint, exception or entry stop
  ever arrives".
- The e2e comment said the pause makes the session forget the decision
  while the assertion checked the opposite; the comment matches now.
- handleListDebugSessions re-gated a field SessionStore.getAll() had
  already gated; it mirrors the field.
- USER_BREAK_REASONS moved next to BREAKPOINT_STOP_REASONS in
  @debugmcp/shared (the core re-exports it); DEBUGGER_ON_STOP_REASONS is
  that set plus 'entry' rather than a third hand-built union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@debugmcpdev

Copy link
Copy Markdown
Collaborator Author

Review round 2 (/code-review 751 medium, scoped to round 1's fixes) — eight findings, all taken

# Finding Resolution
1 step was in the clearing set, but the measured js pause lands because the inspector is attached — so a step taken from it lands too (reason step) and wiped the decision, putting every surface back in the pre-#749 state Measured live: pause → step_overlastStop.reason: 'step', breakpoints still unbound. DEBUGGER_ON_STOP_REASONS = USER_BREAK_REASONSentry; a unit test pins pause-then-step keeping the record; the e2e now steps after the pause and checks it survives
2 The MSVC-toolchain refusal returns the session to CREATED with the record intact, and the gate only excluded terminal states isDebuggerOff() = recorded ∧ (RUNNING ∨ PAUSED), as the docs said; projection test covers CREATED/INITIALIZING/STOPPED/ERROR vs PAUSED
3 stoppedAnyway lost the finalState === PAUSED clause Restored — a launch that ends paused never claims no stop can come; test with a pause stop during launch (record kept, warning not "no stop can arrive")
4 Mutating the adapter's error assumes a writable message Guarded: append in place (keeps identity/props, as round 1 asked); a getter-only message is wrapped with the original as cause. Test with a frozen message
5 The noDebug bullet contradicted itself ("no stop ever arrives" … "js-debug lands a user pause") "no breakpoint, exception or entry stop ever arrives"
6 e2e comment rot Comment matches the assertion (pause and step both keep the decision)
7 handleListDebugSessions re-gated a field getAll() already gated Mirrors the field; the handler test no longer feeds a shape the store cannot produce
8 Third hand-built reason union USER_BREAK_REASONS moved beside BREAKPOINT_STOP_REASONS in @debugmcp/shared (core re-exports); the set here is that ∪ entry

Re-verified: session + server suites (1216), unit + integration, typecheck:all, lint, check:docs; js e2e green against the rebuilt dist (pause, then step, record intact).

…t reason, a hit, an exception or an entry — and the launch warning stops claiming no stop can arrive (#749 review 3)

Third pass on #751 (twelve findings; eleven taken, one declined below):

- js-debug relabels a `debugger;` statement stop ('pause', "Paused on
  debugger statement") to 'breakpoint', and under noDebug that pause
  lands (measured) — so the clear must key on what the adapter itself
  reported: stopProvesDebuggerOn() = hitBreakpointIds present, or an
  exception or entry stop, or a breakpoint reason the adapter called one
  (raw and normalized). An uncaught throw under js noDebug was measured
  not to stop, so 'exception' stays as proof.
- The restored PAUSED clause in stoppedAnyway made the launch response say
  "no effect … breakpoints work as usual" while the record said they cannot
  bind. The root was #747's wording — "the debugger is disabled … and no
  stop can arrive" — refuted for js by a pause, a step and a `debugger;`
  statement. The warning now says the debugger is off for this launch and
  names what will not fire, so the record and the response agree, and the
  PAUSED clause goes again.
- The raw record is now `launchDebuggerOff` on ManagedSession, distinct
  from the projected `debuggerDisabled` on DebugSessionInfo, so no handler
  can read the ungated value by accident.
- isDebuggerOff() includes INITIALIZING: the proxy is up and a breakpoint
  set in that window still goes to the adapter, whose "Unbound breakpoint"
  needs the why.
- A paused session gets only the clause still true of it — breakpoints
  cannot bind — not "no stop is expected".
- The debugger-off pending-pause message keeps the policy's own
  explanation: on js-debug the pause can land under the flag, and #678's
  smart-stepper advice is what makes it.
- withDebuggerOffWhy no longer mutates the adapter's error: a new Error
  with the why, the original untouched as the cause.
- The composed "not paused" texts live in error-messages.ts (notPaused,
  cannotEvaluateNotPaused, stackTraceNotPaused, noStackFramesNotPaused,
  withDebuggerOffWhy, pausePendingDebuggerOff); one debuggerOffWhyFor(ctx,
  id) in handlers/shared.ts replaces three ctx→session→why copies; the
  USER_BREAK_REASONS re-export shim is gone.
- Declined: treating an auto-continued 'pause'-reason entry stop as proof
  the flag was ignored (a stale-pin js-debug build stopping at entry as
  'pause' while the launcher neutralised stopOnEntry) — the record
  self-corrects on the first breakpoint the adapter reports hit, and
  reading a pause as proof is the bug the round fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@debugmcpdev

Copy link
Copy Markdown
Collaborator Author

Review round 3 (/code-review 751 low, scoped to round 2) — twelve findings; eleven taken, one declined

Two more measurements first (js-debug, noDebug: true, this branch's dist): an uncaught throw does not stop (exit 1 — the exception filters do not arm), so exception stays as proof; a debugger; statement does pause, and js-debug relabels it breakpoint (raw pause, "Paused on debugger statement").

# Finding Resolution
1 Pending-pause under the flag dropped #678's smart-stepper hint, which on js-debug is the advice that makes the pause land pausePendingDebuggerOff(n, policyHint) keeps the policy's explanation
2 The restored PAUSED clause made the launch say "no effect … breakpoints work as usual" while the record said they cannot bind Root cause was #747's "no stop can arrive" wording — refuted for js by a pause, a step and a debugger;. The warning now says the debugger is off for this launch: N breakpoint(s) will not fire; the PAUSED clause goes; response and record agree
3 js-debug's relabel of a debugger; stop to breakpoint cleared the record stopProvesDebuggerOn(reason, rawReason, body): hitBreakpointIds, or exception/entry, or a breakpoint reason the adapter itself gave (raw and normalized). Tests: the relabelled stop keeps the record; a pause with hitBreakpointIds clears it
4 exception unmeasured Measured: no stop under the flag → stays
5 PAUSED got "no stop is expected" debuggerOffForLaunchPaused: the binding clause only
6 Auto-continued pause-reason entry stop under a stale pin Declined: reading a pause as proof is the bug this round fixed; the record self-corrects on the first breakpoint the adapter reports hit
7 In-place mutation of the adapter's error Always new Error(composed, { cause }); the original untouched (tests assert cause identity and that its message is unchanged)
8 USER_BREAK_REASONS re-export shim Removed; the controller imports from @debugmcp/shared
9 Three ctx→session→why copies debuggerOffWhyFor(ctx, id) in handlers/shared.ts
10 Raw record shares its name with the projection ManagedSession.launchDebuggerOff (raw) vs DebugSessionInfo.debuggerDisabled (projected by getAll() only)
11 INITIALIZING window unexplained isDebuggerOff includes INITIALIZING (a breakpoint set then still reaches the adapter)
12 Composed "not paused" literals scattered Centralised in error-messages.ts (notPaused, cannotEvaluateNotPaused, stackTraceNotPaused, noStackFramesNotPaused, withDebuggerOffWhy)

Re-verified: session + server suites (1219), unit + integration, typecheck:all, lint, check:docs; the #710 and #749 js e2e cases green against the rebuilt dist.

cynarlab and others added 4 commits September 17, 2026 14:44
… stop ever arrives (#749)

The schema is the one noDebug text the model reads before choosing the
flag, and it still said "no stop ever arrives" after #751 retired that
claim in the warning, the fragment and the docs: js-debug lands a user
pause under the flag. It now says what is true of every honouring
adapter — no breakpoint, exception or entry stop — and that the session
reports debuggerDisabled while such a launch runs. Fences re-recorded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ers not-paused with the why, not "no active proxy" (#749)

Measured on the merged tree (#750 + #751) with debugpy under noDebug:
nothing ever stops, so no thread is current, and debugpy refuses the
`threads` discovery ("Server is not available") — the facade then threw
ProxyNotRunningError, "Cannot get stack trace: no active proxy", for a
session whose proxy was alive. js-debug hid this: its inspector answers
`threads`, so the js e2e reached the resolver's not-paused note.

On a session that is not paused, no thread is expected; the session
layer's not-paused answer (with the debugger-off why) needs none, so the
facade hands it there. A paused session with no thread to name keeps
the error — that is the anomaly it describes. The two existing throw
tests now say "paused".

The python surfaces e2e (the clean case: debugpy attaches no debugger
at all) covers set_breakpoint, list_breakpoints, get_stack_trace,
get_local_variables, evaluate_expression, step_over and pause_execution;
watched it fail on exactly this answer against a dist without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stack_trace never claims "no active proxy"; pause on an initializing launch says why (#749 review 4)

Fourth pass, on the merged tree (#750 + #751), seven findings, all taken:

- get_stack_trace asked the adapter for `threads` before the state check
  that made the answer irrelevant: a session that is not paused gets the
  session layer's not-paused note (with the why) with no round trip — on
  a wedged adapter that request blocked for the DAP timeout first.
- The PAUSED + no-thread branch still threw "no active proxy" for a proxy
  that was alive; the resolver's "No stopped thread is known for this
  session." is the truthful answer, so the throw is gone (the error stays
  for a session with no proxy at all).
- A breakpoint the adapter verified is proof this build debugs after all,
  and it arrives before any hit. Measured first: every honouring adapter
  refuses or unbinds a live breakpoint under the flag (js-debug "Unbound
  breakpoint", debugpy "Server is not available", Delve "noDebug mode:
  unable to process 'setBreakpoints'", CodeLLDB "Not supported in noDebug
  mode"), so a verified record can only come from a build that ignores
  it. Consulted on read (adapterVerifiedABreakpoint, inside isDebuggerOff)
  since bindings are per-launch state; the launch response reads the same
  evidence, so "will not fire" is never said of a breakpoint
  list_breakpoints shows bound.
- pause_execution on an INITIALIZING debugger-off session was the one
  surface without the why.
- withDebuggerOffWhy now passes the rejected value itself as the cause,
  not a synthesized Error, when the bridge threw a non-Error.
- The "Used in" map on debuggerOffForLaunch named five files that never
  reference it; it names the one gate that does.
- The list_breakpoints doc note quotes the paused variant too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@debugmcpdev

Copy link
Copy Markdown
Collaborator Author

Review round 4 (/code-review 751 low, on the merged tree with #750) — seven findings, all taken

# Finding Resolution
1 get_stack_trace asked the adapter for threads before the state check that made the answer irrelevant (a wasted round trip; the DAP timeout on a wedged adapter) A session that is not paused goes straight to the session layer's not-paused note. Test: sendDapRequest not called on a running debugger-off session
2 The PAUSED + no-thread branch still threw "no active proxy" for a live proxy The resolver's "No stopped thread is known for this session." is the answer in both branches; the error stays only for a session with no proxy at all. The two throw tests and the handler test now pin that
3 A breakpoint the adapter verified is as strong a proof as a stop that the build ignores the flag, but nothing consumed it — contradictory answers until the first hit Measured first on the merged build: every honouring adapter refuses or unbinds a live breakpoint under the flag (js-debug "Unbound breakpoint", debugpy "Server is not available", Delve "noDebug mode: unable to process 'setBreakpoints' request", CodeLLDB "Not supported in noDebug mode"), so a verified record can only come from a build that ignores it. adapterVerifiedABreakpoint inside isDebuggerOff — consulted on read, since bindings are per-launch state — and the launcher reads the same evidence, so the launch response never says "will not fire" of a breakpoint list_breakpoints shows bound. Tests: projection drops (line and function breakpoints), the why drops, the launch says "no effect" on a verified echo with no stop
4 pause_execution on an INITIALIZING debugger-off session was the one surface without the why Composed with debuggerOffWhy; test
5 "Used in:" on debuggerOffForLaunch named five files that never reference it Names the one gate (debugger-off.ts)
6 The list_breakpoints doc note quoted only the running sentence Quotes the paused variant too, and the verified-breakpoint drop
7 withDebuggerOffWhy attached a synthesized Error as cause for a non-Error rejection The rejected value itself is the cause; test

Re-verified on the merged tree: typecheck:all, lint, check:docs; unit + integration (5435); python/js/cpp/go noDebug e2e (34) against the rebuilt dist.

@debugmcpdev
debugmcpdev merged commit 9fb6191 into main Sep 17, 2026
10 checks passed
@debugmcpdev
debugmcpdev deleted the fix/749-debugger-off-surfaces branch September 17, 2026 19:33
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.

noDebug sessions: set_breakpoint, list_breakpoints, pause and inspection still explain themselves in debugger terms after a debugger-off launch

2 participants