gdharness 1.0.22 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -16415,7 +16415,7 @@ var init_tool_definitions = __esm(() => {
16415
16415
  },
16416
16416
  {
16417
16417
  name: "editor_status",
16418
- description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved, which is that it was held when the bridge started, most often by the server a reconnect replaced.",
16418
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait.",
16419
16419
  parameters: {},
16420
16420
  requires: []
16421
16421
  },
@@ -16570,12 +16570,12 @@ var init_tool_definitions = __esm(() => {
16570
16570
  property: {
16571
16571
  type: "string",
16572
16572
  ops: ["set"],
16573
- description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a method taking no arguments returned. The answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
16573
+ description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a method taking no arguments returned. The answer reads back off the same holder, and a write that leaves the property as it was, because the engine would not take it, is refused and says what the property still holds.'
16574
16574
  },
16575
16575
  value: {
16576
16576
  ops: ["set"],
16577
16577
  blank: true,
16578
- description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance.`
16578
+ description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key.`
16579
16579
  },
16580
16580
  method: {
16581
16581
  type: "string",
@@ -16585,7 +16585,7 @@ var init_tool_definitions = __esm(() => {
16585
16585
  args: {
16586
16586
  type: "array",
16587
16587
  ops: ["call"],
16588
- description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused.`
16588
+ description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused. 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.`
16589
16589
  }
16590
16590
  },
16591
16591
  requires: ["nodePath"],
@@ -36145,7 +36145,10 @@ var init_godot_bridge = __esm(() => {
36145
36145
  get port() {
36146
36146
  return this.boundPort ?? this.wantedPort;
36147
36147
  }
36148
- async start() {
36148
+ get configuredPort() {
36149
+ return this.wantedPort;
36150
+ }
36151
+ async start(moveIfHeld = true) {
36149
36152
  if (this.httpServer) {
36150
36153
  return;
36151
36154
  }
@@ -36153,7 +36156,7 @@ var init_godot_bridge = __esm(() => {
36153
36156
  await this.listenOn(this.wantedPort);
36154
36157
  } catch (error) {
36155
36158
  const held = error instanceof Error && "code" in error && error.code === "EADDRINUSE";
36156
- if (!held || !this.mayMove) {
36159
+ if (!held || !this.mayMove || !moveIfHeld) {
36157
36160
  throw error;
36158
36161
  }
36159
36162
  this.log("warn", `Editor bridge port ${this.wantedPort} is held; taking another one.`);
@@ -38146,6 +38149,14 @@ function runIsUp(run, editorSays, now = Date.now()) {
38146
38149
  function noCodeWillCome(run) {
38147
38150
  return run.exitCode === null && run.process === null;
38148
38151
  }
38152
+ async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
38153
+ const gone = () => (handle?.exitCode !== null || handle.signalCode !== null) && pids.every((pid) => !alive(pid));
38154
+ const deadline = Date.now() + withinMs;
38155
+ while (!gone() && Date.now() < deadline) {
38156
+ await delay3(50);
38157
+ }
38158
+ return gone();
38159
+ }
38149
38160
  function endedWithoutACode(run) {
38150
38161
  if (!run.throughEditor) {
38151
38162
  return run.endedHere ? "This run was started by another server, so nothing here held it and its exit code was never collected. Everything it printed is below, read back from its transcript." : "This run was started by another server, the one this server replaced or one running beside it, and it ended with nothing here waiting on it, so its exit code was never collected. Everything it printed is below, read back from its transcript.";
@@ -38419,6 +38430,8 @@ class GodotServer {
38419
38430
  launchedFrom = null;
38420
38431
  ownProject;
38421
38432
  announcedAt = null;
38433
+ handingOverFrom = null;
38434
+ notHandedOverBy = null;
38422
38435
  constructor() {
38423
38436
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
38424
38437
  this.godotBridge = getDefaultBridge(this.ownProject);
@@ -38465,6 +38478,7 @@ class GodotServer {
38465
38478
  });
38466
38479
  console.error("gdharness running on stdio");
38467
38480
  try {
38481
+ await this.takeOverFromAPredecessor();
38468
38482
  await this.godotBridge.start();
38469
38483
  this.bridgeStartupError = null;
38470
38484
  const bridgeStatus = this.godotBridge.getStatus();
@@ -38479,6 +38493,35 @@ class GodotServer {
38479
38493
  this.keepTryingTheBridge();
38480
38494
  }
38481
38495
  }
38496
+ async takeOverFromAPredecessor() {
38497
+ if (this.ownProject === null) {
38498
+ return;
38499
+ }
38500
+ const wanted = this.godotBridge.configuredPort;
38501
+ const announced = readAnnouncement2(announcementPath2(this.ownProject));
38502
+ if (announced === null || announced.pid === process.pid || announced.port !== wanted || !alive(announced.pid)) {
38503
+ return;
38504
+ }
38505
+ this.handingOverFrom = announced.pid;
38506
+ this.announcedAt = announceBridge(this.ownProject, {
38507
+ host: this.godotBridge.getStatus().host,
38508
+ port: wanted,
38509
+ version: SERVER_VERSION
38510
+ });
38511
+ const until = Date.now() + HANDOVER_MS;
38512
+ let taken = false;
38513
+ while (!taken && Date.now() < until && !this.shutdownInitiated) {
38514
+ try {
38515
+ await this.godotBridge.start(false);
38516
+ taken = true;
38517
+ this.logDebug(`Took port ${wanted} over from pid ${announced.pid}`);
38518
+ } catch {
38519
+ await delay3(HANDOVER_POLL_MS);
38520
+ }
38521
+ }
38522
+ this.handingOverFrom = null;
38523
+ this.notHandedOverBy = taken ? null : announced.pid;
38524
+ }
38482
38525
  keepTryingTheBridge() {
38483
38526
  if (this.bridgeRetry !== null || this.shutdownInitiated) {
38484
38527
  return;
@@ -39852,7 +39895,12 @@ class GodotServer {
39852
39895
  awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
39853
39896
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
39854
39897
  startupError: this.bridgeStartupError,
39855
- portNote: status.portWanted === undefined ? undefined : `Port ${status.portWanted}, the one this server was configured with, was held by another process when the bridge started, so it took ${status.port} and announced that where the editor looks for it${this.announcedAt === null ? "" : `, ${this.announcedAt}`}. The editor finds it there; the next server started takes ${status.portWanted} again if it is free.`,
39898
+ 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.`,
39899
+ bridgeHandover: this.handingOverFrom === null ? undefined : {
39900
+ fromPid: this.handingOverFrom,
39901
+ port: this.godotBridge.configuredPort,
39902
+ 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.`
39903
+ },
39856
39904
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
39857
39905
  addonNote: status.connected ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
39858
39906
  ...this.breakpointsAtRisk(),
@@ -40404,24 +40452,25 @@ class GodotServer {
40404
40452
  const recorded = running?.process === null ? readRunRecord() : null;
40405
40453
  clearRunRecord((record) => this.couldBeOurs(record.projectPath));
40406
40454
  if (!running || !going) {
40407
- return;
40455
+ return true;
40408
40456
  }
40409
40457
  running.endedHere = reason;
40458
+ const announced = this.announcedPidOf(running);
40410
40459
  if (running.throughEditor) {
40411
40460
  await this.handleViaBridge("stop_playing", {});
40412
- return;
40461
+ return await untilGone(null, announced === undefined ? [] : [announced]);
40413
40462
  }
40414
40463
  if (running.process !== null) {
40415
40464
  running.process.kill();
40416
- return;
40465
+ return await untilGone(running.process, announced === undefined ? [] : [announced]);
40417
40466
  }
40418
40467
  if (running.pid === null) {
40419
- return;
40468
+ return true;
40420
40469
  }
40421
40470
  if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
40422
40471
  running.endedHere = null;
40423
40472
  running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the run that was recorded, so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
40424
- return;
40473
+ return true;
40425
40474
  }
40426
40475
  const signalled = runningAs(running.pid);
40427
40476
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
@@ -40431,7 +40480,9 @@ class GodotServer {
40431
40480
  } catch {
40432
40481
  landed = false;
40433
40482
  }
40483
+ const gone = await untilGone(null, [running.pid]);
40434
40484
  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}.`);
40485
+ return gone;
40435
40486
  }
40436
40487
  spawnGame(godotPath, cmdArgs, env) {
40437
40488
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -41033,7 +41084,7 @@ class GodotServer {
41033
41084
  const endedPid = stopped.pid ?? announcedPid;
41034
41085
  const children = readBoolean(args, "andChildren") === true && wasRunning ? await this.whatTheRunStarted(stopped) : null;
41035
41086
  this.logDebug("Stopping the running game");
41036
- await this.endActiveGame("editor_run stop", wasRunning);
41087
+ const gone = await this.endActiveGame("editor_run stop", wasRunning);
41037
41088
  const ended = children === null ? null : endChildrenAmong(children);
41038
41089
  if (wasRunning) {
41039
41090
  for (const pid of new Set([endedPid, announcedPid])) {
@@ -41057,7 +41108,7 @@ class GodotServer {
41057
41108
  errors: stopped.log.count("error"),
41058
41109
  warnings: stopped.log.count("warning"),
41059
41110
  clean: stopped.log.count("error") === 0,
41060
- note: !wasRunning ? "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running." : `${stopped.throughEditor ? endedPid === null ? "The editor was asked to stop the scene it is playing. Its game had not announced a runtime, so no process was named under endedPid, and its exit code stays with the editor." : "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."} ${aboutTheChildren(ended, stopped.throughEditor)}`,
41111
+ 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)}`,
41061
41112
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
41062
41113
  });
41063
41114
  }
@@ -41573,7 +41624,7 @@ async function runGodotServer() {
41573
41624
  const server = new GodotServer;
41574
41625
  await server.run();
41575
41626
  }
41576
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CACHE_WRITE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
41627
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
41577
41628
  var init_server3 = __esm(() => {
41578
41629
  init_mcp();
41579
41630
  init_stdio2();
@@ -41620,6 +41671,7 @@ var init_server3 = __esm(() => {
41620
41671
  boolean: "a boolean",
41621
41672
  object: "an object"
41622
41673
  };
41674
+ HANDOVER_MS = SUCCESSOR_CHECK_MS + 5000;
41623
41675
  PATH_SOLUTIONS = [
41624
41676
  'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
41625
41677
  "Point projectPath at the project the file belongs to"
@@ -629,14 +629,49 @@ func set_property(params: Dictionary) -> Dictionary:
629
629
  % [reached["called"], named, type_string(wanted), type_string(typeof(given))]
630
630
  )
631
631
  }
632
+ var typed: Dictionary = _values.typed_like(given, old_value, _element_object.bind(node, node_path))
633
+ if typed.has("message"):
634
+ return {
635
+ "type": "error",
636
+ "message":
637
+ "%s.%s is a typed %s: %s." % [reached["called"], named, _sort_of(old_value), typed["message"]]
638
+ }
639
+ given = typed["value"]
632
640
  Paths.write(holder, named, given)
641
+ var now: Variant = Paths.read_under(holder, named)
642
+ # Read back rather than trusted. The engine drops a write it will not take without a word, and
643
+ # the answer then showed the old value as the new one, shaped as a success: a typed container
644
+ # refusing a plain list did exactly that. A value the property changed on the way in, a setter
645
+ # clamping it, still changed it, so only a write that left the property as it was is refused.
646
+ if (
647
+ Values.comparable(given, old_value)
648
+ and given != old_value
649
+ and Values.comparable(now, old_value)
650
+ and now == old_value
651
+ ):
652
+ return {
653
+ "type": "error",
654
+ "message":
655
+ (
656
+ (
657
+ "%s.%s was given %s and still holds %s: the engine kept what it had,"
658
+ + " which a property it will not write does."
659
+ )
660
+ % [
661
+ reached["called"],
662
+ named,
663
+ JSON.stringify(_values.serialize(given)),
664
+ JSON.stringify(_values.serialize(now))
665
+ ]
666
+ )
667
+ }
633
668
 
634
669
  return {
635
670
  "type": "property_set",
636
671
  "path": node_path,
637
672
  "property": property,
638
673
  "old_value": _values.serialize(old_value),
639
- "new_value": _values.serialize(Paths.read_under(holder, named))
674
+ "new_value": _values.serialize(now)
640
675
  }
641
676
 
642
677
 
@@ -711,6 +746,23 @@ func call_method(params: Dictionary) -> Dictionary:
711
746
  % [reached["called"], named, type_string(wants), index + 1, type_string(typeof(given))]
712
747
  )
713
748
  }
749
+ # A typed list or map is built as one, since a plain one handed to such a parameter raises
750
+ # inside the game, which is what every check here exists to stop.
751
+ var container: Variant = _values.parameter_container(holder, named, index)
752
+ if container != null:
753
+ var typed: Dictionary = _values.typed_like(
754
+ given, container, _element_object.bind(node, node_path)
755
+ )
756
+ if typed.has("message"):
757
+ return {
758
+ "type": "error",
759
+ "message":
760
+ (
761
+ "%s.%s takes a typed %s as argument %d: %s."
762
+ % [reached["called"], named, _sort_of(container), index + 1, typed["message"]]
763
+ )
764
+ }
765
+ given = typed["value"]
714
766
  deserialized_args.append(given)
715
767
 
716
768
  var result: Variant = holder.callv(named, deserialized_args)
@@ -718,6 +770,17 @@ func call_method(params: Dictionary) -> Dictionary:
718
770
  return {"type": "method_result", "path": node_path, "method": method, "result": _values.serialize(result)}
719
771
 
720
772
 
773
+ ## [method _object_named] with the path first, which is the order [method Values.typed_like] calls
774
+ ## an element resolver in.
775
+ func _element_object(given: Variant, declared: String, node: Node, node_path: String) -> Dictionary:
776
+ return _object_named(node, node_path, given, declared)
777
+
778
+
779
+ ## "list" or "map", for a refusal about a typed container.
780
+ static func _sort_of(container: Variant) -> String:
781
+ return "list" if container is Array else "map"
782
+
783
+
721
784
  ## The object [param given] names for a slot or a parameter declared as [param declared], as
722
785
  ## `{"object": ...}`, or `{"message": ...}` saying why it names none that fits.
723
786
  ##
@@ -163,3 +163,149 @@ static func class_of(object: Object) -> String:
163
163
  ## A value from the wire fitted to the type a property or parameter declares.
164
164
  func fitted(value: Variant, type: int) -> Variant:
165
165
  return _shared.fitted(value, type)
166
+
167
+
168
+ ## An empty list or map typed the way argument [param index] of [param method] declares, or null
169
+ ## when that argument is not a typed container. What [method typed_like] builds against.
170
+ func parameter_container(object: Object, method: String, index: int) -> Variant:
171
+ for entry: Dictionary in object.get_method_list():
172
+ if entry.get("name", "") != method:
173
+ continue
174
+ var params: Array = entry.get("args", [])
175
+ if index < 0 or index >= params.size():
176
+ return null
177
+ var parameter: Dictionary = params[index]
178
+ var hint: int = Read.as_int(parameter.get("hint", 0), 0)
179
+ var named: String = str(parameter.get("hint_string", ""))
180
+ if hint == PROPERTY_HINT_ARRAY_TYPE:
181
+ var element: Dictionary = _element_type(named)
182
+ if element.is_empty():
183
+ return null
184
+ var builtin: int = element["builtin"]
185
+ var engine_class: StringName = element["class"]
186
+ return Array([], builtin, engine_class, element["script"])
187
+ if hint == PROPERTY_HINT_DICTIONARY_TYPE and named.contains(";"):
188
+ var key: Dictionary = _element_type(named.get_slice(";", 0))
189
+ var entry_type: Dictionary = _element_type(named.get_slice(";", 1))
190
+ if key.is_empty() or entry_type.is_empty():
191
+ return null
192
+ var key_builtin: int = key["builtin"]
193
+ var key_class: StringName = key["class"]
194
+ var value_builtin: int = entry_type["builtin"]
195
+ var value_class: StringName = entry_type["class"]
196
+ return Dictionary(
197
+ {}, key_builtin, key_class, key["script"], value_builtin, value_class, entry_type["script"]
198
+ )
199
+ return null
200
+ return null
201
+
202
+
203
+ ## The type a container hint names, as `{"builtin", "class", "script"}`, or {} for a name this
204
+ ## cannot resolve. A built-in type by its name, an engine class, or a script class by its global
205
+ ## name, which a typed container holds as the class it extends and the script itself.
206
+ static func _element_type(named: String) -> Dictionary:
207
+ for type: int in TYPE_MAX:
208
+ if type != TYPE_OBJECT and type_string(type) == named:
209
+ return {"builtin": type, "class": &"", "script": null}
210
+ if ClassDB.class_exists(named):
211
+ return {"builtin": TYPE_OBJECT, "class": StringName(named), "script": null}
212
+ for global: Dictionary in ProjectSettings.get_global_class_list():
213
+ if str(global.get("class", "")) == named:
214
+ var script: Script = load(str(global.get("path", "")))
215
+ if script == null:
216
+ return {}
217
+ return {"builtin": TYPE_OBJECT, "class": script.get_instance_base_type(), "script": script}
218
+ return {}
219
+
220
+
221
+ ## [param given] rebuilt as the typed list or map [param like] is, as `{"value": ...}`, or
222
+ ## `{"message": ...}` naming the element that cannot become what the container holds.
223
+ ##
224
+ ## A typed container refuses a plain one, and the engine says nothing about it: an Array[int]
225
+ ## property written with the list JSON carries kept what it held, and the answer showed the write
226
+ ## as done. Each element is fitted the way a single value is, and an object element is named by
227
+ ## its path through [param resolve], which is called with the path and the class declared and
228
+ ## answers the way an object argument does. Anything that is not a typed container comes back as
229
+ ## it was given.
230
+ func typed_like(given: Variant, like: Variant, resolve: Callable) -> Dictionary:
231
+ if like is Array and given is Array:
232
+ var template: Array = like
233
+ if not template.is_typed():
234
+ return {"value": given}
235
+ var items: Array = given
236
+ var built: Array = []
237
+ for index: int in items.size():
238
+ var element: Dictionary = _element(
239
+ items[index],
240
+ template.get_typed_builtin(),
241
+ _typed_class(template.get_typed_script(), template.get_typed_class_name()),
242
+ resolve
243
+ )
244
+ if element.has("message"):
245
+ return {"message": "element %d %s" % [index, element["message"]]}
246
+ built.append(element["value"])
247
+ return {
248
+ "value":
249
+ Array(
250
+ built,
251
+ template.get_typed_builtin(),
252
+ template.get_typed_class_name(),
253
+ template.get_typed_script()
254
+ )
255
+ }
256
+ if like is Dictionary and given is Dictionary:
257
+ var map_like: Dictionary = like
258
+ if not map_like.is_typed():
259
+ return {"value": given}
260
+ var entries: Dictionary = given
261
+ var rebuilt: Dictionary = {}
262
+ for key: Variant in entries:
263
+ var key_fitted: Dictionary = _element(
264
+ key,
265
+ map_like.get_typed_key_builtin(),
266
+ _typed_class(map_like.get_typed_key_script(), map_like.get_typed_key_class_name()),
267
+ resolve
268
+ )
269
+ if key_fitted.has("message"):
270
+ return {"message": "key %s %s" % [str(key), key_fitted["message"]]}
271
+ var value_fitted: Dictionary = _element(
272
+ entries[key],
273
+ map_like.get_typed_value_builtin(),
274
+ _typed_class(map_like.get_typed_value_script(), map_like.get_typed_value_class_name()),
275
+ resolve
276
+ )
277
+ if value_fitted.has("message"):
278
+ return {"message": "the value under %s %s" % [str(key), value_fitted["message"]]}
279
+ rebuilt[key_fitted["value"]] = value_fitted["value"]
280
+ return {
281
+ "value":
282
+ Dictionary(
283
+ rebuilt,
284
+ map_like.get_typed_key_builtin(),
285
+ map_like.get_typed_key_class_name(),
286
+ map_like.get_typed_key_script(),
287
+ map_like.get_typed_value_builtin(),
288
+ map_like.get_typed_value_class_name(),
289
+ map_like.get_typed_value_script()
290
+ )
291
+ }
292
+ return {"value": given}
293
+
294
+
295
+ ## One element fitted to a container's element type, as `{"value"}` or `{"message"}` saying what it
296
+ ## was and what goes there. The message is the part after "element N".
297
+ func _element(item: Variant, builtin: int, declared: String, resolve: Callable) -> Dictionary:
298
+ if builtin == TYPE_NIL:
299
+ return {"value": item}
300
+ if builtin == TYPE_OBJECT and item is String:
301
+ var named: Dictionary = resolve.call(item, declared)
302
+ if named.has("message"):
303
+ return {"message": str(named["message"])}
304
+ return {"value": named["object"]}
305
+ var fitted_item: Variant = fitted(item, builtin)
306
+ if not acceptable(fitted_item, builtin):
307
+ # The value as it was sent rather than its engine type: JSON numbers arrive as floats, so a 7
308
+ # the caller wrote was named "float".
309
+ var wanted: String = declared if builtin == TYPE_OBJECT else type_string(builtin)
310
+ return {"message": "is %s, which cannot become %s" % [JSON.stringify(serialize(item)), wanted]}
311
+ return {"value": fitted_item}
package/build/index.js CHANGED
@@ -27107,7 +27107,10 @@ class GodotBridge extends EventEmitter {
27107
27107
  get port() {
27108
27108
  return this.boundPort ?? this.wantedPort;
27109
27109
  }
27110
- async start() {
27110
+ get configuredPort() {
27111
+ return this.wantedPort;
27112
+ }
27113
+ async start(moveIfHeld = true) {
27111
27114
  if (this.httpServer) {
27112
27115
  return;
27113
27116
  }
@@ -27115,7 +27118,7 @@ class GodotBridge extends EventEmitter {
27115
27118
  await this.listenOn(this.wantedPort);
27116
27119
  } catch (error) {
27117
27120
  const held = error instanceof Error && "code" in error && error.code === "EADDRINUSE";
27118
- if (!held || !this.mayMove) {
27121
+ if (!held || !this.mayMove || !moveIfHeld) {
27119
27122
  throw error;
27120
27123
  }
27121
27124
  this.log("warn", `Editor bridge port ${this.wantedPort} is held; taking another one.`);
@@ -30287,7 +30290,7 @@ var TOOL_SPECS = [
30287
30290
  },
30288
30291
  {
30289
30292
  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.",
30293
+ 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
30294
  parameters: {},
30292
30295
  requires: []
30293
30296
  },
@@ -30442,12 +30445,12 @@ var TOOL_SPECS = [
30442
30445
  property: {
30443
30446
  type: "string",
30444
30447
  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.'
30448
+ 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
30449
  },
30447
30450
  value: {
30448
30451
  ops: ["set"],
30449
30452
  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.`
30453
+ 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
30454
  },
30452
30455
  method: {
30453
30456
  type: "string",
@@ -30457,7 +30460,7 @@ var TOOL_SPECS = [
30457
30460
  args: {
30458
30461
  type: "array",
30459
30462
  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.`
30463
+ 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.`
30461
30464
  }
30462
30465
  },
30463
30466
  requires: ["nodePath"],
@@ -30834,6 +30837,15 @@ function runIsUp(run, editorSays, now = Date.now()) {
30834
30837
  function noCodeWillCome(run) {
30835
30838
  return run.exitCode === null && run.process === null;
30836
30839
  }
30840
+ var STOP_WAIT_MS = 1e4;
30841
+ async function untilGone(handle, pids, withinMs = STOP_WAIT_MS) {
30842
+ const gone = () => (handle?.exitCode !== null || handle.signalCode !== null) && pids.every((pid) => !alive(pid));
30843
+ const deadline = Date.now() + withinMs;
30844
+ while (!gone() && Date.now() < deadline) {
30845
+ await delay3(50);
30846
+ }
30847
+ return gone();
30848
+ }
30837
30849
  function endedWithoutACode(run) {
30838
30850
  if (!run.throughEditor) {
30839
30851
  return run.endedHere ? "This run was started by another server, so nothing here held it and its exit code was never collected. Everything it printed is below, read back from its transcript." : "This run was started by another server, the one this server replaced or one running beside it, and it ended with nothing here waiting on it, so its exit code was never collected. Everything it printed is below, read back from its transcript.";
@@ -31019,6 +31031,8 @@ function outsideTheirLists(spec, args) {
31019
31031
  }
31020
31032
  var BRIDGE_RETRY_MS = 2000;
31021
31033
  var SUCCESSOR_CHECK_MS = 1e4;
31034
+ var HANDOVER_MS = SUCCESSOR_CHECK_MS + 5000;
31035
+ var HANDOVER_POLL_MS = 250;
31022
31036
  var CACHE_WRITE_MS = 2000;
31023
31037
  var PLAYING_STATUS_MS = 1000;
31024
31038
  var SCAN_START_MS = 2000;
@@ -31152,6 +31166,8 @@ class GodotServer {
31152
31166
  launchedFrom = null;
31153
31167
  ownProject;
31154
31168
  announcedAt = null;
31169
+ handingOverFrom = null;
31170
+ notHandedOverBy = null;
31155
31171
  constructor() {
31156
31172
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
31157
31173
  this.godotBridge = getDefaultBridge(this.ownProject);
@@ -31198,6 +31214,7 @@ class GodotServer {
31198
31214
  });
31199
31215
  console.error("gdharness running on stdio");
31200
31216
  try {
31217
+ await this.takeOverFromAPredecessor();
31201
31218
  await this.godotBridge.start();
31202
31219
  this.bridgeStartupError = null;
31203
31220
  const bridgeStatus = this.godotBridge.getStatus();
@@ -31212,6 +31229,35 @@ class GodotServer {
31212
31229
  this.keepTryingTheBridge();
31213
31230
  }
31214
31231
  }
31232
+ async takeOverFromAPredecessor() {
31233
+ if (this.ownProject === null) {
31234
+ return;
31235
+ }
31236
+ const wanted = this.godotBridge.configuredPort;
31237
+ const announced = readAnnouncement(announcementPath(this.ownProject));
31238
+ if (announced === null || announced.pid === process.pid || announced.port !== wanted || !alive(announced.pid)) {
31239
+ return;
31240
+ }
31241
+ this.handingOverFrom = announced.pid;
31242
+ this.announcedAt = announceBridge(this.ownProject, {
31243
+ host: this.godotBridge.getStatus().host,
31244
+ port: wanted,
31245
+ version: SERVER_VERSION
31246
+ });
31247
+ const until = Date.now() + HANDOVER_MS;
31248
+ let taken = false;
31249
+ while (!taken && Date.now() < until && !this.shutdownInitiated) {
31250
+ try {
31251
+ await this.godotBridge.start(false);
31252
+ taken = true;
31253
+ this.logDebug(`Took port ${wanted} over from pid ${announced.pid}`);
31254
+ } catch {
31255
+ await delay3(HANDOVER_POLL_MS);
31256
+ }
31257
+ }
31258
+ this.handingOverFrom = null;
31259
+ this.notHandedOverBy = taken ? null : announced.pid;
31260
+ }
31215
31261
  keepTryingTheBridge() {
31216
31262
  if (this.bridgeRetry !== null || this.shutdownInitiated) {
31217
31263
  return;
@@ -32585,7 +32631,12 @@ class GodotServer {
32585
32631
  awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
32586
32632
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
32587
32633
  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.`,
32634
+ 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.`,
32635
+ bridgeHandover: this.handingOverFrom === null ? undefined : {
32636
+ fromPid: this.handingOverFrom,
32637
+ port: this.godotBridge.configuredPort,
32638
+ 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.`
32639
+ },
32589
32640
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
32590
32641
  addonNote: status.connected ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
32591
32642
  ...this.breakpointsAtRisk(),
@@ -33137,24 +33188,25 @@ class GodotServer {
33137
33188
  const recorded = running?.process === null ? readRunRecord() : null;
33138
33189
  clearRunRecord((record) => this.couldBeOurs(record.projectPath));
33139
33190
  if (!running || !going) {
33140
- return;
33191
+ return true;
33141
33192
  }
33142
33193
  running.endedHere = reason;
33194
+ const announced = this.announcedPidOf(running);
33143
33195
  if (running.throughEditor) {
33144
33196
  await this.handleViaBridge("stop_playing", {});
33145
- return;
33197
+ return await untilGone(null, announced === undefined ? [] : [announced]);
33146
33198
  }
33147
33199
  if (running.process !== null) {
33148
33200
  running.process.kill();
33149
- return;
33201
+ return await untilGone(running.process, announced === undefined ? [] : [announced]);
33150
33202
  }
33151
33203
  if (running.pid === null) {
33152
- return;
33204
+ return true;
33153
33205
  }
33154
33206
  if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
33155
33207
  running.endedHere = null;
33156
33208
  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;
33209
+ return true;
33158
33210
  }
33159
33211
  const signalled = runningAs(running.pid);
33160
33212
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
@@ -33164,7 +33216,9 @@ class GodotServer {
33164
33216
  } catch {
33165
33217
  landed = false;
33166
33218
  }
33219
+ const gone = await untilGone(null, [running.pid]);
33167
33220
  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}.`);
33221
+ return gone;
33168
33222
  }
33169
33223
  spawnGame(godotPath, cmdArgs, env) {
33170
33224
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -33766,7 +33820,7 @@ class GodotServer {
33766
33820
  const endedPid = stopped.pid ?? announcedPid;
33767
33821
  const children = readBoolean(args, "andChildren") === true && wasRunning ? await this.whatTheRunStarted(stopped) : null;
33768
33822
  this.logDebug("Stopping the running game");
33769
- await this.endActiveGame("editor_run stop", wasRunning);
33823
+ const gone = await this.endActiveGame("editor_run stop", wasRunning);
33770
33824
  const ended = children === null ? null : endChildrenAmong(children);
33771
33825
  if (wasRunning) {
33772
33826
  for (const pid of new Set([endedPid, announcedPid])) {
@@ -33790,7 +33844,7 @@ class GodotServer {
33790
33844
  errors: stopped.log.count("error"),
33791
33845
  warnings: stopped.log.count("warning"),
33792
33846
  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)}`,
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)}`,
33794
33848
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
33795
33849
  });
33796
33850
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",