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