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/index.js CHANGED
@@ -25504,14 +25504,43 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
25504
25504
  };
25505
25505
  }
25506
25506
  var nextRequestId = 1;
25507
- function runtimeRequest(endpoint, command, params, timeoutMs) {
25507
+ var LATE_ANSWER_MS = 30 * 60000;
25508
+ var LATE_KEPT = 50;
25509
+ var lateAnswers = new Map;
25510
+ function keepLate(id, answer) {
25511
+ lateAnswers.delete(id);
25512
+ lateAnswers.set(id, answer);
25513
+ while (lateAnswers.size > LATE_KEPT) {
25514
+ const oldest = lateAnswers.keys().next().value;
25515
+ if (oldest === undefined) {
25516
+ break;
25517
+ }
25518
+ lateAnswers.delete(oldest);
25519
+ }
25520
+ }
25521
+ function lateAnswer(id) {
25522
+ return lateAnswers.get(id);
25523
+ }
25524
+ function runtimeRequest(endpoint, command, params, timeoutMs, keepListening = false) {
25508
25525
  const id = nextRequestId++;
25526
+ const sentAt = Date.now();
25509
25527
  return new Promise((settle) => {
25510
25528
  let done = false;
25529
+ let late = false;
25511
25530
  let buffered = "";
25512
25531
  let welcomed = false;
25513
25532
  const socket = createConnection({ port: endpoint.port, host: endpoint.address });
25533
+ let lateTimer = null;
25514
25534
  const finish = (reply) => {
25535
+ if (late) {
25536
+ if (lateTimer !== null) {
25537
+ clearTimeout(lateTimer);
25538
+ }
25539
+ socket.destroy();
25540
+ keepLate(id, reply.ok || reply.reason === "error" ? { state: "answered", command, sentAt, answeredAt: Date.now(), reply } : { state: "never", command, sentAt, why: reply.message });
25541
+ late = false;
25542
+ return;
25543
+ }
25515
25544
  if (done) {
25516
25545
  return;
25517
25546
  }
@@ -25521,10 +25550,32 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
25521
25550
  settle(reply);
25522
25551
  };
25523
25552
  const timer = setTimeout(() => {
25524
- finish({
25553
+ 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.`;
25554
+ if (!keepListening) {
25555
+ finish({
25556
+ ok: false,
25557
+ reason: "busy",
25558
+ message: `${still} It is either working through something long or held at a breakpoint.`
25559
+ });
25560
+ return;
25561
+ }
25562
+ done = true;
25563
+ late = true;
25564
+ keepLate(id, { state: "waiting", command, sentAt });
25565
+ lateTimer = setTimeout(() => {
25566
+ finish({
25567
+ ok: false,
25568
+ reason: "busy",
25569
+ message: `No answer came within ${LATE_ANSWER_MS / 60000} minutes.`
25570
+ });
25571
+ }, LATE_ANSWER_MS);
25572
+ lateTimer.unref();
25573
+ socket.unref();
25574
+ settle({
25525
25575
  ok: false,
25526
25576
  reason: "busy",
25527
- 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.`
25577
+ requestId: id,
25578
+ 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.`
25528
25579
  });
25529
25580
  }, timeoutMs);
25530
25581
  socket.setEncoding("utf8");
@@ -25536,7 +25587,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
25536
25587
  buffered += chunk;
25537
25588
  let newline = buffered.indexOf(`
25538
25589
  `);
25539
- while (newline !== -1 && !done) {
25590
+ while (newline !== -1 && (!done || late)) {
25540
25591
  const line = buffered.slice(0, newline).trim();
25541
25592
  buffered = buffered.slice(newline + 1);
25542
25593
  newline = buffered.indexOf(`
@@ -27107,7 +27158,10 @@ class GodotBridge extends EventEmitter {
27107
27158
  get port() {
27108
27159
  return this.boundPort ?? this.wantedPort;
27109
27160
  }
27110
- async start() {
27161
+ get configuredPort() {
27162
+ return this.wantedPort;
27163
+ }
27164
+ async start(moveIfHeld = true) {
27111
27165
  if (this.httpServer) {
27112
27166
  return;
27113
27167
  }
@@ -27115,7 +27169,7 @@ class GodotBridge extends EventEmitter {
27115
27169
  await this.listenOn(this.wantedPort);
27116
27170
  } catch (error) {
27117
27171
  const held = error instanceof Error && "code" in error && error.code === "EADDRINUSE";
27118
- if (!held || !this.mayMove) {
27172
+ if (!held || !this.mayMove || !moveIfHeld) {
27119
27173
  throw error;
27120
27174
  }
27121
27175
  this.log("warn", `Editor bridge port ${this.wantedPort} is held; taking another one.`);
@@ -28189,6 +28243,81 @@ function whyNoReport(printed, asked) {
28189
28243
  }
28190
28244
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
28191
28245
  }
28246
+ var ESCAPE = String.fromCharCode(27);
28247
+ var PRINTED_ACTUAL = new RegExp(` but was\\n '([\\s\\S]*?)'((?:${ESCAPE}\\[[0-9;]*m)*)\\tat ('[^'\\n]*' in \\S+:\\d+)`, "g");
28248
+ var COLOUR = new RegExp(`${ESCAPE}\\[([0-9;]*)m`, "g");
28249
+ var SPELLED = {
28250
+ "<LF>": `
28251
+ `,
28252
+ "<CR>": "\r",
28253
+ "<TAB>": "\t",
28254
+ "<BS>": "\b",
28255
+ "<FF>": "\f",
28256
+ "<VT>": "\v",
28257
+ "<BEL>": "\x07",
28258
+ "<ESC>": ESCAPE,
28259
+ "<DEL>": ""
28260
+ };
28261
+ function unspelled(text) {
28262
+ 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);
28263
+ }
28264
+ function readDiff(printed) {
28265
+ let merged = "";
28266
+ let actual = "";
28267
+ let background = null;
28268
+ let last = 0;
28269
+ const take = (text) => {
28270
+ merged += text;
28271
+ if (background === null) {
28272
+ actual += text;
28273
+ } else if (background === "added") {
28274
+ actual += unspelled(text);
28275
+ }
28276
+ };
28277
+ for (const match of printed.matchAll(COLOUR)) {
28278
+ take(printed.slice(last, match.index));
28279
+ last = match.index + match[0].length;
28280
+ const codes = (match[1] ?? "").split(";");
28281
+ if (codes[0] === "48" && codes[1] === "2") {
28282
+ background = Number(codes[2]) > Number(codes[3]) ? "missing" : "added";
28283
+ } else if (codes[0] === "0" || codes[0] === "") {
28284
+ background = null;
28285
+ }
28286
+ }
28287
+ take(printed.slice(last));
28288
+ return { merged, actual };
28289
+ }
28290
+ var 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.]";
28291
+ function withActualsPrinted(failed, printed) {
28292
+ const diffs = [...printed.replaceAll(`\r
28293
+ `, `
28294
+ `).matchAll(PRINTED_ACTUAL)].map((match) => ({
28295
+ at: match[3] ?? "",
28296
+ ...readDiff(match[1] ?? "")
28297
+ }));
28298
+ return failed.map((entry) => {
28299
+ if (entry.detail === null) {
28300
+ return entry;
28301
+ }
28302
+ let detail = entry.detail;
28303
+ for (const diff of diffs) {
28304
+ const wrong = ` but was
28305
+ '${diff.merged.replaceAll("\r", "")}'
28306
+ at ${diff.at}`;
28307
+ if (detail.includes(wrong)) {
28308
+ detail = detail.replace(wrong, () => ` but was
28309
+ '${diff.actual}'
28310
+ at ${diff.at}`);
28311
+ }
28312
+ }
28313
+ const alike = /Expecting:\n '([\s\S]*)'\n but was\n '\1'\n\tat /.test(detail);
28314
+ if (alike) {
28315
+ detail = `${detail}
28316
+ ${BOTH_SIDES_ALIKE_NOTE}`;
28317
+ }
28318
+ return detail === entry.detail ? entry : { ...entry, detail };
28319
+ });
28320
+ }
28192
28321
  var RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
28193
28322
  var ORPHANS = /(\d+)\s+orphans/;
28194
28323
  var OVERALL = "Overall Summary";
@@ -29257,14 +29386,19 @@ function editorNoteAt(path) {
29257
29386
  }
29258
29387
  return { projectPath, transcript, startedAt };
29259
29388
  }
29260
- function recordRunEnded(pid, exitCode) {
29389
+ function recordRunEnded(pid, ending) {
29261
29390
  const path = recordPath();
29262
29391
  const record = recordAt(path);
29263
29392
  if (record === null || record.pid !== pid) {
29264
29393
  return;
29265
29394
  }
29395
+ const written = {
29396
+ ...record,
29397
+ ...ending.exitCode === null ? {} : { exitCode: ending.exitCode },
29398
+ ...ending.exitSignal === null ? {} : { exitSignal: ending.exitSignal }
29399
+ };
29266
29400
  try {
29267
- writeFileSync9(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
29401
+ writeFileSync9(path, JSON.stringify(written, null, 2), "utf8");
29268
29402
  } catch {}
29269
29403
  }
29270
29404
  function readRunRecord() {
@@ -29301,7 +29435,8 @@ function recordAt(path) {
29301
29435
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
29302
29436
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
29303
29437
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
29304
- ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {}
29438
+ ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
29439
+ ...typeof fields["exitSignal"] === "string" ? { exitSignal: fields["exitSignal"] } : {}
29305
29440
  };
29306
29441
  }
29307
29442
  function runningAs(pid) {
@@ -29715,7 +29850,7 @@ var TILEMAP_CELLS = {
29715
29850
  additionalProperties: false
29716
29851
  }
29717
29852
  };
29718
- var COLOUR = {
29853
+ var COLOUR2 = {
29719
29854
  type: "object",
29720
29855
  description: "set_theme_color: r, g and b from 0 to 1, with a optional and opaque by default.",
29721
29856
  properties: { r: { type: "number" }, g: { type: "number" }, b: { type: "number" }, a: { type: "number" } },
@@ -30145,7 +30280,7 @@ var TOOL_SPECS = [
30145
30280
  sources: TILESET_SOURCES,
30146
30281
  controlType: { type: "string", description: 'Theme ops: the Control class, such as "Button".' },
30147
30282
  colorName: { type: "string", description: 'set_theme_color: such as "font_color".' },
30148
- color: COLOUR,
30283
+ color: COLOUR2,
30149
30284
  fontSizeName: { type: "string", description: 'set_theme_font_size: such as "font_size".' },
30150
30285
  size: { type: "number", description: "set_theme_font_size: pixels." }
30151
30286
  },
@@ -30241,7 +30376,7 @@ var TOOL_SPECS = [
30241
30376
  },
30242
30377
  {
30243
30378
  name: "editor_output",
30244
- 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.",
30379
+ 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.",
30245
30380
  parameters: {
30246
30381
  severity: {
30247
30382
  type: "string",
@@ -30287,7 +30422,7 @@ var TOOL_SPECS = [
30287
30422
  },
30288
30423
  {
30289
30424
  name: "editor_status",
30290
- 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.",
30425
+ 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.",
30291
30426
  parameters: {},
30292
30427
  requires: []
30293
30428
  },
@@ -30361,7 +30496,7 @@ var TOOL_SPECS = [
30361
30496
  property: {
30362
30497
  type: "string",
30363
30498
  ops: ["property", "find"],
30364
- 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.'
30499
+ 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.'
30365
30500
  },
30366
30501
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
30367
30502
  includeProperties: {
@@ -30407,6 +30542,10 @@ var TOOL_SPECS = [
30407
30542
  items: { type: "string" },
30408
30543
  ops: ["metrics"],
30409
30544
  description: "metrics: which to read. Default all."
30545
+ },
30546
+ timeoutMs: {
30547
+ type: "number",
30548
+ 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."
30410
30549
  }
30411
30550
  },
30412
30551
  requires: [],
@@ -30434,20 +30573,24 @@ var TOOL_SPECS = [
30434
30573
  },
30435
30574
  {
30436
30575
  name: "runtime_invoke",
30437
- description: "Sets a property or calls a method on a node in the running game. Needs the game running with the runtime addon.",
30576
+ 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.",
30438
30577
  parameters: {
30439
30578
  projectPath: RUNNING_PROJECT_PATH,
30440
30579
  pid: RUNNING_PID,
30441
- nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
30580
+ nodePath: {
30581
+ type: "string",
30582
+ ops: ["set", "call"],
30583
+ description: 'Absolute node path, such as "/root/Main/Player".'
30584
+ },
30442
30585
  property: {
30443
30586
  type: "string",
30444
30587
  ops: ["set"],
30445
- 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.'
30588
+ 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.'
30446
30589
  },
30447
30590
  value: {
30448
30591
  ops: ["set"],
30449
30592
  blank: true,
30450
- 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.`
30593
+ 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.`
30451
30594
  },
30452
30595
  method: {
30453
30596
  type: "string",
@@ -30457,18 +30600,32 @@ var TOOL_SPECS = [
30457
30600
  args: {
30458
30601
  type: "array",
30459
30602
  ops: ["call"],
30460
- 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.`
30603
+ 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.`
30604
+ },
30605
+ timeoutMs: {
30606
+ type: "number",
30607
+ ops: ["set", "call"],
30608
+ 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."
30609
+ },
30610
+ requestId: {
30611
+ type: "number",
30612
+ ops: ["result"],
30613
+ description: "result: the requestId a pending answer gave."
30461
30614
  }
30462
30615
  },
30463
- requires: ["nodePath"],
30616
+ requires: [],
30464
30617
  operations: {
30465
30618
  set: {
30466
30619
  summary: "set a property, on a node or on an object it holds",
30467
- requires: ["property", "value"]
30620
+ requires: ["nodePath", "property", "value"]
30468
30621
  },
30469
30622
  call: {
30470
30623
  summary: "call a method, on a node or on an object it holds, and return its result",
30471
- requires: ["method"]
30624
+ requires: ["nodePath", "method"]
30625
+ },
30626
+ result: {
30627
+ summary: "collect the reply to a set or call whose wait ran out, by the requestId it answered with",
30628
+ requires: ["requestId"]
30472
30629
  }
30473
30630
  }
30474
30631
  },
@@ -30800,8 +30957,21 @@ function describeHalt(halt) {
30800
30957
  return `stopped with reason "${halt.reason}"`;
30801
30958
  }
30802
30959
  }
30960
+ function exited(run) {
30961
+ return run.exitCode !== null || run.exitSignal !== null;
30962
+ }
30963
+ function endingOf(failed) {
30964
+ return {
30965
+ exitCode: typeof failed.code === "number" ? failed.code : null,
30966
+ exitSignal: typeof failed.signal === "string" ? failed.signal : null,
30967
+ failure: typeof failed.code === "string" ? failed.code : null
30968
+ };
30969
+ }
30970
+ function howItExited(run) {
30971
+ return run.exitSignal === null ? `exit code ${run.exitCode ?? "unknown"}` : `ended by ${run.exitSignal}, with no exit code`;
30972
+ }
30803
30973
  function stillRunning(run) {
30804
- if (run?.exitCode !== null || run.endedUnwatched === true) {
30974
+ if (run === null || exited(run) || run.endedUnwatched === true) {
30805
30975
  return false;
30806
30976
  }
30807
30977
  if (typeof run.endedHere === "string") {
@@ -30832,7 +31002,17 @@ function runIsUp(run, editorSays, now = Date.now()) {
30832
31002
  return editorSays ?? stillRunning(run);
30833
31003
  }
30834
31004
  function noCodeWillCome(run) {
30835
- return run.exitCode === null && run.process === null;
31005
+ return !exited(run) && run.process === null;
31006
+ }
31007
+ var STOP_WAIT_MS = 1e4;
31008
+ var EXIT_REPORTED_WITHIN_MS = 2000;
31009
+ async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
31010
+ const gone = () => (handle?.exitCode !== null || handle.signalCode !== null) && pids.every((pid) => !alive(pid));
31011
+ const deadline = Date.now() + withinMs;
31012
+ while (!gone() && Date.now() < deadline) {
31013
+ await delay3(50);
31014
+ }
31015
+ return gone();
30836
31016
  }
30837
31017
  function endedWithoutACode(run) {
30838
31018
  if (!run.throughEditor) {
@@ -31019,6 +31199,8 @@ function outsideTheirLists(spec, args) {
31019
31199
  }
31020
31200
  var BRIDGE_RETRY_MS = 2000;
31021
31201
  var SUCCESSOR_CHECK_MS = 1e4;
31202
+ var HANDOVER_MS = SUCCESSOR_CHECK_MS + 5000;
31203
+ var HANDOVER_POLL_MS = 250;
31022
31204
  var CACHE_WRITE_MS = 2000;
31023
31205
  var PLAYING_STATUS_MS = 1000;
31024
31206
  var SCAN_START_MS = 2000;
@@ -31152,6 +31334,8 @@ class GodotServer {
31152
31334
  launchedFrom = null;
31153
31335
  ownProject;
31154
31336
  announcedAt = null;
31337
+ handingOverFrom = null;
31338
+ notHandedOverBy = null;
31155
31339
  constructor() {
31156
31340
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
31157
31341
  this.godotBridge = getDefaultBridge(this.ownProject);
@@ -31198,6 +31382,7 @@ class GodotServer {
31198
31382
  });
31199
31383
  console.error("gdharness running on stdio");
31200
31384
  try {
31385
+ await this.takeOverFromAPredecessor();
31201
31386
  await this.godotBridge.start();
31202
31387
  this.bridgeStartupError = null;
31203
31388
  const bridgeStatus = this.godotBridge.getStatus();
@@ -31212,6 +31397,35 @@ class GodotServer {
31212
31397
  this.keepTryingTheBridge();
31213
31398
  }
31214
31399
  }
31400
+ async takeOverFromAPredecessor() {
31401
+ if (this.ownProject === null) {
31402
+ return;
31403
+ }
31404
+ const wanted = this.godotBridge.configuredPort;
31405
+ const announced = readAnnouncement(announcementPath(this.ownProject));
31406
+ if (announced === null || announced.pid === process.pid || announced.port !== wanted || !alive(announced.pid)) {
31407
+ return;
31408
+ }
31409
+ this.handingOverFrom = announced.pid;
31410
+ this.announcedAt = announceBridge(this.ownProject, {
31411
+ host: this.godotBridge.getStatus().host,
31412
+ port: wanted,
31413
+ version: SERVER_VERSION
31414
+ });
31415
+ const until = Date.now() + HANDOVER_MS;
31416
+ let taken = false;
31417
+ while (!taken && Date.now() < until && !this.shutdownInitiated) {
31418
+ try {
31419
+ await this.godotBridge.start(false);
31420
+ taken = true;
31421
+ this.logDebug(`Took port ${wanted} over from pid ${announced.pid}`);
31422
+ } catch {
31423
+ await delay3(HANDOVER_POLL_MS);
31424
+ }
31425
+ }
31426
+ this.handingOverFrom = null;
31427
+ this.notHandedOverBy = taken ? null : announced.pid;
31428
+ }
31215
31429
  keepTryingTheBridge() {
31216
31430
  if (this.bridgeRetry !== null || this.shutdownInitiated) {
31217
31431
  return;
@@ -31670,7 +31884,8 @@ class GodotServer {
31670
31884
  return await this.handleEditorStatus();
31671
31885
  case "editor_rescan":
31672
31886
  return await this.handleRescanFilesystem(args);
31673
- case "runtime_inspect":
31887
+ case "runtime_inspect": {
31888
+ const patience = readPositiveNumber(args, "timeoutMs") ?? this.runtimeTimeoutMs();
31674
31889
  switch (op) {
31675
31890
  case "tree":
31676
31891
  return await this.handleRuntimeCommand("get_tree", {
@@ -31679,45 +31894,45 @@ class GodotServer {
31679
31894
  depth: readPositiveNumber(args, "depth") ?? 3,
31680
31895
  include_properties: readBoolean(args, "includeProperties") ?? false,
31681
31896
  properties: readArray(args, "properties") ?? []
31682
- });
31897
+ }, patience);
31683
31898
  case "find":
31684
- return await this.handleFindRuntimeNodes(args);
31899
+ return await this.handleFindRuntimeNodes(args, patience);
31685
31900
  case "text":
31686
31901
  return await this.handleRuntimeCommand("read_text", {
31687
31902
  ...whichGame(args),
31688
31903
  root: readNonEmptyString(args, "nodePath") ?? "/root",
31689
31904
  include_hidden: readBoolean(args, "includeHidden") ?? false,
31690
31905
  limit: readPositiveNumber(args, "limit") ?? 500
31691
- });
31906
+ }, patience);
31692
31907
  case "rect":
31693
- return await this.handleRuntimeCommand("get_rect", {
31694
- ...whichGame(args),
31695
- path: readNonEmptyString(args, "nodePath") ?? ""
31696
- });
31908
+ return await this.handleRuntimeCommand("get_rect", { ...whichGame(args), path: readNonEmptyString(args, "nodePath") ?? "" }, patience);
31697
31909
  case "property":
31698
31910
  return await this.handleRuntimeCommand("get_property", {
31699
31911
  ...whichGame(args),
31700
31912
  path: readNonEmptyString(args, "nodePath") ?? "",
31701
31913
  property: readNonEmptyString(args, "property") ?? ""
31702
- });
31914
+ }, patience);
31703
31915
  default:
31704
- return await this.handleRuntimeCommand("get_metrics", {
31705
- ...whichGame(args),
31706
- metrics: readArray(args, "metrics") ?? []
31707
- });
31916
+ return await this.handleRuntimeCommand("get_metrics", { ...whichGame(args), metrics: readArray(args, "metrics") ?? [] }, patience);
31917
+ }
31918
+ }
31919
+ case "runtime_invoke": {
31920
+ if (op === "result") {
31921
+ return this.handleLateAnswer(args);
31708
31922
  }
31709
- case "runtime_invoke":
31923
+ const patience = readPositiveNumber(args, "timeoutMs") ?? this.runtimeTimeoutMs();
31710
31924
  return op === "set" ? await this.handleRuntimeCommand("set_property", {
31711
31925
  ...whichGame(args),
31712
31926
  path: readNonEmptyString(args, "nodePath") ?? "",
31713
31927
  property: readString(args, "property") ?? "",
31714
31928
  value: args["value"]
31715
- }) : await this.handleRuntimeCommand("call_method", {
31929
+ }, patience) : await this.handleRuntimeCommand("call_method", {
31716
31930
  ...whichGame(args),
31717
31931
  path: readNonEmptyString(args, "nodePath") ?? "",
31718
31932
  method: readString(args, "method") ?? "",
31719
31933
  args: readArray(args, "args") ?? []
31720
- });
31934
+ }, patience);
31935
+ }
31721
31936
  case "runtime_capture":
31722
31937
  return await this.handleRuntimeCommand(op === "screenshot" ? "capture_screenshot" : "capture_viewport", args);
31723
31938
  case "runtime_input":
@@ -31868,10 +32083,10 @@ class GodotServer {
31868
32083
  }
31869
32084
  async debuggedGame(intent) {
31870
32085
  const game = this.activeProcess;
31871
- if (game !== null && game.exitCode !== null) {
32086
+ if (game !== null && exited(game)) {
31872
32087
  return {
31873
32088
  ok: false,
31874
- response: this.createErrorResponse(`The last run has already finished (exit code ${game.exitCode}), so there is no debug session to answer for.`, [
32089
+ response: this.createErrorResponse(`The last run has already finished (${howItExited(game)}), so there is no debug session to answer for.`, [
31875
32090
  "editor_output still reads what it printed, until the next run starts",
31876
32091
  "editor_run start plays another, which the debugger can hold"
31877
32092
  ])
@@ -32145,10 +32360,11 @@ class GodotServer {
32145
32360
  ];
32146
32361
  this.logDebug(`Exporting: ${engine.value} ${exportArgs.join(" ")}`);
32147
32362
  const log = new GameLog;
32148
- let exitCode = 0;
32363
+ let ending = { exitCode: 0, exitSignal: null, failure: null };
32149
32364
  try {
32150
32365
  const { stdout, stderr } = await run5(engine.value, exportArgs, {
32151
32366
  timeout: 300000,
32367
+ maxBuffer: 64 * 1024 * 1024,
32152
32368
  signal: callSignal()
32153
32369
  });
32154
32370
  log.append("stdout", stdout);
@@ -32160,26 +32376,32 @@ class GodotServer {
32160
32376
  const failed = error;
32161
32377
  log.append("stdout", failed.stdout);
32162
32378
  log.append("stderr", failed.stderr);
32163
- exitCode = typeof failed.code === "number" ? failed.code : -1;
32379
+ ending = endingOf(failed);
32164
32380
  } finally {
32165
32381
  discard(exportLogs);
32166
32382
  }
32167
32383
  log.finish();
32168
32384
  const problems = log.select({ severity: "warning", sinceLastCall: false, limit: 200 });
32169
32385
  const verdict = {
32170
- exported: exitCode === 0 && log.count("error") === 0 && existsSync10(output.absolutePath),
32386
+ exported: ending.exitCode === 0 && log.count("error") === 0 && existsSync10(output.absolutePath),
32171
32387
  preset,
32172
32388
  outputPath: output.relativePath,
32173
32389
  debug,
32174
- exitCode,
32390
+ exitCode: ending.exitCode,
32391
+ exitSignal: ending.exitSignal ?? undefined,
32392
+ failure: ending.failure ?? undefined,
32175
32393
  errors: log.count("error"),
32176
32394
  warnings: log.count("warning"),
32177
32395
  entries: forAnswer(problems.entries)
32178
32396
  };
32179
32397
  if (!verdict.exported) {
32398
+ 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.`;
32180
32399
  return {
32181
32400
  content: [
32182
- { type: "text", text: `Export with preset '${preset}' did not produce ${output.relativePath}.` },
32401
+ {
32402
+ type: "text",
32403
+ text: `Export with preset '${preset}' did not produce ${output.relativePath}.${why}`
32404
+ },
32183
32405
  { type: "text", text: JSON.stringify(verdict, null, 2) }
32184
32406
  ],
32185
32407
  isError: true
@@ -32234,6 +32456,10 @@ class GodotServer {
32234
32456
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
32235
32457
  const userData = mkdtempSync2(join16(tmpdir4(), "gdharness-tests-"));
32236
32458
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
32459
+ const console2 = [];
32460
+ run.process.stdout?.on("data", (data) => {
32461
+ console2.push(data);
32462
+ });
32237
32463
  const hung = await new Promise((resolve) => {
32238
32464
  const timer = setTimeout(() => {
32239
32465
  run.process.kill();
@@ -32278,7 +32504,7 @@ class GodotServer {
32278
32504
  const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
32279
32505
  const said = printed.map((entry) => entry.text);
32280
32506
  const nothingRan = hung ? null : whyNoReport(said, asked);
32281
- const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? verdicts[exitCode ?? -1] ?? `exit ${exitCode ?? "unknown"}`;
32507
+ const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
32282
32508
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
32283
32509
  if (report === null) {
32284
32510
  const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
@@ -32292,6 +32518,7 @@ class GodotServer {
32292
32518
  verdict,
32293
32519
  tests: 0,
32294
32520
  exitCode,
32521
+ exitSignal: run.exitSignal ?? undefined,
32295
32522
  hung,
32296
32523
  arguments: cmdArgs,
32297
32524
  entries: forAnswer(printed.slice(0, 60)),
@@ -32302,7 +32529,7 @@ class GodotServer {
32302
32529
  isError: true
32303
32530
  };
32304
32531
  }
32305
- const failed = report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path })));
32532
+ 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"));
32306
32533
  const orphans = orphansPrinted(said);
32307
32534
  const leftBehind = new Map(orphans.suites.map((suite) => [suite.path, suite.orphans]));
32308
32535
  const orphansIn = (path) => path === null ? 0 : leftBehind.get(path) ?? 0;
@@ -32319,6 +32546,7 @@ class GodotServer {
32319
32546
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
32320
32547
  verdict,
32321
32548
  exitCode,
32549
+ exitSignal: run.exitSignal ?? undefined,
32322
32550
  tests: report.tests,
32323
32551
  failures: report.failures,
32324
32552
  errors: report.errors,
@@ -32585,7 +32813,12 @@ class GodotServer {
32585
32813
  awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
32586
32814
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
32587
32815
  startupError: this.bridgeStartupError,
32588
- 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.`,
32816
+ 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.`,
32817
+ bridgeHandover: this.handingOverFrom === null ? undefined : {
32818
+ fromPid: this.handingOverFrom,
32819
+ port: this.godotBridge.configuredPort,
32820
+ 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.`
32821
+ },
32589
32822
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
32590
32823
  addonNote: status.connected ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
32591
32824
  ...this.breakpointsAtRisk(),
@@ -33045,6 +33278,7 @@ class GodotServer {
33045
33278
  projectPath,
33046
33279
  startedAt,
33047
33280
  exitCode: null,
33281
+ exitSignal: null,
33048
33282
  throughEditor: true,
33049
33283
  brokeOn: null,
33050
33284
  announcedBefore: alreadyPlaying,
@@ -33137,24 +33371,25 @@ class GodotServer {
33137
33371
  const recorded = running?.process === null ? readRunRecord() : null;
33138
33372
  clearRunRecord((record) => this.couldBeOurs(record.projectPath));
33139
33373
  if (!running || !going) {
33140
- return;
33374
+ return true;
33141
33375
  }
33142
33376
  running.endedHere = reason;
33377
+ const announced = this.announcedPidOf(running);
33143
33378
  if (running.throughEditor) {
33144
33379
  await this.handleViaBridge("stop_playing", {});
33145
- return;
33380
+ return await untilGone(null, announced === undefined ? [] : [announced]);
33146
33381
  }
33147
33382
  if (running.process !== null) {
33148
33383
  running.process.kill();
33149
- return;
33384
+ return await untilGone(running.process, announced === undefined ? [] : [announced]);
33150
33385
  }
33151
33386
  if (running.pid === null) {
33152
- return;
33387
+ return true;
33153
33388
  }
33154
33389
  if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
33155
33390
  running.endedHere = null;
33156
33391
  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.`);
33157
- return;
33392
+ return true;
33158
33393
  }
33159
33394
  const signalled = runningAs(running.pid);
33160
33395
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
@@ -33164,7 +33399,9 @@ class GodotServer {
33164
33399
  } catch {
33165
33400
  landed = false;
33166
33401
  }
33402
+ const gone = await untilGone(null, [running.pid]);
33167
33403
  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}.`);
33404
+ return gone;
33168
33405
  }
33169
33406
  spawnGame(godotPath, cmdArgs, env) {
33170
33407
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -33195,6 +33432,7 @@ class GodotServer {
33195
33432
  projectPath: null,
33196
33433
  startedAt: Date.now(),
33197
33434
  exitCode: null,
33435
+ exitSignal: null,
33198
33436
  throughEditor: false,
33199
33437
  brokeOn: null
33200
33438
  };
@@ -33204,13 +33442,11 @@ class GodotServer {
33204
33442
  child.stderr.on("data", (data) => {
33205
33443
  log.append("stderr", data);
33206
33444
  });
33207
- child.on("exit", (code) => {
33208
- this.logDebug(`Godot process exited with code ${code ?? "none"}`);
33445
+ child.on("exit", (code, signal) => {
33446
+ this.logDebug(`Godot process exited with code ${code ?? "none"}, signal ${signal ?? "none"}`);
33209
33447
  log.finish();
33210
- started.exitCode = code ?? -1;
33211
- if (started.pid !== null) {
33212
- recordRunEnded(started.pid, started.exitCode);
33213
- }
33448
+ started.exitCode = code;
33449
+ started.exitSignal = code === null ? signal : null;
33214
33450
  });
33215
33451
  child.on("error", (err) => {
33216
33452
  console.error("Failed to start Godot process:", err);
@@ -33244,14 +33480,19 @@ class GodotServer {
33244
33480
  projectPath,
33245
33481
  startedAt,
33246
33482
  exitCode: null,
33483
+ exitSignal: null,
33247
33484
  throughEditor: false,
33248
33485
  brokeOn: null
33249
33486
  };
33250
- child.on("exit", (code) => {
33251
- this.logDebug(`Godot process exited with code ${code ?? "none"}`);
33487
+ child.on("exit", (code, signal) => {
33488
+ this.logDebug(`Godot process exited with code ${code ?? "none"}, signal ${signal ?? "none"}`);
33252
33489
  this.drainTranscript(started);
33253
33490
  log.finish();
33254
- started.exitCode = code ?? -1;
33491
+ started.exitCode = code;
33492
+ started.exitSignal = code === null ? signal : null;
33493
+ if (started.pid !== null) {
33494
+ recordRunEnded(started.pid, { exitCode: started.exitCode, exitSignal: started.exitSignal });
33495
+ }
33255
33496
  });
33256
33497
  child.on("error", (err) => {
33257
33498
  console.error("Failed to start Godot process:", err);
@@ -33334,6 +33575,7 @@ class GodotServer {
33334
33575
  projectPath: project,
33335
33576
  startedAt: note?.startedAt ?? Date.now(),
33336
33577
  exitCode: null,
33578
+ exitSignal: null,
33337
33579
  throughEditor: true,
33338
33580
  brokeOn: null,
33339
33581
  pickedUpPlaying: true
@@ -33470,6 +33712,7 @@ class GodotServer {
33470
33712
  projectPath: record.projectPath === "" ? null : record.projectPath,
33471
33713
  startedAt: record.startedAt,
33472
33714
  exitCode: null,
33715
+ exitSignal: null,
33473
33716
  throughEditor: false,
33474
33717
  brokeOn: null
33475
33718
  };
@@ -33477,7 +33720,8 @@ class GodotServer {
33477
33720
  if (!alive(record.pid) || !couldStillBeTheRecordedRun(record)) {
33478
33721
  adopted.log.finish();
33479
33722
  adopted.exitCode = record.exitCode ?? null;
33480
- adopted.endedUnwatched = record.exitCode === undefined;
33723
+ adopted.exitSignal = record.exitSignal ?? null;
33724
+ adopted.endedUnwatched = !exited(adopted);
33481
33725
  }
33482
33726
  this.activeProcess = adopted;
33483
33727
  return adopted;
@@ -33576,6 +33820,7 @@ class GodotServer {
33576
33820
  booted: !hung && boot.exitCode === 0 && errors === 0,
33577
33821
  hung,
33578
33822
  exitCode: boot.exitCode,
33823
+ exitSignal: boot.exitSignal ?? undefined,
33579
33824
  durationMs: Date.now() - boot.startedAt,
33580
33825
  frames,
33581
33826
  errors,
@@ -33602,7 +33847,11 @@ class GodotServer {
33602
33847
  }
33603
33848
  async runStillGoing(run, editorSays) {
33604
33849
  if (!run.throughEditor) {
33605
- return stillRunning(run);
33850
+ const going = stillRunning(run);
33851
+ if (!going && run.process !== null && !exited(run)) {
33852
+ await untilGone(run.process, [], EXIT_REPORTED_WITHIN_MS);
33853
+ }
33854
+ return going;
33606
33855
  }
33607
33856
  const announced = this.announcedPidOf(run);
33608
33857
  if (announced !== undefined && !alive(announced)) {
@@ -33710,8 +33959,8 @@ class GodotServer {
33710
33959
  }
33711
33960
  if (typeof run.endedHere === "string") {
33712
33961
  notes.push(`This run was ended here, by ${run.endedHere}.`);
33713
- } else if (!stillRunning(run) && !run.throughEditor && run.exitCode !== null) {
33714
- 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.");
33962
+ } else if (!stillRunning(run) && !run.throughEditor && exited(run)) {
33963
+ 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.`);
33715
33964
  }
33716
33965
  if (run.endedUnwatched === true) {
33717
33966
  notes.push(endedWithoutACode({
@@ -33734,6 +33983,7 @@ class GodotServer {
33734
33983
  return this.jsonTextResponse({
33735
33984
  running: going,
33736
33985
  exitCode: run.exitCode,
33986
+ exitSignal: run.exitSignal ?? undefined,
33737
33987
  through: run.throughEditor ? "editor" : "gdharness",
33738
33988
  pid: run.pid ?? announced ?? run.announcedPid ?? null,
33739
33989
  errors: run.log.count("error"),
@@ -33766,7 +34016,7 @@ class GodotServer {
33766
34016
  const endedPid = stopped.pid ?? announcedPid;
33767
34017
  const children = readBoolean(args, "andChildren") === true && wasRunning ? await this.whatTheRunStarted(stopped) : null;
33768
34018
  this.logDebug("Stopping the running game");
33769
- await this.endActiveGame("editor_run stop", wasRunning);
34019
+ const gone = await this.endActiveGame("editor_run stop", wasRunning);
33770
34020
  const ended = children === null ? null : endChildrenAmong(children);
33771
34021
  if (wasRunning) {
33772
34022
  for (const pid of new Set([endedPid, announcedPid])) {
@@ -33787,10 +34037,11 @@ class GodotServer {
33787
34037
  },
33788
34038
  exitedBeforeStop: !wasRunning,
33789
34039
  exitCode: stopped.exitCode,
34040
+ exitSignal: stopped.exitSignal ?? undefined,
33790
34041
  errors: stopped.log.count("error"),
33791
34042
  warnings: stopped.log.count("warning"),
33792
34043
  clean: stopped.log.count("error") === 0,
33793
- 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)}`,
34044
+ 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)}`,
33794
34045
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
33795
34046
  });
33796
34047
  }
@@ -34206,7 +34457,17 @@ class GodotServer {
34206
34457
  const screenshotDir = expectsScreenshot ? mkdtempSync2(join16(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
34207
34458
  const screenshotPath = screenshotDir ? join16(screenshotDir, "capture.png") : null;
34208
34459
  try {
34209
- const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
34460
+ const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs, !expectsScreenshot);
34461
+ if (!reply.ok && reply.requestId !== undefined) {
34462
+ return this.jsonTextResponse({
34463
+ pending: true,
34464
+ requestId: reply.requestId,
34465
+ command,
34466
+ waitedMs: timeoutMs,
34467
+ note: reply.message,
34468
+ ...answeredBy
34469
+ });
34470
+ }
34210
34471
  if (!reply.ok) {
34211
34472
  return this.createErrorResponse(reply.message);
34212
34473
  }
@@ -34242,7 +34503,36 @@ class GodotServer {
34242
34503
  discard(screenshotDir);
34243
34504
  }
34244
34505
  }
34245
- async handleFindRuntimeNodes(args) {
34506
+ handleLateAnswer(args) {
34507
+ const requestId = readPositiveNumber(args, "requestId") ?? 0;
34508
+ const late = lateAnswer(requestId);
34509
+ if (late === undefined) {
34510
+ 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.`);
34511
+ }
34512
+ if (late.state === "waiting") {
34513
+ return this.jsonTextResponse({
34514
+ pending: true,
34515
+ requestId,
34516
+ command: late.command,
34517
+ sentAgoMs: Date.now() - late.sentAt,
34518
+ note: "The game has not answered it yet. It is still being listened for, and this answers with the reply once it comes."
34519
+ });
34520
+ }
34521
+ if (late.state === "never") {
34522
+ return this.createErrorResponse(`Request ${requestId} ('${late.command}') got no answer: ${late.why} Read the state back to see whether it ran.`);
34523
+ }
34524
+ const reply = late.reply;
34525
+ if (!reply.ok) {
34526
+ return this.createErrorResponse(`Request ${requestId} ('${late.command}') was answered with a refusal: ${reply.message}`);
34527
+ }
34528
+ const { id: _id, ...payload } = reply.payload;
34529
+ return this.jsonTextResponse({
34530
+ ...payload,
34531
+ requestId,
34532
+ answeredAfterMs: late.answeredAt - late.sentAt
34533
+ });
34534
+ }
34535
+ async handleFindRuntimeNodes(args, patience) {
34246
34536
  const filters = {};
34247
34537
  const className = readNonEmptyString(args, "className");
34248
34538
  const script = readNonEmptyString(args, "script");
@@ -34271,7 +34561,7 @@ class GodotServer {
34271
34561
  limit: readPositiveNumber(args, "limit") ?? 100,
34272
34562
  ...property === undefined ? {} : { property },
34273
34563
  ...includeHidden === undefined ? {} : { include_hidden: includeHidden }
34274
- });
34564
+ }, patience);
34275
34565
  }
34276
34566
  async handleRuntimeWait(op, args) {
34277
34567
  if (op === "frames") {