gdharness 1.0.30 → 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
@@ -10202,7 +10202,7 @@ 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
10207
  import { fileURLToPath as fileURLToPath4 } from "node:url";
10208
10208
  import { promisify as promisify5 } from "node:util";
@@ -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
  }
@@ -28887,10 +28895,27 @@ async function handleLSPTool(client, toolName, args) {
28887
28895
  // src/outside.ts
28888
28896
  import { spawn } from "node:child_process";
28889
28897
  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");
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;
28893
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));
28894
28919
  function readLaunched(printed) {
28895
28920
  const line = printed.split(`
28896
28921
  `).find((one) => one.trim() !== "")?.trim() ?? "";
@@ -28902,10 +28927,16 @@ function readLaunched(printed) {
28902
28927
  }
28903
28928
  async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = KEEPER_SCRIPT) {
28904
28929
  return await new Promise((resolve) => {
28905
- const launcher = spawn(runtime, [keeper, "launch", encodeSpec(spec)], {
28906
- stdio: ["ignore", "pipe", "pipe"],
28930
+ const launcher = spawn(runtime, [keeper, "launch"], {
28931
+ stdio: ["pipe", "pipe", "pipe"],
28907
28932
  windowsHide: true
28908
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
+ });
28909
28940
  let printed = "";
28910
28941
  let complained = "";
28911
28942
  launcher.stdout.on("data", (chunk) => {
@@ -29371,30 +29402,62 @@ function restartSettled(projectPath) {
29371
29402
 
29372
29403
  // src/run-record.ts
29373
29404
  import { execFileSync as execFileSync2 } from "node:child_process";
29374
- 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";
29375
- 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";
29376
29417
  function runsDirectory() {
29377
29418
  return join14(runtimeDirectory(), "runs");
29378
29419
  }
29379
- function recordPaths() {
29380
- return runtimeDirectories().map((directory) => join14(directory, "runs", "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);
29381
29440
  }
29441
+ function runRecordPath(projectPath, directory = runtimeDirectory()) {
29442
+ return join14(directory, "runs", `note-${projectKey(projectPath)}.json`);
29443
+ }
29444
+ var SHARED_NOTE = "run.json";
29382
29445
  function openTranscript(startedAt) {
29383
29446
  mkdirSync8(runsDirectory(), { recursive: true });
29384
29447
  const path = join14(runsDirectory(), `run-${startedAt}.log`);
29385
29448
  return { path, fd: openSync2(path, "a") };
29386
29449
  }
29387
- function editorNotePath() {
29388
- return join14(runsDirectory(), "editor-run.json");
29450
+ function editorNotePath(projectPath, directory = runtimeDirectory()) {
29451
+ return join14(directory, "runs", `editor-${projectKey(projectPath)}.json`);
29389
29452
  }
29390
29453
  function writeEditorRunNote(note) {
29391
29454
  mkdirSync8(runsDirectory(), { recursive: true });
29392
- writeFileSync9(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
29455
+ writeFileSync9(editorNotePath(note.projectPath), JSON.stringify(note, null, 2), "utf8");
29393
29456
  }
29394
- function readEditorRunNote() {
29457
+ function readEditorRunNote(projectPath) {
29395
29458
  for (const directory of runtimeDirectories()) {
29396
- const note = editorNoteAt(join14(directory, "runs", "editor-run.json"));
29397
- 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)) {
29398
29461
  return note;
29399
29462
  }
29400
29463
  }
@@ -29419,15 +29482,39 @@ function editorNoteAt(path) {
29419
29482
  }
29420
29483
  return { projectPath, transcript, startedAt };
29421
29484
  }
29422
- function readRunRecord() {
29423
- for (const path of recordPaths()) {
29424
- const record = recordAt(path);
29425
- if (record !== null) {
29426
- return record;
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;
29427
29494
  }
29428
29495
  }
29429
29496
  return null;
29430
29497
  }
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
+ }
29514
+ }
29515
+ }
29516
+ return found.sort((one, other) => other.startedAt - one.startedAt);
29517
+ }
29431
29518
  function recordAt(path) {
29432
29519
  let parsed;
29433
29520
  try {
@@ -29572,7 +29659,6 @@ function stillTheRecordedRun(record) {
29572
29659
  function couldStillBeTheRecordedRun(record) {
29573
29660
  return judgeRun(record, runningAs(record.pid), "possible");
29574
29661
  }
29575
- var AN_EDITOR = /(?:^|\s)(?:-e|--editor)(?:\s|$)/;
29576
29662
  var SAME_RUN_WINDOW_MS = 1e4;
29577
29663
  function judgeRun(record, running, needed) {
29578
29664
  if (running === null) {
@@ -29587,7 +29673,7 @@ function judgeRun(record, running, needed) {
29587
29673
  if (wanted !== null && !said.includes(wanted)) {
29588
29674
  return false;
29589
29675
  }
29590
- if (running.kind === "commandLine" && AN_EDITOR.test(said)) {
29676
+ if (running.kind === "commandLine" && readCommandLine(running.text).editor) {
29591
29677
  return false;
29592
29678
  }
29593
29679
  if (record.projectPath === "") {
@@ -29599,15 +29685,32 @@ function judgeRun(record, running, needed) {
29599
29685
  const project = process.platform === "win32" ? record.projectPath.toLowerCase() : record.projectPath;
29600
29686
  return said.includes(project);
29601
29687
  }
29602
- function clearRunRecord(ours = () => true) {
29603
- for (const path of recordPaths()) {
29604
- const record = recordAt(path);
29605
- if (record === null || !ours(record)) {
29606
- 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 {}
29607
29713
  }
29608
- try {
29609
- rmSync6(path);
29610
- } catch {}
29611
29714
  }
29612
29715
  }
29613
29716
  function ageMs(path, now) {
@@ -29619,7 +29722,7 @@ function ageMs(path, now) {
29619
29722
  }
29620
29723
  function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
29621
29724
  const directory = runsDirectory();
29622
- const keep = readRunRecord()?.transcript;
29725
+ const kept = new Set(everyRunRecord().map((record) => record.transcript));
29623
29726
  let entries;
29624
29727
  try {
29625
29728
  entries = readdirSync5(directory);
@@ -29631,7 +29734,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
29631
29734
  continue;
29632
29735
  }
29633
29736
  const path = join14(directory, entry);
29634
- if (path === keep || ageMs(path, now) < keepMs) {
29737
+ if (kept.has(path) || ageMs(path, now) < keepMs) {
29635
29738
  continue;
29636
29739
  }
29637
29740
  try {
@@ -29641,7 +29744,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
29641
29744
  }
29642
29745
 
29643
29746
  // src/setup.ts
29644
- import { createHash as createHash3 } from "node:crypto";
29747
+ import { createHash as createHash4 } from "node:crypto";
29645
29748
  import {
29646
29749
  cpSync,
29647
29750
  existsSync as existsSync9,
@@ -29652,7 +29755,7 @@ import {
29652
29755
  statSync as statSync6,
29653
29756
  writeFileSync as writeFileSync10
29654
29757
  } from "node:fs";
29655
- import { dirname as dirname9, join as join15 } from "node:path";
29758
+ import { dirname as dirname10, join as join15 } from "node:path";
29656
29759
  import { fileURLToPath as fileURLToPath3 } from "node:url";
29657
29760
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
29658
29761
  var EDITOR_PLUGINS = ["gdharness_editor", "auto_reload"];
@@ -29663,7 +29766,7 @@ var RUNTIME_AUTOLOAD = {
29663
29766
  var VERSION_MARKER = ".gdharness-version";
29664
29767
  var DIGEST_MARKER = ".gdharness-digest";
29665
29768
  function editorAddonDigest(from = shippedAddonsDirectory()) {
29666
- const hash = createHash3("sha256");
29769
+ const hash = createHash4("sha256");
29667
29770
  const walk = (directory, relative) => {
29668
29771
  for (const name of readdirSync6(directory).sort()) {
29669
29772
  if (name === VERSION_MARKER || name === DIGEST_MARKER) {
@@ -29699,7 +29802,7 @@ function shippedEditorDigest() {
29699
29802
  return shippedDigest ?? undefined;
29700
29803
  }
29701
29804
  function shippedAddonsDirectory() {
29702
- return join15(dirname9(fileURLToPath3(import.meta.url)), "godot", "addons");
29805
+ return join15(dirname10(fileURLToPath3(import.meta.url)), "godot", "addons");
29703
29806
  }
29704
29807
  function installedAddonVersion(projectPath) {
29705
29808
  const marker = join15(projectPath, "addons", ADDONS[0], VERSION_MARKER);
@@ -30381,6 +30484,12 @@ var TOOL_SPECS = [
30381
30484
  type: "boolean",
30382
30485
  ops: ["stop"],
30383
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."
30384
30493
  }
30385
30494
  },
30386
30495
  requires: [],
@@ -30390,7 +30499,7 @@ var TOOL_SPECS = [
30390
30499
  requires: ["projectPath"]
30391
30500
  },
30392
30501
  stop: {
30393
- 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",
30394
30503
  requires: []
30395
30504
  },
30396
30505
  check: {
@@ -30966,7 +31075,7 @@ function buildToolDefinitions() {
30966
31075
  var UPDATE_NOTICE_EVERY = 500;
30967
31076
  var FEEDBACK_NOTICE_EVERY = 250;
30968
31077
  var run5 = promisify5(execFile5);
30969
- var __dirname2 = dirname10(fileURLToPath4(import.meta.url));
31078
+ var __dirname2 = dirname11(fileURLToPath4(import.meta.url));
30970
31079
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
30971
31080
  var HOLD_PING_MS = 2000;
30972
31081
  var SLOWEST_FRAME_RATE = 20;
@@ -30980,9 +31089,9 @@ function captureDestinationRefusal(outputPath, projects) {
30980
31089
  if (extname2(outputPath).toLowerCase() !== ".png") {
30981
31090
  return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
30982
31091
  }
30983
- const directory = realPathOr(dirname10(outputPath));
31092
+ const directory = realPathOr(dirname11(outputPath));
30984
31093
  if (!existsSync10(directory)) {
30985
- 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.`;
30986
31095
  }
30987
31096
  const where = join16(directory, basename3(outputPath));
30988
31097
  const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
@@ -31090,12 +31199,41 @@ function endedWithoutACode(run) {
31090
31199
  }
31091
31200
  return `The editor stopped playing this run. ${kept} What it printed is below.`;
31092
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
+ }
31093
31228
  function endedPreviousRun(ended) {
31094
- if (ended === null) {
31229
+ if (ended === null || ended.ending === "refused") {
31095
31230
  return;
31096
31231
  }
31097
31232
  return ended.pid ?? true;
31098
31233
  }
31234
+ function previousRunLeft(ended) {
31235
+ return ended?.ending === "refused" && ended.pid !== null ? ended.pid : undefined;
31236
+ }
31099
31237
  var CONSOLE_WRAPPER = /_console(\.exe)?$/i;
31100
31238
  function engineNamesOf(executable) {
31101
31239
  return CONSOLE_WRAPPER.test(executable) ? [executable, executable.replace(CONSOLE_WRAPPER, "$1")] : [executable];
@@ -31136,6 +31274,17 @@ function aboutTheChildren(ended, throughEditor) {
31136
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.`;
31137
31275
  return `${endedToo}${leftToo}`;
31138
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
+ }
31139
31288
  function uidsLeftNote(left) {
31140
31289
  if (left === 0) {
31141
31290
  return "No scene was written: this reads the project and imports it, and does not resave anything.";
@@ -31143,12 +31292,24 @@ function uidsLeftNote(left) {
31143
31292
  const them = left === 1 ? "it" : "them";
31144
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.`;
31145
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
+ }
31146
31303
  function endedToStartThis(ended) {
31147
31304
  if (ended === null) {
31148
31305
  return "";
31149
31306
  }
31150
31307
  const which = ended.pid === null ? "The game the editor was playing" : `The run that was going, pid ${ended.pid},`;
31151
- 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}`;
31152
31313
  }
31153
31314
  var LONGEST_BOOT_NOTED_MS = 5 * 60000;
31154
31315
  var MOST_GROUPS = 20;
@@ -32500,11 +32661,11 @@ class GodotServer {
32500
32661
  if (!engine.ok) {
32501
32662
  return engine.response;
32502
32663
  }
32503
- const folder = dirname10(output.absolutePath);
32664
+ const folder = dirname11(output.absolutePath);
32504
32665
  try {
32505
32666
  mkdirSync10(folder, { recursive: true });
32506
32667
  } catch (error) {
32507
- 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)}`);
32508
32669
  }
32509
32670
  const debug = readBoolean(args, "debug") ?? false;
32510
32671
  const exportLogs = mkdtempSync2(join16(tmpdir4(), "gdharness-export-"));
@@ -32649,7 +32810,7 @@ class GodotServer {
32649
32810
  } finally {
32650
32811
  discard(reportsDir, userData);
32651
32812
  try {
32652
- rmdirSync(dirname10(reportsDir));
32813
+ rmdirSync(dirname11(reportsDir));
32653
32814
  } catch {}
32654
32815
  }
32655
32816
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
@@ -33311,8 +33472,10 @@ class GodotServer {
33311
33472
  const before = this.currentRun();
33312
33473
  if (before !== null && await this.runStillGoing(before)) {
33313
33474
  this.logDebug("Ending the running game before starting another");
33314
- ended = { pid: before.pid ?? this.announcedPidOf(before) ?? null };
33315
- 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
+ };
33316
33479
  }
33317
33480
  const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
33318
33481
  const wereHereFirst = this.everyRuntimeOfOurs();
@@ -33343,8 +33506,7 @@ class GodotServer {
33343
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." : "";
33344
33507
  const endedForThis = endedToStartThis(ended);
33345
33508
  const stranded = wereHereFirst.filter((one) => one.pid !== ended?.pid && alive(one.pid));
33346
- const named = stranded.map((one) => one.protocol === null ? `pid ${one.pid}` : `pid ${one.pid} on protocol ${one.protocol}`).join(", ");
33347
- 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);
33348
33510
  return this.jsonTextResponse({
33349
33511
  started: true,
33350
33512
  through: "gdharness",
@@ -33356,6 +33518,7 @@ class GodotServer {
33356
33518
  ...scanWaitAnswer(scanned),
33357
33519
  savesNote: asked.value.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
33358
33520
  endedPreviousRun: endedPreviousRun(ended),
33521
+ previousRunLeft: previousRunLeft(ended),
33359
33522
  runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
33360
33523
  message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}${alsoRunning}`
33361
33524
  });
@@ -33488,6 +33651,7 @@ class GodotServer {
33488
33651
  ...scanWaitAnswer(scanned),
33489
33652
  debugPort: readNumber(playAnswer, "debugPort"),
33490
33653
  endedPreviousRun: endedPreviousRun(ended),
33654
+ previousRunLeft: previousRunLeft(ended),
33491
33655
  breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
33492
33656
  breakpointsRefused: breakpoints.refused.length === 0 ? undefined : breakpoints.refused.map((one) => ({
33493
33657
  scriptPath: projectSpelling(projectPath, one.scriptPath),
@@ -33562,31 +33726,41 @@ class GodotServer {
33562
33726
  }
33563
33727
  async endActiveGame(reason, going) {
33564
33728
  const running = this.activeProcess;
33565
- const recorded = running !== null && !running.throughEditor ? readRunRecord() : null;
33729
+ const project = running?.projectPath ?? this.ourProject();
33566
33730
  const clearTheRecord = () => {
33567
- clearRunRecord((record) => this.couldBeOurs(record.projectPath));
33731
+ if (project !== null) {
33732
+ clearRunRecord(project);
33733
+ }
33568
33734
  };
33569
33735
  const aKeeperHoldsIt = running !== null && going && !running.throughEditor && running.pid !== null;
33570
33736
  if (!aKeeperHoldsIt) {
33571
33737
  clearTheRecord();
33572
33738
  }
33573
33739
  if (!running || !going) {
33574
- return true;
33740
+ return "gone";
33575
33741
  }
33576
33742
  running.endedHere = reason;
33577
33743
  const announced = this.announcedPidOf(running);
33578
33744
  if (running.throughEditor) {
33579
33745
  await this.handleViaBridge("stop_playing", {});
33580
- return await untilGone(announced === undefined ? [] : [announced]);
33746
+ return await untilGone(announced === undefined ? [] : [announced]) ? "gone" : "lingering";
33581
33747
  }
33582
33748
  if (running.pid === null) {
33583
- return true;
33584
- }
33585
- 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)) {
33586
33760
  clearTheRecord();
33587
33761
  running.endedHere = null;
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.`);
33589
- 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";
33590
33764
  }
33591
33765
  const signalled = runningAs(running.pid);
33592
33766
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
@@ -33606,7 +33780,7 @@ class GodotServer {
33606
33780
  }
33607
33781
  clearTheRecord();
33608
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}.`);
33609
- return gone;
33783
+ return gone ? "gone" : "lingering";
33610
33784
  }
33611
33785
  spawnGame(godotPath, cmdArgs, env) {
33612
33786
  const child = spawn2(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -33682,6 +33856,7 @@ class GodotServer {
33682
33856
  readOffset: 0,
33683
33857
  projectPath,
33684
33858
  startedAt,
33859
+ command: godotPath,
33685
33860
  exitCode: null,
33686
33861
  exitSignal: null,
33687
33862
  throughEditor: false,
@@ -33739,8 +33914,7 @@ class GodotServer {
33739
33914
  this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
33740
33915
  }
33741
33916
  const project = this.godotBridge.getStatus().projectPath ?? null;
33742
- const left = readEditorRunNote();
33743
- const note = left !== null && project !== null && isSameDirectory(left.projectPath, project) ? left : null;
33917
+ const note = project === null ? null : readEditorRunNote(project);
33744
33918
  const picked = {
33745
33919
  pid: null,
33746
33920
  log: new GameLog,
@@ -33873,7 +34047,8 @@ class GodotServer {
33873
34047
  return endpoint.editorPid === undefined || endpoint.editorPid !== editor;
33874
34048
  }
33875
34049
  adoptRecordedRun() {
33876
- const record = readRunRecord();
34050
+ const project = this.ourProject();
34051
+ const record = project === null ? null : readRunRecord(project);
33877
34052
  if (record === null || !this.couldBeOurs(record.projectPath)) {
33878
34053
  return null;
33879
34054
  }
@@ -33884,6 +34059,7 @@ class GodotServer {
33884
34059
  readOffset: 0,
33885
34060
  projectPath: record.projectPath === "" ? null : record.projectPath,
33886
34061
  startedAt: record.startedAt,
34062
+ ...record.command === undefined ? {} : { command: record.command },
33887
34063
  exitCode: null,
33888
34064
  exitSignal: null,
33889
34065
  throughEditor: false,
@@ -33905,18 +34081,18 @@ class GodotServer {
33905
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." : "";
33906
34082
  const live = this.aRuntimeOfOursIsAnswering();
33907
34083
  if (live !== null) {
33908
- 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.";
33909
34085
  }
33910
34086
  const unreadable = this.aRuntimeOfOursIsTooFarOff();
33911
34087
  if (unreadable !== null) {
33912
34088
  const behind = whichHalfIsBehind([unreadable]);
33913
- 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.";
33914
34090
  }
33915
- const record = readRunRecord();
34091
+ const mine = this.ourProject();
34092
+ const record = everyRunRecord().find((one) => mine === null || !isSameDirectory(one.projectPath, mine)) ?? null;
33916
34093
  if (record === null) {
33917
34094
  return `No game is running.${noEditor} Start one with editor_run.`;
33918
34095
  }
33919
- const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
33920
34096
  const whose = record.projectPath === "" ? "a project it does not name" : record.projectPath;
33921
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}`;
33922
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.`;
@@ -33959,13 +34135,16 @@ class GodotServer {
33959
34135
  ];
33960
34136
  }
33961
34137
  couldBeOurs(project) {
33962
- const status = this.godotBridge.getStatus();
33963
- const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
34138
+ const mine = this.ourProject();
33964
34139
  if (mine === null) {
33965
34140
  return false;
33966
34141
  }
33967
34142
  return project !== "" && isSameDirectory(mine, project);
33968
34143
  }
34144
+ ourProject() {
34145
+ const status = this.godotBridge.getStatus();
34146
+ return this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
34147
+ }
33969
34148
  currentRun() {
33970
34149
  return this.activeProcess ?? this.adoptRecordedRun();
33971
34150
  }
@@ -34044,7 +34223,7 @@ class GodotServer {
34044
34223
  async untilTheExitIsRecorded(run) {
34045
34224
  const until = Date.now() + EXIT_REPORTED_WITHIN_MS;
34046
34225
  for (;; ) {
34047
- const record = readRunRecord();
34226
+ const record = run.projectPath === null ? null : readRunRecord(run.projectPath);
34048
34227
  if (record !== null && record.pid === run.pid && (record.exitCode !== undefined || record.exitSignal !== undefined)) {
34049
34228
  run.exitCode = record.exitCode ?? null;
34050
34229
  run.exitSignal = record.exitSignal ?? null;
@@ -34197,6 +34376,10 @@ class GodotServer {
34197
34376
  async handleStopProject(args) {
34198
34377
  await this.pickUpWhatTheEditorIsPlaying();
34199
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
+ }
34200
34383
  if (!stopped) {
34201
34384
  return this.createErrorResponse(this.nothingOfOursIsRunning());
34202
34385
  }
@@ -34207,35 +34390,87 @@ class GodotServer {
34207
34390
  const endedPid = stopped.pid ?? announcedPid;
34208
34391
  const children = readBoolean(args, "andChildren") === true && wasRunning ? await this.whatTheRunStarted(stopped) : null;
34209
34392
  this.logDebug("Stopping the running game");
34210
- const gone = await this.endActiveGame("editor_run stop", wasRunning);
34211
- const ended = children === null ? null : endChildrenAmong(children);
34212
- 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) {
34213
34397
  for (const pid of new Set([endedPid, announcedPid])) {
34214
34398
  if (pid !== null) {
34215
34399
  await announcementEnded(pid);
34216
34400
  }
34217
34401
  }
34218
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
+ });
34219
34411
  return this.jsonTextResponse({
34220
- stopped: true,
34412
+ stopped: verdict.stopped,
34221
34413
  through: stopped.throughEditor ? "editor" : "gdharness",
34222
- endedPid,
34223
- ...ended === null ? {} : {
34224
- endedChildren: ended.ended.map((one) => one.pid),
34225
- identifiedBy: ended.ended.length === 0 ? undefined : Object.fromEntries(ended.ended.map((one) => [String(one.pid), one.identifiedBy])),
34226
- childrenLeft: ended.left.length === 0 ? undefined : ended.left,
34227
- childrenUnknown: ended.unknown === false ? undefined : true
34228
- },
34414
+ endedPid: verdict.endedPid,
34415
+ notSignalled: verdict.notSignalled,
34416
+ ...childrenFields(ended),
34229
34417
  exitedBeforeStop: !wasRunning,
34230
34418
  exitCode: stopped.exitCode,
34231
34419
  exitSignal: stopped.exitSignal ?? undefined,
34232
34420
  errors: stopped.log.count("error"),
34233
34421
  warnings: stopped.log.count("warning"),
34234
- clean: stopped.log.count("error") === 0,
34235
- 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,
34236
34424
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
34237
34425
  });
34238
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
+ }
34239
34474
  async whatTheRunStarted(run) {
34240
34475
  const tree = await processTree();
34241
34476
  if (tree === undefined) {