gdharness 1.0.29 → 1.0.30

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/build/cli.js CHANGED
@@ -37730,6 +37730,49 @@ var init_lsp_client = __esm(() => {
37730
37730
  init_ports();
37731
37731
  });
37732
37732
 
37733
+ // src/outside.ts
37734
+ import { spawn } from "node:child_process";
37735
+ import { fileURLToPath as fileURLToPath5 } from "node:url";
37736
+ function encodeSpec(spec) {
37737
+ return Buffer.from(JSON.stringify(spec), "utf8").toString("base64url");
37738
+ }
37739
+ function readLaunched(printed) {
37740
+ const line = printed.split(`
37741
+ `).find((one) => one.trim() !== "")?.trim() ?? "";
37742
+ const pid = /^pid (\d+)$/.exec(line);
37743
+ if (pid?.[1] !== undefined) {
37744
+ return { pid: Number(pid[1]) };
37745
+ }
37746
+ return { error: line.replace(/^error /, "") || "the launcher said nothing" };
37747
+ }
37748
+ async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = KEEPER_SCRIPT) {
37749
+ return await new Promise((resolve) => {
37750
+ const launcher = spawn(runtime, [keeper, "launch", encodeSpec(spec)], {
37751
+ stdio: ["ignore", "pipe", "pipe"],
37752
+ windowsHide: true
37753
+ });
37754
+ let printed = "";
37755
+ let complained = "";
37756
+ launcher.stdout.on("data", (chunk) => {
37757
+ printed += chunk.toString();
37758
+ });
37759
+ launcher.stderr.on("data", (chunk) => {
37760
+ complained += chunk.toString();
37761
+ });
37762
+ launcher.once("error", (error) => {
37763
+ resolve({ error: `the launcher did not start: ${error.message}` });
37764
+ });
37765
+ launcher.once("close", () => {
37766
+ const launched = readLaunched(printed);
37767
+ resolve("error" in launched && complained.trim() !== "" ? { error: `${launched.error}: ${complained.trim()}` } : launched);
37768
+ });
37769
+ });
37770
+ }
37771
+ var KEEPER_SCRIPT;
37772
+ var init_outside = __esm(() => {
37773
+ KEEPER_SCRIPT = fileURLToPath5(new URL("./keeper.js", import.meta.url));
37774
+ });
37775
+
37733
37776
  // src/process-time.ts
37734
37777
  import { execFile as execFile6 } from "node:child_process";
37735
37778
  import { promisify as promisify6 } from "node:util";
@@ -37949,9 +37992,6 @@ import { basename as basename3, join as join22 } from "node:path";
37949
37992
  function runsDirectory() {
37950
37993
  return join22(runtimeDirectory(), "runs");
37951
37994
  }
37952
- function recordPath() {
37953
- return join22(runsDirectory(), "run.json");
37954
- }
37955
37995
  function recordPaths() {
37956
37996
  return runtimeDirectories().map((directory) => join22(directory, "runs", "run.json"));
37957
37997
  }
@@ -37960,10 +38000,6 @@ function openTranscript(startedAt) {
37960
38000
  const path = join22(runsDirectory(), `run-${startedAt}.log`);
37961
38001
  return { path, fd: openSync2(path, "a") };
37962
38002
  }
37963
- function writeRunRecord(record) {
37964
- mkdirSync13(runsDirectory(), { recursive: true });
37965
- writeFileSync15(recordPath(), JSON.stringify(record, null, 2), "utf8");
37966
- }
37967
38003
  function editorNotePath() {
37968
38004
  return join22(runsDirectory(), "editor-run.json");
37969
38005
  }
@@ -37999,21 +38035,6 @@ function editorNoteAt(path) {
37999
38035
  }
38000
38036
  return { projectPath, transcript, startedAt };
38001
38037
  }
38002
- function recordRunEnded(pid, ending) {
38003
- const path = recordPath();
38004
- const record = recordAt(path);
38005
- if (record === null || record.pid !== pid) {
38006
- return;
38007
- }
38008
- const written = {
38009
- ...record,
38010
- ...ending.exitCode === null ? {} : { exitCode: ending.exitCode },
38011
- ...ending.exitSignal === null ? {} : { exitSignal: ending.exitSignal }
38012
- };
38013
- try {
38014
- writeFileSync15(path, JSON.stringify(written, null, 2), "utf8");
38015
- } catch {}
38016
- }
38017
38038
  function readRunRecord() {
38018
38039
  for (const path of recordPaths()) {
38019
38040
  const record = recordAt(path);
@@ -38240,7 +38261,7 @@ var init_run_record = __esm(() => {
38240
38261
  });
38241
38262
 
38242
38263
  // src/server.ts
38243
- import { execFile as execFile7, spawn } from "node:child_process";
38264
+ import { execFile as execFile7, spawn as spawn2 } from "node:child_process";
38244
38265
  import { randomUUID as randomUUID2 } from "node:crypto";
38245
38266
  import {
38246
38267
  appendFileSync,
@@ -38261,7 +38282,7 @@ import {
38261
38282
  import { tmpdir as tmpdir5 } from "node:os";
38262
38283
  import { basename as basename4, dirname as dirname16, extname as extname2, isAbsolute as isAbsolute3, join as join23, normalize as normalize3, relative as relative3 } from "node:path";
38263
38284
  import { setTimeout as delay3 } from "node:timers/promises";
38264
- import { fileURLToPath as fileURLToPath5 } from "node:url";
38285
+ import { fileURLToPath as fileURLToPath6 } from "node:url";
38265
38286
  import { promisify as promisify7 } from "node:util";
38266
38287
  function captureDestinationRefusal(outputPath, projects) {
38267
38288
  if (/^(res|user):\/\//.test(outputPath)) {
@@ -38330,7 +38351,7 @@ function stillRunning(run) {
38330
38351
  if (typeof run.endedHere === "string") {
38331
38352
  return false;
38332
38353
  }
38333
- if (!run.throughEditor && run.process === null && run.pid !== null) {
38354
+ if (!run.throughEditor && run.pid !== null) {
38334
38355
  return alive(run.pid);
38335
38356
  }
38336
38357
  if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
@@ -38354,10 +38375,10 @@ function runIsUp(run, editorSays, now = Date.now()) {
38354
38375
  return editorSays ?? stillRunning(run);
38355
38376
  }
38356
38377
  function noCodeWillCome(run) {
38357
- return !exited(run) && run.process === null;
38378
+ return !exited(run);
38358
38379
  }
38359
- async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
38360
- const gone = () => (handle?.exitCode !== null || handle.signalCode !== null) && pids.every((pid) => !alive(pid));
38380
+ async function untilGone(pids, withinMs = STOP_WAIT_MS) {
38381
+ const gone = () => pids.every((pid) => !alive(pid));
38361
38382
  const deadline = Date.now() + withinMs;
38362
38383
  while (!gone() && Date.now() < deadline) {
38363
38384
  await delay3(50);
@@ -38365,6 +38386,9 @@ async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
38365
38386
  return gone();
38366
38387
  }
38367
38388
  function endedWithoutACode(run) {
38389
+ if (!run.throughEditor && !run.pickedUp) {
38390
+ return "This run was started here, and the keeper holding it wrote no exit code into its note: the keeper was ended before the game, or another run replaced the note. Everything it printed is below, read back from its transcript.";
38391
+ }
38368
38392
  if (!run.throughEditor) {
38369
38393
  return run.endedHere ? "This run was started by another server, so nothing here held it and its exit code was never collected. Everything it printed is below, read back from its transcript." : "This run was started by another server, the one this server replaced or one running beside it, and it ended with nothing here waiting on it, so its exit code was never collected. Everything it printed is below, read back from its transcript.";
38370
38394
  }
@@ -40116,7 +40140,12 @@ class GodotServer {
40116
40140
  return anEditorIsStillComing(listeningSince, this.launchedEditorIsUp());
40117
40141
  }
40118
40142
  launchedEditorIsUp() {
40119
- return this.launchedEditor?.exitCode === null && this.launchedEditor.signalCode === null;
40143
+ const launched = this.launchedEditor;
40144
+ if (launched === null || !alive(launched.pid)) {
40145
+ return false;
40146
+ }
40147
+ const now = startTimesOf([launched.pid]).get(launched.pid);
40148
+ return launched.startedAt === null || now === undefined || now === launched.startedAt;
40120
40149
  }
40121
40150
  dap() {
40122
40151
  const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
@@ -40477,9 +40506,9 @@ class GodotServer {
40477
40506
  }
40478
40507
  async openAnEditor(engine, projectPath, ports) {
40479
40508
  clearEditorLog(projectPath);
40480
- const editor = spawn(engine, editorArguments(projectPath, ports, editorLogPath(projectPath)), {
40481
- stdio: "ignore",
40482
- detached: true,
40509
+ const launched = await launchOutsideTheTree({
40510
+ command: engine,
40511
+ args: editorArguments(projectPath, ports, editorLogPath(projectPath)),
40483
40512
  env: {
40484
40513
  ...process.env,
40485
40514
  GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
@@ -40488,23 +40517,15 @@ class GodotServer {
40488
40517
  [OPENED_BY_A_SERVER]: "1"
40489
40518
  }
40490
40519
  });
40491
- if (editor.pid !== undefined) {
40492
- writeEditorLogNote(projectPath, editor.pid);
40520
+ if ("error" in launched) {
40521
+ return { pid: null, error: launched.error };
40493
40522
  }
40494
- const started = await new Promise((resolve) => {
40495
- editor.once("spawn", () => {
40496
- resolve(null);
40497
- });
40498
- editor.once("error", (error) => {
40499
- resolve(error.message);
40500
- });
40501
- });
40502
- if (started !== null) {
40503
- return { pid: null, error: started };
40504
- }
40505
- editor.unref();
40506
- this.launchedEditor = editor.pid === undefined ? null : editor;
40507
- return { pid: editor.pid ?? null, error: null };
40523
+ writeEditorLogNote(projectPath, launched.pid);
40524
+ this.launchedEditor = {
40525
+ pid: launched.pid,
40526
+ startedAt: startTimesOf([launched.pid]).get(launched.pid) ?? null
40527
+ };
40528
+ return { pid: launched.pid, error: null };
40508
40529
  }
40509
40530
  async portsForAnEditor() {
40510
40531
  const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
@@ -40574,7 +40595,12 @@ class GodotServer {
40574
40595
  userArgs: given.value
40575
40596
  });
40576
40597
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
40577
- const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path, needsItsOwnProcess(asked.value) ? environmentFor({ ...asked.value, runtimeDirectory: runtimeDirectory() }) : undefined);
40598
+ const started = await this.launchKeptGame(engine.value, cmdArgs, project.value.path, needsItsOwnProcess(asked.value) ? environmentFor({ ...asked.value, runtimeDirectory: runtimeDirectory() }) : undefined);
40599
+ if ("error" in started) {
40600
+ return this.createErrorResponse(`The game could not be started: ${started.error}`, [
40601
+ "editor_status says which Godot this server runs and whether it answers"
40602
+ ]);
40603
+ }
40578
40604
  started.announcedBefore = alreadyPlaying;
40579
40605
  this.activeProcess = started;
40580
40606
  const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && needsItsOwnProcess(asked.value) ? " The editor plays a game with its own environment, so a run given one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
@@ -40586,7 +40612,7 @@ class GodotServer {
40586
40612
  started: true,
40587
40613
  through: "gdharness",
40588
40614
  alsoRunning: stranded.length === 0 ? undefined : stranded.map((one) => one.pid),
40589
- pid: started.process.pid ?? null,
40615
+ pid: started.pid,
40590
40616
  arguments: cmdArgs,
40591
40617
  transcript: started.transcript,
40592
40618
  refreshedClasses: refreshed.value,
@@ -40701,7 +40727,6 @@ class GodotServer {
40701
40727
  closeSync2(transcript.fd);
40702
40728
  const playAnswer = asParams(JSON.parse(answer.content[0]?.text ?? "{}"));
40703
40729
  const played = {
40704
- process: null,
40705
40730
  pid: null,
40706
40731
  log,
40707
40732
  transcript: transcript.path,
@@ -40800,8 +40825,14 @@ class GodotServer {
40800
40825
  }
40801
40826
  async endActiveGame(reason, going) {
40802
40827
  const running = this.activeProcess;
40803
- const recorded = running?.process === null ? readRunRecord() : null;
40804
- clearRunRecord((record) => this.couldBeOurs(record.projectPath));
40828
+ const recorded = running !== null && !running.throughEditor ? readRunRecord() : null;
40829
+ const clearTheRecord = () => {
40830
+ clearRunRecord((record) => this.couldBeOurs(record.projectPath));
40831
+ };
40832
+ const aKeeperHoldsIt = running !== null && going && !running.throughEditor && running.pid !== null;
40833
+ if (!aKeeperHoldsIt) {
40834
+ clearTheRecord();
40835
+ }
40805
40836
  if (!running || !going) {
40806
40837
  return true;
40807
40838
  }
@@ -40809,16 +40840,13 @@ class GodotServer {
40809
40840
  const announced = this.announcedPidOf(running);
40810
40841
  if (running.throughEditor) {
40811
40842
  await this.handleViaBridge("stop_playing", {});
40812
- return await untilGone(null, announced === undefined ? [] : [announced]);
40813
- }
40814
- if (running.process !== null) {
40815
- running.process.kill();
40816
- return await untilGone(running.process, announced === undefined ? [] : [announced]);
40843
+ return await untilGone(announced === undefined ? [] : [announced]);
40817
40844
  }
40818
40845
  if (running.pid === null) {
40819
40846
  return true;
40820
40847
  }
40821
40848
  if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
40849
+ clearTheRecord();
40822
40850
  running.endedHere = null;
40823
40851
  running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the run that was recorded, so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
40824
40852
  return true;
@@ -40831,12 +40859,20 @@ class GodotServer {
40831
40859
  } catch {
40832
40860
  landed = false;
40833
40861
  }
40834
- const gone = await untilGone(null, [running.pid]);
40862
+ const gone = await untilGone([running.pid]);
40863
+ if (gone) {
40864
+ await this.untilTheExitIsRecorded(running);
40865
+ }
40866
+ if (landed && gone) {
40867
+ running.exitCode = null;
40868
+ running.exitSignal = "SIGTERM";
40869
+ }
40870
+ clearTheRecord();
40835
40871
  running.log.record("info", landed ? `gdharness ended pid ${running.pid}, which the operating system described as ${described}.` : `gdharness signalled pid ${running.pid} and it was already gone; the operating system had described it as ${described}.`);
40836
40872
  return gone;
40837
40873
  }
40838
40874
  spawnGame(godotPath, cmdArgs, env) {
40839
- const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
40875
+ const child = spawn2(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
40840
40876
  const cancelled = callSignal();
40841
40877
  if (cancelled !== undefined) {
40842
40878
  const end = () => {
@@ -40888,25 +40924,23 @@ class GodotServer {
40888
40924
  });
40889
40925
  return started;
40890
40926
  }
40891
- spawnKeptGame(godotPath, cmdArgs, projectPath, env) {
40927
+ async launchKeptGame(godotPath, cmdArgs, projectPath, env) {
40892
40928
  const startedAt = Date.now();
40893
40929
  const transcript = openTranscript(startedAt);
40894
- let child;
40895
- try {
40896
- child = spawn(godotPath, cmdArgs, {
40897
- stdio: ["ignore", transcript.fd, transcript.fd],
40898
- detached: true,
40899
- ...env ? { env } : {}
40900
- });
40901
- } finally {
40902
- closeSync2(transcript.fd);
40930
+ closeSync2(transcript.fd);
40931
+ const launched = await launchOutsideTheTree({
40932
+ command: godotPath,
40933
+ args: cmdArgs,
40934
+ ...env === undefined ? {} : { env },
40935
+ run: { transcript: transcript.path, startedAt, projectPath }
40936
+ });
40937
+ if ("error" in launched) {
40938
+ return launched;
40903
40939
  }
40904
- child.unref();
40905
- const log = new GameLog;
40906
- const started = {
40907
- process: child,
40908
- pid: child.pid ?? null,
40909
- log,
40940
+ sweepTranscripts();
40941
+ return {
40942
+ pid: launched.pid,
40943
+ log: new GameLog,
40910
40944
  transcript: transcript.path,
40911
40945
  readOffset: 0,
40912
40946
  projectPath,
@@ -40916,34 +40950,6 @@ class GodotServer {
40916
40950
  throughEditor: false,
40917
40951
  brokeOn: null
40918
40952
  };
40919
- child.on("exit", (code, signal) => {
40920
- this.logDebug(`Godot process exited with code ${code ?? "none"}, signal ${signal ?? "none"}`);
40921
- this.drainTranscript(started);
40922
- log.finish();
40923
- started.exitCode = code;
40924
- started.exitSignal = code === null ? signal : null;
40925
- if (started.pid !== null) {
40926
- recordRunEnded(started.pid, { exitCode: started.exitCode, exitSignal: started.exitSignal });
40927
- }
40928
- });
40929
- child.on("error", (err) => {
40930
- console.error("Failed to start Godot process:", err);
40931
- log.append("stderr", `${err.message}
40932
- `);
40933
- started.exitCode = -1;
40934
- });
40935
- if (started.pid !== null) {
40936
- writeRunRecord({
40937
- pid: started.pid,
40938
- transcript: transcript.path,
40939
- startedAt,
40940
- projectPath,
40941
- arguments: cmdArgs,
40942
- command: godotPath
40943
- });
40944
- }
40945
- sweepTranscripts();
40946
- return started;
40947
40953
  }
40948
40954
  drainTranscript(game) {
40949
40955
  if (game.transcript === null) {
@@ -40999,7 +41005,6 @@ class GodotServer {
40999
41005
  const left = readEditorRunNote();
41000
41006
  const note = left !== null && project !== null && isSameDirectory(left.projectPath, project) ? left : null;
41001
41007
  const picked = {
41002
- process: null,
41003
41008
  pid: null,
41004
41009
  log: new GameLog,
41005
41010
  transcript: note?.transcript ?? null,
@@ -41136,7 +41141,6 @@ class GodotServer {
41136
41141
  return null;
41137
41142
  }
41138
41143
  const adopted = {
41139
- process: null,
41140
41144
  pid: record.pid,
41141
41145
  log: new GameLog,
41142
41146
  transcript: record.transcript,
@@ -41146,7 +41150,8 @@ class GodotServer {
41146
41150
  exitCode: null,
41147
41151
  exitSignal: null,
41148
41152
  throughEditor: false,
41149
- brokeOn: null
41153
+ brokeOn: null,
41154
+ pickedUp: true
41150
41155
  };
41151
41156
  this.drainTranscript(adopted);
41152
41157
  if (!alive(record.pid) || !couldStillBeTheRecordedRun(record)) {
@@ -41281,8 +41286,8 @@ class GodotServer {
41281
41286
  async runStillGoing(run, editorSays) {
41282
41287
  if (!run.throughEditor) {
41283
41288
  const going = stillRunning(run);
41284
- if (!going && run.process !== null && !exited(run)) {
41285
- await untilGone(run.process, [], EXIT_REPORTED_WITHIN_MS);
41289
+ if (!going && run.pid !== null && !exited(run) && run.endedUnwatched !== true && typeof run.endedHere !== "string" && !await this.untilTheExitIsRecorded(run)) {
41290
+ run.endedUnwatched = true;
41286
41291
  }
41287
41292
  return going;
41288
41293
  }
@@ -41299,6 +41304,21 @@ class GodotServer {
41299
41304
  }
41300
41305
  return runIsUp(run, said);
41301
41306
  }
41307
+ async untilTheExitIsRecorded(run) {
41308
+ const until = Date.now() + EXIT_REPORTED_WITHIN_MS;
41309
+ for (;; ) {
41310
+ const record = readRunRecord();
41311
+ if (record !== null && record.pid === run.pid && (record.exitCode !== undefined || record.exitSignal !== undefined)) {
41312
+ run.exitCode = record.exitCode ?? null;
41313
+ run.exitSignal = record.exitSignal ?? null;
41314
+ return true;
41315
+ }
41316
+ if (Date.now() >= until) {
41317
+ return false;
41318
+ }
41319
+ await delay3(50);
41320
+ }
41321
+ }
41302
41322
  handleEditorConsole(args) {
41303
41323
  const status = this.godotBridge.getStatus();
41304
41324
  const projectPath = status.projectPath ?? this.ownProject ?? undefined;
@@ -41400,7 +41420,8 @@ class GodotServer {
41400
41420
  throughEditor: run.throughEditor,
41401
41421
  announced: announced !== undefined || run.announcedPid !== undefined,
41402
41422
  couldAnnounce: run.projectPath !== null && existsSync15(join23(run.projectPath, RUNTIME_AUTOLOAD2.path)),
41403
- endedHere: typeof run.endedHere === "string"
41423
+ endedHere: typeof run.endedHere === "string",
41424
+ pickedUp: run.pickedUp === true
41404
41425
  }));
41405
41426
  }
41406
41427
  if (run.pickedUpPlaying === true) {
@@ -41426,7 +41447,7 @@ class GodotServer {
41426
41447
  ...hold,
41427
41448
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
41428
41449
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
41429
- project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
41450
+ project: run.throughEditor ? undefined : run.projectPath ?? undefined,
41430
41451
  transcript: run.transcript ?? undefined,
41431
41452
  startedAt: new Date(run.startedAt).toISOString(),
41432
41453
  elapsedMs: Date.now() - run.startedAt,
@@ -42056,6 +42077,7 @@ var init_server3 = __esm(() => {
42056
42077
  init_issues();
42057
42078
  init_junit();
42058
42079
  init_lsp_client();
42080
+ init_outside();
42059
42081
  init_paths();
42060
42082
  init_ports();
42061
42083
  init_process_children();
@@ -42071,7 +42093,7 @@ var init_server3 = __esm(() => {
42071
42093
  init_tool_definitions();
42072
42094
  init_update_check();
42073
42095
  run7 = promisify7(execFile7);
42074
- __dirname2 = dirname16(fileURLToPath5(import.meta.url));
42096
+ __dirname2 = dirname16(fileURLToPath6(import.meta.url));
42075
42097
  CONSOLE_WRAPPER = /_console(\.exe)?$/i;
42076
42098
  LONGEST_BOOT_NOTED_MS = 5 * 60000;
42077
42099
  VALUE_NAMES = {
package/build/index.js CHANGED
@@ -10183,7 +10183,7 @@ function defectReport(where, error, context = {}) {
10183
10183
  }
10184
10184
 
10185
10185
  // src/server.ts
10186
- import { execFile as execFile5, spawn } from "node:child_process";
10186
+ import { execFile as execFile5, spawn as spawn2 } from "node:child_process";
10187
10187
  import { randomUUID as randomUUID2 } from "node:crypto";
10188
10188
  import {
10189
10189
  appendFileSync,
@@ -10204,7 +10204,7 @@ import {
10204
10204
  import { tmpdir as tmpdir4 } from "node:os";
10205
10205
  import { basename as basename3, dirname as dirname10, extname as extname2, isAbsolute as isAbsolute3, join as join16, normalize as normalize2, relative as relative3 } from "node:path";
10206
10206
  import { setTimeout as delay3 } from "node:timers/promises";
10207
- import { fileURLToPath as fileURLToPath3 } from "node:url";
10207
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
10208
10208
  import { promisify as promisify5 } from "node:util";
10209
10209
 
10210
10210
  // node_modules/zod/v3/helpers/util.js
@@ -28884,6 +28884,46 @@ async function handleLSPTool(client, toolName, args) {
28884
28884
  }
28885
28885
  }
28886
28886
 
28887
+ // src/outside.ts
28888
+ import { spawn } from "node:child_process";
28889
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
28890
+ var KEEPER_SCRIPT = fileURLToPath2(new URL("./keeper.js", import.meta.url));
28891
+ function encodeSpec(spec) {
28892
+ return Buffer.from(JSON.stringify(spec), "utf8").toString("base64url");
28893
+ }
28894
+ function readLaunched(printed) {
28895
+ const line = printed.split(`
28896
+ `).find((one) => one.trim() !== "")?.trim() ?? "";
28897
+ const pid = /^pid (\d+)$/.exec(line);
28898
+ if (pid?.[1] !== undefined) {
28899
+ return { pid: Number(pid[1]) };
28900
+ }
28901
+ return { error: line.replace(/^error /, "") || "the launcher said nothing" };
28902
+ }
28903
+ async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = KEEPER_SCRIPT) {
28904
+ return await new Promise((resolve) => {
28905
+ const launcher = spawn(runtime, [keeper, "launch", encodeSpec(spec)], {
28906
+ stdio: ["ignore", "pipe", "pipe"],
28907
+ windowsHide: true
28908
+ });
28909
+ let printed = "";
28910
+ let complained = "";
28911
+ launcher.stdout.on("data", (chunk) => {
28912
+ printed += chunk.toString();
28913
+ });
28914
+ launcher.stderr.on("data", (chunk) => {
28915
+ complained += chunk.toString();
28916
+ });
28917
+ launcher.once("error", (error) => {
28918
+ resolve({ error: `the launcher did not start: ${error.message}` });
28919
+ });
28920
+ launcher.once("close", () => {
28921
+ const launched = readLaunched(printed);
28922
+ resolve("error" in launched && complained.trim() !== "" ? { error: `${launched.error}: ${complained.trim()}` } : launched);
28923
+ });
28924
+ });
28925
+ }
28926
+
28887
28927
  // src/process-time.ts
28888
28928
  import { execFile as execFile4 } from "node:child_process";
28889
28929
  import { promisify as promisify4 } from "node:util";
@@ -29336,9 +29376,6 @@ import { basename as basename2, join as join14 } from "node:path";
29336
29376
  function runsDirectory() {
29337
29377
  return join14(runtimeDirectory(), "runs");
29338
29378
  }
29339
- function recordPath() {
29340
- return join14(runsDirectory(), "run.json");
29341
- }
29342
29379
  function recordPaths() {
29343
29380
  return runtimeDirectories().map((directory) => join14(directory, "runs", "run.json"));
29344
29381
  }
@@ -29347,10 +29384,6 @@ function openTranscript(startedAt) {
29347
29384
  const path = join14(runsDirectory(), `run-${startedAt}.log`);
29348
29385
  return { path, fd: openSync2(path, "a") };
29349
29386
  }
29350
- function writeRunRecord(record) {
29351
- mkdirSync8(runsDirectory(), { recursive: true });
29352
- writeFileSync9(recordPath(), JSON.stringify(record, null, 2), "utf8");
29353
- }
29354
29387
  function editorNotePath() {
29355
29388
  return join14(runsDirectory(), "editor-run.json");
29356
29389
  }
@@ -29386,21 +29419,6 @@ function editorNoteAt(path) {
29386
29419
  }
29387
29420
  return { projectPath, transcript, startedAt };
29388
29421
  }
29389
- function recordRunEnded(pid, ending) {
29390
- const path = recordPath();
29391
- const record = recordAt(path);
29392
- if (record === null || record.pid !== pid) {
29393
- return;
29394
- }
29395
- const written = {
29396
- ...record,
29397
- ...ending.exitCode === null ? {} : { exitCode: ending.exitCode },
29398
- ...ending.exitSignal === null ? {} : { exitSignal: ending.exitSignal }
29399
- };
29400
- try {
29401
- writeFileSync9(path, JSON.stringify(written, null, 2), "utf8");
29402
- } catch {}
29403
- }
29404
29422
  function readRunRecord() {
29405
29423
  for (const path of recordPaths()) {
29406
29424
  const record = recordAt(path);
@@ -29635,7 +29653,7 @@ import {
29635
29653
  writeFileSync as writeFileSync10
29636
29654
  } from "node:fs";
29637
29655
  import { dirname as dirname9, join as join15 } from "node:path";
29638
- import { fileURLToPath as fileURLToPath2 } from "node:url";
29656
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
29639
29657
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
29640
29658
  var EDITOR_PLUGINS = ["gdharness_editor", "auto_reload"];
29641
29659
  var RUNTIME_AUTOLOAD = {
@@ -29681,7 +29699,7 @@ function shippedEditorDigest() {
29681
29699
  return shippedDigest ?? undefined;
29682
29700
  }
29683
29701
  function shippedAddonsDirectory() {
29684
- return join15(dirname9(fileURLToPath2(import.meta.url)), "godot", "addons");
29702
+ return join15(dirname9(fileURLToPath3(import.meta.url)), "godot", "addons");
29685
29703
  }
29686
29704
  function installedAddonVersion(projectPath) {
29687
29705
  const marker = join15(projectPath, "addons", ADDONS[0], VERSION_MARKER);
@@ -30948,7 +30966,7 @@ function buildToolDefinitions() {
30948
30966
  var UPDATE_NOTICE_EVERY = 500;
30949
30967
  var FEEDBACK_NOTICE_EVERY = 250;
30950
30968
  var run5 = promisify5(execFile5);
30951
- var __dirname2 = dirname10(fileURLToPath3(import.meta.url));
30969
+ var __dirname2 = dirname10(fileURLToPath4(import.meta.url));
30952
30970
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
30953
30971
  var HOLD_PING_MS = 2000;
30954
30972
  var SLOWEST_FRAME_RATE = 20;
@@ -31019,7 +31037,7 @@ function stillRunning(run) {
31019
31037
  if (typeof run.endedHere === "string") {
31020
31038
  return false;
31021
31039
  }
31022
- if (!run.throughEditor && run.process === null && run.pid !== null) {
31040
+ if (!run.throughEditor && run.pid !== null) {
31023
31041
  return alive(run.pid);
31024
31042
  }
31025
31043
  if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
@@ -31044,12 +31062,12 @@ function runIsUp(run, editorSays, now = Date.now()) {
31044
31062
  return editorSays ?? stillRunning(run);
31045
31063
  }
31046
31064
  function noCodeWillCome(run) {
31047
- return !exited(run) && run.process === null;
31065
+ return !exited(run);
31048
31066
  }
31049
31067
  var STOP_WAIT_MS = 1e4;
31050
31068
  var EXIT_REPORTED_WITHIN_MS = 2000;
31051
- async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
31052
- const gone = () => (handle?.exitCode !== null || handle.signalCode !== null) && pids.every((pid) => !alive(pid));
31069
+ async function untilGone(pids, withinMs = STOP_WAIT_MS) {
31070
+ const gone = () => pids.every((pid) => !alive(pid));
31053
31071
  const deadline = Date.now() + withinMs;
31054
31072
  while (!gone() && Date.now() < deadline) {
31055
31073
  await delay3(50);
@@ -31057,6 +31075,9 @@ async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
31057
31075
  return gone();
31058
31076
  }
31059
31077
  function endedWithoutACode(run) {
31078
+ if (!run.throughEditor && !run.pickedUp) {
31079
+ return "This run was started here, and the keeper holding it wrote no exit code into its note: the keeper was ended before the game, or another run replaced the note. Everything it printed is below, read back from its transcript.";
31080
+ }
31060
31081
  if (!run.throughEditor) {
31061
31082
  return run.endedHere ? "This run was started by another server, so nothing here held it and its exit code was never collected. Everything it printed is below, read back from its transcript." : "This run was started by another server, the one this server replaced or one running beside it, and it ended with nothing here waiting on it, so its exit code was never collected. Everything it printed is below, read back from its transcript.";
31062
31083
  }
@@ -32856,7 +32877,12 @@ class GodotServer {
32856
32877
  return anEditorIsStillComing(listeningSince, this.launchedEditorIsUp());
32857
32878
  }
32858
32879
  launchedEditorIsUp() {
32859
- return this.launchedEditor?.exitCode === null && this.launchedEditor.signalCode === null;
32880
+ const launched = this.launchedEditor;
32881
+ if (launched === null || !alive(launched.pid)) {
32882
+ return false;
32883
+ }
32884
+ const now = startTimesOf([launched.pid]).get(launched.pid);
32885
+ return launched.startedAt === null || now === undefined || now === launched.startedAt;
32860
32886
  }
32861
32887
  dap() {
32862
32888
  const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
@@ -33217,9 +33243,9 @@ class GodotServer {
33217
33243
  }
33218
33244
  async openAnEditor(engine, projectPath, ports) {
33219
33245
  clearEditorLog(projectPath);
33220
- const editor = spawn(engine, editorArguments(projectPath, ports, editorLogPath(projectPath)), {
33221
- stdio: "ignore",
33222
- detached: true,
33246
+ const launched = await launchOutsideTheTree({
33247
+ command: engine,
33248
+ args: editorArguments(projectPath, ports, editorLogPath(projectPath)),
33223
33249
  env: {
33224
33250
  ...process.env,
33225
33251
  GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
@@ -33228,23 +33254,15 @@ class GodotServer {
33228
33254
  [OPENED_BY_A_SERVER]: "1"
33229
33255
  }
33230
33256
  });
33231
- if (editor.pid !== undefined) {
33232
- writeEditorLogNote(projectPath, editor.pid);
33233
- }
33234
- const started = await new Promise((resolve) => {
33235
- editor.once("spawn", () => {
33236
- resolve(null);
33237
- });
33238
- editor.once("error", (error) => {
33239
- resolve(error.message);
33240
- });
33241
- });
33242
- if (started !== null) {
33243
- return { pid: null, error: started };
33257
+ if ("error" in launched) {
33258
+ return { pid: null, error: launched.error };
33244
33259
  }
33245
- editor.unref();
33246
- this.launchedEditor = editor.pid === undefined ? null : editor;
33247
- return { pid: editor.pid ?? null, error: null };
33260
+ writeEditorLogNote(projectPath, launched.pid);
33261
+ this.launchedEditor = {
33262
+ pid: launched.pid,
33263
+ startedAt: startTimesOf([launched.pid]).get(launched.pid) ?? null
33264
+ };
33265
+ return { pid: launched.pid, error: null };
33248
33266
  }
33249
33267
  async portsForAnEditor() {
33250
33268
  const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
@@ -33314,7 +33332,12 @@ class GodotServer {
33314
33332
  userArgs: given.value
33315
33333
  });
33316
33334
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
33317
- const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path, needsItsOwnProcess(asked.value) ? environmentFor({ ...asked.value, runtimeDirectory: runtimeDirectory() }) : undefined);
33335
+ const started = await this.launchKeptGame(engine.value, cmdArgs, project.value.path, needsItsOwnProcess(asked.value) ? environmentFor({ ...asked.value, runtimeDirectory: runtimeDirectory() }) : undefined);
33336
+ if ("error" in started) {
33337
+ return this.createErrorResponse(`The game could not be started: ${started.error}`, [
33338
+ "editor_status says which Godot this server runs and whether it answers"
33339
+ ]);
33340
+ }
33318
33341
  started.announcedBefore = alreadyPlaying;
33319
33342
  this.activeProcess = started;
33320
33343
  const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && needsItsOwnProcess(asked.value) ? " The editor plays a game with its own environment, so a run given one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
@@ -33326,7 +33349,7 @@ class GodotServer {
33326
33349
  started: true,
33327
33350
  through: "gdharness",
33328
33351
  alsoRunning: stranded.length === 0 ? undefined : stranded.map((one) => one.pid),
33329
- pid: started.process.pid ?? null,
33352
+ pid: started.pid,
33330
33353
  arguments: cmdArgs,
33331
33354
  transcript: started.transcript,
33332
33355
  refreshedClasses: refreshed.value,
@@ -33441,7 +33464,6 @@ class GodotServer {
33441
33464
  closeSync2(transcript.fd);
33442
33465
  const playAnswer = asParams(JSON.parse(answer.content[0]?.text ?? "{}"));
33443
33466
  const played = {
33444
- process: null,
33445
33467
  pid: null,
33446
33468
  log,
33447
33469
  transcript: transcript.path,
@@ -33540,8 +33562,14 @@ class GodotServer {
33540
33562
  }
33541
33563
  async endActiveGame(reason, going) {
33542
33564
  const running = this.activeProcess;
33543
- const recorded = running?.process === null ? readRunRecord() : null;
33544
- clearRunRecord((record) => this.couldBeOurs(record.projectPath));
33565
+ const recorded = running !== null && !running.throughEditor ? readRunRecord() : null;
33566
+ const clearTheRecord = () => {
33567
+ clearRunRecord((record) => this.couldBeOurs(record.projectPath));
33568
+ };
33569
+ const aKeeperHoldsIt = running !== null && going && !running.throughEditor && running.pid !== null;
33570
+ if (!aKeeperHoldsIt) {
33571
+ clearTheRecord();
33572
+ }
33545
33573
  if (!running || !going) {
33546
33574
  return true;
33547
33575
  }
@@ -33549,16 +33577,13 @@ class GodotServer {
33549
33577
  const announced = this.announcedPidOf(running);
33550
33578
  if (running.throughEditor) {
33551
33579
  await this.handleViaBridge("stop_playing", {});
33552
- return await untilGone(null, announced === undefined ? [] : [announced]);
33553
- }
33554
- if (running.process !== null) {
33555
- running.process.kill();
33556
- return await untilGone(running.process, announced === undefined ? [] : [announced]);
33580
+ return await untilGone(announced === undefined ? [] : [announced]);
33557
33581
  }
33558
33582
  if (running.pid === null) {
33559
33583
  return true;
33560
33584
  }
33561
33585
  if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
33586
+ clearTheRecord();
33562
33587
  running.endedHere = null;
33563
33588
  running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the run that was recorded, so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
33564
33589
  return true;
@@ -33571,12 +33596,20 @@ class GodotServer {
33571
33596
  } catch {
33572
33597
  landed = false;
33573
33598
  }
33574
- const gone = await untilGone(null, [running.pid]);
33599
+ const gone = await untilGone([running.pid]);
33600
+ if (gone) {
33601
+ await this.untilTheExitIsRecorded(running);
33602
+ }
33603
+ if (landed && gone) {
33604
+ running.exitCode = null;
33605
+ running.exitSignal = "SIGTERM";
33606
+ }
33607
+ clearTheRecord();
33575
33608
  running.log.record("info", landed ? `gdharness ended pid ${running.pid}, which the operating system described as ${described}.` : `gdharness signalled pid ${running.pid} and it was already gone; the operating system had described it as ${described}.`);
33576
33609
  return gone;
33577
33610
  }
33578
33611
  spawnGame(godotPath, cmdArgs, env) {
33579
- const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
33612
+ const child = spawn2(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
33580
33613
  const cancelled = callSignal();
33581
33614
  if (cancelled !== undefined) {
33582
33615
  const end = () => {
@@ -33628,25 +33661,23 @@ class GodotServer {
33628
33661
  });
33629
33662
  return started;
33630
33663
  }
33631
- spawnKeptGame(godotPath, cmdArgs, projectPath, env) {
33664
+ async launchKeptGame(godotPath, cmdArgs, projectPath, env) {
33632
33665
  const startedAt = Date.now();
33633
33666
  const transcript = openTranscript(startedAt);
33634
- let child;
33635
- try {
33636
- child = spawn(godotPath, cmdArgs, {
33637
- stdio: ["ignore", transcript.fd, transcript.fd],
33638
- detached: true,
33639
- ...env ? { env } : {}
33640
- });
33641
- } finally {
33642
- closeSync2(transcript.fd);
33667
+ closeSync2(transcript.fd);
33668
+ const launched = await launchOutsideTheTree({
33669
+ command: godotPath,
33670
+ args: cmdArgs,
33671
+ ...env === undefined ? {} : { env },
33672
+ run: { transcript: transcript.path, startedAt, projectPath }
33673
+ });
33674
+ if ("error" in launched) {
33675
+ return launched;
33643
33676
  }
33644
- child.unref();
33645
- const log = new GameLog;
33646
- const started = {
33647
- process: child,
33648
- pid: child.pid ?? null,
33649
- log,
33677
+ sweepTranscripts();
33678
+ return {
33679
+ pid: launched.pid,
33680
+ log: new GameLog,
33650
33681
  transcript: transcript.path,
33651
33682
  readOffset: 0,
33652
33683
  projectPath,
@@ -33656,34 +33687,6 @@ class GodotServer {
33656
33687
  throughEditor: false,
33657
33688
  brokeOn: null
33658
33689
  };
33659
- child.on("exit", (code, signal) => {
33660
- this.logDebug(`Godot process exited with code ${code ?? "none"}, signal ${signal ?? "none"}`);
33661
- this.drainTranscript(started);
33662
- log.finish();
33663
- started.exitCode = code;
33664
- started.exitSignal = code === null ? signal : null;
33665
- if (started.pid !== null) {
33666
- recordRunEnded(started.pid, { exitCode: started.exitCode, exitSignal: started.exitSignal });
33667
- }
33668
- });
33669
- child.on("error", (err) => {
33670
- console.error("Failed to start Godot process:", err);
33671
- log.append("stderr", `${err.message}
33672
- `);
33673
- started.exitCode = -1;
33674
- });
33675
- if (started.pid !== null) {
33676
- writeRunRecord({
33677
- pid: started.pid,
33678
- transcript: transcript.path,
33679
- startedAt,
33680
- projectPath,
33681
- arguments: cmdArgs,
33682
- command: godotPath
33683
- });
33684
- }
33685
- sweepTranscripts();
33686
- return started;
33687
33690
  }
33688
33691
  drainTranscript(game) {
33689
33692
  if (game.transcript === null) {
@@ -33739,7 +33742,6 @@ class GodotServer {
33739
33742
  const left = readEditorRunNote();
33740
33743
  const note = left !== null && project !== null && isSameDirectory(left.projectPath, project) ? left : null;
33741
33744
  const picked = {
33742
- process: null,
33743
33745
  pid: null,
33744
33746
  log: new GameLog,
33745
33747
  transcript: note?.transcript ?? null,
@@ -33876,7 +33878,6 @@ class GodotServer {
33876
33878
  return null;
33877
33879
  }
33878
33880
  const adopted = {
33879
- process: null,
33880
33881
  pid: record.pid,
33881
33882
  log: new GameLog,
33882
33883
  transcript: record.transcript,
@@ -33886,7 +33887,8 @@ class GodotServer {
33886
33887
  exitCode: null,
33887
33888
  exitSignal: null,
33888
33889
  throughEditor: false,
33889
- brokeOn: null
33890
+ brokeOn: null,
33891
+ pickedUp: true
33890
33892
  };
33891
33893
  this.drainTranscript(adopted);
33892
33894
  if (!alive(record.pid) || !couldStillBeTheRecordedRun(record)) {
@@ -34021,8 +34023,8 @@ class GodotServer {
34021
34023
  async runStillGoing(run, editorSays) {
34022
34024
  if (!run.throughEditor) {
34023
34025
  const going = stillRunning(run);
34024
- if (!going && run.process !== null && !exited(run)) {
34025
- await untilGone(run.process, [], EXIT_REPORTED_WITHIN_MS);
34026
+ if (!going && run.pid !== null && !exited(run) && run.endedUnwatched !== true && typeof run.endedHere !== "string" && !await this.untilTheExitIsRecorded(run)) {
34027
+ run.endedUnwatched = true;
34026
34028
  }
34027
34029
  return going;
34028
34030
  }
@@ -34039,6 +34041,21 @@ class GodotServer {
34039
34041
  }
34040
34042
  return runIsUp(run, said);
34041
34043
  }
34044
+ async untilTheExitIsRecorded(run) {
34045
+ const until = Date.now() + EXIT_REPORTED_WITHIN_MS;
34046
+ for (;; ) {
34047
+ const record = readRunRecord();
34048
+ if (record !== null && record.pid === run.pid && (record.exitCode !== undefined || record.exitSignal !== undefined)) {
34049
+ run.exitCode = record.exitCode ?? null;
34050
+ run.exitSignal = record.exitSignal ?? null;
34051
+ return true;
34052
+ }
34053
+ if (Date.now() >= until) {
34054
+ return false;
34055
+ }
34056
+ await delay3(50);
34057
+ }
34058
+ }
34042
34059
  handleEditorConsole(args) {
34043
34060
  const status = this.godotBridge.getStatus();
34044
34061
  const projectPath = status.projectPath ?? this.ownProject ?? undefined;
@@ -34140,7 +34157,8 @@ class GodotServer {
34140
34157
  throughEditor: run.throughEditor,
34141
34158
  announced: announced !== undefined || run.announcedPid !== undefined,
34142
34159
  couldAnnounce: run.projectPath !== null && existsSync10(join16(run.projectPath, RUNTIME_AUTOLOAD.path)),
34143
- endedHere: typeof run.endedHere === "string"
34160
+ endedHere: typeof run.endedHere === "string",
34161
+ pickedUp: run.pickedUp === true
34144
34162
  }));
34145
34163
  }
34146
34164
  if (run.pickedUpPlaying === true) {
@@ -34166,7 +34184,7 @@ class GodotServer {
34166
34184
  ...hold,
34167
34185
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
34168
34186
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
34169
- project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
34187
+ project: run.throughEditor ? undefined : run.projectPath ?? undefined,
34170
34188
  transcript: run.transcript ?? undefined,
34171
34189
  startedAt: new Date(run.startedAt).toISOString(),
34172
34190
  elapsedMs: Date.now() - run.startedAt,
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/keeper.ts
4
+ import { spawn } from "node:child_process";
5
+ import { closeSync, openSync as openSync2 } from "node:fs";
6
+
7
+ // src/outside.ts
8
+ import { fileURLToPath } from "node:url";
9
+ var KEEPER_SCRIPT = fileURLToPath(new URL("./keeper.js", import.meta.url));
10
+ function decodeSpec(encoded) {
11
+ return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
12
+ }
13
+ function readLaunched(printed) {
14
+ const line = printed.split(`
15
+ `).find((one) => one.trim() !== "")?.trim() ?? "";
16
+ const pid = /^pid (\d+)$/.exec(line);
17
+ if (pid?.[1] !== undefined) {
18
+ return { pid: Number(pid[1]) };
19
+ }
20
+ return { error: line.replace(/^error /, "") || "the launcher said nothing" };
21
+ }
22
+
23
+ // src/run-record.ts
24
+ import { mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
25
+ import { basename, join as join2 } from "node:path";
26
+
27
+ // src/process-children.ts
28
+ import { execFile, execFileSync, spawnSync } from "node:child_process";
29
+ import { promisify } from "node:util";
30
+ var run = promisify(execFile);
31
+
32
+ // src/runtime-client.ts
33
+ import { tmpdir } from "node:os";
34
+ import { dirname, join, resolve } from "node:path";
35
+
36
+ // src/launch.ts
37
+ function envValue(name, variables = process.env) {
38
+ const value = variables[name];
39
+ return value === undefined || value === "" ? undefined : value;
40
+ }
41
+
42
+ // src/runtime-client.ts
43
+ function runtimeDirectory(variables = process.env) {
44
+ const explicit = envValue("GDHARNESS_RUNTIME_DIR", variables);
45
+ if (explicit) {
46
+ return explicit;
47
+ }
48
+ const perUser = envValue("XDG_RUNTIME_DIR", variables);
49
+ return join(perUser ?? tmpdir(), "gdharness");
50
+ }
51
+ var ERROR_REPORT_KEEP_MS = 60 * 60 * 1000;
52
+ var verdicts = new Map;
53
+ var LATE_ANSWER_MS = 30 * 60000;
54
+ var lateAnswers = new Map;
55
+
56
+ // src/run-record.ts
57
+ function runsDirectory() {
58
+ return join2(runtimeDirectory(), "runs");
59
+ }
60
+ function recordPath() {
61
+ return join2(runsDirectory(), "run.json");
62
+ }
63
+ function writeRunRecord(record) {
64
+ mkdirSync(runsDirectory(), { recursive: true });
65
+ writeFileSync(recordPath(), JSON.stringify(record, null, 2), "utf8");
66
+ }
67
+ function recordRunEnded(pid, ending) {
68
+ const path = recordPath();
69
+ const record = recordAt(path);
70
+ if (record === null || record.pid !== pid) {
71
+ return;
72
+ }
73
+ const written = {
74
+ ...record,
75
+ ...ending.exitCode === null ? {} : { exitCode: ending.exitCode },
76
+ ...ending.exitSignal === null ? {} : { exitSignal: ending.exitSignal }
77
+ };
78
+ try {
79
+ writeFileSync(path, JSON.stringify(written, null, 2), "utf8");
80
+ } catch {}
81
+ }
82
+ function recordAt(path) {
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(readFileSync(path, "utf8"));
86
+ } catch {
87
+ return null;
88
+ }
89
+ if (!parsed || typeof parsed !== "object") {
90
+ return null;
91
+ }
92
+ const fields = parsed;
93
+ const pid = fields["pid"];
94
+ const transcript = fields["transcript"];
95
+ const startedAt = fields["startedAt"];
96
+ if (typeof pid !== "number" || typeof transcript !== "string" || typeof startedAt !== "number") {
97
+ return null;
98
+ }
99
+ const args = fields["arguments"];
100
+ return {
101
+ pid,
102
+ transcript,
103
+ startedAt,
104
+ projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
105
+ arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
106
+ ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
107
+ ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
108
+ ...typeof fields["exitSignal"] === "string" ? { exitSignal: fields["exitSignal"] } : {}
109
+ };
110
+ }
111
+
112
+ // src/keeper.ts
113
+ async function startDetached(spec, output) {
114
+ return await new Promise((resolve) => {
115
+ const child = spawn(spec.command, [...spec.args], {
116
+ stdio: ["ignore", output, output],
117
+ detached: true,
118
+ ...spec.env === undefined ? {} : { env: spec.env }
119
+ });
120
+ child.once("error", (error) => {
121
+ resolve({ error: error.message });
122
+ });
123
+ child.once("spawn", () => {
124
+ resolve(child.pid === undefined ? { error: "the operating system gave it no pid" } : { child, pid: child.pid });
125
+ });
126
+ });
127
+ }
128
+ async function launch(encoded) {
129
+ const spec = decodeSpec(encoded);
130
+ if (spec.run === undefined) {
131
+ const started = await startDetached(spec, "ignore");
132
+ if ("error" in started) {
133
+ process.stdout.write(`error ${started.error}
134
+ `);
135
+ } else {
136
+ started.child.unref();
137
+ process.stdout.write(`pid ${started.pid}
138
+ `);
139
+ }
140
+ process.exit(0);
141
+ }
142
+ const keeper = spawn(process.execPath, [KEEPER_SCRIPT, "keep", encoded], {
143
+ stdio: ["ignore", "pipe", "pipe"],
144
+ detached: true,
145
+ windowsHide: true
146
+ });
147
+ let printed = "";
148
+ let complained = "";
149
+ keeper.once("error", (error) => {
150
+ process.stdout.write(`error the keeper did not start: ${error.message}
151
+ `);
152
+ process.exit(0);
153
+ });
154
+ keeper.stderr.on("data", (chunk) => {
155
+ complained += chunk.toString();
156
+ });
157
+ keeper.stdout.on("data", (chunk) => {
158
+ printed += chunk.toString();
159
+ if (!printed.includes(`
160
+ `)) {
161
+ return;
162
+ }
163
+ const launched = readLaunched(printed);
164
+ process.stdout.write("pid" in launched ? `pid ${launched.pid}
165
+ ` : `error ${launched.error}
166
+ `);
167
+ keeper.unref();
168
+ process.exit(0);
169
+ });
170
+ keeper.once("close", () => {
171
+ process.stdout.write(`error the keeper ended before it said anything${complained ? `: ${complained.trim()}` : ""}
172
+ `);
173
+ process.exit(0);
174
+ });
175
+ }
176
+ async function keep(encoded) {
177
+ const spec = decodeSpec(encoded);
178
+ if (spec.run === undefined) {
179
+ process.stdout.write(`error a keeper was started without a run
180
+ `);
181
+ process.exit(1);
182
+ }
183
+ const run = spec.run;
184
+ const transcript = openSync2(run.transcript, "a");
185
+ let started;
186
+ try {
187
+ started = await startDetached(spec, transcript);
188
+ } finally {
189
+ closeSync(transcript);
190
+ }
191
+ if ("error" in started) {
192
+ process.stdout.write(`error ${started.error}
193
+ `);
194
+ process.exit(1);
195
+ }
196
+ const { child, pid } = started;
197
+ writeRunRecord({
198
+ pid,
199
+ transcript: run.transcript,
200
+ startedAt: run.startedAt,
201
+ projectPath: run.projectPath,
202
+ arguments: spec.args,
203
+ command: spec.command
204
+ });
205
+ child.once("exit", (code, signal) => {
206
+ recordRunEnded(pid, { exitCode: code, exitSignal: code === null ? signal : null });
207
+ process.exit(0);
208
+ });
209
+ process.stdout.write(`pid ${pid}
210
+ `);
211
+ process.stdout.end();
212
+ }
213
+ var [mode, encoded] = process.argv.slice(2);
214
+ if (mode === "launch" && encoded !== undefined) {
215
+ await launch(encoded);
216
+ } else if (mode === "keep" && encoded !== undefined) {
217
+ await keep(encoded);
218
+ } else {
219
+ process.stderr.write(`keeper.js launch|keep <spec>
220
+ `);
221
+ process.exit(2);
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.0.29",
3
+ "version": "1.0.30",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",