gdharness 1.0.22 → 1.0.24

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
@@ -16369,7 +16369,7 @@ var init_tool_definitions = __esm(() => {
16369
16369
  },
16370
16370
  {
16371
16371
  name: "editor_output",
16372
- 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. For a run the editor plays the reports come from the game itself, through the runtime addon, since the editor's debugger relays what a game prints and not what it reports; a played game without the runtime addon answers here with its prints alone. Answers with the counts and the verdict as well as the entries. heldAt is three answers: null for a run this server knows is not held, running or over, an object for one it knows is held at a breakpoint, whether the adapter said so or the runtime did by accepting a connection and never answering, and absent with heldUnknown beside it for a server that connected to the debugger after the stop and has no runtime to ask, since the adapter reports a stop only to the sessions connected when it happened. 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. op: \"editor\" answers about the editor itself instead, which is a different log and the only way anything here can read what an editor printed: its console reaches no plugin, so the addon cannot scrape it, and an editor gdharness opened is asked for a log file as it starts. That covers the whole session from the editor's first line, which is where the output worth reading usually is, since the class cache, the plugins and the language server all say what they have to say before any harness has connected. The answer carries the entries in the order they were printed and, beside them under repeated, each message shape several lines share: how many, what varied in each slot of it and how many different values stood there, the first occurrence, and the ordinary lines above the group, found by walking back past the group rather than by counting lines. An editor somebody opened by hand has no console here and is refused saying so rather than answered empty, because an editor that printed nothing and an editor nobody captured are not the same thing.",
16372
+ 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. For a run the editor plays the reports come from the game itself, through the runtime addon, since the editor's debugger relays what a game prints and not what it reports; a played game without the runtime addon answers here with its prints alone. Answers with the counts and the verdict as well as the entries. heldAt is three answers: null for a run this server knows is not held, running or over, an object for one it knows is held at a breakpoint, whether the adapter said so or the runtime did by accepting a connection and never answering, and absent with heldUnknown beside it for a server that connected to the debugger after the stop and has no runtime to ask, since the adapter reports a stop only to the sessions connected when it happened. A run that has quit still answers here, with running false and its exit code, until the next one starts; a run ended by a signal has no exit code, and exitSignal names the signal instead. 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. op: \"editor\" answers about the editor itself instead, which is a different log and the only way anything here can read what an editor printed: its console reaches no plugin, so the addon cannot scrape it, and an editor gdharness opened is asked for a log file as it starts. That covers the whole session from the editor's first line, which is where the output worth reading usually is, since the class cache, the plugins and the language server all say what they have to say before any harness has connected. The answer carries the entries in the order they were printed and, beside them under repeated, each message shape several lines share: how many, what varied in each slot of it and how many different values stood there, the first occurrence, and the ordinary lines above the group, found by walking back past the group rather than by counting lines. An editor somebody opened by hand has no console here and is refused saying so rather than answered empty, because an editor that printed nothing and an editor nobody captured are not the same thing.",
16373
16373
  parameters: {
16374
16374
  severity: {
16375
16375
  type: "string",
@@ -16415,7 +16415,7 @@ var init_tool_definitions = __esm(() => {
16415
16415
  },
16416
16416
  {
16417
16417
  name: "editor_status",
16418
- 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: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. 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. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved, which is that it was held when the bridge started, most often by the server a reconnect replaced.",
16418
+ 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: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. 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. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait.",
16419
16419
  parameters: {},
16420
16420
  requires: []
16421
16421
  },
@@ -16489,7 +16489,7 @@ var init_tool_definitions = __esm(() => {
16489
16489
  property: {
16490
16490
  type: "string",
16491
16491
  ops: ["property", "find"],
16492
- description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that takes no arguments and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. A step that is not there is named, and says what was there instead.'
16492
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that takes no arguments and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. A list or a map answers the calls that read it and take no arguments, "_game:run:board:size()" or "_game:inbox:keys()", and not those that change it, such as clear() or sort(). A step that is not there is named, and says what was there instead.'
16493
16493
  },
16494
16494
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
16495
16495
  includeProperties: {
@@ -16535,6 +16535,10 @@ var init_tool_definitions = __esm(() => {
16535
16535
  items: { type: "string" },
16536
16536
  ops: ["metrics"],
16537
16537
  description: "metrics: which to read. Default all."
16538
+ },
16539
+ timeoutMs: {
16540
+ type: "number",
16541
+ description: "How long to wait for the answer before answering pending with a requestId, which runtime_invoke op result collects the reply by. Default 10000, or GDHARNESS_RUNTIME_TIMEOUT_MS."
16538
16542
  }
16539
16543
  },
16540
16544
  requires: [],
@@ -16562,20 +16566,24 @@ var init_tool_definitions = __esm(() => {
16562
16566
  },
16563
16567
  {
16564
16568
  name: "runtime_invoke",
16565
- description: "Sets a property or calls a method on a node in the running game. Needs the game running with the runtime addon.",
16569
+ description: "Sets a property or calls a method on a node in the running game. Needs the game running with the runtime addon. A call that takes longer than timeoutMs is not cancelled: the answer is pending: true with a requestId, the call goes on and does everything it was asked, and op result with that requestId collects its reply once it comes.",
16566
16570
  parameters: {
16567
16571
  projectPath: RUNNING_PROJECT_PATH,
16568
16572
  pid: RUNNING_PID,
16569
- nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
16573
+ nodePath: {
16574
+ type: "string",
16575
+ ops: ["set", "call"],
16576
+ description: 'Absolute node path, such as "/root/Main/Player".'
16577
+ },
16570
16578
  property: {
16571
16579
  type: "string",
16572
16580
  ops: ["set"],
16573
- description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a method taking no arguments returned. The answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
16581
+ description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a method taking no arguments returned. The answer reads back off the same holder, and a write that leaves the property as it was, because the engine would not take it, is refused and says what the property still holds.'
16574
16582
  },
16575
16583
  value: {
16576
16584
  ops: ["set"],
16577
16585
  blank: true,
16578
- description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance.`
16586
+ description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key.`
16579
16587
  },
16580
16588
  method: {
16581
16589
  type: "string",
@@ -16585,18 +16593,32 @@ var init_tool_definitions = __esm(() => {
16585
16593
  args: {
16586
16594
  type: "array",
16587
16595
  ops: ["call"],
16588
- description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused.`
16596
+ description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused. A parameter typed as a list or map, such as Array[int] or Array[Gear], is built from a JSON list or object element by element, objects named by their paths.`
16597
+ },
16598
+ timeoutMs: {
16599
+ type: "number",
16600
+ ops: ["set", "call"],
16601
+ description: "set, call: how long to wait for the answer before answering pending with a requestId, which does not stop the call. Default 10000, or GDHARNESS_RUNTIME_TIMEOUT_MS. Give a longer one for a call known to take a while, such as one that plays many turns in one go."
16602
+ },
16603
+ requestId: {
16604
+ type: "number",
16605
+ ops: ["result"],
16606
+ description: "result: the requestId a pending answer gave."
16589
16607
  }
16590
16608
  },
16591
- requires: ["nodePath"],
16609
+ requires: [],
16592
16610
  operations: {
16593
16611
  set: {
16594
16612
  summary: "set a property, on a node or on an object it holds",
16595
- requires: ["property", "value"]
16613
+ requires: ["nodePath", "property", "value"]
16596
16614
  },
16597
16615
  call: {
16598
16616
  summary: "call a method, on a node or on an object it holds, and return its result",
16599
- requires: ["method"]
16617
+ requires: ["nodePath", "method"]
16618
+ },
16619
+ result: {
16620
+ summary: "collect the reply to a set or call whose wait ran out, by the requestId it answered with",
16621
+ requires: ["requestId"]
16600
16622
  }
16601
16623
  }
16602
16624
  },
@@ -31916,14 +31938,40 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
31916
31938
  problem: `Several games are running: ${endpoints.map(describe2).join("; ")}. Pass pid to choose one, or projectPath when the project you mean is running only one.`
31917
31939
  };
31918
31940
  }
31919
- function runtimeRequest(endpoint, command, params, timeoutMs) {
31941
+ function keepLate(id, answer) {
31942
+ lateAnswers.delete(id);
31943
+ lateAnswers.set(id, answer);
31944
+ while (lateAnswers.size > LATE_KEPT) {
31945
+ const oldest = lateAnswers.keys().next().value;
31946
+ if (oldest === undefined) {
31947
+ break;
31948
+ }
31949
+ lateAnswers.delete(oldest);
31950
+ }
31951
+ }
31952
+ function lateAnswer(id) {
31953
+ return lateAnswers.get(id);
31954
+ }
31955
+ function runtimeRequest(endpoint, command, params, timeoutMs, keepListening = false) {
31920
31956
  const id = nextRequestId++;
31957
+ const sentAt = Date.now();
31921
31958
  return new Promise((settle) => {
31922
31959
  let done = false;
31960
+ let late = false;
31923
31961
  let buffered = "";
31924
31962
  let welcomed = false;
31925
31963
  const socket = createConnection({ port: endpoint.port, host: endpoint.address });
31964
+ let lateTimer = null;
31926
31965
  const finish = (reply) => {
31966
+ if (late) {
31967
+ if (lateTimer !== null) {
31968
+ clearTimeout(lateTimer);
31969
+ }
31970
+ socket.destroy();
31971
+ keepLate(id, reply.ok || reply.reason === "error" ? { state: "answered", command, sentAt, answeredAt: Date.now(), reply } : { state: "never", command, sentAt, why: reply.message });
31972
+ late = false;
31973
+ return;
31974
+ }
31927
31975
  if (done) {
31928
31976
  return;
31929
31977
  }
@@ -31933,10 +31981,32 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
31933
31981
  settle(reply);
31934
31982
  };
31935
31983
  const timer = setTimeout(() => {
31936
- finish({
31984
+ const still = welcomed ? `The game (${describe2(endpoint)}) has the request and did not answer '${command}' within ${timeoutMs}ms.` : `The game (${describe2(endpoint)}) did not answer '${command}' within ${timeoutMs}ms and had not taken the request: its frame has not ended since it was sent, which a long frame or a breakpoint does, and the request runs when it does.`;
31985
+ if (!keepListening) {
31986
+ finish({
31987
+ ok: false,
31988
+ reason: "busy",
31989
+ message: `${still} It is either working through something long or held at a breakpoint.`
31990
+ });
31991
+ return;
31992
+ }
31993
+ done = true;
31994
+ late = true;
31995
+ keepLate(id, { state: "waiting", command, sentAt });
31996
+ lateTimer = setTimeout(() => {
31997
+ finish({
31998
+ ok: false,
31999
+ reason: "busy",
32000
+ message: `No answer came within ${LATE_ANSWER_MS / 60000} minutes.`
32001
+ });
32002
+ }, LATE_ANSWER_MS);
32003
+ lateTimer.unref();
32004
+ socket.unref();
32005
+ settle({
31937
32006
  ok: false,
31938
32007
  reason: "busy",
31939
- message: `The game (${describe2(endpoint)}) accepted the connection but did not answer '${command}' within ${timeoutMs}ms. It may be paused at a breakpoint or stuck in a long frame.`
32008
+ requestId: id,
32009
+ message: `${still} Nothing was cancelled: a call that takes longer than the wait goes on and does everything it was asked, so read the state back rather than repeating it. runtime_invoke op result with requestId ${id} answers with its reply once it comes, and timeoutMs waits longer next time. A game held at a breakpoint instead is one debug_state stack answers about.`
31940
32010
  });
31941
32011
  }, timeoutMs);
31942
32012
  socket.setEncoding("utf8");
@@ -31948,7 +32018,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
31948
32018
  buffered += chunk;
31949
32019
  let newline = buffered.indexOf(`
31950
32020
  `);
31951
- while (newline !== -1 && !done) {
32021
+ while (newline !== -1 && (!done || late)) {
31952
32022
  const line = buffered.slice(0, newline).trim();
31953
32023
  buffered = buffered.slice(newline + 1);
31954
32024
  newline = buffered.indexOf(`
@@ -32003,7 +32073,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
32003
32073
  });
32004
32074
  });
32005
32075
  }
32006
- var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, ERROR_REPORT_PATTERN, ERROR_REPORT_KEEP_MS, GONE_WINDOW_MS = 120000, GONE_KEPT = 32, gone, STARTED_AFTER_ANNOUNCING_MS = 2000, CONFIRMED_FOR_MS = 60000, JUDGED_AFTER_MS = 60000, verdicts, ENDED_WITHIN_MS = 3000, LOOK_EVERY_MS = 50, ANNOUNCE_BUDGET_MS = 5000, nextRequestId = 1;
32076
+ var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, ERROR_REPORT_PATTERN, ERROR_REPORT_KEEP_MS, GONE_WINDOW_MS = 120000, GONE_KEPT = 32, gone, STARTED_AFTER_ANNOUNCING_MS = 2000, CONFIRMED_FOR_MS = 60000, JUDGED_AFTER_MS = 60000, verdicts, ENDED_WITHIN_MS = 3000, LOOK_EVERY_MS = 50, ANNOUNCE_BUDGET_MS = 5000, nextRequestId = 1, LATE_ANSWER_MS, LATE_KEPT = 50, lateAnswers;
32007
32077
  var init_runtime_client = __esm(() => {
32008
32078
  init_paths();
32009
32079
  init_process_children();
@@ -32012,6 +32082,8 @@ var init_runtime_client = __esm(() => {
32012
32082
  ERROR_REPORT_KEEP_MS = 60 * 60 * 1000;
32013
32083
  gone = [];
32014
32084
  verdicts = new Map;
32085
+ LATE_ANSWER_MS = 30 * 60000;
32086
+ lateAnswers = new Map;
32015
32087
  });
32016
32088
 
32017
32089
  // src/boot-note.ts
@@ -36145,7 +36217,10 @@ var init_godot_bridge = __esm(() => {
36145
36217
  get port() {
36146
36218
  return this.boundPort ?? this.wantedPort;
36147
36219
  }
36148
- async start() {
36220
+ get configuredPort() {
36221
+ return this.wantedPort;
36222
+ }
36223
+ async start(moveIfHeld = true) {
36149
36224
  if (this.httpServer) {
36150
36225
  return;
36151
36226
  }
@@ -36153,7 +36228,7 @@ var init_godot_bridge = __esm(() => {
36153
36228
  await this.listenOn(this.wantedPort);
36154
36229
  } catch (error) {
36155
36230
  const held = error instanceof Error && "code" in error && error.code === "EADDRINUSE";
36156
- if (!held || !this.mayMove) {
36231
+ if (!held || !this.mayMove || !moveIfHeld) {
36157
36232
  throw error;
36158
36233
  }
36159
36234
  this.log("warn", `Editor bridge port ${this.wantedPort} is held; taking another one.`);
@@ -36956,6 +37031,65 @@ function whyNoReport(printed, asked) {
36956
37031
  }
36957
37032
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
36958
37033
  }
37034
+ function unspelled(text) {
37035
+ return text.replace(/<(?:LF|CR|TAB|BS|FF|VT|BEL|ESC|DEL|0x[0-9A-F]{2})>/g, (spelling) => spelling.startsWith("<0x") ? String.fromCharCode(Number.parseInt(spelling.slice(3, 5), 16)) : SPELLED[spelling] ?? spelling);
37036
+ }
37037
+ function readDiff(printed) {
37038
+ let merged = "";
37039
+ let actual = "";
37040
+ let background = null;
37041
+ let last = 0;
37042
+ const take = (text) => {
37043
+ merged += text;
37044
+ if (background === null) {
37045
+ actual += text;
37046
+ } else if (background === "added") {
37047
+ actual += unspelled(text);
37048
+ }
37049
+ };
37050
+ for (const match of printed.matchAll(COLOUR2)) {
37051
+ take(printed.slice(last, match.index));
37052
+ last = match.index + match[0].length;
37053
+ const codes = (match[1] ?? "").split(";");
37054
+ if (codes[0] === "48" && codes[1] === "2") {
37055
+ background = Number(codes[2]) > Number(codes[3]) ? "missing" : "added";
37056
+ } else if (codes[0] === "0" || codes[0] === "") {
37057
+ background = null;
37058
+ }
37059
+ }
37060
+ take(printed.slice(last));
37061
+ return { merged, actual };
37062
+ }
37063
+ function withActualsPrinted(failed, printed) {
37064
+ const diffs = [...printed.replaceAll(`\r
37065
+ `, `
37066
+ `).matchAll(PRINTED_ACTUAL)].map((match) => ({
37067
+ at: match[3] ?? "",
37068
+ ...readDiff(match[1] ?? "")
37069
+ }));
37070
+ return failed.map((entry) => {
37071
+ if (entry.detail === null) {
37072
+ return entry;
37073
+ }
37074
+ let detail = entry.detail;
37075
+ for (const diff of diffs) {
37076
+ const wrong = ` but was
37077
+ '${diff.merged.replaceAll("\r", "")}'
37078
+ at ${diff.at}`;
37079
+ if (detail.includes(wrong)) {
37080
+ detail = detail.replace(wrong, () => ` but was
37081
+ '${diff.actual}'
37082
+ at ${diff.at}`);
37083
+ }
37084
+ }
37085
+ const alike = /Expecting:\n '([\s\S]*)'\n but was\n '\1'\n\tat /.test(detail);
37086
+ if (alike) {
37087
+ detail = `${detail}
37088
+ ${BOTH_SIDES_ALIKE_NOTE}`;
37089
+ }
37090
+ return detail === entry.detail ? entry : { ...entry, detail };
37091
+ });
37092
+ }
36959
37093
  function orphansPrinted(printed) {
36960
37094
  const suites = [];
36961
37095
  let total = null;
@@ -36986,7 +37120,7 @@ function orphansPrinted(printed) {
36986
37120
  suites
36987
37121
  };
36988
37122
  }
36989
- var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH, RUNNING_SUITE, ORPHANS, OVERALL = "Overall Summary";
37123
+ var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH, ESCAPE, PRINTED_ACTUAL, COLOUR2, SPELLED, BOTH_SIDES_ALIKE_NOTE = "[gdharness: the expected and the actual printed alike because gdUnit4 drops the marks that tell them apart when it writes its report, and the runner printed no copy of this one, so neither side above is the value the assertion found.]", RUNNING_SUITE, ORPHANS, OVERALL = "Overall Summary";
36990
37124
  var init_junit = __esm(() => {
36991
37125
  MalformedReportError = class MalformedReportError extends Error {
36992
37126
  constructor(message) {
@@ -36998,6 +37132,21 @@ var init_junit = __esm(() => {
36998
37132
  ATTRIBUTE = /\s*([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/y;
36999
37133
  NOTHING_RAN = /no test cases found/i;
37000
37134
  NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
37135
+ ESCAPE = String.fromCharCode(27);
37136
+ PRINTED_ACTUAL = new RegExp(` but was\\n '([\\s\\S]*?)'((?:${ESCAPE}\\[[0-9;]*m)*)\\tat ('[^'\\n]*' in \\S+:\\d+)`, "g");
37137
+ COLOUR2 = new RegExp(`${ESCAPE}\\[([0-9;]*)m`, "g");
37138
+ SPELLED = {
37139
+ "<LF>": `
37140
+ `,
37141
+ "<CR>": "\r",
37142
+ "<TAB>": "\t",
37143
+ "<BS>": "\b",
37144
+ "<FF>": "\f",
37145
+ "<VT>": "\v",
37146
+ "<BEL>": "\x07",
37147
+ "<ESC>": ESCAPE,
37148
+ "<DEL>": ""
37149
+ };
37001
37150
  RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
37002
37151
  ORPHANS = /(\d+)\s+orphans/;
37003
37152
  });
@@ -37808,14 +37957,19 @@ function editorNoteAt(path) {
37808
37957
  }
37809
37958
  return { projectPath, transcript, startedAt };
37810
37959
  }
37811
- function recordRunEnded(pid, exitCode) {
37960
+ function recordRunEnded(pid, ending) {
37812
37961
  const path = recordPath();
37813
37962
  const record = recordAt(path);
37814
37963
  if (record === null || record.pid !== pid) {
37815
37964
  return;
37816
37965
  }
37966
+ const written = {
37967
+ ...record,
37968
+ ...ending.exitCode === null ? {} : { exitCode: ending.exitCode },
37969
+ ...ending.exitSignal === null ? {} : { exitSignal: ending.exitSignal }
37970
+ };
37817
37971
  try {
37818
- writeFileSync15(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
37972
+ writeFileSync15(path, JSON.stringify(written, null, 2), "utf8");
37819
37973
  } catch {}
37820
37974
  }
37821
37975
  function readRunRecord() {
@@ -37852,7 +38006,8 @@ function recordAt(path) {
37852
38006
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
37853
38007
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
37854
38008
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
37855
- ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {}
38009
+ ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
38010
+ ...typeof fields["exitSignal"] === "string" ? { exitSignal: fields["exitSignal"] } : {}
37856
38011
  };
37857
38012
  }
37858
38013
  function runningAs(pid) {
@@ -38113,8 +38268,21 @@ function describeHalt(halt) {
38113
38268
  return `stopped with reason "${halt.reason}"`;
38114
38269
  }
38115
38270
  }
38271
+ function exited(run) {
38272
+ return run.exitCode !== null || run.exitSignal !== null;
38273
+ }
38274
+ function endingOf(failed) {
38275
+ return {
38276
+ exitCode: typeof failed.code === "number" ? failed.code : null,
38277
+ exitSignal: typeof failed.signal === "string" ? failed.signal : null,
38278
+ failure: typeof failed.code === "string" ? failed.code : null
38279
+ };
38280
+ }
38281
+ function howItExited(run) {
38282
+ return run.exitSignal === null ? `exit code ${run.exitCode ?? "unknown"}` : `ended by ${run.exitSignal}, with no exit code`;
38283
+ }
38116
38284
  function stillRunning(run) {
38117
- if (run?.exitCode !== null || run.endedUnwatched === true) {
38285
+ if (run === null || exited(run) || run.endedUnwatched === true) {
38118
38286
  return false;
38119
38287
  }
38120
38288
  if (typeof run.endedHere === "string") {
@@ -38144,7 +38312,15 @@ function runIsUp(run, editorSays, now = Date.now()) {
38144
38312
  return editorSays ?? stillRunning(run);
38145
38313
  }
38146
38314
  function noCodeWillCome(run) {
38147
- return run.exitCode === null && run.process === null;
38315
+ return !exited(run) && run.process === null;
38316
+ }
38317
+ async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
38318
+ const gone = () => (handle?.exitCode !== null || handle.signalCode !== null) && pids.every((pid) => !alive(pid));
38319
+ const deadline = Date.now() + withinMs;
38320
+ while (!gone() && Date.now() < deadline) {
38321
+ await delay3(50);
38322
+ }
38323
+ return gone();
38148
38324
  }
38149
38325
  function endedWithoutACode(run) {
38150
38326
  if (!run.throughEditor) {
@@ -38419,6 +38595,8 @@ class GodotServer {
38419
38595
  launchedFrom = null;
38420
38596
  ownProject;
38421
38597
  announcedAt = null;
38598
+ handingOverFrom = null;
38599
+ notHandedOverBy = null;
38422
38600
  constructor() {
38423
38601
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
38424
38602
  this.godotBridge = getDefaultBridge(this.ownProject);
@@ -38465,6 +38643,7 @@ class GodotServer {
38465
38643
  });
38466
38644
  console.error("gdharness running on stdio");
38467
38645
  try {
38646
+ await this.takeOverFromAPredecessor();
38468
38647
  await this.godotBridge.start();
38469
38648
  this.bridgeStartupError = null;
38470
38649
  const bridgeStatus = this.godotBridge.getStatus();
@@ -38479,6 +38658,35 @@ class GodotServer {
38479
38658
  this.keepTryingTheBridge();
38480
38659
  }
38481
38660
  }
38661
+ async takeOverFromAPredecessor() {
38662
+ if (this.ownProject === null) {
38663
+ return;
38664
+ }
38665
+ const wanted = this.godotBridge.configuredPort;
38666
+ const announced = readAnnouncement2(announcementPath2(this.ownProject));
38667
+ if (announced === null || announced.pid === process.pid || announced.port !== wanted || !alive(announced.pid)) {
38668
+ return;
38669
+ }
38670
+ this.handingOverFrom = announced.pid;
38671
+ this.announcedAt = announceBridge(this.ownProject, {
38672
+ host: this.godotBridge.getStatus().host,
38673
+ port: wanted,
38674
+ version: SERVER_VERSION
38675
+ });
38676
+ const until = Date.now() + HANDOVER_MS;
38677
+ let taken = false;
38678
+ while (!taken && Date.now() < until && !this.shutdownInitiated) {
38679
+ try {
38680
+ await this.godotBridge.start(false);
38681
+ taken = true;
38682
+ this.logDebug(`Took port ${wanted} over from pid ${announced.pid}`);
38683
+ } catch {
38684
+ await delay3(HANDOVER_POLL_MS);
38685
+ }
38686
+ }
38687
+ this.handingOverFrom = null;
38688
+ this.notHandedOverBy = taken ? null : announced.pid;
38689
+ }
38482
38690
  keepTryingTheBridge() {
38483
38691
  if (this.bridgeRetry !== null || this.shutdownInitiated) {
38484
38692
  return;
@@ -38937,7 +39145,8 @@ class GodotServer {
38937
39145
  return await this.handleEditorStatus();
38938
39146
  case "editor_rescan":
38939
39147
  return await this.handleRescanFilesystem(args);
38940
- case "runtime_inspect":
39148
+ case "runtime_inspect": {
39149
+ const patience = readPositiveNumber(args, "timeoutMs") ?? this.runtimeTimeoutMs();
38941
39150
  switch (op) {
38942
39151
  case "tree":
38943
39152
  return await this.handleRuntimeCommand("get_tree", {
@@ -38946,45 +39155,45 @@ class GodotServer {
38946
39155
  depth: readPositiveNumber(args, "depth") ?? 3,
38947
39156
  include_properties: readBoolean(args, "includeProperties") ?? false,
38948
39157
  properties: readArray(args, "properties") ?? []
38949
- });
39158
+ }, patience);
38950
39159
  case "find":
38951
- return await this.handleFindRuntimeNodes(args);
39160
+ return await this.handleFindRuntimeNodes(args, patience);
38952
39161
  case "text":
38953
39162
  return await this.handleRuntimeCommand("read_text", {
38954
39163
  ...whichGame(args),
38955
39164
  root: readNonEmptyString(args, "nodePath") ?? "/root",
38956
39165
  include_hidden: readBoolean(args, "includeHidden") ?? false,
38957
39166
  limit: readPositiveNumber(args, "limit") ?? 500
38958
- });
39167
+ }, patience);
38959
39168
  case "rect":
38960
- return await this.handleRuntimeCommand("get_rect", {
38961
- ...whichGame(args),
38962
- path: readNonEmptyString(args, "nodePath") ?? ""
38963
- });
39169
+ return await this.handleRuntimeCommand("get_rect", { ...whichGame(args), path: readNonEmptyString(args, "nodePath") ?? "" }, patience);
38964
39170
  case "property":
38965
39171
  return await this.handleRuntimeCommand("get_property", {
38966
39172
  ...whichGame(args),
38967
39173
  path: readNonEmptyString(args, "nodePath") ?? "",
38968
39174
  property: readNonEmptyString(args, "property") ?? ""
38969
- });
39175
+ }, patience);
38970
39176
  default:
38971
- return await this.handleRuntimeCommand("get_metrics", {
38972
- ...whichGame(args),
38973
- metrics: readArray(args, "metrics") ?? []
38974
- });
39177
+ return await this.handleRuntimeCommand("get_metrics", { ...whichGame(args), metrics: readArray(args, "metrics") ?? [] }, patience);
39178
+ }
39179
+ }
39180
+ case "runtime_invoke": {
39181
+ if (op === "result") {
39182
+ return this.handleLateAnswer(args);
38975
39183
  }
38976
- case "runtime_invoke":
39184
+ const patience = readPositiveNumber(args, "timeoutMs") ?? this.runtimeTimeoutMs();
38977
39185
  return op === "set" ? await this.handleRuntimeCommand("set_property", {
38978
39186
  ...whichGame(args),
38979
39187
  path: readNonEmptyString(args, "nodePath") ?? "",
38980
39188
  property: readString(args, "property") ?? "",
38981
39189
  value: args["value"]
38982
- }) : await this.handleRuntimeCommand("call_method", {
39190
+ }, patience) : await this.handleRuntimeCommand("call_method", {
38983
39191
  ...whichGame(args),
38984
39192
  path: readNonEmptyString(args, "nodePath") ?? "",
38985
39193
  method: readString(args, "method") ?? "",
38986
39194
  args: readArray(args, "args") ?? []
38987
- });
39195
+ }, patience);
39196
+ }
38988
39197
  case "runtime_capture":
38989
39198
  return await this.handleRuntimeCommand(op === "screenshot" ? "capture_screenshot" : "capture_viewport", args);
38990
39199
  case "runtime_input":
@@ -39135,10 +39344,10 @@ class GodotServer {
39135
39344
  }
39136
39345
  async debuggedGame(intent) {
39137
39346
  const game = this.activeProcess;
39138
- if (game !== null && game.exitCode !== null) {
39347
+ if (game !== null && exited(game)) {
39139
39348
  return {
39140
39349
  ok: false,
39141
- response: this.createErrorResponse(`The last run has already finished (exit code ${game.exitCode}), so there is no debug session to answer for.`, [
39350
+ response: this.createErrorResponse(`The last run has already finished (${howItExited(game)}), so there is no debug session to answer for.`, [
39142
39351
  "editor_output still reads what it printed, until the next run starts",
39143
39352
  "editor_run start plays another, which the debugger can hold"
39144
39353
  ])
@@ -39412,10 +39621,11 @@ class GodotServer {
39412
39621
  ];
39413
39622
  this.logDebug(`Exporting: ${engine.value} ${exportArgs.join(" ")}`);
39414
39623
  const log = new GameLog;
39415
- let exitCode = 0;
39624
+ let ending = { exitCode: 0, exitSignal: null, failure: null };
39416
39625
  try {
39417
39626
  const { stdout, stderr } = await run7(engine.value, exportArgs, {
39418
39627
  timeout: 300000,
39628
+ maxBuffer: 64 * 1024 * 1024,
39419
39629
  signal: callSignal()
39420
39630
  });
39421
39631
  log.append("stdout", stdout);
@@ -39427,26 +39637,32 @@ class GodotServer {
39427
39637
  const failed = error;
39428
39638
  log.append("stdout", failed.stdout);
39429
39639
  log.append("stderr", failed.stderr);
39430
- exitCode = typeof failed.code === "number" ? failed.code : -1;
39640
+ ending = endingOf(failed);
39431
39641
  } finally {
39432
39642
  discard(exportLogs);
39433
39643
  }
39434
39644
  log.finish();
39435
39645
  const problems = log.select({ severity: "warning", sinceLastCall: false, limit: 200 });
39436
39646
  const verdict = {
39437
- exported: exitCode === 0 && log.count("error") === 0 && existsSync15(output.absolutePath),
39647
+ exported: ending.exitCode === 0 && log.count("error") === 0 && existsSync15(output.absolutePath),
39438
39648
  preset,
39439
39649
  outputPath: output.relativePath,
39440
39650
  debug,
39441
- exitCode,
39651
+ exitCode: ending.exitCode,
39652
+ exitSignal: ending.exitSignal ?? undefined,
39653
+ failure: ending.failure ?? undefined,
39442
39654
  errors: log.count("error"),
39443
39655
  warnings: log.count("warning"),
39444
39656
  entries: forAnswer(problems.entries)
39445
39657
  };
39446
39658
  if (!verdict.exported) {
39659
+ const why = ending.exitSignal === null ? ending.failure === null ? "" : ` The engine was ended by Node (${ending.failure}), so it has no exit code.` : ` The engine was ended by ${ending.exitSignal}, so it has no exit code: the export ran past its five minutes or the call was cancelled.`;
39447
39660
  return {
39448
39661
  content: [
39449
- { type: "text", text: `Export with preset '${preset}' did not produce ${output.relativePath}.` },
39662
+ {
39663
+ type: "text",
39664
+ text: `Export with preset '${preset}' did not produce ${output.relativePath}.${why}`
39665
+ },
39450
39666
  { type: "text", text: JSON.stringify(verdict, null, 2) }
39451
39667
  ],
39452
39668
  isError: true
@@ -39501,6 +39717,10 @@ class GodotServer {
39501
39717
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
39502
39718
  const userData = mkdtempSync3(join23(tmpdir5(), "gdharness-tests-"));
39503
39719
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
39720
+ const console2 = [];
39721
+ run.process.stdout?.on("data", (data) => {
39722
+ console2.push(data);
39723
+ });
39504
39724
  const hung = await new Promise((resolve) => {
39505
39725
  const timer = setTimeout(() => {
39506
39726
  run.process.kill();
@@ -39545,7 +39765,7 @@ class GodotServer {
39545
39765
  const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
39546
39766
  const said = printed.map((entry) => entry.text);
39547
39767
  const nothingRan = hung ? null : whyNoReport(said, asked);
39548
- const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? verdicts[exitCode ?? -1] ?? `exit ${exitCode ?? "unknown"}`;
39768
+ const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
39549
39769
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
39550
39770
  if (report === null) {
39551
39771
  const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
@@ -39559,6 +39779,7 @@ class GodotServer {
39559
39779
  verdict,
39560
39780
  tests: 0,
39561
39781
  exitCode,
39782
+ exitSignal: run.exitSignal ?? undefined,
39562
39783
  hung,
39563
39784
  arguments: cmdArgs,
39564
39785
  entries: forAnswer(printed.slice(0, 60)),
@@ -39569,7 +39790,7 @@ class GodotServer {
39569
39790
  isError: true
39570
39791
  };
39571
39792
  }
39572
- const failed = report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path })));
39793
+ const failed = withActualsPrinted(report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path }))), Buffer.concat(console2).toString("utf8"));
39573
39794
  const orphans = orphansPrinted(said);
39574
39795
  const leftBehind = new Map(orphans.suites.map((suite) => [suite.path, suite.orphans]));
39575
39796
  const orphansIn = (path) => path === null ? 0 : leftBehind.get(path) ?? 0;
@@ -39586,6 +39807,7 @@ class GodotServer {
39586
39807
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
39587
39808
  verdict,
39588
39809
  exitCode,
39810
+ exitSignal: run.exitSignal ?? undefined,
39589
39811
  tests: report.tests,
39590
39812
  failures: report.failures,
39591
39813
  errors: report.errors,
@@ -39852,7 +40074,12 @@ class GodotServer {
39852
40074
  awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
39853
40075
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
39854
40076
  startupError: this.bridgeStartupError,
39855
- portNote: status.portWanted === undefined ? undefined : `Port ${status.portWanted}, the one this server was configured with, was held by another process when the bridge started, so it took ${status.port} and announced that where the editor looks for it${this.announcedAt === null ? "" : `, ${this.announcedAt}`}. The editor finds it there; the next server started takes ${status.portWanted} again if it is free.`,
40077
+ portNote: status.portWanted === undefined ? undefined : `Port ${status.portWanted}, the one this server was configured with, was held ${this.notHandedOverBy === null ? "by another process" : `by pid ${this.notHandedOverBy}, the gdharness server this one replaced, which had not let go of it ${HANDOVER_MS / 1000} seconds after being told it was replaced`} when the bridge started, so it took ${status.port} and announced that where the editor looks for it${this.announcedAt === null ? "" : `, ${this.announcedAt}`}. The editor finds it there; the next server started takes ${status.portWanted} again if it is free.`,
40078
+ bridgeHandover: this.handingOverFrom === null ? undefined : {
40079
+ fromPid: this.handingOverFrom,
40080
+ port: this.godotBridge.configuredPort,
40081
+ note: `The gdharness server this one replaced, pid ${this.handingOverFrom}, still holds port ${this.godotBridge.configuredPort} and is standing down; this server takes the port when it lets go, and takes another after ${HANDOVER_MS / 1000} seconds if it does not.`
40082
+ },
39856
40083
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
39857
40084
  addonNote: status.connected ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
39858
40085
  ...this.breakpointsAtRisk(),
@@ -40312,6 +40539,7 @@ class GodotServer {
40312
40539
  projectPath,
40313
40540
  startedAt,
40314
40541
  exitCode: null,
40542
+ exitSignal: null,
40315
40543
  throughEditor: true,
40316
40544
  brokeOn: null,
40317
40545
  announcedBefore: alreadyPlaying,
@@ -40404,24 +40632,25 @@ class GodotServer {
40404
40632
  const recorded = running?.process === null ? readRunRecord() : null;
40405
40633
  clearRunRecord((record) => this.couldBeOurs(record.projectPath));
40406
40634
  if (!running || !going) {
40407
- return;
40635
+ return true;
40408
40636
  }
40409
40637
  running.endedHere = reason;
40638
+ const announced = this.announcedPidOf(running);
40410
40639
  if (running.throughEditor) {
40411
40640
  await this.handleViaBridge("stop_playing", {});
40412
- return;
40641
+ return await untilGone(null, announced === undefined ? [] : [announced]);
40413
40642
  }
40414
40643
  if (running.process !== null) {
40415
40644
  running.process.kill();
40416
- return;
40645
+ return await untilGone(running.process, announced === undefined ? [] : [announced]);
40417
40646
  }
40418
40647
  if (running.pid === null) {
40419
- return;
40648
+ return true;
40420
40649
  }
40421
40650
  if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
40422
40651
  running.endedHere = null;
40423
40652
  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.`);
40424
- return;
40653
+ return true;
40425
40654
  }
40426
40655
  const signalled = runningAs(running.pid);
40427
40656
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
@@ -40431,7 +40660,9 @@ class GodotServer {
40431
40660
  } catch {
40432
40661
  landed = false;
40433
40662
  }
40663
+ const gone = await untilGone(null, [running.pid]);
40434
40664
  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}.`);
40665
+ return gone;
40435
40666
  }
40436
40667
  spawnGame(godotPath, cmdArgs, env) {
40437
40668
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -40462,6 +40693,7 @@ class GodotServer {
40462
40693
  projectPath: null,
40463
40694
  startedAt: Date.now(),
40464
40695
  exitCode: null,
40696
+ exitSignal: null,
40465
40697
  throughEditor: false,
40466
40698
  brokeOn: null
40467
40699
  };
@@ -40471,13 +40703,11 @@ class GodotServer {
40471
40703
  child.stderr.on("data", (data) => {
40472
40704
  log.append("stderr", data);
40473
40705
  });
40474
- child.on("exit", (code) => {
40475
- this.logDebug(`Godot process exited with code ${code ?? "none"}`);
40706
+ child.on("exit", (code, signal) => {
40707
+ this.logDebug(`Godot process exited with code ${code ?? "none"}, signal ${signal ?? "none"}`);
40476
40708
  log.finish();
40477
- started.exitCode = code ?? -1;
40478
- if (started.pid !== null) {
40479
- recordRunEnded(started.pid, started.exitCode);
40480
- }
40709
+ started.exitCode = code;
40710
+ started.exitSignal = code === null ? signal : null;
40481
40711
  });
40482
40712
  child.on("error", (err) => {
40483
40713
  console.error("Failed to start Godot process:", err);
@@ -40511,14 +40741,19 @@ class GodotServer {
40511
40741
  projectPath,
40512
40742
  startedAt,
40513
40743
  exitCode: null,
40744
+ exitSignal: null,
40514
40745
  throughEditor: false,
40515
40746
  brokeOn: null
40516
40747
  };
40517
- child.on("exit", (code) => {
40518
- this.logDebug(`Godot process exited with code ${code ?? "none"}`);
40748
+ child.on("exit", (code, signal) => {
40749
+ this.logDebug(`Godot process exited with code ${code ?? "none"}, signal ${signal ?? "none"}`);
40519
40750
  this.drainTranscript(started);
40520
40751
  log.finish();
40521
- started.exitCode = code ?? -1;
40752
+ started.exitCode = code;
40753
+ started.exitSignal = code === null ? signal : null;
40754
+ if (started.pid !== null) {
40755
+ recordRunEnded(started.pid, { exitCode: started.exitCode, exitSignal: started.exitSignal });
40756
+ }
40522
40757
  });
40523
40758
  child.on("error", (err) => {
40524
40759
  console.error("Failed to start Godot process:", err);
@@ -40601,6 +40836,7 @@ class GodotServer {
40601
40836
  projectPath: project,
40602
40837
  startedAt: note?.startedAt ?? Date.now(),
40603
40838
  exitCode: null,
40839
+ exitSignal: null,
40604
40840
  throughEditor: true,
40605
40841
  brokeOn: null,
40606
40842
  pickedUpPlaying: true
@@ -40737,6 +40973,7 @@ class GodotServer {
40737
40973
  projectPath: record.projectPath === "" ? null : record.projectPath,
40738
40974
  startedAt: record.startedAt,
40739
40975
  exitCode: null,
40976
+ exitSignal: null,
40740
40977
  throughEditor: false,
40741
40978
  brokeOn: null
40742
40979
  };
@@ -40744,7 +40981,8 @@ class GodotServer {
40744
40981
  if (!alive(record.pid) || !couldStillBeTheRecordedRun(record)) {
40745
40982
  adopted.log.finish();
40746
40983
  adopted.exitCode = record.exitCode ?? null;
40747
- adopted.endedUnwatched = record.exitCode === undefined;
40984
+ adopted.exitSignal = record.exitSignal ?? null;
40985
+ adopted.endedUnwatched = !exited(adopted);
40748
40986
  }
40749
40987
  this.activeProcess = adopted;
40750
40988
  return adopted;
@@ -40843,6 +41081,7 @@ class GodotServer {
40843
41081
  booted: !hung && boot.exitCode === 0 && errors === 0,
40844
41082
  hung,
40845
41083
  exitCode: boot.exitCode,
41084
+ exitSignal: boot.exitSignal ?? undefined,
40846
41085
  durationMs: Date.now() - boot.startedAt,
40847
41086
  frames,
40848
41087
  errors,
@@ -40869,7 +41108,11 @@ class GodotServer {
40869
41108
  }
40870
41109
  async runStillGoing(run, editorSays) {
40871
41110
  if (!run.throughEditor) {
40872
- return stillRunning(run);
41111
+ const going = stillRunning(run);
41112
+ if (!going && run.process !== null && !exited(run)) {
41113
+ await untilGone(run.process, [], EXIT_REPORTED_WITHIN_MS);
41114
+ }
41115
+ return going;
40873
41116
  }
40874
41117
  const announced = this.announcedPidOf(run);
40875
41118
  if (announced !== undefined && !alive(announced)) {
@@ -40977,8 +41220,8 @@ class GodotServer {
40977
41220
  }
40978
41221
  if (typeof run.endedHere === "string") {
40979
41222
  notes.push(`This run was ended here, by ${run.endedHere}.`);
40980
- } else if (!stillRunning(run) && !run.throughEditor && run.exitCode !== null) {
40981
- 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.");
41223
+ } else if (!stillRunning(run) && !run.throughEditor && exited(run)) {
41224
+ notes.push(run.exitCode === 0 ? "This run quit on its own, cleanly: exit code 0." : run.exitSignal === null ? "Nothing here ended this run: it stopped on its own or something outside this server stopped it." : `Nothing here ended this run: something outside this server sent it ${run.exitSignal}, so it has no exit code.`);
40982
41225
  }
40983
41226
  if (run.endedUnwatched === true) {
40984
41227
  notes.push(endedWithoutACode({
@@ -41001,6 +41244,7 @@ class GodotServer {
41001
41244
  return this.jsonTextResponse({
41002
41245
  running: going,
41003
41246
  exitCode: run.exitCode,
41247
+ exitSignal: run.exitSignal ?? undefined,
41004
41248
  through: run.throughEditor ? "editor" : "gdharness",
41005
41249
  pid: run.pid ?? announced ?? run.announcedPid ?? null,
41006
41250
  errors: run.log.count("error"),
@@ -41033,7 +41277,7 @@ class GodotServer {
41033
41277
  const endedPid = stopped.pid ?? announcedPid;
41034
41278
  const children = readBoolean(args, "andChildren") === true && wasRunning ? await this.whatTheRunStarted(stopped) : null;
41035
41279
  this.logDebug("Stopping the running game");
41036
- await this.endActiveGame("editor_run stop", wasRunning);
41280
+ const gone = await this.endActiveGame("editor_run stop", wasRunning);
41037
41281
  const ended = children === null ? null : endChildrenAmong(children);
41038
41282
  if (wasRunning) {
41039
41283
  for (const pid of new Set([endedPid, announcedPid])) {
@@ -41054,10 +41298,11 @@ class GodotServer {
41054
41298
  },
41055
41299
  exitedBeforeStop: !wasRunning,
41056
41300
  exitCode: stopped.exitCode,
41301
+ exitSignal: stopped.exitSignal ?? undefined,
41057
41302
  errors: stopped.log.count("error"),
41058
41303
  warnings: stopped.log.count("warning"),
41059
41304
  clean: stopped.log.count("error") === 0,
41060
- note: !wasRunning ? "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running." : `${stopped.throughEditor ? endedPid === null ? "The editor was asked to stop the scene it is playing. Its game had not announced a runtime, so no process was named under endedPid, and its exit code stays with the editor." : "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."} ${aboutTheChildren(ended, stopped.throughEditor)}`,
41305
+ note: !wasRunning ? "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running." : `${stopped.throughEditor ? endedPid === null ? "The editor was asked to stop the scene it is playing. Its game had not announced a runtime, so no process was named under endedPid, and its exit code stays with the editor." : "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."}${stopped.exitSignal === null ? "" : ` It was ended by ${stopped.exitSignal} and so has no exit code.`}${gone ? "" : ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be going: editor_status says whether it is.`} ${aboutTheChildren(ended, stopped.throughEditor)}`,
41061
41306
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
41062
41307
  });
41063
41308
  }
@@ -41473,7 +41718,17 @@ class GodotServer {
41473
41718
  const screenshotDir = expectsScreenshot ? mkdtempSync3(join23(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
41474
41719
  const screenshotPath = screenshotDir ? join23(screenshotDir, "capture.png") : null;
41475
41720
  try {
41476
- const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
41721
+ const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs, !expectsScreenshot);
41722
+ if (!reply.ok && reply.requestId !== undefined) {
41723
+ return this.jsonTextResponse({
41724
+ pending: true,
41725
+ requestId: reply.requestId,
41726
+ command,
41727
+ waitedMs: timeoutMs,
41728
+ note: reply.message,
41729
+ ...answeredBy
41730
+ });
41731
+ }
41477
41732
  if (!reply.ok) {
41478
41733
  return this.createErrorResponse(reply.message);
41479
41734
  }
@@ -41509,7 +41764,36 @@ class GodotServer {
41509
41764
  discard(screenshotDir);
41510
41765
  }
41511
41766
  }
41512
- async handleFindRuntimeNodes(args) {
41767
+ handleLateAnswer(args) {
41768
+ const requestId = readPositiveNumber(args, "requestId") ?? 0;
41769
+ const late = lateAnswer(requestId);
41770
+ if (late === undefined) {
41771
+ return this.createErrorResponse(`No request ${requestId} is waiting to be collected here: an id is kept only from a pending answer this server gave, and only for its fifty most recent.`);
41772
+ }
41773
+ if (late.state === "waiting") {
41774
+ return this.jsonTextResponse({
41775
+ pending: true,
41776
+ requestId,
41777
+ command: late.command,
41778
+ sentAgoMs: Date.now() - late.sentAt,
41779
+ note: "The game has not answered it yet. It is still being listened for, and this answers with the reply once it comes."
41780
+ });
41781
+ }
41782
+ if (late.state === "never") {
41783
+ return this.createErrorResponse(`Request ${requestId} ('${late.command}') got no answer: ${late.why} Read the state back to see whether it ran.`);
41784
+ }
41785
+ const reply = late.reply;
41786
+ if (!reply.ok) {
41787
+ return this.createErrorResponse(`Request ${requestId} ('${late.command}') was answered with a refusal: ${reply.message}`);
41788
+ }
41789
+ const { id: _id, ...payload } = reply.payload;
41790
+ return this.jsonTextResponse({
41791
+ ...payload,
41792
+ requestId,
41793
+ answeredAfterMs: late.answeredAt - late.sentAt
41794
+ });
41795
+ }
41796
+ async handleFindRuntimeNodes(args, patience) {
41513
41797
  const filters = {};
41514
41798
  const className = readNonEmptyString(args, "className");
41515
41799
  const script = readNonEmptyString(args, "script");
@@ -41538,7 +41822,7 @@ class GodotServer {
41538
41822
  limit: readPositiveNumber(args, "limit") ?? 100,
41539
41823
  ...property === undefined ? {} : { property },
41540
41824
  ...includeHidden === undefined ? {} : { include_hidden: includeHidden }
41541
- });
41825
+ }, patience);
41542
41826
  }
41543
41827
  async handleRuntimeWait(op, args) {
41544
41828
  if (op === "frames") {
@@ -41573,7 +41857,7 @@ async function runGodotServer() {
41573
41857
  const server = new GodotServer;
41574
41858
  await server.run();
41575
41859
  }
41576
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 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/";
41860
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, HANDOVER_MS, HANDOVER_POLL_MS = 250, 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/";
41577
41861
  var init_server3 = __esm(() => {
41578
41862
  init_mcp();
41579
41863
  init_stdio2();
@@ -41620,6 +41904,7 @@ var init_server3 = __esm(() => {
41620
41904
  boolean: "a boolean",
41621
41905
  object: "an object"
41622
41906
  };
41907
+ HANDOVER_MS = SUCCESSOR_CHECK_MS + 5000;
41623
41908
  PATH_SOLUTIONS = [
41624
41909
  'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
41625
41910
  "Point projectPath at the project the file belongs to"