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