import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { EventEmitter, once } from "node:events"; import { resolve } from "node:path"; import test from "node:test"; import { ImageClient } from "../src/client"; import { parseValue } from "../src/values"; import { startImage } from "./server"; interface DapMessage { type: string; request_seq?: number; success?: boolean; message?: string; event?: string; body?: unknown; } function startAdapter(address: string) { const child = spawn( resolve( __dirname, "../../../target/debug", process.platform === "win32" ? "runa-dap.exe" : "runa-dap", ), ["--connect", address], { stdio: ["pipe", "pipe", "pipe"] }, ); const messages: DapMessage[] = []; const changed = new EventEmitter(); let buffer = Buffer.alloc(0); let sequence = 0; let failure: Error | undefined; let diagnostics = ""; const fail = (error: Error) => { failure = error; changed.emit("message"); }; child.on("error", fail); child.stderr.on("data", (chunk: Buffer) => { diagnostics += chunk.toString(); }); child.on("exit", (code) => fail(new Error(`DAP exited (${code}): ${diagnostics}`))); child.stdout.on("data", (chunk: Buffer) => { try { buffer = Buffer.concat([buffer, chunk]); for (;;) { const end = buffer.indexOf("\r\n\r\n"); if (end < 0) return; const match = /^Content-Length: (\d+)$/im.exec(buffer.subarray(0, end).toString()); assert.ok(match, "DAP stdout must contain framed messages only"); const length = Number(match[1]); if (buffer.length < end + 4 + length) return; const message: DapMessage = JSON.parse( buffer.subarray(end + 4, end + 4 + length).toString(), ); buffer = buffer.subarray(end + 4 + length); messages.push(message); changed.emit("message"); } } catch (error) { fail(error instanceof Error ? error : new Error(String(error))); } }); const wait = (predicate: (message: DapMessage) => boolean, since = 0): Promise => new Promise((resolve, reject) => { const cleanup = () => { clearTimeout(timer); changed.off("message", check); }; const check = () => { const found = messages.slice(since).find(predicate); if (found) { cleanup(); resolve(found); } else if (failure) { cleanup(); reject(failure); } }; const timer = setTimeout(() => { cleanup(); reject( new Error( `DAP response timed out: ${diagnostics}; ${JSON.stringify(messages.slice(-5))}`, ), ); }, 5000); changed.on("message", check); check(); }); return { messages, wait, async request(command: string, args: unknown = {}): Promise { const seq = ++sequence; const body = Buffer.from(JSON.stringify({ seq, type: "request", command, arguments: args })); child.stdin.write( Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]), ); const response = await wait( (message) => message.type === "response" && message.request_seq === seq, ); if (!response.success) throw new Error(response.message); return response.body as T; }, async stop() { if (child.exitCode !== null || child.signalCode !== null) return; const exited = once(child, "exit"); child.kill(); await exited; }, }; } test( "standalone Rust DAP binds behavior.rn breakpoints to one actor and serves exact loaded code", { timeout: 20000 }, async (t) => { const image = await startImage(); t.after(async () => { try { await client.close(); } finally { await image.stop(); } }); const client = await ImageClient.connect(image.address, (error) => assert.equal(error, undefined), ); const source = "pub fn handle(ctx, state, sender, message) {\n let next = state + 1;\n next\n}"; const file = await client.writeSource("tests/dap_server", null, source); const actor = await client.spawn(file.path, parseValue("10")); const other = await client.spawn(file.path, parseValue("20")); const uri = `runa-actor://1/${actor}/behavior.rn`; const adapter = startAdapter(image.address); t.after(() => adapter.stop()); await adapter.request("initialize", { linesStartAt1: true, columnsStartAt1: true }); await adapter.request("attach", { actorRef: actor, behaviorSourceUri: uri }); await adapter.request("configurationDone"); await adapter.wait((message) => message.event === "stopped"); assert.equal((await client.inspectActor(actor)).status.status, "paused"); assert.equal((await client.inspectActor(other)).status.status, "idle"); const breakpoints = await adapter.request<{ breakpoints: { verified: boolean }[] }>( "setBreakpoints", { source: { path: uri }, breakpoints: [{ line: 2 }] }, ); assert.equal(breakpoints.breakpoints[0]?.verified, true); assert.equal((await client.inspectExecution(other)).debugger?.breakpoints.length ?? 0, 0); const dirty = await adapter.request<{ breakpoints: { verified: boolean }[] }>( "setBreakpoints", { source: { path: uri }, sourceModified: true, breakpoints: [{ line: 2 }] }, ); assert.equal(dirty.breakpoints[0]?.verified, false); await adapter.request("setBreakpoints", { source: { path: uri }, breakpoints: [{ line: 2 }] }); assert.deepEqual(await adapter.request("source", { sourceReference: 1 }), { content: source, mimeType: "text/x-rune", }); await adapter.request("continue", { threadId: 1 }); assert.equal((await client.inspectActor(actor)).execution, null, "continue must not tick"); const since = adapter.messages.length; await client.send(actor, parseValue("null")); await client.tick(1, 1000); await adapter.wait((message) => message.event === "stopped", since); const stack = await adapter.request<{ stackFrames: { id: number; line: number; source: { name: string } }[]; }>("stackTrace", { threadId: 1 }); assert.equal(stack.stackFrames[0]?.line, 2); assert.equal(stack.stackFrames[0]?.source.name, "behavior.rn"); assert.deepEqual((await client.inspectActor(actor)).state, parseValue("10")); await assert.rejects(adapter.request("evaluate", { expression: "state + 1", context: "repl" })); await adapter.request("disconnect"); assert.equal((await client.inspectActor(actor)).status.status, "paused"); }, ); test( "standalone Rust DAP inspects failed actors without retrying their invocation", { timeout: 20000 }, async (t) => { const image = await startImage(); t.after(async () => { try { await client.close(); } finally { await image.stop(); } }); const client = await ImageClient.connect(image.address, (error) => assert.equal(error, undefined), ); const file = await client.writeSource( "tests/dap_failure", null, 'pub fn handle(ctx, state, sender, message) { panic("DAP failure probe") }', ); const actor = await client.spawn(file.path, parseValue("42")); await client.send(actor, parseValue('{"op":"fail"}')); await client.tick(1, 1000); const before = await client.inspectActor(actor); assert.equal(before.status.status, "failed"); const adapter = startAdapter(image.address); t.after(() => adapter.stop()); await adapter.request("initialize"); await adapter.request("attach", { actorRef: actor }); await adapter.request("configurationDone"); const stop = await adapter.wait((message) => message.event === "stopped"); assert.equal((stop.body as { reason: string }).reason, "exception"); const exception = await adapter.request<{ description: string }>("exceptionInfo", { threadId: 1, }); assert.equal(exception.description, before.status.message); assert.deepEqual(await adapter.request("stackTrace", { threadId: 1 }), { stackFrames: [], totalFrames: 0, }); assert.equal( ( await adapter.request<{ result: string }>("evaluate", { expression: "actor/committed_state", context: "watch", }) ).result, "42", ); assert.equal( ( await adapter.request<{ result: string }>("evaluate", { expression: "actor/mailbox/reserved/0/op", context: "repl", }) ).result, '"fail"', ); for (const command of ["continue", "pause", "next", "stepIn", "stepOut"]) { await assert.rejects(adapter.request(command, { threadId: 1 }), /repair\/restart explicitly/); } await adapter.request("disconnect"); assert.deepEqual(await client.inspectActor(actor), before); }, );