@henols/c64-re-tools 1.1.0 → 2.0.0-rc.2
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 +190 -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 +121 -0
- package/src/host/server.ts +409 -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 +241 -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 +109 -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 +252 -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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Bounded native-tool execution: argv only, no shell, a fixed
|
|
2
|
+
// timeout, capped output, and the whole process group stopped on timeout or abort.
|
|
3
|
+
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { sep } from "node:path";
|
|
6
|
+
import { StringDecoder } from "node:string_decoder";
|
|
7
|
+
|
|
8
|
+
import { WireFailure } from "../protocol/messages.ts";
|
|
9
|
+
import { elapsedMs, trace } from "../trace.ts";
|
|
10
|
+
import type { ProcessSupervisor } from "./processes.ts";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
|
|
13
|
+
export const DEFAULT_OUTPUT_LIMIT = 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
/** What running a native tool needs from its caller: the supervisor that owns it, the request's abort signal, and optionally its environment and a log. */
|
|
16
|
+
export interface ToolContext {
|
|
17
|
+
supervisor: ProcessSupervisor;
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
20
|
+
log?: ((line: string) => void) | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ToolRun {
|
|
24
|
+
code: number | null;
|
|
25
|
+
signal: NodeJS.Signals | null;
|
|
26
|
+
stdout: string;
|
|
27
|
+
stderr: string;
|
|
28
|
+
timedOut: boolean;
|
|
29
|
+
aborted: boolean;
|
|
30
|
+
/** True when output beyond the limit was dropped. */
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
/** The error code, for example ENOENT, when the program did not start. */
|
|
33
|
+
spawnError?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TAIL_LINES = 20;
|
|
37
|
+
const TAIL_CHARS = 2_000;
|
|
38
|
+
const TRACE_TAIL_LINES = 40;
|
|
39
|
+
|
|
40
|
+
/** The last lines of a tool's output, without empty ones, for the trace. */
|
|
41
|
+
function lastLines(text: string): string {
|
|
42
|
+
return text
|
|
43
|
+
.split(/\r?\n/)
|
|
44
|
+
.filter((line) => line.trim() !== "")
|
|
45
|
+
.slice(-TRACE_TAIL_LINES)
|
|
46
|
+
.join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The last lines that a tool printed, for a refusal: empty lines and lines
|
|
51
|
+
* that do not match `only` are left out, and so is the workspace root in a
|
|
52
|
+
* path. At most 20 lines and 2000 characters. Empty when nothing is left.
|
|
53
|
+
*/
|
|
54
|
+
export function outputTail(tool: string, run: Pick<ToolRun, "stdout" | "stderr">, root: string, only?: RegExp): string {
|
|
55
|
+
const roots = new Set([root]);
|
|
56
|
+
try {
|
|
57
|
+
roots.add(realpathSync(root));
|
|
58
|
+
} catch {
|
|
59
|
+
// The workspace is gone: only the root as given is left out.
|
|
60
|
+
}
|
|
61
|
+
const variants = [...roots].flatMap((path) => [path, path.split(sep).join("/")]).sort((a, b) => b.length - a.length);
|
|
62
|
+
const withoutRoot = (line: string) => variants.reduce((text, path) => text.split(`${path}${sep}`).join("").split(`${path}/`).join("").split(path).join("."), line);
|
|
63
|
+
const lines = `${run.stdout}\n${run.stderr}`
|
|
64
|
+
.split(/\r?\n/)
|
|
65
|
+
.map((line) => line.trim())
|
|
66
|
+
.filter((line) => line !== "" && (only === undefined || only.test(line)))
|
|
67
|
+
.slice(-TAIL_LINES)
|
|
68
|
+
.map(withoutRoot);
|
|
69
|
+
while (lines.length > 1 && lines.join("\n").length > TAIL_CHARS) lines.shift();
|
|
70
|
+
const first = lines[0];
|
|
71
|
+
if (first === undefined) return "";
|
|
72
|
+
lines[0] = first.slice(-TAIL_CHARS);
|
|
73
|
+
return `\nThe last output of ${tool}:\n ${lines.join("\n ")}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Spawn errors that mean the file is not a program that this system can start. */
|
|
77
|
+
const NOT_A_PROGRAM = new Set(["ENOENT", "EACCES", "ENOEXEC", "EFTYPE", "EINVAL", "EPERM", "ENOTDIR"]);
|
|
78
|
+
|
|
79
|
+
export async function runTool(options: {
|
|
80
|
+
argv: string[];
|
|
81
|
+
cwd: string;
|
|
82
|
+
supervisor: ProcessSupervisor;
|
|
83
|
+
signal: AbortSignal;
|
|
84
|
+
timeoutMs?: number;
|
|
85
|
+
outputLimit?: number;
|
|
86
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
87
|
+
}): Promise<ToolRun> {
|
|
88
|
+
const limit = options.outputLimit ?? DEFAULT_OUTPUT_LIMIT;
|
|
89
|
+
const started = performance.now();
|
|
90
|
+
const child = options.supervisor.spawn(options.argv, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
91
|
+
// "exit" can come before the output streams finish; "close" comes after both.
|
|
92
|
+
const closed = new Promise<void>((resolve) => {
|
|
93
|
+
if (child.pid < 0) resolve();
|
|
94
|
+
else child.child.once("close", () => resolve());
|
|
95
|
+
});
|
|
96
|
+
const output = { stdout: "", stderr: "", truncated: false };
|
|
97
|
+
// The limit counts bytes. A decoder per stream keeps a character whose bytes arrive in two chunks whole.
|
|
98
|
+
let bytes = 0;
|
|
99
|
+
const decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
|
|
100
|
+
const collect = (stream: "stdout" | "stderr") => (chunk: Buffer) => {
|
|
101
|
+
const room = limit - bytes;
|
|
102
|
+
if (chunk.length > room) output.truncated = true;
|
|
103
|
+
if (room <= 0) return;
|
|
104
|
+
const kept = chunk.subarray(0, room);
|
|
105
|
+
bytes += kept.length;
|
|
106
|
+
output[stream] += decoders[stream].write(kept);
|
|
107
|
+
};
|
|
108
|
+
child.child.stdout?.on("data", collect("stdout"));
|
|
109
|
+
child.child.stderr?.on("data", collect("stderr"));
|
|
110
|
+
|
|
111
|
+
let timedOut = false;
|
|
112
|
+
let aborted = false;
|
|
113
|
+
// A failed stop here is not lost: the stop after the exit below tries again and rejects the run.
|
|
114
|
+
const stopQuietly = () => {
|
|
115
|
+
void child.stop().catch(() => {});
|
|
116
|
+
// A process outside the group can hold the output pipes open: the run does not wait for it.
|
|
117
|
+
child.child.stdout?.destroy();
|
|
118
|
+
child.child.stderr?.destroy();
|
|
119
|
+
};
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
timedOut = true;
|
|
122
|
+
stopQuietly();
|
|
123
|
+
}, options.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS);
|
|
124
|
+
const onAbort = () => {
|
|
125
|
+
aborted = true;
|
|
126
|
+
stopQuietly();
|
|
127
|
+
};
|
|
128
|
+
if (options.signal.aborted) onAbort();
|
|
129
|
+
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const status = await child.exited;
|
|
133
|
+
// A tool that exited may have left descendants in its group, and they can hold its output
|
|
134
|
+
// pipes open: stop them before the wait for the end of the output.
|
|
135
|
+
await child.stop();
|
|
136
|
+
await closed;
|
|
137
|
+
output.stdout += decoders.stdout.end();
|
|
138
|
+
output.stderr += decoders.stderr.end();
|
|
139
|
+
trace().event("tool.run", { argv: options.argv, cwd: options.cwd, pid: child.pid, ms: elapsedMs(started), ...status, timedOut, aborted, truncated: output.truncated, stdout: lastLines(output.stdout), stderr: lastLines(output.stderr) });
|
|
140
|
+
return { ...status, ...output, timedOut, aborted };
|
|
141
|
+
} finally {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Runs one task of a named tool and turns a cancelled or timed-out run, a
|
|
149
|
+
* program that did not start and a stop by a signal into a WireFailure. With
|
|
150
|
+
* `truncated`, output beyond the limit is refused with that message too. Any
|
|
151
|
+
* exit code is returned to the caller. `root` (the default is `cwd`) is left
|
|
152
|
+
* out of the tool output that a refusal quotes.
|
|
153
|
+
*/
|
|
154
|
+
export async function runToolOrFail(
|
|
155
|
+
tool: string,
|
|
156
|
+
task: string,
|
|
157
|
+
options: { argv: string[]; cwd: string; timeoutMs: number; outputLimit?: number; truncated?: string; root?: string },
|
|
158
|
+
context: ToolContext,
|
|
159
|
+
): Promise<ToolRun> {
|
|
160
|
+
const { truncated, root, ...runOptions } = options;
|
|
161
|
+
const run = await runTool({ ...runOptions, supervisor: context.supervisor, signal: context.signal, env: context.env });
|
|
162
|
+
if (run.aborted) throw new WireFailure("operation-failed", `${task} was cancelled.`);
|
|
163
|
+
if (run.timedOut) throw new WireFailure("operation-failed", `${tool} did not finish within ${options.timeoutMs / 1000} seconds.`);
|
|
164
|
+
if (run.spawnError !== undefined) {
|
|
165
|
+
if (NOT_A_PROGRAM.has(run.spawnError)) throw new WireFailure("installation-incomplete", `${tool} is not a program that this system can start (${run.spawnError}). Install it again.`);
|
|
166
|
+
throw new WireFailure("operation-failed", `${tool} did not start (${run.spawnError}).`);
|
|
167
|
+
}
|
|
168
|
+
// A tool that a signal stopped has crashed: that is not a result of the task.
|
|
169
|
+
if (run.signal !== null) throw new WireFailure("operation-failed", `${tool} stopped on the signal ${run.signal}.${outputTail(tool, run, root ?? options.cwd)}`);
|
|
170
|
+
if (run.truncated && truncated !== undefined) throw new WireFailure("operation-failed", truncated);
|
|
171
|
+
return run;
|
|
172
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Request-owned temporary workspaces. Everything staged lives
|
|
2
|
+
// outside the user's project and is removed when the request ends.
|
|
3
|
+
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { WireFailure } from "../protocol/messages.ts";
|
|
9
|
+
import { isRelativePath, type SourceTree } from "../protocol/tools.ts";
|
|
10
|
+
import type { ProcessSupervisor } from "./processes.ts";
|
|
11
|
+
|
|
12
|
+
/** How often a removal tries again when a file is busy, for example one that Windows still holds open. */
|
|
13
|
+
const REMOVE_RETRIES = 5;
|
|
14
|
+
|
|
15
|
+
export class Workspace {
|
|
16
|
+
readonly root: string;
|
|
17
|
+
/** The owner's release, when a supervisor owns the workspace. */
|
|
18
|
+
readonly #release: (() => void) | undefined;
|
|
19
|
+
|
|
20
|
+
private constructor(root: string, release: (() => void) | undefined) {
|
|
21
|
+
this.root = root;
|
|
22
|
+
this.#release = release;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A fresh, empty workspace in the temporary directory. With a supervisor,
|
|
27
|
+
* the workspace is also removed if its process dies.
|
|
28
|
+
*/
|
|
29
|
+
static create(owner?: Pick<ProcessSupervisor, "ownPath">, prefix = "c64-re-tools-request-"): Workspace {
|
|
30
|
+
const root = mkdtempSync(join(tmpdir(), prefix));
|
|
31
|
+
return new Workspace(root, owner?.ownPath(root));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The absolute path of a relative path inside the workspace; refuses any escape. */
|
|
35
|
+
path(path: string): string {
|
|
36
|
+
if (!isRelativePath(path)) throw new WireFailure("invalid-input", `${path} is not a relative path inside the workspace.`);
|
|
37
|
+
const full = resolve(this.root, path);
|
|
38
|
+
const fromRoot = relative(this.root, full);
|
|
39
|
+
if (fromRoot.startsWith(`..${sep}`) || fromRoot === ".." || isAbsolute(fromRoot)) {
|
|
40
|
+
throw new WireFailure("invalid-input", `${path} leaves the workspace.`);
|
|
41
|
+
}
|
|
42
|
+
return full;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Writes a transferred tree under `directory` as ordinary files. Returns the directory's absolute path. */
|
|
46
|
+
materialize(directory: string, files: SourceTree, contents: readonly Uint8Array[]): string {
|
|
47
|
+
const base = this.path(directory);
|
|
48
|
+
mkdirSync(base, { recursive: true });
|
|
49
|
+
files.forEach((file, index) => {
|
|
50
|
+
const content = contents[index];
|
|
51
|
+
if (content === undefined) throw new WireFailure("invalid-input", `The request gives no bytes for ${file.path}.`);
|
|
52
|
+
const target = this.path(`${directory}/${file.path}`);
|
|
53
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
54
|
+
// wx: never write through a file or link that is already there.
|
|
55
|
+
writeFileSync(target, content, { flag: "wx" });
|
|
56
|
+
});
|
|
57
|
+
return base;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A directory inside the workspace, created empty. */
|
|
61
|
+
directory(path: string): string {
|
|
62
|
+
const full = this.path(path);
|
|
63
|
+
rmSync(full, { recursive: true, force: true });
|
|
64
|
+
mkdirSync(full, { recursive: true });
|
|
65
|
+
return full;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Removes the workspace. An owned workspace that cannot be removed stays
|
|
70
|
+
* owned, and its supervisor removes it when its process ends: the error is
|
|
71
|
+
* not thrown, so that it cannot replace the failure of the request that
|
|
72
|
+
* used the workspace. Without an owner the error is thrown.
|
|
73
|
+
*/
|
|
74
|
+
remove(): void {
|
|
75
|
+
try {
|
|
76
|
+
rmSync(this.root, { recursive: true, force: true, maxRetries: REMOVE_RETRIES });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (this.#release === undefined) throw error;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.#release?.();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Tool status: finds each native tool the way its operations do and runs it
|
|
2
|
+
// once with a version option, so a user can see in advance which tools work.
|
|
3
|
+
// The Host Runtime reports VICE and its tools (host.status); the CLI reports
|
|
4
|
+
// the tools that skill scripts run themselves (ACME, DXA, Ghidra).
|
|
5
|
+
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
|
|
9
|
+
import { WireFailure } from "../protocol/messages.ts";
|
|
10
|
+
import { type ToolStatus } from "../protocol/tools.ts";
|
|
11
|
+
import { ACME, DXA, findTool } from "./discover.ts";
|
|
12
|
+
import { findGhidra, ghidraVersion } from "./ghidra/index.ts";
|
|
13
|
+
import { runTool, type ToolContext } from "./run.ts";
|
|
14
|
+
import { Workspace } from "./staging.ts";
|
|
15
|
+
|
|
16
|
+
const PROBE_TIMEOUT_MS = 20_000;
|
|
17
|
+
|
|
18
|
+
export interface Probe {
|
|
19
|
+
name: string;
|
|
20
|
+
/** The executable; throws WireFailure(installation-incomplete) when missing. */
|
|
21
|
+
find: (env: NodeJS.ProcessEnv) => string;
|
|
22
|
+
args: string[];
|
|
23
|
+
/** The version line; no such line means the tool did not run correctly. */
|
|
24
|
+
pattern: RegExp;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** dxa's version line, also as "D:\\tools\\dxa.exe v0.1.5 -- symbolic 65xx disassembler" on Windows. */
|
|
28
|
+
export const DXA_VERSION = /^(?:.*[\\/])?dxa(?:\.exe)? v\d/i;
|
|
29
|
+
|
|
30
|
+
/** The first output line that matches, without terminal color codes (c1541 colors an OpenCBM notice). */
|
|
31
|
+
export function versionLine(output: string, pattern: RegExp): string | undefined {
|
|
32
|
+
return stripVTControlCharacters(output)
|
|
33
|
+
.split(/\r?\n/)
|
|
34
|
+
.map((line) => line.trim())
|
|
35
|
+
.find((line) => pattern.test(line));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function run(name: string, path: string, args: string[], version: (output: string) => string | undefined, context: ToolContext): Promise<ToolStatus> {
|
|
39
|
+
const result = await runTool({ argv: [path, ...args], cwd: tmpdir(), supervisor: context.supervisor, signal: context.signal, timeoutMs: PROBE_TIMEOUT_MS, env: context.env });
|
|
40
|
+
const found = version(`${result.stdout}\n${result.stderr}`);
|
|
41
|
+
if (found !== undefined) return { name, found: true, path, version: found, runs: true };
|
|
42
|
+
const problem = result.timedOut ? "It did not answer in time." : result.spawnError !== undefined ? `It did not start (${result.spawnError}).` : `It did not run correctly (exit ${result.code ?? result.signal}).`;
|
|
43
|
+
return { name, found: true, path, runs: false, problem };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function missing(name: string, error: unknown): ToolStatus {
|
|
47
|
+
if (!(error instanceof WireFailure)) throw error;
|
|
48
|
+
return { name, found: false, runs: false, problem: error.message };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Finds and runs each probed tool once. */
|
|
52
|
+
export function probeTools(probes: Probe[], context: ToolContext): Promise<ToolStatus[]> {
|
|
53
|
+
const env = context.env ?? process.env;
|
|
54
|
+
return Promise.all(
|
|
55
|
+
probes.map(async (probe) => {
|
|
56
|
+
let path: string;
|
|
57
|
+
try {
|
|
58
|
+
path = probe.find(env);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return missing(probe.name, error);
|
|
61
|
+
}
|
|
62
|
+
return run(probe.name, path, probe.args, (output) => versionLine(output, probe.pattern), context);
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Ghidra: without arguments analyzeHeadless starts Java and prints its usage, which proves that it runs. */
|
|
68
|
+
export async function probeGhidra(context: ToolContext): Promise<ToolStatus> {
|
|
69
|
+
const env = context.env ?? process.env;
|
|
70
|
+
try {
|
|
71
|
+
const ghidra = findGhidra(env);
|
|
72
|
+
// Its own settings directory keeps the user's Ghidra settings and logs untouched.
|
|
73
|
+
const workspace = Workspace.create(context.supervisor, "c64-re-tools-ghidra-status-");
|
|
74
|
+
const settings = workspace.root;
|
|
75
|
+
try {
|
|
76
|
+
const status = await run(
|
|
77
|
+
"Ghidra",
|
|
78
|
+
ghidra.analyzeHeadless,
|
|
79
|
+
[],
|
|
80
|
+
(output) => (/Headless Analyzer Usage/.test(output) ? ghidraVersion(ghidra) : undefined),
|
|
81
|
+
{ ...context, env: { ...env, XDG_CONFIG_HOME: settings, XDG_CACHE_HOME: settings } },
|
|
82
|
+
);
|
|
83
|
+
return { ...status, path: ghidra.root };
|
|
84
|
+
} finally {
|
|
85
|
+
workspace.remove();
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return missing("Ghidra", error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The tools that skill scripts run themselves, on this machine. */
|
|
93
|
+
export async function localToolStatus(context: ToolContext): Promise<ToolStatus[]> {
|
|
94
|
+
const probes: Probe[] = [
|
|
95
|
+
{ name: "ACME", find: (env) => findTool(ACME, env), args: ["--version"], pattern: /^This is ACME, release / },
|
|
96
|
+
// dxa -V prints its version and exits with status 1. It names itself by argv[0], without the
|
|
97
|
+
// directory only where that uses "/": on Windows the line starts with the full path.
|
|
98
|
+
{ name: "dxa", find: (env) => findTool(DXA, env), args: ["-V"], pattern: DXA_VERSION },
|
|
99
|
+
];
|
|
100
|
+
return [...(await probeTools(probes, context)), await probeGhidra(context)];
|
|
101
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Types of the native tools that skill scripts run directly (ACME, DXA,
|
|
2
|
+
// Ghidra), and the checks of their complete results. The skill script runs
|
|
3
|
+
// these tools itself: nothing here crosses the Host Runtime wire. Bytes
|
|
4
|
+
// (a program, an image, a listing) go next to these values as attachments.
|
|
5
|
+
|
|
6
|
+
import { prgRange, REFERENCE_KINDS, SYMBOL_NAME, type ReferenceKind } from "../c64.ts";
|
|
7
|
+
import { isInteger, isObject, isOneOf, WireFailure } from "../protocol/messages.ts";
|
|
8
|
+
import { type SourceTree } from "../protocol/tools.ts";
|
|
9
|
+
|
|
10
|
+
export interface Diagnostic {
|
|
11
|
+
severity: "error" | "warning" | "note";
|
|
12
|
+
/** Relative to the source root; absent when the message has no source location. */
|
|
13
|
+
file?: string;
|
|
14
|
+
line?: number;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AcmeParams {
|
|
19
|
+
files: SourceTree;
|
|
20
|
+
/** Relative to the source root. */
|
|
21
|
+
entrySource: string;
|
|
22
|
+
/** Directories relative to the source root. */
|
|
23
|
+
includeDirs: string[];
|
|
24
|
+
defines: Record<string, number | boolean>;
|
|
25
|
+
setPc?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A symbol from the assembly: an address inside the program, or a constant value. */
|
|
29
|
+
export type AssembledSymbol = { name: string; kind: "address"; address: number; used: boolean } | { name: string; kind: "constant"; value: number; used: boolean };
|
|
30
|
+
|
|
31
|
+
export interface AcmeResult {
|
|
32
|
+
assembled: boolean;
|
|
33
|
+
/** Present when assembled: where the program loads. The program bytes are the one attachment that assemble returns. */
|
|
34
|
+
loadRange?: { start: number; end: number; bytes: number };
|
|
35
|
+
symbols?: AssembledSymbol[];
|
|
36
|
+
diagnostics: Diagnostic[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const IMAGE_KINDS = ["prg", "flat64k"] as const;
|
|
40
|
+
export type ImageKind = (typeof IMAGE_KINDS)[number];
|
|
41
|
+
|
|
42
|
+
export const GHIDRA_LIMITS = { entryPoints: 1024, dataRanges: 1024, labels: 4096, decompile: 32, decompiledChars: 16_000, decompiledTotalChars: 128_000 } as const;
|
|
43
|
+
|
|
44
|
+
/** The Ghidra analysis. Its image is given next to these fields: a PRG, or 64 KiB from $0000. */
|
|
45
|
+
export interface GhidraParams {
|
|
46
|
+
imageKind: ImageKind;
|
|
47
|
+
/** Seeds from current knowledge. */
|
|
48
|
+
entryPoints: number[];
|
|
49
|
+
dataRanges: Array<{ start: number; end: number }>;
|
|
50
|
+
labels: Array<{ address: number; name: string }>;
|
|
51
|
+
/** Routine entries to decompile for immediate reasoning; the text is never stored. */
|
|
52
|
+
decompile: number[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GhidraResult {
|
|
56
|
+
coverage: Array<{ start: number; end: number }>;
|
|
57
|
+
functions: Array<{ entry: number; name: string; nameSource: "seed" | "generated" | "native" }>;
|
|
58
|
+
regions: Array<{ start: number; end: number; classification: "code" | "data" }>;
|
|
59
|
+
references: Array<{ from: number; to: number; type: ReferenceKind }>;
|
|
60
|
+
decompilations: Array<{ entry: number; text: string; truncated: boolean }>;
|
|
61
|
+
/** Requested routines without decompiled code, with the decompiler's reason. */
|
|
62
|
+
notDecompiled?: Array<{ entry: number; reason: string }>;
|
|
63
|
+
/** Which categories are complete inside the coverage; private to the importer. */
|
|
64
|
+
completeness: { functions: boolean; regions: boolean; references: boolean };
|
|
65
|
+
/** The version of the Ghidra that made the result, for example "Ghidra 12.1.3". */
|
|
66
|
+
toolVersion?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Refuses a DXA or Ghidra request with more seeds or decompilations than the request bounds allow, by name. */
|
|
70
|
+
export function checkRequestBounds(params: DxaParams & { decompile?: number[] }): void {
|
|
71
|
+
const bounds = [
|
|
72
|
+
["entry points", params.entryPoints.length, GHIDRA_LIMITS.entryPoints],
|
|
73
|
+
["data ranges", params.dataRanges.length, GHIDRA_LIMITS.dataRanges],
|
|
74
|
+
["labels", params.labels.length, GHIDRA_LIMITS.labels],
|
|
75
|
+
["routines to decompile", params.decompile?.length ?? 0, GHIDRA_LIMITS.decompile],
|
|
76
|
+
] as const;
|
|
77
|
+
for (const [name, count, limit] of bounds) {
|
|
78
|
+
if (count > limit) throw new WireFailure("invalid-input", `The request has ${count} ${name}. Give at most ${limit} ${name}.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The DXA analysis: the same image and seeds as Ghidra, without decompilation. */
|
|
83
|
+
export type DxaParams = Omit<GhidraParams, "decompile">;
|
|
84
|
+
|
|
85
|
+
export interface DxaResult {
|
|
86
|
+
coverage: Array<{ start: number; end: number }>;
|
|
87
|
+
regions: Array<{ start: number; end: number; classification: "code" | "data" }>;
|
|
88
|
+
labels: Array<{ address: number; name: string }>;
|
|
89
|
+
/** The size of the listing, which is the one attachment that analyze returns (UTF-8 text). */
|
|
90
|
+
listingBytes: number;
|
|
91
|
+
/** Which categories are complete inside the coverage; private to the importer. */
|
|
92
|
+
completeness: { regions: boolean; labels: boolean };
|
|
93
|
+
/** The version line of the dxa that made the result; absent when dxa prints none. */
|
|
94
|
+
toolVersion?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The longest tool version that a result can carry. */
|
|
98
|
+
const MAX_TOOL_VERSION = 200;
|
|
99
|
+
|
|
100
|
+
/** True for a tool version that a result can carry: absent, or one line of text without control characters, at most MAX_TOOL_VERSION characters. */
|
|
101
|
+
const isToolVersion = (value: unknown) => value === undefined || (typeof value === "string" && value.length <= MAX_TOOL_VERSION && /^[^\p{Cc}]+$/u.test(value));
|
|
102
|
+
|
|
103
|
+
/** Ends a result check with the Error that names its first problem. */
|
|
104
|
+
function fail(message: string): never {
|
|
105
|
+
throw new Error(message);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Narrows to a list whose items are still unchecked. */
|
|
109
|
+
const isList = (value: unknown): value is unknown[] => Array.isArray(value);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Checks a complete Ghidra result: every field typed and in range, every
|
|
113
|
+
* function, region and reference source inside the coverage, regions sorted
|
|
114
|
+
* and without overlap. Throws an Error naming the first problem.
|
|
115
|
+
*/
|
|
116
|
+
export function checkGhidraResult(value: unknown): GhidraResult {
|
|
117
|
+
if (!isObject(value)) fail("not an object");
|
|
118
|
+
const { coverage, functions, regions, references, decompilations, notDecompiled, completeness, toolVersion } = value;
|
|
119
|
+
if (!isList(coverage) || coverage.length === 0) fail("no coverage");
|
|
120
|
+
const ranges: Array<{ start: number; end: number }> = [];
|
|
121
|
+
for (const range of coverage) {
|
|
122
|
+
if (!isObject(range) || !isInteger(range.start, 0, 0xffff) || !isInteger(range.end, range.start, 0xffff)) fail("bad coverage range");
|
|
123
|
+
ranges.push({ start: range.start, end: range.end });
|
|
124
|
+
}
|
|
125
|
+
const covered = (address: number) => ranges.some((range) => range.start <= address && address <= range.end);
|
|
126
|
+
if (!isList(functions)) fail("no functions");
|
|
127
|
+
for (const fn of functions) {
|
|
128
|
+
if (!isObject(fn) || !isInteger(fn.entry, 0, 0xffff) || typeof fn.name !== "string" || !SYMBOL_NAME.test(fn.name) || !isOneOf(["seed", "generated", "native"] as const, fn.nameSource)) fail("bad function");
|
|
129
|
+
if (!covered(fn.entry)) fail("a function lies outside the coverage");
|
|
130
|
+
}
|
|
131
|
+
if (!isList(regions)) fail("no regions");
|
|
132
|
+
let previousEnd = -1;
|
|
133
|
+
for (const region of regions) {
|
|
134
|
+
if (!isObject(region) || !isInteger(region.start, 0, 0xffff) || !isInteger(region.end, region.start, 0xffff) || (region.classification !== "code" && region.classification !== "data")) fail("bad region");
|
|
135
|
+
const start = region.start;
|
|
136
|
+
const end = region.end;
|
|
137
|
+
if (start <= previousEnd) fail("regions overlap or are out of order");
|
|
138
|
+
if (!ranges.some((range) => range.start <= start && end <= range.end)) fail("a region lies outside the coverage");
|
|
139
|
+
previousEnd = end;
|
|
140
|
+
}
|
|
141
|
+
if (!isList(references)) fail("no references");
|
|
142
|
+
for (const reference of references) {
|
|
143
|
+
if (!isObject(reference) || !isInteger(reference.from, 0, 0xffff) || !isInteger(reference.to, 0, 0xffff) || !isOneOf(REFERENCE_KINDS, reference.type)) fail("bad reference");
|
|
144
|
+
if (!covered(reference.from)) fail("a reference source lies outside the coverage");
|
|
145
|
+
}
|
|
146
|
+
if (!isList(decompilations) || decompilations.length > GHIDRA_LIMITS.decompile) fail("bad decompilations");
|
|
147
|
+
let total = 0;
|
|
148
|
+
for (const item of decompilations) {
|
|
149
|
+
if (!isObject(item) || !isInteger(item.entry, 0, 0xffff) || typeof item.text !== "string" || typeof item.truncated !== "boolean") fail("bad decompilation");
|
|
150
|
+
if (item.text.length > GHIDRA_LIMITS.decompiledChars) fail("a decompilation is too long");
|
|
151
|
+
total += item.text.length;
|
|
152
|
+
}
|
|
153
|
+
if (total > GHIDRA_LIMITS.decompiledTotalChars) fail("the decompilations are too long together");
|
|
154
|
+
if (notDecompiled !== undefined) {
|
|
155
|
+
if (!isList(notDecompiled) || notDecompiled.length > GHIDRA_LIMITS.decompile) fail("bad list of routines that were not decompiled");
|
|
156
|
+
for (const item of notDecompiled) {
|
|
157
|
+
if (!isObject(item) || !isInteger(item.entry, 0, 0xffff) || typeof item.reason !== "string" || item.reason.length > 1000) fail("bad routine that was not decompiled");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!isObject(completeness) || typeof completeness.functions !== "boolean" || typeof completeness.regions !== "boolean" || typeof completeness.references !== "boolean") fail("no completeness");
|
|
161
|
+
if (!isToolVersion(toolVersion)) fail("bad tool version");
|
|
162
|
+
// Every field is checked: the object is the result as it is.
|
|
163
|
+
return value as unknown as GhidraResult;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Checks a complete DXA result: typed fields, regions in order, without
|
|
168
|
+
* overlap and covering the coverage exactly, labels inside the coverage, and
|
|
169
|
+
* the listing as the one attachment. Throws an Error naming the first problem.
|
|
170
|
+
*/
|
|
171
|
+
export function checkDxaResult(value: unknown, attachments: readonly Uint8Array[]): DxaResult {
|
|
172
|
+
if (!isObject(value)) fail("not an object");
|
|
173
|
+
const { coverage, regions, labels, listingBytes, completeness, toolVersion } = value;
|
|
174
|
+
if (!isList(coverage) || coverage.length !== 1) fail("the coverage is not one range");
|
|
175
|
+
const range = coverage[0];
|
|
176
|
+
if (!isObject(range) || !isInteger(range.start, 0, 0xffff) || !isInteger(range.end, range.start, 0xffff)) fail("bad coverage range");
|
|
177
|
+
const start = range.start;
|
|
178
|
+
const end = range.end;
|
|
179
|
+
if (!isList(regions)) fail("no regions");
|
|
180
|
+
let next = start;
|
|
181
|
+
for (const region of regions) {
|
|
182
|
+
if (!isObject(region) || !isInteger(region.start, 0, 0xffff) || !isInteger(region.end, region.start, 0xffff) || (region.classification !== "code" && region.classification !== "data")) fail("bad region");
|
|
183
|
+
if (region.start !== next) fail("the regions leave a gap or overlap");
|
|
184
|
+
next = region.end + 1;
|
|
185
|
+
}
|
|
186
|
+
if (next !== end + 1) fail("the regions do not reach the end of the coverage");
|
|
187
|
+
if (!isList(labels)) fail("no labels");
|
|
188
|
+
for (const label of labels) {
|
|
189
|
+
if (!isObject(label) || !isInteger(label.address, start, end) || typeof label.name !== "string" || !SYMBOL_NAME.test(label.name)) fail("bad label");
|
|
190
|
+
}
|
|
191
|
+
const [listing] = attachments;
|
|
192
|
+
if (attachments.length !== 1 || listing === undefined || listingBytes !== listing.length) fail("the listing is missing");
|
|
193
|
+
if (!isObject(completeness) || typeof completeness.regions !== "boolean" || typeof completeness.labels !== "boolean") fail("no completeness");
|
|
194
|
+
if (!isToolVersion(toolVersion)) fail("bad tool version");
|
|
195
|
+
// Every field is checked: the object is the result as it is.
|
|
196
|
+
return value as unknown as DxaResult;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The address range of an analyzer image: a PRG at its load address, or 64 KiB
|
|
201
|
+
* of memory from $0000. Throws WireFailure(invalid-input) for anything else.
|
|
202
|
+
*/
|
|
203
|
+
export function imageRange(kind: ImageKind, image: Uint8Array): { start: number; end: number; body: Uint8Array } {
|
|
204
|
+
if (kind === "flat64k") {
|
|
205
|
+
if (image.length !== 0x10000) throw new WireFailure("invalid-input", "A flat 64 KiB image has exactly 65536 bytes.");
|
|
206
|
+
return { start: 0, end: 0xffff, body: image };
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
return prgRange(image);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (!(error instanceof RangeError)) throw error;
|
|
212
|
+
throw new WireFailure("invalid-input", error.message);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The seeds that an analysis of [start, end] can use: the entry points inside
|
|
218
|
+
* it, and the data ranges cut to it. A data range that holds an entry point
|
|
219
|
+
* is left out, so that the entry point is disassembled.
|
|
220
|
+
*/
|
|
221
|
+
export function seedsInside(params: Pick<DxaParams, "entryPoints" | "dataRanges">, start: number, end: number): Pick<DxaParams, "entryPoints" | "dataRanges"> {
|
|
222
|
+
const entryPoints = params.entryPoints.filter((address) => address >= start && address <= end);
|
|
223
|
+
const dataRanges = params.dataRanges
|
|
224
|
+
.filter((range) => range.end >= start && range.start <= end)
|
|
225
|
+
.map((range) => ({ start: Math.max(range.start, start), end: Math.min(range.end, end) }))
|
|
226
|
+
.filter((range) => !entryPoints.some((entry) => entry >= range.start && entry <= range.end));
|
|
227
|
+
return { entryPoints, dataRanges };
|
|
228
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The watchdog of a supervisor's process (the Host Runtime or a skill
|
|
2
|
+
// script), started by ProcessSupervisor.startWatchdog.
|
|
3
|
+
// Arguments: <owner pid> <registry file>. Standard input: a pipe whose
|
|
4
|
+
// writing end only the owner holds.
|
|
5
|
+
//
|
|
6
|
+
// It runs in its own process group, so a signal to the owner's group does
|
|
7
|
+
// not reach it. When the owner dies without its exit guard (SIGKILL, a
|
|
8
|
+
// crash), it stops every process group in the registry and removes every
|
|
9
|
+
// path. When the owner exits normally, the exit guard removes the registry
|
|
10
|
+
// and the watchdog ends.
|
|
11
|
+
//
|
|
12
|
+
// The end of the pipe tells it at once that the owner is gone: the system
|
|
13
|
+
// closes the owner's end when the owner ends, however it ends, and a pid that
|
|
14
|
+
// another process now uses cannot keep the pipe open. A poll of the owner's
|
|
15
|
+
// pid is the fallback for a pipe that does not end.
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
18
|
+
import { dirname } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { isAlive, killTree, signalGroup, type Registry } from "./processes.ts";
|
|
21
|
+
|
|
22
|
+
const POLL_MS = 500;
|
|
23
|
+
const GRACE_MS = 2_000;
|
|
24
|
+
|
|
25
|
+
const [ownerPidText, registryArgument] = process.argv.slice(2);
|
|
26
|
+
const ownerPid = Number(ownerPidText);
|
|
27
|
+
if (!Number.isInteger(ownerPid) || ownerPid <= 0 || registryArgument === undefined) process.exit(2);
|
|
28
|
+
const registryFile: string = registryArgument;
|
|
29
|
+
|
|
30
|
+
function signal(group: number, name: NodeJS.Signals): void {
|
|
31
|
+
try {
|
|
32
|
+
if (process.platform !== "win32") signalGroup(group, name);
|
|
33
|
+
else if (name === "SIGKILL") killTree(group);
|
|
34
|
+
} catch {
|
|
35
|
+
// The group is gone already.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Removes a path. A path that cannot be removed does not stop the removal of the others; nothing reads the watchdog's output. */
|
|
40
|
+
function remove(path: string): void {
|
|
41
|
+
try {
|
|
42
|
+
rmSync(path, { recursive: true, force: true });
|
|
43
|
+
} catch {
|
|
44
|
+
// The path stays.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let cleaning = false;
|
|
49
|
+
|
|
50
|
+
function cleanUp(): void {
|
|
51
|
+
if (cleaning) return;
|
|
52
|
+
cleaning = true;
|
|
53
|
+
clearInterval(timer);
|
|
54
|
+
let registry: Registry;
|
|
55
|
+
try {
|
|
56
|
+
registry = JSON.parse(readFileSync(registryFile, "utf8")) as Registry;
|
|
57
|
+
} catch {
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
for (const group of registry.groups) signal(group, "SIGTERM");
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
for (const group of registry.groups) signal(group, "SIGKILL");
|
|
63
|
+
for (const path of registry.paths) remove(path);
|
|
64
|
+
remove(dirname(registryFile));
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}, GRACE_MS);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The owner writes nothing: only the end of the pipe (or an error, which also ends it) counts.
|
|
70
|
+
process.stdin.on("data", () => {});
|
|
71
|
+
process.stdin.once("end", cleanUp);
|
|
72
|
+
process.stdin.once("error", cleanUp);
|
|
73
|
+
|
|
74
|
+
const timer = setInterval(() => {
|
|
75
|
+
// A normal exit removed the registry: nothing is left to do.
|
|
76
|
+
if (!existsSync(registryFile)) process.exit(0);
|
|
77
|
+
if (!isAlive(ownerPid)) cleanUp();
|
|
78
|
+
}, POLL_MS);
|