import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { connect } from "node:net"; import { parseEndpoint } from "../src/client"; 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 { host, port } = parseEndpoint(address); const socket = connect({ host, port }); const messages: DapMessage[] = []; const changed = new EventEmitter(); let buffer = Buffer.alloc(0); let sequence = 0; let failure: Error | undefined; const fail = (error: Error) => { failure = error; changed.emit("message"); }; socket.on("error", fail); socket.on("close", () => fail(new Error("DAP connection closed"))); socket.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: ${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 })); socket.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() { socket.destroy(); }, }; } test( "image-wide Rust DAP discovers actors without pausing and isolates behavior breakpoints", { timeout: 20000 }, async (t) => { const image = await startImage(); const client = await ImageClient.connect(image.address, (error) => assert.equal(error, undefined), ); const adapter = startAdapter(image.dapAddress); t.after(async () => { try { await adapter.stop(); } finally { try { await client.close(); } finally { await image.stop(); } } }); const source = "pub fn handle(ctx, state, sender, message) {\n state + 1\n}"; const file = await client.writeSource("tests/image_dap", null, source); const actor = await client.spawn(file.path, parseValue("10")); const other = await client.spawn(file.path, parseValue("20")); const before = await client.inspectActor(actor); const otherBefore = await client.inspectActor(other); await adapter.request("initialize"); await adapter.request("attach", { image: true, actorSourceBaseUri: "runa-actor://1", workingSourceBaseUri: `runa-image://${image.address}/source`, }); await adapter.request("configurationDone"); const threads = await adapter.request<{ threads: { id: number; name: string }[] }>("threads"); const thread = threads.threads.find((thread) => thread.name.includes(actor))!; const otherThread = threads.threads.find((thread) => thread.name.includes(other))!; assert.ok(thread); assert.ok(otherThread); assert.notEqual(thread.id, otherThread.id); assert.deepEqual(await client.inspectActor(actor), before); assert.deepEqual(await client.inspectActor(other), otherBefore); const result = await adapter.request<{ breakpoints: { verified: boolean }[] }>( "setBreakpoints", { source: { path: `runa-actor://1/${actor}/behavior.rn` }, breakpoints: [{ line: 2 }], }, ); assert.equal(result.breakpoints[0]?.verified, true); assert.equal((await client.inspectExecution(other)).debugger?.breakpoints.length ?? 0, 0); const since = adapter.messages.length; await client.send(actor, parseValue("null")); await client.tick(1, 1000); const stopped = await adapter.wait((message) => message.event === "stopped", since); assert.equal((stopped.body as { threadId: number }).threadId, thread.id); assert.equal((stopped.body as { allThreadsStopped: boolean }).allThreadsStopped, false); const stack = await adapter.request<{ stackFrames: { id: number; source: { path: string; sourceReference: number } }[]; }>("stackTrace", { threadId: thread.id }); assert.equal(stack.stackFrames[0]?.source.path, `runa-actor://1/${actor}/behavior.rn`); assert.deepEqual(await client.inspectActor(other), otherBefore); const added = await client.spawn(file.path, parseValue("30")); const discovered = await adapter.request<{ threads: { id: number; name: string }[] }>( "threads", ); assert.ok(discovered.threads.some((thread) => thread.name.includes(added))); assert.equal((await client.inspectActor(added)).status.status, "idle"); await client.system(actor, { kind: "kill" }); const remaining = await adapter.request<{ threads: { id: number; name: string }[] }>("threads"); assert.ok(!remaining.threads.some((entry) => entry.id === thread.id)); assert.ok(remaining.threads.some((entry) => entry.id === otherThread.id)); await adapter.request("disconnect"); assert.deepEqual(await client.inspectActor(other), otherBefore); }, ); test( "image server 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.dapAddress); 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( "image server 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.dapAddress); 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); }, );