2026-11-03 14:36:21 +00:00
|
|
|
import { isIP } from "node:net";
|
2026-11-03 13:52:28 +00:00
|
|
|
import { TextDecoder } from "node:util";
|
2026-11-03 14:36:21 +00:00
|
|
|
import WebSocket, { RawData } from "ws";
|
2026-11-03 14:54:07 +00:00
|
|
|
import { stringify } from "lossless-json";
|
2026-11-03 13:52:28 +00:00
|
|
|
import {
|
|
|
|
|
ActorSnapshot,
|
2026-11-03 19:37:05 +00:00
|
|
|
ExecutionInspection,
|
2026-11-03 19:53:43 +00:00
|
|
|
SourceBreakpoint,
|
|
|
|
|
DebugStopReason,
|
2026-11-03 20:14:06 +00:00
|
|
|
STEP_MODES,
|
|
|
|
|
StepMode,
|
2026-11-03 14:36:21 +00:00
|
|
|
decodeNotification,
|
2026-11-03 13:52:28 +00:00
|
|
|
decodeResponse,
|
2026-11-03 14:54:07 +00:00
|
|
|
decodeValue,
|
2026-11-03 13:52:28 +00:00
|
|
|
isActorRef,
|
2026-11-03 16:01:24 +00:00
|
|
|
isSourcePath,
|
|
|
|
|
isSourceRevision,
|
2026-11-03 14:36:21 +00:00
|
|
|
MAX_MESSAGE_BYTES,
|
2026-11-03 13:52:28 +00:00
|
|
|
PROTOCOL_VERSION,
|
|
|
|
|
ProtocolError,
|
2026-11-03 14:54:07 +00:00
|
|
|
Command,
|
|
|
|
|
Reply,
|
2026-11-03 16:01:24 +00:00
|
|
|
SourceCommit,
|
|
|
|
|
SourceFile,
|
2026-11-03 19:28:56 +00:00
|
|
|
SourceDiagnostic,
|
2026-11-03 16:01:24 +00:00
|
|
|
SystemMessage,
|
2026-11-03 14:54:07 +00:00
|
|
|
Value,
|
2026-11-03 14:36:21 +00:00
|
|
|
WEBSOCKET_SUBPROTOCOL,
|
2026-11-03 17:26:21 +00:00
|
|
|
LogLevel,
|
2026-11-03 19:11:39 +00:00
|
|
|
LogEntry,
|
2026-11-03 17:26:21 +00:00
|
|
|
LOG_LEVELS,
|
2026-11-03 13:52:28 +00:00
|
|
|
} from "./protocol";
|
|
|
|
|
|
|
|
|
|
export function parseEndpoint(endpoint: string): { host: string; port: number } {
|
|
|
|
|
const match = /^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/.exec(endpoint);
|
|
|
|
|
if (!match) throw new Error("Use a numeric loopback address, e.g. 127.0.0.1:3737 or [::1]:3737");
|
|
|
|
|
const host = match[1] ?? match[2];
|
|
|
|
|
if (
|
|
|
|
|
host === undefined ||
|
|
|
|
|
!((isIP(host) === 4 && host.startsWith("127.")) || (isIP(host) === 6 && host === "::1"))
|
|
|
|
|
) {
|
|
|
|
|
throw new Error("Only numeric IPv4 loopback or [::1] endpoints are supported");
|
|
|
|
|
}
|
|
|
|
|
const port = Number(match[3]);
|
2026-11-03 14:36:21 +00:00
|
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
|
|
|
throw new Error("Invalid WebSocket port");
|
2026-11-03 13:52:28 +00:00
|
|
|
return { host, port };
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 16:01:24 +00:00
|
|
|
function sourcePath(path: string): string {
|
|
|
|
|
if (!isSourcePath(path)) {
|
|
|
|
|
throw new Error("Source paths look like actors/counter (letters, digits, _ and -; no .rn)");
|
|
|
|
|
}
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sourceRevision(revision: string): string {
|
|
|
|
|
if (!isSourceRevision(revision)) throw new Error("Invalid source revision");
|
|
|
|
|
return revision;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 13:52:28 +00:00
|
|
|
interface Pending {
|
|
|
|
|
requestId: string;
|
2026-11-03 14:54:07 +00:00
|
|
|
command: Command;
|
|
|
|
|
resolve: (reply: Reply) => void;
|
2026-11-03 13:52:28 +00:00
|
|
|
reject: (error: Error) => void;
|
|
|
|
|
timer: NodeJS.Timeout;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class ImageClient {
|
|
|
|
|
private pending?: Pending;
|
|
|
|
|
private queue: Promise<unknown> = Promise.resolve();
|
|
|
|
|
private sequence = 0;
|
2026-11-03 14:36:21 +00:00
|
|
|
private revision = 0n;
|
2026-11-03 13:52:28 +00:00
|
|
|
private closed = false;
|
2026-11-03 14:36:21 +00:00
|
|
|
private ready = false;
|
2026-11-03 16:24:09 +00:00
|
|
|
private closing: Promise<void> = Promise.resolve();
|
2026-11-03 13:52:28 +00:00
|
|
|
|
|
|
|
|
private constructor(
|
2026-11-03 14:36:21 +00:00
|
|
|
private readonly socket: WebSocket,
|
2026-11-03 13:52:28 +00:00
|
|
|
private readonly onDisconnect: (error?: Error) => void,
|
2026-11-03 14:36:21 +00:00
|
|
|
private readonly onImageChanged: (revision: bigint) => void,
|
2026-11-03 13:52:28 +00:00
|
|
|
private readonly requestTimeout: number,
|
|
|
|
|
) {
|
2026-11-03 14:36:21 +00:00
|
|
|
socket.on("message", (data, binary) => this.receive(data, binary));
|
2026-11-03 13:52:28 +00:00
|
|
|
socket.on("error", (error) => this.close(error));
|
2026-11-03 14:36:21 +00:00
|
|
|
socket.on("close", () => this.close(new Error("Image WebSocket closed")));
|
2026-11-03 13:52:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static async connect(
|
|
|
|
|
endpoint: string,
|
|
|
|
|
onDisconnect: (error?: Error) => void,
|
2026-11-03 14:36:21 +00:00
|
|
|
onImageChanged: (revision: bigint) => void = () => {},
|
2026-11-03 13:52:28 +00:00
|
|
|
timeouts = { connect: 5000, request: 30000 },
|
|
|
|
|
): Promise<ImageClient> {
|
2026-11-03 14:36:21 +00:00
|
|
|
parseEndpoint(endpoint);
|
|
|
|
|
const socket = new WebSocket(`ws://${endpoint}/`, WEBSOCKET_SUBPROTOCOL, {
|
|
|
|
|
handshakeTimeout: timeouts.connect,
|
|
|
|
|
maxPayload: MAX_MESSAGE_BYTES,
|
|
|
|
|
perMessageDeflate: false,
|
|
|
|
|
followRedirects: false,
|
2026-11-03 13:52:28 +00:00
|
|
|
});
|
2026-11-03 14:36:21 +00:00
|
|
|
const client = new ImageClient(socket, onDisconnect, onImageChanged, timeouts.request);
|
|
|
|
|
try {
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
const fail = (error: Error) => {
|
|
|
|
|
socket.off("open", opened);
|
|
|
|
|
reject(error);
|
|
|
|
|
};
|
|
|
|
|
const opened = () => {
|
|
|
|
|
socket.off("error", fail);
|
|
|
|
|
if (socket.protocol !== WEBSOCKET_SUBPROTOCOL) {
|
|
|
|
|
reject(new Error("Server did not negotiate runa-debug"));
|
|
|
|
|
} else {
|
|
|
|
|
client.ready = true;
|
|
|
|
|
resolve();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
socket.once("error", fail);
|
|
|
|
|
socket.once("open", opened);
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
client.close();
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
return client;
|
2026-11-03 13:52:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async listActors(): Promise<ActorSnapshot[]> {
|
|
|
|
|
const reply = await this.request({ operation: "list_actors" });
|
|
|
|
|
if (reply.kind !== "actors") throw new Error("Expected actor listing");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 19:28:56 +00:00
|
|
|
async checkSource(source: string): Promise<SourceDiagnostic[]> {
|
|
|
|
|
const reply = await this.request({ operation: "check_source", parameters: { source } });
|
|
|
|
|
if (reply.kind !== "source_checked") throw new Error("Expected source diagnostics");
|
|
|
|
|
return reply.data.diagnostics;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 19:11:39 +00:00
|
|
|
async actorBehavior(actorRef: string): Promise<{ source: string; source_id: string }> {
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "actor_behavior",
|
|
|
|
|
parameters: { actor_ref: actorRef },
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "actor_behavior") throw new Error("Expected actor behavior");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async writeActorBehavior(
|
|
|
|
|
actorRef: string,
|
|
|
|
|
expected: string,
|
|
|
|
|
source: string,
|
|
|
|
|
): Promise<{ source: string; source_id: string }> {
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "write_actor_behavior",
|
|
|
|
|
parameters: { actor_ref: actorRef, expected_source_id: expected, source },
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "actor_behavior") throw new Error("Expected actor behavior");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async updateBehaviorSource(
|
|
|
|
|
actorRef: string,
|
|
|
|
|
expected: string,
|
|
|
|
|
temporary: string,
|
|
|
|
|
path: string,
|
|
|
|
|
): Promise<SourceFile> {
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "update_behavior_source",
|
|
|
|
|
parameters: {
|
|
|
|
|
actor_ref: actorRef,
|
|
|
|
|
expected_source_id: expected,
|
|
|
|
|
temporary_source_id: temporary,
|
|
|
|
|
path: sourcePath(path),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "source") throw new Error("Expected updated behavior source");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async renameSource(
|
|
|
|
|
path: string,
|
|
|
|
|
target: string,
|
|
|
|
|
expected: string,
|
|
|
|
|
updateActors: boolean,
|
|
|
|
|
): Promise<SourceFile> {
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "rename_source",
|
|
|
|
|
parameters: {
|
|
|
|
|
path: sourcePath(path),
|
|
|
|
|
target: sourcePath(target),
|
|
|
|
|
expected_source_id: expected,
|
|
|
|
|
update_actors: updateActors,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "source") throw new Error("Expected renamed source");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async deleteSource(path: string, expected: string): Promise<void> {
|
|
|
|
|
await this.request({
|
|
|
|
|
operation: "delete_source",
|
|
|
|
|
parameters: { path: sourcePath(path), expected_source_id: expected },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async logs(actorRef: string): Promise<LogEntry[]> {
|
|
|
|
|
const entries: LogEntry[] = [];
|
|
|
|
|
let after = 0n;
|
|
|
|
|
for (;;) {
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "logs",
|
|
|
|
|
parameters: { actors: [actorRef], follow: false, after },
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "logs") throw new Error("Expected actor logs");
|
|
|
|
|
entries.push(...reply.data.entries);
|
|
|
|
|
if (!reply.data.has_more) return entries;
|
|
|
|
|
if (reply.data.next_sequence <= after) throw new Error("Non-increasing log cursor");
|
|
|
|
|
after = reply.data.next_sequence;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 13:52:28 +00:00
|
|
|
async inspectActor(actorRef: string): Promise<ActorSnapshot> {
|
|
|
|
|
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference");
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "inspect_actor",
|
|
|
|
|
parameters: { actor_ref: actorRef },
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "actor") throw new Error("Expected actor snapshot");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 19:37:05 +00:00
|
|
|
async inspectExecution(actorRef: string): Promise<ExecutionInspection> {
|
|
|
|
|
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference");
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "inspect_execution",
|
|
|
|
|
parameters: { actor_ref: actorRef },
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "execution_inspected") throw new Error("Expected execution inspection");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
2026-11-03 19:53:43 +00:00
|
|
|
|
|
|
|
|
async setBreakpoints(
|
|
|
|
|
actorRef: string,
|
|
|
|
|
implementationId: string,
|
|
|
|
|
lines: number[],
|
|
|
|
|
): Promise<SourceBreakpoint[]> {
|
|
|
|
|
if (
|
|
|
|
|
!isActorRef(actorRef) ||
|
|
|
|
|
!implementationId ||
|
|
|
|
|
lines.length > 128 ||
|
|
|
|
|
lines.some((line) => !Number.isSafeInteger(line) || line < 0 || line > 4294967295)
|
|
|
|
|
)
|
|
|
|
|
throw new Error("Invalid actor, implementation or breakpoint lines");
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "set_breakpoints",
|
|
|
|
|
parameters: { actor_ref: actorRef, implementation_id: implementationId, lines },
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "breakpoints_set") throw new Error("Expected breakpoint verification");
|
|
|
|
|
return reply.data.breakpoints;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async stepSource(
|
|
|
|
|
actorRef: string,
|
|
|
|
|
implementationId: string,
|
|
|
|
|
budget = 1000,
|
2026-11-03 20:14:06 +00:00
|
|
|
mode: StepMode = "into",
|
2026-11-03 19:53:43 +00:00
|
|
|
): Promise<DebugStopReason> {
|
|
|
|
|
if (
|
|
|
|
|
!isActorRef(actorRef) ||
|
|
|
|
|
!implementationId ||
|
|
|
|
|
!Number.isSafeInteger(budget) ||
|
|
|
|
|
budget < 1 ||
|
2026-11-03 20:14:06 +00:00
|
|
|
budget > 10000 ||
|
|
|
|
|
!STEP_MODES.includes(mode)
|
2026-11-03 19:53:43 +00:00
|
|
|
)
|
|
|
|
|
throw new Error("Invalid actor, implementation or source step budget");
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "step_source",
|
|
|
|
|
parameters: {
|
|
|
|
|
actor_ref: actorRef,
|
|
|
|
|
implementation_id: implementationId,
|
|
|
|
|
instruction_budget: budget,
|
2026-11-03 20:14:06 +00:00
|
|
|
mode,
|
2026-11-03 19:53:43 +00:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "debug_step_completed") throw new Error("Expected source step result");
|
|
|
|
|
return reply.data.reason;
|
|
|
|
|
}
|
2026-11-03 16:01:24 +00:00
|
|
|
async listSources(): Promise<SourceFile[]> {
|
|
|
|
|
const reply = await this.request({ operation: "list_sources" });
|
|
|
|
|
if (reply.kind !== "sources") throw new Error("Expected source listing");
|
2026-11-03 14:54:07 +00:00
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 16:01:24 +00:00
|
|
|
async readSource(path: string): Promise<SourceFile> {
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "read_source",
|
|
|
|
|
parameters: { path: sourcePath(path) },
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "source") throw new Error("Expected source file");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Saves working source only if the image still holds `expectedSourceId` (null creates). */
|
|
|
|
|
async writeSource(
|
|
|
|
|
path: string,
|
|
|
|
|
expectedSourceId: string | null,
|
|
|
|
|
source: string,
|
|
|
|
|
): Promise<SourceFile> {
|
|
|
|
|
if (expectedSourceId !== null && (typeof expectedSourceId !== "string" || !expectedSourceId)) {
|
|
|
|
|
throw new Error("Expected source identity must be a non-empty string or null");
|
2026-11-03 14:57:43 +00:00
|
|
|
}
|
2026-11-03 16:01:24 +00:00
|
|
|
if (typeof source !== "string") throw new Error("Source must be text");
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "write_source",
|
|
|
|
|
parameters: { path: sourcePath(path), expected_source_id: expectedSourceId, source },
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "source") throw new Error("Expected source file");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async commitSource(path: string): Promise<SourceCommit> {
|
2026-11-03 14:57:43 +00:00
|
|
|
const reply = await this.request({
|
2026-11-03 16:01:24 +00:00
|
|
|
operation: "commit_source",
|
|
|
|
|
parameters: { path: sourcePath(path) },
|
2026-11-03 14:57:43 +00:00
|
|
|
});
|
2026-11-03 16:01:24 +00:00
|
|
|
if (reply.kind !== "source_committed") throw new Error("Expected source commit");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async sourceHistory(path: string): Promise<SourceCommit[]> {
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "source_history",
|
|
|
|
|
parameters: { path: sourcePath(path) },
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "source_history") throw new Error("Expected source history");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async revisionSource(path: string, revision: string): Promise<string> {
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "revision_source",
|
|
|
|
|
parameters: { path: sourcePath(path), revision: sourceRevision(revision) },
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "behavior_source") throw new Error("Expected revision source");
|
2026-11-03 14:57:43 +00:00
|
|
|
return reply.data.source;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 16:01:24 +00:00
|
|
|
async rollbackSource(path: string, revision: string): Promise<SourceFile> {
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "rollback_source",
|
|
|
|
|
parameters: { path: sourcePath(path), revision: sourceRevision(revision) },
|
|
|
|
|
});
|
|
|
|
|
if (reply.kind !== "source") throw new Error("Expected source file");
|
|
|
|
|
return reply.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async spawn(path: string, state: Value, capacity = 64): Promise<string> {
|
2026-11-03 14:54:07 +00:00
|
|
|
if (!Number.isSafeInteger(capacity) || capacity < 1)
|
|
|
|
|
throw new Error("Mailbox capacity must be positive");
|
|
|
|
|
const reply = await this.request({
|
|
|
|
|
operation: "spawn",
|
2026-11-03 16:01:24 +00:00
|
|
|
parameters: { path: sourcePath(path), state: decodeValue(state), mailbox_capacity: capacity },
|
2026-11-03 14:54:07 +00:00
|
|
|
});
|
|
|
|
|
if (reply.kind !== "actor_spawned") throw new Error("Expected spawned actor reference");
|
|
|
|
|
return reply.data.actor_ref;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async send(actorRef: string, message: Value): Promise<void> {
|
|
|
|
|
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference");
|
|
|
|
|
await this.request({
|
|
|
|
|
operation: "send",
|
|
|
|
|
parameters: { actor_ref: actorRef, message: decodeValue(message) },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 17:26:21 +00:00
|
|
|
async setLogLevel(actorRef: string, level: LogLevel): Promise<void> {
|
|
|
|
|
if (!isActorRef(actorRef) || !LOG_LEVELS.includes(level))
|
|
|
|
|
throw new Error("Invalid actor or log level");
|
|
|
|
|
await this.request({ operation: "set_log_level", parameters: { actor_ref: actorRef, level } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async editActor(
|
|
|
|
|
actorRef: string,
|
|
|
|
|
state: Value | null,
|
|
|
|
|
messages: { reserved: Value | null; queued: Value[] } | null,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference");
|
|
|
|
|
await this.request({
|
|
|
|
|
operation: "edit_actor",
|
|
|
|
|
parameters: {
|
|
|
|
|
actor_ref: actorRef,
|
|
|
|
|
state: state === null ? null : decodeValue(state),
|
|
|
|
|
messages:
|
|
|
|
|
messages === null
|
|
|
|
|
? null
|
|
|
|
|
: {
|
|
|
|
|
reserved: messages.reserved === null ? null : decodeValue(messages.reserved),
|
|
|
|
|
queued: messages.queued.map(decodeValue),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 16:01:24 +00:00
|
|
|
async system(actorRef: string, message: SystemMessage): Promise<void> {
|
2026-11-03 14:54:07 +00:00
|
|
|
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference");
|
2026-11-03 16:01:24 +00:00
|
|
|
const kinds = ["stop", "kill", "restart", "pause", "resume", "reload", "become"];
|
|
|
|
|
if (typeof message !== "object" || message === null || !kinds.includes(message.kind)) {
|
|
|
|
|
throw new Error("Unsupported system message");
|
|
|
|
|
}
|
|
|
|
|
const checked: SystemMessage =
|
|
|
|
|
message.kind === "become"
|
|
|
|
|
? { kind: "become", path: sourcePath(message.path) }
|
|
|
|
|
: { kind: message.kind };
|
|
|
|
|
await this.request({
|
|
|
|
|
operation: "system",
|
|
|
|
|
parameters: { actor_ref: actorRef, message: checked },
|
|
|
|
|
});
|
2026-11-03 14:54:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async tick(ticks: number, budget = 32): Promise<void> {
|
|
|
|
|
if (!Number.isSafeInteger(ticks) || ticks < 1 || !Number.isSafeInteger(budget) || budget < 1) {
|
|
|
|
|
throw new Error("Ticks and instruction budget must be positive safe integers");
|
|
|
|
|
}
|
|
|
|
|
await this.request({ operation: "tick", parameters: { ticks, instruction_budget: budget } });
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-04 00:57:37 +00:00
|
|
|
async saveImage(file: string): Promise<void> {
|
|
|
|
|
await this.snapshotFile("save_image", file);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async restoreImage(file: string): Promise<void> {
|
|
|
|
|
await this.snapshotFile("restore_image", file);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async snapshotFile(
|
|
|
|
|
operation: "save_image" | "restore_image",
|
|
|
|
|
file: string,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (!file.trim() || Buffer.byteLength(file, "utf8") > 4096 || file.includes("\0"))
|
|
|
|
|
throw new Error("Snapshot file must be a nonempty server-local path of at most 4096 bytes");
|
|
|
|
|
await this.request({ operation, parameters: { file } });
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 14:54:07 +00:00
|
|
|
private request(command: Command): Promise<Reply> {
|
2026-11-03 13:52:28 +00:00
|
|
|
const request = this.queue.then(
|
|
|
|
|
() =>
|
2026-11-03 14:54:07 +00:00
|
|
|
new Promise<Reply>((resolve, reject) => {
|
2026-11-03 13:52:28 +00:00
|
|
|
if (this.closed) {
|
|
|
|
|
reject(new Error("Not connected to an image"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const requestId = `runa-vscode-${++this.sequence}`;
|
2026-11-03 14:54:07 +00:00
|
|
|
const message = stringify({
|
2026-11-03 13:52:28 +00:00
|
|
|
version: PROTOCOL_VERSION,
|
|
|
|
|
request_id: requestId,
|
|
|
|
|
command,
|
|
|
|
|
});
|
2026-11-03 14:54:07 +00:00
|
|
|
if (message === undefined || Buffer.byteLength(message) > MAX_MESSAGE_BYTES) {
|
|
|
|
|
reject(new Error("Request exceeds 1 MiB"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-11-03 13:52:28 +00:00
|
|
|
const timer = setTimeout(
|
|
|
|
|
() => this.close(new Error("Image request timed out; connection closed without retry")),
|
|
|
|
|
this.requestTimeout,
|
|
|
|
|
);
|
|
|
|
|
this.pending = { requestId, command, resolve, reject, timer };
|
2026-11-03 14:36:21 +00:00
|
|
|
this.socket.send(message, (error) => {
|
2026-11-03 13:52:28 +00:00
|
|
|
if (error) this.close(error);
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-11-03 14:36:21 +00:00
|
|
|
// Each caller retains its own rejection, without poisoning subsequent requests.
|
2026-11-03 13:52:28 +00:00
|
|
|
this.queue = request.then(
|
|
|
|
|
() => undefined,
|
|
|
|
|
() => undefined,
|
|
|
|
|
);
|
|
|
|
|
return request;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 14:36:21 +00:00
|
|
|
private receive(data: RawData, binary: boolean): void {
|
2026-11-03 13:52:28 +00:00
|
|
|
if (this.closed) return;
|
2026-11-03 14:36:21 +00:00
|
|
|
try {
|
|
|
|
|
if (binary) throw new Error("Only WebSocket text protocol messages are accepted");
|
|
|
|
|
const bytes = Array.isArray(data)
|
|
|
|
|
? Buffer.concat(data)
|
|
|
|
|
: data instanceof ArrayBuffer
|
|
|
|
|
? Buffer.from(data)
|
|
|
|
|
: data;
|
|
|
|
|
if (bytes.length > MAX_MESSAGE_BYTES) throw new Error("Image response exceeds 1 MiB");
|
|
|
|
|
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
|
|
|
const revision = decodeNotification(text);
|
|
|
|
|
if (revision !== undefined) {
|
|
|
|
|
if (revision <= this.revision)
|
|
|
|
|
throw new Error("Non-increasing image notification revision");
|
|
|
|
|
this.revision = revision;
|
|
|
|
|
this.onImageChanged(revision);
|
2026-11-03 13:52:28 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const pending = this.pending;
|
2026-11-03 14:36:21 +00:00
|
|
|
if (!pending) throw new Error("Unexpected unsolicited image response");
|
2026-11-03 13:52:28 +00:00
|
|
|
try {
|
2026-11-03 14:36:21 +00:00
|
|
|
pending.resolve(decodeResponse(text, pending.requestId, pending.command));
|
2026-11-03 13:52:28 +00:00
|
|
|
} catch (error) {
|
2026-11-03 14:36:21 +00:00
|
|
|
if (!(error instanceof ProtocolError)) throw error;
|
|
|
|
|
pending.reject(error);
|
2026-11-03 13:52:28 +00:00
|
|
|
}
|
2026-11-03 14:36:21 +00:00
|
|
|
this.pending = undefined;
|
|
|
|
|
clearTimeout(pending.timer);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.close(error instanceof Error ? error : new Error(String(error)));
|
2026-11-03 13:52:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 16:24:09 +00:00
|
|
|
close(error?: Error): Promise<void> {
|
|
|
|
|
if (this.closed) return this.closing;
|
2026-11-03 13:52:28 +00:00
|
|
|
this.closed = true;
|
2026-11-03 16:24:09 +00:00
|
|
|
this.closing = new Promise<void>((resolve) => {
|
|
|
|
|
if (this.socket.readyState === WebSocket.CLOSED) {
|
|
|
|
|
resolve();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const timer = setTimeout(() => this.socket.terminate(), 5000);
|
|
|
|
|
timer.unref();
|
|
|
|
|
this.socket.once("close", () => {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
resolve();
|
|
|
|
|
});
|
|
|
|
|
if (error || this.socket.readyState === WebSocket.CONNECTING) {
|
|
|
|
|
this.socket.terminate();
|
|
|
|
|
} else {
|
|
|
|
|
this.socket.close(1000);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-11-03 13:52:28 +00:00
|
|
|
if (this.pending) {
|
|
|
|
|
clearTimeout(this.pending.timer);
|
|
|
|
|
this.pending.reject(error ?? new Error("Disconnected from image"));
|
|
|
|
|
this.pending = undefined;
|
|
|
|
|
}
|
2026-11-03 14:36:21 +00:00
|
|
|
if (this.ready) this.onDisconnect(error);
|
2026-11-03 16:24:09 +00:00
|
|
|
return this.closing;
|
2026-11-03 13:52:28 +00:00
|
|
|
}
|
|
|
|
|
}
|