diff --git a/CHANGELOG.md b/CHANGELOG.md index 589d1de..5e6c39d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,11 @@ All notable changes to this project will be documented in this file. ### Added +- Native VS Code **Debug Actor** attach sessions with exact loaded sources, + gutter breakpoint verification, call stacks, pause/continue and bounded toolbar + stepping. Sessions terminate on actor removal, implementation/incarnation change + or image disconnect; detaching never kills or implicitly resumes the actor. + - Bounded source step-over and step-out through the runtime, image API, protocol, and VS Code actor commands, preserving nested breakpoints and async stop boundaries. diff --git a/README.md b/README.md index 65f9940..37dd2c7 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,10 @@ callbacks are not preemptible. The debugger foundation exposes bounded active Ru stack frames and exact loaded source through read-only protocol inspection and VS Code's **Inspect Execution Stack...** actor action. Actor-local source breakpoints and bounded step-into/over/out are implemented through the protocol and VS -Code actor actions; locals, DAP, and persistent storage remain -unimplemented. +Code actor actions. **Debug Actor** attaches a native VS Code debug session with +gutter breakpoints, the call-stack view, and bounded toolbar stepping. Continue +resumes the actor; scheduler ticks remain explicit. Locals, expression evaluation, +and persistent storage remain unimplemented. ## Design goals diff --git a/docs/architecture.md b/docs/architecture.md index 984b1ad..3677a65 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -538,10 +538,15 @@ Debugger foundations now expose bounded head-VM stack frames and UTF-16 source locations through read-only execution inspection. VS Code navigates immutable exact-loaded-source documents, including old code during pending replacements. Await locations represent the last safe snapshot; native/hidden async frames, -locals, post-mortem stacks, and DAP remain unimplemented. Actor-specific +locals and post-mortem stacks remain unimplemented. Actor-specific implementation-bound line breakpoints and bounded step-into/over/out now preserve active -drafts/effects and use exact loaded-source mappings. These are command-based -controls, not gutter breakpoints; conditional stops remain planned. +drafts/effects and use exact loaded-source mappings. An attach-only inline DAP +adapter now exposes one actor as one VS Code debug thread with exact loaded +sources, gutter breakpoints, call stacks, pause/resume, and bounded toolbar +stepping. Continue does not implicitly tick the shared scheduler. Sessions bind +to the connection, implementation and incarnation; a change terminates the +session rather than remapping live frames. Detach leaves actor status and +breakpoints unchanged. Locals/evaluation and conditional stops remain planned. Non-destructive image-change notifications and automatic snapshot refresh are implemented over the shared `runa-debug` WebSocket transport. diff --git a/vscode/README.md b/vscode/README.md index b82e3dc..3967df6 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -186,9 +186,9 @@ Failure is surfaced with its error text; successful shutdown may remove the acto Same-line loops are bounded and pending awaits return immediately. Installation clears breakpoints to prevent stale source mappings; pending edits do not. Breakpoints belong to the actor/image, so disconnect does not clear -them. Restart on unchanged code retains them. These are command-based controls, -not editor-gutter breakpoints or an F5/debug-toolbar session. Conditions, hit -counts, and variables remain planned. +them. Restart on unchanged code retains them. These commands also remain +available independently of the native debugger described below. Conditions, +hit counts, and variables remain planned. For automation, `runa.setBreakpoints` accepts `{actor_ref: string}` and optional comma-separated 1-based line text (including `""` to clear). `runa.stepSource` @@ -196,6 +196,72 @@ accepts `{actor_ref: string}` and returns its stop reason. `runa.stepOver` and `runa.stepOut` accept the same argument and return stop reasons. Use a server built with mode-aware stepping for these new commands. +### Native actor debugger + +Connect to an image, then right-click an actor and choose **Debug Actor**. +Alternatively choose **Runa Actor** in Run and Debug and use an attach +configuration (omit `actorRef` to choose an actor): + +```json +{ + "type": "runa", + "request": "attach", + "name": "Attach to Runa Actor" +} +``` + +**Debug Actor** does not save open editors. Starting an attach configuration +directly through Run and Debug uses VS Code's `debug.saveBeforeStart` setting. +The extension's inline Debug Adapter Protocol adapter uses the existing image +connection; it does not start a server, spawn an actor, or open a separate debug +port. Each session represents one actor/thread and is bound to the current +connection, installed implementation, and incarnation. Only one session per +actor is allowed. Attach pauses a live nonfailed actor, retaining its active +invocation if any. An idle actor can be attached, but has no frames until it +starts a handler. Failed actors must be repaired/restarted before attachment. + +- Set gutter breakpoints in the actor's immutable loaded source, opened by the + command or Call Stack view. The debugger serves the exact compiled text, + including temporary/older implementations, using source references. + Breakpoints in the image source editor are accepted only when both the saved + image file and any open editor buffer exactly match the loaded text. Other, + stale, or unsaved-different files are unverified, not silently mapped to code. + Changing a working document's breakpoint list removes that document's old + locations if it no longer matches. Loaded and matching working documents' + breakpoint groups are combined, with at most 128 distinct lines per actor. + Blank/nonexecutable lines remain unverified. Conditions, hit counts, and + logpoints are unsupported. +- **Continue** resumes scheduling eligibility but does **not** execute image + ticks. Send a message and use **Runa: Advance Scheduler** to run it toward a + breakpoint. Ordinary ticks can advance all eligible actors. **Pause** pauses + only the selected actor between ticks. +- Toolbar **Step Into**, **Step Over**, and **Step Out** use the existing bounded + source steps (1,000 instruction polls) and advance only this actor. A pending + await, breakpoint, budget, completion, or failure ends the request. The Debug + Console reports the exact reason; a surviving nonfailed actor stays paused. + Steps require an active invocation and never consume the next queued message. +- Call Stack displays at most the innermost 64 head-VM frames; truncation and + last-safe-await snapshots are identified in the stop description. Unmapped + frames have no invented source location. At handler completion the stack is + empty, state commits normally, and the actor remains paused. A failure shows + an exception stop with its error text, not a post-mortem stack. +- Variables/scopes, watches/evaluation, restart, instruction-level stepping, + exception filters, and native/hidden async frames are not implemented. Rune + native calls remain nonpreemptible. Unsupported requests fail explicitly. +- Disconnecting/stopping the debug session leaves the actor's current status + and breakpoint list unchanged: it does not kill, resume, or restart the actor. + Actor removal, implementation installation, restart/incarnation change, or + image disconnect terminates the session with a console explanation. Attach + again to use the new version. Pending code edits alone do not change the + loaded version. + +Breakpoint lists are actor/image state shared with other clients and the +standalone **Set Actor Breakpoints...** command; a later writer replaces the +runtime list. Native verification updates when an external change removes or +restores a session breakpoint. Disconnect does not clear them. +For automation, `runa.debugActor` accepts `{actor_ref: string}` and returns +whether VS Code started the session. + For automation, the `runa.connect` command also accepts an explicit endpoint string, and `runa.inspect` accepts an object with an `actor_ref` field. **Copy Actor Reference** in the actor context menu or inline button copies the diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index 6c81336..befce4f 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import * as vscode from "vscode"; import { control, spawnCounter, startImage } from "./server"; -import { ImageSourceTree, RevisionDocuments } from "../src/sourceProvider"; +import { ImageSourceTree, RevisionDocuments, sourceFileUri } from "../src/sourceProvider"; import { actorFileUri } from "../src/actorFiles"; import type { activate } from "../src/extension"; import { SourceDiagnostics } from "../src/diagnostics"; @@ -9,6 +9,7 @@ import { SourceDiagnostic } from "../src/protocol"; import { ImageClient } from "../src/client"; import { parseValue } from "../src/values"; import { ExecutionDocuments, frameSelection } from "../src/executionProvider"; +import { DebugMessage } from "../src/debugger"; export async function run(): Promise { const image = await startImage(); @@ -46,6 +47,7 @@ export async function run(): Promise { await checkSourceDiagnostics(image.address); await inspectExecution(image.address); await sourceDebugging(image.address); + await nativeDebugging(image.address); await editActorFiles(image.address); const spawned = await vscode.commands.executeCommand("runa.spawn", { path: "actors/counter", @@ -405,6 +407,193 @@ async function sourceDebugging(address: string): Promise { } } +async function nativeDebugging(address: string): Promise { + const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); + const messages: DebugMessage[] = []; + const changes = new vscode.EventEmitter(); + const tracker = vscode.debug.registerDebugAdapterTrackerFactory("runa", { + createDebugAdapterTracker: () => ({ + onDidSendMessage: (message: DebugMessage) => { + messages.push(message); + changes.fire(); + }, + }), + }); + const wait = (predicate: (message: DebugMessage) => boolean, since = 0) => + new Promise((resolve, reject) => { + const check = () => { + const found = messages.slice(since).find(predicate); + if (!found) return; + clearTimeout(timer); + subscription.dispose(); + resolve(found); + }; + const subscription = changes.event(check); + const timer = setTimeout(() => { + subscription.dispose(); + reject(new Error(`Native DAP message timed out: ${JSON.stringify(messages.slice(since))}`)); + }, 10000); + check(); + }); + const selectedLine = (uri: vscode.Uri, line: number) => + new Promise((resolve, reject) => { + const check = () => { + const editor = vscode.window.activeTextEditor; + if ( + editor?.document.uri.toString() !== uri.toString() || + editor.selection.start.line !== line + ) + return; + clearTimeout(timer); + subscriptions.forEach((subscription) => subscription.dispose()); + resolve(); + }; + const subscriptions = [ + vscode.window.onDidChangeActiveTextEditor(check), + vscode.window.onDidChangeTextEditorSelection(check), + ]; + const timer = setTimeout(() => { + subscriptions.forEach((subscription) => subscription.dispose()); + reject(new Error(`Native debugger did not navigate to source line ${line + 1}`)); + }, 5000); + check(); + }); + let breakpoint: vscode.SourceBreakpoint | undefined; + let session: vscode.DebugSession | undefined; + try { + const source = + "pub fn handle(ctx, state, sender, message) {\n let next = nested(state);\n next + 1\n}\nfn nested(value) {\n value + 1\n}"; + const file = await client.writeSource("tests/native_debugging", null, source); + const actor = await client.spawn(file.path, parseValue("10")); + const other = await client.spawn(file.path, parseValue("20")); + await vscode.commands.executeCommand("runa.refresh"); + const working = await vscode.workspace.openTextDocument(sourceFileUri(address, file.path)); + await replaceText(working, source + "\n// unsaved edit"); + assert.equal( + await vscode.commands.executeCommand("runa.debugActor", { actor_ref: actor }), + true, + ); + assert.equal(working.isDirty, true, "Debug Actor must not save open editors"); + assert.equal((await client.readSource(file.path)).source, source); + await replaceText(working, source); + await working.save(); + await wait((message) => message.event === "stopped"); + session = vscode.debug.activeDebugSession; + assert.ok(session); + assert.equal(session.type, "runa"); + assert.equal((await client.inspectActor(actor)).status.status, "paused"); + const loaded: { sources: { name: string; path: string; sourceReference: number }[] } = + await session.customRequest("loadedSources"); + const debugSource = loaded.sources[0]; + assert.ok(debugSource); + const uri = vscode.debug.asDebugSourceUri(debugSource, session); + const document = await vscode.workspace.openTextDocument(uri); + assert.equal(document.getText(), source); + assert.equal(document.languageId, "rune"); + breakpoint = new vscode.SourceBreakpoint(new vscode.Location(uri, new vscode.Position(1, 0))); + let since = messages.length; + vscode.debug.addBreakpoints([breakpoint]); + const response = await wait( + (message) => message.type === "response" && message.command === "setBreakpoints", + since, + ); + assert.equal(response.success, true); + assert.equal( + (response.body as { breakpoints: { verified: boolean }[] }).breakpoints[0]?.verified, + true, + ); + since = messages.length; + await vscode.commands.executeCommand("workbench.action.debug.continue"); + await wait((message) => message.event === "continued", since); + assert.equal( + (await client.inspectActor(actor)).execution, + null, + "continue must not tick the image", + ); + since = messages.length; + await vscode.commands.executeCommand("workbench.action.debug.pause"); + await wait((message) => message.event === "stopped", since); + assert.equal((await client.inspectActor(actor)).status.status, "paused"); + since = messages.length; + await vscode.commands.executeCommand("workbench.action.debug.continue"); + await wait((message) => message.event === "continued", since); + await client.send(actor, parseValue("null")); + await client.tick(1, 1000); + await wait((message) => message.event === "stopped", since); + assert.equal((await client.inspectExecution(actor)).debugger?.stop_reason, "breakpoint"); + const otherBefore = await client.inspectActor(other); + since = messages.length; + await vscode.commands.executeCommand("workbench.action.debug.stepInto"); + await wait((message) => message.event === "stopped", since); + let stack: { + stackFrames: { id: number; name: string; line: number; source: vscode.DebugProtocolSource }[]; + } = await session.customRequest("stackTrace", { threadId: 1 }); + assert.equal(stack.stackFrames[0]?.name, "nested"); + assert.equal(stack.stackFrames[0]?.line, 6); + await selectedLine(uri, 5); + assert.deepEqual(await client.inspectActor(other), otherBefore); + assert.deepEqual((await client.inspectActor(actor)).state, parseValue("10")); + assert.deepEqual(await session.customRequest("scopes", { frameId: stack.stackFrames[0]!.id }), { + scopes: [], + }); + since = messages.length; + await vscode.commands.executeCommand("workbench.action.debug.stepOut"); + await wait((message) => message.event === "stopped", since); + stack = await session.customRequest("stackTrace", { threadId: 1 }); + assert.equal(stack.stackFrames[0]?.name, "handle"); + assert.equal(stack.stackFrames[0]?.line, 3); + await selectedLine(uri, 2); + for (let count = 0; count < 10 && (await client.inspectActor(actor)).execution; count++) { + since = messages.length; + await vscode.commands.executeCommand("workbench.action.debug.stepOver"); + await wait((message) => message.event === "stopped", since); + } + const completed = await client.inspectActor(actor); + assert.equal(completed.execution, null); + assert.equal(completed.status.status, "paused"); + assert.deepEqual(completed.state, parseValue("12")); + assert.deepEqual(await session.customRequest("stackTrace", { threadId: 1 }), { + stackFrames: [], + totalFrames: 0, + }); + since = messages.length; + vscode.debug.removeBreakpoints([breakpoint]); + breakpoint = undefined; + await wait( + (message) => message.type === "response" && message.command === "setBreakpoints", + since, + ); + assert.deepEqual((await client.inspectExecution(actor)).debugger?.breakpoints, []); + since = messages.length; + await vscode.debug.stopDebugging(session); + await wait((message) => message.event === "terminated", since); + assert.equal((await client.inspectActor(actor)).status.status, "paused"); + since = messages.length; + assert.equal( + await vscode.commands.executeCommand("runa.debugActor", { actor_ref: actor }), + true, + ); + await wait((message) => message.event === "stopped", since); + session = vscode.debug.activeDebugSession; + since = messages.length; + await vscode.commands.executeCommand("runa.disconnect"); + await wait((message) => message.event === "terminated", since); + assert.equal((await client.inspectActor(actor)).status.status, "paused"); + await vscode.commands.executeCommand("runa.connect", address); + await client.system(actor, { kind: "kill" }); + await client.system(other, { kind: "kill" }); + console.log( + "Runa native debugger: attach, gutter breakpoint, real toolbar stepping, exact sources, isolation and detach passed", + ); + } finally { + if (breakpoint) vscode.debug.removeBreakpoints([breakpoint]); + if (session) await vscode.debug.stopDebugging(session); + tracker.dispose(); + changes.dispose(); + await client.close(); + } +} + async function editSources(address: string): Promise { const extension = vscode.extensions.all.find( (candidate) => candidate.packageJSON.name === "runa", diff --git a/vscode/package.json b/vscode/package.json index ca9d11b..62fbc85 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -13,6 +13,7 @@ ], "main": "./out/src/extension.js", "activationEvents": [ + "onDebug:runa", "onFileSystem:runa-actor", "onView:runa.actors", "onView:runa.sources", @@ -39,6 +40,37 @@ "typescript": "^5.9.3" }, "contributes": { + "debuggers": [ + { + "type": "runa", + "label": "Runa Actor", + "languages": [ + "rune" + ], + "configurationAttributes": { + "attach": { + "properties": { + "actorRef": { + "type": "string", + "description": "Actor reference in the connected image. Omit to choose an actor." + } + } + } + }, + "initialConfigurations": [ + { + "type": "runa", + "request": "attach", + "name": "Attach to Runa Actor" + } + ] + } + ], + "breakpoints": [ + { + "language": "rune" + } + ], "languages": [ { "id": "rune", @@ -47,10 +79,20 @@ ], "extensions": [ ".rn" + ], + "mimetypes": [ + "text/x-rune" ] } ], "commands": [ + { + "command": "runa.debugActor", + "title": "Debug Actor", + "category": "Runa", + "enablement": "runa.connected", + "icon": "$(debug-alt)" + }, { "command": "runa.stepOver", "title": "Step Over Source Line", @@ -335,6 +377,11 @@ } ], "view/item/context": [ + { + "command": "runa.debugActor", + "when": "view == runa.actors && viewItem == runa.actor && runa.connected", + "group": "debug@0" + }, { "command": "runa.stepOver", "when": "view == runa.actors && viewItem == runa.actor && runa.connected", diff --git a/vscode/src/debugger.ts b/vscode/src/debugger.ts new file mode 100644 index 0000000..86c4efd --- /dev/null +++ b/vscode/src/debugger.ts @@ -0,0 +1,507 @@ +import { ImageClient } from "./client"; +import { + ExecutionInspection, + isActorRef, + SourceBreakpoint, + StackFrame, + StepMode, +} from "./protocol"; +import { RefreshQueue } from "./refresh"; + +interface Request { + seq: number; + type: "request"; + command: string; + arguments?: Record; +} + +export interface DebugMessage { + seq: number; + type: "response" | "event"; + request_seq?: number; + command?: string; + success?: boolean; + message?: string; + event?: string; + body?: unknown; +} + +interface DebugSource { + name: string; + path: string; + sourceReference: number; +} + +interface BreakpointEntry { + id: number; + line: number; + unsupported: boolean; + sourceMatches: boolean; +} + +interface BreakpointGroup { + source: Record; + entries: BreakpointEntry[]; +} + +type DebugClient = Pick< + ImageClient, + | "inspectActor" + | "inspectExecution" + | "listActors" + | "system" + | "readSource" + | "setBreakpoints" + | "stepSource" +>; + +function record(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error("Expected a debug protocol object"); + return value as Record; +} + +function integer(value: unknown, minimum = 0): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) + throw new Error("Expected a debug protocol integer"); + return value; +} + +/** One actor, one installed implementation/incarnation, and no implicit scheduler ticks. */ +export class ActorDebugAdapter { + private sequence = 0; + private queue: Promise = Promise.resolve(); + private closed = false; + private attached = false; + private configured = false; + private lineBase = 1; + private columnBase = 1; + private inspection?: ExecutionInspection; + private source?: DebugSource; + private readonly breakpointGroups = new Map(); + private readonly breakpointStates = new Map(); + private readonly frames = new Map(); + private nextFrame = 0; + private nextBreakpoint = 0; + private stopKey?: string; + private paused = false; + private readonly updates: RefreshQueue; + + constructor( + private readonly client: DebugClient, + readonly actorRef: string, + private readonly publishSource: (inspection: ExecutionInspection) => string, + private readonly checkConnection: () => void, + private readonly send: (message: DebugMessage) => void, + private readonly workingSourcePath: (path: string) => string, + private readonly visibleSourceMatches: (path: string, source: string) => boolean, + ) { + if (!isActorRef(actorRef)) throw new Error("Invalid debug actor reference"); + this.updates = new RefreshQueue( + () => this.enqueue(() => this.synchronize()), + (error) => this.terminate(error instanceof Error ? error.message : String(error)), + ); + } + + handleMessage(message: unknown): void { + let request: Request; + try { + const input = record(message); + if (input.type !== "request" || typeof input.command !== "string") + throw new Error("Expected a debug protocol request"); + request = { + seq: integer(input.seq), + type: "request", + command: input.command, + arguments: input.arguments === undefined ? undefined : record(input.arguments), + }; + } catch (error) { + this.terminate(error instanceof Error ? error.message : String(error)); + return; + } + void this.enqueue(async () => { + try { + const result = await this.dispatch(request); + this.emit({ + type: "response", + request_seq: request.seq, + command: request.command, + success: true, + body: result.body, + }); + result.after?.(); + } catch (error) { + this.emit({ + type: "response", + request_seq: request.seq, + command: request.command, + success: false, + message: error instanceof Error ? error.message : String(error), + }); + if (request.command === "attach") this.terminate(); + } + }); + } + + invalidate(): void { + if (this.attached && this.configured) this.updates.invalidate(); + } + + terminate(message?: string): void { + if (this.closed) return; + if (message) this.output(message); + this.event("terminated"); + this.dispose(); + } + + dispose(): void { + this.closed = true; + this.updates.dispose(); + this.frames.clear(); + this.breakpointGroups.clear(); + this.breakpointStates.clear(); + } + + private enqueue(action: () => Promise): Promise { + const result = this.queue.then(async () => { + if (!this.closed) await action(); + }); + this.queue = result.catch((error: unknown) => + this.terminate(error instanceof Error ? error.message : String(error)), + ); + return result; + } + + private emit(message: Omit): void { + if (!this.closed) this.send({ seq: ++this.sequence, ...message }); + } + + private event(event: string, body?: unknown): void { + this.emit({ type: "event", event, body }); + } + + private output(message: string): void { + this.event("output", { category: "console", output: `Runa: ${message}\n` }); + } + + private check(): void { + if (this.closed) throw new Error("Debug session ended"); + this.checkConnection(); + } + + private async read(): Promise { + this.check(); + const inspection = await this.client.inspectExecution(this.actorRef); + this.check(); + if ( + this.inspection && + (inspection.implementation_id !== this.inspection.implementation_id || + inspection.incarnation !== this.inspection.incarnation) + ) { + this.terminate("Actor implementation or incarnation changed; attach again."); + throw new Error("Actor implementation or incarnation changed"); + } + this.inspection = inspection; + if (!this.source) { + this.source = { + name: `${inspection.behavior_path ?? "loaded behavior"}.rn`, + path: this.publishSource(inspection), + sourceReference: 1, + }; + } + return inspection; + } + + private async synchronize(): Promise { + if (!this.attached || !this.configured) return; + this.check(); + const actors = await this.client.listActors(); + this.check(); + const actor = actors.find((actor) => actor.actor_ref === this.actorRef); + if (!actor) { + this.terminate("Actor removed."); + return; + } + const inspection = await this.read(); + this.check(); + this.verifyBreakpoints(inspection.debugger?.breakpoints ?? []); + const paused = actor.status.status === "paused" || actor.status.status === "failed"; + if (!paused) { + if (this.paused) this.event("continued", { threadId: 1, allThreadsContinued: true }); + this.paused = false; + this.stopKey = undefined; + this.frames.clear(); + return; + } + const execution = inspection.execution; + const key = `${actor.status.status}:${execution?.vm_steps}:${execution?.frames[0]?.instruction_pointer}:${inspection.debugger?.stop_reason}`; + if (key === this.stopKey) return; + this.stopKey = key; + this.paused = true; + this.frames.clear(); + for (const frame of execution?.frames ?? []) this.frames.set(++this.nextFrame, frame); + const reason = inspection.debugger?.stop_reason; + this.event("stopped", { + reason: + actor.status.status === "failed" + ? "exception" + : reason === "breakpoint" + ? "breakpoint" + : reason === "step" + ? "step" + : "pause", + description: + actor.status.message ?? + (execution + ? `${reason ?? "paused"}${execution.awaiting ? " (last safe await snapshot)" : ""}${execution.frames_truncated ? "; innermost 64 frames shown" : ""}` + : "Paused at handler boundary; no active invocation"), + threadId: 1, + allThreadsStopped: true, + }); + } + + private async dispatch(request: Request): Promise<{ body?: unknown; after?: () => void }> { + const args = request.arguments ?? {}; + if (request.command === "initialize") { + this.lineBase = args.linesStartAt1 === false ? 0 : 1; + this.columnBase = args.columnsStartAt1 === false ? 0 : 1; + return { + body: { + supportsConfigurationDoneRequest: true, + supportsLoadedSourcesRequest: true, + supportsTerminateDebuggee: false, + supportSuspendDebuggee: false, + }, + }; + } + if (request.command === "disconnect") return { after: () => this.terminate() }; + if (request.command === "attach") { + if (this.attached) throw new Error("Debugger is already attached"); + this.check(); + const actor = await this.client.inspectActor(this.actorRef); + this.check(); + if (actor.status.status === "failed" || actor.status.status === "stopped") + throw new Error("Attach requires a live, nonfailed actor"); + await this.read(); + if (actor.status.status !== "paused") { + this.check(); + await this.client.system(this.actorRef, { kind: "pause" }); + this.check(); + } + this.attached = true; + return { + after: () => { + this.output( + "Attached to one actor. Continue resumes scheduling eligibility but does not tick the image; use Runa: Advance Scheduler. Disconnect leaves actor status and breakpoints unchanged. Locals/evaluation are not available.", + ); + this.event("initialized"); + }, + }; + } + if (!this.attached) throw new Error("Attach to an actor first"); + this.check(); + switch (request.command) { + case "configurationDone": + this.configured = true; + return { after: () => this.invalidate() }; + case "threads": + return { body: { threads: [{ id: 1, name: `@${this.actorRef}` }] } }; + case "loadedSources": + return { body: { sources: this.source ? [this.source] : [] } }; + case "source": { + if (args.sourceReference !== 1) throw new Error("Unknown loaded source"); + return { body: { content: this.inspection?.source, mimeType: "text/x-rune" } }; + } + case "setBreakpoints": + return { body: await this.setBreakpoints(args) }; + case "setExceptionBreakpoints": + if (Array.isArray(args.filters) && args.filters.length === 0) + return { body: { breakpoints: [] } }; + throw new Error("Exception filters are not supported"); + case "stackTrace": { + this.thread(args); + const frames = [...this.frames].map(([id, frame]) => ({ + id, + name: frame.function ?? "", + source: frame.range ? this.source : undefined, + line: frame.range ? frame.range.start.line + this.lineBase : 0, + column: frame.range ? frame.range.start.character + this.columnBase : 0, + endLine: frame.range ? frame.range.end.line + this.lineBase : undefined, + endColumn: frame.range ? frame.range.end.character + this.columnBase : undefined, + presentationHint: frame.range ? "normal" : "subtle", + })); + const start = args.startFrame === undefined ? 0 : integer(args.startFrame); + const levels = args.levels === undefined ? 0 : integer(args.levels); + return { + body: { + stackFrames: frames.slice(start, levels === 0 ? undefined : start + levels), + totalFrames: frames.length, + }, + }; + } + case "scopes": + if (!this.frames.has(integer(args.frameId))) throw new Error("Stale or unknown frame"); + return { body: { scopes: [] } }; + case "pause": + this.thread(args); + await this.read(); + this.check(); + await this.client.system(this.actorRef, { kind: "pause" }); + this.check(); + this.stopKey = undefined; + return { after: () => this.invalidate() }; + case "continue": + this.thread(args); + await this.read(); + this.check(); + await this.client.system(this.actorRef, { kind: "resume" }); + this.check(); + this.paused = false; + this.stopKey = undefined; + this.frames.clear(); + return { + body: { allThreadsContinued: true }, + after: () => { + this.event("continued", { threadId: 1, allThreadsContinued: true }); + this.output("Actor resumed. Use Runa: Advance Scheduler to execute image ticks."); + }, + }; + case "next": + case "stepIn": + case "stepOut": { + this.thread(args); + if ( + args.granularity !== undefined && + args.granularity !== "statement" && + args.granularity !== "line" + ) + throw new Error("Only source-line stepping is supported"); + const mode: StepMode = + request.command === "next" ? "over" : request.command === "stepOut" ? "out" : "into"; + const inspection = await this.read(); + this.check(); + const reason = await this.client.stepSource( + this.actorRef, + inspection.implementation_id, + 1000, + mode, + ); + this.check(); + this.frames.clear(); + this.stopKey = undefined; + return { + after: () => { + this.event("continued", { threadId: 1, allThreadsContinued: true }); + this.output(`Source step ${mode}: ${reason}.`); + this.invalidate(); + }, + }; + } + default: + throw new Error(`Unsupported debug request: ${request.command}`); + } + } + + private thread(args: Record): void { + if (args.threadId !== 1) throw new Error("Unknown actor thread"); + } + + private async setBreakpoints(args: Record): Promise { + const source = record(args.source); + const requested = args.breakpoints === undefined ? [] : args.breakpoints; + if (!Array.isArray(requested)) throw new Error("Expected source breakpoints"); + if (requested.length > 128) throw new Error("At most 128 breakpoints are supported"); + const inspection = await this.read(); + let matches = source.sourceReference === 1 || source.path === this.source?.path; + const knownWorking = + !!inspection.behavior_path && + source.path === this.workingSourcePath(inspection.behavior_path); + if (!matches && knownWorking && inspection.behavior_path) { + // Only a byte-for-byte match can bind a mutable image file to loaded code. + const working = await this.client.readSource(inspection.behavior_path); + this.check(); + matches = + working.source === inspection.source && + this.visibleSourceMatches(inspection.behavior_path, inspection.source); + } + const entries = requested.map((entry: unknown) => { + const bp = record(entry); + const line = integer(bp.line, this.lineBase) - this.lineBase; + const unsupported = + bp.condition !== undefined || bp.hitCondition !== undefined || bp.logMessage !== undefined; + return { id: ++this.nextBreakpoint, line, unsupported, sourceMatches: matches }; + }); + if (!matches && !(knownWorking && this.breakpointGroups.has("working"))) + return { + breakpoints: entries.map((entry) => ({ + id: entry.id, + verified: false, + line: entry.line + this.lineBase, + source, + message: "Not this actor's exact loaded source; set breakpoints in its loaded source", + })), + }; + const key = + source.sourceReference === 1 || source.path === this.source?.path ? "loaded" : "working"; + const groups = new Map(this.breakpointGroups); + groups.set(key, { source, entries }); + const lines = [ + ...new Set( + [...groups.values()].flatMap((group) => + group.entries + .filter((entry) => entry.sourceMatches && !entry.unsupported) + .map((entry) => entry.line), + ), + ), + ]; + if (lines.length > 128) + throw new Error("At most 128 combined loaded/working breakpoint lines are supported"); + const verified = await this.client.setBreakpoints( + this.actorRef, + inspection.implementation_id, + lines, + ); + this.check(); + for (const previous of this.breakpointGroups.get(key)?.entries ?? []) + this.breakpointStates.delete(previous.id); + this.breakpointGroups.set(key, { source, entries }); + this.verifyBreakpoints(verified); + return { breakpoints: entries.map((entry) => this.breakpointResult(entry, source, verified)) }; + } + + private verifyBreakpoints(resolved: SourceBreakpoint[]): void { + for (const group of this.breakpointGroups.values()) + for (const entry of group.entries) { + const breakpoint = this.breakpointResult(entry, group.source, resolved); + const previous = this.breakpointStates.get(entry.id); + this.breakpointStates.set(entry.id, breakpoint.verified); + if (previous !== undefined && previous !== breakpoint.verified) + this.event("breakpoint", { reason: "changed", breakpoint }); + } + } + + private breakpointResult( + entry: BreakpointEntry, + source: Record, + resolved: SourceBreakpoint[], + ) { + const breakpoint = resolved.find((bp) => bp.line === entry.line); + const verified = + entry.sourceMatches && !entry.unsupported && !!breakpoint?.instruction_pointers.length; + return { + id: entry.id, + verified, + line: entry.line + this.lineBase, + source, + message: !entry.sourceMatches + ? "Not this actor's exact loaded source; set breakpoints in its loaded source" + : entry.unsupported + ? "Conditions, hit counts and logpoints are not supported" + : verified + ? undefined + : breakpoint + ? "No executable line entry in the loaded source" + : "Actor breakpoint list changed outside this source document", + }; + } +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 964cac1..005173f 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -15,6 +15,7 @@ import { SnapshotDocuments } from "./snapshotProvider"; import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits"; import { SourceDiagnostics } from "./diagnostics"; import { ExecutionDocuments, EXECUTION_SCHEME, frameSelection } from "./executionProvider"; +import { ActorDebugAdapter } from "./debugger"; import { ActorFiles, ACTOR_SCHEME, ACTOR_FILES, actorFileUri } from "./actorFiles"; import { ImageSourceFileSystem, @@ -125,6 +126,11 @@ export function activate(context: vscode.ExtensionContext): { const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree }); const revisions = new RevisionDocuments(); const executions = new ExecutionDocuments(); + const debuggers = new Map(); + const endDebuggers = () => { + for (const adapter of debuggers.values()) adapter.terminate("Image connection ended."); + debuggers.clear(); + }; const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree }); const output = vscode.window.createOutputChannel("Runa"); const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); @@ -147,6 +153,7 @@ export function activate(context: vscode.ExtensionContext): { return closed; }; closeConnections = async () => { + endDebuggers(); await closeClient(client); await Promise.all(closing); }; @@ -173,6 +180,7 @@ export function activate(context: vscode.ExtensionContext): { }; const disconnect = () => { + endDebuggers(); automaticRefresh?.dispose(); automaticRefresh = undefined; const previous = client; @@ -198,6 +206,7 @@ export function activate(context: vscode.ExtensionContext): { if (current !== client || currentSession !== session) return; tree.session = session; tree.replace(actors); + for (const adapter of debuggers.values()) adapter.invalidate(); actorFiles.refreshActors(session, actors); sourceTree.refresh(); documents.refresh(session, endpoint, actors); @@ -246,6 +255,7 @@ export function activate(context: vscode.ExtensionContext): { documents.disconnect(currentSession); diagnostics.clear(); actorFiles.disconnect(currentSession); + endDebuggers(); sources.store.detach(); sourceTree.refresh(); connectionStatus(false); @@ -912,6 +922,97 @@ export function activate(context: vscode.ExtensionContext): { command(`runa.${kind}`, (selected?: { actor_ref: string }) => system(kind, selected)); context.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider("runa", { + resolveDebugConfiguration: async (_folder, configuration) => { + try { + const connection = active(); + if ( + configuration.runaConnection !== undefined && + configuration.runaConnection !== connection.session + ) + throw new Error("Image connection changed before debugger attachment"); + if (configuration.request && configuration.request !== "attach") + throw new Error("Runa supports attach debugging only"); + const actorRef = configuration.actorRef ?? (await chooseActor()); + if (!actorRef) return undefined; + connection.check(); + return { + ...configuration, + type: "runa", + request: "attach", + name: configuration.name ?? `Runa @${actorRef}`, + actorRef, + runaConnection: connection.session, + }; + } catch (error) { + report(error); + return undefined; + } + }, + }), + vscode.debug.registerDebugAdapterDescriptorFactory("runa", { + createDebugAdapterDescriptor: (debugSession) => { + const connection = active(); + if (debugSession.configuration.runaConnection !== connection.session) + throw new Error("Image connection changed before debugger attachment"); + const actorRef: unknown = debugSession.configuration.actorRef; + if (typeof actorRef !== "string") throw new Error("Choose a debug actor"); + if ([...debuggers.values()].some((adapter) => adapter.actorRef === actorRef)) + throw new Error("This actor already has a Runa debug session"); + const messages = new vscode.EventEmitter(); + const adapter = new ActorDebugAdapter( + connection.client, + actorRef, + (inspection) => executions.set(connection.session, inspection).toString(), + connection.check, + (message) => messages.fire(message), + (path) => sourceFileUri(connection.endpoint, path).toString(), + (path, source) => { + const uri = sourceFileUri(connection.endpoint, path).toString(); + const document = vscode.workspace.textDocuments.find( + (document) => document.uri.toString() === uri, + ); + return !document || document.getText() === source; + }, + ); + debuggers.set(debugSession.id, adapter); + return new vscode.DebugAdapterInlineImplementation({ + onDidSendMessage: messages.event, + handleMessage: (message) => adapter.handleMessage(message), + dispose: () => { + debuggers.delete(debugSession.id); + adapter.dispose(); + messages.dispose(); + }, + }); + }, + }), + command("runa.debugActor", async (selected?: { actor_ref: string }) => { + const connection = active(); + const actorRef = await chooseActor(selected); + if (!actorRef) return; + connection.check(); + const inspection = await connection.client.inspectExecution(actorRef); + connection.check(); + const started = await vscode.debug.startDebugging( + undefined, + { + type: "runa", + request: "attach", + name: `Runa @${actorRef}`, + actorRef, + runaConnection: connection.session, + }, + { suppressSaveBeforeStart: true }, + ); + connection.check(); + if (started) { + await vscode.window.showTextDocument( + await vscode.workspace.openTextDocument(executions.set(connection.session, inspection)), + ); + } + return started; + }), tree, sources, sourceTree, diff --git a/vscode/test/debugHarness.ts b/vscode/test/debugHarness.ts new file mode 100644 index 0000000..2470b52 --- /dev/null +++ b/vscode/test/debugHarness.ts @@ -0,0 +1,50 @@ +import { ActorDebugAdapter, DebugMessage } from "../src/debugger"; + +export class DebugHarness { + readonly adapter: ActorDebugAdapter; + readonly messages: DebugMessage[] = []; + private seq = 0; + private readonly listeners = new Set<() => void>(); + + constructor(create: (send: (message: DebugMessage) => void) => ActorDebugAdapter) { + this.adapter = create((message) => { + this.messages.push(message); + for (const listener of this.listeners) listener(); + }); + } + + async request(command: string, args?: Record): Promise { + const seq = ++this.seq; + const response = this.wait((message) => message.request_seq === seq); + this.adapter.handleMessage({ seq, type: "request", command, arguments: args }); + const message = await response; + if (!message.success) throw new Error(message.message); + return message.body as T; + } + + wait(predicate: (message: DebugMessage) => boolean, since = 0): Promise { + return new Promise((resolve, reject) => { + const check = () => { + const found = this.messages.slice(since).find(predicate); + if (!found) return; + clearTimeout(timer); + this.listeners.delete(check); + resolve(found); + }; + const timer = setTimeout(() => { + this.listeners.delete(check); + reject(new Error("Timed out waiting for DAP message")); + }, 5000); + this.listeners.add(check); + check(); + }); + } + + async attach(): Promise { + await this.request("initialize"); + await this.request("attach"); + await this.wait((message) => message.event === "initialized"); + await this.request("configurationDone"); + await this.wait((message) => message.event === "stopped"); + } +} diff --git a/vscode/test/debugger.test.ts b/vscode/test/debugger.test.ts new file mode 100644 index 0000000..ff8b21a --- /dev/null +++ b/vscode/test/debugger.test.ts @@ -0,0 +1,344 @@ +import assert from "node:assert/strict"; +import { test, TestContext } from "node:test"; +import { ActorDebugAdapter } from "../src/debugger"; +import { DebugHarness } from "./debugHarness"; +import { actor, REF } from "./fixtures"; +import { DebugStopReason, ExecutionInspection, SourceBreakpoint, StepMode } from "../src/protocol"; + +function fixture(t: TestContext) { + const snapshot = actor(); + snapshot.status = { status: "running" }; + const inspection: ExecutionInspection = { + actor_ref: REF, + incarnation: 1n, + implementation_id: snapshot.implementation_id, + source_id: "source:test", + source: "pub fn handle(ctx, state, sender, message) {\n state + 1\n}\n", + behavior_path: "actors/test", + execution: { + vm_steps: 20n, + awaiting: false, + call_depth: 1n, + frames_truncated: false, + frames: [ + { + kind: "current", + instruction_pointer: 12n, + source_instruction_pointer: 12n, + function: "handle", + range: { start: { line: 1, character: 4 }, end: { line: 1, character: 9 } }, + }, + ], + }, + debugger: { breakpoints: [], stop_reason: null }, + }; + let present = true; + let connected = true; + let visibleMatches = true; + let working = inspection.source; + const modes: StepMode[] = []; + let stepReason: DebugStopReason = "step"; + let mutations = 0; + const harness = new DebugHarness( + (send) => + new ActorDebugAdapter( + { + inspectActor: async () => snapshot, + inspectExecution: async () => structuredClone(inspection), + listActors: async () => (present ? [snapshot] : []), + system: async (_actor, message) => { + mutations++; + if (message.kind === "pause") snapshot.status = { status: "paused" }; + else if (message.kind === "resume") snapshot.status = { status: "running" }; + }, + readSource: async () => ({ source: working, path: "actors/test", source_id: "working" }), + setBreakpoints: async (_actor, _implementation, lines) => { + mutations++; + const breakpoints = lines.map((line) => ({ + line, + instruction_pointers: line === 1 ? [12n] : [], + })); + inspection.debugger = { breakpoints, stop_reason: null }; + return breakpoints; + }, + stepSource: async (_actor, _implementation, budget, mode = "into") => { + assert.equal(budget, 1000); + mutations++; + modes.push(mode); + inspection.execution!.vm_steps++; + inspection.debugger!.stop_reason = stepReason; + if (stepReason === "awaiting") inspection.execution!.awaiting = true; + if (stepReason === "completed" || stepReason === "failed") { + inspection.execution = null; + snapshot.execution = null; + } + if (stepReason === "failed") snapshot.status = { status: "failed", message: "boom" }; + if (stepReason === "removed") present = false; + return stepReason; + }, + }, + REF, + () => "runa-execution://1/loaded/source.rn", + () => { + if (!connected) throw new Error("Connection changed"); + }, + send, + (path) => `runa-image://127.0.0.1:3737/source/${path}.rn`, + () => visibleMatches, + ), + ); + t.after(() => harness.adapter.dispose()); + return { + harness, + snapshot, + inspection, + modes, + mutations: () => mutations, + reason: (reason: DebugStopReason) => { + stepReason = reason; + }, + remove: () => { + present = false; + }, + disconnect: () => { + connected = false; + }, + edit: (source: string) => { + working = source; + }, + unsave: () => { + visibleMatches = false; + }, + }; +} + +test("DAP attach pauses only its actor, exposes exact stack/source, empty scopes and bounded modes", async (t) => { + const f = fixture(t); + const h = f.harness; + await h.attach(); + assert.equal(f.snapshot.status.status, "paused"); + assert.equal(f.mutations(), 1); + const stack = await h.request<{ + stackFrames: { + id: number; + line: number; + column: number; + source: { sourceReference: number }; + }[]; + totalFrames: number; + }>("stackTrace", { threadId: 1 }); + assert.equal(stack.totalFrames, 1); + const frame = stack.stackFrames[0]!; + assert.equal(frame.line, 2); + assert.equal(frame.column, 5); + assert.deepEqual(await h.request("scopes", { frameId: frame.id }), { scopes: [] }); + assert.deepEqual(await h.request("source", { sourceReference: frame.source.sourceReference }), { + content: f.inspection.source, + mimeType: "text/x-rune", + }); + for (const command of ["stepIn", "next", "stepOut"]) { + const since = h.messages.length; + await h.request(command, { threadId: 1 }); + await h.wait((message) => message.event === "stopped", since); + } + assert.deepEqual(f.modes, ["into", "over", "out"]); + await assert.rejects(h.request("scopes", { frameId: frame.id }), /Stale/); + await assert.rejects(h.request("evaluate", { expression: "state" }), /Unsupported/); + await assert.rejects(h.request("next", { threadId: 2 }), /thread/); + await assert.rejects( + h.request("stepIn", { threadId: 1, granularity: "instruction" }), + /source-line/, + ); + await h.request("continue", { threadId: 1 }); + assert.equal(f.snapshot.status.status, "running"); + assert.equal(f.mutations(), 5); + const mutations = f.mutations(); + await h.request("disconnect", { terminateDebuggee: true }); + assert.equal(f.snapshot.status.status, "running"); + assert.equal(f.mutations(), mutations, "disconnect must not kill/resume/clear breakpoints"); + assert.ok(h.messages.some((message) => message.event === "terminated")); +}); + +test("DAP gutter breakpoints require exact loaded sources and explicitly reject unsupported forms", async (t) => { + const f = fixture(t); + const h = f.harness; + await h.attach(); + type Result = { breakpoints: { verified: boolean; line: number; message?: string }[] }; + const set = (path: string, lines = [2, 3]) => + h.request("setBreakpoints", { + source: { path }, + breakpoints: lines.map((line) => ({ line })), + }); + const path = "runa-image://127.0.0.1:3737/source/actors/test.rn"; + assert.deepEqual( + (await set(path)).breakpoints.map((bp) => bp.verified), + [true, false], + ); + const before = f.mutations(); + f.edit("newer image source"); + assert.equal((await set(path, [2])).breakpoints[0]?.verified, false); + assert.equal( + f.mutations(), + before + 1, + "a changed working document removes only its previous breakpoint group", + ); + assert.deepEqual(f.inspection.debugger?.breakpoints, []); + assert.equal( + (await set("runa-image://127.0.0.1:9999/source/actors/test.rn")).breakpoints[0]?.verified, + false, + ); + assert.equal(f.mutations(), before + 1, "unrelated files cannot replace actor breakpoints"); + f.edit(f.inspection.source); + f.unsave(); + assert.equal((await set(path, [2])).breakpoints[0]?.verified, false); + assert.equal(f.mutations(), before + 2); + const conditional = await h.request("setBreakpoints", { + source: { sourceReference: 1 }, + breakpoints: [{ line: 2, condition: "true" }], + }); + assert.match(conditional.breakpoints[0]!.message!, /not supported/); + assert.equal(conditional.breakpoints[0]?.verified, false); + assert.deepEqual(f.inspection.debugger?.breakpoints, []); + await h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: [{ line: 2 }] }); + assert.deepEqual(f.inspection.debugger?.breakpoints, [ + { line: 1, instruction_pointers: [12n] }, + ] satisfies SourceBreakpoint[]); + await set(path, []); + assert.deepEqual( + f.inspection.debugger?.breakpoints, + [{ line: 1, instruction_pointers: [12n] }], + "clearing an alias must not clear loaded-document breakpoints", + ); + const since = h.messages.length; + f.inspection.debugger!.breakpoints = []; + h.adapter.invalidate(); + const changed = await h.wait((message) => message.event === "breakpoint", since); + assert.equal((changed.body as { breakpoint: { verified: boolean } }).breakpoint.verified, false); + await h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: [] }); + assert.deepEqual(f.inspection.debugger?.breakpoints, []); +}); + +test("DAP reports external pauses/failures/removal and terminates on version or connection changes", async (t) => { + for (const change of [ + "failure", + "remove", + "implementation", + "incarnation", + "connection", + ] as const) { + const f = fixture(t); + const h = f.harness; + await h.attach(); + const since = h.messages.length; + if (change === "failure") f.snapshot.status = { status: "failed", message: "boom" }; + if (change === "remove") f.remove(); + if (change === "implementation") f.inspection.implementation_id = "replacement"; + if (change === "incarnation") f.inspection.incarnation++; + if (change === "connection") f.disconnect(); + h.adapter.invalidate(); + const event = await h.wait( + (message) => message.event === (change === "failure" ? "stopped" : "terminated"), + since, + ); + if (change === "failure") assert.equal((event.body as { reason: string }).reason, "exception"); + } +}); + +test("DAP exposes bounded budget/await/completion/failure/removal outcomes without retrying", async (t) => { + for (const reason of ["budget", "awaiting", "completed", "failed", "removed"] as const) { + const f = fixture(t); + const h = f.harness; + await h.attach(); + f.reason(reason); + const since = h.messages.length; + await h.request("next", { threadId: 1 }); + const event = await h.wait( + (message) => message.event === (reason === "removed" ? "terminated" : "stopped"), + since, + ); + assert.equal(f.modes.length, 1); + assert.ok( + h.messages.some( + (message) => + message.event === "output" && + JSON.stringify(message.body).includes(`Source step over: ${reason}`), + ), + ); + if (reason === "failed") assert.equal((event.body as { reason: string }).reason, "exception"); + if (reason === "completed") + assert.deepEqual(await h.request("stackTrace", { threadId: 1 }), { + stackFrames: [], + totalFrames: 0, + }); + } +}); + +test("DAP enforces the combined breakpoint limit and rejects malformed requests without mutation", async (t) => { + const f = fixture(t); + const h = f.harness; + await h.attach(); + const breakpoints = Array.from({ length: 128 }, (_, line) => ({ line: line + 2 })); + await h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints }); + const mutations = f.mutations(); + await assert.rejects( + h.request("setBreakpoints", { + source: { path: "runa-image://127.0.0.1:3737/source/actors/test.rn" }, + breakpoints: [{ line: 130 }], + }), + /128 combined/, + ); + await assert.rejects( + h.request("setBreakpoints", { + source: { sourceReference: 1 }, + breakpoints: [...breakpoints, { line: 130 }], + }), + /128 breakpoints/, + ); + await assert.rejects( + h.request("setBreakpoints", { + source: { sourceReference: 1 }, + breakpoints: [{ line: 0 }], + }), + /integer/, + ); + await assert.rejects( + h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: null }), + /Expected source breakpoints/, + ); + await assert.rejects(h.request("source", { sourceReference: 2 }), /Unknown loaded source/); + assert.equal(f.mutations(), mutations); + assert.equal(f.inspection.debugger?.breakpoints.length, 128); +}); + +test("DAP honors zero-based client coordinates, paging, unknown locations and failed attach", async (t) => { + const f = fixture(t); + const h = f.harness; + await h.request("initialize", { linesStartAt1: false, columnsStartAt1: false }); + await h.request("attach"); + await h.request("configurationDone"); + await h.wait((message) => message.event === "stopped"); + const result = await h.request<{ stackFrames: { line: number; column: number }[] }>( + "stackTrace", + { threadId: 1, startFrame: 0, levels: 1 }, + ); + assert.equal(result.stackFrames[0]?.line, 1); + assert.equal(result.stackFrames[0]?.column, 4); + assert.deepEqual(await h.request("stackTrace", { threadId: 1, startFrame: 1 }), { + stackFrames: [], + totalFrames: 1, + }); + f.inspection.execution!.frames[0]!.range = null; + const since = h.messages.length; + await h.request("next", { threadId: 1 }); + await h.wait((message) => message.event === "stopped", since); + const unmapped = await h.request<{ stackFrames: { line: number; source?: unknown }[] }>( + "stackTrace", + { threadId: 1 }, + ); + assert.equal(unmapped.stackFrames[0]?.line, 0); + assert.equal(unmapped.stackFrames[0]?.source, undefined); + const failed = fixture(t); + failed.snapshot.status = { status: "failed", message: "boom" }; + await assert.rejects(failed.harness.request("attach"), /nonfailed/); + assert.equal(failed.mutations(), 0); +});