320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
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<DapMessage> =>
|
|
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<T>(command: string, args: unknown = {}): Promise<T> {
|
|
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(
|
|
"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.address);
|
|
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(
|
|
"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);
|
|
},
|
|
);
|