Skip to content

feat(launch): one launch contract for every adapter — a short hold, then pending (#823, #826, #851) - #857

Merged
debugmcpdev merged 2 commits into
mainfrom
fix/823-launch-contract
Oct 5, 2026
Merged

debugmcpdev merged 2 commits into
mainfrom
fix/823-launch-contract

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Closes #823. Closes #826. Closes #851.

What changes

start_debugging (and restart_debugging) now answer by one rule, the same in every language: once the program is launched, the call holds its answer for a short window and reports whichever comes first —

Before this PR the answer depended on the adapter and on what was armed:

before after
JavaScript, breakpoint reached as the program starts paused (it landed inside a 500 ms window) paused
JavaScript, breakpoint reached later bare running, no pending, no mention of the breakpoint running + pending: true, names the breakpoint
other 8 languages, breakpoint reached within ~1 s paused paused
other 8 languages, breakpoint reached later, or needing an outside trigger call held open up to 30 s, then pending pending after the hold; wait_for_stop collects the stop
nothing armed, program keeps running pending after 5 s (JavaScript: bare running at once) pending after the hold
stopOnEntry: true paused at the entry stop unchanged — waited for up to 30 s, because that stop is certain to come

The hold is not a limit on the program: nothing is cancelled when it elapses, and breakpoints and exception filters stay armed for the life of the process. It only decides whether the launch call reports the first stop itself. The pending message no longer states a duration, and the tool description does not either: the number is a tunable (launchHoldMs), not part of the contract.

How the hold was sized

Measured, not picked. A nested mcp-debugger was launched under the debugger and instrumented with logpoints on the launcher's post-handshake line and on the core's user-visible-stop branch; each language's example was then launched through it with a breakpoint on an early line. "Latency" is the first stop minus the moment the hold begins.

language idle machine, 6 runs (min / median / max ms) all 16 cores saturated, 4 runs
Python (debugpy) 162 / 167 / 172 140 / 184 / 208
JavaScript (js-debug) −11 / −10 / −9 (the stop lands before the handshake returns) −10 / −8 / −8
Ruby (rdbg) 0 / 1 / 1 —
Go (Delve) 4 / 5 / 6 626, 678
Rust (CodeLLDB) 1 / 2 / 4 —
Java (JDI bridge) 12 / 13 / 20 27 / 38 / 46
.NET (netcoredbg) 81 / 87 / 129 335 / 444 / 455
C/C++ (CodeLLDB) 4 / 5 / 6 —
COBOL (CodeLLDB shim) 16 / 17 / 23 25 / 45 / 64

Rule: 1000 ms if the slowest idle first stop is under 500 ms. It is 172 ms, so the hold is 1000 ms — 5.8× the slowest idle case and 1.5× the slowest saturated one. Toolchain work (javac for the JDI bridge, cobc, the g++ auto-compile) happens before the handshake and is outside the hold.

One measurement looked like an outlier and was not: Java took a constant 2,020–2,030 ms because examples/java/HelloWorld.java sleeps 2 s before the line the smoke tests break on. That is the "breakpoint reached after the hold" case: the launch answers pending, and the smoke tests, which poll for the pause, pass unchanged.

The other way a launch is answered: the program ends

The same hold decides whether a short program's end is in the launch answer. Each language's hello-world example launched with nothing armed, three runs; the figure is the session's running -> stopped transition minus initializing -> running, from the server log:

language end reported after launch (ms) launch answers
Go 9, 10, 11 stopped
Ruby 26, 27, 31 stopped
.NET 106, 108, 110 stopped
JavaScript 149, 151, 156 stopped
Python 844, 852, 860 stopped
Java 2,039–2,052 (the example's own 2 s sleep) pending
Rust, C/C++, COBOL — Windows only 2,009–2,030 pending; wait_for_stop returns stopped ~1 s later

Two things in that table are worth knowing before merging:

  • Python is inside the hold, but not by much. debugpy itself takes ~550 ms between the last thread's exit and its exited event, so a trivial script's end is reported ~850 ms after launch. On a loaded machine that launch will answer pending and wait_for_stop will collect the exit. The contract covers it; a 2 s hold would put it well inside. launchHoldMs is one number.
  • On Windows the CodeLLDB adapters report every program's end 2 s late, and that is ours, not CodeLLDB's: the proxy holds exited until the adapter's stdio pipes close, CodeLLDB never closes them while it lives, and the wait runs to its 2 s backstop every time. Filed as Windows: the end of a Rust/C++/COBOL program is reported 2 s late — the stdio drain waits for a pipe close CodeLLDB never produces #856 with the trace. It predates this PR (the old 5 s window absorbed it); with a 1 s hold a short Rust/C++/COBOL program on Windows is answered pending although it has ended. Not fixed here — it is the proxy's exit path, which deserves its own change.

How

Verified live

Against the built server, with the scripts from the issues:

Tests

  • launch-readiness.test.ts rewritten (14 cases, including the auto-continued entry stop and the beforeHold hook).
  • session-manager-launch-contract.test.ts (new, 16 cases) through the real SessionManager: the hold with and without anything armed, exit inside the hold, entry-stop launches waiting past the hold, start_debugging arms its readiness window once, before the wait — a breakpoint set during the 5 s grace still gets the unarmed answer #826, A breakpoint set while start_debugging is still starting is not sent to the adapter until the launch's readiness wait ends (30 s) #851 (set and remove), the re-send rule (none to a running launch, kept for a paused one), JavaScript following the same contract, restart.
  • The launcher cases in session-manager-operations-coverage.test.ts now move the session's state the way the core does instead of hooking the old wait's proxy listeners.
  • CI-run integration: JavaScript (a breakpoint reached only later → pending → wait_for_stop; a module-load breakpoint), Ruby (the unarmed answer; the first-breakpoint launch follows the contract).
  • The host e2e suites were run locally on Windows for every language on the final build — 38 files, 285 tests, all passing and none of them edited: the tests that wait for a late breakpoint already poll for the pause. (CI runs only the COBOL host lane and the container suite from that set.)

🤖 Generated with Claude Code

…hen pending

start_debugging and restart_debugging now answer by one rule in every
language: once the program is launched the call holds its answer briefly
and reports whichever comes first — the program stops (paused, with the
reason), the program ends (stopped, with the exit code), or the hold
elapses (running, pending: true, naming what is armed and pointing at
wait_for_stop).

- launch-readiness.ts is rebuilt on waitForSessionState as two waits on
  the session's state: until the program is launched (launchReadyCeilingMs,
  which also covers a stopOnEntry launch's entry stop), then the hold
  (launchHoldMs, 1000 ms — sized from measured launch-to-first-stop
  latency, at most 172 ms idle and 678 ms saturated across the nine
  language adapters).
- js-debug no longer answers `running` at once (#823): AdapterPolicy
  .isSessionReady is removed from the interface and the nine policies.
- The wait no longer depends on what is armed; describeLaunchArming is read
  when the answer is built and only words it (#826).
- Breakpoints set, removed or cleared while the launch was starting are
  sent as soon as the program is launched, before the hold (#851).
- The fresh-echo re-send after the launch is made only to a paused
  program: one still running when the hold elapses may already have ended
  with its exit not yet forwarded (#856), and the re-send then un-verified
  a logpoint that had fired.

Closes #823. Closes #826. Closes #851.

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

codecov Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.76543% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/session/launch/debug-launcher.ts 96.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

…reaches the caller

Review follow-up on the "re-send only when paused" rule. The worker's
pre-launch setFunctionBreakpoints left a rejection to the parent's
post-launch re-send to surface; a launch answered while running no longer
makes that re-send, so the answer fell back to "could not resolve the
name — check the symbol name", which is the wrong advice for a refusal.

- The worker echoes any error answer to the pre-launch
  setFunctionBreakpoints (it did so only under noDebug); a transport
  failure or timeout is still not echoed.
- buildFunctionBreakpointLaunchWarning reports a refusal-stamped record as
  a refusal, in the adapter's words and under every policy, and keeps the
  symbol-name sentence for names the adapter could not resolve.
- Comments that described the re-send as what surfaces the state say what
  happens now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@debugmcpdev

Copy link
Copy Markdown
Collaborator Author

Code review

Five reviewers (CLAUDE.md compliance, bug scan, git history, earlier PR feedback, code comments) went over 54cd41d. No finding reached the reporting threshold; three related ones scored 75 and are fixed in 212e9d6 because they share one cause, and one of them hid a real loss.

The cause: the launch's fresh-echo re-send is now made only to a paused program.

// that answer rather than clearing and re-setting a second time.
let resync: ResyncOutcome = earlyResync ?? { warnings: [], functionBreakpointsFailed: false };
if (!earlyResync && finalState === SessionState.PAUSED && !debuggerOff) {
resync = await this.breakpoints.resyncAll(finalSession, { forceFreshEcho: true });
}

  1. Two comments still said the unbound-function-breakpoint warning reads state "fresh after the re-sync above", which no longer holds for a launch answered running. Both now say what the warning reads in each case.

// Unbound-at-launch warning (issue #308): a name the adapter could not
// resolve is reported here instead of failing silently at "the program
// never stopped". The verified state it reads is fresh from the re-sync
// above for a launch that paused; for one answered while running it is
// what the worker's pre-launch send and the adapter's breakpoint events
// left, a rejected request included — the worker echoes that, and it is
// quoted as a refusal (issue #856). Suppressed for bind-late adapters
// (js/java), where unverified-at-launch is the designed deferral path.
// Withheld when the post-launch function-breakpoint re-send failed —

  1. The worker's comments left a rejected pre-launch setFunctionBreakpoints to "the post-launch re-sync" to surface. For a launch answered running nothing re-asks any more, so the answer fell back to "could not resolve the name — check the symbol name": the wrong advice for a refusal. The worker now echoes any error answer to that request (it did so only under noDebug), and the warning reports a refusal-stamped record as a refusal, in the adapter's words. A transport failure or timeout is still not echoed.

);
this.noticeRefusalUnderNoDebug('setFunctionBreakpoints', err);
if (err instanceof DapResponseError) {
// The adapter answered the request with an error — under noDebug
// (issue #750), or because it will not take function breakpoints.
// That is its answer for every one of them: echo it so the store
// carries it, as the line breakpoints get. A transport failure or a
// timeout is nobody's answer and is not echoed.
this.sendStatusSafely('function_breakpoints_synced', {
functionBreakpoints: this.currentInitPayload.initialFunctionBreakpoints.map((bp) => ({
name: bp.name,
verified: false,
message: err.message,
refused: true
}))
});
}

/**
* Launch-time unbound-function-breakpoint warning (issue #308). Reads the
* records as they stand when the launch is answered: freshly re-sent for a
* launch that paused, and otherwise as the worker's pre-launch send and the
* adapter's breakpoint events left them (issue #856).
*
* Two things can leave a function breakpoint unbound, and they are told
* apart. A name the adapter could not resolve gets the "check the symbol
* name" sentence — except under a bind-late policy (js/java), where
* unverified-at-launch is the designed deferral, not a failure. A request
* the adapter refused outright (the worker echoes a rejected pre-launch set,
* stamped as a refusal) is reported as that, in the adapter's words and
* under every policy: it is not a name problem, and the advice would send
* the caller looking for a typo.
*
* The policy is a parameter because the caller resolves it from the session
* store, whose lookup throws for an unknown language.
*/
export function buildFunctionBreakpointLaunchWarning(
session: Pick<ManagedSession, 'functionBreakpoints'>,
policy: AdapterPolicy | undefined
): string | undefined {
if ((session.functionBreakpoints?.size ?? 0) === 0) {
return undefined;
}
const bindLate = policy?.functionBreakpointsBindLate === true;
/** The names each distinct refusal was given for, in store order. */
const refused = new Map<string, string[]>();
const unresolved: string[] = [];
for (const bp of session.functionBreakpoints.values()) {
if (bp.verified) {
continue;
}
if (bp.messageOrigin === 'refusal' && bp.message !== undefined) {
refused.set(bp.message, [...(refused.get(bp.message) ?? []), `'${bp.functionName}'`]);
continue;
}
if (bindLate) {
continue;
}
const hint = policy?.functionBreakpointNameHint?.(bp.functionName) ?? bp.message;
unresolved.push(`'${bp.functionName}'${hint ? ` (${hint})` : ''}`);
}
const sentences = [...refused].map(
([refusal, names]) =>
`The debugger refused function breakpoint(s) ${names.join(', ')}: ${refusal} — ` +
`the program will not stop there`
);
if (unresolved.length > 0) {
sentences.push(
`Function breakpoint(s) not bound at launch: ${unresolved.join('; ')}. ` +
`The adapter could not resolve the name, so the program will not stop there — ` +
`check the symbol name; list_breakpoints shows the current state`
);
}
return sentences.length > 0 ? sentences.join('; ') : undefined;

Checked and not an issue: a reviewer asked whether a launch answered running could now carry a spurious "function breakpoint not bound" warning. Run live with a function breakpoint behind a 2 s sleep in Python, C++, Go, COBOL, .NET, Rust and Java: no warning, verified: true, and wait_for_stop returns function breakpoint. The JavaScript run of the same check exposed an unrelated bug that is on main too, filed as #858.

After the follow-up: unit 335 files / 6,657 tests, lint, typecheck:all, integration, and the function-breakpoint, logpoint, restart and exception e2e suites (10 files, 97 tests) pass locally.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@debugmcpdev
debugmcpdev merged commit 20e809b into main Oct 5, 2026
11 checks passed
@debugmcpdev
debugmcpdev deleted the fix/823-launch-contract branch October 5, 2026 18:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment