Repository navigation
fix: answers that mislead at a stop or a failure (#1135, #1140, #1141, #1142, #1139) - #1153
Open
debugmcpdev wants to merge 10 commits into
Open
debugmcpdev wants to merge 10 commits into
debugmcpdev wants to merge 10 commits into
Conversation
…ession is explained by kind (#1141) Two guidance gaps in the inspection tools, both reproduced on main. 1. The includeInternals hint was circular. get_stack_trace adds the "No program frame is active" sentence only when it hid nothing, and that sentence ended "get_stack_trace with includeInternals: true lists the frames" - so a caller who had passed the flag, and was looking at every frame, was told to pass it. ErrorMessages.stopWithoutProgramFrame now takes whether the thread has frames the answer did not show. The stop answers (stop-location.ts) say so when the thread has more than the one frame they show; get_stack_trace never does. A one-frame stack loses the clause everywhere: the sentence already names its only frame. 2. An ended session was explained by one tool of five. get_local_variables added "The program has terminated ... Use restart_debugging"; get_stack_trace, get_scopes, get_variables and evaluate_expression answered the bare "Session is terminated: <id>". The sentence was also wrong for two kinds of session: after detach_from_process on a Python attach it said the program had terminated (it was still running) and offered a restart that restart_debugging then refused. One helper (handlers/shared.ts endedSessionExtras) now words the message for what the session is, by the same three cases restart_debugging tells apart: a child names its parent, an attach session is attached again, a launch is restarted. `error` is unchanged everywhere; only `message` is added. Tests were written first and seen failing: the sentence builder, the stop landing for a one-frame and a four-frame runtime stack, get_stack_trace with and without includeInternals, and each of the five tools on each kind of ended session. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…rom is not program code (#1135) On a stop where no frame of the stack is program code, get_stack_trace and the pause/step answers carried the resolver's verdict (noProgramFrame, "No program frame is active..."), and get_local_variables did not: it returned the runtime frame's variables as plain locals. Reproduced on main with a pause on examples/javascript/pause_test.js (`now = 4210` from process.processTimers) and a step_out of main in hello_world.cpp (`[return value] = 0` and five CRT locals from __tmainCRTStartup). evaluate_expression without a frameId had the same gap. Root cause: extractLocalsFromResolved never read StackTraceResult.noProgramFrame, and the evaluator's anchor fields had no place for it. - LocalVariablesResult carries the verdict (thread, frame count, top frame) when the resolver set it; the handler answers noProgramFrame: true and the sentence the stack gives, plus "The variables shown belong to '<frame>', not to your program." The clause names the frame the variables came from (not the stack's top, after a walk-down) and is left out for an empty list. - The variables stay. They are what the adapter reported, and the return value of main is worth having. - evaluate_expression without a frameId gets the flag and the sentence on anchorNote, ending "The expression was evaluated in that frame." An explicit frameId adds nothing: the caller chose the frame. - The pointer at get_stack_trace follows the rule of the previous commit: only when the thread has more frames than the one named. Not covered: a Rust step_out of main. Its landing (FnOnce::call_once) is not classified as runtime, so the resolver sets no verdict there; that is #1131. Tests written first and seen failing: the data layer on one-frame, two-frame and program-reaching stacks; the handler (flag, clause, walk-down, empty list, note joining); the evaluator. New real-adapter suite javascript-no-program-frame.test.ts drives a real server and js-debug: the three tools agree on the verdict, get_stack_trace with includeInternals: true never mentions includeInternals, and an ended session is explained by all five inspection tools. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…g a value (#1140) A variable absent from the diff base was written as a change from the string "<undefined>": a value-shaped placeholder in JavaScript's vocabulary. Reproduced on main: Python `b = 2` stepped over gave {"b": {"before": "<undefined>", "after": "2"}}, js-debug's return-value entry gave {"Return value": {"before": "<undefined>", "after": "undefined"}}. A variable that left scope dropped out silently, so "unchanged" and "gone" read the same. The shape, decided with JF on 2026-11-13 (one map, explicit kinds, and gone added now so the diff's vocabulary is settled before the response-shape freeze): { "before": "1", "after": "2" } a changed value { "after": "30", "new": true } a variable that appeared { "before": "3", "gone": true } a variable that left scope An empty {} keeps its meaning: nothing visible changed. A flag is set only when it is known. The locals a snapshot shows are capped (20), so a name missing from a list that was cut may only not have fitted: - the diff base (ManagedSession.snapshotDiffBase) records `complete`: no variable omitted, no scope unread. The truncation figures are computed before the diff now; - `new` needs the BASE to have been complete. Otherwise the entry is { after } alone; - `gone` needs the CURRENT list to be complete. Otherwise the variable is not listed, as before. Whether the base was cut does not matter here: a variable that was shown and is absent from a complete list has left scope. (The plan said "both lists complete"; this is the same rule without the half that withheld a known fact.) The adapters' return-value entries stay in the diff, as new. Neither adapter marks them except by name, and measured they do not flicker: debugpy keeps "(return) add" in the frame once it appears; js-debug shows "Return value" at a function's return point, after which the frame changes and the diff rebases. Tests written first and seen failing (stop-snapshot.test.ts: 12 red): the three kinds together, a cut base, a cut current list, an unread scope on either side, upstream omissions, a merely shortened value, completeness carried from one diff to the next, the literal text "<undefined>" as a value, exotic names both ways, the empty read. Through a real step answer in session-manager-snapshot.test.ts. Real adapters: debugpy (`new`) and js-debug leaving a block (`gone`), in two new integration suites. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… the resolver would find (#1142) assertLineContent appended "Or address by content: set_breakpoint {statement: "<expected>"}" to every mismatch without asking whether that text resolves. Reproduced on main: line 11 of simple_test.py with expectedContent "THIS TEXT IS WRONG" earned the hint, and following it answered `statement "THIS TEXT IS WRONG" not found`. The sentence is now built from resolveStatement(lines, expected, path, line) - the function set_breakpoint {statement} itself runs - so the hint and the resolver cannot disagree: - one match: "...{statement: "a, b = b, a"} - that text is on line 11." - several: nearLine (the line asked for) is added and the lines are listed, with the one it picks; more than five are summarised. - none: no statement hint; "That text matches no line of the file, so it cannot be addressed by content (statement) either." - a comment, a blank or text spanning lines: nothing. The resolver would refuse it for what it is. A matched line is never the line that just failed: the resolver's substring pass is a subset of what assertLineContent accepts for that line. Tests written first and seen failing (8 red): each of the four outcomes, a comment-only line never named, the cap on a long list, and a property over nine lines and nine expectations - every hint emitted, fed back to resolveStatement, lands on the line it names, and where no hint is given the resolver finds nothing either. At the tool: an off-by-one refusal names the line and the same text as `statement` then binds there. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…n adapter that never existed (#1139) A Python or Ruby attach starts nothing: the worker connects straight to the host and port the caller gave (the policies' `mode: 'connect'`). Every failure message was written for a spawned adapter. Reproduced on main: - a listener that accepts and stays silent (the wrong port) waited 30 s and answered "...an adapter-side protocol stall, not a missing install - the adapter started and accepted the connection. Retrying usually succeeds"; - a real HTTP port (`python -m http.server`, the issue's own example) hangs up on the DAP frame, a different path: "Critical initialization error: DAP client disconnected [Executable: python | adapter PID=none exitCode=n/a]". Root cause: ProxyInitProgress had no fact for connect mode, and the worker's init-failure report described an executable and an adapter process whatever the mode. - The worker says what it dialed: in its connect branch it sends a `direct_connect` status (host, port, and the policy's phrase for what should be listening there - a new optional `listener` on the connect spawn config, supplied by the Python and Ruby policies). - ProxyManager records it as initProgress.directConnect, which also reaches the failed answer's data. initProgress is reset per start(). - proxyInitTimeout words every branch for a direct connection, keyed on what is pending: while `initialize` is unanswered the endpoint has not spoken DAP, so the message names host:port, says nothing was started, and says to check that it is the listener meant, and that retrying the same port stalls the same way. Once `initialize` was answered the endpoint is a debug server and the stall is worded as its own. A connection never made no longer says to check that the adapter is installed. - The worker's own report for a direct connection: an endpoint that never answered `initialize` in DAP (it hung up, or the request died on the wire) gets the same two sentences; any other failure ends "[direct connect to host:port; no adapter process was started]". A refusal in DAP (DapResponseError) is an answer, and keeps the adapter's own words. The spawned-adapter sentences are byte for byte what they were (pinned). The 30 s deadline is untouched: a shorter one for direct connect would be a hold sized without a measurement. Tests written first and seen failing (13 red): every branch of the message, with the four spawned-adapter sentences pinned whole; status -> progress -> message in the proxy manager, and a status naming no usable endpoint; the worker in connect mode (status sent before the transport connects, nothing spawned; hang-up, DAP refusal, connection never made); both policies. New real-server suite python-attach-wrong-port.test.ts: a listener that hangs up on the first bytes, the exact answer. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…e endpoint report too (#1139) Found by the independent review of this branch. A Python or Ruby attach to a listener that resets the connection (a forwarder with nothing behind it) still answered the bare "DAP client error: read ECONNRESET": the reset reaches the worker as the socket's error event before the pending `initialize` request rejects, the worker sent that first, and the parent fails the attach on the first error it receives. The init catch composed the endpoint report afterwards, for nobody. While a direct-connect attach is still initializing, the socket-error handler now sends the init-failure report itself — the same sentence a hang-up gets, with the socket's own reason — and latches it, and the init catch honours the latch, so the parent gets one report. Measured against the built server with a listener that resets on the first bytes and one that resets on accept: both answered "DAP client error: read|write ECONNRESET" before, and "127.0.0.1:<port> never answered the DAP "initialize" request (read|write ECONNRESET). Nothing was started for this attach: …" after. Unchanged, and pinned: a spawned adapter's two reports for a reset during the handshake, and the plain report for a socket error on an established direct connection. Also pins what the review's mutation pass found unpinned: once `initialize` was answered, a later failure does not doubt that the endpoint is a debug server (dropping the `initializeAnswered` check, or never setting it, both survived the unit project before). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…y the check's own rules (#1142) Found by the independent review of this branch. When the statement resolver did not find the expected text, the refusal said "That text matches no line of the file" — the resolver's verdict worded as a fact about the file. The resolver ignores comment lines and whatever follows a `//` or `#`; expectedContent does not. So text that sat in a trailing comment one line away, or past a `//` inside a string, or an expectation carrying a comment the file no longer has, was declared absent while the same expectedContent would have passed on the right line. The line-matching rules move into one function, `matchLineContent`, which `assertLineContent` reads for the line the caller named and the mismatch message reads for every other line. With no statement form to offer, the message names the lines the check passes on ("That expectedContent matches line 3."), and says "matches no line of the file" only when it passes on none. A comment expectation gets the same sentence (it used to get nothing); text spanning lines still gets nothing. The property test now holds the message to that: every line it names passes the check, and "no line" means none does. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ect four doc claims (#1135, #1139, #1140, #1142) Tests (each checked against the reviewer's surviving mutant, which it now kills): - evaluate_expression on a stack that reaches the program carries no noProgramFrame and no note (#1135); - the never-connected direct-connect sentence without a policy phrase, and each half of the parent's endpoint guard on its own (#1139); - a return-value entry is asserted as `new` when it appears (#1140); - the two snapshot-diff suites land a launch that answers pending with wait_for_stop instead of assuming the launch hold was enough (#1140). Docs: - locals and evaluate carry the sentence of the stop's own answer, not get_stack_trace's (which words a hidden-frames stack differently), and the example is a launched program idle between timers (#1135); - `data.initProgress.directConnect` rides only on the 30 s stall's answer; the reset case is described (#1139); - the flagless `{after}` entry is in the skill text, and a frame whose every local left scope is documented as saying nothing (#1140); - the expectedContent mismatch sentence as it now reads (#1142); - `getLocalVariables`' return type in the API reference (#1135). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ted by endpoint too (#1139) Found by the second review, of the reset fix. That fix covered a reset, which reaches the worker as the client's `error` event. A hang-up reaches it as the `close` event, and the worker answered a close with the connection-closed status. Before `initialize` is answered the init catch happens to report first; after it, with debugpy's attach order, the status won, and the parent read it as the proxy exiting: Failed to attach: Proxy exited during initialization. Code: null, Signal: undefined Stderr output (last 10 of 39 lines): …worker debug lines… A drop under the configuration requests answered "Error in DAP sequence: DAP client disconnected". Neither names the endpoint or says that nothing was started. Both need a listener that answers `initialize` and then goes away: a debug server, or a forwarder to one, that dies mid-handshake. The two socket events now share one path (`reportDirectConnectDrop`): the first of them during a direct-connect handshake sends the init-failure report and latches it; a later event, the init catch and the configuration phase send nothing more. The client emits `close` before it rejects what was pending, so the report is first by construction, not by which continuation runs sooner. Measured against the built server with a scripted DAP listener (answers `initialize`, then drops), Python and Ruby: hang-up while attach is pending Proxy exited during initialization… (python) hang-up during configuration Error in DAP sequence: DAP client disconnected both, after Critical initialization error: DAP client disconnected [direct connect to 127.0.0.1:<port>; no adapter process was started] Unchanged, and pinned: a close on an established direct connection sends no error, and a close after a failure already reported adds nothing. A reset after the worker's handshake completed, while the attach is still being verified, still answers the plain "DAP client error: read ECONNRESET": that is past the handshake this issue is about. Also pins the cap on the list of lines in "That expectedContent matches lines …" (#1142), which the same review found unpinned, and makes the changelog say what each of the three first-contact failures reads. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The third review, of the hang-up fix, found the changelog claiming more than was measured: a debug server that sends a DAP `terminated` event and then closes during the handshake is still answered "Proxy exited during initialization…", because the worker is already shutting down when the socket closes. Not a regression, and seen only from a scripted listener; it is #1152 now. The changelog and the troubleshooting guide say so. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cluster 2 of the bugs gating 0.27.0: five answers that are right about the debugger's state and wrong about what the caller should conclude from it.
Closes #1135, closes #1140, closes #1141, closes #1142, closes #1139.
One commit per issue, then five commits from the reviews (three fixes, the tests and doc corrections they asked for, and one doc limit). Every row below was reproduced before its fix and run again after: on
main(0f1eb6ac) through the dev proxy, except the two #1139 rows the reviews found (a reset, and a hang-up afterinitialize), which were measured on this branch's own build with a scripted listener.What was wrong, and what changes
#1141 —
get_stack_tracepointed at itself, and an ended session was explained by one tool of fiveThe
includeInternalshint.get_stack_traceadds "No program frame is active…" only when it hid nothing, and that sentence ended "get_stack_trace with includeInternals: true lists the frames" — said to a caller who had passed the flag and was looking at every frame.ErrorMessages.stopWithoutProgramFramenow takes whether the thread has frames the answer did not show.get_stack_tracenever says the clause. The pause and step answers say it when the thread has more than the one frame they show.An ended session.
get_local_variablesexplained it;get_stack_trace,get_scopes,get_variablesandevaluate_expressionanswered the bareSession is terminated: <id>. The one sentence that existed was also wrong for two kinds of session: afterdetach_from_processon a Python attach it said "The program has terminated… Use restart_debugging" while the process was still running andrestart_debuggingrefused it (measured).handlers/shared.tsendedSessionExtras) addsmessage, worded by the three casesrestart_debuggingitself tells apart: a child names its parent, an attach session is attached again, a launch is restarted.erroris unchanged everywhere.#1135 —
get_local_variablesanswered a runtime frame's variables as plain localsextractLocalsFromResolvednever read the resolver'snoProgramFrameverdict.get_local_variablesnow carriesnoProgramFrame: trueand the sentence the pause or step answer gave for that stop ("No program frame is active…"), plus "The variables shown belong to '', not to your program."evaluate_expressionwithoutframeIdhad the same gap and gets the same flag, ending "The expression was evaluated in that frame." An explicitframeIdadds nothing.pause_test.jsnow = 4210, no flag, no notestep_outofmain[return value] = 0and five CRT locals, no flagstep_outofmain[return value] = 0from___lldb_unnamed_symbol_2a580, no flag (the issue's report; not re-run by me onmain)step_outofmainThe Rust row is not this bug: its landing frame (
core::ops::function::FnOnce::call_once) is not classified as runtime, soget_stack_tracehas no verdict there either. That is #1131; I left a comment there.#1140 —
changedVariablesinvented a value for a variable that was not thereA name absent from the diff base was written as a change from the string
"<undefined>". The shape below is the one the maintainer chose during planning (one map, explicit kinds,goneadded now):{}still means nothing visible changed.newneeds a complete previous list; without one the entry is{ "after": … }alone.goneneeds a complete current list; without one the variable is not listed, as before.new.#1142 — the
statementhint could not be followedAn
expectedContentmismatch ended "Or address by content: set_breakpoint {statement: ""}" whether or not the text was in the file.resolveStatement's own answer for that text: on one line it names the line; on several it addsnearLineand lists them.expectedContentcheck passes on ("That expectedContent matches line 3."), or says it matches no line of the file.matchLineContent, whichassertLineContentnow reads too). The resolver ignores comment lines and whatever follows a//or#;expectedContentdoes not. The first version of this fix said "matches no line of the file" for the resolver's reasons, so text sitting in a trailing comment one line away was called absent. The review caught it.#1139 — a direct-connect attach described an adapter that never existed
A Python or Ruby attach starts nothing; the worker connects to the caller's host and port. Every failure was worded for a spawned adapter, or not worded at all:
python -m http.server, about 1 s)initialize, then hangs up (about 1 s)direct_connectstatus with what it dialed; the policy supplies what should be listening there. The answer for a 30 s stall carries it asdata.initProgress.directConnect; the answers that fail sooner name the endpoint in the message only.error, a hang-up asclose. The parent fails the attach on the first thing it is sent, and for a close that used to be the connection-closed status. During a direct-connect handshake the first socket event now sends the endpoint report, once (reportDirectConnectDrop).initializepaths. The first version of the fix covered only a hang-up beforeinitializewas answered.initializewas answered the endpoint is a debug server, and the port is no longer blamed.Behaviour a client can observe
snapshot.changedVariablesno longer hasbefore; it hasnew: true. A variable that left scope is now listed, withgone: true.noProgramFrameonget_local_variablesandevaluate_expression;messageon four more tools for an ended session;data.initProgress.directConnecton a direct-connect attach that stalls for the 30 s.Evidence
Each fix's unit tests were run failing before the fix. The tests that pin behaviour a review found unpinned passed when written, so each was checked against the reviewer's surviving mutant, which it now kills.
typecheck:all,lint,check-docs,changelog:check9e15d749The four real-adapter suites were written after their fixes, so their "before" is the live answers on
mainquoted above, not a failing test run. The reset and hang-up-after-initializecases of #1139 are the exception: their tests failed first (for the hang-up, the real-server test too), and their "before" was measured on this branch's own build.Review
Three independent reviews, each by a fresh agent with a shell. Each ran the built server against its own listeners, ran a mutation pass, and restored every edit.
1. The five issue commits. No Critical findings; four Important, all fixed:
57b3281e).//inside a string (fc211017).evaluate_expressionis not flagged (get_local_variables returns a runtime frame's variables with no note when no program frame is active (JS, C/C++, Rust) #1135), and a failure afterinitializewas answered does not doubt the endpoint (attach to a port that never answers DAP: the 30 s stall message says 'the adapter started… retrying usually succeeds' — for a direct-connect attach the likely cause is the wrong port #1139). Both are pinned.bf157654).2. The two fix commits above. No Critical findings; one Important, fixed:
initializestill lost the endpoint (6fa2651c).3. The last fix commit (
6fa2651c). No Critical findings and no regression. One Important, not fixed here:initialize, then sends a DAPterminatedevent and closes, is still answered "Proxy exited during initialization. Code: 0…" with worker stderr. The worker is already shutting down when the socket closes, so it has no report to send first. Re-run by me; not seen from a real debugpy or rdbg.9e15d749).Seen outside the branch by the first reviewer, both reproduced by me, both for cluster 3:
detach_from_processon a launched Python session answers "process still running", and the process is dead.step_intofromsimple_test.js:11never lands, and the program runs to its exit.Calls I made
localsout on such a stop; the two now agree on what the frame is.evaluate_expression, which the issue does not name.get_scopes,get_variablesandevaluate_expression, and corrects the sentence for attach and child sessions.goneneeds only the current list to be complete. The plan said both lists. A variable that was shown and is absent from a complete list has left scope, whatever the earlier list omitted.(return) addstays in the frame once it appears, and js-debug'sReturn valueshows at a return point, after which the frame changes. Neither flickers, so no exception was added.expectedContent: "# setup"on the wrong line used to get no sentence; it now gets the line the comment is on. No statement is offered for it.Known limits
statementhint names where the resolver finds the text ("that text is on line 5").expectedContentmay also pass on a line the resolver ignores, such as a comment; the hint does not list those.terminated, then close), and a configuration request refused in DAP ("Error in DAP sequence: …", the server's words without the endpoint). Both are direct-connect attach: a debug server that ends the session mid-handshake still answers 'Proxy exited during initialization', and a refused configuration request names no endpoint (follow-up to #1139) #1152.attach_to_processis still verifying the attach, still answers the plain "DAP client error: read ECONNRESET". The endpoint wording stops at the handshake.list_threads, the step tools andpause_executionstill answer the bareSession is terminated: <id>(asdocs/tool-reference.mdsays).🤖 Generated with Claude Code