@henols/c64-re-tools 1.0.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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -37
  3. package/distribution/package.ts +36 -0
  4. package/distribution/plugin.ts +120 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +73 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +103 -0
  8. package/skills/c64-basic/SKILL.md +67 -0
  9. package/skills/c64-basic/scripts/basic.ts +46 -0
  10. package/skills/c64-disk/SKILL.md +79 -0
  11. package/skills/c64-disk/scripts/disk.ts +65 -0
  12. package/skills/c64-emulator/SKILL.md +124 -0
  13. package/skills/c64-knowledge/SKILL.md +102 -0
  14. package/skills/c64-knowledge/scripts/knowledge.ts +257 -0
  15. package/skills/c64-memory-map/SKILL.md +76 -0
  16. package/skills/c64-memory-map/scripts/memmap.ts +81 -0
  17. package/skills/c64-memory-map/scripts/platform.ts +424 -0
  18. package/skills/c64-provenance/SKILL.md +76 -0
  19. package/skills/c64-provenance/scripts/compare.ts +92 -0
  20. package/skills/c64-provenance/scripts/provenance.ts +83 -0
  21. package/skills/c64-reverse-engineering/SKILL.md +90 -0
  22. package/skills/c64-static-analysis/SKILL.md +96 -0
  23. package/skills/c64-static-analysis/scripts/analyze.ts +175 -0
  24. package/skills/c64-static-analysis/scripts/findings.ts +73 -0
  25. package/skills/c64-testing/SKILL.md +79 -0
  26. package/skills/c64-testing/references/scenario-format.md +76 -0
  27. package/skills/c64-testing/scripts/checklist.ts +51 -0
  28. package/skills/c64-testing/scripts/scenario.ts +473 -0
  29. package/skills/c64-testing/scripts/test.ts +55 -0
  30. package/skills/c64-unpacker/SKILL.md +82 -0
  31. package/skills/c64-unpacker/scripts/evidence.ts +135 -0
  32. package/skills/c64-unpacker/scripts/unpack.ts +216 -0
  33. package/src/c64.ts +281 -0
  34. package/src/cli/ap-sdk.ts +44 -0
  35. package/src/cli/cleanup.ts +194 -0
  36. package/src/cli/main.ts +122 -0
  37. package/src/cli/status.ts +34 -0
  38. package/src/host/main.ts +121 -0
  39. package/src/host/server.ts +409 -0
  40. package/src/host/tools/c1541.ts +255 -0
  41. package/src/host/tools/index.ts +31 -0
  42. package/src/host/tools/petcat.ts +70 -0
  43. package/src/host/tools/status.ts +30 -0
  44. package/src/host/tools/version.ts +41 -0
  45. package/src/host/vice/adapter.ts +761 -0
  46. package/src/host/vice/binary-monitor.ts +389 -0
  47. package/src/host/vice/chips.ts +108 -0
  48. package/src/host/vice/connect.ts +34 -0
  49. package/src/host/vice/process.ts +402 -0
  50. package/src/host/vice/screen.ts +183 -0
  51. package/src/host/vice/session.ts +1316 -0
  52. package/src/host/vice/text-monitor.ts +223 -0
  53. package/src/host-client/connect.ts +241 -0
  54. package/src/host-client/tools.ts +89 -0
  55. package/src/host-client/transfer.ts +100 -0
  56. package/src/host-client/vice-session.ts +254 -0
  57. package/src/knowledge/database.ts +132 -0
  58. package/src/knowledge/history.ts +128 -0
  59. package/src/knowledge/import.ts +344 -0
  60. package/src/knowledge/read.ts +191 -0
  61. package/src/knowledge/schema.ts +140 -0
  62. package/src/knowledge/write.ts +348 -0
  63. package/src/mcp/main.ts +109 -0
  64. package/src/mcp/server.ts +307 -0
  65. package/src/mcp/tools/debug.ts +237 -0
  66. package/src/mcp/tools/execution.ts +116 -0
  67. package/src/mcp/tools/input.ts +69 -0
  68. package/src/mcp/tools/machine.ts +97 -0
  69. package/src/mcp/tools/media.ts +106 -0
  70. package/src/mcp/tools/memory.ts +203 -0
  71. package/src/mcp/tools/video.ts +260 -0
  72. package/src/native/acme.ts +116 -0
  73. package/src/native/discover.ts +141 -0
  74. package/src/native/dxa.ts +166 -0
  75. package/src/native/ghidra/analyze.ts +86 -0
  76. package/src/native/ghidra/index.ts +157 -0
  77. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  78. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  79. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  80. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  81. package/src/native/ghidra/scripts/C64Export.java +242 -0
  82. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  83. package/src/native/local.ts +34 -0
  84. package/src/native/processes.ts +350 -0
  85. package/src/native/run.ts +172 -0
  86. package/src/native/staging.ts +83 -0
  87. package/src/native/status.ts +101 -0
  88. package/src/native/types.ts +228 -0
  89. package/src/native/watchdog.ts +78 -0
  90. package/src/project.ts +39 -0
  91. package/src/protocol/framing.ts +132 -0
  92. package/src/protocol/messages.ts +252 -0
  93. package/src/protocol/tools.ts +247 -0
  94. package/src/protocol/vice.ts +1235 -0
  95. package/src/script.ts +59 -0
  96. package/src/time.ts +15 -0
  97. package/src/trace.ts +162 -0
  98. package/THIRD-PARTY-NOTICES.md +0 -26
  99. package/bin/cli.mjs +0 -283
  100. package/skills/acme-build/SKILL.md +0 -260
  101. package/skills/acme-build/scripts/acme.mjs +0 -341
  102. package/skills/acme-build/template.a +0 -53
  103. package/skills/c64-disk-access/SKILL.md +0 -159
  104. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  105. package/skills/c64-memory-mapping/SKILL.md +0 -631
  106. package/skills/c64-memory-mapping/memmap.json +0 -8800
  107. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  108. package/skills/c64-petcat/SKILL.md +0 -87
  109. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  110. package/skills/c64-program-recon/SKILL.md +0 -706
  111. package/skills/c64-program-recon/references/control-flow.md +0 -180
  112. package/skills/c64-program-recon/references/graphics.md +0 -79
  113. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  114. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  115. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  116. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  117. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  118. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  119. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  120. package/skills/c64-provenance-diff/SKILL.md +0 -292
  121. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  122. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  123. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  124. package/skills/c64-ram-capture/SKILL.md +0 -474
  125. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  126. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  127. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  128. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  129. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  130. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  131. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  132. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  133. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  134. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  135. package/skills/c64-ram-capture/transients/README.md +0 -137
  136. package/skills/routine-queue-walker/SKILL.md +0 -394
  137. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  138. package/skills/vice-wedge-triage/SKILL.md +0 -269
@@ -0,0 +1,242 @@
1
+ // Runs after Ghidra's analysis. Arguments: <request.json> <out.json>.
2
+ //
3
+ // Writes the structural result for the loaded image: functions with the
4
+ // source of their names, code and defined-data regions, memory references
5
+ // from instructions, and the requested decompilations within their bounds.
6
+ //@category c64-re-tools
7
+
8
+ import java.io.FileReader;
9
+ import java.io.FileWriter;
10
+ import java.util.LinkedHashSet;
11
+ import java.util.Set;
12
+
13
+ import com.google.gson.Gson;
14
+ import com.google.gson.JsonArray;
15
+ import com.google.gson.JsonElement;
16
+ import com.google.gson.JsonObject;
17
+
18
+ import ghidra.app.decompiler.DecompInterface;
19
+ import ghidra.app.decompiler.DecompileResults;
20
+ import ghidra.app.script.GhidraScript;
21
+ import ghidra.program.model.address.Address;
22
+ import ghidra.program.model.address.AddressSet;
23
+ import ghidra.program.model.listing.CodeUnit;
24
+ import ghidra.program.model.listing.Data;
25
+ import ghidra.program.model.listing.Function;
26
+ import ghidra.program.model.listing.Instruction;
27
+ import ghidra.program.model.symbol.RefType;
28
+ import ghidra.program.model.symbol.Reference;
29
+ import ghidra.program.model.symbol.SourceType;
30
+
31
+ public class C64Export extends GhidraScript {
32
+ private static final java.util.regex.Pattern NAME = java.util.regex.Pattern.compile("^\\.?[A-Za-z_][A-Za-z0-9_]{0,63}$");
33
+ /** The longest reason that the result check accepts. */
34
+ private static final int MAX_REASON = 1000;
35
+ /** The longest time for one decompilation, in seconds. */
36
+ private static final int MAX_DECOMPILE_SECONDS = 60;
37
+
38
+ @Override
39
+ public void run() throws Exception {
40
+ String[] args = getScriptArgs();
41
+ JsonObject request;
42
+ try (FileReader reader = new FileReader(args[0])) {
43
+ request = new Gson().fromJson(reader, JsonObject.class);
44
+ }
45
+ long start = request.get("start").getAsLong();
46
+ long end = request.get("end").getAsLong();
47
+ AddressSet coverage = new AddressSet(toAddr(start), toAddr(end));
48
+
49
+ JsonObject out = new JsonObject();
50
+ JsonArray coverageOut = new JsonArray();
51
+ coverageOut.add(range(start, end));
52
+ out.add("coverage", coverageOut);
53
+ out.add("functions", functions(coverage));
54
+ out.add("regions", regions(coverage));
55
+ out.add("references", references(coverage));
56
+ JsonArray notDecompiled = new JsonArray();
57
+ out.add("decompilations", decompile(request, notDecompiled));
58
+ out.add("notDecompiled", notDecompiled);
59
+ JsonObject completeness = new JsonObject();
60
+ completeness.addProperty("functions", true);
61
+ completeness.addProperty("regions", true);
62
+ completeness.addProperty("references", true);
63
+ out.add("completeness", completeness);
64
+ try (FileWriter writer = new FileWriter(args[1])) {
65
+ new Gson().toJson(out, writer);
66
+ }
67
+ }
68
+
69
+ private static JsonObject range(long start, long end) {
70
+ JsonObject range = new JsonObject();
71
+ range.addProperty("start", start);
72
+ range.addProperty("end", end);
73
+ return range;
74
+ }
75
+
76
+ private JsonArray functions(AddressSet coverage) {
77
+ JsonArray functions = new JsonArray();
78
+ for (Function function : currentProgram.getFunctionManager().getFunctions(coverage, true)) {
79
+ String name = function.getName();
80
+ SourceType source = function.getSymbol().getSource();
81
+ // Only the seeds carry user-defined names in this disposable project.
82
+ String nameSource = source == SourceType.USER_DEFINED ? "seed" : source == SourceType.DEFAULT ? "generated" : "native";
83
+ if (!NAME.matcher(name).matches()) {
84
+ // A name that this script makes is generated, whatever name it replaces.
85
+ name = String.format("FUN_%04x", function.getEntryPoint().getOffset());
86
+ nameSource = "generated";
87
+ }
88
+ JsonObject item = new JsonObject();
89
+ item.addProperty("entry", function.getEntryPoint().getOffset());
90
+ item.addProperty("name", name);
91
+ item.addProperty("nameSource", nameSource);
92
+ functions.add(item);
93
+ }
94
+ return functions;
95
+ }
96
+
97
+ /** Instructions are code and defined data is data; undefined bytes are not classified. */
98
+ private JsonArray regions(AddressSet coverage) {
99
+ JsonArray regions = new JsonArray();
100
+ String kind = null;
101
+ long first = 0;
102
+ long last = -2;
103
+ for (CodeUnit unit : currentProgram.getListing().getCodeUnits(coverage, true)) {
104
+ String unitKind = unit instanceof Instruction ? "code" : (unit instanceof Data && ((Data) unit).isDefined()) ? "data" : null;
105
+ long unitStart = unit.getMinAddress().getOffset();
106
+ long unitEnd = Math.min(unit.getMaxAddress().getOffset(), coverage.getMaxAddress().getOffset());
107
+ if (unitKind != null && unitKind.equals(kind) && unitStart == last + 1) {
108
+ last = unitEnd;
109
+ continue;
110
+ }
111
+ if (kind != null) {
112
+ JsonObject region = range(first, last);
113
+ region.addProperty("classification", kind);
114
+ regions.add(region);
115
+ }
116
+ kind = unitKind;
117
+ first = unitStart;
118
+ last = unitEnd;
119
+ }
120
+ if (kind != null) {
121
+ JsonObject region = range(first, last);
122
+ region.addProperty("classification", kind);
123
+ regions.add(region);
124
+ }
125
+ return regions;
126
+ }
127
+
128
+ private JsonArray references(AddressSet coverage) {
129
+ Set<String> seen = new LinkedHashSet<>();
130
+ JsonArray references = new JsonArray();
131
+ for (Instruction instruction : currentProgram.getListing().getInstructions(coverage, true)) {
132
+ for (Reference reference : instruction.getReferencesFrom()) {
133
+ RefType type = reference.getReferenceType();
134
+ Address to = reference.getToAddress();
135
+ if (!reference.isMemoryReference() || !to.isMemoryAddress() || type == RefType.FALL_THROUGH) {
136
+ continue;
137
+ }
138
+ long from = instruction.getMinAddress().getOffset();
139
+ if (type.isCall()) {
140
+ add(references, seen, from, to.getOffset(), "call");
141
+ } else if (type.isJump() || type.isFlow()) {
142
+ add(references, seen, from, to.getOffset(), "jump");
143
+ } else {
144
+ if (type.isRead()) {
145
+ add(references, seen, from, to.getOffset(), "read");
146
+ }
147
+ if (type.isWrite()) {
148
+ add(references, seen, from, to.getOffset(), "write");
149
+ }
150
+ if (!type.isRead() && !type.isWrite()) {
151
+ add(references, seen, from, to.getOffset(), "reference");
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return references;
157
+ }
158
+
159
+ private static void add(JsonArray references, Set<String> seen, long from, long to, String type) {
160
+ if (!seen.add(from + ":" + to + ":" + type)) {
161
+ return;
162
+ }
163
+ JsonObject reference = new JsonObject();
164
+ reference.addProperty("from", from);
165
+ reference.addProperty("to", to);
166
+ reference.addProperty("type", type);
167
+ references.add(reference);
168
+ }
169
+
170
+ /** A requested routine without decompiled code, and the decompiler's reason. */
171
+ private static void notDecompiled(JsonArray list, long entry, String reason) {
172
+ JsonObject item = new JsonObject();
173
+ item.addProperty("entry", entry);
174
+ String text = reason == null || reason.isBlank() ? "the decompiler gave no reason" : reason.strip();
175
+ item.addProperty("reason", text.length() > MAX_REASON ? text.substring(0, MAX_REASON) : text);
176
+ list.add(item);
177
+ }
178
+
179
+ private JsonArray decompile(JsonObject request, JsonArray failures) {
180
+ JsonArray decompilations = new JsonArray();
181
+ JsonArray entries = request.getAsJsonArray("decompile");
182
+ if (entries.size() == 0) {
183
+ return decompilations;
184
+ }
185
+ int perFunction = request.get("maxChars").getAsInt();
186
+ int remaining = request.get("maxTotalChars").getAsInt();
187
+ // The time that is left for the decompilations, as a clock time in milliseconds.
188
+ long deadline = request.get("decompileDeadline").getAsLong();
189
+ int routinesLeft = entries.size();
190
+ DecompInterface decompiler = new DecompInterface();
191
+ try {
192
+ // Ghidra ships no native decompiler for some platforms (macOS): it then does not start.
193
+ if (!decompiler.openProgram(currentProgram)) {
194
+ String reason = "the Ghidra decompiler did not start: " + decompiler.getLastMessage();
195
+ for (JsonElement element : entries) {
196
+ notDecompiled(failures, element.getAsLong(), reason);
197
+ }
198
+ return decompilations;
199
+ }
200
+ for (JsonElement element : entries) {
201
+ // Each routine gets an equal part of the time that is left, so every routine gets a turn.
202
+ long secondsLeft = (deadline - System.currentTimeMillis()) / 1000;
203
+ int seconds = (int) Math.min(MAX_DECOMPILE_SECONDS, secondsLeft / routinesLeft);
204
+ routinesLeft--;
205
+ Function function = getFunctionAt(toAddr(element.getAsLong()));
206
+ if (function == null) {
207
+ notDecompiled(failures, element.getAsLong(), "no function starts at this address");
208
+ continue;
209
+ }
210
+ if (remaining <= 0) {
211
+ notDecompiled(failures, element.getAsLong(), "the decompiled text of the other routines used the whole limit");
212
+ continue;
213
+ }
214
+ if (seconds < 1) {
215
+ notDecompiled(failures, element.getAsLong(), "the time for the Ghidra run ended before this routine");
216
+ continue;
217
+ }
218
+ DecompileResults results = decompiler.decompileFunction(function, seconds, monitor);
219
+ if (!results.decompileCompleted() || results.getDecompiledFunction() == null) {
220
+ notDecompiled(failures, element.getAsLong(), results.getErrorMessage());
221
+ continue;
222
+ }
223
+ String text = results.getDecompiledFunction().getC();
224
+ int limit = Math.min(perFunction, remaining);
225
+ boolean truncated = text.length() > limit;
226
+ if (truncated) {
227
+ text = text.substring(0, limit);
228
+ }
229
+ remaining -= text.length();
230
+ JsonObject item = new JsonObject();
231
+ item.addProperty("entry", function.getEntryPoint().getOffset());
232
+ item.addProperty("text", text);
233
+ item.addProperty("truncated", truncated);
234
+ decompilations.add(item);
235
+ }
236
+ }
237
+ finally {
238
+ decompiler.dispose();
239
+ }
240
+ return decompilations;
241
+ }
242
+ }
@@ -0,0 +1,60 @@
1
+ // Runs before Ghidra's analysis. Arguments: <seeds.json>.
2
+ //
3
+ // Fills the rest of the 64 KiB address space with uninitialized memory, so
4
+ // references to I/O and other RAM resolve, then applies the knowledge seeds:
5
+ // labels (user-defined names), data ranges (defined bytes that analysis does
6
+ // not disassemble) and entry points (disassembled functions).
7
+ //@category c64-re-tools
8
+
9
+ import java.io.FileReader;
10
+
11
+ import com.google.gson.Gson;
12
+ import com.google.gson.JsonElement;
13
+ import com.google.gson.JsonObject;
14
+
15
+ import ghidra.app.script.GhidraScript;
16
+ import ghidra.program.model.address.Address;
17
+ import ghidra.program.model.data.ArrayDataType;
18
+ import ghidra.program.model.data.ByteDataType;
19
+ import ghidra.program.model.mem.Memory;
20
+ import ghidra.program.model.symbol.SourceType;
21
+
22
+ public class C64Prepare extends GhidraScript {
23
+ @Override
24
+ public void run() throws Exception {
25
+ JsonObject seeds;
26
+ try (FileReader reader = new FileReader(getScriptArgs()[0])) {
27
+ seeds = new Gson().fromJson(reader, JsonObject.class);
28
+ }
29
+ long start = seeds.get("start").getAsLong();
30
+ long end = seeds.get("end").getAsLong();
31
+ Memory memory = currentProgram.getMemory();
32
+ if (start > 0) {
33
+ memory.createUninitializedBlock("RAM_LOW", toAddr(0), start, false);
34
+ }
35
+ if (end < 0xffff) {
36
+ memory.createUninitializedBlock("RAM_HIGH", toAddr(end + 1), 0xffff - end, false);
37
+ }
38
+
39
+ for (JsonElement element : seeds.getAsJsonArray("labels")) {
40
+ JsonObject label = element.getAsJsonObject();
41
+ createLabel(toAddr(label.get("address").getAsLong()), label.get("name").getAsString(), true, SourceType.USER_DEFINED);
42
+ }
43
+ for (JsonElement element : seeds.getAsJsonArray("dataRanges")) {
44
+ JsonObject range = element.getAsJsonObject();
45
+ Address first = toAddr(range.get("start").getAsLong());
46
+ Address last = toAddr(range.get("end").getAsLong());
47
+ int length = (int) (last.getOffset() - first.getOffset() + 1);
48
+ currentProgram.getListing().clearCodeUnits(first, last, false);
49
+ currentProgram.getListing().createData(first, new ArrayDataType(ByteDataType.dataType, length, 1));
50
+ }
51
+ for (JsonElement element : seeds.getAsJsonArray("entryPoints")) {
52
+ Address entry = toAddr(element.getAsLong());
53
+ addEntryPoint(entry);
54
+ disassemble(entry);
55
+ if (getFunctionAt(entry) == null) {
56
+ createFunction(entry, null);
57
+ }
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,34 @@
1
+ // The context for running native tools inside a skill script or the CLI:
2
+ // one supervisor whose exit guard stops every tool process group when
3
+ // the script ends, also on Ctrl+C or SIGTERM, and whose watchdog does the same
4
+ // when the script dies without its exit guard, for example by SIGKILL.
5
+ // A line that a tool adapter logs goes to the trace of the script.
6
+
7
+ import { trace } from "../trace.ts";
8
+ import { ProcessSupervisor } from "./processes.ts";
9
+
10
+ export interface LocalToolContext {
11
+ supervisor: ProcessSupervisor;
12
+ signal: AbortSignal;
13
+ log: (line: string) => void;
14
+ }
15
+
16
+ let shared: LocalToolContext | undefined;
17
+
18
+ /** The script's one tool context; created on first use. */
19
+ export function localToolContext(): LocalToolContext {
20
+ if (shared !== undefined) return shared;
21
+ const supervisor = new ProcessSupervisor();
22
+ // The watchdog also installs the exit guard.
23
+ supervisor.startWatchdog("skill-script");
24
+ const controller = new AbortController();
25
+ // A signal ends the script through process.exit, so the exit guard runs.
26
+ for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143]] as const) {
27
+ process.once(signal, () => {
28
+ controller.abort();
29
+ process.exit(code);
30
+ });
31
+ }
32
+ shared = { supervisor, signal: controller.signal, log: (line) => trace().log(line) };
33
+ return shared;
34
+ }
@@ -0,0 +1,350 @@
1
+ import { spawn, spawnSync, type ChildProcess, type StdioOptions } from "node:child_process";
2
+ import type { Writable } from "node:stream";
3
+ import { mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import { WireFailure } from "../protocol/messages.ts";
10
+
11
+ /** How long a process group gets between SIGTERM and SIGKILL. */
12
+ export const STOP_GRACE_MS = 5_000;
13
+ /** How long to wait for the group to vanish after SIGKILL before giving up. */
14
+ const KILL_WAIT_MS = 2_000;
15
+ const POLL_MS = 25;
16
+
17
+ export interface SpawnOptions {
18
+ cwd?: string;
19
+ env?: NodeJS.ProcessEnv | undefined;
20
+ stdio?: StdioOptions;
21
+ }
22
+
23
+ export interface ExitStatus {
24
+ code: number | null;
25
+ signal: NodeJS.Signals | null;
26
+ /** The error code, for example ENOENT, when the program did not start. */
27
+ spawnError?: string;
28
+ }
29
+
30
+ /** A child started in its own process group. Stopping it stops every descendant in that group. */
31
+ export interface SupervisedProcess {
32
+ readonly pid: number;
33
+ readonly child: ChildProcess;
34
+ /** Settles when the group leader exits, for any reason. */
35
+ readonly exited: Promise<ExitStatus>;
36
+ /** Terminates the whole process group: SIGTERM, then SIGKILL after the grace period. */
37
+ stop(): Promise<void>;
38
+ }
39
+
40
+ /**
41
+ * A process's state letter and process group on Linux, from /proc; undefined
42
+ * when it is gone. A zombie (Z) or a dead process (X) has exited: only the
43
+ * wait of its parent is missing. In a container whose PID 1 never reaps (for
44
+ * example `tail -f /dev/null`), an orphan stays a zombie for ever.
45
+ */
46
+ function linuxProcess(pid: number | string): { state: string; group: number } | undefined {
47
+ try {
48
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
49
+ const [state, , group] = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
50
+ return state === undefined ? undefined : { state, group: Number(group) };
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ const exitedState = (state: string) => state === "Z" || state === "X";
57
+
58
+ /** True while a process runs, also one this process may not signal (EPERM). A zombie does not run. */
59
+ export function isAlive(pid: number): boolean {
60
+ try {
61
+ process.kill(pid, 0);
62
+ } catch (error) {
63
+ if ((error as NodeJS.ErrnoException).code !== "EPERM") return false;
64
+ }
65
+ if (process.platform !== "linux") return true;
66
+ const found = linuxProcess(pid);
67
+ return found !== undefined && !exitedState(found.state);
68
+ }
69
+
70
+ /** Linux: true while a member of the process group runs; zombies do not count. */
71
+ function groupRuns(pgid: number): boolean {
72
+ for (const entry of readdirSync("/proc")) {
73
+ if (!/^\d+$/.test(entry)) continue;
74
+ const found = linuxProcess(entry);
75
+ if (found !== undefined && found.group === pgid && !exitedState(found.state)) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * Quotes one argument as the Microsoft C runtime and Java read it:
82
+ * backslashes before a quote and at the end are doubled, and an inner quote
83
+ * is escaped. A path that ends in a backslash then cannot swallow the
84
+ * arguments after it.
85
+ */
86
+ export function windowsQuote(arg: string): string {
87
+ return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
88
+ }
89
+
90
+ /**
91
+ * On Windows a .bat or .cmd file (Ghidra's analyzeHeadless.bat, for example)
92
+ * starts only through cmd.exe: Node refuses it without a shell (EINVAL). /s
93
+ * keeps the quoted command line as it is. Undefined for any other program.
94
+ */
95
+ export function batchInvocation(argv: readonly string[], comSpec = "cmd.exe"): { command: string; args: string[] } | undefined {
96
+ if (!/\.(bat|cmd)$/i.test(argv[0] ?? "")) return undefined;
97
+ const quote = windowsQuote;
98
+ return { command: comSpec, args: ["/d", "/s", "/c", `"${argv.map(quote).join(" ")}"`] };
99
+ }
100
+
101
+ /** How long taskkill may block: it runs synchronously, so without a limit it could stop this whole process. */
102
+ const TASKKILL_LIMIT_MS = 5_000;
103
+
104
+ /**
105
+ * Ends a process and every descendant on Windows, which has no process
106
+ * groups. taskkill finds the descendants through the process, so it must run
107
+ * while the process still runs. Returns undefined when taskkill ended the tree
108
+ * or found no such process, else why it failed.
109
+ */
110
+ export function killTree(pid: number): string | undefined {
111
+ const result = spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: TASKKILL_LIMIT_MS });
112
+ if (result.error !== undefined) return `taskkill did not run (${(result.error as NodeJS.ErrnoException).code ?? result.error.message})`;
113
+ // taskkill exits with 128 when no process has this pid: the process has ended.
114
+ if (result.status === 0 || result.status === 128) return undefined;
115
+ return `taskkill ended with exit code ${result.status ?? result.signal}`;
116
+ }
117
+
118
+ /** True when Node has seen the child's exit. */
119
+ const hasExited = (child: ChildProcess) => child.exitCode !== null || child.signalCode !== null;
120
+
121
+ /** Sends a signal to a process group (POSIX). Returns false when the group does not exist. */
122
+ export function signalGroup(pgid: number, signal: NodeJS.Signals | 0): boolean {
123
+ try {
124
+ process.kill(-pgid, signal);
125
+ return true;
126
+ } catch (error) {
127
+ const code = (error as NodeJS.ErrnoException).code;
128
+ // macOS answers EPERM for a group whose members are all zombies, which is a stopped group too.
129
+ if (code === "ESRCH" || (code === "EPERM" && process.platform === "darwin")) return false;
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ /** True while a process group has a running member (POSIX); a group of zombies has ended. */
135
+ export function isGroupRunning(pgid: number): boolean {
136
+ return signalGroup(pgid, 0) && (process.platform !== "linux" || groupRuns(pgid));
137
+ }
138
+
139
+ async function waitForGroupGone(pgid: number, timeoutMs: number): Promise<boolean> {
140
+ const deadline = Date.now() + timeoutMs;
141
+ while (isGroupRunning(pgid)) {
142
+ if (Date.now() >= deadline) return false;
143
+ await sleep(POLL_MS);
144
+ }
145
+ return true;
146
+ }
147
+
148
+ /** What the watchdog must clean up if the Host Runtime dies: process groups and paths. */
149
+ export interface Registry {
150
+ groups: number[];
151
+ paths: string[];
152
+ }
153
+
154
+ /** The watchdog's program, next to this module. A new URL literal, so an installed skill gets it too. */
155
+ const WATCHDOG = fileURLToPath(new URL("./watchdog.ts", import.meta.url));
156
+
157
+ /**
158
+ * The one child-process supervisor of a process: the Host Runtime, or a
159
+ * skill script that runs its own tools. Every emulator and native tool
160
+ * is started here, with an argv array and never a shell string,
161
+ * as the leader of its own process group. It also owns scratch paths, so
162
+ * that they go away with the processes that used them.
163
+ */
164
+ export class ProcessSupervisor {
165
+ readonly #children = new Set<SupervisedProcess>();
166
+ readonly #paths = new Set<string>();
167
+ readonly #graceMs: number;
168
+ #exitGuardInstalled = false;
169
+ /** The registry file that the watchdog reads, once a watchdog runs. */
170
+ #registry: string | undefined;
171
+ #watchdogPid: number | undefined;
172
+ /** The writing end of the watchdog's pipe. Kept open until this process ends: its end tells the watchdog that this process is gone. */
173
+ #watchdogPipe: Writable | undefined;
174
+
175
+ constructor(options: { graceMs?: number } = {}) {
176
+ this.#graceMs = options.graceMs ?? STOP_GRACE_MS;
177
+ }
178
+
179
+ /** Number of process groups that have not been stopped yet. */
180
+ get size(): number {
181
+ return this.#children.size;
182
+ }
183
+
184
+ /** The process id of the watchdog, once a watchdog runs. */
185
+ get watchdogPid(): number | undefined {
186
+ return this.#watchdogPid;
187
+ }
188
+
189
+ /** Starts argv[0] with argv[1..] as the leader of a new process group. */
190
+ spawn(argv: readonly string[], options: SpawnOptions = {}): SupervisedProcess {
191
+ const program = argv[0];
192
+ if (program === undefined) throw new TypeError("argv must name a command");
193
+ const batch = process.platform === "win32" ? batchInvocation(argv, process.env.ComSpec) : undefined;
194
+ const { command, args } = batch ?? { command: program, args: argv.slice(1) };
195
+ let child: ChildProcess;
196
+ try {
197
+ child = spawn(command, args, {
198
+ cwd: options.cwd,
199
+ env: options.env,
200
+ stdio: options.stdio ?? "ignore",
201
+ detached: process.platform !== "win32",
202
+ windowsHide: true,
203
+ ...(batch === undefined ? {} : { windowsVerbatimArguments: true }),
204
+ });
205
+ } catch (error) {
206
+ // Some failures throw at once instead of an "error" event: a file Windows cannot start
207
+ // (EFTYPE, for example a broken build) or a .bat without a shell (EINVAL).
208
+ const code = (error as NodeJS.ErrnoException).code ?? "an unknown error";
209
+ throw new WireFailure("installation-incomplete", `${program} is not a program that this system can start (${code}). Install it again.`);
210
+ }
211
+ const exited = new Promise<ExitStatus>((resolve) => {
212
+ child.once("exit", (code, signal) => resolve({ code, signal }));
213
+ // A failed spawn emits "error" and never "exit". A later "error" (a failed kill) changes nothing:
214
+ // the listener stays, so that such an error does not end this process.
215
+ child.on("error", (error: NodeJS.ErrnoException) => {
216
+ if (child.pid === undefined) resolve({ code: null, signal: null, spawnError: error.code ?? "an unknown error" });
217
+ });
218
+ });
219
+ if (child.pid === undefined) {
220
+ // Spawn failed (for example ENOENT); "error" follows asynchronously.
221
+ const failed: SupervisedProcess = { pid: -1, child, exited, stop: async () => {} };
222
+ return failed;
223
+ }
224
+
225
+ const pid = child.pid;
226
+ let stopping: Promise<void> | undefined;
227
+ const supervised: SupervisedProcess = {
228
+ pid,
229
+ child,
230
+ exited,
231
+ stop: () => {
232
+ stopping ??= this.#stopGroup(pid, child, exited).then(
233
+ () => {
234
+ this.#children.delete(supervised);
235
+ this.#writeRegistry();
236
+ },
237
+ (error: unknown) => {
238
+ // The group may still run: it stays registered for the exit guard and the watchdog, and a later stop tries again.
239
+ stopping = undefined;
240
+ throw error;
241
+ },
242
+ );
243
+ return stopping;
244
+ },
245
+ };
246
+ this.#children.add(supervised);
247
+ this.#writeRegistry();
248
+ return supervised;
249
+ }
250
+
251
+ /** Makes a scratch path part of what this supervisor cleans up. Returns the release for a normal removal. */
252
+ ownPath(path: string): () => void {
253
+ this.#paths.add(path);
254
+ this.#writeRegistry();
255
+ return () => {
256
+ this.#paths.delete(path);
257
+ this.#writeRegistry();
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Starts a watchdog process that cleans up if this process dies without
263
+ * running its exit guard, for example by SIGKILL: it stops every
264
+ * registered process group and removes every owned path. It ends by itself
265
+ * when this process exits normally. Its registry directory names the owner.
266
+ */
267
+ startWatchdog(owner: "host" | "skill-script"): void {
268
+ if (this.#registry !== undefined) return;
269
+ const directory = mkdtempSync(join(tmpdir(), `c64-re-tools-${owner}-watchdog-`));
270
+ this.#registry = join(directory, "registry.json");
271
+ this.#writeRegistry();
272
+ // The watchdog runs in the temporary directory, never in the caller's directory: Windows refuses
273
+ // to remove a directory that a running process uses. Its standard input is a pipe from this
274
+ // process, which the system closes when this process ends.
275
+ const watchdog = spawn(process.execPath, [...process.execArgv, WATCHDOG, String(process.pid), this.#registry], { cwd: tmpdir(), detached: true, stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
276
+ watchdog.unref();
277
+ // The pipe does not keep this process running, and an error on it does not end this process.
278
+ const pipe = watchdog.stdin as (Writable & { unref?: () => void }) | null;
279
+ pipe?.on("error", () => {});
280
+ pipe?.unref?.();
281
+ this.#watchdogPipe = pipe ?? undefined;
282
+ this.#watchdogPid = watchdog.pid;
283
+ this.installExitGuard();
284
+ }
285
+
286
+ #writeRegistry(): void {
287
+ if (this.#registry === undefined) return;
288
+ const registry: Registry = { groups: [...this.#children].map((child) => child.pid), paths: [...this.#paths] };
289
+ // Write and rename, so the watchdog never reads half a file.
290
+ writeFileSync(`${this.#registry}.next`, JSON.stringify(registry));
291
+ renameSync(`${this.#registry}.next`, this.#registry);
292
+ }
293
+
294
+ /** Stops every process group this supervisor started. */
295
+ async stopAll(): Promise<void> {
296
+ await Promise.all([...this.#children].map((child) => child.stop()));
297
+ }
298
+
299
+ /**
300
+ * SIGKILLs every remaining group and removes every owned path, synchronously.
301
+ * For process "exit", where nothing async runs. The watchdog then ends.
302
+ */
303
+ killAllSync(): void {
304
+ for (const supervised of this.#children) {
305
+ // On Windows the pid of an ended leader can belong to another process now.
306
+ if (process.platform === "win32") {
307
+ if (!hasExited(supervised.child)) killTree(supervised.pid);
308
+ } else signalGroup(supervised.pid, "SIGKILL");
309
+ }
310
+ this.#children.clear();
311
+ for (const path of this.#paths) rmSync(path, { recursive: true, force: true });
312
+ this.#paths.clear();
313
+ if (this.#registry !== undefined) rmSync(join(this.#registry, ".."), { recursive: true, force: true });
314
+ }
315
+
316
+ /** Makes any exit of this process, however it happens, SIGKILL the remaining groups. Idempotent. */
317
+ installExitGuard(): void {
318
+ if (this.#exitGuardInstalled) return;
319
+ this.#exitGuardInstalled = true;
320
+ process.on("exit", () => this.killAllSync());
321
+ }
322
+
323
+ async #stopGroup(pid: number, child: ChildProcess, exited: Promise<ExitStatus>): Promise<void> {
324
+ if (process.platform === "win32") {
325
+ // Windows has no SIGTERM for a console process. taskkill finds the descendants through the
326
+ // leader, so it runs first, and only while the leader runs: the pid of an ended leader can
327
+ // belong to another process. child.kill then ends the leader through its own handle: after
328
+ // taskkill alone, Node does not always report the leader's exit. The exit wait has a limit.
329
+ const failure = hasExited(child) ? undefined : killTree(pid);
330
+ try {
331
+ child.kill("SIGKILL");
332
+ } catch {
333
+ // The leader has ended already.
334
+ }
335
+ await Promise.race([exited, sleep(this.#graceMs)]);
336
+ if (!hasExited(child)) throw new Error(`process ${pid} did not end${failure === undefined ? "" : `: ${failure}`}`);
337
+ return;
338
+ }
339
+ // The group outlives its leader when descendants remain, so wait on the group, not the leader.
340
+ if (signalGroup(pid, "SIGTERM") && !(await waitForGroupGone(pid, this.#graceMs))) {
341
+ signalGroup(pid, "SIGKILL");
342
+ if (!(await waitForGroupGone(pid, KILL_WAIT_MS))) {
343
+ throw new Error(`process group ${pid} survived SIGKILL`);
344
+ }
345
+ }
346
+ // A group of zombies counts as gone before Node reaps the leader and reports its exit;
347
+ // stopped means that Node has seen that exit.
348
+ await Promise.race([exited, sleep(KILL_WAIT_MS)]);
349
+ }
350
+ }