Skip to content

Commit eceeaaf

Browse files
debugmcpdevcynarlabclaude
authored
fix(attach): report a CodeLLDB attach's initial break-in stop as a pause (#817) (#821)
* fix(attach): report a CodeLLDB attach's initial break-in stop as a pause (#817) On Windows a cpp/cobol attach by PID reported its first stop as `reason: "exception"` with "Exception 0x80000003 encountered at address …": the DebugBreakProcess break-in Windows injects to stop the target, on a helper thread. CodeLLDB stops the target itself when the attach did not opt out of stopOnEntry, so no pause intent was ever armed and the normalizer's attach rule (#597) could not fire. - session-manager-core: the stop-reason context's stopOnEntry is read per mode — a launch stops on entry only when asked (=== true), an attach stops unless told not to (!== false), which is what the attach controller and the adapters (CodeLLDB's `stopOnEntry ?? true`) apply. A default attach reaches the core as `stopOnEntry: undefined`. - lldb-policy-shared: the first stop of an attach that asked to stop, with no pause in flight and the 0x80000003 break-in code in the description, is 'pause'; rawReason 'exception' and the description stay beside it. A break-in met later, or first by a stopOnEntry:false attach, stays an exception. Measured (Windows 11, CodeLLDB 1.11.8, examples/cpp/pause_test.cpp), through a nested mcp-debugger paused inside its own stop handler: the stopped body is {allThreadsStopped, description, reason, threadId} and arrives 2 ms before the attach response; the context read {sessionMode:'attach', firstStop:true, pausePending:false, stopOnEntry:false}. After: lastStop {reason:'pause', rawReason:'exception'}, get_stack_trace stopReason 'pause'. Closes #817 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(attach): read the attach's stopOnEntry as the adapter receives it; review follow-ups (#817) Review of #821: - adapterConfig.stopOnEntry wins the proxy launcher's merge on the wire (issue #336) but the core's mode facts only saw the top-level parameter, so an attach with `adapterConfig: { stopOnEntry: false }` and no top-level value made the core read "asked to stop" while CodeLLDB resumed — a genuine first __debugbreak() would have been relabelled a pause. The attach controller now folds a boolean adapterConfig.stopOnEntry into the dapLaunchArgs the core sees; the verification gate keeps reading the top-level parameter (the existing warning says so). - The per-mode stopOnEntry is computed once, with the other mode facts, and the auto-continue decision reads it too. - The POSIX SIGSTOP remark is hedged: that shape is not measured for this stop (the cpp attach e2e logs it off Windows). - docs/cobol/README.md points at the C/C++ guide for the attach stop's wording, as #814 did for the detach mechanism. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: JF <john.franklin@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent b8d8c90 commit eceeaaf

14 files changed

Lines changed: 261 additions & 11 deletions

‎changelog.d/817.fixed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**A CodeLLDB attach's initial stop is reported as a pause, not an exception** — on Windows, attaching to a process by PID (C/C++ and COBOL) reported the target's post-attach stop as `lastStop.reason: "exception"` / `stopReason: "exception"` with `Exception 0x80000003 encountered at address …`: that is the `DebugBreakProcess` break-in Windows injects to stop the target for the attach, on a helper thread, and nothing in the program threw. CodeLLDB stops the target itself when the attach did not opt out of `stopOnEntry`, so the session layer never armed the pause intent the shared normalizer keyed on; the first stop of such an attach is now `pause`, with the adapter's own reason kept as `rawReason: "exception"` and its description beside it. The stop-reason context's `stopOnEntry` now reads an attach the way the attach controller and the adapters do — stop unless told not to — so a real `__debugbreak()` met first by an attach that passed `stopOnEntry: false` stays an exception (#817)

‎docs/cobol/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ attach_to_process sessionId=... processId=<pid> adapterConfig={"manifestDirs": [
146146
- On Windows the attach stop is reported on the break thread the OS injects (exception `0x80000003`), a pause may land on a runtime worker thread: when the reported thread has no COBOL frame, the shim reports the stop on the thread that is inside the COBOL program (the description names both), so `get_stack_trace` shows the program on first contact. Breakpoint, step, entry and runtime-error stops are always on the right thread and are left alone.
147147
- A batch job is usually paused inside libcob or a `C$SLEEP`/I/O call, a frame with no COBOL source: `get_scopes`, `get_local_variables` and `evaluate_expression` then serve the nearest COBOL program up the stack (the scope names say whose and how far up, `WORKING-STORAGE of PAYROLL (0000-MAIN, 3 frames up)`), so the data division is visible on first contact; `get_stack_trace` shows the COBOL frame's paragraph.
148148
- When the client that started the session goes away (the server or the proxy dies), the shim detaches: an attached process is never terminated the way a launched one is.
149-
- The target is held paused after attach (`stopOnEntry` is a top-level `attach_to_process` parameter and defaults to `true`; pass `false` to resume immediately).
149+
- The target is held paused after attach (`stopOnEntry` is a top-level `attach_to_process` parameter and defaults to `true`; pass `false` to resume immediately). That initial stop is reported as `lastStop.reason: "pause"`; on Windows the adapter's own words stay beside it as `rawReason: "exception"` and the `0x80000003` description (issue #817; the mechanism is in the C/C++ guide's attach section).
150150
- Recognised `adapterConfig` keys: `processId`/`pid`, `program` (CodeLLDB's explicit-binary hint; a relative path is resolved against `cwd` before it reaches CodeLLDB), `cwd`, `waitFor`, `manifestDirs`, `engineScopes`, `sources`, `dialect`, `format`, `copybookDirs`, `cobcFlags`, `runtimeChecks`, `forceRebuild`, `initCommands`, `preRunCommands`, `postRunCommands`, `exitCommands`, `targetCreateCommands`, `processCreateCommands`, `expressions`, `sourceMap`, `sourceLanguages`, `relativePathBase`, `breakpointMode`. The manifest keys (`sources` … `forceRebuild`, `manifestDirs`, `engineScopes`, `cwd`) are consumed by the adapter and never appear in the attach request, and are not reported as ignored. Unlisted keys are still forwarded, with a warning naming them.
151151
- The regeneration runs before CodeLLDB is spawned, under the attach's `timeout` (default 30 s) less a margin; a cached translate (unchanged sources and options) is instant, a first one is a cobc run.
152152
- `detach_from_process` leaves the target running. On Windows a stopped target is resumed (DAP `continue`) before the detaching `disconnect` — on detach, on `close_debug_session` and on shutdown — and the detach result reports `data.resumedBeforeDetach: true`: the engine is CodeLLDB, whose Windows detach otherwise kills a target whose last resume was a step (issue #763; the mechanism is in the C/C++ guide's attach section).

‎docs/cpp/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Compile with **`-gdwarf-4 -O0`**: full debug info, no optimization (optimized co
4949
attach_to_process sessionId=... processId=<pid>
5050
```
5151

52-
- The target is held paused after attach (`stopOnEntry` defaults to `true` for attach in every language, not just C/C++; pass `false` to resume immediately).
52+
- The target is held paused after attach (`stopOnEntry` defaults to `true` for attach in every language, not just C/C++; pass `false` to resume immediately). That initial stop is reported as `lastStop.reason: "pause"` (`stopReason: "pause"` in `get_stack_trace`). On Windows it is the `DebugBreakProcess` break-in Windows injects to stop the target, which CodeLLDB reports as an `0x80000003` exception on a helper thread — the adapter's own words stay beside the pause as `rawReason: "exception"` and `description: "Exception 0x80000003 encountered at address …"`, and the anchored thread is the program's (issue #817). A `stopOnEntry: false` attach has no initial stop, so a break-in met first there (a real `__debugbreak()`) is reported as the exception it is.
5353
- `detach_from_process` leaves the target running. On Windows a stopped target is resumed (DAP `continue`) before the `disconnect` — on detach, on `close_debug_session` and on shutdown — and the detach result reports `data.resumedBeforeDetach: true`: CodeLLDB's Windows detach resumes each thread with the resume state of its last stop, and after a `step_over`/`step_into` that state is still "stepping", so the target would single-step with no debugger attached and exit with `STATUS_SINGLE_STEP` (`0x80000004`) (issue #763). A refused continue leaves `resumedBeforeDetach: false` and a `data.warning`.
5454
- **Linux**: `kernel.yama.ptrace_scope=1` (the default on many distros) only allows attaching to child processes. For arbitrary processes: `sudo sysctl kernel.yama.ptrace_scope=0` (temporary) or run the server with `CAP_SYS_PTRACE`.
5555
- **Windows**: attach requires same-or-higher privilege than the target.

‎docs/tool-reference.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,7 @@ When the requested pause has not landed by the time the tool answers (an idle No
12351235

12361236
**Notes:**
12371237
- `state` is `"paused"` only once a stopped event has actually been observed; otherwise the attach reports `"running"`. When a requested post-attach pause is accepted but its stopped event has not arrived within the bounded wait, the response is successful with `state: "running"` and `pending: true` (at the top level and in `data`) and the `message` names the pending pause; the late stopped event is the only transition to `paused`, and every paused session has a `lastStop`.
1238+
- The initial stop of an attach is reported as `lastStop.reason: "pause"` (`stopReason: "pause"` in `get_stack_trace`) by every adapter, whether the debugger paused the target on request or, as CodeLLDB does, stopped it itself. On Windows, CodeLLDB reports that stop as the `DebugBreakProcess` break-in — an `0x80000003` exception on a helper thread — so the adapter's own words stay beside the pause as `lastStop.rawReason: "exception"` and `description: "Exception 0x80000003 encountered at address …"` (issue #817). Only the first stop of an attach that did not pass `stopOnEntry: false` is read this way: a break-in met later, or first by an attach that opted out of the initial stop, is a real `__debugbreak()` and keeps `reason: "exception"`.
12381239
- When `processId` was used, the message reads `Attached to process PID <pid>` instead.
12391240
- The response `warning` reports two distinct `adapterConfig` key outcomes (issues #450/#466): keys the adapter's attach transform genuinely drops (e.g. Python's ptvsd-era `localRoot`/`remoteRoot` — use `pathMappings`) are named as **ignored**, while keys mcp-debugger doesn't recognize are **forwarded to the adapter as-is** and named with an edit-distance suggestion for near-misses (`pathMapping (did you mean pathMappings?)`) — "ignored" means dropped, "forwarded as-is" means the adapter still sees them. The same field also carries the launch-style warning for function breakpoints still unverified at attach (issue #308).
12401241
- js-debug attach honors `adapterConfig` too: `localRoot`/`remoteRoot`/`sourceMaps`/`skipFiles`/`continueOnAttach` and other js-debug attach options reach the debugger (issue #466). Two keys are defaulted when absent (issue #655): `resolveSourceMapLocations: ["**", "!**/node_modules/**"]` (js-debug's own attach default collapses to "resolve everywhere", which applied every dependency's `.js.map` and produced phantom `../src/*.ts` frames) and `cwd` (the server's working directory, or the workspace root in container mode — js-debug needs a base path before it resolves any source map's relative `sources`, even ones next to the generated file). Pass your own value, including `resolveSourceMapLocations: null`, to override. `skipFiles` is deliberately left at js-debug's default: blackboxing `node_modules` would turn a `pause_execution` that lands in framework code on an idle server into the endless step-chase of issue #513 — `get_stack_trace` hides those frames instead.

‎packages/shared/src/interfaces/adapter-policy.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,12 @@ export interface StopReasonContext {
9494
sessionMode?: 'launch' | 'attach';
9595
/** True for the first stop the session has seen since its launch/attach. */
9696
firstStop?: boolean;
97-
/** The effective stopOnEntry of the launch, when known. */
97+
/**
98+
* Whether the session asked for an initial stop, read per mode: a launch
99+
* stops on entry only when the caller asked (`stopOnEntry === true`); an
100+
* attach stops unless the caller opted out (`stopOnEntry !== false`, the
101+
* adapters' own default — issue #817).
102+
*/
98103
stopOnEntry?: boolean;
99104
}
100105

‎packages/shared/src/interfaces/lldb-policy-shared.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ export const LLDB_LOCAL_SCOPE_NAMES = ['Local', 'Locals'] as const;
3131
* any platform-specific exception detail, so its generation-scoped
3232
* intent is authoritative; public pauses retain the narrower Windows
3333
* detail checks so coincident real exceptions are not mislabeled.
34+
* An attach's own initial stop is the same break-in with no intent to
35+
* lean on (issue #817): CodeLLDB stops the target itself when the attach
36+
* did not opt out of stopOnEntry, so the session layer never arms a pause,
37+
* and the stopped event (measured: `{allThreadsStopped, description:
38+
* "Exception 0x80000003 encountered at address 0x…", reason: "exception",
39+
* threadId}`) even precedes the attach response. That first stop of an
40+
* attach that asked to stop is 'pause' too — gated on the break-in code,
41+
* so a real __debugbreak() met first by an attach that opted out stays an
42+
* exception. (Measured on Windows; the POSIX shape of this stop is not
43+
* pinned yet — a SIGSTOP would fall under the first rule.)
3444
*
3545
* 2. An exception-filter hit (rust_panic, cpp_throw) is reported as reason
3646
* 'breakpoint' because CodeLLDB implements filters as internal
@@ -82,6 +92,20 @@ export function normalizeLldbStopReason(
8292
if (context.pauseSource === 'attach') {
8393
return 'pause';
8494
}
95+
// The attach's own initial stop (issue #817): first stop of an attach that
96+
// did not opt out of stopOnEntry, with no pause in flight, reported through
97+
// the Windows break-in code. (On POSIX a ptrace attach stops the target
98+
// with SIGSTOP, which the rule above would cover; that shape has not been
99+
// measured for this stop — the cpp attach e2e logs it off Windows.)
100+
if (
101+
context.sessionMode === 'attach' &&
102+
context.firstStop === true &&
103+
context.stopOnEntry === true &&
104+
!context.pausePending &&
105+
/0x80000003/i.test(detail)
106+
) {
107+
return 'pause';
108+
}
85109
// Windows delivers a user-initiated pause via DebugBreakProcess: the
86110
// injected break-in thread raises EXCEPTION_BREAKPOINT (0x80000003),
87111
// which CodeLLDB reports as an exception stop (issue #275; captured

‎packages/shared/tests/unit/adapter-policy-cpp.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,49 @@ describe('CppAdapterPolicy', () => {
7272
expect(normalize('exception', body, baseContext)).toBeUndefined();
7373
expect(normalize('exception', body, { ...baseContext, pausePending: true })).toBe('pause');
7474
});
75+
76+
// An attach's own initial stop (issue #817): CodeLLDB stops the target
77+
// itself, so no pause intent exists to arm; on Windows the
78+
// DebugBreakProcess break-in is reported as 0x80000003 on a helper thread
79+
// (measured body: {allThreadsStopped, description, reason, threadId}).
80+
describe("the attach's initial stop (issue #817)", () => {
81+
const breakIn = {
82+
reason: 'exception',
83+
description: 'Exception 0x80000003 encountered at address 0x7ff958103ab0'
84+
};
85+
const attachFirst: StopReasonContext = {
86+
...baseContext,
87+
sessionMode: 'attach',
88+
firstStop: true,
89+
stopOnEntry: true
90+
};
91+
92+
it("maps the break-in of a default attach to 'pause'", () => {
93+
expect(normalize('exception', breakIn, attachFirst)).toBe('pause');
94+
});
95+
96+
it('keeps a break-in when the attach opted out of the initial stop (a real __debugbreak came first)', () => {
97+
expect(normalize('exception', breakIn, { ...attachFirst, stopOnEntry: false })).toBeUndefined();
98+
});
99+
100+
it('keeps a break-in on a later stop of the attach session', () => {
101+
expect(normalize('exception', breakIn, { ...attachFirst, firstStop: false })).toBeUndefined();
102+
});
103+
104+
it("keeps a launch's first-stop break-in (a launch entry stop arrives as 'entry', not as an exception)", () => {
105+
expect(normalize('exception', breakIn, { ...attachFirst, sessionMode: 'launch' })).toBeUndefined();
106+
});
107+
108+
it('keeps a first-stop exception whose detail is not the break-in code', () => {
109+
expect(
110+
normalize(
111+
'exception',
112+
{ reason: 'exception', description: 'Exception 0xc0000005 encountered at address 0x1' },
113+
attachFirst
114+
)
115+
).toBeUndefined();
116+
});
117+
});
75118
});
76119

77120
describe('extractLocalVariables (shared LLDB scope handling)', () => {

‎packages/shared/tests/unit/adapter-policy-rust.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,18 @@ describe('RustAdapterPolicy', () => {
152152
).toBeUndefined();
153153
});
154154

155+
// The shared normalizer also owns an attach's own initial stop (issue
156+
// #817): CodeLLDB stops the target itself, so no pause intent is armed,
157+
// and Windows reports the DebugBreakProcess break-in as 0x80000003.
158+
it("maps the break-in of an attach's own initial stop to pause (issue #817)", () => {
159+
const breakIn = { reason: 'exception', description: 'Exception 0x80000003 encountered at address 0x7ff958103ab0' };
160+
const attachFirst: Partial<StopReasonContext> = { sessionMode: 'attach', firstStop: true, stopOnEntry: true };
161+
expect(normalize('exception', breakIn, ctx(attachFirst))).toBe('pause');
162+
expect(normalize('exception', breakIn, ctx({ ...attachFirst, stopOnEntry: false }))).toBeUndefined();
163+
expect(normalize('exception', breakIn, ctx({ ...attachFirst, firstStop: false }))).toBeUndefined();
164+
expect(normalize('exception', breakIn, ctx({ ...attachFirst, sessionMode: 'launch' }))).toBeUndefined();
165+
});
166+
155167
it('never touches step or pause reasons', () => {
156168
expect(normalize('step', { reason: 'step' }, ctx())).toBeUndefined();
157169
expect(normalize('pause', { reason: 'pause' }, ctx({ pausePending: true }))).toBeUndefined();

‎src/session/attach/attach-controller.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ export class AttachController {
115115
const { verifyTimeout, breakOnExceptions, adapterConfig, ...adapterAttachConfig } = attachConfig;
116116
if (adapterConfig && adapterConfig.stopOnEntry !== undefined) {
117117
this.ctx.logger.warn(
118-
'[SessionManager] adapterConfig.stopOnEntry reaches the adapter but does not affect post-attach pause verification; prefer the top-level stopOnEntry parameter'
118+
'[SessionManager] adapterConfig.stopOnEntry reaches the adapter (and is what the stop-reason reading of the ' +
119+
'attach\'s initial stop follows) but does not affect post-attach pause verification; prefer the top-level ' +
120+
'stopOnEntry parameter'
119121
);
120122
}
121123
// Same rules as every per-request 'timeout' override: positive finite
@@ -186,9 +188,16 @@ export class AttachController {
186188
// The actual attach logic will be handled by the adapter via dapLaunchArgs
187189
const placeholderPath = 'attach://remote';
188190

189-
// Pass attach config through dapLaunchArgs with special request type
191+
// Pass attach config through dapLaunchArgs with special request type.
192+
// stopOnEntry as the adapter will receive it: adapterConfig wins the
193+
// merge the proxy launcher applies (issue #336), and the core's mode
194+
// facts — the stop-reason reading of the attach's initial stop (issue
195+
// #817) and the RUNNING projection — must follow the value that reached
196+
// the debugger, not a top-level default the adapter never saw. The
197+
// verification gate below keeps reading the top-level parameter.
190198
const attachLaunchArgs = {
191199
...adapterAttachConfig,
200+
...(typeof adapterConfig?.stopOnEntry === 'boolean' ? { stopOnEntry: adapterConfig.stopOnEntry } : {}),
192201
request: 'attach',
193202
__attachMode: true // Internal flag to signal attach mode
194203
};

‎src/session/session-manager-core.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,16 @@ export abstract class SessionManagerCore extends EventEmitter {
587587
const isAttachSession =
588588
launchArgsRecord.request === 'attach' ||
589589
launchArgsRecord.__attachMode === true;
590+
// Whether the session asked for an initial stop. The two modes default
591+
// differently: a launch stops on entry only when asked (`=== true`), an
592+
// attach stops unless told not to (`!== false`) — the reading the attach
593+
// controller and the adapters (CodeLLDB's `stopOnEntry ?? true`) apply.
594+
// A default attach reaches here as `stopOnEntry: undefined`: the launch
595+
// default (false) is spread first and the caller's undefined key over it
596+
// (issue #817).
597+
const stopOnEntry = isAttachSession
598+
? effectiveLaunchArgs.stopOnEntry !== false
599+
: effectiveLaunchArgs.stopOnEntry === true;
590600
try {
591601
const policy = this.sessionStore.selectPolicy(session.language);
592602
// Collect the adapter-assigned ids of all user breakpoints so the
@@ -619,15 +629,16 @@ export abstract class SessionManagerCore extends EventEmitter {
619629
lineComplete && fnComplete ? new Set([...lineIds, ...fnIds]) : undefined;
620630
pauseIntent = getCurrentPauseIntent(session);
621631
// Mode facts for policies whose adapter reports a launch's entry stop
622-
// under a generic reason (rdbg's `pause`, issue #798): a launch or an
623-
// attach, the first stop or a later one, and whether the launch asked
624-
// for an entry stop at all.
632+
// under a generic reason (rdbg's `pause`, issue #798) or an attach's
633+
// own initial stop as an exception (CodeLLDB's Windows break-in, issue
634+
// #817): a launch or an attach, the first stop or a later one, and
635+
// whether the session asked for an initial stop at all.
625636
const normalized = policy.normalizeStopReason?.(rawReason, body, {
626637
pausePending: pauseIntent !== undefined,
627638
...(pauseIntent ? { pauseSource: pauseIntent.source } : {}),
628639
sessionMode: isAttachSession ? 'attach' : 'launch',
629640
firstStop: isFirstStop,
630-
stopOnEntry: effectiveLaunchArgs.stopOnEntry === true,
641+
stopOnEntry,
631642
userBreakpointIds,
632643
functionBreakpointIds: fnComplete ? fnIds : undefined,
633644
lineBreakpointCount: session.breakpoints.size,
@@ -676,7 +687,7 @@ export abstract class SessionManagerCore extends EventEmitter {
676687
const answersUserPause = pauseIntent?.source === 'user';
677688
const shouldAutoContinue =
678689
!isAttachSession &&
679-
!effectiveLaunchArgs.stopOnEntry &&
690+
!stopOnEntry &&
680691
!answersUserPause &&
681692
(reason === 'entry' ||
682693
(firstStopMayBeNonEntry && isFirstStop && !userBreakReasons.has(reason)));

0 commit comments

Comments
 (0)