gdharness 1.0.21 → 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
@@ -7751,6 +7751,12 @@ function editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest)
7751
7751
  }
7752
7752
  return addonVersion !== serverVersion;
7753
7753
  }
7754
+ function sameCodeNote(addonVersion, serverVersion, addonDigest, shippedDigest) {
7755
+ if (addonVersion === undefined || addonVersion === serverVersion || addonDigest === undefined || addonDigest === "" || addonDigest !== shippedDigest) {
7756
+ return;
7757
+ }
7758
+ return `The editor loaded the ${addonVersion} addons, which are the same code this ${serverVersion} server ships, so nothing needs restarting; addonVersion changes at the next editor start.`;
7759
+ }
7754
7760
  function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest) {
7755
7761
  if (!editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest)) {
7756
7762
  return;
@@ -16409,7 +16415,7 @@ var init_tool_definitions = __esm(() => {
16409
16415
  },
16410
16416
  {
16411
16417
  name: "editor_status",
16412
- 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. 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.",
16413
16419
  parameters: {},
16414
16420
  requires: []
16415
16421
  },
@@ -16564,12 +16570,12 @@ var init_tool_definitions = __esm(() => {
16564
16570
  property: {
16565
16571
  type: "string",
16566
16572
  ops: ["set"],
16567
- 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.'
16568
16574
  },
16569
16575
  value: {
16570
16576
  ops: ["set"],
16571
16577
  blank: true,
16572
- 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.`
16573
16579
  },
16574
16580
  method: {
16575
16581
  type: "string",
@@ -16579,7 +16585,7 @@ var init_tool_definitions = __esm(() => {
16579
16585
  args: {
16580
16586
  type: "array",
16581
16587
  ops: ["call"],
16582
- 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.`
16583
16589
  }
16584
16590
  },
16585
16591
  requires: ["nodePath"],
@@ -36139,7 +36145,10 @@ var init_godot_bridge = __esm(() => {
36139
36145
  get port() {
36140
36146
  return this.boundPort ?? this.wantedPort;
36141
36147
  }
36142
- async start() {
36148
+ get configuredPort() {
36149
+ return this.wantedPort;
36150
+ }
36151
+ async start(moveIfHeld = true) {
36143
36152
  if (this.httpServer) {
36144
36153
  return;
36145
36154
  }
@@ -36147,7 +36156,7 @@ var init_godot_bridge = __esm(() => {
36147
36156
  await this.listenOn(this.wantedPort);
36148
36157
  } catch (error) {
36149
36158
  const held = error instanceof Error && "code" in error && error.code === "EADDRINUSE";
36150
- if (!held || !this.mayMove) {
36159
+ if (!held || !this.mayMove || !moveIfHeld) {
36151
36160
  throw error;
36152
36161
  }
36153
36162
  this.log("warn", `Editor bridge port ${this.wantedPort} is held; taking another one.`);
@@ -38140,6 +38149,14 @@ function runIsUp(run, editorSays, now = Date.now()) {
38140
38149
  function noCodeWillCome(run) {
38141
38150
  return run.exitCode === null && run.process === null;
38142
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
+ }
38143
38160
  function endedWithoutACode(run) {
38144
38161
  if (!run.throughEditor) {
38145
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.";
@@ -38413,6 +38430,8 @@ class GodotServer {
38413
38430
  launchedFrom = null;
38414
38431
  ownProject;
38415
38432
  announcedAt = null;
38433
+ handingOverFrom = null;
38434
+ notHandedOverBy = null;
38416
38435
  constructor() {
38417
38436
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
38418
38437
  this.godotBridge = getDefaultBridge(this.ownProject);
@@ -38459,6 +38478,7 @@ class GodotServer {
38459
38478
  });
38460
38479
  console.error("gdharness running on stdio");
38461
38480
  try {
38481
+ await this.takeOverFromAPredecessor();
38462
38482
  await this.godotBridge.start();
38463
38483
  this.bridgeStartupError = null;
38464
38484
  const bridgeStatus = this.godotBridge.getStatus();
@@ -38473,6 +38493,35 @@ class GodotServer {
38473
38493
  this.keepTryingTheBridge();
38474
38494
  }
38475
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
+ }
38476
38525
  keepTryingTheBridge() {
38477
38526
  if (this.bridgeRetry !== null || this.shutdownInitiated) {
38478
38527
  return;
@@ -39846,8 +39895,14 @@ class GodotServer {
39846
39895
  awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
39847
39896
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
39848
39897
  startupError: this.bridgeStartupError,
39849
- 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
+ },
39850
39904
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
39905
+ addonNote: status.connected ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
39851
39906
  ...this.breakpointsAtRisk(),
39852
39907
  retryingBridge: this.bridgeRetry === null ? undefined : true,
39853
39908
  announcedAt: this.announcedAt ?? undefined,
@@ -40397,24 +40452,25 @@ class GodotServer {
40397
40452
  const recorded = running?.process === null ? readRunRecord() : null;
40398
40453
  clearRunRecord((record) => this.couldBeOurs(record.projectPath));
40399
40454
  if (!running || !going) {
40400
- return;
40455
+ return true;
40401
40456
  }
40402
40457
  running.endedHere = reason;
40458
+ const announced = this.announcedPidOf(running);
40403
40459
  if (running.throughEditor) {
40404
40460
  await this.handleViaBridge("stop_playing", {});
40405
- return;
40461
+ return await untilGone(null, announced === undefined ? [] : [announced]);
40406
40462
  }
40407
40463
  if (running.process !== null) {
40408
40464
  running.process.kill();
40409
- return;
40465
+ return await untilGone(running.process, announced === undefined ? [] : [announced]);
40410
40466
  }
40411
40467
  if (running.pid === null) {
40412
- return;
40468
+ return true;
40413
40469
  }
40414
40470
  if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
40415
40471
  running.endedHere = null;
40416
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.`);
40417
- return;
40473
+ return true;
40418
40474
  }
40419
40475
  const signalled = runningAs(running.pid);
40420
40476
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
@@ -40424,7 +40480,9 @@ class GodotServer {
40424
40480
  } catch {
40425
40481
  landed = false;
40426
40482
  }
40483
+ const gone = await untilGone(null, [running.pid]);
40427
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;
40428
40486
  }
40429
40487
  spawnGame(godotPath, cmdArgs, env) {
40430
40488
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -41026,7 +41084,7 @@ class GodotServer {
41026
41084
  const endedPid = stopped.pid ?? announcedPid;
41027
41085
  const children = readBoolean(args, "andChildren") === true && wasRunning ? await this.whatTheRunStarted(stopped) : null;
41028
41086
  this.logDebug("Stopping the running game");
41029
- await this.endActiveGame("editor_run stop", wasRunning);
41087
+ const gone = await this.endActiveGame("editor_run stop", wasRunning);
41030
41088
  const ended = children === null ? null : endChildrenAmong(children);
41031
41089
  if (wasRunning) {
41032
41090
  for (const pid of new Set([endedPid, announcedPid])) {
@@ -41050,7 +41108,7 @@ class GodotServer {
41050
41108
  errors: stopped.log.count("error"),
41051
41109
  warnings: stopped.log.count("warning"),
41052
41110
  clean: stopped.log.count("error") === 0,
41053
- 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)}`,
41054
41112
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
41055
41113
  });
41056
41114
  }
@@ -41566,7 +41624,7 @@ async function runGodotServer() {
41566
41624
  const server = new GodotServer;
41567
41625
  await server.run();
41568
41626
  }
41569
- 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/";
41570
41628
  var init_server3 = __esm(() => {
41571
41629
  init_mcp();
41572
41630
  init_stdio2();
@@ -41613,6 +41671,7 @@ var init_server3 = __esm(() => {
41613
41671
  boolean: "a boolean",
41614
41672
  object: "an object"
41615
41673
  };
41674
+ HANDOVER_MS = SUCCESSOR_CHECK_MS + 5000;
41616
41675
  PATH_SOLUTIONS = [
41617
41676
  'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
41618
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
@@ -10053,6 +10053,12 @@ function editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest)
10053
10053
  }
10054
10054
  return addonVersion !== serverVersion;
10055
10055
  }
10056
+ function sameCodeNote(addonVersion, serverVersion, addonDigest, shippedDigest) {
10057
+ if (addonVersion === undefined || addonVersion === serverVersion || addonDigest === undefined || addonDigest === "" || addonDigest !== shippedDigest) {
10058
+ return;
10059
+ }
10060
+ return `The editor loaded the ${addonVersion} addons, which are the same code this ${serverVersion} server ships, so nothing needs restarting; addonVersion changes at the next editor start.`;
10061
+ }
10056
10062
  function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest) {
10057
10063
  if (!editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest)) {
10058
10064
  return;
@@ -27101,7 +27107,10 @@ class GodotBridge extends EventEmitter {
27101
27107
  get port() {
27102
27108
  return this.boundPort ?? this.wantedPort;
27103
27109
  }
27104
- async start() {
27110
+ get configuredPort() {
27111
+ return this.wantedPort;
27112
+ }
27113
+ async start(moveIfHeld = true) {
27105
27114
  if (this.httpServer) {
27106
27115
  return;
27107
27116
  }
@@ -27109,7 +27118,7 @@ class GodotBridge extends EventEmitter {
27109
27118
  await this.listenOn(this.wantedPort);
27110
27119
  } catch (error) {
27111
27120
  const held = error instanceof Error && "code" in error && error.code === "EADDRINUSE";
27112
- if (!held || !this.mayMove) {
27121
+ if (!held || !this.mayMove || !moveIfHeld) {
27113
27122
  throw error;
27114
27123
  }
27115
27124
  this.log("warn", `Editor bridge port ${this.wantedPort} is held; taking another one.`);
@@ -30281,7 +30290,7 @@ var TOOL_SPECS = [
30281
30290
  },
30282
30291
  {
30283
30292
  name: "editor_status",
30284
- 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. 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.",
30285
30294
  parameters: {},
30286
30295
  requires: []
30287
30296
  },
@@ -30436,12 +30445,12 @@ var TOOL_SPECS = [
30436
30445
  property: {
30437
30446
  type: "string",
30438
30447
  ops: ["set"],
30439
- 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.'
30440
30449
  },
30441
30450
  value: {
30442
30451
  ops: ["set"],
30443
30452
  blank: true,
30444
- 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.`
30445
30454
  },
30446
30455
  method: {
30447
30456
  type: "string",
@@ -30451,7 +30460,7 @@ var TOOL_SPECS = [
30451
30460
  args: {
30452
30461
  type: "array",
30453
30462
  ops: ["call"],
30454
- 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.`
30455
30464
  }
30456
30465
  },
30457
30466
  requires: ["nodePath"],
@@ -30828,6 +30837,15 @@ function runIsUp(run, editorSays, now = Date.now()) {
30828
30837
  function noCodeWillCome(run) {
30829
30838
  return run.exitCode === null && run.process === null;
30830
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
+ }
30831
30849
  function endedWithoutACode(run) {
30832
30850
  if (!run.throughEditor) {
30833
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.";
@@ -31013,6 +31031,8 @@ function outsideTheirLists(spec, args) {
31013
31031
  }
31014
31032
  var BRIDGE_RETRY_MS = 2000;
31015
31033
  var SUCCESSOR_CHECK_MS = 1e4;
31034
+ var HANDOVER_MS = SUCCESSOR_CHECK_MS + 5000;
31035
+ var HANDOVER_POLL_MS = 250;
31016
31036
  var CACHE_WRITE_MS = 2000;
31017
31037
  var PLAYING_STATUS_MS = 1000;
31018
31038
  var SCAN_START_MS = 2000;
@@ -31146,6 +31166,8 @@ class GodotServer {
31146
31166
  launchedFrom = null;
31147
31167
  ownProject;
31148
31168
  announcedAt = null;
31169
+ handingOverFrom = null;
31170
+ notHandedOverBy = null;
31149
31171
  constructor() {
31150
31172
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
31151
31173
  this.godotBridge = getDefaultBridge(this.ownProject);
@@ -31192,6 +31214,7 @@ class GodotServer {
31192
31214
  });
31193
31215
  console.error("gdharness running on stdio");
31194
31216
  try {
31217
+ await this.takeOverFromAPredecessor();
31195
31218
  await this.godotBridge.start();
31196
31219
  this.bridgeStartupError = null;
31197
31220
  const bridgeStatus = this.godotBridge.getStatus();
@@ -31206,6 +31229,35 @@ class GodotServer {
31206
31229
  this.keepTryingTheBridge();
31207
31230
  }
31208
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
+ }
31209
31261
  keepTryingTheBridge() {
31210
31262
  if (this.bridgeRetry !== null || this.shutdownInitiated) {
31211
31263
  return;
@@ -32579,8 +32631,14 @@ class GodotServer {
32579
32631
  awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
32580
32632
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
32581
32633
  startupError: this.bridgeStartupError,
32582
- 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
+ },
32583
32640
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
32641
+ addonNote: status.connected ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
32584
32642
  ...this.breakpointsAtRisk(),
32585
32643
  retryingBridge: this.bridgeRetry === null ? undefined : true,
32586
32644
  announcedAt: this.announcedAt ?? undefined,
@@ -33130,24 +33188,25 @@ class GodotServer {
33130
33188
  const recorded = running?.process === null ? readRunRecord() : null;
33131
33189
  clearRunRecord((record) => this.couldBeOurs(record.projectPath));
33132
33190
  if (!running || !going) {
33133
- return;
33191
+ return true;
33134
33192
  }
33135
33193
  running.endedHere = reason;
33194
+ const announced = this.announcedPidOf(running);
33136
33195
  if (running.throughEditor) {
33137
33196
  await this.handleViaBridge("stop_playing", {});
33138
- return;
33197
+ return await untilGone(null, announced === undefined ? [] : [announced]);
33139
33198
  }
33140
33199
  if (running.process !== null) {
33141
33200
  running.process.kill();
33142
- return;
33201
+ return await untilGone(running.process, announced === undefined ? [] : [announced]);
33143
33202
  }
33144
33203
  if (running.pid === null) {
33145
- return;
33204
+ return true;
33146
33205
  }
33147
33206
  if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
33148
33207
  running.endedHere = null;
33149
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.`);
33150
- return;
33209
+ return true;
33151
33210
  }
33152
33211
  const signalled = runningAs(running.pid);
33153
33212
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
@@ -33157,7 +33216,9 @@ class GodotServer {
33157
33216
  } catch {
33158
33217
  landed = false;
33159
33218
  }
33219
+ const gone = await untilGone(null, [running.pid]);
33160
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;
33161
33222
  }
33162
33223
  spawnGame(godotPath, cmdArgs, env) {
33163
33224
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -33759,7 +33820,7 @@ class GodotServer {
33759
33820
  const endedPid = stopped.pid ?? announcedPid;
33760
33821
  const children = readBoolean(args, "andChildren") === true && wasRunning ? await this.whatTheRunStarted(stopped) : null;
33761
33822
  this.logDebug("Stopping the running game");
33762
- await this.endActiveGame("editor_run stop", wasRunning);
33823
+ const gone = await this.endActiveGame("editor_run stop", wasRunning);
33763
33824
  const ended = children === null ? null : endChildrenAmong(children);
33764
33825
  if (wasRunning) {
33765
33826
  for (const pid of new Set([endedPid, announcedPid])) {
@@ -33783,7 +33844,7 @@ class GodotServer {
33783
33844
  errors: stopped.log.count("error"),
33784
33845
  warnings: stopped.log.count("warning"),
33785
33846
  clean: stopped.log.count("error") === 0,
33786
- 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)}`,
33787
33848
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
33788
33849
  });
33789
33850
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.0.21",
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",