@henols/c64-re-tools 1.1.0 → 2.0.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +138 -37
- package/distribution/package.ts +36 -0
- package/distribution/plugin.ts +120 -0
- package/package.json +34 -40
- package/skills/c64-assembler/SKILL.md +73 -0
- package/skills/c64-assembler/scripts/assemble.ts +103 -0
- package/skills/c64-basic/SKILL.md +67 -0
- package/skills/c64-basic/scripts/basic.ts +46 -0
- package/skills/c64-disk/SKILL.md +79 -0
- package/skills/c64-disk/scripts/disk.ts +65 -0
- package/skills/c64-emulator/SKILL.md +124 -0
- package/skills/c64-knowledge/SKILL.md +102 -0
- package/skills/c64-knowledge/scripts/knowledge.ts +257 -0
- package/skills/c64-memory-map/SKILL.md +76 -0
- package/skills/c64-memory-map/scripts/memmap.ts +81 -0
- package/skills/c64-memory-map/scripts/platform.ts +424 -0
- package/skills/c64-provenance/SKILL.md +76 -0
- package/skills/c64-provenance/scripts/compare.ts +92 -0
- package/skills/c64-provenance/scripts/provenance.ts +83 -0
- package/skills/c64-reverse-engineering/SKILL.md +90 -0
- package/skills/c64-static-analysis/SKILL.md +96 -0
- package/skills/c64-static-analysis/scripts/analyze.ts +175 -0
- package/skills/c64-static-analysis/scripts/findings.ts +73 -0
- package/skills/c64-testing/SKILL.md +79 -0
- package/skills/c64-testing/references/scenario-format.md +76 -0
- package/skills/c64-testing/scripts/checklist.ts +51 -0
- package/skills/c64-testing/scripts/scenario.ts +473 -0
- package/skills/c64-testing/scripts/test.ts +55 -0
- package/skills/c64-unpacker/SKILL.md +82 -0
- package/skills/c64-unpacker/scripts/evidence.ts +135 -0
- package/skills/c64-unpacker/scripts/unpack.ts +216 -0
- package/src/c64.ts +281 -0
- package/src/cli/ap-sdk.ts +44 -0
- package/src/cli/cleanup.ts +194 -0
- package/src/cli/main.ts +122 -0
- package/src/cli/status.ts +34 -0
- package/src/host/main.ts +106 -0
- package/src/host/server.ts +376 -0
- package/src/host/tools/c1541.ts +255 -0
- package/src/host/tools/index.ts +31 -0
- package/src/host/tools/petcat.ts +70 -0
- package/src/host/tools/status.ts +30 -0
- package/src/host/tools/version.ts +41 -0
- package/src/host/vice/adapter.ts +761 -0
- package/src/host/vice/binary-monitor.ts +389 -0
- package/src/host/vice/chips.ts +108 -0
- package/src/host/vice/connect.ts +34 -0
- package/src/host/vice/process.ts +402 -0
- package/src/host/vice/screen.ts +183 -0
- package/src/host/vice/session.ts +1316 -0
- package/src/host/vice/text-monitor.ts +223 -0
- package/src/host-client/connect.ts +238 -0
- package/src/host-client/tools.ts +89 -0
- package/src/host-client/transfer.ts +100 -0
- package/src/host-client/vice-session.ts +254 -0
- package/src/knowledge/database.ts +132 -0
- package/src/knowledge/history.ts +128 -0
- package/src/knowledge/import.ts +344 -0
- package/src/knowledge/read.ts +191 -0
- package/src/knowledge/schema.ts +140 -0
- package/src/knowledge/write.ts +348 -0
- package/src/mcp/main.ts +106 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/tools/debug.ts +237 -0
- package/src/mcp/tools/execution.ts +116 -0
- package/src/mcp/tools/input.ts +69 -0
- package/src/mcp/tools/machine.ts +97 -0
- package/src/mcp/tools/media.ts +106 -0
- package/src/mcp/tools/memory.ts +203 -0
- package/src/mcp/tools/video.ts +260 -0
- package/src/native/acme.ts +116 -0
- package/src/native/discover.ts +141 -0
- package/src/native/dxa.ts +166 -0
- package/src/native/ghidra/analyze.ts +86 -0
- package/src/native/ghidra/index.ts +157 -0
- package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
- package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
- package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
- package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
- package/src/native/ghidra/scripts/C64Export.java +242 -0
- package/src/native/ghidra/scripts/C64Prepare.java +60 -0
- package/src/native/local.ts +34 -0
- package/src/native/processes.ts +350 -0
- package/src/native/run.ts +172 -0
- package/src/native/staging.ts +83 -0
- package/src/native/status.ts +101 -0
- package/src/native/types.ts +228 -0
- package/src/native/watchdog.ts +78 -0
- package/src/project.ts +39 -0
- package/src/protocol/framing.ts +132 -0
- package/src/protocol/messages.ts +243 -0
- package/src/protocol/tools.ts +247 -0
- package/src/protocol/vice.ts +1235 -0
- package/src/script.ts +59 -0
- package/src/time.ts +15 -0
- package/src/trace.ts +162 -0
- package/THIRD-PARTY-NOTICES.md +0 -26
- package/bin/cli.mjs +0 -283
- package/skills/acme-build/SKILL.md +0 -260
- package/skills/acme-build/scripts/acme.mjs +0 -341
- package/skills/acme-build/template.a +0 -53
- package/skills/c64-disk-access/SKILL.md +0 -159
- package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
- package/skills/c64-memory-mapping/SKILL.md +0 -631
- package/skills/c64-memory-mapping/memmap.json +0 -8800
- package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
- package/skills/c64-petcat/SKILL.md +0 -87
- package/skills/c64-petcat/scripts/petcat.mjs +0 -221
- package/skills/c64-program-recon/SKILL.md +0 -706
- package/skills/c64-program-recon/references/control-flow.md +0 -180
- package/skills/c64-program-recon/references/graphics.md +0 -79
- package/skills/c64-program-recon/references/observation-hazards.md +0 -143
- package/skills/c64-program-recon/references/reconstruction.md +0 -133
- package/skills/c64-program-recon/references/sound-and-input.md +0 -74
- package/skills/c64-program-recon/references/tool-selection.md +0 -78
- package/skills/c64-program-recon/scripts/derive.mjs +0 -364
- package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
- package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
- package/skills/c64-provenance-diff/SKILL.md +0 -292
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
- package/skills/c64-ram-capture/RELEASES.json.example +0 -17
- package/skills/c64-ram-capture/SKILL.md +0 -474
- package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
- package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
- package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
- package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
- package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
- package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
- package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
- package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
- package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
- package/skills/c64-ram-capture/transients/README.md +0 -137
- package/skills/routine-queue-walker/SKILL.md +0 -394
- package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
- package/skills/vice-wedge-triage/SKILL.md +0 -269
package/src/host/main.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_HOST_PORT } from "../protocol/messages.ts";
|
|
6
|
+
import { ProcessSupervisor } from "../native/processes.ts";
|
|
7
|
+
import { startTrace, trace } from "../trace.ts";
|
|
8
|
+
import { ListenError, parsePort, startHostServer, stopSignal } from "./server.ts";
|
|
9
|
+
import { createToolDispatcher } from "./tools/index.ts";
|
|
10
|
+
import { checkViceStarts, findVice } from "./vice/process.ts";
|
|
11
|
+
import { viceSessionFactory } from "./vice/session.ts";
|
|
12
|
+
|
|
13
|
+
const HELP = `c64-re-tools-host
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
npx -y --package=@henols/c64-re-tools@latest c64-re-tools-host [--port <port>]
|
|
17
|
+
c64-re-tools-host --help
|
|
18
|
+
|
|
19
|
+
Runs the c64-re-tools Host Runtime in the foreground. It first starts VICE
|
|
20
|
+
once (x64sc on PATH, or C64RT_VICE) and does not start if VICE cannot run.
|
|
21
|
+
It listens on port ${DEFAULT_HOST_PORT} on every address, so a dev container
|
|
22
|
+
reaches it as host.docker.internal. It starts one VICE emulator for each
|
|
23
|
+
connected c64-re-tools-mcp process, and runs the tools that come with VICE
|
|
24
|
+
(c1541, petcat) for skill scripts. Stop it with Ctrl+C; that stops every
|
|
25
|
+
emulator and tool it started.
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--port <port> Listen on this port instead (0 picks a free port).
|
|
29
|
+
--help Show this help.`;
|
|
30
|
+
|
|
31
|
+
function log(line: string): void {
|
|
32
|
+
process.stderr.write(`c64-re-tools-host: ${line}\n`);
|
|
33
|
+
trace().log(line);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function main(): Promise<number> {
|
|
37
|
+
let values: { help?: boolean; port?: string };
|
|
38
|
+
try {
|
|
39
|
+
({ values } = parseArgs({ options: { help: { type: "boolean" }, port: { type: "string" } }, strict: true }));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${HELP}\n`);
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
if (values.help) {
|
|
45
|
+
process.stdout.write(`${HELP}\n`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
startTrace("host", { warn: log });
|
|
49
|
+
|
|
50
|
+
const port = values.port === undefined ? DEFAULT_HOST_PORT : parsePort(values.port);
|
|
51
|
+
if (port === undefined) {
|
|
52
|
+
process.stderr.write(`--port must be a number from 0 to 65535, written with digits only. You gave "${values.port}".\n`);
|
|
53
|
+
return 2;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Without an emulator the runtime is useless: refuse to start, by name and with the remedy.
|
|
57
|
+
try {
|
|
58
|
+
findVice(process.env);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
process.stderr.write(`${(error as Error).message}\n`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Every emulator and tool is started through this one supervisor; however
|
|
65
|
+
// this process ends, the exit guard takes their process groups down with it,
|
|
66
|
+
// and the watchdog does the same if this process is killed.
|
|
67
|
+
const supervisor = new ProcessSupervisor();
|
|
68
|
+
supervisor.startWatchdog("host");
|
|
69
|
+
|
|
70
|
+
// Finding x64sc is not enough: VICE without its ROM files exits at once.
|
|
71
|
+
try {
|
|
72
|
+
await checkViceStarts({ supervisor });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
process.stderr.write(`${(error as Error).message}\n`);
|
|
75
|
+
await supervisor.stopAll();
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
log("VICE starts");
|
|
79
|
+
|
|
80
|
+
let server;
|
|
81
|
+
try {
|
|
82
|
+
server = await startHostServer({
|
|
83
|
+
host: "0.0.0.0",
|
|
84
|
+
port,
|
|
85
|
+
log,
|
|
86
|
+
createViceSession: viceSessionFactory({ supervisor, log }),
|
|
87
|
+
tools: createToolDispatcher({ supervisor, log }),
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof ListenError && error.code === "EADDRINUSE") {
|
|
91
|
+
process.stderr.write(`Port ${error.port} on ${error.address} is in use. Is another c64-re-tools-host running?\n`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
const address = `${server.host}:${server.port}`;
|
|
97
|
+
process.stdout.write(`c64-re-tools-host listening on ${address}\n`);
|
|
98
|
+
trace().event("host.listening", { addresses: [address] });
|
|
99
|
+
|
|
100
|
+
trace().event("host.stop", { signal: await stopSignal(log) });
|
|
101
|
+
await server.close();
|
|
102
|
+
await supervisor.stopAll();
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
process.exitCode = await main();
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { createServer, type Server, type Socket } from "node:net";
|
|
2
|
+
|
|
3
|
+
import { encodeFrame, MessageReader } from "../protocol/framing.ts";
|
|
4
|
+
import { checkHello, isOneOf, HEARTBEAT_TIMEOUT_MS, parseClientMessage, ProtocolError, type HostMessage, type ReceivedRequest, type VideoStandard, type WireError, WireFailure } from "../protocol/messages.ts";
|
|
5
|
+
import { TOOL_OPERATIONS, validateToolParams, type ToolOperation, type ToolOperations } from "../protocol/tools.ts";
|
|
6
|
+
import { attachmentCount, VICE_OPERATIONS, validateViceParams, type ViceOperation, type ViceOperations } from "../protocol/vice.ts";
|
|
7
|
+
import { elapsedMs, trace } from "../trace.ts";
|
|
8
|
+
|
|
9
|
+
/** One live VICE session, owned by one connection. */
|
|
10
|
+
export interface ViceSessionHandle {
|
|
11
|
+
/**
|
|
12
|
+
* Runs one operation. The session serializes its own work. Throws
|
|
13
|
+
* WireFailure for domain failures. `attachments` holds the bytes the
|
|
14
|
+
* operation takes (see attachmentCount).
|
|
15
|
+
*/
|
|
16
|
+
handle<O extends ViceOperation>(op: O, params: ViceOperations[O]["params"], attachments?: Buffer[]): Promise<ViceOperations[O]["result"]>;
|
|
17
|
+
/** Stops the emulator and drops queued work. Safe to call more than once. */
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Starts a VICE session. Throws WireFailure with an actionable code when it cannot. */
|
|
22
|
+
export type ViceSessionFactory = (options: { videoStandard: VideoStandard }) => Promise<ViceSessionHandle>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runs one native-tool request. `signal` aborts when the client goes away; the
|
|
26
|
+
* tool must then stop its children and remove its workspace. Throws WireFailure.
|
|
27
|
+
*/
|
|
28
|
+
export type ToolDispatcher = <O extends ToolOperation>(
|
|
29
|
+
op: O,
|
|
30
|
+
params: ToolOperations[O]["params"],
|
|
31
|
+
attachments: Buffer[],
|
|
32
|
+
signal: AbortSignal,
|
|
33
|
+
) => Promise<{ result: ToolOperations[O]["result"]; attachments?: Buffer[] }>;
|
|
34
|
+
|
|
35
|
+
export interface HostServerOptions {
|
|
36
|
+
createViceSession: ViceSessionFactory;
|
|
37
|
+
/** Native-tool requests on "tool" connections; without it they are refused. */
|
|
38
|
+
tools?: ToolDispatcher;
|
|
39
|
+
/** Defaults to 127.0.0.1; 0.0.0.0 listens on every address, so a container reaches it too. */
|
|
40
|
+
host?: string;
|
|
41
|
+
/** 0 picks a free port. */
|
|
42
|
+
port: number;
|
|
43
|
+
/** How long a new connection may stay silent before it must send hello. */
|
|
44
|
+
handshakeTimeoutMs?: number;
|
|
45
|
+
/** How long a ready connection may send nothing (no request, no ping) before it is closed with its session. */
|
|
46
|
+
heartbeatTimeoutMs?: number;
|
|
47
|
+
log?: (line: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface HostServer {
|
|
51
|
+
readonly host: string;
|
|
52
|
+
readonly port: number;
|
|
53
|
+
/** Stops listening, closes every connection and stops every session. */
|
|
54
|
+
close(): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
58
|
+
/** How long a refused client may keep its side of the connection open after the host has ended its side. */
|
|
59
|
+
const REFUSED_CLOSE_MS = 1_000;
|
|
60
|
+
|
|
61
|
+
/** A caught value for the log, with its stack when it has one. */
|
|
62
|
+
function describe(error: unknown): string {
|
|
63
|
+
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toWireError(error: unknown, fallback: WireError): WireError {
|
|
67
|
+
return error instanceof WireFailure ? error.toWire() : fallback;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Numbers the connections of this process, for the trace. */
|
|
71
|
+
let connectionCount = 0;
|
|
72
|
+
|
|
73
|
+
/** Owns one client connection: handshake, then requests against at most one VICE session. */
|
|
74
|
+
class Connection {
|
|
75
|
+
readonly closed: Promise<void>;
|
|
76
|
+
readonly #id = ++connectionCount;
|
|
77
|
+
#state: "handshake" | "starting" | "open" | "closed" = "handshake";
|
|
78
|
+
#session: ViceSessionHandle | undefined;
|
|
79
|
+
#starting: Promise<ViceSessionHandle | undefined> | undefined;
|
|
80
|
+
#shuttingDown = false;
|
|
81
|
+
readonly #toolAbort = new AbortController();
|
|
82
|
+
#isTool = false;
|
|
83
|
+
readonly #reader = new MessageReader();
|
|
84
|
+
|
|
85
|
+
readonly #socket: Socket;
|
|
86
|
+
readonly #options: HostServerOptions;
|
|
87
|
+
readonly #log: (line: string) => void;
|
|
88
|
+
readonly #handshakeTimer: NodeJS.Timeout;
|
|
89
|
+
/** Runs from ready on; every received frame restarts it. */
|
|
90
|
+
#heartbeatTimer: NodeJS.Timeout | undefined;
|
|
91
|
+
#refusedTimer: NodeJS.Timeout | undefined;
|
|
92
|
+
#resolveClosed!: () => void;
|
|
93
|
+
|
|
94
|
+
constructor(socket: Socket, options: HostServerOptions, log: (line: string) => void) {
|
|
95
|
+
this.#socket = socket;
|
|
96
|
+
this.#options = options;
|
|
97
|
+
this.#log = log;
|
|
98
|
+
this.closed = new Promise((resolve) => (this.#resolveClosed = resolve));
|
|
99
|
+
trace().event("connection.open", { connection: this.#id, remote: `${socket.remoteAddress}:${socket.remotePort}`, local: socket.localAddress });
|
|
100
|
+
this.#handshakeTimer = setTimeout(
|
|
101
|
+
() => this.#abort("no hello before the handshake timeout"),
|
|
102
|
+
options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS,
|
|
103
|
+
);
|
|
104
|
+
socket.setNoDelay(true);
|
|
105
|
+
// The kernel also probes an idle connection, for a peer that vanished without a close.
|
|
106
|
+
socket.setKeepAlive(true, 10_000);
|
|
107
|
+
socket.on("data", (chunk) => this.#receive(chunk));
|
|
108
|
+
socket.on("error", (error) => this.#log(`connection error: ${error.message}`));
|
|
109
|
+
socket.on("close", () => void this.#shutdown());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Closes the connection and its session. */
|
|
113
|
+
close(): Promise<void> {
|
|
114
|
+
this.#socket.destroy();
|
|
115
|
+
return this.closed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A client that sends nothing for the heartbeat timeout is gone or hung (a
|
|
120
|
+
* half-open connection never closes by itself): close it, and with it its
|
|
121
|
+
* session and emulator.
|
|
122
|
+
*/
|
|
123
|
+
#restartHeartbeat(): void {
|
|
124
|
+
if (this.#state !== "open") return;
|
|
125
|
+
clearTimeout(this.#heartbeatTimer);
|
|
126
|
+
const timeout = this.#options.heartbeatTimeoutMs ?? HEARTBEAT_TIMEOUT_MS;
|
|
127
|
+
this.#heartbeatTimer = setTimeout(() => this.#abort(`no message or ping for ${timeout} ms; the client is gone`), timeout);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#send(message: HostMessage, attachments: readonly Uint8Array[] = []): void {
|
|
131
|
+
if (this.#state !== "closed" && !this.#socket.destroyed) this.#socket.write(encodeFrame(message, attachments));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Ends the host's side after a refusal; a client that does not close its side in time is cut off. */
|
|
135
|
+
#endRefused(): void {
|
|
136
|
+
this.#socket.end();
|
|
137
|
+
this.#refusedTimer = setTimeout(() => this.#abort("the refused client did not close the connection"), REFUSED_CLOSE_MS);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#abort(reason: string): void {
|
|
141
|
+
this.#log(`closing connection: ${reason}`);
|
|
142
|
+
this.#socket.destroy();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#receive(chunk: Buffer): void {
|
|
146
|
+
this.#restartHeartbeat();
|
|
147
|
+
try {
|
|
148
|
+
for (const { message, attachments } of this.#reader.push(chunk)) {
|
|
149
|
+
if (this.#state === "closed") return;
|
|
150
|
+
this.#dispatch(message, attachments);
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// Any failure closes only this connection; the listener and the other connections go on.
|
|
154
|
+
if (error instanceof ProtocolError) this.#abort(`protocol error: ${error.message}`);
|
|
155
|
+
else this.#abort(`internal error: ${describe(error)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Closes this connection when work that runs after its message fails in an unexpected way. */
|
|
160
|
+
#contain(work: Promise<void>): void {
|
|
161
|
+
work.catch((error: unknown) => this.#abort(`internal error: ${describe(error)}`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#dispatch(raw: unknown, attachments: Buffer[]): void {
|
|
165
|
+
const message = parseClientMessage(raw);
|
|
166
|
+
if (this.#state === "handshake") {
|
|
167
|
+
if (message.type !== "hello") throw new ProtocolError("first message is not hello");
|
|
168
|
+
clearTimeout(this.#handshakeTimer);
|
|
169
|
+
const mismatch = checkHello(message);
|
|
170
|
+
if (mismatch !== undefined) {
|
|
171
|
+
this.#log(`refusing a client: ${mismatch.message}`);
|
|
172
|
+
trace().event("connection.refused", { connection: this.#id, role: message.role, ...mismatch });
|
|
173
|
+
this.#send({ type: "error", error: mismatch });
|
|
174
|
+
this.#endRefused();
|
|
175
|
+
this.#state = "closed";
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (message.role === "tool") {
|
|
179
|
+
this.#isTool = true;
|
|
180
|
+
this.#state = "open";
|
|
181
|
+
this.#send({ type: "ready" });
|
|
182
|
+
trace().event("connection.ready", { connection: this.#id, role: "tool" });
|
|
183
|
+
this.#restartHeartbeat();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.#state = "starting";
|
|
187
|
+
this.#contain(this.#startSession(message.videoStandard ?? "pal"));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (message.type === "hello") throw new ProtocolError("hello sent twice");
|
|
191
|
+
if (this.#state !== "open") throw new ProtocolError(`${message.type} sent before ready`);
|
|
192
|
+
if (message.type === "ping") {
|
|
193
|
+
this.#send({ type: "pong" });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.#contain(this.#answer(message, attachments));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async #startSession(videoStandard: VideoStandard): Promise<void> {
|
|
200
|
+
const started = performance.now();
|
|
201
|
+
this.#starting = this.#createSession(videoStandard);
|
|
202
|
+
const session = await this.#starting;
|
|
203
|
+
// A connection closed while starting is shut down by #shutdown, which awaits #starting.
|
|
204
|
+
if (session === undefined || this.#state === "closed") return;
|
|
205
|
+
this.#session = session;
|
|
206
|
+
this.#state = "open";
|
|
207
|
+
this.#send({ type: "ready" });
|
|
208
|
+
trace().event("connection.ready", { connection: this.#id, role: "vice-session", videoStandard, ms: elapsedMs(started) });
|
|
209
|
+
this.#restartHeartbeat();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The new session, or undefined after the failure is sent to the client. Never rejects, also when the factory throws at once. */
|
|
213
|
+
async #createSession(videoStandard: VideoStandard): Promise<ViceSessionHandle | undefined> {
|
|
214
|
+
try {
|
|
215
|
+
return await this.#options.createViceSession({ videoStandard });
|
|
216
|
+
} catch (error) {
|
|
217
|
+
this.#log(`VICE session failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
|
218
|
+
trace().event("connection.session-failed", { connection: this.#id, error });
|
|
219
|
+
this.#send({
|
|
220
|
+
type: "error",
|
|
221
|
+
error: toWireError(error, { code: "machine-unavailable", message: "The emulator could not be started." }),
|
|
222
|
+
});
|
|
223
|
+
this.#endRefused();
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async #answer(request: ReceivedRequest, attachments: Buffer[]): Promise<void> {
|
|
229
|
+
const started = performance.now();
|
|
230
|
+
const record = (outcome: Record<string, unknown>) =>
|
|
231
|
+
trace().event("request", { connection: this.#id, op: request.op, id: request.id, params: request.params, attachments: attachments.length, ms: elapsedMs(started), ...outcome });
|
|
232
|
+
try {
|
|
233
|
+
let result: unknown;
|
|
234
|
+
let replyAttachments: Buffer[] = [];
|
|
235
|
+
if (this.#isTool) {
|
|
236
|
+
const { op } = request;
|
|
237
|
+
const tools = this.#options.tools;
|
|
238
|
+
if (!isOneOf(TOOL_OPERATIONS, op) || tools === undefined) throw new WireFailure("invalid-input", `unknown operation: ${op}`);
|
|
239
|
+
const params = validateToolParams(op, request.params, attachments);
|
|
240
|
+
const answer = await tools(op, params, attachments, this.#toolAbort.signal);
|
|
241
|
+
result = answer.result;
|
|
242
|
+
replyAttachments = answer.attachments ?? [];
|
|
243
|
+
} else {
|
|
244
|
+
const { op } = request;
|
|
245
|
+
const session = this.#session;
|
|
246
|
+
if (session === undefined || !isOneOf(VICE_OPERATIONS, op)) throw new WireFailure("invalid-input", `unknown operation: ${op}`);
|
|
247
|
+
const params = validateViceParams(op, request.params);
|
|
248
|
+
if (attachments.length !== attachmentCount(op)) {
|
|
249
|
+
throw new WireFailure("invalid-input", `${op} takes ${attachmentCount(op)} file attachment(s)`);
|
|
250
|
+
}
|
|
251
|
+
result = await session.handle(op, params, attachments);
|
|
252
|
+
}
|
|
253
|
+
this.#send({ type: "reply", id: request.id, result }, replyAttachments);
|
|
254
|
+
record({ ok: true, result });
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (!(error instanceof WireFailure)) {
|
|
257
|
+
this.#log(`operation ${request.op} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
258
|
+
}
|
|
259
|
+
const wire = toWireError(error, { code: "operation-failed", message: "The operation failed inside the host runtime." });
|
|
260
|
+
this.#send({ type: "reply", id: request.id, error: wire });
|
|
261
|
+
record({ ok: false, ...wire });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async #shutdown(): Promise<void> {
|
|
266
|
+
if (this.#shuttingDown) return;
|
|
267
|
+
this.#shuttingDown = true;
|
|
268
|
+
this.#state = "closed";
|
|
269
|
+
trace().event("connection.close", { connection: this.#id });
|
|
270
|
+
clearTimeout(this.#handshakeTimer);
|
|
271
|
+
clearTimeout(this.#heartbeatTimer);
|
|
272
|
+
clearTimeout(this.#refusedTimer);
|
|
273
|
+
// Tool work for a client that is gone stops: its children and workspaces go too.
|
|
274
|
+
this.#toolAbort.abort();
|
|
275
|
+
const session = this.#session ?? (await this.#starting);
|
|
276
|
+
this.#session = undefined;
|
|
277
|
+
try {
|
|
278
|
+
await session?.close();
|
|
279
|
+
} catch (error) {
|
|
280
|
+
this.#log(`session close failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
281
|
+
}
|
|
282
|
+
this.#resolveClosed();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A port as the user gives it: decimal digits only, 0 to 65535. Anything else gives undefined. */
|
|
287
|
+
export function parsePort(text: string): number | undefined {
|
|
288
|
+
if (!/^\d{1,5}$/.test(text)) return undefined;
|
|
289
|
+
const port = Number(text);
|
|
290
|
+
return port <= 0xffff ? port : undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Resolves with the first SIGINT or SIGTERM. The handlers stay: a later
|
|
295
|
+
* signal is logged and ignored, so a second Ctrl+C cannot cut the shutdown
|
|
296
|
+
* short before every emulator and tool is stopped.
|
|
297
|
+
*/
|
|
298
|
+
export function stopSignal(log: (line: string) => void): Promise<NodeJS.Signals> {
|
|
299
|
+
return new Promise((resolve) => {
|
|
300
|
+
let stopping = false;
|
|
301
|
+
const stop = (signal: NodeJS.Signals) => {
|
|
302
|
+
if (stopping) {
|
|
303
|
+
log(`received ${signal} again; the host is already stopping`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
stopping = true;
|
|
307
|
+
log(`received ${signal}, stopping`);
|
|
308
|
+
resolve(signal);
|
|
309
|
+
};
|
|
310
|
+
process.on("SIGINT", stop);
|
|
311
|
+
process.on("SIGTERM", stop);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** A listener that could not start, with the address and the port it tried and the system error code. */
|
|
316
|
+
export class ListenError extends Error {
|
|
317
|
+
readonly code: string | undefined;
|
|
318
|
+
readonly address: string;
|
|
319
|
+
readonly port: number;
|
|
320
|
+
|
|
321
|
+
constructor(cause: unknown, address: string, port: number) {
|
|
322
|
+
super(`cannot listen on ${address} port ${port}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
323
|
+
this.name = "ListenError";
|
|
324
|
+
this.code = (cause as NodeJS.ErrnoException | undefined)?.code;
|
|
325
|
+
this.address = address;
|
|
326
|
+
this.port = port;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Starts the Host Runtime listener. Each connection owns at most one VICE session. */
|
|
331
|
+
export async function startHostServer(options: HostServerOptions): Promise<HostServer> {
|
|
332
|
+
const log = options.log ?? (() => {});
|
|
333
|
+
const host = options.host ?? "127.0.0.1";
|
|
334
|
+
const connections = new Set<Connection>();
|
|
335
|
+
const servers: Server[] = [];
|
|
336
|
+
const listen = (address: string, port: number) => {
|
|
337
|
+
const server: Server = createServer((socket) => {
|
|
338
|
+
const connection = new Connection(socket, options, log);
|
|
339
|
+
connections.add(connection);
|
|
340
|
+
void connection.closed.then(() => connections.delete(connection));
|
|
341
|
+
});
|
|
342
|
+
servers.push(server);
|
|
343
|
+
return new Promise<number>((resolve, reject) => {
|
|
344
|
+
const failed = (error: Error) => reject(new ListenError(error, address, port));
|
|
345
|
+
server.once("error", failed);
|
|
346
|
+
server.listen({ host: address, port, exclusive: true }, () => {
|
|
347
|
+
server.off("error", failed);
|
|
348
|
+
const bound = server.address();
|
|
349
|
+
if (bound === null || typeof bound === "string") reject(new Error("listener has no TCP address"));
|
|
350
|
+
else resolve(bound.port);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
let port: number;
|
|
356
|
+
try {
|
|
357
|
+
port = await listen(host, options.port);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
await Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let closing: Promise<void> | undefined;
|
|
364
|
+
return {
|
|
365
|
+
host,
|
|
366
|
+
port,
|
|
367
|
+
close() {
|
|
368
|
+
closing ??= (async () => {
|
|
369
|
+
const stopped = Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
|
370
|
+
await Promise.all([...connections].map((connection) => connection.close()));
|
|
371
|
+
await stopped;
|
|
372
|
+
})();
|
|
373
|
+
return closing;
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|