gdharness 1.0.29 → 1.0.31

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/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,
@@ -10202,9 +10202,9 @@ import {
10202
10202
  statSync as statSync7
10203
10203
  } from "node:fs";
10204
10204
  import { tmpdir as tmpdir4 } from "node:os";
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";
10205
+ import { basename as basename3, dirname as dirname11, 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
@@ -25042,7 +25042,9 @@ function ancestorsIn(tree, pid, root) {
25042
25042
  return chain;
25043
25043
  }
25044
25044
  function readCommandLine(command) {
25045
- const words = commandWords(command);
25045
+ const all = commandWords(command);
25046
+ const separator = all.findIndex((word) => word === "--" || word === "++");
25047
+ const words = separator === -1 ? all : all.slice(0, separator);
25046
25048
  const executable = words[0] === undefined ? "" : basename(words[0].replaceAll("\\", "/"));
25047
25049
  const at = words.indexOf("--path");
25048
25050
  let projectPath = null;
@@ -25054,7 +25056,7 @@ function readCommandLine(command) {
25054
25056
  }
25055
25057
  taken.push(word);
25056
25058
  }
25057
- projectPath = taken.join(" ");
25059
+ projectPath = taken.join(" ").replaceAll("%20", " ");
25058
25060
  }
25059
25061
  return {
25060
25062
  executable,
@@ -25216,8 +25218,13 @@ function parseAnnouncement(file, pid) {
25216
25218
  name: readString(project, "name") ?? "",
25217
25219
  path: readString(project, "path") ?? ""
25218
25220
  };
25221
+ let announcedAt;
25222
+ try {
25223
+ announcedAt = statSync(file).mtimeMs;
25224
+ } catch {}
25225
+ const when = announcedAt === undefined ? {} : { announcedAt };
25219
25226
  if (protocol !== RUNTIME_PROTOCOL) {
25220
- return { kind: "unspoken", unspoken: { pid, protocol: protocol ?? 0, project: named } };
25227
+ return { kind: "unspoken", unspoken: { pid, protocol: protocol ?? 0, project: named, ...when } };
25221
25228
  }
25222
25229
  const editorPid = readNumber(fields, "editor_pid");
25223
25230
  return {
@@ -25228,7 +25235,8 @@ function parseAnnouncement(file, pid) {
25228
25235
  address,
25229
25236
  project: named,
25230
25237
  file,
25231
- ...editorPid !== undefined && Number.isInteger(editorPid) && editorPid > 0 ? { editorPid } : {}
25238
+ ...editorPid !== undefined && Number.isInteger(editorPid) && editorPid > 0 ? { editorPid } : {},
25239
+ ...when
25232
25240
  }
25233
25241
  };
25234
25242
  }
@@ -28884,6 +28892,69 @@ async function handleLSPTool(client, toolName, args) {
28884
28892
  }
28885
28893
  }
28886
28894
 
28895
+ // src/outside.ts
28896
+ import { spawn } from "node:child_process";
28897
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
28898
+ function changesFrom(env, base) {
28899
+ const changes = {};
28900
+ if (env === undefined) {
28901
+ return changes;
28902
+ }
28903
+ for (const [name, value] of Object.entries(env)) {
28904
+ if (value !== undefined && base[name] !== value) {
28905
+ changes[name] = value;
28906
+ }
28907
+ }
28908
+ for (const name of Object.keys(base)) {
28909
+ if (env[name] === undefined) {
28910
+ changes[name] = null;
28911
+ }
28912
+ }
28913
+ return changes;
28914
+ }
28915
+ function sendSpec(child, spec) {
28916
+ child.stdin?.end(JSON.stringify(spec), "utf8");
28917
+ }
28918
+ var KEEPER_SCRIPT = fileURLToPath2(new URL("./keeper.js", import.meta.url));
28919
+ function readLaunched(printed) {
28920
+ const line = printed.split(`
28921
+ `).find((one) => one.trim() !== "")?.trim() ?? "";
28922
+ const pid = /^pid (\d+)$/.exec(line);
28923
+ if (pid?.[1] !== undefined) {
28924
+ return { pid: Number(pid[1]) };
28925
+ }
28926
+ return { error: line.replace(/^error /, "") || "the launcher said nothing" };
28927
+ }
28928
+ async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = KEEPER_SCRIPT) {
28929
+ return await new Promise((resolve) => {
28930
+ const launcher = spawn(runtime, [keeper, "launch"], {
28931
+ stdio: ["pipe", "pipe", "pipe"],
28932
+ windowsHide: true
28933
+ });
28934
+ sendSpec(launcher, {
28935
+ command: spec.command,
28936
+ args: spec.args,
28937
+ envChanges: changesFrom(spec.env, process.env),
28938
+ ...spec.run === undefined ? {} : { run: spec.run }
28939
+ });
28940
+ let printed = "";
28941
+ let complained = "";
28942
+ launcher.stdout.on("data", (chunk) => {
28943
+ printed += chunk.toString();
28944
+ });
28945
+ launcher.stderr.on("data", (chunk) => {
28946
+ complained += chunk.toString();
28947
+ });
28948
+ launcher.once("error", (error) => {
28949
+ resolve({ error: `the launcher did not start: ${error.message}` });
28950
+ });
28951
+ launcher.once("close", () => {
28952
+ const launched = readLaunched(printed);
28953
+ resolve("error" in launched && complained.trim() !== "" ? { error: `${launched.error}: ${complained.trim()}` } : launched);
28954
+ });
28955
+ });
28956
+ }
28957
+
28887
28958
  // src/process-time.ts
28888
28959
  import { execFile as execFile4 } from "node:child_process";
28889
28960
  import { promisify as promisify4 } from "node:util";
@@ -29331,37 +29402,62 @@ function restartSettled(projectPath) {
29331
29402
 
29332
29403
  // src/run-record.ts
29333
29404
  import { execFileSync as execFileSync2 } from "node:child_process";
29334
- import { mkdirSync as mkdirSync8, openSync as openSync2, readdirSync as readdirSync5, readFileSync as readFileSync14, rmSync as rmSync6, statSync as statSync5, writeFileSync as writeFileSync9 } from "node:fs";
29335
- import { basename as basename2, join as join14 } from "node:path";
29405
+ import { createHash as createHash3 } from "node:crypto";
29406
+ import {
29407
+ mkdirSync as mkdirSync8,
29408
+ openSync as openSync2,
29409
+ readdirSync as readdirSync5,
29410
+ readFileSync as readFileSync14,
29411
+ realpathSync as realpathSync3,
29412
+ rmSync as rmSync6,
29413
+ statSync as statSync5,
29414
+ writeFileSync as writeFileSync9
29415
+ } from "node:fs";
29416
+ import { basename as basename2, dirname as dirname9, join as join14, resolve as resolve5 } from "node:path";
29336
29417
  function runsDirectory() {
29337
29418
  return join14(runtimeDirectory(), "runs");
29338
29419
  }
29339
- function recordPath() {
29340
- return join14(runsDirectory(), "run.json");
29420
+ function realAsFarAsItExists(path) {
29421
+ const missing = [];
29422
+ let at = path;
29423
+ for (;; ) {
29424
+ try {
29425
+ return join14(realpathSync3.native(at), ...missing);
29426
+ } catch {
29427
+ const parent = dirname9(at);
29428
+ if (parent === at) {
29429
+ return path;
29430
+ }
29431
+ missing.unshift(basename2(at));
29432
+ at = parent;
29433
+ }
29434
+ }
29435
+ }
29436
+ function projectKey(projectPath) {
29437
+ const real = realAsFarAsItExists(resolve5(projectPath)).replace(/[\\/]+$/, "");
29438
+ const folded = process.platform === "win32" || process.platform === "darwin" ? real.toLowerCase() : real;
29439
+ return createHash3("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
29341
29440
  }
29342
- function recordPaths() {
29343
- return runtimeDirectories().map((directory) => join14(directory, "runs", "run.json"));
29441
+ function runRecordPath(projectPath, directory = runtimeDirectory()) {
29442
+ return join14(directory, "runs", `note-${projectKey(projectPath)}.json`);
29344
29443
  }
29444
+ var SHARED_NOTE = "run.json";
29345
29445
  function openTranscript(startedAt) {
29346
29446
  mkdirSync8(runsDirectory(), { recursive: true });
29347
29447
  const path = join14(runsDirectory(), `run-${startedAt}.log`);
29348
29448
  return { path, fd: openSync2(path, "a") };
29349
29449
  }
29350
- function writeRunRecord(record) {
29351
- mkdirSync8(runsDirectory(), { recursive: true });
29352
- writeFileSync9(recordPath(), JSON.stringify(record, null, 2), "utf8");
29353
- }
29354
- function editorNotePath() {
29355
- return join14(runsDirectory(), "editor-run.json");
29450
+ function editorNotePath(projectPath, directory = runtimeDirectory()) {
29451
+ return join14(directory, "runs", `editor-${projectKey(projectPath)}.json`);
29356
29452
  }
29357
29453
  function writeEditorRunNote(note) {
29358
29454
  mkdirSync8(runsDirectory(), { recursive: true });
29359
- writeFileSync9(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
29455
+ writeFileSync9(editorNotePath(note.projectPath), JSON.stringify(note, null, 2), "utf8");
29360
29456
  }
29361
- function readEditorRunNote() {
29457
+ function readEditorRunNote(projectPath) {
29362
29458
  for (const directory of runtimeDirectories()) {
29363
- const note = editorNoteAt(join14(directory, "runs", "editor-run.json"));
29364
- if (note !== null) {
29459
+ const note = editorNoteAt(editorNotePath(projectPath, directory)) ?? editorNoteAt(join14(directory, "runs", "editor-run.json"));
29460
+ if (note !== null && isSameDirectory(note.projectPath, projectPath)) {
29365
29461
  return note;
29366
29462
  }
29367
29463
  }
@@ -29386,29 +29482,38 @@ function editorNoteAt(path) {
29386
29482
  }
29387
29483
  return { projectPath, transcript, startedAt };
29388
29484
  }
29389
- function recordRunEnded(pid, ending) {
29390
- const path = recordPath();
29391
- const record = recordAt(path);
29392
- if (record === null || record.pid !== pid) {
29393
- return;
29485
+ function readRunRecord(projectPath) {
29486
+ for (const directory of runtimeDirectories()) {
29487
+ const own = recordAt(runRecordPath(projectPath, directory));
29488
+ if (own !== null) {
29489
+ return own;
29490
+ }
29491
+ const shared = recordAt(join14(directory, "runs", SHARED_NOTE));
29492
+ if (shared !== null && shared.projectPath !== "" && isSameDirectory(shared.projectPath, projectPath)) {
29493
+ return shared;
29494
+ }
29394
29495
  }
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 {}
29496
+ return null;
29403
29497
  }
29404
- function readRunRecord() {
29405
- for (const path of recordPaths()) {
29406
- const record = recordAt(path);
29407
- if (record !== null) {
29408
- return record;
29498
+ function everyRunRecord() {
29499
+ const found = [];
29500
+ for (const directory of runtimeDirectories()) {
29501
+ let entries;
29502
+ try {
29503
+ entries = readdirSync5(join14(directory, "runs"));
29504
+ } catch {
29505
+ continue;
29506
+ }
29507
+ for (const entry of entries) {
29508
+ if (entry === SHARED_NOTE || entry.startsWith("note-") && entry.endsWith(".json")) {
29509
+ const record = recordAt(join14(directory, "runs", entry));
29510
+ if (record !== null) {
29511
+ found.push(record);
29512
+ }
29513
+ }
29409
29514
  }
29410
29515
  }
29411
- return null;
29516
+ return found.sort((one, other) => other.startedAt - one.startedAt);
29412
29517
  }
29413
29518
  function recordAt(path) {
29414
29519
  let parsed;
@@ -29554,7 +29659,6 @@ function stillTheRecordedRun(record) {
29554
29659
  function couldStillBeTheRecordedRun(record) {
29555
29660
  return judgeRun(record, runningAs(record.pid), "possible");
29556
29661
  }
29557
- var AN_EDITOR = /(?:^|\s)(?:-e|--editor)(?:\s|$)/;
29558
29662
  var SAME_RUN_WINDOW_MS = 1e4;
29559
29663
  function judgeRun(record, running, needed) {
29560
29664
  if (running === null) {
@@ -29569,7 +29673,7 @@ function judgeRun(record, running, needed) {
29569
29673
  if (wanted !== null && !said.includes(wanted)) {
29570
29674
  return false;
29571
29675
  }
29572
- if (running.kind === "commandLine" && AN_EDITOR.test(said)) {
29676
+ if (running.kind === "commandLine" && readCommandLine(running.text).editor) {
29573
29677
  return false;
29574
29678
  }
29575
29679
  if (record.projectPath === "") {
@@ -29581,15 +29685,32 @@ function judgeRun(record, running, needed) {
29581
29685
  const project = process.platform === "win32" ? record.projectPath.toLowerCase() : record.projectPath;
29582
29686
  return said.includes(project);
29583
29687
  }
29584
- function clearRunRecord(ours = () => true) {
29585
- for (const path of recordPaths()) {
29586
- const record = recordAt(path);
29587
- if (record === null || !ours(record)) {
29588
- continue;
29688
+ function stillTheAnnouncedGame(pid, projectPath, announcedAt) {
29689
+ return judgeAnnouncedGame(runningAs(pid), projectPath, announcedAt);
29690
+ }
29691
+ function judgeAnnouncedGame(running, projectPath, announcedAt) {
29692
+ if (running?.kind !== "commandLine" || running.startedAt === undefined) {
29693
+ return null;
29694
+ }
29695
+ if (running.startedAt - announcedAt > STARTED_AFTER_ANNOUNCING_MS) {
29696
+ return null;
29697
+ }
29698
+ const read = readCommandLine(running.text);
29699
+ if (read.editor || read.projectPath === null || !isSameDirectory(read.projectPath, projectPath)) {
29700
+ return null;
29701
+ }
29702
+ return read;
29703
+ }
29704
+ function clearRunRecord(projectPath) {
29705
+ for (const directory of runtimeDirectories()) {
29706
+ const shared = join14(directory, "runs", SHARED_NOTE);
29707
+ const sharedRecord = recordAt(shared);
29708
+ const ours = sharedRecord !== null && sharedRecord.projectPath !== "" && isSameDirectory(sharedRecord.projectPath, projectPath);
29709
+ for (const path of [runRecordPath(projectPath, directory), ...ours ? [shared] : []]) {
29710
+ try {
29711
+ rmSync6(path, { force: true });
29712
+ } catch {}
29589
29713
  }
29590
- try {
29591
- rmSync6(path);
29592
- } catch {}
29593
29714
  }
29594
29715
  }
29595
29716
  function ageMs(path, now) {
@@ -29601,7 +29722,7 @@ function ageMs(path, now) {
29601
29722
  }
29602
29723
  function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
29603
29724
  const directory = runsDirectory();
29604
- const keep = readRunRecord()?.transcript;
29725
+ const kept = new Set(everyRunRecord().map((record) => record.transcript));
29605
29726
  let entries;
29606
29727
  try {
29607
29728
  entries = readdirSync5(directory);
@@ -29613,7 +29734,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
29613
29734
  continue;
29614
29735
  }
29615
29736
  const path = join14(directory, entry);
29616
- if (path === keep || ageMs(path, now) < keepMs) {
29737
+ if (kept.has(path) || ageMs(path, now) < keepMs) {
29617
29738
  continue;
29618
29739
  }
29619
29740
  try {
@@ -29623,7 +29744,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
29623
29744
  }
29624
29745
 
29625
29746
  // src/setup.ts
29626
- import { createHash as createHash3 } from "node:crypto";
29747
+ import { createHash as createHash4 } from "node:crypto";
29627
29748
  import {
29628
29749
  cpSync,
29629
29750
  existsSync as existsSync9,
@@ -29634,8 +29755,8 @@ import {
29634
29755
  statSync as statSync6,
29635
29756
  writeFileSync as writeFileSync10
29636
29757
  } from "node:fs";
29637
- import { dirname as dirname9, join as join15 } from "node:path";
29638
- import { fileURLToPath as fileURLToPath2 } from "node:url";
29758
+ import { dirname as dirname10, join as join15 } from "node:path";
29759
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
29639
29760
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
29640
29761
  var EDITOR_PLUGINS = ["gdharness_editor", "auto_reload"];
29641
29762
  var RUNTIME_AUTOLOAD = {
@@ -29645,7 +29766,7 @@ var RUNTIME_AUTOLOAD = {
29645
29766
  var VERSION_MARKER = ".gdharness-version";
29646
29767
  var DIGEST_MARKER = ".gdharness-digest";
29647
29768
  function editorAddonDigest(from = shippedAddonsDirectory()) {
29648
- const hash = createHash3("sha256");
29769
+ const hash = createHash4("sha256");
29649
29770
  const walk = (directory, relative) => {
29650
29771
  for (const name of readdirSync6(directory).sort()) {
29651
29772
  if (name === VERSION_MARKER || name === DIGEST_MARKER) {
@@ -29681,7 +29802,7 @@ function shippedEditorDigest() {
29681
29802
  return shippedDigest ?? undefined;
29682
29803
  }
29683
29804
  function shippedAddonsDirectory() {
29684
- return join15(dirname9(fileURLToPath2(import.meta.url)), "godot", "addons");
29805
+ return join15(dirname10(fileURLToPath3(import.meta.url)), "godot", "addons");
29685
29806
  }
29686
29807
  function installedAddonVersion(projectPath) {
29687
29808
  const marker = join15(projectPath, "addons", ADDONS[0], VERSION_MARKER);
@@ -30363,6 +30484,12 @@ var TOOL_SPECS = [
30363
30484
  type: "boolean",
30364
30485
  ops: ["stop"],
30365
30486
  description: "stop: also end what the game started for itself, with OS.create_process or otherwise, such as a bench's workers and whatever those started in turn. Only the processes under the run's, to any depth, that are games of this project are ended: announced as one, or this project's engine run with --path on this project by its command line, which is how a bench whose workers deliberately carry no runtime is reached. They are named under endedChildren, with what identified each under identifiedBy; any other process under the run is left and named under childrenLeft, and childrenUnknown is true when nothing could be listed, with the note saying whether the platform would not or the run has no process to list under. Default false, and the run's process is ended either way."
30487
+ },
30488
+ pid: {
30489
+ type: "integer",
30490
+ minimum: 1,
30491
+ ops: ["stop"],
30492
+ description: "stop: a game of this project that no server here is holding, by its process id as editor_status lists it under runtimes: one a server before a reconnect started and left no note for, or one started some other way. It is ended only when it announced itself for this project and the operating system describes it as this project's engine run, --path on the project and no editor flag, started before it announced; otherwise nothing is signalled and the answer says which of those failed. The run this server holds is ended the ordinary way whether or not its number is given."
30366
30493
  }
30367
30494
  },
30368
30495
  requires: [],
@@ -30372,7 +30499,7 @@ var TOOL_SPECS = [
30372
30499
  requires: ["projectPath"]
30373
30500
  },
30374
30501
  stop: {
30375
- summary: "end the run and answer with what it printed last, naming the process ended under endedPid: for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. A game that started processes of its own keeps them unless andChildren says to end those too",
30502
+ summary: "end the run and answer with what it printed last, naming the process ended under endedPid: for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. A game that started processes of its own keeps them unless andChildren says to end those too. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled",
30376
30503
  requires: []
30377
30504
  },
30378
30505
  check: {
@@ -30948,7 +31075,7 @@ function buildToolDefinitions() {
30948
31075
  var UPDATE_NOTICE_EVERY = 500;
30949
31076
  var FEEDBACK_NOTICE_EVERY = 250;
30950
31077
  var run5 = promisify5(execFile5);
30951
- var __dirname2 = dirname10(fileURLToPath3(import.meta.url));
31078
+ var __dirname2 = dirname11(fileURLToPath4(import.meta.url));
30952
31079
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
30953
31080
  var HOLD_PING_MS = 2000;
30954
31081
  var SLOWEST_FRAME_RATE = 20;
@@ -30962,9 +31089,9 @@ function captureDestinationRefusal(outputPath, projects) {
30962
31089
  if (extname2(outputPath).toLowerCase() !== ".png") {
30963
31090
  return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
30964
31091
  }
30965
- const directory = realPathOr(dirname10(outputPath));
31092
+ const directory = realPathOr(dirname11(outputPath));
30966
31093
  if (!existsSync10(directory)) {
30967
- return `outputPath's directory ${dirname10(outputPath)} does not exist; a capture creates no directories.`;
31094
+ return `outputPath's directory ${dirname11(outputPath)} does not exist; a capture creates no directories.`;
30968
31095
  }
30969
31096
  const where = join16(directory, basename3(outputPath));
30970
31097
  const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
@@ -31019,7 +31146,7 @@ function stillRunning(run) {
31019
31146
  if (typeof run.endedHere === "string") {
31020
31147
  return false;
31021
31148
  }
31022
- if (!run.throughEditor && run.process === null && run.pid !== null) {
31149
+ if (!run.throughEditor && run.pid !== null) {
31023
31150
  return alive(run.pid);
31024
31151
  }
31025
31152
  if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
@@ -31044,12 +31171,12 @@ function runIsUp(run, editorSays, now = Date.now()) {
31044
31171
  return editorSays ?? stillRunning(run);
31045
31172
  }
31046
31173
  function noCodeWillCome(run) {
31047
- return !exited(run) && run.process === null;
31174
+ return !exited(run);
31048
31175
  }
31049
31176
  var STOP_WAIT_MS = 1e4;
31050
31177
  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));
31178
+ async function untilGone(pids, withinMs = STOP_WAIT_MS) {
31179
+ const gone = () => pids.every((pid) => !alive(pid));
31053
31180
  const deadline = Date.now() + withinMs;
31054
31181
  while (!gone() && Date.now() < deadline) {
31055
31182
  await delay3(50);
@@ -31057,6 +31184,9 @@ async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
31057
31184
  return gone();
31058
31185
  }
31059
31186
  function endedWithoutACode(run) {
31187
+ if (!run.throughEditor && !run.pickedUp) {
31188
+ 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.";
31189
+ }
31060
31190
  if (!run.throughEditor) {
31061
31191
  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
31192
  }
@@ -31069,12 +31199,41 @@ function endedWithoutACode(run) {
31069
31199
  }
31070
31200
  return `The editor stopped playing this run. ${kept} What it printed is below.`;
31071
31201
  }
31202
+ function stopVerdict(stop) {
31203
+ if (!stop.wasRunning) {
31204
+ return {
31205
+ stopped: true,
31206
+ endedPid: stop.endedPid,
31207
+ clean: stop.errors === 0,
31208
+ note: "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running.",
31209
+ withChildren: false
31210
+ };
31211
+ }
31212
+ if (stop.ending === "refused") {
31213
+ return {
31214
+ stopped: false,
31215
+ notSignalled: stop.endedPid,
31216
+ note: `Nothing was ended: pid ${stop.endedPid ?? "unknown"} no longer answers as the process this run was started as (its engine, its project and when it started), so it was not signalled. If that process is still the game, end it yourself, for instance with runtime_invoke calling get_tree().quit() when the game has the runtime addon; if it is not, it belongs to something else.`,
31217
+ withChildren: false
31218
+ };
31219
+ }
31220
+ return {
31221
+ stopped: true,
31222
+ endedPid: stop.endedPid,
31223
+ clean: stop.errors === 0,
31224
+ note: `${stop.throughEditor ? stop.endedPid === null ? "The editor was asked to stop the scene it is playing. Its game had not announced a runtime, so no process was named under endedPid, and its exit code stays with the editor." : "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."}${stop.exitSignal === null ? "" : ` It was ended by ${stop.exitSignal} and so has no exit code.`}${stop.ending === "gone" ? "" : ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be going: editor_status says whether it is.`}`,
31225
+ withChildren: true
31226
+ };
31227
+ }
31072
31228
  function endedPreviousRun(ended) {
31073
- if (ended === null) {
31229
+ if (ended === null || ended.ending === "refused") {
31074
31230
  return;
31075
31231
  }
31076
31232
  return ended.pid ?? true;
31077
31233
  }
31234
+ function previousRunLeft(ended) {
31235
+ return ended?.ending === "refused" && ended.pid !== null ? ended.pid : undefined;
31236
+ }
31078
31237
  var CONSOLE_WRAPPER = /_console(\.exe)?$/i;
31079
31238
  function engineNamesOf(executable) {
31080
31239
  return CONSOLE_WRAPPER.test(executable) ? [executable, executable.replace(CONSOLE_WRAPPER, "$1")] : [executable];
@@ -31115,6 +31274,17 @@ function aboutTheChildren(ended, throughEditor) {
31115
31274
  const leftToo = ended.left.length === 0 ? "" : ` ${counted(ended.left.length)} it had started ${ended.left.length === 1 ? "was" : "were"} left, named under childrenLeft: neither announced as a game of this project nor this project's engine run with --path on it, so what ${ended.left.length === 1 ? "it is" : "they are"} cannot be told from here.`;
31116
31275
  return `${endedToo}${leftToo}`;
31117
31276
  }
31277
+ function childrenFields(ended) {
31278
+ if (ended === null) {
31279
+ return {};
31280
+ }
31281
+ return {
31282
+ endedChildren: ended.ended.map((one) => one.pid),
31283
+ identifiedBy: ended.ended.length === 0 ? undefined : Object.fromEntries(ended.ended.map((one) => [String(one.pid), one.identifiedBy])),
31284
+ childrenLeft: ended.left.length === 0 ? undefined : ended.left,
31285
+ childrenUnknown: ended.unknown === false ? undefined : true
31286
+ };
31287
+ }
31118
31288
  function uidsLeftNote(left) {
31119
31289
  if (left === 0) {
31120
31290
  return "No scene was written: this reads the project and imports it, and does not resave anything.";
@@ -31122,12 +31292,24 @@ function uidsLeftNote(left) {
31122
31292
  const them = left === 1 ? "it" : "them";
31123
31293
  return `${left === 1 ? "One file" : `${left} files`} still ${left === 1 ? "has" : "have"} no .uid, named under stillWithoutUid, which is the engine declining to import ${them} rather than this op skipping ${them}. No scene was written.`;
31124
31294
  }
31295
+ function leftRunningNote(stranded) {
31296
+ if (stranded.length === 0) {
31297
+ return "";
31298
+ }
31299
+ const named = stranded.map((one) => one.protocol === null ? `pid ${one.pid}` : `pid ${one.pid} on protocol ${one.protocol}`).join(", ");
31300
+ const [only] = stranded;
31301
+ return stranded.length === 1 && only !== undefined ? ` A game of this project was already running and still is: ${named}. Starting this one did not end it, because this server did not start it. editor_run stop with pid ${only.pid} ends it and leaves this run going.` : ` ${stranded.length} games of this project were already running and still are: ${named}. Starting this one did not end them, because this server did not start them. editor_run stop with the pid of each ends it and leaves this run going.`;
31302
+ }
31125
31303
  function endedToStartThis(ended) {
31126
31304
  if (ended === null) {
31127
31305
  return "";
31128
31306
  }
31129
31307
  const which = ended.pid === null ? "The game the editor was playing" : `The run that was going, pid ${ended.pid},`;
31130
- return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.`;
31308
+ if (ended.ending === "refused") {
31309
+ return ` ${which} was not ended: that pid no longer answers as the process the run was started as, so nothing was signalled. If it is still the game, it is running beside this one and you have to end it yourself; if it is not, it belongs to something else.`;
31310
+ }
31311
+ const after = ended.ending === "lingering" ? ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be running beside this one: editor_status says whether it is.` : "";
31312
+ return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.${after}`;
31131
31313
  }
31132
31314
  var LONGEST_BOOT_NOTED_MS = 5 * 60000;
31133
31315
  var MOST_GROUPS = 20;
@@ -32479,11 +32661,11 @@ class GodotServer {
32479
32661
  if (!engine.ok) {
32480
32662
  return engine.response;
32481
32663
  }
32482
- const folder = dirname10(output.absolutePath);
32664
+ const folder = dirname11(output.absolutePath);
32483
32665
  try {
32484
32666
  mkdirSync10(folder, { recursive: true });
32485
32667
  } catch (error) {
32486
- return this.createErrorResponse(`Cannot create ${dirname10(output.relativePath)} to export into: ${errorMessage(error)}`);
32668
+ return this.createErrorResponse(`Cannot create ${dirname11(output.relativePath)} to export into: ${errorMessage(error)}`);
32487
32669
  }
32488
32670
  const debug = readBoolean(args, "debug") ?? false;
32489
32671
  const exportLogs = mkdtempSync2(join16(tmpdir4(), "gdharness-export-"));
@@ -32628,7 +32810,7 @@ class GodotServer {
32628
32810
  } finally {
32629
32811
  discard(reportsDir, userData);
32630
32812
  try {
32631
- rmdirSync(dirname10(reportsDir));
32813
+ rmdirSync(dirname11(reportsDir));
32632
32814
  } catch {}
32633
32815
  }
32634
32816
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
@@ -32856,7 +33038,12 @@ class GodotServer {
32856
33038
  return anEditorIsStillComing(listeningSince, this.launchedEditorIsUp());
32857
33039
  }
32858
33040
  launchedEditorIsUp() {
32859
- return this.launchedEditor?.exitCode === null && this.launchedEditor.signalCode === null;
33041
+ const launched = this.launchedEditor;
33042
+ if (launched === null || !alive(launched.pid)) {
33043
+ return false;
33044
+ }
33045
+ const now = startTimesOf([launched.pid]).get(launched.pid);
33046
+ return launched.startedAt === null || now === undefined || now === launched.startedAt;
32860
33047
  }
32861
33048
  dap() {
32862
33049
  const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
@@ -33217,9 +33404,9 @@ class GodotServer {
33217
33404
  }
33218
33405
  async openAnEditor(engine, projectPath, ports) {
33219
33406
  clearEditorLog(projectPath);
33220
- const editor = spawn(engine, editorArguments(projectPath, ports, editorLogPath(projectPath)), {
33221
- stdio: "ignore",
33222
- detached: true,
33407
+ const launched = await launchOutsideTheTree({
33408
+ command: engine,
33409
+ args: editorArguments(projectPath, ports, editorLogPath(projectPath)),
33223
33410
  env: {
33224
33411
  ...process.env,
33225
33412
  GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
@@ -33228,23 +33415,15 @@ class GodotServer {
33228
33415
  [OPENED_BY_A_SERVER]: "1"
33229
33416
  }
33230
33417
  });
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 };
33418
+ if ("error" in launched) {
33419
+ return { pid: null, error: launched.error };
33244
33420
  }
33245
- editor.unref();
33246
- this.launchedEditor = editor.pid === undefined ? null : editor;
33247
- return { pid: editor.pid ?? null, error: null };
33421
+ writeEditorLogNote(projectPath, launched.pid);
33422
+ this.launchedEditor = {
33423
+ pid: launched.pid,
33424
+ startedAt: startTimesOf([launched.pid]).get(launched.pid) ?? null
33425
+ };
33426
+ return { pid: launched.pid, error: null };
33248
33427
  }
33249
33428
  async portsForAnEditor() {
33250
33429
  const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
@@ -33293,8 +33472,10 @@ class GodotServer {
33293
33472
  const before = this.currentRun();
33294
33473
  if (before !== null && await this.runStillGoing(before)) {
33295
33474
  this.logDebug("Ending the running game before starting another");
33296
- ended = { pid: before.pid ?? this.announcedPidOf(before) ?? null };
33297
- await this.endActiveGame("editor_run start, which ends the run that was going before it starts another", true);
33475
+ ended = {
33476
+ pid: before.pid ?? this.announcedPidOf(before) ?? null,
33477
+ ending: await this.endActiveGame("editor_run start, which ends the run that was going before it starts another", true)
33478
+ };
33298
33479
  }
33299
33480
  const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
33300
33481
  const wereHereFirst = this.everyRuntimeOfOurs();
@@ -33314,25 +33495,30 @@ class GodotServer {
33314
33495
  userArgs: given.value
33315
33496
  });
33316
33497
  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);
33498
+ const started = await this.launchKeptGame(engine.value, cmdArgs, project.value.path, needsItsOwnProcess(asked.value) ? environmentFor({ ...asked.value, runtimeDirectory: runtimeDirectory() }) : undefined);
33499
+ if ("error" in started) {
33500
+ return this.createErrorResponse(`The game could not be started: ${started.error}`, [
33501
+ "editor_status says which Godot this server runs and whether it answers"
33502
+ ]);
33503
+ }
33318
33504
  started.announcedBefore = alreadyPlaying;
33319
33505
  this.activeProcess = started;
33320
33506
  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." : "";
33321
33507
  const endedForThis = endedToStartThis(ended);
33322
33508
  const stranded = wereHereFirst.filter((one) => one.pid !== ended?.pid && alive(one.pid));
33323
- const named = stranded.map((one) => one.protocol === null ? `pid ${one.pid}` : `pid ${one.pid} on protocol ${one.protocol}`).join(", ");
33324
- const alsoRunning = stranded.length === 0 ? "" : ` A game of this project was already running and still is: ${named}. Starting this one did not end it and nothing here can, because this server did not start it. End it yourself, or bring the halves to one version where a protocol is named.`;
33509
+ const alsoRunning = leftRunningNote(stranded);
33325
33510
  return this.jsonTextResponse({
33326
33511
  started: true,
33327
33512
  through: "gdharness",
33328
33513
  alsoRunning: stranded.length === 0 ? undefined : stranded.map((one) => one.pid),
33329
- pid: started.process.pid ?? null,
33514
+ pid: started.pid,
33330
33515
  arguments: cmdArgs,
33331
33516
  transcript: started.transcript,
33332
33517
  refreshedClasses: refreshed.value,
33333
33518
  ...scanWaitAnswer(scanned),
33334
33519
  savesNote: asked.value.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
33335
33520
  endedPreviousRun: endedPreviousRun(ended),
33521
+ previousRunLeft: previousRunLeft(ended),
33336
33522
  runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
33337
33523
  message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}${alsoRunning}`
33338
33524
  });
@@ -33441,7 +33627,6 @@ class GodotServer {
33441
33627
  closeSync2(transcript.fd);
33442
33628
  const playAnswer = asParams(JSON.parse(answer.content[0]?.text ?? "{}"));
33443
33629
  const played = {
33444
- process: null,
33445
33630
  pid: null,
33446
33631
  log,
33447
33632
  transcript: transcript.path,
@@ -33466,6 +33651,7 @@ class GodotServer {
33466
33651
  ...scanWaitAnswer(scanned),
33467
33652
  debugPort: readNumber(playAnswer, "debugPort"),
33468
33653
  endedPreviousRun: endedPreviousRun(ended),
33654
+ previousRunLeft: previousRunLeft(ended),
33469
33655
  breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
33470
33656
  breakpointsRefused: breakpoints.refused.length === 0 ? undefined : breakpoints.refused.map((one) => ({
33471
33657
  scriptPath: projectSpelling(projectPath, one.scriptPath),
@@ -33540,28 +33726,41 @@ class GodotServer {
33540
33726
  }
33541
33727
  async endActiveGame(reason, going) {
33542
33728
  const running = this.activeProcess;
33543
- const recorded = running?.process === null ? readRunRecord() : null;
33544
- clearRunRecord((record) => this.couldBeOurs(record.projectPath));
33729
+ const project = running?.projectPath ?? this.ourProject();
33730
+ const clearTheRecord = () => {
33731
+ if (project !== null) {
33732
+ clearRunRecord(project);
33733
+ }
33734
+ };
33735
+ const aKeeperHoldsIt = running !== null && going && !running.throughEditor && running.pid !== null;
33736
+ if (!aKeeperHoldsIt) {
33737
+ clearTheRecord();
33738
+ }
33545
33739
  if (!running || !going) {
33546
- return true;
33740
+ return "gone";
33547
33741
  }
33548
33742
  running.endedHere = reason;
33549
33743
  const announced = this.announcedPidOf(running);
33550
33744
  if (running.throughEditor) {
33551
33745
  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]);
33746
+ return await untilGone(announced === undefined ? [] : [announced]) ? "gone" : "lingering";
33557
33747
  }
33558
33748
  if (running.pid === null) {
33559
- return true;
33560
- }
33561
- if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
33749
+ return "gone";
33750
+ }
33751
+ const startedAs = {
33752
+ pid: running.pid,
33753
+ transcript: running.transcript ?? "",
33754
+ startedAt: running.startedAt,
33755
+ projectPath: running.projectPath ?? "",
33756
+ arguments: [],
33757
+ ...running.command === undefined ? {} : { command: running.command }
33758
+ };
33759
+ if (!stillTheRecordedRun(startedAs)) {
33760
+ clearTheRecord();
33562
33761
  running.endedHere = null;
33563
- 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
- return true;
33762
+ running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the process the run was started as, so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
33763
+ return "refused";
33565
33764
  }
33566
33765
  const signalled = runningAs(running.pid);
33567
33766
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
@@ -33571,12 +33770,20 @@ class GodotServer {
33571
33770
  } catch {
33572
33771
  landed = false;
33573
33772
  }
33574
- const gone = await untilGone(null, [running.pid]);
33773
+ const gone = await untilGone([running.pid]);
33774
+ if (gone) {
33775
+ await this.untilTheExitIsRecorded(running);
33776
+ }
33777
+ if (landed && gone) {
33778
+ running.exitCode = null;
33779
+ running.exitSignal = "SIGTERM";
33780
+ }
33781
+ clearTheRecord();
33575
33782
  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
- return gone;
33783
+ return gone ? "gone" : "lingering";
33577
33784
  }
33578
33785
  spawnGame(godotPath, cmdArgs, env) {
33579
- const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
33786
+ const child = spawn2(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
33580
33787
  const cancelled = callSignal();
33581
33788
  if (cancelled !== undefined) {
33582
33789
  const end = () => {
@@ -33628,62 +33835,33 @@ class GodotServer {
33628
33835
  });
33629
33836
  return started;
33630
33837
  }
33631
- spawnKeptGame(godotPath, cmdArgs, projectPath, env) {
33838
+ async launchKeptGame(godotPath, cmdArgs, projectPath, env) {
33632
33839
  const startedAt = Date.now();
33633
33840
  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);
33841
+ closeSync2(transcript.fd);
33842
+ const launched = await launchOutsideTheTree({
33843
+ command: godotPath,
33844
+ args: cmdArgs,
33845
+ ...env === undefined ? {} : { env },
33846
+ run: { transcript: transcript.path, startedAt, projectPath }
33847
+ });
33848
+ if ("error" in launched) {
33849
+ return launched;
33643
33850
  }
33644
- child.unref();
33645
- const log = new GameLog;
33646
- const started = {
33647
- process: child,
33648
- pid: child.pid ?? null,
33649
- log,
33851
+ sweepTranscripts();
33852
+ return {
33853
+ pid: launched.pid,
33854
+ log: new GameLog,
33650
33855
  transcript: transcript.path,
33651
33856
  readOffset: 0,
33652
33857
  projectPath,
33653
33858
  startedAt,
33859
+ command: godotPath,
33654
33860
  exitCode: null,
33655
33861
  exitSignal: null,
33656
33862
  throughEditor: false,
33657
33863
  brokeOn: null
33658
33864
  };
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
33865
  }
33688
33866
  drainTranscript(game) {
33689
33867
  if (game.transcript === null) {
@@ -33736,10 +33914,8 @@ class GodotServer {
33736
33914
  this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
33737
33915
  }
33738
33916
  const project = this.godotBridge.getStatus().projectPath ?? null;
33739
- const left = readEditorRunNote();
33740
- const note = left !== null && project !== null && isSameDirectory(left.projectPath, project) ? left : null;
33917
+ const note = project === null ? null : readEditorRunNote(project);
33741
33918
  const picked = {
33742
- process: null,
33743
33919
  pid: null,
33744
33920
  log: new GameLog,
33745
33921
  transcript: note?.transcript ?? null,
@@ -33871,22 +34047,24 @@ class GodotServer {
33871
34047
  return endpoint.editorPid === undefined || endpoint.editorPid !== editor;
33872
34048
  }
33873
34049
  adoptRecordedRun() {
33874
- const record = readRunRecord();
34050
+ const project = this.ourProject();
34051
+ const record = project === null ? null : readRunRecord(project);
33875
34052
  if (record === null || !this.couldBeOurs(record.projectPath)) {
33876
34053
  return null;
33877
34054
  }
33878
34055
  const adopted = {
33879
- process: null,
33880
34056
  pid: record.pid,
33881
34057
  log: new GameLog,
33882
34058
  transcript: record.transcript,
33883
34059
  readOffset: 0,
33884
34060
  projectPath: record.projectPath === "" ? null : record.projectPath,
33885
34061
  startedAt: record.startedAt,
34062
+ ...record.command === undefined ? {} : { command: record.command },
33886
34063
  exitCode: null,
33887
34064
  exitSignal: null,
33888
34065
  throughEditor: false,
33889
- brokeOn: null
34066
+ brokeOn: null,
34067
+ pickedUp: true
33890
34068
  };
33891
34069
  this.drainTranscript(adopted);
33892
34070
  if (!alive(record.pid) || !couldStillBeTheRecordedRun(record)) {
@@ -33903,18 +34081,18 @@ class GodotServer {
33903
34081
  const noEditor = !status.connected ? " No editor is connected, so a game the editor is playing cannot be seen from here at all:" + " editor_status says whether one is on its way, and a run of yours that is still up is" + " reachable again once it is." : "";
33904
34082
  const live = this.aRuntimeOfOursIsAnswering();
33905
34083
  if (live !== null) {
33906
- return `A game is running for ${live.project.path} and has announced its runtime on port ${live.port}, pid ${live.pid},` + " but this server did not start it and cannot reach it through the editor, so editor_run and" + " editor_output have nothing to answer about. The runtime_* tools reach it now." + " A run started here is a separate process rather than a replacement, so starting one leaves" + " this game running as well." + (this.godotBridge.getStatus().connected ? "" : " editor_status says whether an editor is on its way, and editor_run can end it once one is.");
34084
+ return `A game is running for ${live.project.path} and has announced its runtime on port ${live.port}, pid ${live.pid},` + " but this server did not start it and cannot reach it through the editor, so editor_run and" + ` editor_output have nothing to answer about. The runtime_* tools reach it now, and editor_run stop with pid ${live.pid} ends it.` + " A run started here is a separate process rather than a replacement, so starting one leaves" + " this game running as well.";
33907
34085
  }
33908
34086
  const unreadable = this.aRuntimeOfOursIsTooFarOff();
33909
34087
  if (unreadable !== null) {
33910
34088
  const behind = whichHalfIsBehind([unreadable]);
33911
- return `A game is running for ${unreadable.project.path}, pid ${unreadable.pid}, announced in protocol` + ` ${unreadable.protocol}, which this server does not speak: it speaks ${RUNTIME_PROTOCOL}, so ${behind}.` + " Until then neither editor_run nor the runtime_* tools can reach it." + " A run started here is a separate process rather than a replacement, so starting one leaves" + " this game running as well.";
34089
+ return `A game is running for ${unreadable.project.path}, pid ${unreadable.pid}, announced in protocol` + ` ${unreadable.protocol}, which this server does not speak: it speaks ${RUNTIME_PROTOCOL}, so ${behind}.` + ` Until then the runtime_* tools cannot reach it, and editor_run stop with pid ${unreadable.pid} ends it.` + " A run started here is a separate process rather than a replacement, so starting one leaves" + " this game running as well.";
33912
34090
  }
33913
- const record = readRunRecord();
34091
+ const mine = this.ourProject();
34092
+ const record = everyRunRecord().find((one) => mine === null || !isSameDirectory(one.projectPath, mine)) ?? null;
33914
34093
  if (record === null) {
33915
34094
  return `No game is running.${noEditor} Start one with editor_run.`;
33916
34095
  }
33917
- const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
33918
34096
  const whose = record.projectPath === "" ? "a project it does not name" : record.projectPath;
33919
34097
  const ours = mine === null ? "this server was started without GDHARNESS_PROJECT and has no editor connected, so it cannot say whose that run is" : `this server serves ${mine}`;
33920
34098
  return `No game of this server's is running.${noEditor} A run started from ${whose} is recorded on this machine, and ${ours}, so it is not this server's to answer for or to end. Start one with editor_run.`;
@@ -33957,13 +34135,16 @@ class GodotServer {
33957
34135
  ];
33958
34136
  }
33959
34137
  couldBeOurs(project) {
33960
- const status = this.godotBridge.getStatus();
33961
- const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
34138
+ const mine = this.ourProject();
33962
34139
  if (mine === null) {
33963
34140
  return false;
33964
34141
  }
33965
34142
  return project !== "" && isSameDirectory(mine, project);
33966
34143
  }
34144
+ ourProject() {
34145
+ const status = this.godotBridge.getStatus();
34146
+ return this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
34147
+ }
33967
34148
  currentRun() {
33968
34149
  return this.activeProcess ?? this.adoptRecordedRun();
33969
34150
  }
@@ -34021,8 +34202,8 @@ class GodotServer {
34021
34202
  async runStillGoing(run, editorSays) {
34022
34203
  if (!run.throughEditor) {
34023
34204
  const going = stillRunning(run);
34024
- if (!going && run.process !== null && !exited(run)) {
34025
- await untilGone(run.process, [], EXIT_REPORTED_WITHIN_MS);
34205
+ if (!going && run.pid !== null && !exited(run) && run.endedUnwatched !== true && typeof run.endedHere !== "string" && !await this.untilTheExitIsRecorded(run)) {
34206
+ run.endedUnwatched = true;
34026
34207
  }
34027
34208
  return going;
34028
34209
  }
@@ -34039,6 +34220,21 @@ class GodotServer {
34039
34220
  }
34040
34221
  return runIsUp(run, said);
34041
34222
  }
34223
+ async untilTheExitIsRecorded(run) {
34224
+ const until = Date.now() + EXIT_REPORTED_WITHIN_MS;
34225
+ for (;; ) {
34226
+ const record = run.projectPath === null ? null : readRunRecord(run.projectPath);
34227
+ if (record !== null && record.pid === run.pid && (record.exitCode !== undefined || record.exitSignal !== undefined)) {
34228
+ run.exitCode = record.exitCode ?? null;
34229
+ run.exitSignal = record.exitSignal ?? null;
34230
+ return true;
34231
+ }
34232
+ if (Date.now() >= until) {
34233
+ return false;
34234
+ }
34235
+ await delay3(50);
34236
+ }
34237
+ }
34042
34238
  handleEditorConsole(args) {
34043
34239
  const status = this.godotBridge.getStatus();
34044
34240
  const projectPath = status.projectPath ?? this.ownProject ?? undefined;
@@ -34140,7 +34336,8 @@ class GodotServer {
34140
34336
  throughEditor: run.throughEditor,
34141
34337
  announced: announced !== undefined || run.announcedPid !== undefined,
34142
34338
  couldAnnounce: run.projectPath !== null && existsSync10(join16(run.projectPath, RUNTIME_AUTOLOAD.path)),
34143
- endedHere: typeof run.endedHere === "string"
34339
+ endedHere: typeof run.endedHere === "string",
34340
+ pickedUp: run.pickedUp === true
34144
34341
  }));
34145
34342
  }
34146
34343
  if (run.pickedUpPlaying === true) {
@@ -34166,7 +34363,7 @@ class GodotServer {
34166
34363
  ...hold,
34167
34364
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
34168
34365
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
34169
- project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
34366
+ project: run.throughEditor ? undefined : run.projectPath ?? undefined,
34170
34367
  transcript: run.transcript ?? undefined,
34171
34368
  startedAt: new Date(run.startedAt).toISOString(),
34172
34369
  elapsedMs: Date.now() - run.startedAt,
@@ -34179,6 +34376,10 @@ class GodotServer {
34179
34376
  async handleStopProject(args) {
34180
34377
  await this.pickUpWhatTheEditorIsPlaying();
34181
34378
  const stopped = this.currentRun();
34379
+ const named = readNumber(args, "pid");
34380
+ if (named !== undefined && (stopped === null || named !== stopped.pid && named !== this.announcedPidOf(stopped))) {
34381
+ return await this.stopAnnouncedGame(named, readBoolean(args, "andChildren") === true);
34382
+ }
34182
34383
  if (!stopped) {
34183
34384
  return this.createErrorResponse(this.nothingOfOursIsRunning());
34184
34385
  }
@@ -34189,35 +34390,87 @@ class GodotServer {
34189
34390
  const endedPid = stopped.pid ?? announcedPid;
34190
34391
  const children = readBoolean(args, "andChildren") === true && wasRunning ? await this.whatTheRunStarted(stopped) : null;
34191
34392
  this.logDebug("Stopping the running game");
34192
- const gone = await this.endActiveGame("editor_run stop", wasRunning);
34193
- const ended = children === null ? null : endChildrenAmong(children);
34194
- if (wasRunning) {
34393
+ const ending = await this.endActiveGame("editor_run stop", wasRunning);
34394
+ const refused = ending === "refused";
34395
+ const ended = children === null || refused ? null : endChildrenAmong(children);
34396
+ if (wasRunning && !refused) {
34195
34397
  for (const pid of new Set([endedPid, announcedPid])) {
34196
34398
  if (pid !== null) {
34197
34399
  await announcementEnded(pid);
34198
34400
  }
34199
34401
  }
34200
34402
  }
34403
+ const verdict = stopVerdict({
34404
+ wasRunning,
34405
+ ending,
34406
+ endedPid,
34407
+ throughEditor: stopped.throughEditor,
34408
+ exitSignal: stopped.exitSignal,
34409
+ errors: stopped.log.count("error")
34410
+ });
34201
34411
  return this.jsonTextResponse({
34202
- stopped: true,
34412
+ stopped: verdict.stopped,
34203
34413
  through: stopped.throughEditor ? "editor" : "gdharness",
34204
- endedPid,
34205
- ...ended === null ? {} : {
34206
- endedChildren: ended.ended.map((one) => one.pid),
34207
- identifiedBy: ended.ended.length === 0 ? undefined : Object.fromEntries(ended.ended.map((one) => [String(one.pid), one.identifiedBy])),
34208
- childrenLeft: ended.left.length === 0 ? undefined : ended.left,
34209
- childrenUnknown: ended.unknown === false ? undefined : true
34210
- },
34414
+ endedPid: verdict.endedPid,
34415
+ notSignalled: verdict.notSignalled,
34416
+ ...childrenFields(ended),
34211
34417
  exitedBeforeStop: !wasRunning,
34212
34418
  exitCode: stopped.exitCode,
34213
34419
  exitSignal: stopped.exitSignal ?? undefined,
34214
34420
  errors: stopped.log.count("error"),
34215
34421
  warnings: stopped.log.count("warning"),
34216
- clean: stopped.log.count("error") === 0,
34217
- note: !wasRunning ? "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running." : `${stopped.throughEditor ? endedPid === null ? "The editor was asked to stop the scene it is playing. Its game had not announced a runtime, so no process was named under endedPid, and its exit code stays with the editor." : "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."}${stopped.exitSignal === null ? "" : ` It was ended by ${stopped.exitSignal} and so has no exit code.`}${gone ? "" : ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be going: editor_status says whether it is.`} ${aboutTheChildren(ended, stopped.throughEditor)}`,
34422
+ clean: verdict.clean,
34423
+ note: verdict.withChildren ? `${verdict.note} ${aboutTheChildren(ended, stopped.throughEditor)}` : verdict.note,
34218
34424
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
34219
34425
  });
34220
34426
  }
34427
+ async stopAnnouncedGame(pid, andChildren) {
34428
+ const announced = runtimesAnnounced();
34429
+ const game = [
34430
+ ...this.allAnnouncedForOurProject(announced.running),
34431
+ ...this.allAnnouncedForOurProject(announced.unspoken)
34432
+ ].find((one) => one.pid === pid);
34433
+ if (game === undefined) {
34434
+ return this.createErrorResponse(`pid ${pid} is not a game announced for this project, so nothing was signalled. editor_status lists this project's games under runtimes. A game that announces no runtime is ended by the server holding its run, or with andChildren by a stop of the run that started it.`);
34435
+ }
34436
+ const read = game.announcedAt === undefined ? null : stillTheAnnouncedGame(pid, game.project.path, game.announcedAt);
34437
+ if (read === null) {
34438
+ return this.createErrorResponse(`pid ${pid} announced itself as a game of this project, but the operating system does not describe it as this project's engine run started before that announcement (the engine with --path on the project and no editor flag), so nothing was signalled. If it is still the game, runtime_invoke can ask it to quit.`);
34439
+ }
34440
+ const run = {
34441
+ pid,
34442
+ log: new GameLog,
34443
+ transcript: null,
34444
+ readOffset: 0,
34445
+ projectPath: game.project.path,
34446
+ startedAt: game.announcedAt ?? Date.now(),
34447
+ exitCode: null,
34448
+ exitSignal: null,
34449
+ throughEditor: false,
34450
+ brokeOn: null,
34451
+ announcedPid: pid
34452
+ };
34453
+ const children = andChildren ? await this.whatTheRunStarted(run) : null;
34454
+ let landed = true;
34455
+ try {
34456
+ process.kill(pid);
34457
+ } catch {
34458
+ landed = false;
34459
+ }
34460
+ const gone = await untilGone([pid]);
34461
+ const ended = children === null ? null : endChildrenAmong(children);
34462
+ if (gone) {
34463
+ await announcementEnded(pid);
34464
+ }
34465
+ return this.jsonTextResponse({
34466
+ stopped: true,
34467
+ through: "gdharness",
34468
+ endedPid: pid,
34469
+ ...childrenFields(ended),
34470
+ exitedBeforeStop: !landed,
34471
+ note: `${landed ? `pid ${pid} was ended: a game of this project that no server here was holding,` : `pid ${pid} had already gone when it was signalled: a game of this project that no server here was holding,`} identified by its announcement and by its command line, ${read.executable} run with --path ${read.projectPath ?? ""}. Nothing here read what it printed or holds its exit code, so neither is in this answer.${gone ? "" : ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be going: editor_status says whether it is.`} ${aboutTheChildren(ended, false)}`
34472
+ });
34473
+ }
34221
34474
  async whatTheRunStarted(run) {
34222
34475
  const tree = await processTree();
34223
34476
  if (tree === undefined) {