gdharness 0.13.40 → 0.13.42

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
@@ -15393,7 +15393,7 @@ var init_tool_definitions = __esm(() => {
15393
15393
  };
15394
15394
  RUNNING_PID = {
15395
15395
  type: "number",
15396
- description: "Which game, when several are running from one project, such as a bench and its workers: its process id, as editor_status lists under runtimes. Not needed with one game per project."
15396
+ description: "Which game, when several are running from one project, such as a bench and its workers: its process id, as editor_status lists under runtimes. Without it the game this server started or plays is the one asked when it is among them, and the answer says so under answeredBy; with one game per project it is not needed at all."
15397
15397
  };
15398
15398
  SCENE_PATH = {
15399
15399
  type: "string",
@@ -16011,6 +16011,11 @@ var init_tool_definitions = __esm(() => {
16011
16011
  type: "number",
16012
16012
  ops: ["check", "wait"],
16013
16013
  description: "check: how long to give the boot before it is called hung. Default 60000. wait: how long to wait for the run to end before answering anyway. Default 600000."
16014
+ },
16015
+ andChildren: {
16016
+ type: "boolean",
16017
+ ops: ["stop"],
16018
+ description: "stop: also end what the game started for itself, with OS.create_process or otherwise, such as a bench's workers. Only the children of the run's process that are announced as games of this project are ended, named under endedChildren; any other child is left and named under childrenLeft. Default false, and the run's process is ended either way."
16014
16019
  }
16015
16020
  },
16016
16021
  requires: [],
@@ -16020,7 +16025,7 @@ var init_tool_definitions = __esm(() => {
16020
16025
  requires: ["projectPath"]
16021
16026
  },
16022
16027
  stop: {
16023
- summary: "end the run and answer with what it printed last, naming the process ended under endedPid: a game that started processes of its own keeps them",
16028
+ summary: "end the run and answer with what it printed last, naming the process ended under endedPid: a game that started processes of its own keeps them unless andChildren says to end those too",
16024
16029
  requires: []
16025
16030
  },
16026
16031
  check: {
@@ -16061,7 +16066,7 @@ var init_tool_definitions = __esm(() => {
16061
16066
  },
16062
16067
  {
16063
16068
  name: "editor_status",
16064
- description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it.",
16069
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it.",
16065
16070
  parameters: {},
16066
16071
  requires: []
16067
16072
  },
@@ -36413,23 +36418,55 @@ var init_lsp_client = __esm(() => {
36413
36418
  init_ports();
36414
36419
  });
36415
36420
 
36416
- // src/process-time.ts
36421
+ // src/process-children.ts
36417
36422
  import { execFile as execFile5 } from "node:child_process";
36418
36423
  import { promisify as promisify5 } from "node:util";
36424
+ async function childrenOf(pid) {
36425
+ try {
36426
+ const printed = process.platform === "win32" ? await askWindows(pid) : await askPosix(pid);
36427
+ return printed.split(/\r?\n/).map((line) => Number(line.trim())).filter((child) => Number.isInteger(child) && child > 0);
36428
+ } catch (error) {
36429
+ if (process.platform !== "win32" && error.code === 1) {
36430
+ return [];
36431
+ }
36432
+ return;
36433
+ }
36434
+ }
36435
+ async function askWindows(pid) {
36436
+ const { stdout } = await run5("powershell.exe", [
36437
+ "-NoProfile",
36438
+ "-NonInteractive",
36439
+ "-Command",
36440
+ `(Get-CimInstance Win32_Process -Filter "ParentProcessId=${pid}").ProcessId`
36441
+ ], { timeout: ASK_TIMEOUT_MS, windowsHide: true });
36442
+ return stdout;
36443
+ }
36444
+ async function askPosix(pid) {
36445
+ const { stdout } = await run5("pgrep", ["-P", String(pid)], { timeout: ASK_TIMEOUT_MS });
36446
+ return stdout;
36447
+ }
36448
+ var run5, ASK_TIMEOUT_MS = 15000;
36449
+ var init_process_children = __esm(() => {
36450
+ run5 = promisify5(execFile5);
36451
+ });
36452
+
36453
+ // src/process-time.ts
36454
+ import { execFile as execFile6 } from "node:child_process";
36455
+ import { promisify as promisify6 } from "node:util";
36419
36456
  async function cpuSecondsOf(pid) {
36420
36457
  try {
36421
- return process.platform === "win32" ? await askWindows(pid) : await askPosix(pid);
36458
+ return process.platform === "win32" ? await askWindows2(pid) : await askPosix2(pid);
36422
36459
  } catch {
36423
36460
  return;
36424
36461
  }
36425
36462
  }
36426
- async function askWindows(pid) {
36427
- const { stdout } = await run5("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).CPU`], { timeout: ASK_TIMEOUT_MS, windowsHide: true });
36463
+ async function askWindows2(pid) {
36464
+ const { stdout } = await run6("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).CPU`], { timeout: ASK_TIMEOUT_MS2, windowsHide: true });
36428
36465
  const seconds = Number(stdout.trim().replace(",", "."));
36429
36466
  return Number.isFinite(seconds) ? seconds : undefined;
36430
36467
  }
36431
- async function askPosix(pid) {
36432
- const { stdout } = await run5("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS });
36468
+ async function askPosix2(pid) {
36469
+ const { stdout } = await run6("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS2 });
36433
36470
  return secondsFromClock(stdout.trim());
36434
36471
  }
36435
36472
  function secondsFromClock(printed) {
@@ -36447,9 +36484,9 @@ function secondsFromClock(printed) {
36447
36484
  const extra = days === null ? 0 : Number(days) * 86400;
36448
36485
  return Number.isFinite(extra) ? seconds + extra : undefined;
36449
36486
  }
36450
- var run5, ASK_TIMEOUT_MS = 2000;
36487
+ var run6, ASK_TIMEOUT_MS2 = 6000;
36451
36488
  var init_process_time = __esm(() => {
36452
- run5 = promisify5(execFile5);
36489
+ run6 = promisify6(execFile6);
36453
36490
  });
36454
36491
 
36455
36492
  // src/project-scan.ts
@@ -36676,7 +36713,18 @@ function parseAnnouncement(file, pid) {
36676
36713
  if (protocol !== RUNTIME_PROTOCOL) {
36677
36714
  return { kind: "unspoken", unspoken: { pid, protocol: protocol ?? 0, project: named } };
36678
36715
  }
36679
- return { kind: "runtime", endpoint: { pid, port, address, project: named, file } };
36716
+ const editorPid = readNumber(fields, "editor_pid");
36717
+ return {
36718
+ kind: "runtime",
36719
+ endpoint: {
36720
+ pid,
36721
+ port,
36722
+ address,
36723
+ project: named,
36724
+ file,
36725
+ ...editorPid !== undefined && Number.isInteger(editorPid) && editorPid > 0 ? { editorPid } : {}
36726
+ }
36727
+ };
36680
36728
  }
36681
36729
  function runtimesAnnounced(directories = runtimeDirectories()) {
36682
36730
  const running = new Map;
@@ -36750,7 +36798,7 @@ async function announcedSince(projectPath, before, waiting = {}) {
36750
36798
  const directories = waiting.directories ?? runtimeDirectories();
36751
36799
  const until = Date.now() + Math.max(waiting.budgetMs ?? ANNOUNCE_BUDGET_MS, 0);
36752
36800
  for (;; ) {
36753
- const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && resolve4(endpoint.project.path) === wanted);
36801
+ const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && resolve4(endpoint.project.path) === wanted && (waiting.accept?.(endpoint) ?? true));
36754
36802
  if (fresh !== undefined) {
36755
36803
  return fresh;
36756
36804
  }
@@ -37205,7 +37253,7 @@ var init_run_record = __esm(() => {
37205
37253
  });
37206
37254
 
37207
37255
  // src/server.ts
37208
- import { execFile as execFile6, spawn } from "node:child_process";
37256
+ import { execFile as execFile7, spawn } from "node:child_process";
37209
37257
  import { randomUUID as randomUUID2 } from "node:crypto";
37210
37258
  import {
37211
37259
  appendFileSync,
@@ -37223,7 +37271,7 @@ import { tmpdir as tmpdir5 } from "node:os";
37223
37271
  import { basename as basename3, dirname as dirname11, join as join19, normalize as normalize3, relative as relative3 } from "node:path";
37224
37272
  import { setTimeout as delay3 } from "node:timers/promises";
37225
37273
  import { fileURLToPath as fileURLToPath4 } from "node:url";
37226
- import { promisify as promisify6 } from "node:util";
37274
+ import { promisify as promisify7 } from "node:util";
37227
37275
  function patienceForFrames(frames, atLeast) {
37228
37276
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
37229
37277
  }
@@ -37274,6 +37322,18 @@ function endedPreviousRun(ended) {
37274
37322
  }
37275
37323
  return ended.pid ?? true;
37276
37324
  }
37325
+ function aboutTheChildren(ended, throughEditor) {
37326
+ if (ended === null) {
37327
+ return throughEditor ? "Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running; andChildren ends what it started that is announced as a game of this project." : "Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running; andChildren ends what it started that is announced as a game of this project.";
37328
+ }
37329
+ if (ended.unknown) {
37330
+ return "andChildren was asked for and this platform would not list what the game had started, so nothing else was ended.";
37331
+ }
37332
+ const counted = (n) => `${n} ${n === 1 ? "process" : "processes"}`;
37333
+ const endedToo = ended.ended.length === 0 ? "The game had started nothing announced as a game of this project, so nothing else was ended." : `${counted(ended.ended.length)} the game had started for itself, announced as games of this project, ${ended.ended.length === 1 ? "was" : "were"} ended with it, named under endedChildren.`;
37334
+ const leftToo = ended.left.length === 0 ? "" : ` ${counted(ended.left.length)} it had started ${ended.left.length === 1 ? "was" : "were"} left, named under childrenLeft: not announced as a game of this project, so what ${ended.left.length === 1 ? "it is" : "they are"} cannot be told from here.`;
37335
+ return `${endedToo}${leftToo}`;
37336
+ }
37277
37337
  function endedToStartThis(ended) {
37278
37338
  if (ended === null) {
37279
37339
  return "";
@@ -37425,6 +37485,16 @@ function hasMainScene(projectFile) {
37425
37485
  const scene = parseProjectGodot(readFileSync17(projectFile, "utf8"))["application"]?.["run/main_scene"];
37426
37486
  return typeof scene === "string" && scene !== "";
37427
37487
  }
37488
+ function runtimeAddonAnnouncesEditors(projectPath) {
37489
+ if (projectPath === null) {
37490
+ return false;
37491
+ }
37492
+ try {
37493
+ return readFileSync17(join19(projectPath, RUNTIME_AUTOLOAD2.path), "utf8").includes('"editor_pid"');
37494
+ } catch {
37495
+ return false;
37496
+ }
37497
+ }
37428
37498
  function editorPlaysHeadless(projectFile) {
37429
37499
  const runArgs = parseProjectGodot(readFileSync17(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
37430
37500
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
@@ -37963,7 +38033,7 @@ class GodotServer {
37963
38033
  return op === "restart" ? await this.handleRestartEditor() : await this.handleLaunchEditor(args);
37964
38034
  case "editor_run":
37965
38035
  if (op === "stop") {
37966
- return await this.handleStopProject();
38036
+ return await this.handleStopProject(args);
37967
38037
  }
37968
38038
  if (op === "wait") {
37969
38039
  return await this.handleWaitForRun(args);
@@ -38406,7 +38476,7 @@ class GodotServer {
38406
38476
  const log = new GameLog;
38407
38477
  let exitCode = 0;
38408
38478
  try {
38409
- const { stdout, stderr } = await run6(engine.value, exportArgs, { timeout: 300000 });
38479
+ const { stdout, stderr } = await run7(engine.value, exportArgs, { timeout: 300000 });
38410
38480
  log.append("stdout", stdout);
38411
38481
  log.append("stderr", stderr);
38412
38482
  } catch (error) {
@@ -38603,7 +38673,7 @@ class GodotServer {
38603
38673
  }
38604
38674
  async godotVersion(godotPath) {
38605
38675
  try {
38606
- const { stdout } = await run6(godotPath, ["--version"], { timeout: 1e4 });
38676
+ const { stdout } = await run7(godotPath, ["--version"], { timeout: 1e4 });
38607
38677
  this.lastGodotVersion = stdout.trim();
38608
38678
  return this.lastGodotVersion;
38609
38679
  } catch (error) {
@@ -38862,7 +38932,7 @@ class GodotServer {
38862
38932
  const located = await this.locator.find();
38863
38933
  const announced = runtimesAnnounced();
38864
38934
  const pinged = Promise.all(announced.running.map(async (endpoint) => {
38865
- const held = this.knownHoldOn(endpoint);
38935
+ const held = this.knownHoldOn(endpoint, announced.running);
38866
38936
  const reply = held === null ? await runtimeRequest(endpoint, "ping", {}, HOLD_PING_MS) : {
38867
38937
  ok: false,
38868
38938
  message: `The game is held by the editor's debugger, ${describeHalt(held)}, and answers no runtime call while it is. debug_control continue lets it go.`
@@ -38871,6 +38941,7 @@ class GodotServer {
38871
38941
  pid: endpoint.pid,
38872
38942
  port: endpoint.port,
38873
38943
  project: endpoint.project,
38944
+ editorPid: endpoint.editorPid,
38874
38945
  reachable: reply.ok,
38875
38946
  problem: reply.ok ? null : reply.message
38876
38947
  };
@@ -39166,6 +39237,7 @@ class GodotServer {
39166
39237
  let editorSaysGoing = true;
39167
39238
  const endpoint = await announcedSince(projectPath, before, {
39168
39239
  budgetMs,
39240
+ accept: (one) => this.isTheGameOf(this.currentRun(), one, projectPath),
39169
39241
  giveUp: () => {
39170
39242
  if (this.dapClient?.isStopped() === true) {
39171
39243
  return true;
@@ -39471,18 +39543,50 @@ class GodotServer {
39471
39543
  };
39472
39544
  this.activeProcess = picked;
39473
39545
  }
39474
- announcedPidOf(run) {
39546
+ announcedPidOf(run, announced) {
39475
39547
  if (!run.throughEditor || run.announcedPid !== undefined) {
39476
39548
  return run.announcedPid;
39477
39549
  }
39478
39550
  const before = run.announcedBefore ?? new Set;
39479
- const fresh = this.allAnnouncedForOurProject(runtimesAnnounced().running).filter((one) => !before.has(one.pid));
39480
- const theOne = fresh.length === 1 ? fresh[0] : undefined;
39551
+ const fresh = this.allAnnouncedForOurProject(announced ?? runtimesAnnounced().running).filter((one) => !before.has(one.pid));
39552
+ const candidates = fresh.filter((one) => this.playedByOurEditor(one, run.projectPath));
39553
+ const theOne = candidates.length === 1 ? candidates[0] : undefined;
39481
39554
  if (theOne !== undefined) {
39482
39555
  run.announcedPid = theOne.pid;
39483
39556
  }
39484
39557
  return run.announcedPid;
39485
39558
  }
39559
+ ownGameAmong(running, projectPath) {
39560
+ const run = this.currentRun();
39561
+ if (run === null || !stillRunning(run)) {
39562
+ return;
39563
+ }
39564
+ if (projectPath !== undefined && run.projectPath !== null && !isSameDirectory(run.projectPath, projectPath)) {
39565
+ return;
39566
+ }
39567
+ const its = run.throughEditor ? this.announcedPidOf(run, running) : run.pid;
39568
+ return its !== null && its !== undefined && running.some((one) => one.pid === its) ? its : undefined;
39569
+ }
39570
+ playedByOurEditor(endpoint, projectPath) {
39571
+ const editor = this.godotBridge.getStatus().editorPid;
39572
+ if (editor === undefined) {
39573
+ return true;
39574
+ }
39575
+ if (endpoint.editorPid !== undefined) {
39576
+ return endpoint.editorPid === editor;
39577
+ }
39578
+ return !runtimeAddonAnnouncesEditors(projectPath ?? this.ownProject);
39579
+ }
39580
+ isTheGameOf(run, endpoint, projectPath) {
39581
+ if (run === null) {
39582
+ return false;
39583
+ }
39584
+ if (run.throughEditor) {
39585
+ return this.playedByOurEditor(endpoint, projectPath);
39586
+ }
39587
+ const editor = this.godotBridge.getStatus().editorPid;
39588
+ return endpoint.editorPid === undefined || endpoint.editorPid !== editor;
39589
+ }
39486
39590
  adoptRecordedRun() {
39487
39591
  const record = readRunRecord();
39488
39592
  if (record === null || !this.couldBeOurs(record.projectPath)) {
@@ -39626,6 +39730,9 @@ class GodotServer {
39626
39730
  if (announced !== undefined && !alive(announced)) {
39627
39731
  return false;
39628
39732
  }
39733
+ if (announced !== undefined && editorSays === undefined) {
39734
+ return stillRunning(run);
39735
+ }
39629
39736
  return runIsUp(run, editorSays === undefined ? (await this.editorPlayingState())?.playing ?? null : editorSays);
39630
39737
  }
39631
39738
  async handleGetDebugOutput(args, waitedMs) {
@@ -39699,7 +39806,7 @@ class GodotServer {
39699
39806
  entries: forAnswer(selected.entries)
39700
39807
  });
39701
39808
  }
39702
- async handleStopProject() {
39809
+ async handleStopProject(args) {
39703
39810
  await this.pickUpWhatTheEditorIsPlaying();
39704
39811
  const stopped = this.currentRun();
39705
39812
  if (!stopped) {
@@ -39708,21 +39815,48 @@ class GodotServer {
39708
39815
  this.drainEditorOutput(stopped);
39709
39816
  this.drainTranscript(stopped);
39710
39817
  const wasRunning = await this.runStillGoing(stopped);
39818
+ const endedPid = stopped.pid ?? this.announcedPidOf(stopped) ?? null;
39819
+ const children = readBoolean(args, "andChildren") === true && wasRunning && endedPid !== null ? await childrenOf(endedPid) : null;
39711
39820
  this.logDebug("Stopping the running game");
39712
39821
  await this.endActiveGame("editor_run stop");
39822
+ const ended = children === null ? null : this.endChildrenAmong(children, stopped.projectPath);
39713
39823
  return this.jsonTextResponse({
39714
39824
  stopped: true,
39715
39825
  through: stopped.throughEditor ? "editor" : "gdharness",
39716
- endedPid: stopped.pid ?? this.announcedPidOf(stopped) ?? null,
39826
+ endedPid,
39827
+ ...ended === null ? {} : {
39828
+ endedChildren: ended.ended,
39829
+ childrenLeft: ended.left.length === 0 ? undefined : ended.left,
39830
+ childrenUnknown: ended.unknown ? true : undefined
39831
+ },
39717
39832
  exitedBeforeStop: stopped.exitCode !== null || !wasRunning,
39718
39833
  exitCode: stopped.exitCode,
39719
39834
  errors: stopped.log.count("error"),
39720
39835
  warnings: stopped.log.count("warning"),
39721
39836
  clean: stopped.log.count("error") === 0,
39722
- 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 ? "The editor was asked to stop the scene it is playing. Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running." : "The process named under endedPid was ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running.",
39837
+ 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 ? "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."} ${aboutTheChildren(ended, stopped.throughEditor)}`,
39723
39838
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
39724
39839
  });
39725
39840
  }
39841
+ endChildrenAmong(children, projectPath) {
39842
+ if (children === undefined) {
39843
+ return { ended: [], left: [], unknown: true };
39844
+ }
39845
+ const games = new Set(runtimesAnnounced().running.filter((one) => projectPath === null || isSameDirectory(one.project.path, projectPath)).map((one) => one.pid));
39846
+ const ended = [];
39847
+ const left = [];
39848
+ for (const child of children) {
39849
+ if (!games.has(child)) {
39850
+ left.push(child);
39851
+ continue;
39852
+ }
39853
+ try {
39854
+ process.kill(child);
39855
+ ended.push(child);
39856
+ } catch {}
39857
+ }
39858
+ return { ended, left, unknown: false };
39859
+ }
39726
39860
  async handleRescanFilesystem(args) {
39727
39861
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 30000;
39728
39862
  const started = Date.now();
@@ -39870,13 +40004,13 @@ class GodotServer {
39870
40004
  const override = Number.parseInt(envValue("GDHARNESS_RUNTIME_TIMEOUT_MS") ?? "", 10);
39871
40005
  return Number.isInteger(override) && override > 0 ? override : 1e4;
39872
40006
  }
39873
- knownHoldOn(endpoint) {
40007
+ knownHoldOn(endpoint, announced) {
39874
40008
  const run = this.activeProcess;
39875
40009
  const session = this.dapClient;
39876
40010
  if (run === null || !run.throughEditor || !stillRunning(run) || session === null) {
39877
40011
  return null;
39878
40012
  }
39879
- if (this.announcedPidOf(run) !== endpoint.pid || !session.holdIsKnown()) {
40013
+ if (this.announcedPidOf(run, announced) !== endpoint.pid || !session.holdIsKnown()) {
39880
40014
  return null;
39881
40015
  }
39882
40016
  return session.whereItStopped();
@@ -39917,11 +40051,16 @@ class GodotServer {
39917
40051
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
39918
40052
  const { op: _op, projectPath, pid, ...params } = asParams(args);
39919
40053
  const announced = runtimesAnnounced();
39920
- const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken, typeof pid === "number" ? pid : undefined);
40054
+ const wanted = typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined;
40055
+ const own = typeof pid === "number" ? undefined : this.ownGameAmong(announced.running, wanted);
40056
+ const choice = chooseRuntime(announced.running, wanted, announced.unspoken, typeof pid === "number" ? pid : own);
39921
40057
  if ("problem" in choice) {
39922
40058
  return this.createErrorResponse(choice.problem);
39923
40059
  }
39924
- const held = this.knownHoldOn(choice.endpoint);
40060
+ const ofProject = announced.running.filter((one) => wanted === undefined || isSameDirectory(one.project.path, wanted));
40061
+ const chosenAmongSeveral = own !== undefined && ofProject.length > 1;
40062
+ const answeredBy = chosenAmongSeveral ? { answeredBy: own } : {};
40063
+ const held = this.knownHoldOn(choice.endpoint, announced.running);
39925
40064
  if (held !== null) {
39926
40065
  return this.createErrorResponse(`The game (pid ${choice.endpoint.pid}) is held by the editor's debugger, ${describeHalt(held)}, and answers no runtime call while it is.`, [
39927
40066
  "debug_control continue lets it go, after which this call answers",
@@ -39939,7 +40078,7 @@ class GodotServer {
39939
40078
  }
39940
40079
  const { id: _id, ...payload } = reply.payload;
39941
40080
  if (!expectsScreenshot) {
39942
- return this.jsonTextResponse(payload);
40081
+ return this.jsonTextResponse({ ...payload, ...answeredBy });
39943
40082
  }
39944
40083
  const returnedPath = readString(payload, "path");
39945
40084
  if (readString(payload, "type") !== "screenshot_file" || !returnedPath || !screenshotPath) {
@@ -39951,7 +40090,10 @@ class GodotServer {
39951
40090
  const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
39952
40091
  return {
39953
40092
  content: [
39954
- { type: "text", text: `Screenshot captured: ${dimensions}` },
40093
+ {
40094
+ type: "text",
40095
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}`
40096
+ },
39955
40097
  { type: "image", data: readFileSync17(screenshotPath).toString("base64"), mimeType: "image/png" }
39956
40098
  ]
39957
40099
  };
@@ -40018,7 +40160,7 @@ async function runGodotServer() {
40018
40160
  const server = new GodotServer;
40019
40161
  await server.run();
40020
40162
  }
40021
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run6, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CACHE_WRITE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
40163
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CACHE_WRITE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
40022
40164
  var init_server3 = __esm(() => {
40023
40165
  init_mcp();
40024
40166
  init_stdio2();
@@ -40039,6 +40181,7 @@ var init_server3 = __esm(() => {
40039
40181
  init_lsp_client();
40040
40182
  init_paths();
40041
40183
  init_ports();
40184
+ init_process_children();
40042
40185
  init_process_time();
40043
40186
  init_project_scan();
40044
40187
  init_resources();
@@ -40050,7 +40193,7 @@ var init_server3 = __esm(() => {
40050
40193
  init_setup();
40051
40194
  init_tool_definitions();
40052
40195
  init_update_check();
40053
- run6 = promisify6(execFile6);
40196
+ run7 = promisify7(execFile7);
40054
40197
  __dirname2 = dirname11(fileURLToPath4(import.meta.url));
40055
40198
  VALUE_NAMES = {
40056
40199
  string: "a string",
@@ -41561,8 +41704,10 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
41561
41704
 
41562
41705
  - ${projectPathSentence()}. The \`runtime_*\` and \`debug_*\` ones pick between running
41563
41706
  games instead, by \`projectPath\`, or by \`pid\` when several run from one project, as a bench
41564
- and its workers do; the others answer about the editor this server is connected to. There is no
41565
- ambient project, and an argument a tool does not declare is refused rather than ignored.
41707
+ and its workers do: without \`pid\` the game this server started or plays is the one asked, and
41708
+ the answer says so under \`answeredBy\`; the others answer about the editor this server is
41709
+ connected to. There is no ambient project, and an argument a tool does not declare is refused
41710
+ rather than ignored.
41566
41711
  - \`editor_status\` says whether an editor is connected and whether its addon matches the server.
41567
41712
  \`addonIsStale\` means they differ, and \`staleNote\` says which half is behind: an editor that
41568
41713
  needs restarting, or a server that needs reconnecting in your harness. Every answer that came
@@ -41607,6 +41752,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
41607
41752
  | Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
41608
41753
  | Wait for something in a running game | \`runtime_wait\`, never a sleep; \`until\` with \`says\` for a panel that rebuilds its own labels |
41609
41754
  | 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 |
41755
+ | End a bench and the workers it opened | \`editor_run stop\` with \`andChildren\`, never a process listing by scene name: it ends the children of the run's process that are announced as games of the project, names them under \`endedChildren\`, and leaves and names any other child |
41610
41756
  | A picture, for a person who asked to see one | \`runtime_capture\` |
41611
41757
 
41612
41758
  These need the runtime autoload, which \`gdharness setup\` registers. \`runtime_capture\` needs a
@@ -43,6 +43,12 @@ const DAP_ASKED: String = "GDHARNESS_DAP_PORT"
43
43
  ## What a server that opened this editor says about itself, so that being opened by one is stated
44
44
  ## rather than guessed at from the ports. Kept in step with `OPENED_BY_A_SERVER` in src/launch.ts.
45
45
  const OPENED_BY_A_SERVER: String = "GDHARNESS_OPENED_BY_A_SERVER"
46
+ ## Put into this editor's own environment as it loads, so every game it plays inherits it and
47
+ ## the runtime addon can announce which editor played it. A game of the same project that some
48
+ ## other server started has no such variable, or another editor's number, and that is what
49
+ ## tells the two apart when both are announced at once. Kept in step with `EDITOR_PID_VARIABLE`
50
+ ## in the runtime autoload.
51
+ const EDITOR_PID_VARIABLE: String = "GDHARNESS_EDITOR_PID"
46
52
 
47
53
  ## How long one attempt is given before the address is called a bad one.
48
54
  ##
@@ -93,6 +99,7 @@ func _ready() -> void:
93
99
  _project_path = ProjectSettings.globalize_path("res://")
94
100
  version_at_load = _loaded_version()
95
101
  _keep_breakpoints_through_sessions()
102
+ OS.set_environment(EDITOR_PID_VARIABLE, str(OS.get_process_id()))
96
103
 
97
104
  _reconnect_timer = Timer.new()
98
105
  _reconnect_timer.one_shot = true
@@ -30,6 +30,12 @@ const SCRIPT_FLAGS: PackedStringArray = ["-s", "--script"]
30
30
  ## Serve a script run anyway, for somebody driving a `-s` script rather than a game.
31
31
  const SCRIPT_RUNS_SETTING: String = "gdharness/runtime/serve_script_runs"
32
32
 
33
+ ## The variable the editor addon puts into its own environment, which every game the editor plays
34
+ ## inherits: it says which editor played this game, so a server can tell the editor's game from
35
+ ## another of the same project that some other server started. Kept in step with
36
+ ## `EDITOR_PID_VARIABLE` in the editor addon's bridge client.
37
+ const EDITOR_PID_VARIABLE: String = "GDHARNESS_EDITOR_PID"
38
+
33
39
  var values: Values = Values.new()
34
40
 
35
41
  # The modules are members and not locals of _init, because a Callable holds its object by id
@@ -250,7 +256,7 @@ func _announce(bind_address: String) -> void:
250
256
  ## What the announcement file and the welcome both carry: enough to pick this game out of
251
257
  ## several and to know whether the server speaks its protocol.
252
258
  func _identity(bind_address: String) -> Dictionary:
253
- return {
259
+ var identity: Dictionary = {
254
260
  "protocol": PROTOCOL,
255
261
  "pid": OS.get_process_id(),
256
262
  "port": _port,
@@ -262,6 +268,10 @@ func _identity(bind_address: String) -> Dictionary:
262
268
  },
263
269
  "godot": Engine.get_version_info().get("string", ""),
264
270
  }
271
+ var played_by: String = OS.get_environment(EDITOR_PID_VARIABLE)
272
+ if played_by.is_valid_int():
273
+ identity["editor_pid"] = played_by.to_int()
274
+ return identity
265
275
 
266
276
 
267
277
  func _send_welcome(client: StreamPeerTCP) -> void:
package/build/index.js CHANGED
@@ -10168,7 +10168,7 @@ function defectReport(where, error, context = {}) {
10168
10168
  }
10169
10169
 
10170
10170
  // src/server.ts
10171
- import { execFile as execFile4, spawn } from "node:child_process";
10171
+ import { execFile as execFile5, spawn } from "node:child_process";
10172
10172
  import { randomUUID as randomUUID2 } from "node:crypto";
10173
10173
  import {
10174
10174
  appendFileSync,
@@ -10186,7 +10186,7 @@ import { tmpdir as tmpdir4 } from "node:os";
10186
10186
  import { basename as basename2, dirname as dirname6, join as join13, normalize as normalize2, relative as relative3 } from "node:path";
10187
10187
  import { setTimeout as delay3 } from "node:timers/promises";
10188
10188
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10189
- import { promisify as promisify4 } from "node:util";
10189
+ import { promisify as promisify5 } from "node:util";
10190
10190
 
10191
10191
  // node_modules/zod/v3/helpers/util.js
10192
10192
  var util;
@@ -27709,25 +27709,55 @@ async function handleLSPTool(client, toolName, args) {
27709
27709
  }
27710
27710
  }
27711
27711
 
27712
- // src/process-time.ts
27712
+ // src/process-children.ts
27713
27713
  import { execFile as execFile3 } from "node:child_process";
27714
27714
  import { promisify as promisify3 } from "node:util";
27715
27715
  var run3 = promisify3(execFile3);
27716
- var ASK_TIMEOUT_MS = 2000;
27716
+ var ASK_TIMEOUT_MS = 15000;
27717
+ async function childrenOf(pid) {
27718
+ try {
27719
+ const printed = process.platform === "win32" ? await askWindows(pid) : await askPosix(pid);
27720
+ return printed.split(/\r?\n/).map((line) => Number(line.trim())).filter((child) => Number.isInteger(child) && child > 0);
27721
+ } catch (error) {
27722
+ if (process.platform !== "win32" && error.code === 1) {
27723
+ return [];
27724
+ }
27725
+ return;
27726
+ }
27727
+ }
27728
+ async function askWindows(pid) {
27729
+ const { stdout } = await run3("powershell.exe", [
27730
+ "-NoProfile",
27731
+ "-NonInteractive",
27732
+ "-Command",
27733
+ `(Get-CimInstance Win32_Process -Filter "ParentProcessId=${pid}").ProcessId`
27734
+ ], { timeout: ASK_TIMEOUT_MS, windowsHide: true });
27735
+ return stdout;
27736
+ }
27737
+ async function askPosix(pid) {
27738
+ const { stdout } = await run3("pgrep", ["-P", String(pid)], { timeout: ASK_TIMEOUT_MS });
27739
+ return stdout;
27740
+ }
27741
+
27742
+ // src/process-time.ts
27743
+ import { execFile as execFile4 } from "node:child_process";
27744
+ import { promisify as promisify4 } from "node:util";
27745
+ var run4 = promisify4(execFile4);
27746
+ var ASK_TIMEOUT_MS2 = 6000;
27717
27747
  async function cpuSecondsOf(pid) {
27718
27748
  try {
27719
- return process.platform === "win32" ? await askWindows(pid) : await askPosix(pid);
27749
+ return process.platform === "win32" ? await askWindows2(pid) : await askPosix2(pid);
27720
27750
  } catch {
27721
27751
  return;
27722
27752
  }
27723
27753
  }
27724
- async function askWindows(pid) {
27725
- const { stdout } = await run3("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).CPU`], { timeout: ASK_TIMEOUT_MS, windowsHide: true });
27754
+ async function askWindows2(pid) {
27755
+ const { stdout } = await run4("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).CPU`], { timeout: ASK_TIMEOUT_MS2, windowsHide: true });
27726
27756
  const seconds = Number(stdout.trim().replace(",", "."));
27727
27757
  return Number.isFinite(seconds) ? seconds : undefined;
27728
27758
  }
27729
- async function askPosix(pid) {
27730
- const { stdout } = await run3("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS });
27759
+ async function askPosix2(pid) {
27760
+ const { stdout } = await run4("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS2 });
27731
27761
  return secondsFromClock(stdout.trim());
27732
27762
  }
27733
27763
  function secondsFromClock(printed) {
@@ -28270,7 +28300,18 @@ function parseAnnouncement(file, pid) {
28270
28300
  if (protocol !== RUNTIME_PROTOCOL) {
28271
28301
  return { kind: "unspoken", unspoken: { pid, protocol: protocol ?? 0, project: named } };
28272
28302
  }
28273
- return { kind: "runtime", endpoint: { pid, port, address, project: named, file } };
28303
+ const editorPid = readNumber(fields, "editor_pid");
28304
+ return {
28305
+ kind: "runtime",
28306
+ endpoint: {
28307
+ pid,
28308
+ port,
28309
+ address,
28310
+ project: named,
28311
+ file,
28312
+ ...editorPid !== undefined && Number.isInteger(editorPid) && editorPid > 0 ? { editorPid } : {}
28313
+ }
28314
+ };
28274
28315
  }
28275
28316
  function runtimesAnnounced(directories = runtimeDirectories()) {
28276
28317
  const running = new Map;
@@ -28349,7 +28390,7 @@ async function announcedSince(projectPath, before, waiting = {}) {
28349
28390
  const directories = waiting.directories ?? runtimeDirectories();
28350
28391
  const until = Date.now() + Math.max(waiting.budgetMs ?? ANNOUNCE_BUDGET_MS, 0);
28351
28392
  for (;; ) {
28352
- const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && resolve4(endpoint.project.path) === wanted);
28393
+ const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && resolve4(endpoint.project.path) === wanted && (waiting.accept?.(endpoint) ?? true));
28353
28394
  if (fresh !== undefined) {
28354
28395
  return fresh;
28355
28396
  }
@@ -28837,7 +28878,7 @@ var RUNNING_PROJECT_PATH = {
28837
28878
  };
28838
28879
  var RUNNING_PID = {
28839
28880
  type: "number",
28840
- description: "Which game, when several are running from one project, such as a bench and its workers: its process id, as editor_status lists under runtimes. Not needed with one game per project."
28881
+ description: "Which game, when several are running from one project, such as a bench and its workers: its process id, as editor_status lists under runtimes. Without it the game this server started or plays is the one asked when it is among them, and the answer says so under answeredBy; with one game per project it is not needed at all."
28841
28882
  };
28842
28883
  var SCENE_PATH = {
28843
28884
  type: "string",
@@ -29455,6 +29496,11 @@ var TOOL_SPECS = [
29455
29496
  type: "number",
29456
29497
  ops: ["check", "wait"],
29457
29498
  description: "check: how long to give the boot before it is called hung. Default 60000. wait: how long to wait for the run to end before answering anyway. Default 600000."
29499
+ },
29500
+ andChildren: {
29501
+ type: "boolean",
29502
+ ops: ["stop"],
29503
+ description: "stop: also end what the game started for itself, with OS.create_process or otherwise, such as a bench's workers. Only the children of the run's process that are announced as games of this project are ended, named under endedChildren; any other child is left and named under childrenLeft. Default false, and the run's process is ended either way."
29458
29504
  }
29459
29505
  },
29460
29506
  requires: [],
@@ -29464,7 +29510,7 @@ var TOOL_SPECS = [
29464
29510
  requires: ["projectPath"]
29465
29511
  },
29466
29512
  stop: {
29467
- summary: "end the run and answer with what it printed last, naming the process ended under endedPid: a game that started processes of its own keeps them",
29513
+ summary: "end the run and answer with what it printed last, naming the process ended under endedPid: a game that started processes of its own keeps them unless andChildren says to end those too",
29468
29514
  requires: []
29469
29515
  },
29470
29516
  check: {
@@ -29505,7 +29551,7 @@ var TOOL_SPECS = [
29505
29551
  },
29506
29552
  {
29507
29553
  name: "editor_status",
29508
- description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it.",
29554
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it.",
29509
29555
  parameters: {},
29510
29556
  requires: []
29511
29557
  },
@@ -29926,7 +29972,7 @@ function buildToolDefinitions() {
29926
29972
  // src/server.ts
29927
29973
  var UPDATE_NOTICE_EVERY = 500;
29928
29974
  var FEEDBACK_NOTICE_EVERY = 250;
29929
- var run4 = promisify4(execFile4);
29975
+ var run5 = promisify5(execFile5);
29930
29976
  var __dirname2 = dirname6(fileURLToPath2(import.meta.url));
29931
29977
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
29932
29978
  var HOLD_PING_MS = 2000;
@@ -29981,6 +30027,18 @@ function endedPreviousRun(ended) {
29981
30027
  }
29982
30028
  return ended.pid ?? true;
29983
30029
  }
30030
+ function aboutTheChildren(ended, throughEditor) {
30031
+ if (ended === null) {
30032
+ return throughEditor ? "Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running; andChildren ends what it started that is announced as a game of this project." : "Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running; andChildren ends what it started that is announced as a game of this project.";
30033
+ }
30034
+ if (ended.unknown) {
30035
+ return "andChildren was asked for and this platform would not list what the game had started, so nothing else was ended.";
30036
+ }
30037
+ const counted = (n) => `${n} ${n === 1 ? "process" : "processes"}`;
30038
+ const endedToo = ended.ended.length === 0 ? "The game had started nothing announced as a game of this project, so nothing else was ended." : `${counted(ended.ended.length)} the game had started for itself, announced as games of this project, ${ended.ended.length === 1 ? "was" : "were"} ended with it, named under endedChildren.`;
30039
+ const leftToo = ended.left.length === 0 ? "" : ` ${counted(ended.left.length)} it had started ${ended.left.length === 1 ? "was" : "were"} left, named under childrenLeft: not announced as a game of this project, so what ${ended.left.length === 1 ? "it is" : "they are"} cannot be told from here.`;
30040
+ return `${endedToo}${leftToo}`;
30041
+ }
29984
30042
  function endedToStartThis(ended) {
29985
30043
  if (ended === null) {
29986
30044
  return "";
@@ -30174,6 +30232,16 @@ function hasMainScene(projectFile) {
30174
30232
  const scene = parseProjectGodot(readFileSync13(projectFile, "utf8"))["application"]?.["run/main_scene"];
30175
30233
  return typeof scene === "string" && scene !== "";
30176
30234
  }
30235
+ function runtimeAddonAnnouncesEditors(projectPath) {
30236
+ if (projectPath === null) {
30237
+ return false;
30238
+ }
30239
+ try {
30240
+ return readFileSync13(join13(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
30241
+ } catch {
30242
+ return false;
30243
+ }
30244
+ }
30177
30245
  function editorPlaysHeadless(projectFile) {
30178
30246
  const runArgs = parseProjectGodot(readFileSync13(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
30179
30247
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
@@ -30712,7 +30780,7 @@ class GodotServer {
30712
30780
  return op === "restart" ? await this.handleRestartEditor() : await this.handleLaunchEditor(args);
30713
30781
  case "editor_run":
30714
30782
  if (op === "stop") {
30715
- return await this.handleStopProject();
30783
+ return await this.handleStopProject(args);
30716
30784
  }
30717
30785
  if (op === "wait") {
30718
30786
  return await this.handleWaitForRun(args);
@@ -31155,7 +31223,7 @@ class GodotServer {
31155
31223
  const log = new GameLog;
31156
31224
  let exitCode = 0;
31157
31225
  try {
31158
- const { stdout, stderr } = await run4(engine.value, exportArgs, { timeout: 300000 });
31226
+ const { stdout, stderr } = await run5(engine.value, exportArgs, { timeout: 300000 });
31159
31227
  log.append("stdout", stdout);
31160
31228
  log.append("stderr", stderr);
31161
31229
  } catch (error) {
@@ -31352,7 +31420,7 @@ class GodotServer {
31352
31420
  }
31353
31421
  async godotVersion(godotPath) {
31354
31422
  try {
31355
- const { stdout } = await run4(godotPath, ["--version"], { timeout: 1e4 });
31423
+ const { stdout } = await run5(godotPath, ["--version"], { timeout: 1e4 });
31356
31424
  this.lastGodotVersion = stdout.trim();
31357
31425
  return this.lastGodotVersion;
31358
31426
  } catch (error) {
@@ -31611,7 +31679,7 @@ class GodotServer {
31611
31679
  const located = await this.locator.find();
31612
31680
  const announced = runtimesAnnounced();
31613
31681
  const pinged = Promise.all(announced.running.map(async (endpoint) => {
31614
- const held = this.knownHoldOn(endpoint);
31682
+ const held = this.knownHoldOn(endpoint, announced.running);
31615
31683
  const reply = held === null ? await runtimeRequest(endpoint, "ping", {}, HOLD_PING_MS) : {
31616
31684
  ok: false,
31617
31685
  message: `The game is held by the editor's debugger, ${describeHalt(held)}, and answers no runtime call while it is. debug_control continue lets it go.`
@@ -31620,6 +31688,7 @@ class GodotServer {
31620
31688
  pid: endpoint.pid,
31621
31689
  port: endpoint.port,
31622
31690
  project: endpoint.project,
31691
+ editorPid: endpoint.editorPid,
31623
31692
  reachable: reply.ok,
31624
31693
  problem: reply.ok ? null : reply.message
31625
31694
  };
@@ -31915,6 +31984,7 @@ class GodotServer {
31915
31984
  let editorSaysGoing = true;
31916
31985
  const endpoint = await announcedSince(projectPath, before, {
31917
31986
  budgetMs,
31987
+ accept: (one) => this.isTheGameOf(this.currentRun(), one, projectPath),
31918
31988
  giveUp: () => {
31919
31989
  if (this.dapClient?.isStopped() === true) {
31920
31990
  return true;
@@ -32220,18 +32290,50 @@ class GodotServer {
32220
32290
  };
32221
32291
  this.activeProcess = picked;
32222
32292
  }
32223
- announcedPidOf(run) {
32293
+ announcedPidOf(run, announced) {
32224
32294
  if (!run.throughEditor || run.announcedPid !== undefined) {
32225
32295
  return run.announcedPid;
32226
32296
  }
32227
32297
  const before = run.announcedBefore ?? new Set;
32228
- const fresh = this.allAnnouncedForOurProject(runtimesAnnounced().running).filter((one) => !before.has(one.pid));
32229
- const theOne = fresh.length === 1 ? fresh[0] : undefined;
32298
+ const fresh = this.allAnnouncedForOurProject(announced ?? runtimesAnnounced().running).filter((one) => !before.has(one.pid));
32299
+ const candidates = fresh.filter((one) => this.playedByOurEditor(one, run.projectPath));
32300
+ const theOne = candidates.length === 1 ? candidates[0] : undefined;
32230
32301
  if (theOne !== undefined) {
32231
32302
  run.announcedPid = theOne.pid;
32232
32303
  }
32233
32304
  return run.announcedPid;
32234
32305
  }
32306
+ ownGameAmong(running, projectPath) {
32307
+ const run = this.currentRun();
32308
+ if (run === null || !stillRunning(run)) {
32309
+ return;
32310
+ }
32311
+ if (projectPath !== undefined && run.projectPath !== null && !isSameDirectory(run.projectPath, projectPath)) {
32312
+ return;
32313
+ }
32314
+ const its = run.throughEditor ? this.announcedPidOf(run, running) : run.pid;
32315
+ return its !== null && its !== undefined && running.some((one) => one.pid === its) ? its : undefined;
32316
+ }
32317
+ playedByOurEditor(endpoint, projectPath) {
32318
+ const editor = this.godotBridge.getStatus().editorPid;
32319
+ if (editor === undefined) {
32320
+ return true;
32321
+ }
32322
+ if (endpoint.editorPid !== undefined) {
32323
+ return endpoint.editorPid === editor;
32324
+ }
32325
+ return !runtimeAddonAnnouncesEditors(projectPath ?? this.ownProject);
32326
+ }
32327
+ isTheGameOf(run, endpoint, projectPath) {
32328
+ if (run === null) {
32329
+ return false;
32330
+ }
32331
+ if (run.throughEditor) {
32332
+ return this.playedByOurEditor(endpoint, projectPath);
32333
+ }
32334
+ const editor = this.godotBridge.getStatus().editorPid;
32335
+ return endpoint.editorPid === undefined || endpoint.editorPid !== editor;
32336
+ }
32235
32337
  adoptRecordedRun() {
32236
32338
  const record = readRunRecord();
32237
32339
  if (record === null || !this.couldBeOurs(record.projectPath)) {
@@ -32375,6 +32477,9 @@ class GodotServer {
32375
32477
  if (announced !== undefined && !alive(announced)) {
32376
32478
  return false;
32377
32479
  }
32480
+ if (announced !== undefined && editorSays === undefined) {
32481
+ return stillRunning(run);
32482
+ }
32378
32483
  return runIsUp(run, editorSays === undefined ? (await this.editorPlayingState())?.playing ?? null : editorSays);
32379
32484
  }
32380
32485
  async handleGetDebugOutput(args, waitedMs) {
@@ -32448,7 +32553,7 @@ class GodotServer {
32448
32553
  entries: forAnswer(selected.entries)
32449
32554
  });
32450
32555
  }
32451
- async handleStopProject() {
32556
+ async handleStopProject(args) {
32452
32557
  await this.pickUpWhatTheEditorIsPlaying();
32453
32558
  const stopped = this.currentRun();
32454
32559
  if (!stopped) {
@@ -32457,21 +32562,48 @@ class GodotServer {
32457
32562
  this.drainEditorOutput(stopped);
32458
32563
  this.drainTranscript(stopped);
32459
32564
  const wasRunning = await this.runStillGoing(stopped);
32565
+ const endedPid = stopped.pid ?? this.announcedPidOf(stopped) ?? null;
32566
+ const children = readBoolean(args, "andChildren") === true && wasRunning && endedPid !== null ? await childrenOf(endedPid) : null;
32460
32567
  this.logDebug("Stopping the running game");
32461
32568
  await this.endActiveGame("editor_run stop");
32569
+ const ended = children === null ? null : this.endChildrenAmong(children, stopped.projectPath);
32462
32570
  return this.jsonTextResponse({
32463
32571
  stopped: true,
32464
32572
  through: stopped.throughEditor ? "editor" : "gdharness",
32465
- endedPid: stopped.pid ?? this.announcedPidOf(stopped) ?? null,
32573
+ endedPid,
32574
+ ...ended === null ? {} : {
32575
+ endedChildren: ended.ended,
32576
+ childrenLeft: ended.left.length === 0 ? undefined : ended.left,
32577
+ childrenUnknown: ended.unknown ? true : undefined
32578
+ },
32466
32579
  exitedBeforeStop: stopped.exitCode !== null || !wasRunning,
32467
32580
  exitCode: stopped.exitCode,
32468
32581
  errors: stopped.log.count("error"),
32469
32582
  warnings: stopped.log.count("warning"),
32470
32583
  clean: stopped.log.count("error") === 0,
32471
- 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 ? "The editor was asked to stop the scene it is playing. Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running." : "The process named under endedPid was ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running.",
32584
+ 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 ? "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."} ${aboutTheChildren(ended, stopped.throughEditor)}`,
32472
32585
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
32473
32586
  });
32474
32587
  }
32588
+ endChildrenAmong(children, projectPath) {
32589
+ if (children === undefined) {
32590
+ return { ended: [], left: [], unknown: true };
32591
+ }
32592
+ const games = new Set(runtimesAnnounced().running.filter((one) => projectPath === null || isSameDirectory(one.project.path, projectPath)).map((one) => one.pid));
32593
+ const ended = [];
32594
+ const left = [];
32595
+ for (const child of children) {
32596
+ if (!games.has(child)) {
32597
+ left.push(child);
32598
+ continue;
32599
+ }
32600
+ try {
32601
+ process.kill(child);
32602
+ ended.push(child);
32603
+ } catch {}
32604
+ }
32605
+ return { ended, left, unknown: false };
32606
+ }
32475
32607
  async handleRescanFilesystem(args) {
32476
32608
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 30000;
32477
32609
  const started = Date.now();
@@ -32619,13 +32751,13 @@ class GodotServer {
32619
32751
  const override = Number.parseInt(envValue("GDHARNESS_RUNTIME_TIMEOUT_MS") ?? "", 10);
32620
32752
  return Number.isInteger(override) && override > 0 ? override : 1e4;
32621
32753
  }
32622
- knownHoldOn(endpoint) {
32754
+ knownHoldOn(endpoint, announced) {
32623
32755
  const run = this.activeProcess;
32624
32756
  const session = this.dapClient;
32625
32757
  if (run === null || !run.throughEditor || !stillRunning(run) || session === null) {
32626
32758
  return null;
32627
32759
  }
32628
- if (this.announcedPidOf(run) !== endpoint.pid || !session.holdIsKnown()) {
32760
+ if (this.announcedPidOf(run, announced) !== endpoint.pid || !session.holdIsKnown()) {
32629
32761
  return null;
32630
32762
  }
32631
32763
  return session.whereItStopped();
@@ -32666,11 +32798,16 @@ class GodotServer {
32666
32798
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
32667
32799
  const { op: _op, projectPath, pid, ...params } = asParams(args);
32668
32800
  const announced = runtimesAnnounced();
32669
- const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken, typeof pid === "number" ? pid : undefined);
32801
+ const wanted = typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined;
32802
+ const own = typeof pid === "number" ? undefined : this.ownGameAmong(announced.running, wanted);
32803
+ const choice = chooseRuntime(announced.running, wanted, announced.unspoken, typeof pid === "number" ? pid : own);
32670
32804
  if ("problem" in choice) {
32671
32805
  return this.createErrorResponse(choice.problem);
32672
32806
  }
32673
- const held = this.knownHoldOn(choice.endpoint);
32807
+ const ofProject = announced.running.filter((one) => wanted === undefined || isSameDirectory(one.project.path, wanted));
32808
+ const chosenAmongSeveral = own !== undefined && ofProject.length > 1;
32809
+ const answeredBy = chosenAmongSeveral ? { answeredBy: own } : {};
32810
+ const held = this.knownHoldOn(choice.endpoint, announced.running);
32674
32811
  if (held !== null) {
32675
32812
  return this.createErrorResponse(`The game (pid ${choice.endpoint.pid}) is held by the editor's debugger, ${describeHalt(held)}, and answers no runtime call while it is.`, [
32676
32813
  "debug_control continue lets it go, after which this call answers",
@@ -32688,7 +32825,7 @@ class GodotServer {
32688
32825
  }
32689
32826
  const { id: _id, ...payload } = reply.payload;
32690
32827
  if (!expectsScreenshot) {
32691
- return this.jsonTextResponse(payload);
32828
+ return this.jsonTextResponse({ ...payload, ...answeredBy });
32692
32829
  }
32693
32830
  const returnedPath = readString(payload, "path");
32694
32831
  if (readString(payload, "type") !== "screenshot_file" || !returnedPath || !screenshotPath) {
@@ -32700,7 +32837,10 @@ class GodotServer {
32700
32837
  const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
32701
32838
  return {
32702
32839
  content: [
32703
- { type: "text", text: `Screenshot captured: ${dimensions}` },
32840
+ {
32841
+ type: "text",
32842
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}`
32843
+ },
32704
32844
  { type: "image", data: readFileSync13(screenshotPath).toString("base64"), mimeType: "image/png" }
32705
32845
  ]
32706
32846
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.13.40",
3
+ "version": "0.13.42",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",