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
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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,
|
|
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.
|
|
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",
|