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/cli.js CHANGED
@@ -204,7 +204,9 @@ function ancestorsIn(tree, pid, root) {
204
204
  return chain;
205
205
  }
206
206
  function readCommandLine(command) {
207
- const words = commandWords(command);
207
+ const all = commandWords(command);
208
+ const separator = all.findIndex((word) => word === "--" || word === "++");
209
+ const words = separator === -1 ? all : all.slice(0, separator);
208
210
  const executable = words[0] === undefined ? "" : basename(words[0].replaceAll("\\", "/"));
209
211
  const at = words.indexOf("--path");
210
212
  let projectPath = null;
@@ -216,7 +218,7 @@ function readCommandLine(command) {
216
218
  }
217
219
  taken.push(word);
218
220
  }
219
- projectPath = taken.join(" ");
221
+ projectPath = taken.join(" ").replaceAll("%20", " ");
220
222
  }
221
223
  return {
222
224
  executable,
@@ -16356,6 +16358,12 @@ var init_tool_definitions = __esm(() => {
16356
16358
  type: "boolean",
16357
16359
  ops: ["stop"],
16358
16360
  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."
16361
+ },
16362
+ pid: {
16363
+ type: "integer",
16364
+ minimum: 1,
16365
+ ops: ["stop"],
16366
+ 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."
16359
16367
  }
16360
16368
  },
16361
16369
  requires: [],
@@ -16365,7 +16373,7 @@ var init_tool_definitions = __esm(() => {
16365
16373
  requires: ["projectPath"]
16366
16374
  },
16367
16375
  stop: {
16368
- 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",
16376
+ 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",
16369
16377
  requires: []
16370
16378
  },
16371
16379
  check: {
@@ -31703,8 +31711,13 @@ function parseAnnouncement(file, pid) {
31703
31711
  name: readString(project, "name") ?? "",
31704
31712
  path: readString(project, "path") ?? ""
31705
31713
  };
31714
+ let announcedAt;
31715
+ try {
31716
+ announcedAt = statSync5(file).mtimeMs;
31717
+ } catch {}
31718
+ const when = announcedAt === undefined ? {} : { announcedAt };
31706
31719
  if (protocol !== RUNTIME_PROTOCOL) {
31707
- return { kind: "unspoken", unspoken: { pid, protocol: protocol ?? 0, project: named } };
31720
+ return { kind: "unspoken", unspoken: { pid, protocol: protocol ?? 0, project: named, ...when } };
31708
31721
  }
31709
31722
  const editorPid = readNumber(fields, "editor_pid");
31710
31723
  return {
@@ -31715,7 +31728,8 @@ function parseAnnouncement(file, pid) {
31715
31728
  address,
31716
31729
  project: named,
31717
31730
  file,
31718
- ...editorPid !== undefined && Number.isInteger(editorPid) && editorPid > 0 ? { editorPid } : {}
31731
+ ...editorPid !== undefined && Number.isInteger(editorPid) && editorPid > 0 ? { editorPid } : {},
31732
+ ...when
31719
31733
  }
31720
31734
  };
31721
31735
  }
@@ -37730,6 +37744,72 @@ var init_lsp_client = __esm(() => {
37730
37744
  init_ports();
37731
37745
  });
37732
37746
 
37747
+ // src/outside.ts
37748
+ import { spawn } from "node:child_process";
37749
+ import { fileURLToPath as fileURLToPath5 } from "node:url";
37750
+ function changesFrom(env, base) {
37751
+ const changes = {};
37752
+ if (env === undefined) {
37753
+ return changes;
37754
+ }
37755
+ for (const [name, value] of Object.entries(env)) {
37756
+ if (value !== undefined && base[name] !== value) {
37757
+ changes[name] = value;
37758
+ }
37759
+ }
37760
+ for (const name of Object.keys(base)) {
37761
+ if (env[name] === undefined) {
37762
+ changes[name] = null;
37763
+ }
37764
+ }
37765
+ return changes;
37766
+ }
37767
+ function sendSpec(child, spec) {
37768
+ child.stdin?.end(JSON.stringify(spec), "utf8");
37769
+ }
37770
+ function readLaunched(printed) {
37771
+ const line = printed.split(`
37772
+ `).find((one) => one.trim() !== "")?.trim() ?? "";
37773
+ const pid = /^pid (\d+)$/.exec(line);
37774
+ if (pid?.[1] !== undefined) {
37775
+ return { pid: Number(pid[1]) };
37776
+ }
37777
+ return { error: line.replace(/^error /, "") || "the launcher said nothing" };
37778
+ }
37779
+ async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = KEEPER_SCRIPT) {
37780
+ return await new Promise((resolve) => {
37781
+ const launcher = spawn(runtime, [keeper, "launch"], {
37782
+ stdio: ["pipe", "pipe", "pipe"],
37783
+ windowsHide: true
37784
+ });
37785
+ sendSpec(launcher, {
37786
+ command: spec.command,
37787
+ args: spec.args,
37788
+ envChanges: changesFrom(spec.env, process.env),
37789
+ ...spec.run === undefined ? {} : { run: spec.run }
37790
+ });
37791
+ let printed = "";
37792
+ let complained = "";
37793
+ launcher.stdout.on("data", (chunk) => {
37794
+ printed += chunk.toString();
37795
+ });
37796
+ launcher.stderr.on("data", (chunk) => {
37797
+ complained += chunk.toString();
37798
+ });
37799
+ launcher.once("error", (error) => {
37800
+ resolve({ error: `the launcher did not start: ${error.message}` });
37801
+ });
37802
+ launcher.once("close", () => {
37803
+ const launched = readLaunched(printed);
37804
+ resolve("error" in launched && complained.trim() !== "" ? { error: `${launched.error}: ${complained.trim()}` } : launched);
37805
+ });
37806
+ });
37807
+ }
37808
+ var KEEPER_SCRIPT;
37809
+ var init_outside = __esm(() => {
37810
+ KEEPER_SCRIPT = fileURLToPath5(new URL("./keeper.js", import.meta.url));
37811
+ });
37812
+
37733
37813
  // src/process-time.ts
37734
37814
  import { execFile as execFile6 } from "node:child_process";
37735
37815
  import { promisify as promisify6 } from "node:util";
@@ -37944,37 +38024,61 @@ var init_restart_note = __esm(() => {
37944
38024
 
37945
38025
  // src/run-record.ts
37946
38026
  import { execFileSync as execFileSync2 } from "node:child_process";
37947
- import { mkdirSync as mkdirSync13, openSync as openSync2, readdirSync as readdirSync8, readFileSync as readFileSync20, rmSync as rmSync11, statSync as statSync7, writeFileSync as writeFileSync15 } from "node:fs";
37948
- import { basename as basename3, join as join22 } from "node:path";
38027
+ import { createHash as createHash5 } from "node:crypto";
38028
+ import {
38029
+ mkdirSync as mkdirSync13,
38030
+ openSync as openSync2,
38031
+ readdirSync as readdirSync8,
38032
+ readFileSync as readFileSync20,
38033
+ realpathSync as realpathSync3,
38034
+ rmSync as rmSync11,
38035
+ statSync as statSync7,
38036
+ writeFileSync as writeFileSync15
38037
+ } from "node:fs";
38038
+ import { basename as basename3, dirname as dirname16, join as join22, resolve as resolve5 } from "node:path";
37949
38039
  function runsDirectory() {
37950
38040
  return join22(runtimeDirectory(), "runs");
37951
38041
  }
37952
- function recordPath() {
37953
- return join22(runsDirectory(), "run.json");
38042
+ function realAsFarAsItExists(path) {
38043
+ const missing = [];
38044
+ let at = path;
38045
+ for (;; ) {
38046
+ try {
38047
+ return join22(realpathSync3.native(at), ...missing);
38048
+ } catch {
38049
+ const parent = dirname16(at);
38050
+ if (parent === at) {
38051
+ return path;
38052
+ }
38053
+ missing.unshift(basename3(at));
38054
+ at = parent;
38055
+ }
38056
+ }
38057
+ }
38058
+ function projectKey(projectPath) {
38059
+ const real = realAsFarAsItExists(resolve5(projectPath)).replace(/[\\/]+$/, "");
38060
+ const folded = process.platform === "win32" || process.platform === "darwin" ? real.toLowerCase() : real;
38061
+ return createHash5("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
37954
38062
  }
37955
- function recordPaths() {
37956
- return runtimeDirectories().map((directory) => join22(directory, "runs", "run.json"));
38063
+ function runRecordPath(projectPath, directory = runtimeDirectory()) {
38064
+ return join22(directory, "runs", `note-${projectKey(projectPath)}.json`);
37957
38065
  }
37958
38066
  function openTranscript(startedAt) {
37959
38067
  mkdirSync13(runsDirectory(), { recursive: true });
37960
38068
  const path = join22(runsDirectory(), `run-${startedAt}.log`);
37961
38069
  return { path, fd: openSync2(path, "a") };
37962
38070
  }
37963
- function writeRunRecord(record) {
37964
- mkdirSync13(runsDirectory(), { recursive: true });
37965
- writeFileSync15(recordPath(), JSON.stringify(record, null, 2), "utf8");
37966
- }
37967
- function editorNotePath() {
37968
- return join22(runsDirectory(), "editor-run.json");
38071
+ function editorNotePath(projectPath, directory = runtimeDirectory()) {
38072
+ return join22(directory, "runs", `editor-${projectKey(projectPath)}.json`);
37969
38073
  }
37970
38074
  function writeEditorRunNote(note) {
37971
38075
  mkdirSync13(runsDirectory(), { recursive: true });
37972
- writeFileSync15(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
38076
+ writeFileSync15(editorNotePath(note.projectPath), JSON.stringify(note, null, 2), "utf8");
37973
38077
  }
37974
- function readEditorRunNote() {
38078
+ function readEditorRunNote(projectPath) {
37975
38079
  for (const directory of runtimeDirectories()) {
37976
- const note = editorNoteAt(join22(directory, "runs", "editor-run.json"));
37977
- if (note !== null) {
38080
+ const note = editorNoteAt(editorNotePath(projectPath, directory)) ?? editorNoteAt(join22(directory, "runs", "editor-run.json"));
38081
+ if (note !== null && isSameDirectory(note.projectPath, projectPath)) {
37978
38082
  return note;
37979
38083
  }
37980
38084
  }
@@ -37999,29 +38103,38 @@ function editorNoteAt(path) {
37999
38103
  }
38000
38104
  return { projectPath, transcript, startedAt };
38001
38105
  }
38002
- function recordRunEnded(pid, ending) {
38003
- const path = recordPath();
38004
- const record = recordAt(path);
38005
- if (record === null || record.pid !== pid) {
38006
- return;
38106
+ function readRunRecord(projectPath) {
38107
+ for (const directory of runtimeDirectories()) {
38108
+ const own = recordAt(runRecordPath(projectPath, directory));
38109
+ if (own !== null) {
38110
+ return own;
38111
+ }
38112
+ const shared = recordAt(join22(directory, "runs", SHARED_NOTE));
38113
+ if (shared !== null && shared.projectPath !== "" && isSameDirectory(shared.projectPath, projectPath)) {
38114
+ return shared;
38115
+ }
38007
38116
  }
38008
- const written = {
38009
- ...record,
38010
- ...ending.exitCode === null ? {} : { exitCode: ending.exitCode },
38011
- ...ending.exitSignal === null ? {} : { exitSignal: ending.exitSignal }
38012
- };
38013
- try {
38014
- writeFileSync15(path, JSON.stringify(written, null, 2), "utf8");
38015
- } catch {}
38117
+ return null;
38016
38118
  }
38017
- function readRunRecord() {
38018
- for (const path of recordPaths()) {
38019
- const record = recordAt(path);
38020
- if (record !== null) {
38021
- return record;
38119
+ function everyRunRecord() {
38120
+ const found = [];
38121
+ for (const directory of runtimeDirectories()) {
38122
+ let entries;
38123
+ try {
38124
+ entries = readdirSync8(join22(directory, "runs"));
38125
+ } catch {
38126
+ continue;
38127
+ }
38128
+ for (const entry of entries) {
38129
+ if (entry === SHARED_NOTE || entry.startsWith("note-") && entry.endsWith(".json")) {
38130
+ const record = recordAt(join22(directory, "runs", entry));
38131
+ if (record !== null) {
38132
+ found.push(record);
38133
+ }
38134
+ }
38022
38135
  }
38023
38136
  }
38024
- return null;
38137
+ return found.sort((one, other) => other.startedAt - one.startedAt);
38025
38138
  }
38026
38139
  function recordAt(path) {
38027
38140
  let parsed;
@@ -38180,7 +38293,7 @@ function judgeRun(record, running, needed) {
38180
38293
  if (wanted !== null && !said.includes(wanted)) {
38181
38294
  return false;
38182
38295
  }
38183
- if (running.kind === "commandLine" && AN_EDITOR.test(said)) {
38296
+ if (running.kind === "commandLine" && readCommandLine(running.text).editor) {
38184
38297
  return false;
38185
38298
  }
38186
38299
  if (record.projectPath === "") {
@@ -38192,15 +38305,32 @@ function judgeRun(record, running, needed) {
38192
38305
  const project = process.platform === "win32" ? record.projectPath.toLowerCase() : record.projectPath;
38193
38306
  return said.includes(project);
38194
38307
  }
38195
- function clearRunRecord(ours = () => true) {
38196
- for (const path of recordPaths()) {
38197
- const record = recordAt(path);
38198
- if (record === null || !ours(record)) {
38199
- continue;
38308
+ function stillTheAnnouncedGame(pid, projectPath, announcedAt) {
38309
+ return judgeAnnouncedGame(runningAs(pid), projectPath, announcedAt);
38310
+ }
38311
+ function judgeAnnouncedGame(running, projectPath, announcedAt) {
38312
+ if (running?.kind !== "commandLine" || running.startedAt === undefined) {
38313
+ return null;
38314
+ }
38315
+ if (running.startedAt - announcedAt > STARTED_AFTER_ANNOUNCING_MS) {
38316
+ return null;
38317
+ }
38318
+ const read = readCommandLine(running.text);
38319
+ if (read.editor || read.projectPath === null || !isSameDirectory(read.projectPath, projectPath)) {
38320
+ return null;
38321
+ }
38322
+ return read;
38323
+ }
38324
+ function clearRunRecord(projectPath) {
38325
+ for (const directory of runtimeDirectories()) {
38326
+ const shared = join22(directory, "runs", SHARED_NOTE);
38327
+ const sharedRecord = recordAt(shared);
38328
+ const ours = sharedRecord !== null && sharedRecord.projectPath !== "" && isSameDirectory(sharedRecord.projectPath, projectPath);
38329
+ for (const path of [runRecordPath(projectPath, directory), ...ours ? [shared] : []]) {
38330
+ try {
38331
+ rmSync11(path, { force: true });
38332
+ } catch {}
38200
38333
  }
38201
- try {
38202
- rmSync11(path);
38203
- } catch {}
38204
38334
  }
38205
38335
  }
38206
38336
  function ageMs(path, now) {
@@ -38212,7 +38342,7 @@ function ageMs(path, now) {
38212
38342
  }
38213
38343
  function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
38214
38344
  const directory = runsDirectory();
38215
- const keep = readRunRecord()?.transcript;
38345
+ const kept = new Set(everyRunRecord().map((record) => record.transcript));
38216
38346
  let entries;
38217
38347
  try {
38218
38348
  entries = readdirSync8(directory);
@@ -38224,7 +38354,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
38224
38354
  continue;
38225
38355
  }
38226
38356
  const path = join22(directory, entry);
38227
- if (path === keep || ageMs(path, now) < keepMs) {
38357
+ if (kept.has(path) || ageMs(path, now) < keepMs) {
38228
38358
  continue;
38229
38359
  }
38230
38360
  try {
@@ -38232,15 +38362,15 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
38232
38362
  } catch {}
38233
38363
  }
38234
38364
  }
38235
- var AN_EDITOR, SAME_RUN_WINDOW_MS = 1e4;
38365
+ var SHARED_NOTE = "run.json", SAME_RUN_WINDOW_MS = 1e4;
38236
38366
  var init_run_record = __esm(() => {
38367
+ init_paths();
38237
38368
  init_process_children();
38238
38369
  init_runtime_client();
38239
- AN_EDITOR = /(?:^|\s)(?:-e|--editor)(?:\s|$)/;
38240
38370
  });
38241
38371
 
38242
38372
  // src/server.ts
38243
- import { execFile as execFile7, spawn } from "node:child_process";
38373
+ import { execFile as execFile7, spawn as spawn2 } from "node:child_process";
38244
38374
  import { randomUUID as randomUUID2 } from "node:crypto";
38245
38375
  import {
38246
38376
  appendFileSync,
@@ -38259,9 +38389,9 @@ import {
38259
38389
  statSync as statSync8
38260
38390
  } from "node:fs";
38261
38391
  import { tmpdir as tmpdir5 } from "node:os";
38262
- import { basename as basename4, dirname as dirname16, extname as extname2, isAbsolute as isAbsolute3, join as join23, normalize as normalize3, relative as relative3 } from "node:path";
38392
+ import { basename as basename4, dirname as dirname17, extname as extname2, isAbsolute as isAbsolute3, join as join23, normalize as normalize3, relative as relative3 } from "node:path";
38263
38393
  import { setTimeout as delay3 } from "node:timers/promises";
38264
- import { fileURLToPath as fileURLToPath5 } from "node:url";
38394
+ import { fileURLToPath as fileURLToPath6 } from "node:url";
38265
38395
  import { promisify as promisify7 } from "node:util";
38266
38396
  function captureDestinationRefusal(outputPath, projects) {
38267
38397
  if (/^(res|user):\/\//.test(outputPath)) {
@@ -38273,9 +38403,9 @@ function captureDestinationRefusal(outputPath, projects) {
38273
38403
  if (extname2(outputPath).toLowerCase() !== ".png") {
38274
38404
  return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
38275
38405
  }
38276
- const directory = realPathOr(dirname16(outputPath));
38406
+ const directory = realPathOr(dirname17(outputPath));
38277
38407
  if (!existsSync15(directory)) {
38278
- return `outputPath's directory ${dirname16(outputPath)} does not exist; a capture creates no directories.`;
38408
+ return `outputPath's directory ${dirname17(outputPath)} does not exist; a capture creates no directories.`;
38279
38409
  }
38280
38410
  const where = join23(directory, basename4(outputPath));
38281
38411
  const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
@@ -38330,7 +38460,7 @@ function stillRunning(run) {
38330
38460
  if (typeof run.endedHere === "string") {
38331
38461
  return false;
38332
38462
  }
38333
- if (!run.throughEditor && run.process === null && run.pid !== null) {
38463
+ if (!run.throughEditor && run.pid !== null) {
38334
38464
  return alive(run.pid);
38335
38465
  }
38336
38466
  if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
@@ -38354,10 +38484,10 @@ function runIsUp(run, editorSays, now = Date.now()) {
38354
38484
  return editorSays ?? stillRunning(run);
38355
38485
  }
38356
38486
  function noCodeWillCome(run) {
38357
- return !exited(run) && run.process === null;
38487
+ return !exited(run);
38358
38488
  }
38359
- async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
38360
- const gone = () => (handle?.exitCode !== null || handle.signalCode !== null) && pids.every((pid) => !alive(pid));
38489
+ async function untilGone(pids, withinMs = STOP_WAIT_MS) {
38490
+ const gone = () => pids.every((pid) => !alive(pid));
38361
38491
  const deadline = Date.now() + withinMs;
38362
38492
  while (!gone() && Date.now() < deadline) {
38363
38493
  await delay3(50);
@@ -38365,6 +38495,9 @@ async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
38365
38495
  return gone();
38366
38496
  }
38367
38497
  function endedWithoutACode(run) {
38498
+ if (!run.throughEditor && !run.pickedUp) {
38499
+ 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.";
38500
+ }
38368
38501
  if (!run.throughEditor) {
38369
38502
  return run.endedHere ? "This run was started by another server, so nothing here held it and its exit code was never collected. Everything it printed is below, read back from its transcript." : "This run was started by another server, the one this server replaced or one running beside it, and it ended with nothing here waiting on it, so its exit code was never collected. Everything it printed is below, read back from its transcript.";
38370
38503
  }
@@ -38377,12 +38510,41 @@ function endedWithoutACode(run) {
38377
38510
  }
38378
38511
  return `The editor stopped playing this run. ${kept} What it printed is below.`;
38379
38512
  }
38513
+ function stopVerdict(stop) {
38514
+ if (!stop.wasRunning) {
38515
+ return {
38516
+ stopped: true,
38517
+ endedPid: stop.endedPid,
38518
+ clean: stop.errors === 0,
38519
+ 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.",
38520
+ withChildren: false
38521
+ };
38522
+ }
38523
+ if (stop.ending === "refused") {
38524
+ return {
38525
+ stopped: false,
38526
+ notSignalled: stop.endedPid,
38527
+ 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.`,
38528
+ withChildren: false
38529
+ };
38530
+ }
38531
+ return {
38532
+ stopped: true,
38533
+ endedPid: stop.endedPid,
38534
+ clean: stop.errors === 0,
38535
+ 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.`}`,
38536
+ withChildren: true
38537
+ };
38538
+ }
38380
38539
  function endedPreviousRun(ended) {
38381
- if (ended === null) {
38540
+ if (ended === null || ended.ending === "refused") {
38382
38541
  return;
38383
38542
  }
38384
38543
  return ended.pid ?? true;
38385
38544
  }
38545
+ function previousRunLeft(ended) {
38546
+ return ended?.ending === "refused" && ended.pid !== null ? ended.pid : undefined;
38547
+ }
38386
38548
  function engineNamesOf(executable) {
38387
38549
  return CONSOLE_WRAPPER.test(executable) ? [executable, executable.replace(CONSOLE_WRAPPER, "$1")] : [executable];
38388
38550
  }
@@ -38422,6 +38584,17 @@ function aboutTheChildren(ended, throughEditor) {
38422
38584
  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.`;
38423
38585
  return `${endedToo}${leftToo}`;
38424
38586
  }
38587
+ function childrenFields(ended) {
38588
+ if (ended === null) {
38589
+ return {};
38590
+ }
38591
+ return {
38592
+ endedChildren: ended.ended.map((one) => one.pid),
38593
+ identifiedBy: ended.ended.length === 0 ? undefined : Object.fromEntries(ended.ended.map((one) => [String(one.pid), one.identifiedBy])),
38594
+ childrenLeft: ended.left.length === 0 ? undefined : ended.left,
38595
+ childrenUnknown: ended.unknown === false ? undefined : true
38596
+ };
38597
+ }
38425
38598
  function uidsLeftNote(left) {
38426
38599
  if (left === 0) {
38427
38600
  return "No scene was written: this reads the project and imports it, and does not resave anything.";
@@ -38429,12 +38602,24 @@ function uidsLeftNote(left) {
38429
38602
  const them = left === 1 ? "it" : "them";
38430
38603
  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.`;
38431
38604
  }
38605
+ function leftRunningNote(stranded) {
38606
+ if (stranded.length === 0) {
38607
+ return "";
38608
+ }
38609
+ const named = stranded.map((one) => one.protocol === null ? `pid ${one.pid}` : `pid ${one.pid} on protocol ${one.protocol}`).join(", ");
38610
+ const [only] = stranded;
38611
+ 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.`;
38612
+ }
38432
38613
  function endedToStartThis(ended) {
38433
38614
  if (ended === null) {
38434
38615
  return "";
38435
38616
  }
38436
38617
  const which = ended.pid === null ? "The game the editor was playing" : `The run that was going, pid ${ended.pid},`;
38437
- return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.`;
38618
+ if (ended.ending === "refused") {
38619
+ 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.`;
38620
+ }
38621
+ 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.` : "";
38622
+ return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.${after}`;
38438
38623
  }
38439
38624
  function runtimeVerdict(endpoint, after) {
38440
38625
  if (endpoint !== null) {
@@ -39739,11 +39924,11 @@ class GodotServer {
39739
39924
  if (!engine.ok) {
39740
39925
  return engine.response;
39741
39926
  }
39742
- const folder = dirname16(output.absolutePath);
39927
+ const folder = dirname17(output.absolutePath);
39743
39928
  try {
39744
39929
  mkdirSync14(folder, { recursive: true });
39745
39930
  } catch (error) {
39746
- return this.createErrorResponse(`Cannot create ${dirname16(output.relativePath)} to export into: ${errorMessage(error)}`);
39931
+ return this.createErrorResponse(`Cannot create ${dirname17(output.relativePath)} to export into: ${errorMessage(error)}`);
39747
39932
  }
39748
39933
  const debug = readBoolean(args, "debug") ?? false;
39749
39934
  const exportLogs = mkdtempSync3(join23(tmpdir5(), "gdharness-export-"));
@@ -39888,7 +40073,7 @@ class GodotServer {
39888
40073
  } finally {
39889
40074
  discard(reportsDir, userData);
39890
40075
  try {
39891
- rmdirSync(dirname16(reportsDir));
40076
+ rmdirSync(dirname17(reportsDir));
39892
40077
  } catch {}
39893
40078
  }
39894
40079
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
@@ -40116,7 +40301,12 @@ class GodotServer {
40116
40301
  return anEditorIsStillComing(listeningSince, this.launchedEditorIsUp());
40117
40302
  }
40118
40303
  launchedEditorIsUp() {
40119
- return this.launchedEditor?.exitCode === null && this.launchedEditor.signalCode === null;
40304
+ const launched = this.launchedEditor;
40305
+ if (launched === null || !alive(launched.pid)) {
40306
+ return false;
40307
+ }
40308
+ const now = startTimesOf([launched.pid]).get(launched.pid);
40309
+ return launched.startedAt === null || now === undefined || now === launched.startedAt;
40120
40310
  }
40121
40311
  dap() {
40122
40312
  const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
@@ -40477,9 +40667,9 @@ class GodotServer {
40477
40667
  }
40478
40668
  async openAnEditor(engine, projectPath, ports) {
40479
40669
  clearEditorLog(projectPath);
40480
- const editor = spawn(engine, editorArguments(projectPath, ports, editorLogPath(projectPath)), {
40481
- stdio: "ignore",
40482
- detached: true,
40670
+ const launched = await launchOutsideTheTree({
40671
+ command: engine,
40672
+ args: editorArguments(projectPath, ports, editorLogPath(projectPath)),
40483
40673
  env: {
40484
40674
  ...process.env,
40485
40675
  GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
@@ -40488,23 +40678,15 @@ class GodotServer {
40488
40678
  [OPENED_BY_A_SERVER]: "1"
40489
40679
  }
40490
40680
  });
40491
- if (editor.pid !== undefined) {
40492
- writeEditorLogNote(projectPath, editor.pid);
40493
- }
40494
- const started = await new Promise((resolve) => {
40495
- editor.once("spawn", () => {
40496
- resolve(null);
40497
- });
40498
- editor.once("error", (error) => {
40499
- resolve(error.message);
40500
- });
40501
- });
40502
- if (started !== null) {
40503
- return { pid: null, error: started };
40681
+ if ("error" in launched) {
40682
+ return { pid: null, error: launched.error };
40504
40683
  }
40505
- editor.unref();
40506
- this.launchedEditor = editor.pid === undefined ? null : editor;
40507
- return { pid: editor.pid ?? null, error: null };
40684
+ writeEditorLogNote(projectPath, launched.pid);
40685
+ this.launchedEditor = {
40686
+ pid: launched.pid,
40687
+ startedAt: startTimesOf([launched.pid]).get(launched.pid) ?? null
40688
+ };
40689
+ return { pid: launched.pid, error: null };
40508
40690
  }
40509
40691
  async portsForAnEditor() {
40510
40692
  const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
@@ -40553,8 +40735,10 @@ class GodotServer {
40553
40735
  const before = this.currentRun();
40554
40736
  if (before !== null && await this.runStillGoing(before)) {
40555
40737
  this.logDebug("Ending the running game before starting another");
40556
- ended = { pid: before.pid ?? this.announcedPidOf(before) ?? null };
40557
- await this.endActiveGame("editor_run start, which ends the run that was going before it starts another", true);
40738
+ ended = {
40739
+ pid: before.pid ?? this.announcedPidOf(before) ?? null,
40740
+ ending: await this.endActiveGame("editor_run start, which ends the run that was going before it starts another", true)
40741
+ };
40558
40742
  }
40559
40743
  const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
40560
40744
  const wereHereFirst = this.everyRuntimeOfOurs();
@@ -40574,25 +40758,30 @@ class GodotServer {
40574
40758
  userArgs: given.value
40575
40759
  });
40576
40760
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
40577
- const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path, needsItsOwnProcess(asked.value) ? environmentFor({ ...asked.value, runtimeDirectory: runtimeDirectory() }) : undefined);
40761
+ const started = await this.launchKeptGame(engine.value, cmdArgs, project.value.path, needsItsOwnProcess(asked.value) ? environmentFor({ ...asked.value, runtimeDirectory: runtimeDirectory() }) : undefined);
40762
+ if ("error" in started) {
40763
+ return this.createErrorResponse(`The game could not be started: ${started.error}`, [
40764
+ "editor_status says which Godot this server runs and whether it answers"
40765
+ ]);
40766
+ }
40578
40767
  started.announcedBefore = alreadyPlaying;
40579
40768
  this.activeProcess = started;
40580
40769
  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." : "";
40581
40770
  const endedForThis = endedToStartThis(ended);
40582
40771
  const stranded = wereHereFirst.filter((one) => one.pid !== ended?.pid && alive(one.pid));
40583
- const named = stranded.map((one) => one.protocol === null ? `pid ${one.pid}` : `pid ${one.pid} on protocol ${one.protocol}`).join(", ");
40584
- 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.`;
40772
+ const alsoRunning = leftRunningNote(stranded);
40585
40773
  return this.jsonTextResponse({
40586
40774
  started: true,
40587
40775
  through: "gdharness",
40588
40776
  alsoRunning: stranded.length === 0 ? undefined : stranded.map((one) => one.pid),
40589
- pid: started.process.pid ?? null,
40777
+ pid: started.pid,
40590
40778
  arguments: cmdArgs,
40591
40779
  transcript: started.transcript,
40592
40780
  refreshedClasses: refreshed.value,
40593
40781
  ...scanWaitAnswer(scanned),
40594
40782
  savesNote: asked.value.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
40595
40783
  endedPreviousRun: endedPreviousRun(ended),
40784
+ previousRunLeft: previousRunLeft(ended),
40596
40785
  runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
40597
40786
  message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}${alsoRunning}`
40598
40787
  });
@@ -40701,7 +40890,6 @@ class GodotServer {
40701
40890
  closeSync2(transcript.fd);
40702
40891
  const playAnswer = asParams(JSON.parse(answer.content[0]?.text ?? "{}"));
40703
40892
  const played = {
40704
- process: null,
40705
40893
  pid: null,
40706
40894
  log,
40707
40895
  transcript: transcript.path,
@@ -40726,6 +40914,7 @@ class GodotServer {
40726
40914
  ...scanWaitAnswer(scanned),
40727
40915
  debugPort: readNumber(playAnswer, "debugPort"),
40728
40916
  endedPreviousRun: endedPreviousRun(ended),
40917
+ previousRunLeft: previousRunLeft(ended),
40729
40918
  breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
40730
40919
  breakpointsRefused: breakpoints.refused.length === 0 ? undefined : breakpoints.refused.map((one) => ({
40731
40920
  scriptPath: projectSpelling(projectPath, one.scriptPath),
@@ -40800,28 +40989,41 @@ class GodotServer {
40800
40989
  }
40801
40990
  async endActiveGame(reason, going) {
40802
40991
  const running = this.activeProcess;
40803
- const recorded = running?.process === null ? readRunRecord() : null;
40804
- clearRunRecord((record) => this.couldBeOurs(record.projectPath));
40992
+ const project = running?.projectPath ?? this.ourProject();
40993
+ const clearTheRecord = () => {
40994
+ if (project !== null) {
40995
+ clearRunRecord(project);
40996
+ }
40997
+ };
40998
+ const aKeeperHoldsIt = running !== null && going && !running.throughEditor && running.pid !== null;
40999
+ if (!aKeeperHoldsIt) {
41000
+ clearTheRecord();
41001
+ }
40805
41002
  if (!running || !going) {
40806
- return true;
41003
+ return "gone";
40807
41004
  }
40808
41005
  running.endedHere = reason;
40809
41006
  const announced = this.announcedPidOf(running);
40810
41007
  if (running.throughEditor) {
40811
41008
  await this.handleViaBridge("stop_playing", {});
40812
- return await untilGone(null, announced === undefined ? [] : [announced]);
40813
- }
40814
- if (running.process !== null) {
40815
- running.process.kill();
40816
- return await untilGone(running.process, announced === undefined ? [] : [announced]);
41009
+ return await untilGone(announced === undefined ? [] : [announced]) ? "gone" : "lingering";
40817
41010
  }
40818
41011
  if (running.pid === null) {
40819
- return true;
40820
- }
40821
- if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
41012
+ return "gone";
41013
+ }
41014
+ const startedAs = {
41015
+ pid: running.pid,
41016
+ transcript: running.transcript ?? "",
41017
+ startedAt: running.startedAt,
41018
+ projectPath: running.projectPath ?? "",
41019
+ arguments: [],
41020
+ ...running.command === undefined ? {} : { command: running.command }
41021
+ };
41022
+ if (!stillTheRecordedRun(startedAs)) {
41023
+ clearTheRecord();
40822
41024
  running.endedHere = null;
40823
- running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the run that was recorded, so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
40824
- return true;
41025
+ 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.`);
41026
+ return "refused";
40825
41027
  }
40826
41028
  const signalled = runningAs(running.pid);
40827
41029
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
@@ -40831,12 +41033,20 @@ class GodotServer {
40831
41033
  } catch {
40832
41034
  landed = false;
40833
41035
  }
40834
- const gone = await untilGone(null, [running.pid]);
41036
+ const gone = await untilGone([running.pid]);
41037
+ if (gone) {
41038
+ await this.untilTheExitIsRecorded(running);
41039
+ }
41040
+ if (landed && gone) {
41041
+ running.exitCode = null;
41042
+ running.exitSignal = "SIGTERM";
41043
+ }
41044
+ clearTheRecord();
40835
41045
  running.log.record("info", landed ? `gdharness ended pid ${running.pid}, which the operating system described as ${described}.` : `gdharness signalled pid ${running.pid} and it was already gone; the operating system had described it as ${described}.`);
40836
- return gone;
41046
+ return gone ? "gone" : "lingering";
40837
41047
  }
40838
41048
  spawnGame(godotPath, cmdArgs, env) {
40839
- const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
41049
+ const child = spawn2(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
40840
41050
  const cancelled = callSignal();
40841
41051
  if (cancelled !== undefined) {
40842
41052
  const end = () => {
@@ -40888,62 +41098,33 @@ class GodotServer {
40888
41098
  });
40889
41099
  return started;
40890
41100
  }
40891
- spawnKeptGame(godotPath, cmdArgs, projectPath, env) {
41101
+ async launchKeptGame(godotPath, cmdArgs, projectPath, env) {
40892
41102
  const startedAt = Date.now();
40893
41103
  const transcript = openTranscript(startedAt);
40894
- let child;
40895
- try {
40896
- child = spawn(godotPath, cmdArgs, {
40897
- stdio: ["ignore", transcript.fd, transcript.fd],
40898
- detached: true,
40899
- ...env ? { env } : {}
40900
- });
40901
- } finally {
40902
- closeSync2(transcript.fd);
41104
+ closeSync2(transcript.fd);
41105
+ const launched = await launchOutsideTheTree({
41106
+ command: godotPath,
41107
+ args: cmdArgs,
41108
+ ...env === undefined ? {} : { env },
41109
+ run: { transcript: transcript.path, startedAt, projectPath }
41110
+ });
41111
+ if ("error" in launched) {
41112
+ return launched;
40903
41113
  }
40904
- child.unref();
40905
- const log = new GameLog;
40906
- const started = {
40907
- process: child,
40908
- pid: child.pid ?? null,
40909
- log,
41114
+ sweepTranscripts();
41115
+ return {
41116
+ pid: launched.pid,
41117
+ log: new GameLog,
40910
41118
  transcript: transcript.path,
40911
41119
  readOffset: 0,
40912
41120
  projectPath,
40913
41121
  startedAt,
41122
+ command: godotPath,
40914
41123
  exitCode: null,
40915
41124
  exitSignal: null,
40916
41125
  throughEditor: false,
40917
41126
  brokeOn: null
40918
41127
  };
40919
- child.on("exit", (code, signal) => {
40920
- this.logDebug(`Godot process exited with code ${code ?? "none"}, signal ${signal ?? "none"}`);
40921
- this.drainTranscript(started);
40922
- log.finish();
40923
- started.exitCode = code;
40924
- started.exitSignal = code === null ? signal : null;
40925
- if (started.pid !== null) {
40926
- recordRunEnded(started.pid, { exitCode: started.exitCode, exitSignal: started.exitSignal });
40927
- }
40928
- });
40929
- child.on("error", (err) => {
40930
- console.error("Failed to start Godot process:", err);
40931
- log.append("stderr", `${err.message}
40932
- `);
40933
- started.exitCode = -1;
40934
- });
40935
- if (started.pid !== null) {
40936
- writeRunRecord({
40937
- pid: started.pid,
40938
- transcript: transcript.path,
40939
- startedAt,
40940
- projectPath,
40941
- arguments: cmdArgs,
40942
- command: godotPath
40943
- });
40944
- }
40945
- sweepTranscripts();
40946
- return started;
40947
41128
  }
40948
41129
  drainTranscript(game) {
40949
41130
  if (game.transcript === null) {
@@ -40996,10 +41177,8 @@ class GodotServer {
40996
41177
  this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
40997
41178
  }
40998
41179
  const project = this.godotBridge.getStatus().projectPath ?? null;
40999
- const left = readEditorRunNote();
41000
- const note = left !== null && project !== null && isSameDirectory(left.projectPath, project) ? left : null;
41180
+ const note = project === null ? null : readEditorRunNote(project);
41001
41181
  const picked = {
41002
- process: null,
41003
41182
  pid: null,
41004
41183
  log: new GameLog,
41005
41184
  transcript: note?.transcript ?? null,
@@ -41131,22 +41310,24 @@ class GodotServer {
41131
41310
  return endpoint.editorPid === undefined || endpoint.editorPid !== editor;
41132
41311
  }
41133
41312
  adoptRecordedRun() {
41134
- const record = readRunRecord();
41313
+ const project = this.ourProject();
41314
+ const record = project === null ? null : readRunRecord(project);
41135
41315
  if (record === null || !this.couldBeOurs(record.projectPath)) {
41136
41316
  return null;
41137
41317
  }
41138
41318
  const adopted = {
41139
- process: null,
41140
41319
  pid: record.pid,
41141
41320
  log: new GameLog,
41142
41321
  transcript: record.transcript,
41143
41322
  readOffset: 0,
41144
41323
  projectPath: record.projectPath === "" ? null : record.projectPath,
41145
41324
  startedAt: record.startedAt,
41325
+ ...record.command === undefined ? {} : { command: record.command },
41146
41326
  exitCode: null,
41147
41327
  exitSignal: null,
41148
41328
  throughEditor: false,
41149
- brokeOn: null
41329
+ brokeOn: null,
41330
+ pickedUp: true
41150
41331
  };
41151
41332
  this.drainTranscript(adopted);
41152
41333
  if (!alive(record.pid) || !couldStillBeTheRecordedRun(record)) {
@@ -41163,18 +41344,18 @@ class GodotServer {
41163
41344
  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." : "";
41164
41345
  const live = this.aRuntimeOfOursIsAnswering();
41165
41346
  if (live !== null) {
41166
- 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.");
41347
+ 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.";
41167
41348
  }
41168
41349
  const unreadable = this.aRuntimeOfOursIsTooFarOff();
41169
41350
  if (unreadable !== null) {
41170
41351
  const behind = whichHalfIsBehind([unreadable]);
41171
- 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.";
41352
+ 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.";
41172
41353
  }
41173
- const record = readRunRecord();
41354
+ const mine = this.ourProject();
41355
+ const record = everyRunRecord().find((one) => mine === null || !isSameDirectory(one.projectPath, mine)) ?? null;
41174
41356
  if (record === null) {
41175
41357
  return `No game is running.${noEditor} Start one with editor_run.`;
41176
41358
  }
41177
- const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
41178
41359
  const whose = record.projectPath === "" ? "a project it does not name" : record.projectPath;
41179
41360
  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}`;
41180
41361
  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.`;
@@ -41217,13 +41398,16 @@ class GodotServer {
41217
41398
  ];
41218
41399
  }
41219
41400
  couldBeOurs(project) {
41220
- const status = this.godotBridge.getStatus();
41221
- const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
41401
+ const mine = this.ourProject();
41222
41402
  if (mine === null) {
41223
41403
  return false;
41224
41404
  }
41225
41405
  return project !== "" && isSameDirectory(mine, project);
41226
41406
  }
41407
+ ourProject() {
41408
+ const status = this.godotBridge.getStatus();
41409
+ return this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
41410
+ }
41227
41411
  currentRun() {
41228
41412
  return this.activeProcess ?? this.adoptRecordedRun();
41229
41413
  }
@@ -41281,8 +41465,8 @@ class GodotServer {
41281
41465
  async runStillGoing(run, editorSays) {
41282
41466
  if (!run.throughEditor) {
41283
41467
  const going = stillRunning(run);
41284
- if (!going && run.process !== null && !exited(run)) {
41285
- await untilGone(run.process, [], EXIT_REPORTED_WITHIN_MS);
41468
+ if (!going && run.pid !== null && !exited(run) && run.endedUnwatched !== true && typeof run.endedHere !== "string" && !await this.untilTheExitIsRecorded(run)) {
41469
+ run.endedUnwatched = true;
41286
41470
  }
41287
41471
  return going;
41288
41472
  }
@@ -41299,6 +41483,21 @@ class GodotServer {
41299
41483
  }
41300
41484
  return runIsUp(run, said);
41301
41485
  }
41486
+ async untilTheExitIsRecorded(run) {
41487
+ const until = Date.now() + EXIT_REPORTED_WITHIN_MS;
41488
+ for (;; ) {
41489
+ const record = run.projectPath === null ? null : readRunRecord(run.projectPath);
41490
+ if (record !== null && record.pid === run.pid && (record.exitCode !== undefined || record.exitSignal !== undefined)) {
41491
+ run.exitCode = record.exitCode ?? null;
41492
+ run.exitSignal = record.exitSignal ?? null;
41493
+ return true;
41494
+ }
41495
+ if (Date.now() >= until) {
41496
+ return false;
41497
+ }
41498
+ await delay3(50);
41499
+ }
41500
+ }
41302
41501
  handleEditorConsole(args) {
41303
41502
  const status = this.godotBridge.getStatus();
41304
41503
  const projectPath = status.projectPath ?? this.ownProject ?? undefined;
@@ -41400,7 +41599,8 @@ class GodotServer {
41400
41599
  throughEditor: run.throughEditor,
41401
41600
  announced: announced !== undefined || run.announcedPid !== undefined,
41402
41601
  couldAnnounce: run.projectPath !== null && existsSync15(join23(run.projectPath, RUNTIME_AUTOLOAD2.path)),
41403
- endedHere: typeof run.endedHere === "string"
41602
+ endedHere: typeof run.endedHere === "string",
41603
+ pickedUp: run.pickedUp === true
41404
41604
  }));
41405
41605
  }
41406
41606
  if (run.pickedUpPlaying === true) {
@@ -41426,7 +41626,7 @@ class GodotServer {
41426
41626
  ...hold,
41427
41627
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
41428
41628
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
41429
- project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
41629
+ project: run.throughEditor ? undefined : run.projectPath ?? undefined,
41430
41630
  transcript: run.transcript ?? undefined,
41431
41631
  startedAt: new Date(run.startedAt).toISOString(),
41432
41632
  elapsedMs: Date.now() - run.startedAt,
@@ -41439,6 +41639,10 @@ class GodotServer {
41439
41639
  async handleStopProject(args) {
41440
41640
  await this.pickUpWhatTheEditorIsPlaying();
41441
41641
  const stopped = this.currentRun();
41642
+ const named = readNumber(args, "pid");
41643
+ if (named !== undefined && (stopped === null || named !== stopped.pid && named !== this.announcedPidOf(stopped))) {
41644
+ return await this.stopAnnouncedGame(named, readBoolean(args, "andChildren") === true);
41645
+ }
41442
41646
  if (!stopped) {
41443
41647
  return this.createErrorResponse(this.nothingOfOursIsRunning());
41444
41648
  }
@@ -41449,35 +41653,87 @@ class GodotServer {
41449
41653
  const endedPid = stopped.pid ?? announcedPid;
41450
41654
  const children = readBoolean(args, "andChildren") === true && wasRunning ? await this.whatTheRunStarted(stopped) : null;
41451
41655
  this.logDebug("Stopping the running game");
41452
- const gone = await this.endActiveGame("editor_run stop", wasRunning);
41453
- const ended = children === null ? null : endChildrenAmong(children);
41454
- if (wasRunning) {
41656
+ const ending = await this.endActiveGame("editor_run stop", wasRunning);
41657
+ const refused = ending === "refused";
41658
+ const ended = children === null || refused ? null : endChildrenAmong(children);
41659
+ if (wasRunning && !refused) {
41455
41660
  for (const pid of new Set([endedPid, announcedPid])) {
41456
41661
  if (pid !== null) {
41457
41662
  await announcementEnded(pid);
41458
41663
  }
41459
41664
  }
41460
41665
  }
41666
+ const verdict = stopVerdict({
41667
+ wasRunning,
41668
+ ending,
41669
+ endedPid,
41670
+ throughEditor: stopped.throughEditor,
41671
+ exitSignal: stopped.exitSignal,
41672
+ errors: stopped.log.count("error")
41673
+ });
41461
41674
  return this.jsonTextResponse({
41462
- stopped: true,
41675
+ stopped: verdict.stopped,
41463
41676
  through: stopped.throughEditor ? "editor" : "gdharness",
41464
- endedPid,
41465
- ...ended === null ? {} : {
41466
- endedChildren: ended.ended.map((one) => one.pid),
41467
- identifiedBy: ended.ended.length === 0 ? undefined : Object.fromEntries(ended.ended.map((one) => [String(one.pid), one.identifiedBy])),
41468
- childrenLeft: ended.left.length === 0 ? undefined : ended.left,
41469
- childrenUnknown: ended.unknown === false ? undefined : true
41470
- },
41677
+ endedPid: verdict.endedPid,
41678
+ notSignalled: verdict.notSignalled,
41679
+ ...childrenFields(ended),
41471
41680
  exitedBeforeStop: !wasRunning,
41472
41681
  exitCode: stopped.exitCode,
41473
41682
  exitSignal: stopped.exitSignal ?? undefined,
41474
41683
  errors: stopped.log.count("error"),
41475
41684
  warnings: stopped.log.count("warning"),
41476
- clean: stopped.log.count("error") === 0,
41477
- 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)}`,
41685
+ clean: verdict.clean,
41686
+ note: verdict.withChildren ? `${verdict.note} ${aboutTheChildren(ended, stopped.throughEditor)}` : verdict.note,
41478
41687
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
41479
41688
  });
41480
41689
  }
41690
+ async stopAnnouncedGame(pid, andChildren) {
41691
+ const announced = runtimesAnnounced();
41692
+ const game = [
41693
+ ...this.allAnnouncedForOurProject(announced.running),
41694
+ ...this.allAnnouncedForOurProject(announced.unspoken)
41695
+ ].find((one) => one.pid === pid);
41696
+ if (game === undefined) {
41697
+ 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.`);
41698
+ }
41699
+ const read = game.announcedAt === undefined ? null : stillTheAnnouncedGame(pid, game.project.path, game.announcedAt);
41700
+ if (read === null) {
41701
+ 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.`);
41702
+ }
41703
+ const run = {
41704
+ pid,
41705
+ log: new GameLog,
41706
+ transcript: null,
41707
+ readOffset: 0,
41708
+ projectPath: game.project.path,
41709
+ startedAt: game.announcedAt ?? Date.now(),
41710
+ exitCode: null,
41711
+ exitSignal: null,
41712
+ throughEditor: false,
41713
+ brokeOn: null,
41714
+ announcedPid: pid
41715
+ };
41716
+ const children = andChildren ? await this.whatTheRunStarted(run) : null;
41717
+ let landed = true;
41718
+ try {
41719
+ process.kill(pid);
41720
+ } catch {
41721
+ landed = false;
41722
+ }
41723
+ const gone = await untilGone([pid]);
41724
+ const ended = children === null ? null : endChildrenAmong(children);
41725
+ if (gone) {
41726
+ await announcementEnded(pid);
41727
+ }
41728
+ return this.jsonTextResponse({
41729
+ stopped: true,
41730
+ through: "gdharness",
41731
+ endedPid: pid,
41732
+ ...childrenFields(ended),
41733
+ exitedBeforeStop: !landed,
41734
+ 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)}`
41735
+ });
41736
+ }
41481
41737
  async whatTheRunStarted(run) {
41482
41738
  const tree = await processTree();
41483
41739
  if (tree === undefined) {
@@ -42056,6 +42312,7 @@ var init_server3 = __esm(() => {
42056
42312
  init_issues();
42057
42313
  init_junit();
42058
42314
  init_lsp_client();
42315
+ init_outside();
42059
42316
  init_paths();
42060
42317
  init_ports();
42061
42318
  init_process_children();
@@ -42071,7 +42328,7 @@ var init_server3 = __esm(() => {
42071
42328
  init_tool_definitions();
42072
42329
  init_update_check();
42073
42330
  run7 = promisify7(execFile7);
42074
- __dirname2 = dirname16(fileURLToPath5(import.meta.url));
42331
+ __dirname2 = dirname17(fileURLToPath6(import.meta.url));
42075
42332
  CONSOLE_WRAPPER = /_console(\.exe)?$/i;
42076
42333
  LONGEST_BOOT_NOTED_MS = 5 * 60000;
42077
42334
  VALUE_NAMES = {
@@ -42113,7 +42370,7 @@ var init_server3 = __esm(() => {
42113
42370
 
42114
42371
  // src/cli.ts
42115
42372
  import { existsSync as existsSync16 } from "fs";
42116
- import { join as join24, resolve as resolve5 } from "path";
42373
+ import { join as join24, resolve as resolve6 } from "path";
42117
42374
 
42118
42375
  // src/bridge-announce.ts
42119
42376
  init_errors();
@@ -43742,6 +43999,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
43742
43999
  | Whether this engine has a method or property, and what it takes | \`editor_classes info\` with \`member\`, which answers that one member and which class declares it rather than the whole class |
43743
44000
  | Wait for a run to finish | \`editor_run wait\`, never a sleep or a shell loop on the pid: it answers as \`editor_output\` does, once the run is over |
43744
44001
  | End a bench and the workers it opened | \`editor_run stop\` with \`andChildren\`, never a process listing by scene name: it ends every process under the run's, to any depth, that is a game of the project, announced as one or the project's engine run with \`--path\` on it, names them under \`endedChildren\` with what identified each, and leaves and names any other |
44002
+ | End a game of the project that no server is holding | \`editor_run stop\` with its \`pid\` from \`editor_status\` \`runtimes\`, never a process kill by hand: it is ended only when it announced itself for the project and its command line is the project's engine run |
43745
44003
  | Play a game without touching the player's saves | \`editor_run start\` with \`savesIn\`, a directory for this run's \`user://\`: the editor plays with its own environment, so a run given one is started by the server, and the answer says under \`savesNote\` when the platform ignores it |
43746
44004
  | A picture, for a person who asked to see one | \`runtime_capture\` |
43747
44005
 
@@ -43935,7 +44193,7 @@ class UsageError extends Refusal2 {
43935
44193
  }
43936
44194
  function projectArgument(at) {
43937
44195
  const named = args.slice(at).find((value) => !value.startsWith("--"));
43938
- const projectPath = resolve5(named ?? ".");
44196
+ const projectPath = resolve6(named ?? ".");
43939
44197
  if (!existsSync16(join24(projectPath, "project.godot"))) {
43940
44198
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
43941
44199
  }