diff --git a/changelog.d/710.fixed.md b/changelog.d/710.fixed.md index 771422e4b..b7e338a88 100644 --- a/changelog.d/710.fixed.md +++ b/changelog.d/710.fixed.md @@ -1 +1 @@ -**A `noDebug` launch says what it did to the debugger instead of blaming your breakpoints** — `dapLaunchArgs.noDebug: true` is DAP's "launch without enabling debugging", and where the adapter honours it no breakpoint binds and no stop ever arrives; the session then read as a mystery — a short script ended `stopped` with the #467 "check the file path and line" warning for breakpoints that were never going to bind, a server stayed `running` while the caller waited for a stop that could not come. Whether the flag turns the debugger off is now the adapter policy's word (`honoursNoDebug`), measured rather than assumed: js-debug, debugpy, Delve and CodeLLDB honour it; rdbg, netcoredbg and the Java bridge ignore it, and the rust launch transform never forwards it. Where the debugger is off, `start_debugging` warns when the flag is set alongside something the caller asked to stop on — line or function breakpoints, an *explicit* `breakOnExceptions` other than `none` (the policy default does not count, so a deliberate plain run stays silent), or `stopOnEntry` — naming each thing that will not fire, withholds the breakpoint-shaped launch warnings (#308, #467, #469) that presuppose a debugger, and no longer waits for an entry stop that cannot come. Where the adapter ignores the flag, the warning says it had no effect and the usual diagnostics stay. The flag is read the way the adapter will see it (`adapterLaunchConfig` over `dapLaunchArgs` over the server defaults), and the decision lives in the launcher, so `restart_debugging` and a `dryRunSpawn` configuration check carry the same warning; the `dapLaunchArgs` schema documents the flag. Found by the review of PR #706 (#710) +**A `noDebug` launch says what it did to the debugger instead of blaming your breakpoints** — `dapLaunchArgs.noDebug: true` is DAP's "launch without enabling debugging", and where the adapter honours it no breakpoint binds and no breakpoint, exception or entry stop ever arrives; the session then read as a mystery — a short script ended `stopped` with the #467 "check the file path and line" warning for breakpoints that were never going to bind, a server stayed `running` while the caller waited for a stop that could not come. Whether the flag turns the debugger off is now the adapter policy's word (`honoursNoDebug`), measured rather than assumed: js-debug, debugpy, Delve and CodeLLDB honour it; rdbg, netcoredbg and the Java bridge ignore it, and the rust launch transform never forwards it. Where the debugger is off, `start_debugging` warns when the flag is set alongside something the caller asked to stop on — line or function breakpoints, an *explicit* `breakOnExceptions` other than `none` (the policy default does not count, so a deliberate plain run stays silent), or `stopOnEntry` — naming each thing that will not fire, withholds the breakpoint-shaped launch warnings (#308, #467, #469) that presuppose a debugger, and no longer waits for an entry stop that cannot come. Where the adapter ignores the flag, the warning says it had no effect and the usual diagnostics stay. The flag is read the way the adapter will see it (`adapterLaunchConfig` over `dapLaunchArgs` over the server defaults), and the decision lives in the launcher, so `restart_debugging` and a `dryRunSpawn` configuration check carry the same warning; the `dapLaunchArgs` schema documents the flag. Found by the review of PR #706 (#710) diff --git a/changelog.d/749.fixed.md b/changelog.d/749.fixed.md new file mode 100644 index 000000000..6eaea4176 --- /dev/null +++ b/changelog.d/749.fixed.md @@ -0,0 +1 @@ +**A session whose launch runs with the debugger off says so on every later surface** — `start_debugging` decided "this `noDebug` launch runs with the debugger off" (#710) and then forgot it: `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 "may be blocked in native code" guess, and stepping, `get_stack_trace`, `get_local_variables` and `evaluate_expression` answered "not paused" in debugger terms — or worse: with no thread ever current and debugpy refusing the `threads` discovery, `get_stack_trace` claimed "no active proxy" for a session whose proxy was alive. The decision is now recorded on the session — `list_debug_sessions` reports `debuggerDisabled: true` while the launch is live — reset per launch and attach, and cleared by a `stopped` event only a live debugger produces (a breakpoint the adapter itself reports or names in `hitBreakpointIds`, an exception, an entry stop): that proves the debugger on for the adapter build — as does a breakpoint the adapter verified, since every honouring adapter refuses or unbinds one under the flag. A pause, a step taken from one, or a `debugger;` statement does not clear it — js-debug lands all three under the flag with its breakpoints still unbound — and a paused session is told only that breakpoints cannot bind. The `start_debugging` warning itself no longer claims "no stop can arrive": it says the debugger is off for this launch and names what will not fire. Every request still goes to the adapter and its own answer is kept ("Unbound breakpoint", a refusal in the adapter's words); the recorded fact adds the why beside it — "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" — and a pause that is accepted but never lands gets a message that promises no stop instead of the native-code guess (#749) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 2752b1203..1987ceedf 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -120,6 +120,8 @@ Lists all active debugging sessions. **`lastStop`:** present while the session is `paused` (the stop it is at: `reason`, `threadId`, `timestamp`, the adapter's `description`/`text`, and `exceptionInfo` for exception stops) and after it reaches `stopped`/`error` (the last stop before it ended). A `running` session never carries one, so a poller that calls this after `continue_execution` or a step sees `state: "running"` with no stop record until the next stop lands — the record of the stop it just left is not repeated as if the program were still paused. +**`debuggerDisabled`:** present (`true`) while the current launch runs with the debugger off — `dapLaunchArgs.noDebug: true` on an adapter that honours it (see `start_debugging`) — and the launch is live (`initializing`, `running` or `paused`). Cleared by the next launch or attach, and by a `stopped` event only a live debugger produces: a breakpoint the adapter itself reports (or names in `hitBreakpointIds`), an exception stop, or an entry stop — not a pause, a step, or a `debugger;` statement js-debug relabels as a breakpoint. Omitted otherwise (issue #749). + Errored sessions include optional `diagnostics` with the current launch attempt's server-host `proxyLogPath` and remote-safe `proxyLogResource`. The record is retained for proxy initialization failures and for proxy/adapter deaths after initialization, and is cleared when a new launch or attach attempt begins. --- @@ -300,6 +302,7 @@ Lists all breakpoints in a session with their current verified state and adapter - `functionBreakpoints`/`functionCount` are always present in the unfiltered response (empty arrays when none exist). When filtering by `file` they are omitted — function breakpoints are session-global, not file-scoped. - `adapterId` is the debug adapter's own numeric id for the breakpoint, captured from setBreakpoints responses and breakpoint events. It is absent until the adapter has seen the breakpoint. - Verification is eventually consistent: some adapters (js-debug, JDI, netcoredbg) bind breakpoints asynchronously and confirm via DAP breakpoint events shortly after launch or class load. +- `warning` (top level) appears only while the session's current launch runs with the debugger off (`noDebug: true` on an adapter that honours it, see `start_debugging`) and at least one listed breakpoint is unverified: "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" — on a paused session (js-debug lands a pause under the flag) only the clause still true of it, "…: breakpoints cannot bind; drop noDebug and launch again to debug". The per-breakpoint records keep the adapter's own answers; a breakpoint the adapter *did* verify is proof this build debugs after all, and the warning (with `debuggerDisabled` and every other debugger-off note) drops (issue #749). - A breakpoint the program has stopped on is reported `verified: true` from that stop onward, even if the adapter never confirmed it (issue #673), for adapters whose `stopped` event names the breakpoints it hit (`hitBreakpointIds`: js-debug, debugpy, Delve, CodeLLDB — netcoredbg, the JDI bridge and rdbg omit the field). Such a record carries `verifiedBy: "hit"` until the adapter itself confirms it (`"adapter"`); an adapter answer of "unbound" does not downgrade it. A provisional "Unbound breakpoint" `message` is dropped by the hit; any other note is kept. - On entries of the `breakpoints` array, `boundFile`/`boundLine` appear when the adapter answers under a *different* file from the request. For a source-mapped `.ts` request on a JavaScript launch with maps on (the default) js-debug verifies the request under the `.ts` path, `get_stack_trace` frames show `.ts`, and the pair is absent; it appears when js-debug answers under the generated `dist/*.js` instead — measured with `adapterLaunchConfig: { sourceMaps: false }` (the second entry in the example above was captured that way, and the frames then show the generated file too), and possible whenever the `.ts` source cannot be resolved through the map (issues #673, #700). `file` and `line` keep describing the request; the bound pair is where it landed. (Entries of `functionBreakpoints` use the same names for the bound location of the symbol, present whenever it is bound.) @@ -366,7 +369,7 @@ Starts debugging a script. - `dapLaunchArgs` (object, optional): Standard DAP launch arguments: - `stopOnEntry` (boolean): Stop at first line (default `false` — the opposite of attach, which pauses unless `stopOnEntry` is `false`) - `justMyCode` (boolean): Debug only user code (default `true`). JavaScript launch: `true` blackboxes `node_modules` through js-debug's `skipFiles` and keeps js-debug's smart-stepper on, so a pause or step that lands in skipped code (Node internals, `node_modules`) is stepped through and may never land (`pending: true`, with an explanation); `false` drops `node_modules` from the skip list and turns the stepper off, so steps land inside dependencies and `pause_execution` lands as soon as any JavaScript runs (issue #678). A caller `skipFiles` replaces the default list. Source maps are on for every JavaScript launch, `.js` programs included — stops report `src/*.ts` when maps and sources are present; `adapterLaunchConfig: { sourceMaps: false }` opts out (issue #684) - - `noDebug` (boolean): DAP's "launch without enabling debugging" flag (default `false`). What it does depends on the adapter, and the response says which case you are in. **Honoured** — the debugger is off: no breakpoint binds, no exception filter arms, no entry stop lands, and no stop ever arrives, so a short script ends `stopped` and a server stays `running`. That is a legitimate plain run and says nothing by itself; when the session has breakpoints (line or function), an explicit `breakOnExceptions` other than `"none"`, or `stopOnEntry`, the `warning` says the debugger is off and names what will not fire, in place of the unbound-breakpoint diagnostics that would otherwise send you to check paths that are fine (issue #710). js-debug, debugpy, Delve and CodeLLDB honour it (debugpy and Delve open no configuration phase at all, so the launch is complete on the launch response; CodeLLDB refuses the configuration requests, and the refusal reaches the `warning` and `get_output` — issue #746). The run ends `stopped` with the program's output and, where the adapter reports one, the exit code (Delve prints the status to the console only). Under the flag Delve runs the program through Go's `exec`, so on Windows the binary needs its `.exe` extension. **Ignored** — rdbg, netcoredbg and the Java bridge run the debugger regardless, and the rust launch transform never forwards the flag: the `warning` says the flag had no effect and everything works as usual. The same warning comes back from `restart_debugging` and from a `dryRunSpawn` check. + - `noDebug` (boolean): DAP's "launch without enabling debugging" flag (default `false`). What it does depends on the adapter, and the response says which case you are in. **Honoured** — the debugger is off: no breakpoint binds, no exception filter arms, no entry stop lands, and no breakpoint, exception or entry stop ever arrives, so a short script ends `stopped` and a server stays `running`. That is a legitimate plain run and says nothing by itself; when the session has breakpoints (line or function), an explicit `breakOnExceptions` other than `"none"`, or `stopOnEntry`, the `warning` says the debugger is off for this launch and names what will not fire — not that no stop of any kind can come — in place of the unbound-breakpoint diagnostics that would otherwise send you to check paths that are fine (issue #710). js-debug, debugpy, Delve and CodeLLDB honour it (debugpy and Delve open no configuration phase at all, so the launch is complete on the launch response; CodeLLDB refuses the configuration requests, and the refusal reaches the `warning` and `get_output` — issue #746). The run ends `stopped` with the program's output and, where the adapter reports one, the exit code (Delve prints the status to the console only). Under the flag Delve runs the program through Go's `exec`, so on Windows the binary needs its `.exe` extension. **Ignored** — rdbg, netcoredbg and the Java bridge run the debugger regardless, and the rust launch transform never forwards the flag: the `warning` says the flag had no effect and everything works as usual. The same warning comes back from `restart_debugging` and from a `dryRunSpawn` check. While such a launch runs, the session remembers the decision (`list_debug_sessions` reports `debuggerDisabled: true`) and every later surface says why: `set_breakpoint` and `list_breakpoints` keep the adapter's own answer (still sent — e.g. js-debug's "Unbound breakpoint", or CodeLLDB's refusal echoed onto each pre-launch breakpoint) and add "the debugger is off for this launch (noDebug is true)…", `pause_execution` is still sent and the same sentence explains a pause that never lands or one the adapter refused, and `step_*`/`continue_execution`/`get_stack_trace`/`get_local_variables`/`evaluate_expression` append it to their "not paused" answers. A `stopped` event only a live debugger produces — a breakpoint the adapter itself reports (or names in `hitBreakpointIds`), an exception stop, an entry stop — proves the debugger on for that adapter build and clears the decision, and so does a breakpoint the adapter verified (every honouring adapter refuses or unbinds one under the flag, so a bound one can only come from a build that ignores it); a pause, a step taken from one, or a `debugger;` statement do not (js-debug lands all three under the flag while its breakpoints stay unbound; an uncaught throw does not stop). On a paused session the sentence keeps only the clause still true of it — breakpoints cannot bind — and outside a live launch (`initializing`/`running`/`paused`) the decision is neither reported nor consulted (issue #749). - Additional DAP launch keys (`program`, `cwd`, `env`, language-specific options) pass through to the adapter. Top-level parameters do **not** belong here: a nested `breakOnExceptions` is honored as an alias (the top-level value wins if both are given) and reported via a `warning` in the response; other misplaced top-level keys (`dryRunSpawn`, `sessionId`, `scriptPath`, `adapterLaunchConfig`) are stripped with a warning instead of silently riding into the launch config. - `adapterLaunchConfig` (object, optional): Adapter-specific launch configuration overrides. Use this for language-specific settings that go beyond standard DAP arguments (e.g., `mainClass` and `classpath` for Java, `buildCommand` for Rust). For Rust, `_adapterSettings` passes through to CodeLLDB (issue #441) — e.g. `{"_adapterSettings": {"scriptConfig": {"lang": {"rust": {"sysroot": "/path"}}}}}` points the Rust formatter lookup at an explicit sysroot; the `CODELLDB_RUST_SYSROOT` env var does the same without per-launch config (a user-supplied `_adapterSettings` value wins over the env var). - `dryRunSpawn` (boolean, optional): Test spawn without actually starting diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9cad7b34e..f67c43825 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -193,7 +193,7 @@ export type { QueuedDapCommand, PendingStopContext } from './interfaces/adapter-policy.js'; -export { BREAKPOINT_STOP_REASONS } from './interfaces/adapter-policy.js'; +export { BREAKPOINT_STOP_REASONS, USER_BREAK_REASONS } from './interfaces/adapter-policy.js'; export { DefaultAdapterPolicy, resolveExceptionFilters, diff --git a/packages/shared/src/interfaces/adapter-policy.ts b/packages/shared/src/interfaces/adapter-policy.ts index de609d890..c8290e956 100644 --- a/packages/shared/src/interfaces/adapter-policy.ts +++ b/packages/shared/src/interfaces/adapter-policy.ts @@ -57,6 +57,13 @@ export const BREAKPOINT_STOP_REASONS: ReadonlySet = new Set([ 'instruction breakpoint' ]); +/** + * Stop reasons the user asked for: the breakpoint family plus an exception + * the user asked to break on. The first-stop auto-continue must never + * swallow one, and one is proof a debugger is live (issues #749, #746). + */ +export const USER_BREAK_REASONS: ReadonlySet = new Set([...BREAKPOINT_STOP_REASONS, 'exception']); + /** * Context passed to AdapterPolicy.normalizeStopReason (issues #260/#302). * See that method's doc comment for the completeness rules. diff --git a/packages/shared/src/models/index.ts b/packages/shared/src/models/index.ts index 2899fad29..552e9cad8 100644 --- a/packages/shared/src/models/index.ts +++ b/packages/shared/src/models/index.ts @@ -432,6 +432,15 @@ export interface DebugSessionInfo { exitCode?: number; /** Present when the session is in ERROR because its proxy failed. */ diagnostics?: SessionFailureDiagnostics; + /** + * Present (`true`) while the current launch runs with the debugger off: + * the launch carried `noDebug: true` and the adapter honours it (issue + * #710), so no breakpoint can bind. A projection of the session's + * `launchDebuggerOff` record, made only while the launch is live + * (initializing, running or paused) — see `isDebuggerOff` in + * `src/session/debugger-off.ts` (issue #749). + */ + debuggerDisabled?: boolean; /** * Live DAP mirror endpoint from expose_session (issue #217), host/port * only — the attach token is returned solely by the expose_session tool. diff --git a/src/server.ts b/src/server.ts index a7c5077fc..565454e85 100644 --- a/src/server.ts +++ b/src/server.ts @@ -31,6 +31,7 @@ import { Breakpoint, FunctionBreakpoint, SessionLifecycleState, + SessionState, IEnvironment, ILogger, ExceptionBreakMode @@ -529,6 +530,16 @@ export class DebugMcpServer implements ToolContext { } return result; } + // A session that is not paused has no stack to read, and the session + // layer's answer — "not paused", with the why when the launch runs with + // the debugger off (issue #749) — needs no thread. Say so before asking + // the adapter for one: a launch under noDebug never stops, so no thread + // is ever current, and debugpy refuses the `threads` discovery ("Server + // is not available") — a wasted round trip at best, the DAP timeout on + // a wedged adapter at worst. + if (session.state !== SessionState.PAUSED) { + return this.sessionManager.getStackTraceDetailed(sessionId, undefined, includeInternals); + } let currentThreadId = session.proxyManager.getCurrentThreadId(); // If no thread ID is known (e.g. adapter omitted threadId from stopped event), // try to discover one via a 'threads' DAP request. @@ -540,11 +551,14 @@ export class DebugMcpServer implements ToolContext { currentThreadId = threads[0].id; } } catch { - // threads request failed — fall through to error + // threads request failed — fall through } } if (typeof currentThreadId !== 'number') { - throw new ProxyNotRunningError(sessionId || 'unknown', 'get stack trace'); + // Paused, no thread known, and the adapter named none: the proxy is + // alive, so "no active proxy" would be false. The session layer says + // what is true — no stopped thread is known for this session. + return this.sessionManager.getStackTraceDetailed(sessionId, undefined, includeInternals); } // ensureStackReady: the thread above was resolved implicitly (the MCP tool // has no threadId argument), so a paused session answering with zero diff --git a/src/server/handlers/breakpoint-tools.ts b/src/server/handlers/breakpoint-tools.ts index 5ea66a110..af1865e74 100644 --- a/src/server/handlers/breakpoint-tools.ts +++ b/src/server/handlers/breakpoint-tools.ts @@ -13,9 +13,18 @@ import { import type { FunctionBreakpointRemoval } from '../../session/session-manager-operations.js'; import type { ToolContext, ToolHandler } from '../tool-context.js'; import { requireSessionId, type WithSessionId } from '../tool-validation.js'; -import { readLineContext } from './shared.js'; +import { debuggerOffWhyFor, readLineContext } from './shared.js'; import { failureResult, jsonResult, sessionErrorResultOrThrow, type ToolResult } from '../tool-result.js'; +/** + * The why beside an unverified answer while the session's launch runs with + * the debugger off (issue #749). The request still went to the adapter and + * its own answer is kept; a breakpoint it verified anyway needs no note. + */ +function debuggerOffNote(ctx: ToolContext, sessionId: string, verified: boolean): string | undefined { + return verified ? undefined : debuggerOffWhyFor(ctx, sessionId); +} + export const setBreakpointTool: ToolHandler = async (ctx, args) => { const isFunctionBp = args.function !== undefined; if (!isFunctionBp && (!args.file || (args.line === undefined && args.statement === undefined))) { @@ -105,7 +114,10 @@ async function setFunctionBreakpointBranch(ctx: ToolContext, args: WithSessionId timestamp: Date.now() }); - const warnings = [breakpoint.message, fnGate.warning, normalized?.note, nameHint, syncWarning].filter(Boolean); + const warnings = [ + breakpoint.message, fnGate.warning, normalized?.note, nameHint, syncWarning, + debuggerOffNote(ctx, args.sessionId, breakpoint.verified) + ].filter(Boolean); return jsonResult({ success: true, breakpointId: breakpoint.id, @@ -177,7 +189,10 @@ async function setLineBreakpointBranch(ctx: ToolContext, args: WithSessionId): P }` : undefined; - const warnings = [breakpoint.message, logPointGate.warning, syncWarning, snapWarning].filter(Boolean); + const warnings = [ + breakpoint.message, logPointGate.warning, syncWarning, snapWarning, + debuggerOffNote(ctx, args.sessionId, breakpoint.verified) + ].filter(Boolean); const result: ToolResult = jsonResult({ success: true, breakpointId: breakpoint.id, @@ -226,13 +241,21 @@ export const listBreakpointsTool: ToolHandler = async (ctx, args) => { const functionBreakpoints = args.file === undefined ? ctx.sessionManager.listFunctionBreakpoints(args.sessionId) : []; + // Per-breakpoint records carry the adapter's own answers; the one reason + // the unverified ones cannot bind right now goes on the response (issue + // #749) — like set_breakpoint's note, a breakpoint the adapter verified + // anyway is not contradicted. + const anyUnverified = + breakpoints.some((bp) => !bp.verified) || functionBreakpoints.some((bp) => !bp.verified); + const why = debuggerOffNote(ctx, args.sessionId, !anyUnverified); return jsonResult({ success: true, breakpoints, count: breakpoints.length, ...(args.file === undefined ? { functionBreakpoints, functionCount: functionBreakpoints.length } - : {}) + : {}), + ...(why ? { warning: why } : {}) }); } catch (error) { return sessionErrorResultOrThrow(error); diff --git a/src/server/handlers/inspection-tools.ts b/src/server/handlers/inspection-tools.ts index 6173d84fe..484ab4293 100644 --- a/src/server/handlers/inspection-tools.ts +++ b/src/server/handlers/inspection-tools.ts @@ -5,9 +5,10 @@ import { ErrorCode as McpErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { SessionState } from '@debugmcp/shared'; import { SessionTerminatedError } from '../../errors/debug-errors.js'; +import { ErrorMessages } from '../../utils/error-messages.js'; import type { ToolContext, ToolHandler } from '../tool-context.js'; import { enforceExplicitNames, requireSessionId } from '../tool-validation.js'; -import { carriesLastStop, variablePayloadExtras } from './shared.js'; +import { carriesLastStop, debuggerOffWhyFor, variablePayloadExtras } from './shared.js'; import { failureResult, jsonResult, @@ -339,7 +340,8 @@ export async function handleGetLocalVariables(ctx: ToolContext, args: { sessionI ? 'The session is paused, but the anchored thread reported no stack frames. ' + 'Try get_stack_trace with a threadId from list_threads, or continue_execution ' + 'followed by pause_execution to re-anchor on a reportable thread.' - : 'No stack frames available. The debugger may not be paused.'; + // With the why, when the launch runs with the debugger off (issue #749). + : ErrorMessages.noStackFramesNotPaused(debuggerOffWhyFor(ctx, args.sessionId)); } else if (!result.scopeName) { response.message = 'No local scope found in the current frame.'; } else { diff --git a/src/server/handlers/session-tools.ts b/src/server/handlers/session-tools.ts index b691475a2..4a583f970 100644 --- a/src/server/handlers/session-tools.ts +++ b/src/server/handlers/session-tools.ts @@ -175,6 +175,11 @@ export async function handleListDebugSessions(ctx: ToolContext): Promise; diff --git a/src/server/tool-schemas.ts b/src/server/tool-schemas.ts index e170a4813..25552c61e 100644 --- a/src/server/tool-schemas.ts +++ b/src/server/tool-schemas.ts @@ -149,7 +149,7 @@ export function buildToolDefinitions(options: BuildToolDefinitionsOptions): Tool properties: { stopOnEntry: { type: 'boolean', description: 'Pause at the first line before running. Default false — the opposite of attach, which pauses unless stopOnEntry is false' }, justMyCode: { type: 'boolean', description: 'Only debug user code (default true). JavaScript launch: true blackboxes node_modules via skipFiles and keeps js-debug\'s smart-stepper on, so a pause or step that lands in skipped code (Node internals, node_modules) is stepped through and may never land; false drops node_modules from skipFiles and turns the stepper off, so pauses and steps land inside dependencies' }, - noDebug: { type: 'boolean', description: 'DAP\'s launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so' } + noDebug: { type: 'boolean', description: 'DAP\'s launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so' } }, additionalProperties: true }, diff --git a/src/session/attach/attach-controller.ts b/src/session/attach/attach-controller.ts index 4a655f35c..9ba9b8cda 100644 --- a/src/session/attach/attach-controller.ts +++ b/src/session/attach/attach-controller.ts @@ -151,6 +151,8 @@ export class AttachController { // proxy exists emits no stop, so a stop left over from a prior run would // be reported as this attempt's once the session lands in ERROR (#720). session.lastStop = undefined; + // An attach debugs regardless of any earlier noDebug launch (issue #749). + session.launchDebuggerOff = undefined; try { // For attach mode, we use a placeholder scriptPath diff --git a/src/session/breakpoints/launch-warnings.ts b/src/session/breakpoints/launch-warnings.ts index 3771bbb64..a0bccb4c1 100644 --- a/src/session/breakpoints/launch-warnings.ts +++ b/src/session/breakpoints/launch-warnings.ts @@ -118,8 +118,10 @@ export function buildNoDebugLaunchWarning( expected.length === 1 ? expected[0] : `${expected.slice(0, -1).join(', ')} and ${expected[expected.length - 1]}`; + // What is known: the flag, and what it keeps from firing. Not "no stop + // can arrive" — js-debug lands a user pause under the flag (issue #749). return ( - `noDebug is true, so the debugger is disabled for this launch and no stop can arrive: ` + + `noDebug is true, so the debugger is off for this launch: ` + `${list} will not fire. Drop noDebug to debug, or ignore this if you only meant to run the program` ); } diff --git a/src/session/debugger-off.ts b/src/session/debugger-off.ts new file mode 100644 index 000000000..dcc6c8795 --- /dev/null +++ b/src/session/debugger-off.ts @@ -0,0 +1,102 @@ +/** + * The debugger-off decision a session carries for one launch (issue #749): + * `noDebug: true` on an adapter that honours it (issue #710). One gate, one + * rule for what clears it, and one sentence per state, read by every + * surface that would otherwise answer in debugger terms — set_breakpoint, + * list_breakpoints, pause, stepping, inspection, list_debug_sessions — so + * the wording and the gate cannot drift between them. + */ +import type { DebugProtocol } from '@vscode/debugprotocol'; +import { BREAKPOINT_STOP_REASONS, SessionState } from '@debugmcp/shared'; +import { ErrorMessages } from '../utils/error-messages.js'; + +/** The slice of a session the decision is read from. */ +export interface DebuggerOffView { + state: SessionState; + launchDebuggerOff?: boolean; + breakpoints?: ReadonlyMap; + functionBreakpoints?: ReadonlyMap; +} + +/** + * Whether the adapter has verified a breakpoint of this launch — proof as + * strong as a stop that this build debugs after all, and it arrives before + * any hit. Measured: every adapter that honours the flag refuses or unbinds + * a breakpoint under it (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 the flag. Per-launch state (bindings are + * reset at each launch), so it is consulted on read rather than clearing + * the record the way a stop does. + */ +export function adapterVerifiedABreakpoint(session: DebuggerOffView): boolean { + for (const bp of session.breakpoints?.values() ?? []) { + if (bp.verified) { + return true; + } + } + for (const bp of session.functionBreakpoints?.values() ?? []) { + if (bp.verified) { + return true; + } + } + return false; +} + +/** + * Whether a stop proves the debugger is live after all — this adapter + * build ignores the flag, or a stale pin — and the record must go. Judged + * on what the adapter itself reported, not the policy's relabel. Measured + * on js-debug under `noDebug`: the inspector is attached, so a user pause + * lands, a step from it lands, and a `debugger;` statement pauses with the + * adapter's reason 'pause' (relabelled 'breakpoint' by the policy) — while + * line breakpoints still cannot bind and an uncaught throw does not stop. + * Proof, then, is: a breakpoint the adapter itself called one, or named in + * `hitBreakpointIds`; an exception stop; an entry stop. + */ +export function stopProvesDebuggerOn( + reason: string, + rawReason: string, + body: DebugProtocol.StoppedEvent['body'] | undefined +): boolean { + if ((body?.hitBreakpointIds?.length ?? 0) > 0) { + return true; + } + if (reason === 'entry' || reason === 'exception') { + return true; + } + return BREAKPOINT_STOP_REASONS.has(reason) && BREAKPOINT_STOP_REASONS.has(rawReason); +} + +/** + * Whether the decision applies now: recorded for this launch, and the + * launch is live — initializing (the proxy is up; a breakpoint set now + * still goes to the adapter), running, or paused. Over (stopped, error) or + * never launched (created — a launch refused before the proxy existed + * leaves the record behind), it describes nothing that is running: a + * breakpoint set then is an ordinary queued one for the next launch. And + * not once the adapter has verified a breakpoint (see above). + */ +export function isDebuggerOff(session: DebuggerOffView): boolean { + return ( + session.launchDebuggerOff === true && + (session.state === SessionState.INITIALIZING || + session.state === SessionState.RUNNING || + session.state === SessionState.PAUSED) && + !adapterVerifiedABreakpoint(session) + ); +} + +/** + * The why to place beside an answer, when the decision applies; else + * nothing. A paused session gets the clause that is still true of it — + * breakpoints cannot bind — not "no stop is expected". + */ +export function debuggerOffWhy(session: DebuggerOffView): string | undefined { + if (!isDebuggerOff(session)) { + return undefined; + } + return session.state === SessionState.PAUSED + ? ErrorMessages.debuggerOffForLaunchPaused + : ErrorMessages.debuggerOffForLaunch; +} diff --git a/src/session/execution/execution-controller.ts b/src/session/execution/execution-controller.ts index 9478d0e97..387bb65c9 100644 --- a/src/session/execution/execution-controller.ts +++ b/src/session/execution/execution-controller.ts @@ -38,6 +38,7 @@ import { NO_DEBUG_TARGET_MARKER, SessionLifecycleState, SessionState, + USER_BREAK_REASONS, type StackFrame } from '@debugmcp/shared'; import { DebugProtocol } from '@vscode/debugprotocol'; @@ -50,7 +51,7 @@ import type { StepResultData, StopLocation } from '../session-manager-core.js'; -import { USER_BREAK_REASONS } from '../session-manager-core.js'; +import { debuggerOffWhy, isDebuggerOff, type DebuggerOffView } from '../debugger-off.js'; import { samePath } from '../breakpoints/hit-verification.js'; import type { ExecutionContext } from '../operations-context.js'; import type { PauseCoordinator } from './pause-coordinator.js'; @@ -136,6 +137,30 @@ function isSameLine(a: StopLocation, b: StopLocation): boolean { return a.line === b.line && samePath(a.file, b.file); } +/** + * The step/continue refusal for a session that is not paused, with the why + * when the session's launch runs with the debugger off (issue #749). + */ +function notPausedError(session: DebuggerOffView): string { + return ErrorMessages.notPaused(debuggerOffWhy(session)); +} + +/** + * A pause the adapter answered with an error, with the why beside the + * adapter's own words when the launch runs with the debugger off (issue + * #749). The adapter's error travels as the cause exactly as it was + * thrown — an Error, or whatever else the bridge rejected with. + */ +function withDebuggerOffWhy(session: DebuggerOffView, error: unknown, message: string): { error: Error; message: string } { + const err = error instanceof Error ? error : new Error(message); + const why = debuggerOffWhy(session); + if (!why) { + return { error: err, message: err.message }; + } + const composed = ErrorMessages.withDebuggerOffWhy(err.message, why); + return { error: new Error(composed, { cause: error }), message: composed }; +} + export class ExecutionController { constructor( private readonly ctx: ExecutionContext, @@ -177,7 +202,7 @@ export class ExecutionController { } if (session.state !== SessionState.PAUSED) { this.ctx.logger.warn(`[SM ${logTag} ${sessionId}] Not paused. State: ${session.state}`); - return { success: false, error: 'Not paused', state: session.state }; + return { success: false, error: notPausedError(session), state: session.state }; } if (typeof threadId !== 'number') { this.ctx.logger.warn(`[SM ${logTag} ${sessionId}] No current thread ID.`); @@ -201,7 +226,7 @@ export class ExecutionController { } if (session.state !== SessionState.PAUSED) { this.ctx.logger.warn(`[SM ${logTag} ${sessionId}] No longer paused after the origin read. State: ${session.state}`); - return { success: false, error: 'Not paused', state: session.state }; + return { success: false, error: notPausedError(session), state: session.state }; } this.ctx.logger.info(`[SM ${logTag} ${sessionId}] Sending DAP '${command}' for threadId ${threadId}`); @@ -429,7 +454,7 @@ export class ExecutionController { this.ctx.logger.warn( `[SessionManager continue] Session ${sessionId} not paused. State: ${session.state}.` ); - return { success: false, error: 'Not paused', state: session.state }; + return { success: false, error: notPausedError(session), state: session.state }; } if (typeof threadId !== 'number') { this.ctx.logger.warn( @@ -505,7 +530,16 @@ export class ExecutionController { } if (session.state !== SessionState.RUNNING) { - return { success: false, error: `Cannot pause in state: ${session.state}`, state: session.state }; + // With the why while the launch is still initializing with the + // debugger off (issue #749) — every other surface carries it in that + // state. + const why = debuggerOffWhy(session); + const refusal = `Cannot pause in state: ${session.state}`; + return { + success: false, + error: why ? ErrorMessages.withDebuggerOffWhy(refusal, why) : refusal, + state: session.state + }; } this.ctx.logger.debug(`[SessionManager] pauseExecution: sending DAP pause for session=${sessionId} currentState=${session.state}`); @@ -621,8 +655,22 @@ export class ExecutionController { this.ctx.logger.info( `[SessionManager pause] No stopped event within ${this.ctx.tunables.pauseGraceMs}ms grace window in session ${sessionId}; completing asynchronously` ); - const pausePending = ErrorMessages.pausePending(this.ctx.tunables.pauseGraceMs / 1000); const hint = await this.describePendingStop(session, sessionId, 'pause'); + // With the debugger off for this launch the why is known (issue #749): + // one message that promises no stop, with the policy's own explanation + // kept — on js-debug a pause can land under the flag, and #678's + // advice is what makes it. + if (isDebuggerOff(session)) { + return { + success: true, + state: session.state, + data: { + message: ErrorMessages.pausePendingDebuggerOff(this.ctx.tunables.pauseGraceMs / 1000, hint), + pending: true + } + }; + } + const pausePending = ErrorMessages.pausePending(this.ctx.tunables.pauseGraceMs / 1000); return { success: true, state: session.state, @@ -645,10 +693,14 @@ export class ExecutionController { this.ctx.logger.error( `[SessionManager pause] Error sending 'pause' for session ${sessionId}: ${errorMessage}` ); + // The adapter's answer stands — a refusal (CodeLLDB under noDebug), or + // no debug target yet (js-debug before the child adopts) — with the why + // beside it when the launch runs with the debugger off (issue #749). + const answered = withDebuggerOffWhy(session, outcome.error, errorMessage); if (errorMessage.includes(NO_DEBUG_TARGET_MARKER)) { - return { success: false, error: errorMessage, state: session.state }; + return { success: false, error: answered.message, state: session.state }; } - throw outcome.error instanceof Error ? outcome.error : new Error(errorMessage); + throw answered.error; } async listThreads(sessionId: string): Promise> { diff --git a/src/session/inspection/expression-evaluator.ts b/src/session/inspection/expression-evaluator.ts index d85126ed0..154debe4c 100644 --- a/src/session/inspection/expression-evaluator.ts +++ b/src/session/inspection/expression-evaluator.ts @@ -8,6 +8,8 @@ * hook (issue #237) runs before anything, including the logs, sees the result. */ import { getErrorMessage } from '../../errors/debug-errors.js'; +import { debuggerOffWhy } from '../debugger-off.js'; +import { ErrorMessages } from '../../utils/error-messages.js'; import { buildRedactionNotice, isSensitiveName, @@ -133,7 +135,8 @@ export class ExpressionEvaluator { ); return { success: false, - error: 'Cannot evaluate: debugger not paused. Ensure the debugger is stopped at a breakpoint.', + // With the why, when the launch runs with the debugger off (issue #749). + error: ErrorMessages.cannotEvaluateNotPaused(debuggerOffWhy(session)), }; } diff --git a/src/session/inspection/frame-anchor-resolver.ts b/src/session/inspection/frame-anchor-resolver.ts index 2ffc6c1b4..66ba5a9d3 100644 --- a/src/session/inspection/frame-anchor-resolver.ts +++ b/src/session/inspection/frame-anchor-resolver.ts @@ -18,6 +18,8 @@ import type { DebugProtocol } from '@vscode/debugprotocol'; import path from 'path'; import type { IProxyManager } from '../../proxy/proxy-manager.js'; import type { ManagedSession } from '../session-store.js'; +import { debuggerOffWhy } from '../debugger-off.js'; +import { ErrorMessages } from '../../utils/error-messages.js'; /** The frame fields a tool response names when it says which frame answered. */ export type FrameSummary = Pick; @@ -135,10 +137,8 @@ export class FrameAnchorResolver { } if (session.state !== SessionState.PAUSED) { this.ctx.logger.warn(`[FrameAnchor ${sessionId}] Session not paused: ${session.state}.`); - return emptyResult( - `Session is not paused (state: ${session.state}); stack traces are only available while paused.`, - threadId - ); + // With the why, when the launch runs with the debugger off (issue #749). + return emptyResult(ErrorMessages.stackTraceNotPaused(session.state, debuggerOffWhy(session)), threadId); } const effectiveThreadId = threadId ?? currentThreadId; diff --git a/src/session/launch/debug-launcher.ts b/src/session/launch/debug-launcher.ts index be48abdaf..04373cc61 100644 --- a/src/session/launch/debug-launcher.ts +++ b/src/session/launch/debug-launcher.ts @@ -39,6 +39,7 @@ import { import { waitForLaunchReadiness } from './launch-readiness.js'; import type { ProxyLauncher } from './proxy-launcher.js'; import type { InFlightGuard } from '../in-flight-guard.js'; +import { adapterVerifiedABreakpoint } from '../debugger-off.js'; /** * A launch flag the way the adapter will see it. The proxy launcher merges @@ -242,6 +243,9 @@ export class DebugLauncher { session.lastProxyError = undefined; session.failureDiagnostics = undefined; session.lastStop = undefined; + // The previous launch's debugger-off decision does not carry over + // (issue #749); this attempt decides again below. + session.launchDebuggerOff = undefined; this.ctx.logger.info(`[SessionManager] Session ${sessionId} lifecycle state set to ACTIVE`); // Record the launch spec for restart_debugging BEFORE attempting the @@ -275,6 +279,14 @@ export class DebugLauncher { const noDebug = !isAttachShaped && resolveLaunchFlag('noDebug', dapLaunchArgs, adapterLaunchConfig); const honoursNoDebug = policy.honoursNoDebug === true; const debuggerOff = noDebug && honoursNoDebug; + // Recorded on the session so the surfaces after this response can say + // why they answer in non-debugger terms (issue #749). Not for a dry run: + // nothing launches. Written before the proxy starts, so the core's + // per-launch reset (which runs inside proxyLauncher.start) is not the + // place to clear it — the block above is. + if (debuggerOff && !dryRunSpawn) { + session.launchDebuggerOff = true; + } const noDebugWarning = buildNoDebugLaunchWarning( session, { noDebug, stopOnEntry: resolveLaunchFlag('stopOnEntry', dapLaunchArgs, adapterLaunchConfig) }, @@ -531,14 +543,20 @@ export class DebugLauncher { } // The policy's word is a static pin; a stop that arrived anyway is the - // stronger evidence (an adapter build that ignores the flag after all) — - // a pause still standing, or an entry stop the core already resumed - // (firstStopHandled is set on every stop of this launch, resumed or not). - // Then the debugger was on: keep the ordinary diagnostics and say the - // flag had no effect rather than that no stop can come. - const stoppedAnyway = - debuggerOff && (finalState === SessionState.PAUSED || finalSession.firstStopHandled === true); - const noDebugNote = stoppedAnyway + // stronger evidence (an adapter build that ignores the flag after all): + // a stop only a live debugger produces — the core's stopped handler + // is the one judge of that and clears the recorded decision (issue + // #749) — or a breakpoint the adapter verified in its configuration + // phase, which is read from the same evidence every later surface + // reads. Either way the launch response and the record cannot + // disagree. Then the debugger was on: keep the ordinary diagnostics + // and say the flag had no effect rather than that the breakpoints + // will not fire. (A launch that ends paused on a pause or a step + // keeps the record and the warning: the flag still keeps its + // breakpoints from binding.) + const debuggerOnAnyway = + debuggerOff && (finalSession.launchDebuggerOff !== true || adapterVerifiedABreakpoint(finalSession)); + const noDebugNote = debuggerOnAnyway ? buildNoDebugLaunchWarning(finalSession, { noDebug }, breakOnExceptions, false) : noDebugWarning; @@ -546,7 +564,7 @@ export class DebugLauncher { // ("check the file path", "check the symbol name", "will PAUSE") that // has one cause when the debugger is off — the noDebug warning names // it, and they are withheld so they cannot contradict it (issue #710). - const debuggerOn = !debuggerOff || stoppedAnyway; + const debuggerOn = !debuggerOff || debuggerOnAnyway; // Unbound-at-launch warning (issue #308): the verified state is fresh // after the re-sync above, so a name the adapter could not resolve is diff --git a/src/session/session-manager-core.ts b/src/session/session-manager-core.ts index 23e28091a..63bd870b2 100644 --- a/src/session/session-manager-core.ts +++ b/src/session/session-manager-core.ts @@ -8,7 +8,8 @@ import { isTerminalSessionState, AdapterPolicy, SessionOutputEntry, redactSecretsInString } from '@debugmcp/shared'; import type { Breakpoint, FunctionBreakpoint, StackFrame } from '@debugmcp/shared'; -import { BREAKPOINT_STOP_REASONS } from '@debugmcp/shared'; +import { USER_BREAK_REASONS } from '@debugmcp/shared'; +import { stopProvesDebuggerOn } from './debugger-off.js'; import { isRedactionEnabled } from '../utils/redaction-mode.js'; import { ValidationResultCache } from '../utils/language-availability.js'; import { SessionStore, ManagedSession } from './session-store.js'; @@ -50,11 +51,6 @@ import { samePath } from './breakpoints/hit-verification.js'; -/** - * Stop reasons the first-stop auto-continue must never swallow: the shared - * breakpoint family plus an exception the user asked to break on. - */ -export const USER_BREAK_REASONS: ReadonlySet = new Set([...BREAKPOINT_STOP_REASONS, 'exception']); // Custom launch arguments interface extending DebugProtocol.LaunchRequestArguments export interface CustomLaunchRequestArguments extends DebugProtocol.LaunchRequestArguments { @@ -769,6 +765,16 @@ export abstract class SessionManagerCore extends EventEmitter { } session.firstStopHandled = true; + // A stop only a live debugger produces is stronger evidence than the + // policy's noDebug pin: this adapter build debugs after all, so the + // later surfaces must stop explaining themselves in debugger-off + // terms (issue #749). Judged on what the adapter itself reported, not + // the policy's relabel: js-debug lands a pause, a step and a + // `debugger;` statement (relabelled 'breakpoint') under noDebug with + // its breakpoints still off. + if (stopProvesDebuggerOn(reason, rawReason, body)) { + session.launchDebuggerOff = undefined; + } }; proxyManager.on('stopped', handleStopped); handlers.set('stopped', handleStopped); diff --git a/src/session/session-store.ts b/src/session/session-store.ts index d76eba49b..70312ca93 100644 --- a/src/session/session-store.ts +++ b/src/session/session-store.ts @@ -33,6 +33,7 @@ export interface CreateSessionParams { import type { DebugProtocol } from '@vscode/debugprotocol'; import { IProxyManager } from '../proxy/proxy-manager.js'; import { OutputRingBuffer } from './output-buffer.js'; +import { isDebuggerOff } from './debugger-off.js'; import type { PauseIntent } from './execution/pause-intent.js'; import type { ProxyFailureDiagnostics } from './launch/proxy-failure-diagnostics.js'; @@ -139,6 +140,14 @@ export interface ManagedSession extends DebugSessionInfo { // attach — the user's value, or the policy's launch default when unset // (issue #244). Previously write-only pass-through; recorded for read-back. effectiveBreakOnExceptions?: ExceptionBreakMode; + // The launcher's decision that the current launch runs with the debugger + // off — noDebug on an adapter that honours it (issue #710). The raw + // record: written per launch, reset per launch and attach, cleared by a + // stop only a live debugger produces. Never read directly for a + // caller-facing answer — isDebuggerOff/debuggerOffWhy (debugger-off.ts) + // gate it on the launch being live, and DebugSessionInfo.debuggerDisabled + // is its projection (issue #749). + launchDebuggerOff?: boolean; // Caller-provided adapterConfig keys the adapter's attach transform did not // carry into the DAP attach request (issue #450). Recorded per attach by // ProxyLauncher.start; consumed by attachToProcess for the response warning. @@ -289,6 +298,9 @@ export class SessionStore { ...(s.state === SessionState.ERROR && s.failureDiagnostics ? { diagnostics: s.failureDiagnostics } : {}), + // The current launch runs with the debugger off (issue #749) — while + // it runs; a stopped or errored session describes nothing running. + ...(isDebuggerOff(s) ? { debuggerDisabled: true } : {}), // Mirror endpoint without the token (issue #217); the isRunning gate // keeps the projection honest on teardown paths that skip cleanup. ...(s.exposure && s.proxyManager?.isRunning() diff --git a/src/utils/error-messages.ts b/src/utils/error-messages.ts index 0e6b52168..c320ddc84 100644 --- a/src/utils/error-messages.ts +++ b/src/utils/error-messages.ts @@ -35,6 +35,15 @@ const IN_FLIGHT = { /** The launch-shaped operations one session may hold a claim for (issue #711). */ export type InFlightOperation = keyof typeof IN_FLIGHT; +/** + * The why a session whose current launch runs with the debugger off appends + * to every answer that would otherwise read in debugger terms (issue #749) — + * a module constant so the composed messages below can build on it. + */ +const DEBUGGER_OFF_FOR_LAUNCH = + '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'; + export const ErrorMessages = { /** * Error message for DAP request timeouts @@ -151,6 +160,85 @@ export const ErrorMessages = { `'paused' once the stop lands. Check the session state to confirm.`, + /** + * The why a session whose current launch runs with the debugger off appends + * to every answer that would otherwise read in debugger terms (issue #749): + * `dapLaunchArgs.noDebug` on an adapter that honours it (issue #710) — + * an unverified breakpoint, a pause that never lands, "not paused" from + * stepping and inspection. Names the fact and the remedy, never the + * adapter's own answer, which stays as it came. "No stop is expected" + * rather than "cannot come": js-debug still lands a pause under the flag. + * Used in: src/session/debugger-off.ts (`debuggerOffWhy`, the one gate every + * surface reads it through — the handlers and controllers never name it) + */ + debuggerOffForLaunch: DEBUGGER_OFF_FOR_LAUNCH, + + /** + * The same why for a session that is paused (issue #749): a stop did land + * — js-debug lands a pause under noDebug — so only the binding clause is + * still true of it. + * Used in: src/session/debugger-off.ts + */ + debuggerOffForLaunchPaused: + 'the debugger is off for this launch (noDebug is true): breakpoints cannot bind; ' + + 'drop noDebug and launch again to debug', + + /** + * The pending-pause message for a session whose launch runs with the + * debugger off (issue #749): the pause was sent and accepted, no stop came + * within the grace window, and — unlike `pausePending` — no stop is + * promised, since none is expected. The policy's own explanation (#678: + * js-debug's smart-stepper, which can also keep a pause from landing + * under the flag) rides along when there is one. + * Used in: src/session/execution/execution-controller.ts + * @param graceSeconds - The grace window duration in seconds + * @param policyHint - The adapter policy's explanation, when it has one + */ + pausePendingDebuggerOff: (graceSeconds: number, policyHint?: string) => + `Pause requested; no 'stopped' event within ${graceSeconds}s — ${DEBUGGER_OFF_FOR_LAUNCH}. ` + + `Check the session state in case a stop lands anyway.` + + (policyHint ? ` ${policyHint}` : ''), + + /** + * An adapter's own answer with the debugger-off why beside it (issue + * #749): a refused pause, or one that found no debug target yet. + * Used in: src/session/execution/execution-controller.ts + */ + withDebuggerOffWhy: (adapterMessage: string, why: string) => `${adapterMessage} (${why})`, + + /** + * The step/continue refusal for a session that is not paused, with the + * why when the launch runs with the debugger off (issue #749). + * Used in: src/session/execution/execution-controller.ts + */ + notPaused: (why?: string) => (why ? `Not paused: ${why}` : 'Not paused'), + + /** + * The evaluate refusal for a session that is not paused, with the why + * when the launch runs with the debugger off (issue #749). + * Used in: src/session/inspection/expression-evaluator.ts + */ + cannotEvaluateNotPaused: (why?: string) => + why + ? `Cannot evaluate: debugger not paused (${why})` + : 'Cannot evaluate: debugger not paused. Ensure the debugger is stopped at a breakpoint.', + + /** + * The empty stack trace's note for a session that is not paused, with the + * why when the launch runs with the debugger off (issue #749). + * Used in: src/session/inspection/frame-anchor-resolver.ts + */ + stackTraceNotPaused: (state: string, why?: string) => + `Session is not paused (state: ${state}); stack traces are only available while paused${why ? `; ${why}` : ''}.`, + + /** + * get_local_variables with no frame and the session not paused, with the + * why when the launch runs with the debugger off (issue #749). + * Used in: src/server/handlers/inspection-tools.ts + */ + noStackFramesNotPaused: (why?: string) => + why ? `No stack frames available; ${why}.` : 'No stack frames available. The debugger may not be paused.', + /** * Suffix appended to the attach message when the post-attach pause was * requested (explicitly or by the default) but no 'stopped' event arrived diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json index dcf58c25a..3cbaa3596 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json @@ -216,7 +216,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json index d4c98ade1..20f274f09 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json @@ -216,7 +216,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json index d6a330d28..a8649c2ce 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json @@ -216,7 +216,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json index ac53ef4a9..368a1cf0e 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json @@ -216,7 +216,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json index d6a330d28..a8649c2ce 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json @@ -216,7 +216,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json index ac53ef4a9..368a1cf0e 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json @@ -216,7 +216,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json index a111b7172..838213055 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json index 3c91f90a5..4b9eeb751 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json index d7f0e2765..29d5724ac 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json index 4d93b17cf..1e7c24152 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json index d7f0e2765..29d5724ac 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json index 4d93b17cf..1e7c24152 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json index ff96afe58..8aae9a0de 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json @@ -212,7 +212,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json index cd8436dac..61362ac7a 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json @@ -212,7 +212,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json index c6a376813..761c8f46a 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json @@ -212,7 +212,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json index ea3b3ab8a..ad23833b5 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json @@ -212,7 +212,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json index c6a376813..761c8f46a 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json @@ -212,7 +212,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json index ea3b3ab8a..ad23833b5 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json @@ -212,7 +212,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json index a111b7172..838213055 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json index 3c91f90a5..4b9eeb751 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json index d7f0e2765..29d5724ac 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json index 4d93b17cf..1e7c24152 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json index d7f0e2765..29d5724ac 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json index 4d93b17cf..1e7c24152 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json @@ -226,7 +226,7 @@ }, "noDebug": { "type": "boolean", - "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no stop ever arrives — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" + "description": "DAP's launch-without-debugging flag (default false). Where the adapter honours it (js-debug, debugpy, Delve and CodeLLDB) no breakpoint binds, no exception filter arms, no entry stop lands and no breakpoint, exception or entry stop ever arrives (a pause may still land where the runtime allows one) — the response warns when breakpoints, an explicit breakOnExceptions or stopOnEntry are set alongside it, and while the launch runs the session reports debuggerDisabled and every later surface says the debugger is off. rdbg, netcoredbg and the Java bridge ignore the flag, and the rust launch never forwards it: their debugger stays on and the response says so" } }, "additionalProperties": true diff --git a/tests/core/unit/server/handlers/inspection-tools.test.ts b/tests/core/unit/server/handlers/inspection-tools.test.ts index cb862f926..ca4b62610 100644 --- a/tests/core/unit/server/handlers/inspection-tools.test.ts +++ b/tests/core/unit/server/handlers/inspection-tools.test.ts @@ -13,6 +13,7 @@ import { handleGetLocalVariables } from '../../../../../src/server/handlers/inspection-tools.js'; import { SessionTerminatedError } from '../../../../../src/errors/debug-errors.js'; +import { ErrorMessages } from '../../../../../src/utils/error-messages.js'; import { createMockToolContext } from '../server-test-helpers.js'; // DebugMcpServer builds its dependencies in the constructor; mock the container @@ -221,6 +222,27 @@ describe('inspection tool handlers', () => { expect(payload.message).toContain('No stack frames available'); }); + it('says why no frame is available while the launch runs with the debugger off (issue #749)', async () => { + ctx.sessionManager.getSession.mockReturnValue({ + id: 'test-session', + state: 'running', + sessionLifecycle: 'ACTIVE', + launchDebuggerOff: true + }); + ctx.sessionManager.getLocalVariables.mockResolvedValue({ + variables: [], + frame: null, + scopeName: null + }); + + const result = await handleGetLocalVariables(ctx, { sessionId: 'test-session' }); + const payload = JSON.parse(result.content[0].text); + + expect(payload.success).toBe(true); + expect(payload.message).toContain('No stack frames available'); + expect(payload.message).toContain(ErrorMessages.debuggerOffForLaunch); + }); + it('shows "no local scope" message when frame exists but no scope', async () => { ctx.sessionManager.getLocalVariables.mockResolvedValue({ variables: [], diff --git a/tests/core/unit/server/handlers/session-tools.test.ts b/tests/core/unit/server/handlers/session-tools.test.ts index 28f9c57c6..034f14c1d 100644 --- a/tests/core/unit/server/handlers/session-tools.test.ts +++ b/tests/core/unit/server/handlers/session-tools.test.ts @@ -76,5 +76,22 @@ describe('session tool handlers', () => { expect(byId.stopped.lastStop).toMatchObject({ reason: 'breakpoint' }); expect(byId.errored.lastStop).toMatchObject({ reason: 'exception' }); }); + + it('reports debuggerDisabled for a launch running with the debugger off (issue #749)', async () => { + const now = new Date(); + // SessionStore.getAll() is the one gate on the field (running or paused + // only); the handler mirrors what it was given. + ctx.sessionManager.getAllSessions.mockReturnValue([ + { id: 'off', name: 'o', language: 'python', state: 'running', createdAt: now, debuggerDisabled: true }, + { id: 'on', name: 'n', language: 'python', state: 'running', createdAt: now } + ]); + + const result = await handleListDebugSessions(ctx); + const payload = JSON.parse(result.content[0].text); + const byId = Object.fromEntries(payload.sessions.map((s: { id: string }) => [s.id, s])); + + expect(byId.off.debuggerDisabled).toBe(true); + expect(byId.on).not.toHaveProperty('debuggerDisabled'); + }); }); }); diff --git a/tests/core/unit/server/server-breakpoint-management-tools.test.ts b/tests/core/unit/server/server-breakpoint-management-tools.test.ts index 3ddcca4c7..ccc8eec6f 100644 --- a/tests/core/unit/server/server-breakpoint-management-tools.test.ts +++ b/tests/core/unit/server/server-breakpoint-management-tools.test.ts @@ -3,6 +3,7 @@ * list_breakpoints / remove_breakpoint / clear_breakpoints */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ErrorMessages } from '../../../../src/utils/error-messages.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { DebugMcpServer } from '../../../../src/server.js'; @@ -93,6 +94,65 @@ describe('Server Breakpoint Management Tools', () => { expect(mockSessionManager.listBreakpoints).toHaveBeenCalledWith('test-session', undefined); }); + it('says why none of the breakpoints can bind while the launch runs with the debugger off (issue #749)', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + state: 'running', + sessionLifecycle: 'ACTIVE', + launchDebuggerOff: true + }); + mockSessionManager.listBreakpoints.mockReturnValue([ + { id: 'bp-1', file: '/a.py', line: 10, verified: false, message: 'Unbound breakpoint' } + ]); + mockSessionManager.listFunctionBreakpoints.mockReturnValue([]); + + const result = await callToolHandler({ + method: 'tools/call', + params: { name: 'list_breakpoints', arguments: { sessionId: 'test-session' } } + }); + + const content = JSON.parse(result.content[0].text); + expect(content.success).toBe(true); + // The adapter's own per-breakpoint answer is untouched. + expect(content.breakpoints[0].message).toBe('Unbound breakpoint'); + expect(content.warning).toMatch(/debugger is off for this launch/); + expect(content.warning).toBe(ErrorMessages.debuggerOffForLaunch); + }); + + it('adds no debugger-off warning when every breakpoint is verified anyway, or once the launch is over (issue #749)', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + state: 'running', + sessionLifecycle: 'ACTIVE', + launchDebuggerOff: true + }); + mockSessionManager.listBreakpoints.mockReturnValue([ + { id: 'bp-1', file: '/a.py', line: 10, verified: true } + ]); + mockSessionManager.listFunctionBreakpoints.mockReturnValue([]); + + let result = await callToolHandler({ + method: 'tools/call', + params: { name: 'list_breakpoints', arguments: { sessionId: 'test-session' } } + }); + expect(JSON.parse(result.content[0].text).warning).toBeUndefined(); + + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + state: 'stopped', + sessionLifecycle: 'ACTIVE', + launchDebuggerOff: true + }); + mockSessionManager.listBreakpoints.mockReturnValue([ + { id: 'bp-1', file: '/a.py', line: 10, verified: false } + ]); + result = await callToolHandler({ + method: 'tools/call', + params: { name: 'list_breakpoints', arguments: { sessionId: 'test-session' } } + }); + expect(JSON.parse(result.content[0].text).warning).toBeUndefined(); + }); + it('always includes empty function-breakpoint fields in the unfiltered response (#306)', async () => { mockSessionManager.listBreakpoints.mockReturnValue([]); mockSessionManager.listFunctionBreakpoints.mockReturnValue([]); diff --git a/tests/core/unit/server/server-control-tools.test.ts b/tests/core/unit/server/server-control-tools.test.ts index 3ceb9016b..31516459d 100644 --- a/tests/core/unit/server/server-control-tools.test.ts +++ b/tests/core/unit/server/server-control-tools.test.ts @@ -54,6 +54,93 @@ describe('Server Control Tools Tests', () => { }); describe('set_breakpoint', () => { + it('appends why an unverified breakpoint cannot bind while the launch runs with the debugger off (issue #749)', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + state: 'running', + sessionLifecycle: 'ACTIVE', + launchDebuggerOff: true + }); + // The request still went to the adapter; its own answer is kept. + mockSessionManager.setBreakpoint.mockResolvedValue({ + breakpoint: { id: 'bp-1', file: '/path/to/test.py', line: 10, verified: false, message: 'Unbound breakpoint' } + }); + + const result = await callToolHandler({ + method: 'tools/call', + params: { name: 'set_breakpoint', arguments: { sessionId: 'test-session', file: '/path/to/test.py', line: 10 } } + }); + + expect(mockSessionManager.setBreakpoint).toHaveBeenCalled(); + const content = JSON.parse(result.content[0].text); + expect(content.success).toBe(true); + expect(content.verified).toBe(false); + expect(content.warning).toContain('Unbound breakpoint'); + expect(content.warning).toContain(ErrorMessages.debuggerOffForLaunch); + }); + + it('says only that breakpoints cannot bind on a session that is paused — a stop did land (issue #749)', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + state: 'paused', + sessionLifecycle: 'ACTIVE', + launchDebuggerOff: true + }); + mockSessionManager.setBreakpoint.mockResolvedValue({ + breakpoint: { id: 'bp-1', file: '/path/to/test.py', line: 10, verified: false, message: 'Unbound breakpoint' } + }); + + const result = await callToolHandler({ + method: 'tools/call', + params: { name: 'set_breakpoint', arguments: { sessionId: 'test-session', file: '/path/to/test.py', line: 10 } } + }); + + const content = JSON.parse(result.content[0].text); + expect(content.warning).toContain(ErrorMessages.debuggerOffForLaunchPaused); + expect(content.warning).not.toMatch(/no stop is expected/); + }); + + it('adds no debugger-off note once the launch is over — a queued breakpoint is an ordinary one (issue #749)', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + state: 'stopped', + sessionLifecycle: 'ACTIVE', + launchDebuggerOff: true + }); + mockSessionManager.setBreakpoint.mockResolvedValue({ + breakpoint: { id: 'bp-1', file: '/path/to/test.py', line: 10, verified: false } + }); + + const result = await callToolHandler({ + method: 'tools/call', + params: { name: 'set_breakpoint', arguments: { sessionId: 'test-session', file: '/path/to/test.py', line: 10 } } + }); + + const content = JSON.parse(result.content[0].text); + expect(content.warning).toBeUndefined(); + }); + + it('adds no debugger-off note to a breakpoint the adapter verified anyway (issue #749)', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + state: 'running', + sessionLifecycle: 'ACTIVE', + launchDebuggerOff: true + }); + mockSessionManager.setBreakpoint.mockResolvedValue({ + breakpoint: { id: 'bp-1', file: '/path/to/test.py', line: 10, verified: true } + }); + + const result = await callToolHandler({ + method: 'tools/call', + params: { name: 'set_breakpoint', arguments: { sessionId: 'test-session', file: '/path/to/test.py', line: 10 } } + }); + + const content = JSON.parse(result.content[0].text); + expect(content.verified).toBe(true); + expect(content.warning).toBeUndefined(); + }); + it('should set breakpoint successfully', async () => { const mockBreakpoint: Breakpoint = { id: 'bp-1', diff --git a/tests/core/unit/server/server-inspection-tools.test.ts b/tests/core/unit/server/server-inspection-tools.test.ts index e029dbf81..26614bc78 100644 --- a/tests/core/unit/server/server-inspection-tools.test.ts +++ b/tests/core/unit/server/server-inspection-tools.test.ts @@ -19,6 +19,7 @@ import { type MockSessionManager } from './server-test-helpers.js'; import { OutputRingBuffer } from '../../../../src/session/output-buffer.js'; +import { SessionState } from '@debugmcp/shared'; // Mock dependencies vi.mock('@modelcontextprotocol/sdk/server/index.js'); @@ -436,16 +437,21 @@ describe('Server Inspection Tools Tests', () => { expect(content.diagnostics).toEqual({ proxyLogPath: '/logs/proxy-test-session.log' }); }); - it('should handle missing thread ID', async () => { + it('answers a paused session with no thread to name with the session layer\'s note, not "no active proxy"', async () => { const mockSession = { + state: SessionState.PAUSED, failureDiagnostics: { proxyLogPath: '/logs/proxy-test-session.log' }, proxyManager: { - getCurrentThreadId: vi.fn().mockReturnValue(null) + getCurrentThreadId: vi.fn().mockReturnValue(null), + sendDapRequest: vi.fn().mockResolvedValue({ body: { threads: [] } }) } }; - mockSessionManager.getSession.mockReturnValue(mockSession); - + mockSessionManager.getStackTraceDetailed.mockResolvedValue({ + frames: [], totalFrameCount: 0, hiddenFrameCount: 0, allFramesInternal: false, + note: 'No stopped thread is known for this session.' + }); + const result = await callToolHandler({ method: 'tools/call', params: { @@ -453,11 +459,12 @@ describe('Server Inspection Tools Tests', () => { arguments: { sessionId: 'test-session' } } }); - - // The server returns a structured failure result (success: false) with an error message + + // The proxy is alive; the resolver's answer is the truthful one. const content = JSON.parse(result.content[0].text); - expect(content.success).toBe(false); - expect(content.error).toContain('no active proxy for session test-session'); + expect(content.success).toBe(true); + expect(content.stackFrames).toEqual([]); + expect(content.note).toContain('No stopped thread is known for this session.'); }); it('should surface SessionManager errors as a truthful tool-level failure', async () => { diff --git a/tests/core/unit/session/session-manager-nodebug-warning.test.ts b/tests/core/unit/session/session-manager-nodebug-warning.test.ts index b03f3d156..0e251a094 100644 --- a/tests/core/unit/session/session-manager-nodebug-warning.test.ts +++ b/tests/core/unit/session/session-manager-nodebug-warning.test.ts @@ -10,7 +10,7 @@ import { buildNoDebugFailureNote, buildNoDebugLaunchWarning } from '../../../../ import type { ManagedSession } from '../../../../src/session/session-store.js'; import { SessionManager, type SessionManagerConfig } from '../../../../src/session/session-manager.js'; import { DebugLanguage, SessionState, type AdapterPolicy, type Breakpoint, type ExceptionBreakMode, type FunctionBreakpoint } from '@debugmcp/shared'; -import { createMockDependencies, setMockProxyRunning } from './session-manager-test-utils.js'; +import { createMockDependencies, overridePolicy, setMockProxyRunning } from './session-manager-test-utils.js'; import type { MockProxyManager } from '../../../test-utils/mocks/mock-proxy-manager.js'; type BuilderSession = Pick; @@ -202,8 +202,10 @@ describe('SessionManager launches with noDebug (issue #710)', () => { expect(result.success).toBe(true); expect(result.state).toBe(SessionState.STOPPED); - expect(warningOf(result)).toMatch(/noDebug is true/); - expect(warningOf(result)).toMatch(/1 breakpoint\(s\)/); + // What is known: the flag, and what it keeps from firing. Not a claim + // that no stop of any kind can come — js-debug lands a pause under it. + expect(warningOf(result)).toMatch(/^noDebug is true, so the debugger is off for this launch: 1 breakpoint\(s\) will not fire\./); + expect(warningOf(result)).not.toMatch(/no stop can arrive/); // The #467 diagnosis ("check the file path and line") would be wrong here. expect(warningOf(result)).not.toMatch(/never bound during this run/); }); @@ -366,6 +368,28 @@ describe('SessionManager launches with noDebug (issue #710)', () => { expect(warningOf(result)).toMatch(/noDebug is true/); }); + it("never claims no stop can come from a launch that ended paused — even on a 'pause' the record survives", async () => { + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + await sessionManager.setBreakpoint(s.id, { file: '/work/src/app.py', line: 7 }); + const proxy = dependencies.mockProxyManager; + proxy.start = vi.fn().mockImplementation(async (startConfig) => { + setMockProxyRunning(proxy, true); + proxy.startCalls.push(startConfig); + proxy.emit('adapter-configured'); + proxy.emit('initialized'); + proxy.emit('stopped', 1, 'pause', { reason: 'pause', threadId: 1 }); + }) as MockProxyManager['start']; + + const result = await launch(s.id, { stopOnEntry: false, noDebug: true }); + + expect(result.state).toBe(SessionState.PAUSED); + // The response and the record agree: the debugger is off for the + // breakpoints, whatever paused — no "no effect", no "no stop can come". + expect(warningOf(result)).toMatch(/the debugger is off for this launch: 1 breakpoint\(s\) will not fire/); + expect(warningOf(result)).not.toMatch(/has no effect/); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBe(true); + }); + it('believes a stop that arrived anyway over the policy pin', async () => { // The mock adapter stops at its breakpoint whatever the flag says — the // override pinned honoursNoDebug, so this is what a wrong pin looks like @@ -392,6 +416,36 @@ describe('SessionManager launches with noDebug (issue #710)', () => { expect(Date.now() - before).toBeLessThan(30000); }); + it('believes a breakpoint the adapter verified anyway over the policy pin — before any stop', async () => { + // A wrong pin seen from the other side: the adapter binds the + // breakpoint (its configuration-phase echo says verified) and the + // program keeps running. The launch must not say "will not fire" of + // a breakpoint list_breakpoints shows bound; the record stays (a stop + // is what clears it) but is consulted with the evidence. + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + await sessionManager.setBreakpoint(s.id, { file: '/work/src/app.py', line: 7 }); + const [queued] = sessionManager.listBreakpoints(s.id); + const proxy = dependencies.mockProxyManager; + proxy.start = vi.fn().mockImplementation(async (startConfig) => { + setMockProxyRunning(proxy, true); + proxy.startCalls.push(startConfig); + proxy.emit('adapter-configured'); + proxy.emit('initialized'); + proxy.simulateEvent('breakpoints-synced', [ + { id: queued.id, file: '/work/src/app.py', line: 7, verified: true, adapterId: 3 } + ]); + }) as MockProxyManager['start']; + + const result = await launch(s.id, { stopOnEntry: false, noDebug: true }); + + expect(result.state).toBe(SessionState.RUNNING); + expect(warningOf(result)).toMatch(/noDebug has no effect/); + expect(warningOf(result)).not.toMatch(/will not fire/); + expect(sessionManager.listBreakpoints(s.id)[0].verified).toBe(true); + const listed = sessionManager.getAllSessions().find((x) => x.id === s.id); + expect(listed).not.toHaveProperty('debuggerDisabled'); + }); + it('warns on a dry run too — it is a configuration check', async () => { const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); await sessionManager.setBreakpoint(s.id, { file: '/work/src/app.py', line: 7 }); @@ -461,4 +515,194 @@ describe('SessionManager launches with noDebug (issue #710)', () => { expect(warningOf(result)).toBeUndefined(); }); }); + + /** + * The decision outlives the launch response (issue #749): later surfaces — + * set_breakpoint, list_breakpoints, pause, inspection — read it off the + * session to say why they answer the way they do. + */ + describe('records the decision on the session (issue #749)', () => { + it('sets debuggerDisabled on the session for an honoured noDebug launch', async () => { + pinPolicy({ honoursNoDebug: true }); + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + runWithoutStopping(); + + await launch(s.id, { stopOnEntry: false, noDebug: true }); + + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBe(true); + }); + + it('leaves it unset where the adapter ignores the flag, and for a launch without it', async () => { + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + runWithoutStopping(); + + await launch(s.id, { stopOnEntry: false, noDebug: true }); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBeUndefined(); + + pinPolicy({ honoursNoDebug: true }); + runWithoutStopping(); + await launch(s.id, { stopOnEntry: false }); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBeUndefined(); + }); + + it('clears it on the next launch without the flag', async () => { + pinPolicy({ honoursNoDebug: true }); + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + runWithoutStopping(); + await launch(s.id, { stopOnEntry: false, noDebug: true }); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBe(true); + + dependencies.mockProxyManager.simulateEvent('terminated'); + await vi.runAllTimersAsync(); + runWithoutStopping(); + await launch(s.id, { stopOnEntry: false }); + + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBeUndefined(); + }); + + it('does not set it for a dry run — nothing launched', async () => { + pinPolicy({ honoursNoDebug: true }); + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + const proxy = dependencies.mockProxyManager; + proxy.start = vi.fn().mockImplementation(async (startConfig) => { + proxy.startCalls.push(startConfig); + process.nextTick(() => proxy.emit('dry-run-complete', 'python app.py', '/work/src/app.py')); + }) as MockProxyManager['start']; + + const startPromise = sessionManager.startDebugging(s.id, '/work/src/app.py', [], { stopOnEntry: false, noDebug: true }, true); + await vi.runAllTimersAsync(); + const result = await startPromise; + + expect((result.data as { dryRun?: boolean }).dryRun).toBe(true); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBeUndefined(); + }); + + it('survives a pause that lands — js-debug pauses under noDebug while its breakpoints stay unbound', async () => { + pinPolicy({ honoursNoDebug: true }); + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + runWithoutStopping(); + await launch(s.id, { stopOnEntry: false, noDebug: true }); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBe(true); + + // Measured: the inspector is attached and a user pause lands, but the + // debug domains — breakpoints — are off. A pause proves nothing. + dependencies.mockProxyManager.simulateEvent('stopped', 1, 'pause', { reason: 'pause', threadId: 1 }); + await vi.runAllTimersAsync(); + + expect(sessionManager.getSession(s.id)?.state).toBe(SessionState.PAUSED); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBe(true); + }); + + it('survives a step taken from that pause — it proves exactly as much as the pause did', async () => { + pinPolicy({ honoursNoDebug: true }); + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + runWithoutStopping(); + await launch(s.id, { stopOnEntry: false, noDebug: true }); + + // Measured on js-debug: pause lands, step_over from it lands with + // reason 'step', and line breakpoints still cannot bind. + dependencies.mockProxyManager.simulateEvent('stopped', 1, 'pause', { reason: 'pause', threadId: 1 }); + await vi.runAllTimersAsync(); + dependencies.mockProxyManager.simulateEvent('continued'); + dependencies.mockProxyManager.simulateEvent('stopped', 1, 'step', { reason: 'step', threadId: 1 }); + await vi.runAllTimersAsync(); + + expect(sessionManager.getSession(s.id)?.state).toBe(SessionState.PAUSED); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBe(true); + }); + + it('is not consulted while the session is merely created — a launch that failed before the proxy leaves it CREATED', async () => { + pinPolicy({ honoursNoDebug: true }); + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + runWithoutStopping(); + await launch(s.id, { stopOnEntry: false, noDebug: true }); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBe(true); + + // The MSVC-toolchain refusal path: back to CREATED with the record intact. + sessionManager.getSession(s.id)!.state = SessionState.CREATED; + + const listed = sessionManager.getAllSessions().find((x) => x.id === s.id); + expect(listed).not.toHaveProperty('debuggerDisabled'); + }); + + it("survives a `debugger;` statement js-debug relabels 'breakpoint' — the adapter itself said 'pause'", async () => { + pinPolicy({ honoursNoDebug: true }); + // The relabel lives in the store's policy (the core's handleStopped reads it). + overridePolicy(sessionManager, { + normalizeStopReason: (raw: string, body?: { description?: string }) => + raw === 'pause' && body?.description === 'Paused on debugger statement' ? 'breakpoint' : raw + }); + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + runWithoutStopping(); + await launch(s.id, { stopOnEntry: false, noDebug: true }); + + dependencies.mockProxyManager.simulateEvent('stopped', 1, 'pause', { + reason: 'pause', threadId: 1, description: 'Paused on debugger statement' + }); + await vi.runAllTimersAsync(); + + expect(sessionManager.getSession(s.id)?.lastStop?.reason).toBe('breakpoint'); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBe(true); + }); + + it('is cleared by a stop that names the breakpoints it hit, whatever the reason was called', async () => { + pinPolicy({ honoursNoDebug: true }); + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + await sessionManager.setBreakpoint(s.id, { file: '/work/src/app.py', line: 7 }); + runWithoutStopping(); + await launch(s.id, { stopOnEntry: false, noDebug: true }); + + dependencies.mockProxyManager.simulateEvent('stopped', 1, 'pause', { + reason: 'pause', threadId: 1, hitBreakpointIds: [1] + }); + await vi.runAllTimersAsync(); + + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBeUndefined(); + }); + + it.each(['breakpoint', 'function breakpoint', 'exception', 'entry'])( + "is cleared by a '%s' stop the adapter itself reported — one a disabled debugger cannot produce", + async (reason) => { + pinPolicy({ honoursNoDebug: true }); + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + runWithoutStopping(); + await launch(s.id, { stopOnEntry: false, noDebug: true }); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBe(true); + + dependencies.mockProxyManager.simulateEvent('stopped', 1, reason, { reason, threadId: 1 }); + await vi.runAllTimersAsync(); + + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBeUndefined(); + } + ); + + it('is not projected once the launch is over — the next set_breakpoint is an ordinary queued one', async () => { + pinPolicy({ honoursNoDebug: true }); + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + endDuringStartup(); + const result = await launch(s.id, { stopOnEntry: false, noDebug: true }); + expect(result.state).toBe(SessionState.STOPPED); + + const listed = sessionManager.getAllSessions().find((x) => x.id === s.id); + expect(listed).not.toHaveProperty('debuggerDisabled'); + }); + + it('recomputes it on restart_debugging, which replays the same arguments', async () => { + pinPolicy({ honoursNoDebug: true }); + const s = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + runWithoutStopping(); + await launch(s.id, { stopOnEntry: false, noDebug: true }); + dependencies.mockProxyManager.simulateEvent('terminated'); + await vi.runAllTimersAsync(); + // A stop-free termination leaves the flag; restart resets and re-decides. + runWithoutStopping(); + + const restartPromise = sessionManager.restartDebugging(s.id); + await vi.runAllTimersAsync(); + const result = await restartPromise; + + expect(result.success).toBe(true); + expect(sessionManager.getSession(s.id)?.launchDebuggerOff).toBe(true); + }); + }); }); diff --git a/tests/core/unit/session/session-store-projection.test.ts b/tests/core/unit/session/session-store-projection.test.ts index d2f4142da..2df723b83 100644 --- a/tests/core/unit/session/session-store-projection.test.ts +++ b/tests/core/unit/session/session-store-projection.test.ts @@ -52,3 +52,53 @@ describe('SessionStore.getAll() lastStop projection (issue #720)', () => { } }); }); + +describe('SessionStore.getAll() debuggerDisabled projection (issue #749)', () => { + it('projects debuggerDisabled only while the launch runs with the debugger off', () => { + const { store, id } = storeWith(SessionState.RUNNING); + expect(store.getAll().find((s) => s.id === id)!).not.toHaveProperty('debuggerDisabled'); + + store.get(id)!.launchDebuggerOff = true; + expect(store.getAll().find((s) => s.id === id)!.debuggerDisabled).toBe(true); + + store.get(id)!.launchDebuggerOff = undefined; + expect(store.getAll().find((s) => s.id === id)!).not.toHaveProperty('debuggerDisabled'); + + // Only a live launch — initializing (the proxy is up and a breakpoint + // set now still goes to the adapter), running or paused: over + // (stopped/error) or never launched (created), the record describes + // nothing running. + store.get(id)!.launchDebuggerOff = true; + for (const state of [SessionState.STOPPED, SessionState.ERROR, SessionState.CREATED]) { + store.get(id)!.state = state; + expect(store.getAll().find((s) => s.id === id)!, state).not.toHaveProperty('debuggerDisabled'); + } + for (const state of [SessionState.INITIALIZING, SessionState.PAUSED]) { + store.get(id)!.state = state; + expect(store.getAll().find((s) => s.id === id)!.debuggerDisabled, state).toBe(true); + } + }); + + it('drops the projection once the adapter has verified a breakpoint — proof this build debugs after all', () => { + // Measured: every adapter that honours the flag refuses or unbinds a + // breakpoint under it (js-debug "Unbound breakpoint", debugpy "Server is + // not available", Delve "noDebug mode: unable to process + // 'setBreakpoints'", CodeLLDB "Not supported in noDebug mode"), so a + // record the adapter verified can only come from a build that ignores + // the flag — as strong as a stop, and it arrives before any hit. + const { store, id } = storeWith(SessionState.RUNNING); + const managed = store.get(id)!; + managed.launchDebuggerOff = true; + const bp = { id: 'bp-1', file: 'a.py', line: 3, verified: false }; + managed.breakpoints.set(bp.id, bp as never); + expect(store.getAll().find((s) => s.id === id)!.debuggerDisabled).toBe(true); + + bp.verified = true; + expect(store.getAll().find((s) => s.id === id)!).not.toHaveProperty('debuggerDisabled'); + + // A verified function breakpoint counts the same way. + bp.verified = false; + managed.functionBreakpoints.set('fbp-1', { id: 'fbp-1', name: 'main', verified: true } as never); + expect(store.getAll().find((s) => s.id === id)!).not.toHaveProperty('debuggerDisabled'); + }); +}); diff --git a/tests/e2e/mcp-server-break-on-exceptions.test.ts b/tests/e2e/mcp-server-break-on-exceptions.test.ts index 5cc0d89fe..fb4ccdc2f 100644 --- a/tests/e2e/mcp-server-break-on-exceptions.test.ts +++ b/tests/e2e/mcp-server-break-on-exceptions.test.ts @@ -39,6 +39,8 @@ const CRASHING_SCRIPT = path.resolve(ROOT, 'tests', 'fixtures', 'debug-scripts', const JS_CRASHING_SCRIPT = path.resolve(ROOT, 'tests', 'fixtures', 'debug-scripts', 'js-throws.js'); const JS_CLEAN_SCRIPT = path.resolve(ROOT, 'tests', 'fixtures', 'debug-scripts', 'js-clean-exit.js'); const SIMPLE_SCRIPT = path.resolve(ROOT, 'tests', 'fixtures', 'debug-scripts', 'simple.py'); +const JS_PAUSE_SCRIPT = path.resolve(ROOT, 'examples', 'javascript', 'pause_test.js'); +const PY_PAUSE_SCRIPT = path.resolve(ROOT, 'examples', 'python', 'pause_test.py'); const ATTACH_SCRIPT = path.resolve(ROOT, 'tests', 'fixtures', 'python', 'attach_then_raise.py'); const PYTHON = process.platform === 'win32' ? 'python' : 'python3'; @@ -56,6 +58,7 @@ interface SessionSnapshot { }; }; exitCode?: number; + debuggerDisabled?: boolean; } async function getSessionSnapshot(client: Client, sessionId: string): Promise { @@ -431,6 +434,103 @@ describe('Break-on-exception (issue #220)', () => { expect(stopped!.lastStop).toBeUndefined(); expect(stopped!.exitCode).toBe(0); }, 60000); + + it('says the debugger is off on every later surface of a noDebug session (issue #749)', async () => { + // The clean case: debugpy attaches no debugger at all under the flag + // (js-debug keeps its inspector, so a pause still lands there). Every + // request still goes to debugpy; its own answer — "Server is not + // available" — is kept, and the session's recorded decision adds why. + sessionId = await createSession('python', 'py-nodebug-surfaces'); + const bp = parseSdkToolResult(await mcpClient!.callTool({ + name: 'set_breakpoint', + arguments: { sessionId, file: PY_PAUSE_SCRIPT, line: 7 } + })); + expect(bp.success, JSON.stringify(bp)).toBe(true); + + const startRes = parseSdkToolResult(await mcpClient!.callTool({ + name: 'start_debugging', + arguments: { + sessionId, + scriptPath: PY_PAUSE_SCRIPT, + dapLaunchArgs: { stopOnEntry: false, noDebug: true } + } + })); + expect(startRes.success, JSON.stringify(startRes)).toBe(true); + expect(startRes.state).toBe('running'); + expect((startRes as { warning?: string }).warning).toMatch(/noDebug is true/); + + const why = /the debugger is off for this launch/; + const debugpySaid = /Server is not available/; + + const running = await getSessionSnapshot(mcpClient!, sessionId); + expect(running?.debuggerDisabled).toBe(true); + + const live = parseSdkToolResult(await mcpClient!.callTool({ + name: 'set_breakpoint', + arguments: { sessionId, file: PY_PAUSE_SCRIPT, line: 8 } + })) as { success?: boolean; verified?: boolean; warning?: string }; + expect(live.success).toBe(true); + expect(live.verified).toBe(false); + expect(live.warning).toMatch(debugpySaid); + expect(live.warning).toMatch(why); + + const listed = parseSdkToolResult(await mcpClient!.callTool({ + name: 'list_breakpoints', + arguments: { sessionId } + })) as { warning?: string; breakpoints?: Array<{ verified?: boolean }> }; + expect(listed.warning).toMatch(why); + expect(listed.breakpoints?.every(b => b.verified === false)).toBe(true); + + // No thread is ever current (nothing stops), and debugpy refuses the + // `threads` discovery too: the answer is the not-paused note with the + // why, not "no active proxy" — the proxy is alive. + const stack = parseSdkToolResult(await mcpClient!.callTool({ + name: 'get_stack_trace', + arguments: { sessionId } + })) as { success?: boolean; note?: string; error?: string }; + expect(stack.success, JSON.stringify(stack)).toBe(true); + expect(stack.note).toMatch(why); + + const locals = parseSdkToolResult(await mcpClient!.callTool({ + name: 'get_local_variables', + arguments: { sessionId } + })) as { success?: boolean; count?: number; message?: string }; + expect(locals.success).toBe(true); + expect(locals.count).toBe(0); + expect(locals.message).toMatch(why); + + const evaluated = parseSdkToolResult(await mcpClient!.callTool({ + name: 'evaluate_expression', + arguments: { sessionId, expression: 'counter' } + })) as { success?: boolean; error?: string }; + expect(evaluated.success).toBe(false); + expect(evaluated.error).toMatch(why); + + const step = parseSdkToolResult(await mcpClient!.callTool({ + name: 'step_over', + arguments: { sessionId } + })) as { success?: boolean; error?: string }; + expect(step.success).toBe(false); + expect(step.error).toMatch(/^Not paused: /); + expect(step.error).toMatch(why); + + // The pause is still sent. Measured: debugpy refuses it under the flag + // ("Server is not available"), and the refusal reaches the wire as an + // MCP error with the why beside it; should a debugpy build accept and + // land it instead, the decision stays (a pause proves nothing about + // breakpoints). + const pause = await callToolSafely(mcpClient!, 'pause_execution', { sessionId }) as { + success?: boolean; state?: string; error?: unknown; message?: string; data?: { message?: string }; + }; + if (pause.success && pause.state === 'paused') { + const paused = await getSessionSnapshot(mcpClient!, sessionId); + expect(paused?.debuggerDisabled).toBe(true); + } else { + const answer = `${String(pause.error ?? '')} ${pause.message ?? ''} ${pause.data?.message ?? ''}`; + expect(answer).toMatch(why); + expect(answer).toMatch(debugpySaid); + } + }, 60000); }); describe('JavaScript launch (js-debug child session)', () => { @@ -552,6 +652,93 @@ describe('Break-on-exception (issue #220)', () => { expect(stopped!.exitCode).toBe(0); }, 60000); + it('says the debugger is off on every later surface of a noDebug session (issue #749)', async () => { + sessionId = await createSession('javascript', 'js-nodebug-surfaces'); + const bp = parseSdkToolResult(await mcpClient!.callTool({ + name: 'set_breakpoint', + arguments: { sessionId, file: JS_PAUSE_SCRIPT, line: 4 } + })); + expect(bp.success, JSON.stringify(bp)).toBe(true); + + // A program that stays up, so the surfaces are asked while it runs. + const startRes = parseSdkToolResult(await mcpClient!.callTool({ + name: 'start_debugging', + arguments: { + sessionId, + scriptPath: JS_PAUSE_SCRIPT, + dapLaunchArgs: { stopOnEntry: false, noDebug: true } + } + })); + expect(startRes.success, JSON.stringify(startRes)).toBe(true); + expect(startRes.state).toBe('running'); + expect((startRes as { warning?: string }).warning).toMatch(/noDebug is true/); + + const why = /the debugger is off for this launch/; + + // The session remembers the decision... + const running = await getSessionSnapshot(mcpClient!, sessionId); + expect(running?.debuggerDisabled).toBe(true); + + // ...a live breakpoint still goes to js-debug, whose own answer is + // kept ("Unbound breakpoint") with the why beside it... + const live = parseSdkToolResult(await mcpClient!.callTool({ + name: 'set_breakpoint', + arguments: { sessionId, file: JS_PAUSE_SCRIPT, line: 5 } + })) as { success?: boolean; verified?: boolean; warning?: string }; + expect(live.success).toBe(true); + expect(live.verified).toBe(false); + expect(live.warning).toMatch(/Unbound breakpoint/); + expect(live.warning).toMatch(why); + + const listed = parseSdkToolResult(await mcpClient!.callTool({ + name: 'list_breakpoints', + arguments: { sessionId } + })) as { warning?: string; breakpoints?: Array<{ verified?: boolean }> }; + expect(listed.warning).toMatch(why); + expect(listed.breakpoints?.every(b => b.verified === false)).toBe(true); + + // ...inspection and stepping say why there is nothing paused... + const stack = parseSdkToolResult(await mcpClient!.callTool({ + name: 'get_stack_trace', + arguments: { sessionId } + })) as { note?: string }; + expect(stack.note).toMatch(why); + + const step = parseSdkToolResult(await mcpClient!.callTool({ + name: 'step_over', + arguments: { sessionId } + })) as { success?: boolean; error?: string }; + expect(step.success).toBe(false); + expect(step.error).toMatch(/^Not paused: /); + expect(step.error).toMatch(why); + + // ...and a pause is still sent to js-debug. Measured: js-debug lands + // it even under noDebug (the inspector is attached; line breakpoints + // still cannot bind), and so does a step taken from it — neither + // proves anything about breakpoints, so the session keeps the + // decision. Should a js-debug build refuse or never land the pause, + // the why rides on that answer instead. + // callToolSafely: a refusal reaches the wire as an MCP error, which + // callTool would throw rather than return. + const pause = await callToolSafely(mcpClient!, 'pause_execution', { sessionId }) as { + success?: boolean; state?: string; error?: unknown; message?: string; data?: { message?: string }; + }; + if (pause.success && pause.state === 'paused') { + const paused = await getSessionSnapshot(mcpClient!, sessionId); + expect(paused?.state).toBe('paused'); + expect(paused?.debuggerDisabled).toBe(true); + + const stepped = await callToolSafely(mcpClient!, 'step_over', { sessionId }) as { success?: boolean; state?: string }; + if (stepped.success && stepped.state === 'paused') { + const afterStep = await getSessionSnapshot(mcpClient!, sessionId); + expect(afterStep?.lastStop?.reason).toBe('step'); + expect(afterStep?.debuggerDisabled).toBe(true); + } + } else { + expect(`${String(pause.error ?? '')} ${pause.message ?? ''} ${pause.data?.message ?? ''}`).toMatch(why); + } + }, 60000); + it('reports exit code 0 for a clean run (issue #247)', async () => { sessionId = await createSession('javascript', 'js-clean-exit-code'); diff --git a/tests/unit/server-coverage.test.ts b/tests/unit/server-coverage.test.ts index 2ab2068fa..e99aad57d 100644 --- a/tests/unit/server-coverage.test.ts +++ b/tests/unit/server-coverage.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { DebugMcpServer } from '../../src/server'; import { McpError } from '@modelcontextprotocol/sdk/types.js'; -import { SessionLifecycleState } from '@debugmcp/shared'; +import { SessionLifecycleState, SessionState } from '@debugmcp/shared'; import { createProductionDependencies } from '../../src/container/dependencies.js'; import { SessionManager } from '../../src/session/session-manager.js'; import { @@ -200,6 +200,7 @@ describe('Server Coverage - Error Paths and Edge Cases', () => { }; mockSessionManager.getSession.mockReturnValue({ id: 'test-session', + state: SessionState.PAUSED, sessionLifecycle: SessionLifecycleState.ACTIVE, proxyManager: mockProxy }); @@ -215,7 +216,12 @@ describe('Server Coverage - Error Paths and Edge Cases', () => { expect(result.frames).toHaveLength(1); }); - it('should throw when getStackTrace has no thread and threads request fails', async () => { + // A paused session with no current thread asks the adapter (`threads`); + // when that names none too, the session layer answers — "No stopped + // thread is known for this session." — because the proxy is alive and + // "no active proxy" would be false (issue #749). The error is reserved + // for a session with no proxy at all. + it('hands a paused session with no thread to the session layer when the threads request fails', async () => { const mockProxy = { getCurrentThreadId: () => null, isRunning: () => true, @@ -223,15 +229,23 @@ describe('Server Coverage - Error Paths and Edge Cases', () => { }; mockSessionManager.getSession.mockReturnValue({ id: 'test-session', + state: SessionState.PAUSED, sessionLifecycle: SessionLifecycleState.ACTIVE, proxyManager: mockProxy }); + const noThread = { + frames: [], totalFrameCount: 0, hiddenFrameCount: 0, allFramesInternal: false, + note: 'No stopped thread is known for this session.' + }; + mockSessionManager.getStackTraceDetailed.mockResolvedValue(noThread); - await expect(server.getStackTrace('test-session')) - .rejects.toThrow('Cannot get stack trace: no active proxy'); + await expect(server.getStackTrace('test-session')).resolves.toBe(noThread); + + expect(mockProxy.sendDapRequest).toHaveBeenCalledWith('threads', {}); + expect(mockSessionManager.getStackTraceDetailed).toHaveBeenCalledWith('test-session', undefined, false); }); - it('should throw when getStackTrace has no thread and threads response is empty', async () => { + it('hands a paused session with no thread to the session layer when the threads response is empty', async () => { const mockProxy = { getCurrentThreadId: () => null, isRunning: () => true, @@ -239,12 +253,50 @@ describe('Server Coverage - Error Paths and Edge Cases', () => { }; mockSessionManager.getSession.mockReturnValue({ id: 'test-session', + state: SessionState.PAUSED, sessionLifecycle: SessionLifecycleState.ACTIVE, proxyManager: mockProxy }); + const noThread = { + frames: [], totalFrameCount: 0, hiddenFrameCount: 0, allFramesInternal: false, + note: 'No stopped thread is known for this session.' + }; + mockSessionManager.getStackTraceDetailed.mockResolvedValue(noThread); - await expect(server.getStackTrace('test-session')) - .rejects.toThrow('Cannot get stack trace: no active proxy'); + await expect(server.getStackTrace('test-session')).resolves.toBe(noThread); + + expect(mockSessionManager.getStackTraceDetailed).toHaveBeenCalledWith('test-session', undefined, false); + }); + + it('answers a session that is not paused without asking the adapter for threads (issue #749)', async () => { + // A launch that runs with the debugger off never stops, so no thread + // is ever current, and debugpy refuses the `threads` discovery + // ("Server is not available") — and on a wedged adapter that request + // would block for the DAP timeout. The resolver's not-paused answer + // (with the why) needs no thread, so the discovery is skipped: the + // same answer a debug-mode running session gets, one round trip cheaper. + const mockProxy = { + getCurrentThreadId: () => null, + isRunning: () => true, + sendDapRequest: vi.fn().mockRejectedValue(new Error('Server is not available')) + }; + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + state: SessionState.RUNNING, + launchDebuggerOff: true, + sessionLifecycle: SessionLifecycleState.ACTIVE, + proxyManager: mockProxy + }); + const notPaused = { + frames: [], totalFrameCount: 0, hiddenFrameCount: 0, allFramesInternal: false, + note: 'Session is running, not paused: the debugger is off for this launch (noDebug is true)' + }; + mockSessionManager.getStackTraceDetailed.mockResolvedValue(notPaused); + + await expect(server.getStackTrace('test-session')).resolves.toBe(notPaused); + + expect(mockProxy.sendDapRequest).not.toHaveBeenCalled(); + expect(mockSessionManager.getStackTraceDetailed).toHaveBeenCalledWith('test-session', undefined, false); }); }); diff --git a/tests/unit/session-manager-operations-coverage.test.ts b/tests/unit/session-manager-operations-coverage.test.ts index 51342ea65..d3d236fd2 100644 --- a/tests/unit/session-manager-operations-coverage.test.ts +++ b/tests/unit/session-manager-operations-coverage.test.ts @@ -18,8 +18,7 @@ import { type Breakpoint, type CustomLaunchRequestArguments, type FunctionBreakpoint, - type ILogger -} from '@debugmcp/shared'; + type ILogger, NO_DEBUG_TARGET_MARKER } from '@debugmcp/shared'; /** Concrete subclass for testing the abstract SessionManagerOperations */ class TestableSessionManagerOperations extends SessionManagerOperations { @@ -4432,4 +4431,196 @@ describe('Session Manager Operations Coverage - Error Paths and Edge Cases', () expect(bpArg).not.toHaveProperty('suspendPolicy'); }); }); + + /** + * A session whose launch runs with the debugger off (noDebug honoured, + * issue #710) still sends every request to the adapter and surfaces the + * adapter's own answer; the recorded fact supplies the why beside it + * (issue #749). + */ + describe('a launch running with the debugger off says so on every later surface (issue #749)', () => { + const why = ErrorMessages.debuggerOffForLaunch; + + it('has one sentence for the why, naming the fact and the remedy', () => { + expect(why).toMatch(/debugger is off for this launch/); + expect(why).toMatch(/noDebug/); + }); + + it('explains a pause that the adapter accepted but that never lands, instead of guessing at native code', async () => { + vi.useFakeTimers(); + try { + mockSession.state = SessionState.RUNNING; + mockSession.launchDebuggerOff = true; + mockProxyManager.sendDapRequest.mockResolvedValue({}); + const describePendingStop = vi.fn().mockReturnValue('Explained by the policy.'); + vi.spyOn(operations as any, 'selectPolicy').mockReturnValue({ describePendingStop } as any); + + const promise = operations.pause('test-session', 1); + await vi.advanceTimersByTimeAsync(5000); + const result = await promise; + + // The pause was still sent: the adapter's answer is the ground truth. + expect(mockProxyManager.sendDapRequest).toHaveBeenCalledWith('pause', expect.objectContaining({ threadId: 1 })); + expect(result.success).toBe(true); + expect(result.data?.pending).toBe(true); + // One message that does not promise the stop the base text promises, + // with the policy's own explanation kept: on js-debug the pause can + // land under the flag, and #678's advice is what makes it. + expect(result.data?.message).toBe(ErrorMessages.pausePendingDebuggerOff(5, 'Explained by the policy.')); + expect(result.data?.message).toContain(why); + expect(result.data?.message).toContain('Explained by the policy.'); + expect(result.data?.message).not.toMatch(/blocked in native code/); + expect(result.data?.message).not.toMatch(/will report 'paused' once the stop lands/); + expect(describePendingStop).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the adapter's refusal of a pause as the cause, untouched, and appends the why", async () => { + mockSession.state = SessionState.RUNNING; + mockSession.launchDebuggerOff = true; + const refusal = Object.assign(new Error('Internal debugger error: Not supported in noDebug mode.'), { code: 'E_NODEBUG' }); + mockProxyManager.sendDapRequest.mockImplementation(async (command: string) => { + if (command === 'pause') { + throw refusal; + } + return {}; + }); + + const thrown = await operations.pause('test-session', 1).then( + () => { throw new Error('expected a rejection'); }, + (err: unknown) => err as Error & { cause?: unknown } + ); + expect(thrown.message).toBe(ErrorMessages.withDebuggerOffWhy('Internal debugger error: Not supported in noDebug mode.', why)); + expect(thrown.cause).toBe(refusal); + expect(refusal.message).toBe('Internal debugger error: Not supported in noDebug mode.'); + expect((thrown.cause as { code?: string }).code).toBe('E_NODEBUG'); + }); + + it('carries the why on a pause that found no debug target yet (js-debug before the child adopts)', async () => { + mockSession.state = SessionState.RUNNING; + mockSession.launchDebuggerOff = true; + mockProxyManager.sendDapRequest.mockImplementation(async (command: string) => { + if (command === 'pause') { + throw new Error(`pause failed: ${NO_DEBUG_TARGET_MARKER}`); + } + return {}; + }); + + const result = await operations.pause('test-session', 1); + + expect(result.success).toBe(false); + expect(result.error).toContain(NO_DEBUG_TARGET_MARKER); + expect(result.error).toContain(why); + }); + + it('carries the why while the launch is still initializing — the proxy is up and answering', async () => { + mockSession.state = SessionState.INITIALIZING; + mockSession.launchDebuggerOff = true; + + const result = await operations.getStackTraceDetailed('test-session'); + + expect(result.frames).toEqual([]); + expect(result.note).toContain(why); + }); + + it('drops the why once the launch is over — the flag describes a launch that is no longer running', async () => { + mockSession.state = SessionState.STOPPED; + mockSession.launchDebuggerOff = true; + + const result = await operations.getStackTraceDetailed('test-session'); + + expect(result.frames).toEqual([]); + expect(result.note ?? '').not.toContain(why); + }); + + it('says why stepping and continuing find nothing paused', async () => { + mockSession.state = SessionState.RUNNING; + mockSession.launchDebuggerOff = true; + + const step = await operations.stepOver('test-session'); + expect(step.success).toBe(false); + expect(step.error).toBe(ErrorMessages.notPaused(why)); + expect(step.error).toContain(why); + + const cont = await operations.continue('test-session'); + expect(cont.success).toBe(false); + expect(cont.error).toBe(ErrorMessages.notPaused(why)); + }); + + it('leaves the plain "Not paused" alone when the debugger is on', async () => { + mockSession.state = SessionState.RUNNING; + mockSession.launchDebuggerOff = undefined; + + const step = await operations.stepOver('test-session'); + expect(step.error).toBe(ErrorMessages.notPaused()); + expect(step.error).toBe('Not paused'); + }); + + it('drops the why once the adapter has verified a breakpoint — the build debugs after all', async () => { + mockSession.state = SessionState.RUNNING; + mockSession.launchDebuggerOff = true; + mockSession.breakpoints.set('bp-1', { id: 'bp-1', file: 'a.py', line: 3, verified: true } as never); + + const step = await operations.stepOver('test-session'); + expect(step.error).toBe('Not paused'); + + const stack = await operations.getStackTraceDetailed('test-session'); + expect(stack.note ?? '').not.toContain(why); + }); + + it('says why a pause is refused while the launch is still initializing (issue #749)', async () => { + mockSession.state = SessionState.INITIALIZING; + mockSession.launchDebuggerOff = true; + + const result = await operations.pause('test-session', 1); + + expect(result.success).toBe(false); + expect(result.error).toContain('Cannot pause in state: initializing'); + expect(result.error).toContain(why); + expect(mockProxyManager.sendDapRequest).not.toHaveBeenCalledWith('pause', expect.anything()); + }); + + it('keeps a non-Error refusal of a pause as the cause, as it was thrown', async () => { + mockSession.state = SessionState.RUNNING; + mockSession.launchDebuggerOff = true; + const refusal = { code: 'E_NODEBUG', text: 'not an Error instance' }; + mockProxyManager.sendDapRequest.mockImplementation(async (command: string) => { + if (command === 'pause') { + throw refusal; + } + return {}; + }); + + const thrown = await operations.pause('test-session', 1).then( + () => { throw new Error('expected a rejection'); }, + (err: unknown) => err as Error & { cause?: unknown } + ); + expect(thrown.message).toContain(why); + expect(thrown.cause).toBe(refusal); + }); + + it('says why an expression cannot be evaluated', async () => { + mockSession.state = SessionState.RUNNING; + mockSession.launchDebuggerOff = true; + + const result = await operations.evaluateExpression('test-session', '1 + 1'); + + expect(result.success).toBe(false); + expect(result.error).toContain('not paused'); + expect(result.error).toContain(why); + }); + + it('says why the stack trace is empty', async () => { + mockSession.state = SessionState.RUNNING; + mockSession.launchDebuggerOff = true; + + const result = await operations.getStackTraceDetailed('test-session'); + + expect(result.frames).toEqual([]); + expect(result.note).toMatch(/not paused/i); + expect(result.note).toContain(why); + }); + }); });