gdharness 0.13.34 → 0.13.36

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.
Files changed (3) hide show
  1. package/build/cli.js +120 -22
  2. package/build/index.js +113 -20
  3. package/package.json +1 -1
package/build/cli.js CHANGED
@@ -15994,7 +15994,7 @@ var init_tool_definitions = __esm(() => {
15994
15994
  },
15995
15995
  {
15996
15996
  name: "editor_output",
15997
- description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: running is asked of the operating system, endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over.",
15997
+ description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering.",
15998
15998
  parameters: {
15999
15999
  severity: {
16000
16000
  type: "string",
@@ -16022,7 +16022,7 @@ var init_tool_definitions = __esm(() => {
16022
16022
  },
16023
16023
  {
16024
16024
  name: "editor_status",
16025
- 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. 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. 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.',
16025
+ 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. 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. 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.',
16026
16026
  parameters: {},
16027
16027
  requires: []
16028
16028
  },
@@ -36662,23 +36662,32 @@ function recordAt(path) {
36662
36662
  }
36663
36663
  function runningAs(pid) {
36664
36664
  if (process.platform === "win32") {
36665
- const line = windowsCommandLine(pid);
36666
- if (line !== null) {
36667
- return { kind: "commandLine", text: line };
36665
+ const answer = windowsCommandLine(pid);
36666
+ if (answer !== null) {
36667
+ const [, ...rest] = answer.split(`
36668
+ `);
36669
+ const line = rest.join(`
36670
+ `).trim();
36671
+ const began = startedAt(pid, answer);
36672
+ if (line !== "") {
36673
+ return { kind: "commandLine", text: line, ...began === null ? {} : { startedAt: began } };
36674
+ }
36668
36675
  }
36669
36676
  const image = windowsImage(pid);
36670
36677
  return image === null ? null : { kind: "image", text: image };
36671
36678
  }
36672
36679
  try {
36680
+ const began = startedAt(pid, null);
36681
+ const when = began === null ? {} : { startedAt: began };
36673
36682
  if (process.platform === "linux") {
36674
36683
  const raw = readFileSync14(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
36675
- return raw === "" ? null : { kind: "commandLine", text: raw };
36684
+ return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
36676
36685
  }
36677
36686
  const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
36678
36687
  encoding: "utf8",
36679
36688
  timeout: 15000
36680
36689
  }).trim();
36681
- return args === "" ? null : { kind: "commandLine", text: args };
36690
+ return args === "" ? null : { kind: "commandLine", text: args, ...when };
36682
36691
  } catch {
36683
36692
  return null;
36684
36693
  }
@@ -36717,13 +36726,30 @@ function windowsCommandLine(pid) {
36717
36726
  "-NoProfile",
36718
36727
  "-NonInteractive",
36719
36728
  "-Command",
36720
- `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`
36729
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("o"); $p.CommandLine }`
36721
36730
  ], { encoding: "utf8", timeout: 15000, windowsHide: true }).trim();
36722
36731
  return answer === "" ? null : answer;
36723
36732
  } catch {
36724
36733
  return null;
36725
36734
  }
36726
36735
  }
36736
+ function startedAt(pid, windowsAnswer) {
36737
+ if (process.platform === "win32") {
36738
+ const when = Date.parse(windowsAnswer?.split(`
36739
+ `)[0]?.trim() ?? "");
36740
+ return Number.isNaN(when) ? null : when;
36741
+ }
36742
+ try {
36743
+ const said = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
36744
+ encoding: "utf8",
36745
+ timeout: 15000
36746
+ }).trim();
36747
+ const when = Date.parse(said);
36748
+ return said === "" || Number.isNaN(when) ? null : when;
36749
+ } catch {
36750
+ return null;
36751
+ }
36752
+ }
36727
36753
  function windowsImage(pid) {
36728
36754
  try {
36729
36755
  const csv = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
@@ -36746,12 +36772,18 @@ function judgeRun(record, running, needed) {
36746
36772
  if (running === null) {
36747
36773
  return false;
36748
36774
  }
36775
+ if (running.startedAt !== undefined && running.startedAt - record.startedAt > SAME_RUN_WINDOW_MS) {
36776
+ return false;
36777
+ }
36749
36778
  const engine = record.command === undefined ? null : basename2(record.command);
36750
36779
  const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
36751
36780
  const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
36752
36781
  if (wanted !== null && !said.includes(wanted)) {
36753
36782
  return false;
36754
36783
  }
36784
+ if (running.kind === "commandLine" && AN_EDITOR.test(said)) {
36785
+ return false;
36786
+ }
36755
36787
  if (record.projectPath === "") {
36756
36788
  return wanted !== null;
36757
36789
  }
@@ -36801,8 +36833,10 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
36801
36833
  } catch {}
36802
36834
  }
36803
36835
  }
36836
+ var AN_EDITOR, SAME_RUN_WINDOW_MS = 1e4;
36804
36837
  var init_run_record = __esm(() => {
36805
36838
  init_runtime_client();
36839
+ AN_EDITOR = /(?:^|\s)(?:-e|--editor)(?:\s|$)/;
36806
36840
  });
36807
36841
 
36808
36842
  // src/server.ts
@@ -36835,12 +36869,18 @@ function stillRunning(run) {
36835
36869
  if (!run.throughEditor && run.process === null && run.pid !== null) {
36836
36870
  return alive(run.pid);
36837
36871
  }
36872
+ if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
36873
+ return false;
36874
+ }
36838
36875
  return true;
36839
36876
  }
36840
36877
  function runIsUp(run, editorSays) {
36841
36878
  if (run?.throughEditor !== true) {
36842
36879
  return stillRunning(run);
36843
36880
  }
36881
+ if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
36882
+ return false;
36883
+ }
36844
36884
  return editorSays ?? stillRunning(run);
36845
36885
  }
36846
36886
  function runtimeVerdict(endpoint, after) {
@@ -38229,7 +38269,10 @@ class GodotServer {
38229
38269
  return `The debug adapter on port ${port} belongs to process ${holder}, and the editor on this server's bridge is process ${ours}. Godot gives every editor the same debug adapter port by default, so the one this editor names in its settings is not necessarily the one it holds. Playing the game now would put another editor's console and another project's debugger under this project's name.`;
38230
38270
  }
38231
38271
  anEditorIsStillComing(listeningSince) {
38232
- return anEditorIsStillComing(listeningSince, this.launchedEditor !== null && alive(this.launchedEditor));
38272
+ return anEditorIsStillComing(listeningSince, this.launchedEditorIsUp());
38273
+ }
38274
+ launchedEditorIsUp() {
38275
+ return this.launchedEditor?.exitCode === null && this.launchedEditor.signalCode === null;
38233
38276
  }
38234
38277
  dap() {
38235
38278
  const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
@@ -38275,7 +38318,7 @@ class GodotServer {
38275
38318
  addonIsStale: status.connected ? stale : undefined,
38276
38319
  bridgeAvailable: this.bridgeStartupError === null,
38277
38320
  mayYetConnect: status.connected ? undefined : this.anEditorIsStillComing(status.listeningSince),
38278
- awaitingLaunchedEditor: status.connected || this.launchedEditor === null || !alive(this.launchedEditor) ? undefined : this.launchedEditor,
38321
+ awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
38279
38322
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
38280
38323
  startupError: this.bridgeStartupError,
38281
38324
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
@@ -38311,7 +38354,8 @@ class GodotServer {
38311
38354
  }
38312
38355
  async handleEditorStatus() {
38313
38356
  const godotPath = await this.locator.find();
38314
- const games = await Promise.all(discoverRuntimes().map(async (endpoint) => {
38357
+ const announced = runtimesAnnounced();
38358
+ const games = await Promise.all(announced.running.map(async (endpoint) => {
38315
38359
  const reply = await runtimeRequest(endpoint, "ping", {}, this.runtimeTimeoutMs());
38316
38360
  return {
38317
38361
  pid: endpoint.pid,
@@ -38333,10 +38377,17 @@ class GodotServer {
38333
38377
  version: godotPath === null ? null : await this.godotVersion(godotPath)
38334
38378
  },
38335
38379
  game: {
38336
- processActive: stillRunning(this.currentRun()),
38380
+ processActive: runIsUp(this.currentRun(), playing === null ? null : playing.playing),
38337
38381
  playingInEditor: playing,
38338
38382
  runtimeConnected: games.some((game) => game.reachable),
38339
- runtimes: games
38383
+ runtimes: games,
38384
+ unreadable: announced.unspoken.map((game) => ({
38385
+ pid: game.pid,
38386
+ project: game.project,
38387
+ protocol: game.protocol,
38388
+ serverSpeaks: RUNTIME_PROTOCOL,
38389
+ behind: game.protocol > RUNTIME_PROTOCOL ? "server" : "addon"
38390
+ }))
38340
38391
  }
38341
38392
  });
38342
38393
  }
@@ -38486,7 +38537,7 @@ class GodotServer {
38486
38537
  return { pid: null, error: started };
38487
38538
  }
38488
38539
  editor.unref();
38489
- this.launchedEditor = editor.pid ?? null;
38540
+ this.launchedEditor = editor.pid === undefined ? null : editor;
38490
38541
  return { pid: editor.pid ?? null, error: null };
38491
38542
  }
38492
38543
  async portsForAnEditor() {
@@ -38533,6 +38584,7 @@ class GodotServer {
38533
38584
  await this.endActiveGame("editor_run start, which ends the run that was going before it starts another");
38534
38585
  }
38535
38586
  const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
38587
+ const wereHereFirst = this.everyRuntimeOfOurs();
38536
38588
  const headless = resolveHeadless(args["headless"], {
38537
38589
  platform: process.platform,
38538
38590
  variables: process.env
@@ -38553,16 +38605,20 @@ class GodotServer {
38553
38605
  this.activeProcess = started;
38554
38606
  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." : "";
38555
38607
  const endedForThis = ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`;
38608
+ const stranded = wereHereFirst.filter((one) => one.pid !== ended && alive(one.pid));
38609
+ const named = stranded.map((one) => one.protocol === null ? `pid ${one.pid}` : `pid ${one.pid} on protocol ${one.protocol}`).join(", ");
38610
+ 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.`;
38556
38611
  return this.jsonTextResponse({
38557
38612
  started: true,
38558
38613
  through: "gdharness",
38614
+ alsoRunning: stranded.length === 0 ? undefined : stranded.map((one) => one.pid),
38559
38615
  pid: started.process.pid ?? null,
38560
38616
  arguments: cmdArgs,
38561
38617
  transcript: started.transcript,
38562
38618
  refreshedClasses: refreshed.value,
38563
38619
  endedPreviousRun: ended ?? undefined,
38564
38620
  runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
38565
- message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}`
38621
+ message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}${alsoRunning}`
38566
38622
  });
38567
38623
  }
38568
38624
  async gameIsUp() {
@@ -38578,6 +38634,10 @@ class GodotServer {
38578
38634
  budgetMs,
38579
38635
  giveUp: () => this.dapClient?.isStopped() === true || !stillRunning(this.currentRun())
38580
38636
  });
38637
+ const going = this.currentRun();
38638
+ if (endpoint !== null && going?.throughEditor === true && going.announcedPid === undefined) {
38639
+ going.announcedPid = endpoint.pid;
38640
+ }
38581
38641
  return runtimeVerdict(endpoint, {
38582
38642
  addon: true,
38583
38643
  budgetMs,
@@ -38687,9 +38747,15 @@ class GodotServer {
38687
38747
  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.`);
38688
38748
  return;
38689
38749
  }
38750
+ const signalled = runningAs(running.pid);
38751
+ const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
38752
+ let landed = true;
38690
38753
  try {
38691
38754
  process.kill(running.pid);
38692
- } catch {}
38755
+ } catch {
38756
+ landed = false;
38757
+ }
38758
+ 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}.`);
38693
38759
  }
38694
38760
  spawnGame(godotPath, cmdArgs, env) {
38695
38761
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -38878,7 +38944,12 @@ class GodotServer {
38878
38944
  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." : "";
38879
38945
  const live = this.aRuntimeOfOursIsAnswering();
38880
38946
  if (live !== null) {
38881
- 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." + " Do not start another: editor_run start replaces the game that is playing rather than" + " adding one, so it would end this one." + (this.godotBridge.getStatus().connected ? "" : " editor_status says whether an editor is on its way, and editor_run can end it once one is.");
38947
+ 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.");
38948
+ }
38949
+ const unreadable = this.aRuntimeOfOursIsTooFarOff();
38950
+ if (unreadable !== null) {
38951
+ const behind = unreadable.protocol > RUNTIME_PROTOCOL ? "this server is the older half: reconnect it so it spawns the installed version" : "the addon is the older half: reinstall it and restart the game";
38952
+ 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.";
38882
38953
  }
38883
38954
  const record = readRunRecord();
38884
38955
  if (record === null) {
@@ -38890,12 +38961,34 @@ class GodotServer {
38890
38961
  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.`;
38891
38962
  }
38892
38963
  aRuntimeOfOursIsAnswering() {
38964
+ return this.announcedForOurProject(discoverRuntimes());
38965
+ }
38966
+ aRuntimeOfOursIsTooFarOff() {
38967
+ return this.announcedForOurProject(runtimesAnnounced().unspoken);
38968
+ }
38969
+ announcedForOurProject(announced) {
38970
+ return this.allAnnouncedForOurProject(announced)[0] ?? null;
38971
+ }
38972
+ allAnnouncedForOurProject(announced) {
38893
38973
  const status = this.godotBridge.getStatus();
38894
38974
  const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
38895
38975
  if (mine === null) {
38896
- return null;
38976
+ return [];
38897
38977
  }
38898
- return discoverRuntimes().find((one) => one.project.path !== "" && isSameDirectory(mine, one.project.path)) ?? null;
38978
+ return announced.filter((one) => one.project.path !== "" && isSameDirectory(mine, one.project.path));
38979
+ }
38980
+ everyRuntimeOfOurs() {
38981
+ const announced = runtimesAnnounced();
38982
+ return [
38983
+ ...this.allAnnouncedForOurProject(announced.running).map((one) => ({
38984
+ pid: one.pid,
38985
+ protocol: null
38986
+ })),
38987
+ ...this.allAnnouncedForOurProject(announced.unspoken).map((one) => ({
38988
+ pid: one.pid,
38989
+ protocol: one.protocol
38990
+ }))
38991
+ ];
38899
38992
  }
38900
38993
  couldBeOurs(project) {
38901
38994
  const status = this.godotBridge.getStatus();
@@ -39000,7 +39093,7 @@ class GodotServer {
39000
39093
  notes.push(run.exitCode === 0 ? "This run quit on its own, cleanly: exit code 0." : "Nothing here ended this run: it stopped on its own or something outside this server stopped it.");
39001
39094
  }
39002
39095
  if (run.transcript !== null && selected.omitted > 0) {
39003
- notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written. Read it for output, not for whether the run is alive: running above is asked of the operating system, and a transcript that stops growing is a run between prints.`);
39096
+ notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written. Read it for output, not for whether the run is alive: a transcript that stops growing is a run between prints. running above is asked of the operating system while there is a process to ask about, and for an editor-played run that announced no runtime it is the editor's answer instead, which can lag after the game has gone.`);
39004
39097
  }
39005
39098
  return this.jsonTextResponse({
39006
39099
  running: stillRunning(run),
@@ -40796,8 +40889,13 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
40796
40889
  with their backtraces, and a \`clean\` verdict, so a run that printed an error is one call away
40797
40890
  from being known. For a long run it also names a transcript file: read that for output and this
40798
40891
  answer for state. Never decide a run has ended by watching the file stop growing, or a process
40799
- list, or a timeout. \`running\` is asked of the operating system and \`endedBy\` says whether
40800
- gdharness ended it; a bench between prints looks exactly like a bench that died to everything else.
40892
+ list, or a timeout; a bench between prints looks exactly like a bench that died to everything else.
40893
+ \`endedBy\` says whether gdharness ended it, and \`running\` is asked of the operating system
40894
+ whenever there is a process to ask about: every run started through \`editor_run\`, and every
40895
+ editor-played run whose game announced its runtime. An editor-played run in a project with no
40896
+ runtime addon announces nothing, so there is no process id and \`running\` is the editor's answer,
40897
+ which can lag by seconds after the game has gone. \`editor_status\` names the game under
40898
+ \`runtimes\`, which does not depend on the editor answering.
40801
40899
  - One server, one editor. A second project is a second harness session with its own server, and
40802
40900
  that works: each editor is opened on its own language server and debug adapter ports, and says
40803
40901
  where it serves. Two editors on one project is the thing to refuse.
package/build/index.js CHANGED
@@ -28256,23 +28256,32 @@ function recordAt(path) {
28256
28256
  }
28257
28257
  function runningAs(pid) {
28258
28258
  if (process.platform === "win32") {
28259
- const line = windowsCommandLine(pid);
28260
- if (line !== null) {
28261
- return { kind: "commandLine", text: line };
28259
+ const answer = windowsCommandLine(pid);
28260
+ if (answer !== null) {
28261
+ const [, ...rest] = answer.split(`
28262
+ `);
28263
+ const line = rest.join(`
28264
+ `).trim();
28265
+ const began = startedAt(pid, answer);
28266
+ if (line !== "") {
28267
+ return { kind: "commandLine", text: line, ...began === null ? {} : { startedAt: began } };
28268
+ }
28262
28269
  }
28263
28270
  const image = windowsImage(pid);
28264
28271
  return image === null ? null : { kind: "image", text: image };
28265
28272
  }
28266
28273
  try {
28274
+ const began = startedAt(pid, null);
28275
+ const when = began === null ? {} : { startedAt: began };
28267
28276
  if (process.platform === "linux") {
28268
28277
  const raw = readFileSync9(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
28269
- return raw === "" ? null : { kind: "commandLine", text: raw };
28278
+ return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
28270
28279
  }
28271
28280
  const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
28272
28281
  encoding: "utf8",
28273
28282
  timeout: 15000
28274
28283
  }).trim();
28275
- return args === "" ? null : { kind: "commandLine", text: args };
28284
+ return args === "" ? null : { kind: "commandLine", text: args, ...when };
28276
28285
  } catch {
28277
28286
  return null;
28278
28287
  }
@@ -28311,13 +28320,30 @@ function windowsCommandLine(pid) {
28311
28320
  "-NoProfile",
28312
28321
  "-NonInteractive",
28313
28322
  "-Command",
28314
- `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`
28323
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("o"); $p.CommandLine }`
28315
28324
  ], { encoding: "utf8", timeout: 15000, windowsHide: true }).trim();
28316
28325
  return answer === "" ? null : answer;
28317
28326
  } catch {
28318
28327
  return null;
28319
28328
  }
28320
28329
  }
28330
+ function startedAt(pid, windowsAnswer) {
28331
+ if (process.platform === "win32") {
28332
+ const when = Date.parse(windowsAnswer?.split(`
28333
+ `)[0]?.trim() ?? "");
28334
+ return Number.isNaN(when) ? null : when;
28335
+ }
28336
+ try {
28337
+ const said = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
28338
+ encoding: "utf8",
28339
+ timeout: 15000
28340
+ }).trim();
28341
+ const when = Date.parse(said);
28342
+ return said === "" || Number.isNaN(when) ? null : when;
28343
+ } catch {
28344
+ return null;
28345
+ }
28346
+ }
28321
28347
  function windowsImage(pid) {
28322
28348
  try {
28323
28349
  const csv = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
@@ -28336,16 +28362,24 @@ function stillTheRecordedRun(record) {
28336
28362
  function couldStillBeTheRecordedRun(record) {
28337
28363
  return judgeRun(record, runningAs(record.pid), "possible");
28338
28364
  }
28365
+ var AN_EDITOR = /(?:^|\s)(?:-e|--editor)(?:\s|$)/;
28366
+ var SAME_RUN_WINDOW_MS = 1e4;
28339
28367
  function judgeRun(record, running, needed) {
28340
28368
  if (running === null) {
28341
28369
  return false;
28342
28370
  }
28371
+ if (running.startedAt !== undefined && running.startedAt - record.startedAt > SAME_RUN_WINDOW_MS) {
28372
+ return false;
28373
+ }
28343
28374
  const engine = record.command === undefined ? null : basename(record.command);
28344
28375
  const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
28345
28376
  const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
28346
28377
  if (wanted !== null && !said.includes(wanted)) {
28347
28378
  return false;
28348
28379
  }
28380
+ if (running.kind === "commandLine" && AN_EDITOR.test(said)) {
28381
+ return false;
28382
+ }
28349
28383
  if (record.projectPath === "") {
28350
28384
  return wanted !== null;
28351
28385
  }
@@ -29076,7 +29110,7 @@ var TOOL_SPECS = [
29076
29110
  },
29077
29111
  {
29078
29112
  name: "editor_output",
29079
- description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: running is asked of the operating system, endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over.",
29113
+ description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering.",
29080
29114
  parameters: {
29081
29115
  severity: {
29082
29116
  type: "string",
@@ -29104,7 +29138,7 @@ var TOOL_SPECS = [
29104
29138
  },
29105
29139
  {
29106
29140
  name: "editor_status",
29107
- 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. 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. 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.',
29141
+ 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. 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. 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.',
29108
29142
  parameters: {},
29109
29143
  requires: []
29110
29144
  },
@@ -29534,12 +29568,18 @@ function stillRunning(run) {
29534
29568
  if (!run.throughEditor && run.process === null && run.pid !== null) {
29535
29569
  return alive(run.pid);
29536
29570
  }
29571
+ if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
29572
+ return false;
29573
+ }
29537
29574
  return true;
29538
29575
  }
29539
29576
  function runIsUp(run, editorSays) {
29540
29577
  if (run?.throughEditor !== true) {
29541
29578
  return stillRunning(run);
29542
29579
  }
29580
+ if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
29581
+ return false;
29582
+ }
29543
29583
  return editorSays ?? stillRunning(run);
29544
29584
  }
29545
29585
  function runtimeVerdict(endpoint, after) {
@@ -30970,7 +31010,10 @@ class GodotServer {
30970
31010
  return `The debug adapter on port ${port} belongs to process ${holder}, and the editor on this server's bridge is process ${ours}. Godot gives every editor the same debug adapter port by default, so the one this editor names in its settings is not necessarily the one it holds. Playing the game now would put another editor's console and another project's debugger under this project's name.`;
30971
31011
  }
30972
31012
  anEditorIsStillComing(listeningSince) {
30973
- return anEditorIsStillComing(listeningSince, this.launchedEditor !== null && alive(this.launchedEditor));
31013
+ return anEditorIsStillComing(listeningSince, this.launchedEditorIsUp());
31014
+ }
31015
+ launchedEditorIsUp() {
31016
+ return this.launchedEditor?.exitCode === null && this.launchedEditor.signalCode === null;
30974
31017
  }
30975
31018
  dap() {
30976
31019
  const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
@@ -31016,7 +31059,7 @@ class GodotServer {
31016
31059
  addonIsStale: status.connected ? stale : undefined,
31017
31060
  bridgeAvailable: this.bridgeStartupError === null,
31018
31061
  mayYetConnect: status.connected ? undefined : this.anEditorIsStillComing(status.listeningSince),
31019
- awaitingLaunchedEditor: status.connected || this.launchedEditor === null || !alive(this.launchedEditor) ? undefined : this.launchedEditor,
31062
+ awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
31020
31063
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
31021
31064
  startupError: this.bridgeStartupError,
31022
31065
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
@@ -31052,7 +31095,8 @@ class GodotServer {
31052
31095
  }
31053
31096
  async handleEditorStatus() {
31054
31097
  const godotPath = await this.locator.find();
31055
- const games = await Promise.all(discoverRuntimes().map(async (endpoint) => {
31098
+ const announced = runtimesAnnounced();
31099
+ const games = await Promise.all(announced.running.map(async (endpoint) => {
31056
31100
  const reply = await runtimeRequest(endpoint, "ping", {}, this.runtimeTimeoutMs());
31057
31101
  return {
31058
31102
  pid: endpoint.pid,
@@ -31074,10 +31118,17 @@ class GodotServer {
31074
31118
  version: godotPath === null ? null : await this.godotVersion(godotPath)
31075
31119
  },
31076
31120
  game: {
31077
- processActive: stillRunning(this.currentRun()),
31121
+ processActive: runIsUp(this.currentRun(), playing === null ? null : playing.playing),
31078
31122
  playingInEditor: playing,
31079
31123
  runtimeConnected: games.some((game) => game.reachable),
31080
- runtimes: games
31124
+ runtimes: games,
31125
+ unreadable: announced.unspoken.map((game) => ({
31126
+ pid: game.pid,
31127
+ project: game.project,
31128
+ protocol: game.protocol,
31129
+ serverSpeaks: RUNTIME_PROTOCOL,
31130
+ behind: game.protocol > RUNTIME_PROTOCOL ? "server" : "addon"
31131
+ }))
31081
31132
  }
31082
31133
  });
31083
31134
  }
@@ -31227,7 +31278,7 @@ class GodotServer {
31227
31278
  return { pid: null, error: started };
31228
31279
  }
31229
31280
  editor.unref();
31230
- this.launchedEditor = editor.pid ?? null;
31281
+ this.launchedEditor = editor.pid === undefined ? null : editor;
31231
31282
  return { pid: editor.pid ?? null, error: null };
31232
31283
  }
31233
31284
  async portsForAnEditor() {
@@ -31274,6 +31325,7 @@ class GodotServer {
31274
31325
  await this.endActiveGame("editor_run start, which ends the run that was going before it starts another");
31275
31326
  }
31276
31327
  const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
31328
+ const wereHereFirst = this.everyRuntimeOfOurs();
31277
31329
  const headless = resolveHeadless(args["headless"], {
31278
31330
  platform: process.platform,
31279
31331
  variables: process.env
@@ -31294,16 +31346,20 @@ class GodotServer {
31294
31346
  this.activeProcess = started;
31295
31347
  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." : "";
31296
31348
  const endedForThis = ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`;
31349
+ const stranded = wereHereFirst.filter((one) => one.pid !== ended && alive(one.pid));
31350
+ const named = stranded.map((one) => one.protocol === null ? `pid ${one.pid}` : `pid ${one.pid} on protocol ${one.protocol}`).join(", ");
31351
+ 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.`;
31297
31352
  return this.jsonTextResponse({
31298
31353
  started: true,
31299
31354
  through: "gdharness",
31355
+ alsoRunning: stranded.length === 0 ? undefined : stranded.map((one) => one.pid),
31300
31356
  pid: started.process.pid ?? null,
31301
31357
  arguments: cmdArgs,
31302
31358
  transcript: started.transcript,
31303
31359
  refreshedClasses: refreshed.value,
31304
31360
  endedPreviousRun: ended ?? undefined,
31305
31361
  runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
31306
- message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}`
31362
+ message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}${alsoRunning}`
31307
31363
  });
31308
31364
  }
31309
31365
  async gameIsUp() {
@@ -31319,6 +31375,10 @@ class GodotServer {
31319
31375
  budgetMs,
31320
31376
  giveUp: () => this.dapClient?.isStopped() === true || !stillRunning(this.currentRun())
31321
31377
  });
31378
+ const going = this.currentRun();
31379
+ if (endpoint !== null && going?.throughEditor === true && going.announcedPid === undefined) {
31380
+ going.announcedPid = endpoint.pid;
31381
+ }
31322
31382
  return runtimeVerdict(endpoint, {
31323
31383
  addon: true,
31324
31384
  budgetMs,
@@ -31428,9 +31488,15 @@ class GodotServer {
31428
31488
  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.`);
31429
31489
  return;
31430
31490
  }
31491
+ const signalled = runningAs(running.pid);
31492
+ const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
31493
+ let landed = true;
31431
31494
  try {
31432
31495
  process.kill(running.pid);
31433
- } catch {}
31496
+ } catch {
31497
+ landed = false;
31498
+ }
31499
+ 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}.`);
31434
31500
  }
31435
31501
  spawnGame(godotPath, cmdArgs, env) {
31436
31502
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -31619,7 +31685,12 @@ class GodotServer {
31619
31685
  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." : "";
31620
31686
  const live = this.aRuntimeOfOursIsAnswering();
31621
31687
  if (live !== null) {
31622
- 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." + " Do not start another: editor_run start replaces the game that is playing rather than" + " adding one, so it would end this one." + (this.godotBridge.getStatus().connected ? "" : " editor_status says whether an editor is on its way, and editor_run can end it once one is.");
31688
+ 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.");
31689
+ }
31690
+ const unreadable = this.aRuntimeOfOursIsTooFarOff();
31691
+ if (unreadable !== null) {
31692
+ const behind = unreadable.protocol > RUNTIME_PROTOCOL ? "this server is the older half: reconnect it so it spawns the installed version" : "the addon is the older half: reinstall it and restart the game";
31693
+ 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.";
31623
31694
  }
31624
31695
  const record = readRunRecord();
31625
31696
  if (record === null) {
@@ -31631,12 +31702,34 @@ class GodotServer {
31631
31702
  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.`;
31632
31703
  }
31633
31704
  aRuntimeOfOursIsAnswering() {
31705
+ return this.announcedForOurProject(discoverRuntimes());
31706
+ }
31707
+ aRuntimeOfOursIsTooFarOff() {
31708
+ return this.announcedForOurProject(runtimesAnnounced().unspoken);
31709
+ }
31710
+ announcedForOurProject(announced) {
31711
+ return this.allAnnouncedForOurProject(announced)[0] ?? null;
31712
+ }
31713
+ allAnnouncedForOurProject(announced) {
31634
31714
  const status = this.godotBridge.getStatus();
31635
31715
  const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
31636
31716
  if (mine === null) {
31637
- return null;
31717
+ return [];
31638
31718
  }
31639
- return discoverRuntimes().find((one) => one.project.path !== "" && isSameDirectory(mine, one.project.path)) ?? null;
31719
+ return announced.filter((one) => one.project.path !== "" && isSameDirectory(mine, one.project.path));
31720
+ }
31721
+ everyRuntimeOfOurs() {
31722
+ const announced = runtimesAnnounced();
31723
+ return [
31724
+ ...this.allAnnouncedForOurProject(announced.running).map((one) => ({
31725
+ pid: one.pid,
31726
+ protocol: null
31727
+ })),
31728
+ ...this.allAnnouncedForOurProject(announced.unspoken).map((one) => ({
31729
+ pid: one.pid,
31730
+ protocol: one.protocol
31731
+ }))
31732
+ ];
31640
31733
  }
31641
31734
  couldBeOurs(project) {
31642
31735
  const status = this.godotBridge.getStatus();
@@ -31741,7 +31834,7 @@ class GodotServer {
31741
31834
  notes.push(run.exitCode === 0 ? "This run quit on its own, cleanly: exit code 0." : "Nothing here ended this run: it stopped on its own or something outside this server stopped it.");
31742
31835
  }
31743
31836
  if (run.transcript !== null && selected.omitted > 0) {
31744
- notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written. Read it for output, not for whether the run is alive: running above is asked of the operating system, and a transcript that stops growing is a run between prints.`);
31837
+ notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written. Read it for output, not for whether the run is alive: a transcript that stops growing is a run between prints. running above is asked of the operating system while there is a process to ask about, and for an editor-played run that announced no runtime it is the editor's answer instead, which can lag after the game has gone.`);
31745
31838
  }
31746
31839
  return this.jsonTextResponse({
31747
31840
  running: stillRunning(run),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.13.34",
3
+ "version": "0.13.36",
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",