gdharness 1.1.23 → 1.1.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.
@@ -44,6 +44,11 @@ const EDITOR_PID_VARIABLE: String = "GDHARNESS_EDITOR_PID"
44
44
  ## `errorReportPath` in src/runtime-client.ts.
45
45
  const ERROR_REPORT_NAME: String = "runtime-%d.log"
46
46
 
47
+ ## Written beside this addon by setup and upgrade. Kept in step with `VERSION_MARKER` and
48
+ ## `DIGEST_MARKER` in src/setup.ts.
49
+ const VERSION_MARKER: String = "res://addons/gdharness_runtime/.gdharness-version"
50
+ const DIGEST_MARKER: String = "res://addons/gdharness_runtime/.gdharness-digest"
51
+
47
52
  var values: Values = Values.new()
48
53
 
49
54
  # The modules are members and not locals of _init, because a Callable holds its object by id
@@ -76,6 +81,11 @@ var _announcement: String = ""
76
81
  var _errors: ErrorReport
77
82
  ## Every command, by the name a request uses, as the module method that answers it.
78
83
  var _commands: Dictionary = {}
84
+ ## The addon this game loaded, read once at start: an upgrade rewrites the markers under a running
85
+ ## game, whose code stays what it loaded, and reading them later would call that code current.
86
+ ## Empty when the markers cannot be read, as in a copy made by hand.
87
+ var _loaded_version: String = _read_marker(VERSION_MARKER)
88
+ var _loaded_digest: String = _read_marker(DIGEST_MARKER)
79
89
 
80
90
 
81
91
  func _init() -> void:
@@ -336,6 +346,8 @@ func _identity(bind_address: String) -> Dictionary:
336
346
  "path": ProjectSettings.globalize_path("res://").rstrip("/")
337
347
  },
338
348
  "godot": Engine.get_version_info().get("string", ""),
349
+ "addon_version": _loaded_version,
350
+ "addon_digest": _loaded_digest,
339
351
  }
340
352
  var played_by: String = OS.get_environment(EDITOR_PID_VARIABLE)
341
353
  if played_by.is_valid_int():
@@ -343,6 +355,17 @@ func _identity(bind_address: String) -> Dictionary:
343
355
  return identity
344
356
 
345
357
 
358
+ static func _read_marker(path: String) -> String:
359
+ if not FileAccess.file_exists(path):
360
+ return ""
361
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
362
+ if file == null:
363
+ return ""
364
+ var text: String = file.get_as_text().strip_edges()
365
+ file.close()
366
+ return text
367
+
368
+
346
369
  func _send_welcome(client: StreamPeerTCP) -> void:
347
370
  var welcome: Dictionary = _identity(
348
371
  str(ProjectSettings.get_setting(BIND_ADDRESS_SETTING, DEFAULT_BIND_ADDRESS))
package/build/index.js CHANGED
@@ -10074,6 +10074,33 @@ function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest,
10074
10074
  }
10075
10075
  return `${both} ${restart}`;
10076
10076
  }
10077
+ function runtimeAddonUnknown(addon) {
10078
+ return addon?.version === "" && addon.digest === "";
10079
+ }
10080
+ function runtimeMismatch(pid, addon, serverVersion, shippedDigest, onDisk, projectPath) {
10081
+ if (runtimeAddonUnknown(addon)) {
10082
+ return;
10083
+ }
10084
+ if (addon !== undefined && !editorIsStale(addon.version, serverVersion, addon.digest, shippedDigest)) {
10085
+ return;
10086
+ }
10087
+ const game = `The game (pid ${pid})`;
10088
+ if (addon !== undefined && addon.version !== "" && isNewer(addon.version, serverVersion)) {
10089
+ return `${game} is running the ${addon.version} runtime addon while this server ships ${serverVersion}. This server is the older half: reconnect it in your harness so it spawns ${addon.version}.`;
10090
+ }
10091
+ let loaded;
10092
+ if (addon === undefined) {
10093
+ loaded = `a runtime addon from before games reported theirs, while this server ships ${serverVersion}`;
10094
+ } else if (addon.version === serverVersion) {
10095
+ loaded = `a different build of the ${serverVersion} runtime addon from the one this server ships`;
10096
+ } else if (addon.version === "") {
10097
+ loaded = `a runtime addon with no version marker, while this server ships ${serverVersion}`;
10098
+ } else {
10099
+ loaded = `the ${addon.version} runtime addon, while this server ships ${serverVersion}`;
10100
+ }
10101
+ const remedy = onDisk !== undefined && onDisk !== shippedDigest ? `The project's own runtime addon is not this server's either, so starting the game again loads the same code: gdharness upgrade in ${projectPath} installs the ${serverVersion} addons, and the game picks them up when it next starts.` : `The project holds this server's runtime addon, so the game picks it up when it next starts.`;
10102
+ return `${game} is running ${loaded}, and its answers come from that code. ${remedy}`;
10103
+ }
10077
10104
  function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest, onDisk) {
10078
10105
  const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest, onDisk);
10079
10106
  if (staleNote === undefined || typeof answer !== "object" || answer === null || Array.isArray(answer)) {
@@ -25452,6 +25479,7 @@ function parseAnnouncement(file, pid) {
25452
25479
  return { kind: "unspoken", unspoken: { pid, protocol: protocol ?? 0, project: named, ...when } };
25453
25480
  }
25454
25481
  const editorPid = readNumber(fields, "editor_pid");
25482
+ const addonVersion = readString(fields, "addon_version");
25455
25483
  return {
25456
25484
  kind: "runtime",
25457
25485
  endpoint: {
@@ -25461,7 +25489,8 @@ function parseAnnouncement(file, pid) {
25461
25489
  project: named,
25462
25490
  file,
25463
25491
  ...editorPid !== undefined && Number.isInteger(editorPid) && editorPid > 0 ? { editorPid } : {},
25464
- ...when
25492
+ ...when,
25493
+ ...addonVersion === undefined ? {} : { addon: { version: addonVersion, digest: readString(fields, "addon_digest") ?? "" } }
25465
25494
  }
25466
25495
  };
25467
25496
  }
@@ -29006,6 +29035,47 @@ function orphansPrinted(printed) {
29006
29035
  };
29007
29036
  }
29008
29037
 
29038
+ // src/keeper-channel.ts
29039
+ import { connect, createServer as createServer2 } from "node:net";
29040
+ function askTheKeeperToStop(address, withinMs) {
29041
+ return new Promise((resolve) => {
29042
+ let said = "";
29043
+ let settled = false;
29044
+ const settle = (answer) => {
29045
+ if (!settled) {
29046
+ settled = true;
29047
+ clearTimeout(timer);
29048
+ socket.destroy();
29049
+ resolve(answer);
29050
+ }
29051
+ };
29052
+ const socket = connect(address);
29053
+ const timer = setTimeout(() => {
29054
+ settle({ unreached: `it did not answer within ${withinMs} ms` });
29055
+ }, withinMs);
29056
+ socket.setEncoding("utf8");
29057
+ socket.on("connect", () => {
29058
+ socket.write(`stop
29059
+ `);
29060
+ });
29061
+ socket.on("data", (chunk) => {
29062
+ said += chunk;
29063
+ const line = said.split(`
29064
+ `)[0] ?? "";
29065
+ if (said.includes(`
29066
+ `)) {
29067
+ settle(line === "signalled" || line === "gone" ? line : { unreached: `it answered ${JSON.stringify(line)}` });
29068
+ }
29069
+ });
29070
+ socket.on("error", (error) => {
29071
+ settle({ unreached: error.code ?? error.message });
29072
+ });
29073
+ socket.on("close", () => {
29074
+ settle({ unreached: "it closed the connection without answering" });
29075
+ });
29076
+ });
29077
+ }
29078
+
29009
29079
  // src/lsp_client.ts
29010
29080
  import { existsSync as existsSync11, realpathSync as realpathSync3 } from "node:fs";
29011
29081
  import { readFile, realpath } from "node:fs/promises";
@@ -30293,6 +30363,7 @@ function recordAt(path) {
30293
30363
  ...typeof fields["startedBy"] === "number" ? { startedBy: fields["startedBy"] } : {},
30294
30364
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
30295
30365
  ...typeof fields["servedBy"] === "string" && fields["servedBy"] !== "" ? { servedBy: fields["servedBy"] } : {},
30366
+ ...typeof fields["keeper"] === "string" && fields["keeper"] !== "" ? { keeper: fields["keeper"] } : {},
30296
30367
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
30297
30368
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
30298
30369
  ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
@@ -30314,11 +30385,8 @@ async function runningAs(pid) {
30314
30385
  }
30315
30386
  }
30316
30387
  const image = await windowsImage(pid);
30317
- return image === null ? null : {
30318
- kind: "image",
30319
- text: image,
30320
- unread: "unread" in read ? read.unread : "Windows gave an empty command line"
30321
- };
30388
+ const unread = "unread" in read ? read.unread : "Windows gave an empty command line";
30389
+ return "image" in image ? { kind: "image", text: image.image, unread } : { kind: "none", text: "", unread: `${unread}; tasklist ${image.why}` };
30322
30390
  }
30323
30391
  try {
30324
30392
  const began = await startedAt(pid, null);
@@ -30403,9 +30471,11 @@ async function windowsImage(pid) {
30403
30471
  timeout: 15000,
30404
30472
  windowsHide: true
30405
30473
  });
30406
- return /^"([^"]+)"/.exec(csv.trim())?.[1] ?? null;
30407
- } catch {
30408
- return null;
30474
+ const image = /^"([^"]+)"/.exec(csv.trim())?.[1];
30475
+ return image === undefined ? { why: "named no process under that number" } : { image };
30476
+ } catch (error) {
30477
+ const failed = error;
30478
+ return { why: failed.killed === true ? "did not answer within 15000 ms" : errorMessage(error) };
30409
30479
  }
30410
30480
  }
30411
30481
  async function whyNotStillTheRecordedRun(record) {
@@ -30422,6 +30492,9 @@ function whyNotTheRun(record, running, needed) {
30422
30492
  if (running === null) {
30423
30493
  return "the operating system would not describe that process";
30424
30494
  }
30495
+ if (running.kind === "none") {
30496
+ return `the operating system would not describe that process (${running.unread ?? "no reason given"})`;
30497
+ }
30425
30498
  const after = running.startedAt === undefined ? 0 : running.startedAt - (record.startedBy ?? record.startedAt);
30426
30499
  if (after > SAME_RUN_WINDOW_MS) {
30427
30500
  return `that process started ${Math.round(after / 1000)} seconds after the run had`;
@@ -30529,7 +30602,14 @@ var RUNTIME_AUTOLOAD = {
30529
30602
  };
30530
30603
  var VERSION_MARKER = ".gdharness-version";
30531
30604
  var DIGEST_MARKER = ".gdharness-digest";
30605
+ var RUNTIME_ADDON = "gdharness_runtime";
30532
30606
  function editorAddonDigest(from = shippedAddonsDirectory()) {
30607
+ return addonsDigest(EDITOR_PLUGINS, from);
30608
+ }
30609
+ function runtimeAddonDigest(from = shippedAddonsDirectory()) {
30610
+ return addonsDigest([RUNTIME_ADDON], from);
30611
+ }
30612
+ function addonsDigest(names, from) {
30533
30613
  const hash = createHash4("sha256");
30534
30614
  const walk = (directory, relative) => {
30535
30615
  for (const name of readdirSync7(directory).sort()) {
@@ -30549,12 +30629,13 @@ function editorAddonDigest(from = shippedAddonsDirectory()) {
30549
30629
  }
30550
30630
  }
30551
30631
  };
30552
- for (const name of EDITOR_PLUGINS) {
30632
+ for (const name of names) {
30553
30633
  walk(join18(from, name), name);
30554
30634
  }
30555
30635
  return hash.digest("hex");
30556
30636
  }
30557
30637
  var shippedDigest;
30638
+ var shippedRuntime;
30558
30639
  function shippedEditorDigest() {
30559
30640
  if (shippedDigest === undefined) {
30560
30641
  try {
@@ -30565,8 +30646,24 @@ function shippedEditorDigest() {
30565
30646
  }
30566
30647
  return shippedDigest ?? undefined;
30567
30648
  }
30649
+ function shippedRuntimeDigest() {
30650
+ if (shippedRuntime === undefined) {
30651
+ try {
30652
+ shippedRuntime = runtimeAddonDigest();
30653
+ } catch {
30654
+ shippedRuntime = null;
30655
+ }
30656
+ }
30657
+ return shippedRuntime ?? undefined;
30658
+ }
30568
30659
  function installedEditorDigest(projectPath) {
30569
- const marker = join18(projectPath, "addons", ADDONS[0], DIGEST_MARKER);
30660
+ return digestMarker(projectPath, ADDONS[0]);
30661
+ }
30662
+ function installedRuntimeDigest(projectPath) {
30663
+ return digestMarker(projectPath, RUNTIME_ADDON);
30664
+ }
30665
+ function digestMarker(projectPath, addon) {
30666
+ const marker = join18(projectPath, "addons", addon, DIGEST_MARKER);
30570
30667
  if (!existsSync13(marker)) {
30571
30668
  return null;
30572
30669
  }
@@ -31504,7 +31601,7 @@ var TOOL_SPECS = [
31504
31601
  },
31505
31602
  {
31506
31603
  name: "editor_status",
31507
- 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. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. 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. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. 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. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs.",
31604
+ 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. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. 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. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. 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. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. 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. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs.",
31508
31605
  parameters: {},
31509
31606
  requires: []
31510
31607
  },
@@ -32142,6 +32239,7 @@ function noCodeWillCome(run) {
32142
32239
  return !exited(run);
32143
32240
  }
32144
32241
  var STOP_WAIT_MS = 1e4;
32242
+ var KEEPER_ANSWER_MS = 15000;
32145
32243
  var EXIT_REPORTED_WITHIN_MS = 2000;
32146
32244
  async function untilGone(pids, withinMs = STOP_WAIT_MS) {
32147
32245
  const gone = () => pids.every((pid) => !alive(pid));
@@ -33075,6 +33173,10 @@ class GodotServer {
33075
33173
  const project = (status.connected ? status.projectPath : undefined) ?? this.ownProject;
33076
33174
  return project === null || project === "" ? undefined : installedEditorDigest(project);
33077
33175
  }
33176
+ runtimeStaleNote(endpoint) {
33177
+ const project = endpoint.project.path;
33178
+ return runtimeMismatch(endpoint.pid, endpoint.addon, SERVER_VERSION, shippedRuntimeDigest(), project === "" ? undefined : installedRuntimeDigest(project), project);
33179
+ }
33078
33180
  configDisagreement() {
33079
33181
  return this.ownProject === null ? null : configDisagrees(this.ownProject, SERVER_VERSION);
33080
33182
  }
@@ -34661,12 +34763,17 @@ class GodotServer {
34661
34763
  ok: false,
34662
34764
  message: `The game is held by the editor's debugger, ${describeHalt(held)}, and answers no runtime call while it is. debug_control continue lets it go.`
34663
34765
  };
34766
+ const staleNote = this.runtimeStaleNote(endpoint);
34767
+ const unknown = runtimeAddonUnknown(endpoint.addon);
34664
34768
  return {
34665
34769
  pid: endpoint.pid,
34666
34770
  port: endpoint.port,
34667
34771
  project: endpoint.project,
34668
34772
  ...this.ownProject === null ? {} : { ofThisProject: isSameDirectory(endpoint.project.path, this.ownProject) },
34669
34773
  editorPid: endpoint.editorPid,
34774
+ addonVersion: endpoint.addon === undefined || endpoint.addon.version === "" ? null : endpoint.addon.version,
34775
+ ...unknown ? {} : { addonIsStale: staleNote !== undefined },
34776
+ ...staleNote === undefined ? {} : { staleNote },
34670
34777
  reachable: reply.ok,
34671
34778
  problem: reply.ok ? null : reply.message
34672
34779
  };
@@ -35349,6 +35456,13 @@ class GodotServer {
35349
35456
  if (running.pid === null) {
35350
35457
  return "gone";
35351
35458
  }
35459
+ if (running.keeper !== undefined) {
35460
+ const ended = await this.endThroughTheKeeper(running, running.pid, running.keeper);
35461
+ if (ended !== null) {
35462
+ clearTheRecord();
35463
+ return ended;
35464
+ }
35465
+ }
35352
35466
  const startedAs = {
35353
35467
  pid: running.pid,
35354
35468
  transcript: running.transcript ?? "",
@@ -35366,7 +35480,7 @@ class GodotServer {
35366
35480
  return "refused";
35367
35481
  }
35368
35482
  const signalled = await runningAs(running.pid);
35369
- const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
35483
+ const described = signalled === null || signalled.kind === "none" ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
35370
35484
  let landed = true;
35371
35485
  let declined = null;
35372
35486
  try {
@@ -35390,6 +35504,23 @@ class GodotServer {
35390
35504
  running.log.record(gone ? "info" : "warning", declined !== null ? `gdharness could not signal pid ${running.pid}: the operating system refused it (${declined}). It had described that process as ${described}.` : !gone ? `gdharness signalled pid ${running.pid}, which the operating system described as ${described}, and it was still running ${STOP_WAIT_MS / 1000} seconds later.` : 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}.`);
35391
35505
  return gone ? "gone" : "lingering";
35392
35506
  }
35507
+ async endThroughTheKeeper(run, pid, keeper) {
35508
+ const asked = await askTheKeeperToStop(keeper, KEEPER_ANSWER_MS);
35509
+ if (typeof asked !== "string") {
35510
+ this.logDebug(`The keeper of pid ${pid} could not be asked to end it (${asked.unreached})`);
35511
+ return null;
35512
+ }
35513
+ const gone = await untilGone([pid]);
35514
+ if (gone) {
35515
+ await this.untilTheExitIsRecorded(run);
35516
+ }
35517
+ if (asked === "signalled" && gone) {
35518
+ run.exitCode = null;
35519
+ run.exitSignal = "SIGTERM";
35520
+ }
35521
+ run.log.record(gone ? "info" : "warning", !gone ? `gdharness had the keeper holding pid ${pid} end it, and it was still running ${STOP_WAIT_MS / 1000} seconds later.` : asked === "signalled" ? `gdharness ended pid ${pid} through the keeper holding it.` : `gdharness asked the keeper holding pid ${pid} to end it, and it had already gone.`);
35522
+ return gone ? "gone" : "lingering";
35523
+ }
35393
35524
  spawnGame(godotPath, cmdArgs, env) {
35394
35525
  const child = spawn4(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
35395
35526
  const cancelled = callSignal();
@@ -35479,6 +35610,8 @@ class GodotServer {
35479
35610
  }
35480
35611
  const startedBy = Date.now();
35481
35612
  sweepTranscripts();
35613
+ const noted = readRunRecord(projectPath);
35614
+ const keeper = noted?.pid === launched.pid ? noted.keeper : undefined;
35482
35615
  return {
35483
35616
  pid: launched.pid,
35484
35617
  log: new GameLog,
@@ -35487,6 +35620,7 @@ class GodotServer {
35487
35620
  projectPath,
35488
35621
  startedAt,
35489
35622
  startedBy,
35623
+ ...keeper === undefined ? {} : { keeper },
35490
35624
  command: godotPath,
35491
35625
  exitCode: null,
35492
35626
  exitSignal: null,
@@ -35719,6 +35853,7 @@ class GodotServer {
35719
35853
  projectPath: record.projectPath === "" ? null : record.projectPath,
35720
35854
  startedAt: record.startedAt,
35721
35855
  ...record.startedBy === undefined ? {} : { startedBy: record.startedBy },
35856
+ ...record.keeper === undefined ? {} : { keeper: record.keeper },
35722
35857
  ...record.command === undefined ? {} : { command: record.command },
35723
35858
  exitCode: null,
35724
35859
  exitSignal: null,
@@ -36762,6 +36897,11 @@ class GodotServer {
36762
36897
  }
36763
36898
  const screenshotDir = expectsScreenshot ? scratchDirectory("runtime-screenshot") : null;
36764
36899
  const screenshotPath = screenshotDir ? join19(screenshotDir, "capture.png") : null;
36900
+ const staleNote = this.runtimeStaleNote(choice.endpoint);
36901
+ const stale = staleNote === undefined ? {} : { addonIsStale: true, staleNote };
36902
+ const refused = (message) => this.createErrorResponse(staleNote === undefined ? message : `${message}
36903
+
36904
+ ${staleNote}`);
36765
36905
  try {
36766
36906
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs, !expectsScreenshot);
36767
36907
  if (!reply.ok && reply.requestId !== undefined) {
@@ -36771,22 +36911,23 @@ class GodotServer {
36771
36911
  command,
36772
36912
  waitedMs: timeoutMs,
36773
36913
  note: reply.message,
36774
- ...answeredBy
36914
+ ...answeredBy,
36915
+ ...stale
36775
36916
  });
36776
36917
  }
36777
36918
  if (!reply.ok) {
36778
- return this.createErrorResponse(reply.message);
36919
+ return refused(reply.message);
36779
36920
  }
36780
36921
  const { id: _id, ...payload } = reply.payload;
36781
36922
  if (!expectsScreenshot) {
36782
- return this.jsonTextResponse({ ...payload, ...answeredBy });
36923
+ return this.jsonTextResponse({ ...payload, ...answeredBy, ...stale });
36783
36924
  }
36784
36925
  const returnedPath = readString(payload, "path");
36785
36926
  if (readString(payload, "type") !== "screenshot_file" || !returnedPath || !screenshotPath) {
36786
- return this.createErrorResponse(`The game answered a capture with ${JSON.stringify(payload)}`);
36927
+ return refused(`The game answered a capture with ${JSON.stringify(payload)}`);
36787
36928
  }
36788
36929
  if (normalize2(returnedPath) !== normalize2(screenshotPath)) {
36789
- return this.createErrorResponse(`Rejected screenshot file path outside the managed capture path: '${returnedPath}'`);
36930
+ return refused(`Rejected screenshot file path outside the managed capture path: '${returnedPath}'`);
36790
36931
  }
36791
36932
  const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
36792
36933
  if (saveTo !== null) {
@@ -36800,7 +36941,9 @@ class GodotServer {
36800
36941
  content: [
36801
36942
  {
36802
36943
  type: "text",
36803
- text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}`
36944
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}${staleNote === undefined ? "" : `
36945
+
36946
+ ${staleNote}`}`
36804
36947
  },
36805
36948
  { type: "image", data: readFileSync18(screenshotPath).toString("base64"), mimeType: "image/png" }
36806
36949
  ]