gdharness 1.0.23 → 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(`
@@ -28192,6 +28243,81 @@ function whyNoReport(printed, asked) {
28192
28243
  }
28193
28244
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
28194
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
+ }
28195
28321
  var RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
28196
28322
  var ORPHANS = /(\d+)\s+orphans/;
28197
28323
  var OVERALL = "Overall Summary";
@@ -29260,14 +29386,19 @@ function editorNoteAt(path) {
29260
29386
  }
29261
29387
  return { projectPath, transcript, startedAt };
29262
29388
  }
29263
- function recordRunEnded(pid, exitCode) {
29389
+ function recordRunEnded(pid, ending) {
29264
29390
  const path = recordPath();
29265
29391
  const record = recordAt(path);
29266
29392
  if (record === null || record.pid !== pid) {
29267
29393
  return;
29268
29394
  }
29395
+ const written = {
29396
+ ...record,
29397
+ ...ending.exitCode === null ? {} : { exitCode: ending.exitCode },
29398
+ ...ending.exitSignal === null ? {} : { exitSignal: ending.exitSignal }
29399
+ };
29269
29400
  try {
29270
- writeFileSync9(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
29401
+ writeFileSync9(path, JSON.stringify(written, null, 2), "utf8");
29271
29402
  } catch {}
29272
29403
  }
29273
29404
  function readRunRecord() {
@@ -29304,7 +29435,8 @@ function recordAt(path) {
29304
29435
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
29305
29436
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
29306
29437
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
29307
- ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {}
29438
+ ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
29439
+ ...typeof fields["exitSignal"] === "string" ? { exitSignal: fields["exitSignal"] } : {}
29308
29440
  };
29309
29441
  }
29310
29442
  function runningAs(pid) {
@@ -29718,7 +29850,7 @@ var TILEMAP_CELLS = {
29718
29850
  additionalProperties: false
29719
29851
  }
29720
29852
  };
29721
- var COLOUR = {
29853
+ var COLOUR2 = {
29722
29854
  type: "object",
29723
29855
  description: "set_theme_color: r, g and b from 0 to 1, with a optional and opaque by default.",
29724
29856
  properties: { r: { type: "number" }, g: { type: "number" }, b: { type: "number" }, a: { type: "number" } },
@@ -30148,7 +30280,7 @@ var TOOL_SPECS = [
30148
30280
  sources: TILESET_SOURCES,
30149
30281
  controlType: { type: "string", description: 'Theme ops: the Control class, such as "Button".' },
30150
30282
  colorName: { type: "string", description: 'set_theme_color: such as "font_color".' },
30151
- color: COLOUR,
30283
+ color: COLOUR2,
30152
30284
  fontSizeName: { type: "string", description: 'set_theme_font_size: such as "font_size".' },
30153
30285
  size: { type: "number", description: "set_theme_font_size: pixels." }
30154
30286
  },
@@ -30244,7 +30376,7 @@ var TOOL_SPECS = [
30244
30376
  },
30245
30377
  {
30246
30378
  name: "editor_output",
30247
- 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.",
30248
30380
  parameters: {
30249
30381
  severity: {
30250
30382
  type: "string",
@@ -30364,7 +30496,7 @@ var TOOL_SPECS = [
30364
30496
  property: {
30365
30497
  type: "string",
30366
30498
  ops: ["property", "find"],
30367
- 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.'
30368
30500
  },
30369
30501
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
30370
30502
  includeProperties: {
@@ -30410,6 +30542,10 @@ var TOOL_SPECS = [
30410
30542
  items: { type: "string" },
30411
30543
  ops: ["metrics"],
30412
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."
30413
30549
  }
30414
30550
  },
30415
30551
  requires: [],
@@ -30437,11 +30573,15 @@ var TOOL_SPECS = [
30437
30573
  },
30438
30574
  {
30439
30575
  name: "runtime_invoke",
30440
- 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.",
30441
30577
  parameters: {
30442
30578
  projectPath: RUNNING_PROJECT_PATH,
30443
30579
  pid: RUNNING_PID,
30444
- 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
+ },
30445
30585
  property: {
30446
30586
  type: "string",
30447
30587
  ops: ["set"],
@@ -30461,17 +30601,31 @@ var TOOL_SPECS = [
30461
30601
  type: "array",
30462
30602
  ops: ["call"],
30463
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."
30464
30614
  }
30465
30615
  },
30466
- requires: ["nodePath"],
30616
+ requires: [],
30467
30617
  operations: {
30468
30618
  set: {
30469
30619
  summary: "set a property, on a node or on an object it holds",
30470
- requires: ["property", "value"]
30620
+ requires: ["nodePath", "property", "value"]
30471
30621
  },
30472
30622
  call: {
30473
30623
  summary: "call a method, on a node or on an object it holds, and return its result",
30474
- 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"]
30475
30629
  }
30476
30630
  }
30477
30631
  },
@@ -30803,8 +30957,21 @@ function describeHalt(halt) {
30803
30957
  return `stopped with reason "${halt.reason}"`;
30804
30958
  }
30805
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
+ }
30806
30973
  function stillRunning(run) {
30807
- if (run?.exitCode !== null || run.endedUnwatched === true) {
30974
+ if (run === null || exited(run) || run.endedUnwatched === true) {
30808
30975
  return false;
30809
30976
  }
30810
30977
  if (typeof run.endedHere === "string") {
@@ -30835,9 +31002,10 @@ function runIsUp(run, editorSays, now = Date.now()) {
30835
31002
  return editorSays ?? stillRunning(run);
30836
31003
  }
30837
31004
  function noCodeWillCome(run) {
30838
- return run.exitCode === null && run.process === null;
31005
+ return !exited(run) && run.process === null;
30839
31006
  }
30840
31007
  var STOP_WAIT_MS = 1e4;
31008
+ var EXIT_REPORTED_WITHIN_MS = 2000;
30841
31009
  async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
30842
31010
  const gone = () => (handle?.exitCode !== null || handle.signalCode !== null) && pids.every((pid) => !alive(pid));
30843
31011
  const deadline = Date.now() + withinMs;
@@ -31716,7 +31884,8 @@ class GodotServer {
31716
31884
  return await this.handleEditorStatus();
31717
31885
  case "editor_rescan":
31718
31886
  return await this.handleRescanFilesystem(args);
31719
- case "runtime_inspect":
31887
+ case "runtime_inspect": {
31888
+ const patience = readPositiveNumber(args, "timeoutMs") ?? this.runtimeTimeoutMs();
31720
31889
  switch (op) {
31721
31890
  case "tree":
31722
31891
  return await this.handleRuntimeCommand("get_tree", {
@@ -31725,45 +31894,45 @@ class GodotServer {
31725
31894
  depth: readPositiveNumber(args, "depth") ?? 3,
31726
31895
  include_properties: readBoolean(args, "includeProperties") ?? false,
31727
31896
  properties: readArray(args, "properties") ?? []
31728
- });
31897
+ }, patience);
31729
31898
  case "find":
31730
- return await this.handleFindRuntimeNodes(args);
31899
+ return await this.handleFindRuntimeNodes(args, patience);
31731
31900
  case "text":
31732
31901
  return await this.handleRuntimeCommand("read_text", {
31733
31902
  ...whichGame(args),
31734
31903
  root: readNonEmptyString(args, "nodePath") ?? "/root",
31735
31904
  include_hidden: readBoolean(args, "includeHidden") ?? false,
31736
31905
  limit: readPositiveNumber(args, "limit") ?? 500
31737
- });
31906
+ }, patience);
31738
31907
  case "rect":
31739
- return await this.handleRuntimeCommand("get_rect", {
31740
- ...whichGame(args),
31741
- path: readNonEmptyString(args, "nodePath") ?? ""
31742
- });
31908
+ return await this.handleRuntimeCommand("get_rect", { ...whichGame(args), path: readNonEmptyString(args, "nodePath") ?? "" }, patience);
31743
31909
  case "property":
31744
31910
  return await this.handleRuntimeCommand("get_property", {
31745
31911
  ...whichGame(args),
31746
31912
  path: readNonEmptyString(args, "nodePath") ?? "",
31747
31913
  property: readNonEmptyString(args, "property") ?? ""
31748
- });
31914
+ }, patience);
31749
31915
  default:
31750
- return await this.handleRuntimeCommand("get_metrics", {
31751
- ...whichGame(args),
31752
- metrics: readArray(args, "metrics") ?? []
31753
- });
31916
+ return await this.handleRuntimeCommand("get_metrics", { ...whichGame(args), metrics: readArray(args, "metrics") ?? [] }, patience);
31754
31917
  }
31755
- case "runtime_invoke":
31918
+ }
31919
+ case "runtime_invoke": {
31920
+ if (op === "result") {
31921
+ return this.handleLateAnswer(args);
31922
+ }
31923
+ const patience = readPositiveNumber(args, "timeoutMs") ?? this.runtimeTimeoutMs();
31756
31924
  return op === "set" ? await this.handleRuntimeCommand("set_property", {
31757
31925
  ...whichGame(args),
31758
31926
  path: readNonEmptyString(args, "nodePath") ?? "",
31759
31927
  property: readString(args, "property") ?? "",
31760
31928
  value: args["value"]
31761
- }) : await this.handleRuntimeCommand("call_method", {
31929
+ }, patience) : await this.handleRuntimeCommand("call_method", {
31762
31930
  ...whichGame(args),
31763
31931
  path: readNonEmptyString(args, "nodePath") ?? "",
31764
31932
  method: readString(args, "method") ?? "",
31765
31933
  args: readArray(args, "args") ?? []
31766
- });
31934
+ }, patience);
31935
+ }
31767
31936
  case "runtime_capture":
31768
31937
  return await this.handleRuntimeCommand(op === "screenshot" ? "capture_screenshot" : "capture_viewport", args);
31769
31938
  case "runtime_input":
@@ -31914,10 +32083,10 @@ class GodotServer {
31914
32083
  }
31915
32084
  async debuggedGame(intent) {
31916
32085
  const game = this.activeProcess;
31917
- if (game !== null && game.exitCode !== null) {
32086
+ if (game !== null && exited(game)) {
31918
32087
  return {
31919
32088
  ok: false,
31920
- 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.`, [
31921
32090
  "editor_output still reads what it printed, until the next run starts",
31922
32091
  "editor_run start plays another, which the debugger can hold"
31923
32092
  ])
@@ -32191,10 +32360,11 @@ class GodotServer {
32191
32360
  ];
32192
32361
  this.logDebug(`Exporting: ${engine.value} ${exportArgs.join(" ")}`);
32193
32362
  const log = new GameLog;
32194
- let exitCode = 0;
32363
+ let ending = { exitCode: 0, exitSignal: null, failure: null };
32195
32364
  try {
32196
32365
  const { stdout, stderr } = await run5(engine.value, exportArgs, {
32197
32366
  timeout: 300000,
32367
+ maxBuffer: 64 * 1024 * 1024,
32198
32368
  signal: callSignal()
32199
32369
  });
32200
32370
  log.append("stdout", stdout);
@@ -32206,26 +32376,32 @@ class GodotServer {
32206
32376
  const failed = error;
32207
32377
  log.append("stdout", failed.stdout);
32208
32378
  log.append("stderr", failed.stderr);
32209
- exitCode = typeof failed.code === "number" ? failed.code : -1;
32379
+ ending = endingOf(failed);
32210
32380
  } finally {
32211
32381
  discard(exportLogs);
32212
32382
  }
32213
32383
  log.finish();
32214
32384
  const problems = log.select({ severity: "warning", sinceLastCall: false, limit: 200 });
32215
32385
  const verdict = {
32216
- exported: exitCode === 0 && log.count("error") === 0 && existsSync10(output.absolutePath),
32386
+ exported: ending.exitCode === 0 && log.count("error") === 0 && existsSync10(output.absolutePath),
32217
32387
  preset,
32218
32388
  outputPath: output.relativePath,
32219
32389
  debug,
32220
- exitCode,
32390
+ exitCode: ending.exitCode,
32391
+ exitSignal: ending.exitSignal ?? undefined,
32392
+ failure: ending.failure ?? undefined,
32221
32393
  errors: log.count("error"),
32222
32394
  warnings: log.count("warning"),
32223
32395
  entries: forAnswer(problems.entries)
32224
32396
  };
32225
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.`;
32226
32399
  return {
32227
32400
  content: [
32228
- { 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
+ },
32229
32405
  { type: "text", text: JSON.stringify(verdict, null, 2) }
32230
32406
  ],
32231
32407
  isError: true
@@ -32280,6 +32456,10 @@ class GodotServer {
32280
32456
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
32281
32457
  const userData = mkdtempSync2(join16(tmpdir4(), "gdharness-tests-"));
32282
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
+ });
32283
32463
  const hung = await new Promise((resolve) => {
32284
32464
  const timer = setTimeout(() => {
32285
32465
  run.process.kill();
@@ -32324,7 +32504,7 @@ class GodotServer {
32324
32504
  const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
32325
32505
  const said = printed.map((entry) => entry.text);
32326
32506
  const nothingRan = hung ? null : whyNoReport(said, asked);
32327
- 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));
32328
32508
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
32329
32509
  if (report === null) {
32330
32510
  const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
@@ -32338,6 +32518,7 @@ class GodotServer {
32338
32518
  verdict,
32339
32519
  tests: 0,
32340
32520
  exitCode,
32521
+ exitSignal: run.exitSignal ?? undefined,
32341
32522
  hung,
32342
32523
  arguments: cmdArgs,
32343
32524
  entries: forAnswer(printed.slice(0, 60)),
@@ -32348,7 +32529,7 @@ class GodotServer {
32348
32529
  isError: true
32349
32530
  };
32350
32531
  }
32351
- 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"));
32352
32533
  const orphans = orphansPrinted(said);
32353
32534
  const leftBehind = new Map(orphans.suites.map((suite) => [suite.path, suite.orphans]));
32354
32535
  const orphansIn = (path) => path === null ? 0 : leftBehind.get(path) ?? 0;
@@ -32365,6 +32546,7 @@ class GodotServer {
32365
32546
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
32366
32547
  verdict,
32367
32548
  exitCode,
32549
+ exitSignal: run.exitSignal ?? undefined,
32368
32550
  tests: report.tests,
32369
32551
  failures: report.failures,
32370
32552
  errors: report.errors,
@@ -33096,6 +33278,7 @@ class GodotServer {
33096
33278
  projectPath,
33097
33279
  startedAt,
33098
33280
  exitCode: null,
33281
+ exitSignal: null,
33099
33282
  throughEditor: true,
33100
33283
  brokeOn: null,
33101
33284
  announcedBefore: alreadyPlaying,
@@ -33249,6 +33432,7 @@ class GodotServer {
33249
33432
  projectPath: null,
33250
33433
  startedAt: Date.now(),
33251
33434
  exitCode: null,
33435
+ exitSignal: null,
33252
33436
  throughEditor: false,
33253
33437
  brokeOn: null
33254
33438
  };
@@ -33258,13 +33442,11 @@ class GodotServer {
33258
33442
  child.stderr.on("data", (data) => {
33259
33443
  log.append("stderr", data);
33260
33444
  });
33261
- child.on("exit", (code) => {
33262
- 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"}`);
33263
33447
  log.finish();
33264
- started.exitCode = code ?? -1;
33265
- if (started.pid !== null) {
33266
- recordRunEnded(started.pid, started.exitCode);
33267
- }
33448
+ started.exitCode = code;
33449
+ started.exitSignal = code === null ? signal : null;
33268
33450
  });
33269
33451
  child.on("error", (err) => {
33270
33452
  console.error("Failed to start Godot process:", err);
@@ -33298,14 +33480,19 @@ class GodotServer {
33298
33480
  projectPath,
33299
33481
  startedAt,
33300
33482
  exitCode: null,
33483
+ exitSignal: null,
33301
33484
  throughEditor: false,
33302
33485
  brokeOn: null
33303
33486
  };
33304
- child.on("exit", (code) => {
33305
- 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"}`);
33306
33489
  this.drainTranscript(started);
33307
33490
  log.finish();
33308
- 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
+ }
33309
33496
  });
33310
33497
  child.on("error", (err) => {
33311
33498
  console.error("Failed to start Godot process:", err);
@@ -33388,6 +33575,7 @@ class GodotServer {
33388
33575
  projectPath: project,
33389
33576
  startedAt: note?.startedAt ?? Date.now(),
33390
33577
  exitCode: null,
33578
+ exitSignal: null,
33391
33579
  throughEditor: true,
33392
33580
  brokeOn: null,
33393
33581
  pickedUpPlaying: true
@@ -33524,6 +33712,7 @@ class GodotServer {
33524
33712
  projectPath: record.projectPath === "" ? null : record.projectPath,
33525
33713
  startedAt: record.startedAt,
33526
33714
  exitCode: null,
33715
+ exitSignal: null,
33527
33716
  throughEditor: false,
33528
33717
  brokeOn: null
33529
33718
  };
@@ -33531,7 +33720,8 @@ class GodotServer {
33531
33720
  if (!alive(record.pid) || !couldStillBeTheRecordedRun(record)) {
33532
33721
  adopted.log.finish();
33533
33722
  adopted.exitCode = record.exitCode ?? null;
33534
- adopted.endedUnwatched = record.exitCode === undefined;
33723
+ adopted.exitSignal = record.exitSignal ?? null;
33724
+ adopted.endedUnwatched = !exited(adopted);
33535
33725
  }
33536
33726
  this.activeProcess = adopted;
33537
33727
  return adopted;
@@ -33630,6 +33820,7 @@ class GodotServer {
33630
33820
  booted: !hung && boot.exitCode === 0 && errors === 0,
33631
33821
  hung,
33632
33822
  exitCode: boot.exitCode,
33823
+ exitSignal: boot.exitSignal ?? undefined,
33633
33824
  durationMs: Date.now() - boot.startedAt,
33634
33825
  frames,
33635
33826
  errors,
@@ -33656,7 +33847,11 @@ class GodotServer {
33656
33847
  }
33657
33848
  async runStillGoing(run, editorSays) {
33658
33849
  if (!run.throughEditor) {
33659
- 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;
33660
33855
  }
33661
33856
  const announced = this.announcedPidOf(run);
33662
33857
  if (announced !== undefined && !alive(announced)) {
@@ -33764,8 +33959,8 @@ class GodotServer {
33764
33959
  }
33765
33960
  if (typeof run.endedHere === "string") {
33766
33961
  notes.push(`This run was ended here, by ${run.endedHere}.`);
33767
- } else if (!stillRunning(run) && !run.throughEditor && run.exitCode !== null) {
33768
- 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.`);
33769
33964
  }
33770
33965
  if (run.endedUnwatched === true) {
33771
33966
  notes.push(endedWithoutACode({
@@ -33788,6 +33983,7 @@ class GodotServer {
33788
33983
  return this.jsonTextResponse({
33789
33984
  running: going,
33790
33985
  exitCode: run.exitCode,
33986
+ exitSignal: run.exitSignal ?? undefined,
33791
33987
  through: run.throughEditor ? "editor" : "gdharness",
33792
33988
  pid: run.pid ?? announced ?? run.announcedPid ?? null,
33793
33989
  errors: run.log.count("error"),
@@ -33841,10 +34037,11 @@ class GodotServer {
33841
34037
  },
33842
34038
  exitedBeforeStop: !wasRunning,
33843
34039
  exitCode: stopped.exitCode,
34040
+ exitSignal: stopped.exitSignal ?? undefined,
33844
34041
  errors: stopped.log.count("error"),
33845
34042
  warnings: stopped.log.count("warning"),
33846
34043
  clean: stopped.log.count("error") === 0,
33847
- 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."}${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)}`,
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)}`,
33848
34045
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
33849
34046
  });
33850
34047
  }
@@ -34260,7 +34457,17 @@ class GodotServer {
34260
34457
  const screenshotDir = expectsScreenshot ? mkdtempSync2(join16(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
34261
34458
  const screenshotPath = screenshotDir ? join16(screenshotDir, "capture.png") : null;
34262
34459
  try {
34263
- 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
+ }
34264
34471
  if (!reply.ok) {
34265
34472
  return this.createErrorResponse(reply.message);
34266
34473
  }
@@ -34296,7 +34503,36 @@ class GodotServer {
34296
34503
  discard(screenshotDir);
34297
34504
  }
34298
34505
  }
34299
- 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) {
34300
34536
  const filters = {};
34301
34537
  const className = readNonEmptyString(args, "className");
34302
34538
  const script = readNonEmptyString(args, "script");
@@ -34325,7 +34561,7 @@ class GodotServer {
34325
34561
  limit: readPositiveNumber(args, "limit") ?? 100,
34326
34562
  ...property === undefined ? {} : { property },
34327
34563
  ...includeHidden === undefined ? {} : { include_hidden: includeHidden }
34328
- });
34564
+ }, patience);
34329
34565
  }
34330
34566
  async handleRuntimeWait(op, args) {
34331
34567
  if (op === "frames") {