diff --git a/src/features/debugger/__tests__/actions.test.ts b/src/features/debugger/__tests__/actions.test.ts index 295b6944..0e89449d 100644 --- a/src/features/debugger/__tests__/actions.test.ts +++ b/src/features/debugger/__tests__/actions.test.ts @@ -6,6 +6,7 @@ const ipc = vi.hoisted(() => { return { ok, debugStep: vi.fn(() => ok(null)), + debugResume: vi.fn(() => ok(null)), debugForceStepOver: vi.fn(() => ok(null)), debugRunTo: vi.fn(() => ok(null)), debugStepInTargets: vi.fn(() => ok([] as Array<{ id: number; label: string }>)), @@ -54,7 +55,7 @@ import type { DebugCapabilities } from "@/platform/ipc"; import { registerDebugActions } from "../actions"; import { breakpointsOf, loadBreakpoints } from "../breakpoints/store"; import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special"; -import { publishContext, useDebug, type DebugSession } from "../state"; +import { publishContext, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state"; import { focusDebugView, blurDebugView } from "../ui/focusedView"; import { useOverlays } from "../ui/popoverStore"; @@ -97,6 +98,7 @@ function paused(p: Partial = {}) { }; useDebug.setState({ sessions: { 3: s }, active: 3 }); publishContext(); + updateExecutionPoint(); } function caret(path: string, language: string, doc: string, line: number) { @@ -154,15 +156,41 @@ describe("debugger actions (IDEA parity)", () => { expect(getAction("debug.jumpToSource")!.keyWhen).toBe("debugFramesFocus || debugVariablesFocus"); }); + it.each([ + ["debug.resume", "debugResume"], + ["debug.stepOver", "debugStep"], + ["debug.stepInto", "debugStep"], + ["debug.stepOut", "debugStep"], + ["debug.forceStepOver", "debugForceStepOver"], + ["debug.forceStepInto", "debugStep"], + ["debug.smartStepInto", "debugStep"], + ["debug.runToCursor", "debugRunTo"], + ["debug.forceRunToCursor", "debugRunTo"], + ["debug.resetFrame", "debugRestartFrame"], + ] as const)("%s clears the old execution point before its request", async (id, command) => { + paused({ caps: caps({ restartFrame: true }) }); + caret("/p/app.py", "python", "a\nb\nc", 2); + const request = ipc[command].mockImplementationOnce(() => { + expect(useExecutionPoint.getState().path).toBeNull(); + expect(useDebug.getState().sessions[3]?.status).toBe("running"); + return ipc.ok(null); + }); + await executeAction(id); + expect(request).toHaveBeenCalledOnce(); + }); + it("Force Step Over / Force Step Into / Force Run to Cursor", async () => { paused(); caret("/p/app.py", "python", "a\nb\nc\nd", 2); await executeAction("debug.forceStepOver"); expect(ipc.debugForceStepOver).toHaveBeenCalledWith(3, 1); + paused(); await executeAction("debug.forceStepInto"); expect(ipc.debugStep).toHaveBeenCalledWith(3, 1, "into"); + paused(); await executeAction("debug.forceRunToCursor"); expect(ipc.debugRunTo).toHaveBeenCalledWith(3, 1, "/p/app.py", 2, true); + paused(); await executeAction("debug.runToCursor"); expect(ipc.debugRunTo).toHaveBeenLastCalledWith(3, 1, "/p/app.py", 2, false); }); @@ -180,6 +208,7 @@ describe("debugger actions (IDEA parity)", () => { expect(ipc.debugStepInTargets).toHaveBeenCalledWith(3, 71); expect(useOverlays.getState().smartStep?.targets.map((t) => t.label)).toEqual(["str(i)", "parse(str(i))"]); + paused({ caps: caps({ stepInTargets: true }) }); ipc.debugStepInTargets.mockReturnValueOnce(ipc.ok([{ id: 5, label: "parse()" }])); await executeAction("debug.smartStepInto"); await settle(); diff --git a/src/features/debugger/__tests__/editor.test.ts b/src/features/debugger/__tests__/editor.test.ts index b975cca6..c7967b7d 100644 --- a/src/features/debugger/__tests__/editor.test.ts +++ b/src/features/debugger/__tests__/editor.test.ts @@ -166,6 +166,60 @@ describe("breakpoint gutter clicks", () => { }); describe("execution point", () => { + it("keeps the execution line synchronized if inline rendering throws", async () => { + useExecutionPoint.setState({ path: null, values: {} }); + const errors: unknown[] = []; + const view = new EditorView({ + state: EditorState.create({ + doc: "x\nx\nx", + extensions: [executionPoint(ctx), EditorView.exceptionSink.of((e) => errors.push(e))], + }), + parent: document.body, + }); + views.push(view); + const values: Record = Object.defineProperty({}, "x", { + enumerable: true, + get() { + throw new Error("inline rendering failed"); + }, + }); + useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values }); + await flush(); + expect(errors).toHaveLength(1); + useExecutionPoint.setState({ line: 2, values: {} }); + await flush(); + expect(view.dom.querySelectorAll(".cm-line")[2]!.className).toContain(styles.execLine); + useExecutionPoint.setState({ path: null }); + await flush(); + expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull(); + }); + + it("keeps refreshing the execution line around Object.prototype names", async () => { + useExecutionPoint.setState({ path: null, values: {} }); + const view = makeView("String text = Integer.toString(40);\nint count = 40;\ncount++;\ncount++;"); + useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { text: "40" } }); + await flush(); + useExecutionPoint.setState({ line: 3, values: { text: "40", count: "42" } }); + await flush(); + expect(view.dom.querySelector(`.${styles.execLine}`)?.textContent).toContain("count++"); + useExecutionPoint.setState({ path: null, values: {} }); + await flush(); + expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull(); + }); + + it.each([2, null])("refreshes a cached editor state when remounted at %s", async (line) => { + useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values: {} }); + const view = makeView("first\nsecond\nthird"); + const state = view.state; + view.destroy(); + views.splice(views.indexOf(view), 1); + useExecutionPoint.setState({ path: line == null ? null : ctx.path, line: line ?? 0, values: {} }); + const restored = new EditorView({ state, parent: document.body }); + views.push(restored); + await flush(); + expect(restored.dom.querySelector(`.${styles.execLine}`)?.textContent ?? null).toBe(line == null ? null : "third"); + }); + it("highlights the suspended line and shows inline values", async () => { const view = makeView("x = 1\ny = x + 1\nprint(y)"); useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { x: "1", y: "2" } }); diff --git a/src/features/debugger/__tests__/executionState.test.ts b/src/features/debugger/__tests__/executionState.test.ts new file mode 100644 index 00000000..9cbbde52 --- /dev/null +++ b/src/features/debugger/__tests__/executionState.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StackFrame, Variable } from "@/platform/ipc"; + +const ipc = vi.hoisted(() => { + const ok = (data: T) => ({ status: "ok" as const, data }); + return { + ok, + debugCapabilities: vi.fn(async () => ok(null)), + debugThreads: vi.fn(async () => + ok([ + { id: 5, name: "http-nio-8179-exec-5" }, + { id: 1, name: "main" }, + ]), + ), + debugStackTrace: vi.fn(async () => ok([] as StackFrame[])), + debugScopes: vi.fn(async () => ok([{ name: "Locals", variablesReference: 10, expensive: false }])), + debugVariables: vi.fn(async () => ok([] as Variable[])), + }; +}); +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + const { ok: _ok, ...commands } = ipc; + return { ...real, commands: { ...real.commands, ...commands } }; +}); +vi.mock("@/workbench/contributions", async (orig) => ({ + ...(await orig()), + maybeEditorService: () => ({ openFile: vi.fn(async () => {}) }), +})); +import { adoptSession, handleDebugEvent, selectThread, resumeExecution, useDebug, useExecutionPoint } from "../state"; + +const path = "/proj/MsMemberUtils.java"; +const frame = (line: number, id = 100): StackFrame => ({ + id, + name: "queryMsTicket", + path, + line, + column: 0, + presentationHint: null, +}); +const stopped = (threadId = 5, allThreadsStopped = false) => + handleDebugEvent(1, { kind: "stopped", threadId, allThreadsStopped, reason: "step", description: null }); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +beforeEach(() => { + vi.clearAllMocks(); + useDebug.setState({ sessions: {}, active: null, stopSeq: 0 }); + useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} }); + adoptSession(1, { adapter: "java-debug" }); + ipc.debugStackTrace.mockResolvedValue(ipc.ok([frame(6840)])); + ipc.debugVariables.mockResolvedValue(ipc.ok([])); +}); + +describe("execution point state", () => { + it.each([false, true])("selects each stopped thread and its fresh top frame (all=%s)", async (all) => { + stopped(5, all); + await settle(); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6840 }); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(5, all); + await settle(); + expect(useDebug.getState().sessions[1]).toMatchObject({ selectedThread: 5, selectedFrame: 100 }); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(9)])); + stopped(1, all); + await settle(); + expect(useDebug.getState().sessions[1]?.selectedThread).toBe(1); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 9 }); + }); + + it("clears the old point while a new stack or selected thread is loading", async () => { + stopped(); + await settle(); + const pending = deferred>>(); + ipc.debugStackTrace.mockReturnValueOnce(pending.promise); + stopped(); + expect(useExecutionPoint.getState().path).toBeNull(); + pending.resolve(ipc.ok([frame(6842)])); + await settle(); + const other = deferred>>(); + ipc.debugStackTrace.mockReturnValueOnce(other.promise); + const selecting = selectThread(1, 1); + expect(useExecutionPoint.getState().path).toBeNull(); + other.resolve(ipc.ok([frame(9)])); + await selecting; + expect(useExecutionPoint.getState().line).toBe(9); + }); + + it("ignores a stack response from an earlier stop on the same thread", async () => { + const old = deferred>>(); + ipc.debugStackTrace.mockReturnValueOnce(old.promise); + stopped(); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(); + await settle(); + old.resolve(ipc.ok([frame(6840)])); + await settle(); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + expect(useDebug.getState().sessions[1]?.frames[0]?.line).toBe(6842); + }); + + it("keeps the selected paused thread when only another thread continues", async () => { + stopped(); + await settle(); + handleDebugEvent(1, { kind: "continued", threadId: 1, allThreadsContinued: false }); + expect(useExecutionPoint.getState().path).toBe(path); + expect(useDebug.getState().sessions[1]?.status).toBe("paused"); + handleDebugEvent(1, { kind: "continued", threadId: 5, allThreadsContinued: false }); + expect(useExecutionPoint.getState().path).toBeNull(); + }); + + it.each(["continued", "terminated"] as const)("clears the execution point on %s", async (kind) => { + stopped(); + await settle(); + handleDebugEvent(1, kind === "continued" ? { kind, threadId: 5, allThreadsContinued: true } : { kind }); + expect(useExecutionPoint.getState()).toMatchObject({ path: null, values: {} }); + }); + + it("retains locals whose names are Object.prototype properties", async () => { + const names = Object.getOwnPropertyNames(Object.prototype); + ipc.debugVariables.mockResolvedValueOnce( + ipc.ok( + names.map((name) => ({ name, value: "known", typeName: null, variablesReference: 0, evaluateName: name })), + ), + ); + stopped(); + await settle(); + for (const name of names) expect(Object.hasOwn(useExecutionPoint.getState().values, name), name).toBe(true); + }); + it.each(["step", "continue"])("clears suspended data before a %s request resolves", async () => { + stopped(); + await settle(); + const pending = deferred(); + const request = vi.fn(() => { + expect(useExecutionPoint.getState().path).toBeNull(); + expect(useDebug.getState().sessions[1]).toMatchObject({ status: "running", frames: [], selectedFrame: null }); + return pending.promise; + }); + const result = resumeExecution(1, request); + expect(request).toHaveBeenCalledOnce(); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(); + await settle(); + pending.resolve(); + await result; + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + }); + + it("restores suspended data if the command fails without a newer event", async () => { + stopped(); + await settle(); + await expect( + resumeExecution(1, async () => { + throw new Error("rejected"); + }), + ).rejects.toThrow("rejected"); + expect(useDebug.getState().sessions[1]?.status).toBe("paused"); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6840 }); + }); + + it("does not restore an older stop when a failed command has already stopped again", async () => { + stopped(); + await settle(); + let reject!: (e: Error) => void; + const result = resumeExecution( + 1, + () => + new Promise((_, r) => { + reject = r; + }), + ); + const rejected = expect(result).rejects.toThrow("rejected"); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(); + await settle(); + reject(new Error("rejected")); + await rejected; + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + }); + + it("discards locals from an earlier stop even when the frame ID is reused", async () => { + const old = deferred>>(); + ipc.debugVariables.mockReturnValueOnce(old.promise); + stopped(); + await settle(); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + ipc.debugVariables.mockResolvedValueOnce( + ipc.ok([{ name: "count", value: "42", typeName: null, variablesReference: 0, evaluateName: "count" }]), + ); + stopped(); + await settle(); + old.resolve(ipc.ok([{ name: "count", value: "40", typeName: null, variablesReference: 0, evaluateName: "count" }])); + await settle(); + expect(useExecutionPoint.getState().values.count).toBe("42"); + }); +}); diff --git a/src/features/debugger/__tests__/smartStep.test.ts b/src/features/debugger/__tests__/smartStep.test.ts index 84eb48fd..eeffc599 100644 --- a/src/features/debugger/__tests__/smartStep.test.ts +++ b/src/features/debugger/__tests__/smartStep.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { _addEditor, _removeEditor, type TextEditorHandle } from "@/editor/api"; -import { useExecutionPoint } from "../state"; +import { adoptSession, useDebug, useExecutionPoint } from "../state"; import { useOverlays } from "../ui/popoverStore"; import { calleeName, @@ -92,6 +92,8 @@ describe("picking in the editor", () => { }); function setup() { + useDebug.setState({ sessions: {}, active: null }); + adoptSession(3, { status: "paused", selectedThread: 1, stoppedThread: 1 }); const ctx = { doc: 1, path: "/p/app.py", language: "python", uiMode: null, readOnly: false }; const view = new EditorView({ state: EditorState.create({ doc: "def f():\n x = parse(str(i))\n", extensions: [smartStepTargets(ctx)] }), diff --git a/src/features/debugger/__tests__/values.test.ts b/src/features/debugger/__tests__/values.test.ts index 38a5bcfc..15ee72a7 100644 --- a/src/features/debugger/__tests__/values.test.ts +++ b/src/features/debugger/__tests__/values.test.ts @@ -52,6 +52,12 @@ describe("value classification", () => { ); expect(inlineValuesFor("return 1", values)).toBeNull(); }); + + it.each(Object.getOwnPropertyNames(Object.prototype))("only displays an own local named %s", (name) => { + expect(inlineValuesFor(name, { count: "40" })).toBeNull(); + expect(inlineValuesFor(name, Object.fromEntries([[name, "known"]]))).toBe(`${name}: known`); + expect(inlineValuesFor(name, Object.create({ [name]: "inherited" }))).toBeNull(); + }); }); const frame = (id: number, path: string | null, hint: string | null = null, line = 9): StackFrame => ({ diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 235b86be..098395a0 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -24,6 +24,7 @@ import { debugUnsupportedReason, launchConfigFor } from "./launch"; import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote"; import { activeSession, + resumeExecution, evaluate, isLive, rerunSession, @@ -89,7 +90,10 @@ function breakpointPathAllowed(path: string): boolean { export function runToLine(path: string, line: number) { const t = pausedTarget(); if (t && breakpointPathAllowed(path)) - void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false))); + void report( + "Run to Cursor failed", + resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, path, line, false))), + ); } function notSupported(what: string, s: DebugSession) { @@ -115,15 +119,17 @@ async function smartStepInto() { if (!t) return; const frame = selectedFrame(t.s); if (!frame || !sessionSupports("stepInTargets", t.s)) { - await call(commands.debugStep(t.s.id, t.thread, "into")); + await resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into"))); return; } const targets = pausedLineTargets(await call(commands.debugStepInTargets(t.s.id, frame.id))); if (targets.length <= 1) { - await call( - targets[0] - ? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id) - : commands.debugStep(t.s.id, t.thread, "into"), + await resumeExecution(t.s.id, () => + call( + targets[0] + ? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id) + : commands.debugStep(t.s.id, t.thread, "into"), + ), ); return; } @@ -317,7 +323,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report(title, call(commands.debugStep(t.s.id, t.thread, kind))); + if (t) + void report( + title, + resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, kind))), + ); }, }); @@ -400,7 +410,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report("Resume failed", call(commands.debugResume(t.s.id, t.thread))); + if (t) + void report( + "Resume failed", + resumeExecution(t.s.id, () => call(commands.debugResume(t.s.id, t.thread))), + ); }, }, { @@ -432,7 +446,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report("Force Step Over failed", call(commands.debugForceStepOver(t.s.id, t.thread))); + if (t) + void report( + "Force Step Over failed", + resumeExecution(t.s.id, () => call(commands.debugForceStepOver(t.s.id, t.thread))), + ); }, }, { @@ -445,7 +463,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report("Force Step Into failed", call(commands.debugStep(t.s.id, t.thread, "into"))); + if (t) + void report( + "Force Step Into failed", + resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into"))), + ); }, }, { @@ -484,7 +506,10 @@ export function registerDebugActions(): () => void { notSupported("Reset Frame", t.s); return; } - void report("Reset Frame failed", call(commands.debugRestartFrame(t.s.id, frame.id))); + void report( + "Reset Frame failed", + resumeExecution(t.s.id, () => call(commands.debugRestartFrame(t.s.id, frame.id))), + ); }, }, { @@ -531,7 +556,10 @@ export function registerDebugActions(): () => void { const t = pausedTarget(); const c = cursor(); if (t && c && breakpointPathAllowed(c.path)) - void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))); + void report( + "Run to Cursor failed", + resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))), + ); }, }, { @@ -545,7 +573,10 @@ export function registerDebugActions(): () => void { const t = pausedTarget(); const c = cursor(); if (t && c && breakpointPathAllowed(c.path)) - void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))); + void report( + "Force Run to Cursor failed", + resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))), + ); }, }, { diff --git a/src/features/debugger/editor/execution.ts b/src/features/debugger/editor/execution.ts index f466e459..749bce22 100644 --- a/src/features/debugger/editor/execution.ts +++ b/src/features/debugger/editor/execution.ts @@ -49,6 +49,7 @@ function currentExec(path: string): Exec | null { export function inlineValuesFor(text: string, values: Record): string | null { const parts: string[] = []; for (const id of identifiersIn(text)) { + if (!Object.hasOwn(values, id)) continue; const v = values[id]; if (v === undefined) continue; parts.push(`${id}: ${inlineValue(v, 40)}`); @@ -80,33 +81,48 @@ export function executionPoint(ctx: EditorDocContext): Extension { provide: (f) => EditorView.decorations.compute([f], (state) => lineDecorations(state, state.field(f))), }); + // Keep the execution line alive even if an inline-value renderer fails. + const sync = ViewPlugin.fromClass( + class { + private unsub: () => void; + private pending = false; + private destroyed = false; + constructor(readonly view: EditorView) { + this.unsub = useExecutionPoint.subscribe(() => this.refresh()); + // Hidden tabs retain EditorState while their views are unmounted. + this.refresh(); + } + refresh() { + if (this.pending) return; + this.pending = true; + queueMicrotask(() => { + this.pending = false; + if (this.destroyed) return; + const next = currentExec(path); + const cur = this.view.state.field(execField); + const moved = cur?.line !== next?.line || cur?.top !== next?.top || !!cur !== !!next; + // Locals can change without moving the execution point. + this.view.dispatch(moved ? { effects: setExec.of(next) } : {}); + }); + } + destroy() { + this.destroyed = true; + this.unsub(); + } + }, + ); + const inline = ViewPlugin.fromClass( class { decorations: DecorationSet = Decoration.none; - private unsub: () => void; - private pending = false; private destroyed = false; private unsubSettings: () => void; constructor(readonly view: EditorView) { this.decorations = this.build(); - // Settings › Debugger › Data Views › Show values inline. this.unsubSettings = onWorkflowSettingsChange( (s) => s.debugger.inlineValues, () => queueMicrotask(() => !this.destroyed && this.view.dispatch({})), ); - this.unsub = useExecutionPoint.subscribe(() => { - if (this.pending) return; - this.pending = true; - queueMicrotask(() => { - this.pending = false; - if (this.destroyed) return; - const next = currentExec(path); - const cur = this.view.state.field(execField); - const moved = cur?.line !== next?.line || cur?.top !== next?.top || !!cur !== !!next; - // An empty transaction still rebuilds the inline values (locals arrive after the frame). - this.view.dispatch(moved ? { effects: setExec.of(next) } : {}); - }); - }); } update(u: ViewUpdate) { @@ -136,12 +152,11 @@ export function executionPoint(ctx: EditorDocContext): Extension { destroy() { this.destroyed = true; - this.unsub(); this.unsubSettings(); } }, { decorations: (v) => v.decorations }, ); - return [execField, inline]; + return [execField, sync, inline]; } diff --git a/src/features/debugger/editor/smartStep.ts b/src/features/debugger/editor/smartStep.ts index 6eec156e..c5617ab4 100644 --- a/src/features/debugger/editor/smartStep.ts +++ b/src/features/debugger/editor/smartStep.ts @@ -10,7 +10,7 @@ import { editorsFor, activeEditor, type EditorDocContext, type TextEditorHandle import { call, commands, type DebugSessionId, type StepInTarget } from "@/platform/ipc"; import { suspendKeyboardDispatch } from "@/platform/keymap"; import { notifications } from "@/workbench/contributions"; -import { useExecutionPoint } from "../state"; +import { useExecutionPoint, resumeExecution } from "../state"; import { useOverlays, type InlineStepTarget } from "../ui/popoverStore"; import styles from "./editor.module.css"; @@ -139,7 +139,7 @@ export function chooseStepTarget(id?: number) { const target = id ?? s.inline?.ranges[s.inline.index]?.id; cancelSmartStep(); if (target == null) return; - void call(commands.debugStepInTarget(s.session, s.thread, target)).catch((e) => + void resumeExecution(s.session, () => call(commands.debugStepInTarget(s.session, s.thread, target))).catch((e) => notifications().notify({ group: "run", level: "error", diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index d983c628..e910ff0c 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -644,6 +644,7 @@ export function selectSession(id: DebugSessionId) { export async function selectThread(id: DebugSessionId, threadId: number) { patch(id, { selectedThread: threadId, frames: [], selectedFrame: null }); + updateExecutionPoint(); await loadFrames(id, threadId, true); } @@ -652,7 +653,7 @@ export function selectFrame(id: DebugSessionId, frameId: number, reveal = true) updateExecutionPoint(); const s = useDebug.getState().sessions[id]; const frame = s?.frames.find((f) => f.id === frameId); - if (reveal && frame?.path) { + if (reveal && useDebug.getState().active === id && frame?.path) { void maybeEditorService()?.openFile(frame.path, { selection: { line: frame.line, col: frame.column }, focus: false, @@ -662,6 +663,7 @@ export function selectFrame(id: DebugSessionId, frameId: number, reveal = true) } async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean) { + const before = useDebug.getState().sessions[id]; let frames: StackFrame[] = []; try { frames = await call(commands.debugStackTrace(id, threadId)); @@ -669,7 +671,7 @@ async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean) console.warn("stackTrace failed", e); } const s = useDebug.getState().sessions[id]; - if (!s || s.status !== "paused" || s.selectedThread !== threadId) return; + if (!s || s.status !== "paused" || s.selectedThread !== threadId || s.frames !== before?.frames) return; patch(id, { frames }); const top = frames.find((f) => f.path && f.presentationHint !== "label") ?? frames[0]; if (top?.path && (s.stopReason === "breakpoint" || s.stopReason === "function breakpoint")) { @@ -707,19 +709,21 @@ async function loadThreads(id: DebugSessionId) { /** Locals of `frameId` (first non-expensive scope) for inline values. */ async function loadLocals(id: DebugSessionId, frameId: number) { - const values: Record = {}; + const frames = useDebug.getState().sessions[id]?.frames; + const values: Record = Object.create(null); try { const scopes = await call(commands.debugScopes(id, frameId)); const scope = scopes.find((sc) => !sc.expensive); if (scope) { const vars = await call(commands.debugVariables(id, scope.variablesReference)); - for (const v of vars) if (v.name && !(v.name in values)) values[v.name] = v.value; + for (const v of vars) if (v.name && !Object.hasOwn(values, v.name)) values[v.name] = v.value; } } catch { // No inline values. } const s = useDebug.getState().sessions[id]; - if (useDebug.getState().active !== id || s?.selectedFrame !== frameId) return; + if (useDebug.getState().active !== id || s?.status !== "paused" || s.selectedFrame !== frameId || s.frames !== frames) + return; useExecutionPoint.setState({ values }); } @@ -729,6 +733,7 @@ export async function showExecutionPoint(id: DebugSessionId) { if (!s || s.status !== "paused" || s.stoppedThread == null) return; if (s.selectedThread !== s.stoppedThread) { patch(id, { selectedThread: s.stoppedThread, frames: [], selectedFrame: null }); + updateExecutionPoint(); await loadFrames(id, s.stoppedThread, false); } const cur = useDebug.getState().sessions[id]; @@ -757,6 +762,32 @@ export function updateExecutionPoint() { useExecutionPoint.setState({ path: frame.path, line: frame.line, top, values: {} }); } +/** Invalidate suspended data before sending a command that resumes execution. */ +export async function resumeExecution(id: DebugSessionId, request: () => Promise) { + const before = useDebug.getState().sessions[id]; + if (!before || before.status !== "paused") return; + const frames: StackFrame[] = []; + patch(id, { status: "running", frames, selectedFrame: null, stoppedThread: null }); + updateExecutionPoint(); + try { + await request(); + } catch (e) { + const current = useDebug.getState().sessions[id]; + // A stop/continue/termination received during the request owns the newer state. + if (current?.status === "running" && current.frames === frames) { + patch(id, { + status: before.status, + frames: before.frames, + selectedFrame: before.selectedFrame, + stoppedThread: before.stoppedThread, + }); + updateExecutionPoint(); + if (before.selectedFrame != null) void loadLocals(id, before.selectedFrame); + } + throw e; + } +} + // --- watches ------------------------------------------------------------------- export function addWatch(expression: string) { @@ -862,6 +893,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { }); useDebug.setState((st) => ({ active: id, stopSeq: st.stopSeq + 1 })); publishContext(); + updateExecutionPoint(); void loadThreads(id); void loadFrames(id, event.threadId, true); const dbg = workflowSettings().debugger; @@ -876,6 +908,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { case "continued": { const cur = useDebug.getState().sessions[id]!; if (cur.status === "terminated") break; + if (!event.allThreadsContinued && event.threadId !== (cur.selectedThread ?? cur.stoppedThread)) break; patch(id, { status: "running", frames: [], diff --git a/src/features/debugger/ui/Overlays.tsx b/src/features/debugger/ui/Overlays.tsx index 14806793..1f4ae51e 100644 --- a/src/features/debugger/ui/Overlays.tsx +++ b/src/features/debugger/ui/Overlays.tsx @@ -13,7 +13,7 @@ import { updateBreakpoint, useBreakpoints, } from "../breakpoints/store"; -import { activeSession, addWatch, evaluate, isLive, startSession, useDebug } from "../state"; +import { activeSession, addWatch, evaluate, isLive, startSession, useDebug, resumeExecution } from "../state"; import { addMethodBreakpoint, removeAllSpecial, @@ -978,7 +978,9 @@ function SmartStepChooser() { const pick = (targetId: number) => { if (!state) return; close(); - void call(commands.debugStepInTarget(state.session, state.thread, targetId)).catch((e) => + void resumeExecution(state.session, () => + call(commands.debugStepInTarget(state.session, state.thread, targetId)), + ).catch((e) => notifications().notify({ group: "run", level: "error", title: "Smart Step Into failed", body: errorText(e) }), ); };