gdharness 0.12.3 → 0.12.5
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 +199 -48
- package/build/index.js +192 -46
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -15688,19 +15688,34 @@ var init_tool_definitions = __esm(() => {
|
|
|
15688
15688
|
},
|
|
15689
15689
|
{
|
|
15690
15690
|
name: "editor_run",
|
|
15691
|
-
description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice.",
|
|
15691
|
+
description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for.",
|
|
15692
15692
|
parameters: {
|
|
15693
15693
|
projectPath: PROJECT_PATH,
|
|
15694
|
-
scene: {
|
|
15694
|
+
scene: {
|
|
15695
|
+
type: "string",
|
|
15696
|
+
ops: ["start", "check"],
|
|
15697
|
+
description: "A scene to run instead of the main scene."
|
|
15698
|
+
},
|
|
15695
15699
|
args: {
|
|
15696
15700
|
type: "array",
|
|
15697
15701
|
items: { type: "string" },
|
|
15702
|
+
ops: ["start", "check"],
|
|
15698
15703
|
description: `The game's own arguments, what OS.get_cmdline_user_args() answers, such as ["--level=2"]. The separator is added here. A run with any is started by this server rather than by the editor, which fixes the game's command line when it opens the project, so the debug_* tools do not answer for it.`
|
|
15699
15704
|
},
|
|
15700
|
-
headless: { type: "boolean", description: "start: force a window or no window." },
|
|
15701
|
-
|
|
15705
|
+
headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
|
|
15706
|
+
runtimeWaitMs: {
|
|
15707
|
+
type: "number",
|
|
15708
|
+
ops: ["start"],
|
|
15709
|
+
description: "start: how long to wait for the game to announce its runtime before answering. Default 5000. Worth raising for a project that takes longer than that to reach its first frame, which is what leaves runtime listening false on a game whose runtime arrives a moment later."
|
|
15710
|
+
},
|
|
15711
|
+
frames: {
|
|
15712
|
+
type: "number",
|
|
15713
|
+
ops: ["check"],
|
|
15714
|
+
description: "check: frames to run before quitting. Default 3."
|
|
15715
|
+
},
|
|
15702
15716
|
timeoutMs: {
|
|
15703
15717
|
type: "number",
|
|
15718
|
+
ops: ["check"],
|
|
15704
15719
|
description: "check: how long to give the boot before it is called hung. Default 60000."
|
|
15705
15720
|
}
|
|
15706
15721
|
},
|
|
@@ -15714,7 +15729,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
15714
15729
|
},
|
|
15715
15730
|
{
|
|
15716
15731
|
name: "editor_output",
|
|
15717
|
-
description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away.",
|
|
15732
|
+
description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: running is asked of the operating system, endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over.",
|
|
15718
15733
|
parameters: {
|
|
15719
15734
|
severity: {
|
|
15720
15735
|
type: "string",
|
|
@@ -36095,8 +36110,9 @@ var init_runtime_client = __esm(() => {
|
|
|
36095
36110
|
});
|
|
36096
36111
|
|
|
36097
36112
|
// src/run-record.ts
|
|
36113
|
+
import { execFileSync } from "node:child_process";
|
|
36098
36114
|
import { mkdirSync as mkdirSync6, openSync, readdirSync as readdirSync6, readFileSync as readFileSync12, rmSync as rmSync7, statSync as statSync2, writeFileSync as writeFileSync8 } from "node:fs";
|
|
36099
|
-
import { join as join14 } from "node:path";
|
|
36115
|
+
import { basename as basename2, join as join14 } from "node:path";
|
|
36100
36116
|
function runsDirectory() {
|
|
36101
36117
|
return join14(runtimeDirectory(), "runs");
|
|
36102
36118
|
}
|
|
@@ -36147,9 +36163,75 @@ function recordAt(path) {
|
|
|
36147
36163
|
transcript,
|
|
36148
36164
|
startedAt,
|
|
36149
36165
|
projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
|
|
36150
|
-
arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : []
|
|
36166
|
+
arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
|
|
36167
|
+
...typeof fields["command"] === "string" ? { command: fields["command"] } : {}
|
|
36151
36168
|
};
|
|
36152
36169
|
}
|
|
36170
|
+
function runningAs(pid) {
|
|
36171
|
+
if (process.platform === "win32") {
|
|
36172
|
+
const line = windowsCommandLine(pid);
|
|
36173
|
+
if (line !== null) {
|
|
36174
|
+
return { kind: "commandLine", text: line };
|
|
36175
|
+
}
|
|
36176
|
+
const image = windowsImage(pid);
|
|
36177
|
+
return image === null ? null : { kind: "image", text: image };
|
|
36178
|
+
}
|
|
36179
|
+
try {
|
|
36180
|
+
if (process.platform === "linux") {
|
|
36181
|
+
const raw = readFileSync12(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
|
|
36182
|
+
return raw === "" ? null : { kind: "commandLine", text: raw };
|
|
36183
|
+
}
|
|
36184
|
+
const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
36185
|
+
encoding: "utf8",
|
|
36186
|
+
timeout: 15000
|
|
36187
|
+
}).trim();
|
|
36188
|
+
return args === "" ? null : { kind: "commandLine", text: args };
|
|
36189
|
+
} catch {
|
|
36190
|
+
return null;
|
|
36191
|
+
}
|
|
36192
|
+
}
|
|
36193
|
+
function windowsCommandLine(pid) {
|
|
36194
|
+
try {
|
|
36195
|
+
const answer = execFileSync("powershell", [
|
|
36196
|
+
"-NoProfile",
|
|
36197
|
+
"-NonInteractive",
|
|
36198
|
+
"-Command",
|
|
36199
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`
|
|
36200
|
+
], { encoding: "utf8", timeout: 15000, windowsHide: true }).trim();
|
|
36201
|
+
return answer === "" ? null : answer;
|
|
36202
|
+
} catch {
|
|
36203
|
+
return null;
|
|
36204
|
+
}
|
|
36205
|
+
}
|
|
36206
|
+
function windowsImage(pid) {
|
|
36207
|
+
try {
|
|
36208
|
+
const csv = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
|
|
36209
|
+
encoding: "utf8",
|
|
36210
|
+
timeout: 15000,
|
|
36211
|
+
windowsHide: true
|
|
36212
|
+
});
|
|
36213
|
+
return /^"([^"]+)"/.exec(csv.trim())?.[1] ?? null;
|
|
36214
|
+
} catch {
|
|
36215
|
+
return null;
|
|
36216
|
+
}
|
|
36217
|
+
}
|
|
36218
|
+
function stillTheRecordedRun(record) {
|
|
36219
|
+
const running = runningAs(record.pid);
|
|
36220
|
+
if (running === null) {
|
|
36221
|
+
return false;
|
|
36222
|
+
}
|
|
36223
|
+
const engine = record.command === undefined ? null : basename2(record.command);
|
|
36224
|
+
const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
|
|
36225
|
+
const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
|
|
36226
|
+
if (wanted !== null && !said.includes(wanted)) {
|
|
36227
|
+
return false;
|
|
36228
|
+
}
|
|
36229
|
+
if (running.kind === "image" || record.projectPath === "") {
|
|
36230
|
+
return wanted !== null;
|
|
36231
|
+
}
|
|
36232
|
+
const project = process.platform === "win32" ? record.projectPath.toLowerCase() : record.projectPath;
|
|
36233
|
+
return said.includes(project);
|
|
36234
|
+
}
|
|
36153
36235
|
function clearRunRecord(ours = () => true) {
|
|
36154
36236
|
for (const path of recordPaths()) {
|
|
36155
36237
|
const record = recordAt(path);
|
|
@@ -36234,7 +36316,7 @@ import {
|
|
|
36234
36316
|
rmSync as rmSync9
|
|
36235
36317
|
} from "node:fs";
|
|
36236
36318
|
import { tmpdir as tmpdir5 } from "node:os";
|
|
36237
|
-
import { basename as
|
|
36319
|
+
import { basename as basename3, dirname as dirname9, join as join16, normalize as normalize3 } from "node:path";
|
|
36238
36320
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
36239
36321
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
36240
36322
|
import { promisify as promisify5 } from "node:util";
|
|
@@ -36250,6 +36332,38 @@ function stillRunning(run) {
|
|
|
36250
36332
|
}
|
|
36251
36333
|
return true;
|
|
36252
36334
|
}
|
|
36335
|
+
function runIsUp(run, editorSays) {
|
|
36336
|
+
if (run?.throughEditor !== true) {
|
|
36337
|
+
return stillRunning(run);
|
|
36338
|
+
}
|
|
36339
|
+
return editorSays ?? stillRunning(run);
|
|
36340
|
+
}
|
|
36341
|
+
function runtimeVerdict(endpoint, after) {
|
|
36342
|
+
if (endpoint !== null) {
|
|
36343
|
+
return { listening: true, pid: endpoint.pid, port: endpoint.port };
|
|
36344
|
+
}
|
|
36345
|
+
if (!after.addon) {
|
|
36346
|
+
return {
|
|
36347
|
+
listening: false,
|
|
36348
|
+
mayYetAnnounce: false,
|
|
36349
|
+
note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
|
|
36350
|
+
};
|
|
36351
|
+
}
|
|
36352
|
+
if (after.heldAt !== null) {
|
|
36353
|
+
return {
|
|
36354
|
+
listening: false,
|
|
36355
|
+
mayYetAnnounce: true,
|
|
36356
|
+
note: "the game stopped before its runtime came up, so the runtime_* tools have nothing to talk to yet: debug_control continue lets it carry on",
|
|
36357
|
+
heldAt: after.heldAt
|
|
36358
|
+
};
|
|
36359
|
+
}
|
|
36360
|
+
return {
|
|
36361
|
+
listening: false,
|
|
36362
|
+
mayYetAnnounce: after.running,
|
|
36363
|
+
note: after.running ? `nothing announced itself within ${after.budgetMs}ms and the game is still running, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this` : `nothing announced itself within ${after.budgetMs}ms and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
|
|
36364
|
+
heldAt: null
|
|
36365
|
+
};
|
|
36366
|
+
}
|
|
36253
36367
|
function alive(pid) {
|
|
36254
36368
|
if (pid === undefined || pid === null) {
|
|
36255
36369
|
return false;
|
|
@@ -37128,7 +37242,7 @@ class GodotServer {
|
|
|
37128
37242
|
const name = application["config/name"];
|
|
37129
37243
|
const mainScene = application["run/main_scene"];
|
|
37130
37244
|
const info = {
|
|
37131
|
-
name: typeof name === "string" && name !== "" ? name :
|
|
37245
|
+
name: typeof name === "string" && name !== "" ? name : basename3(project.value.path),
|
|
37132
37246
|
path: project.value.path,
|
|
37133
37247
|
mainScene: typeof mainScene === "string" && mainScene !== "" ? mainScene : null,
|
|
37134
37248
|
godotVersion: await this.godotVersion(engine.value),
|
|
@@ -37676,18 +37790,21 @@ class GodotServer {
|
|
|
37676
37790
|
if (op === "check") {
|
|
37677
37791
|
return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
|
|
37678
37792
|
}
|
|
37793
|
+
let ended = null;
|
|
37679
37794
|
if (stillRunning(this.currentRun())) {
|
|
37680
37795
|
this.logDebug("Ending the running game before starting another");
|
|
37681
|
-
|
|
37796
|
+
ended = this.currentRun()?.pid ?? null;
|
|
37797
|
+
await this.endActiveGame("editor_run start, which ends the run that was going before it starts another");
|
|
37682
37798
|
}
|
|
37683
37799
|
const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
|
|
37684
37800
|
const headless = resolveHeadless(args["headless"], {
|
|
37685
37801
|
platform: process.platform,
|
|
37686
37802
|
variables: process.env
|
|
37687
37803
|
});
|
|
37804
|
+
const runtimeWaitMs = readPositiveNumber(args, "runtimeWaitMs") ?? ANNOUNCE_BUDGET_MS;
|
|
37688
37805
|
const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
|
|
37689
37806
|
if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0) {
|
|
37690
|
-
return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying);
|
|
37807
|
+
return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
|
|
37691
37808
|
}
|
|
37692
37809
|
const cmdArgs = runArguments({
|
|
37693
37810
|
projectPath: project.value.path,
|
|
@@ -37699,37 +37816,39 @@ class GodotServer {
|
|
|
37699
37816
|
const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
|
|
37700
37817
|
this.activeProcess = started;
|
|
37701
37818
|
const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
|
|
37819
|
+
const endedForThis = ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`;
|
|
37702
37820
|
return this.jsonTextResponse({
|
|
37703
37821
|
started: true,
|
|
37704
37822
|
through: "gdharness",
|
|
37705
37823
|
pid: started.process.pid ?? null,
|
|
37706
37824
|
arguments: cmdArgs,
|
|
37707
37825
|
refreshedClasses: refreshed.value,
|
|
37708
|
-
|
|
37709
|
-
|
|
37826
|
+
endedPreviousRun: ended ?? undefined,
|
|
37827
|
+
runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs),
|
|
37828
|
+
message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}`
|
|
37710
37829
|
});
|
|
37711
37830
|
}
|
|
37712
|
-
async
|
|
37831
|
+
async gameIsUp() {
|
|
37832
|
+
const run = this.currentRun();
|
|
37833
|
+
const editorSays = run?.throughEditor === true ? (await this.editorPlayingState())?.playing ?? null : null;
|
|
37834
|
+
return runIsUp(run, editorSays);
|
|
37835
|
+
}
|
|
37836
|
+
async runtimeUp(projectPath, before, budgetMs) {
|
|
37713
37837
|
if (!existsSync12(join16(projectPath, RUNTIME_AUTOLOAD2.path))) {
|
|
37714
|
-
return {
|
|
37715
|
-
listening: false,
|
|
37716
|
-
note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
|
|
37717
|
-
};
|
|
37838
|
+
return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false });
|
|
37718
37839
|
}
|
|
37719
37840
|
const endpoint = await announcedSince(projectPath, before, {
|
|
37720
|
-
|
|
37841
|
+
budgetMs,
|
|
37842
|
+
giveUp: () => this.dapClient?.isStopped() === true || !stillRunning(this.currentRun())
|
|
37843
|
+
});
|
|
37844
|
+
return runtimeVerdict(endpoint, {
|
|
37845
|
+
addon: true,
|
|
37846
|
+
budgetMs,
|
|
37847
|
+
heldAt: this.dapClient?.whereItStopped() ?? null,
|
|
37848
|
+
running: await this.gameIsUp()
|
|
37721
37849
|
});
|
|
37722
|
-
if (endpoint === null) {
|
|
37723
|
-
const held = this.dapClient?.whereItStopped() ?? null;
|
|
37724
|
-
return {
|
|
37725
|
-
listening: false,
|
|
37726
|
-
note: held === null ? `nothing announced itself within ${ANNOUNCE_BUDGET_MS}ms: the game may be slow to boot, or its runtime is not coming up` : "the game stopped before its runtime came up, so the runtime_* tools have nothing to talk to yet: debug_control continue lets it carry on",
|
|
37727
|
-
heldAt: held
|
|
37728
|
-
};
|
|
37729
|
-
}
|
|
37730
|
-
return { listening: true, pid: endpoint.pid, port: endpoint.port };
|
|
37731
37850
|
}
|
|
37732
|
-
async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying) {
|
|
37851
|
+
async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
|
|
37733
37852
|
const log = new GameLog;
|
|
37734
37853
|
try {
|
|
37735
37854
|
await this.dap().connect();
|
|
@@ -37762,8 +37881,9 @@ class GodotServer {
|
|
|
37762
37881
|
scene: scene === null ? "the main scene" : `res://${scene}`,
|
|
37763
37882
|
refreshedClasses,
|
|
37764
37883
|
debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
|
|
37765
|
-
|
|
37766
|
-
|
|
37884
|
+
endedPreviousRun: ended ?? undefined,
|
|
37885
|
+
runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs),
|
|
37886
|
+
message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it." + (ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`)
|
|
37767
37887
|
});
|
|
37768
37888
|
}
|
|
37769
37889
|
drainEditorOutput(game) {
|
|
@@ -37785,13 +37905,15 @@ class GodotServer {
|
|
|
37785
37905
|
game.brokeOn = halt.text;
|
|
37786
37906
|
game.log.record("error", halt.text);
|
|
37787
37907
|
}
|
|
37788
|
-
async endActiveGame() {
|
|
37908
|
+
async endActiveGame(reason) {
|
|
37789
37909
|
const running = this.activeProcess;
|
|
37790
37910
|
this.activeProcess = null;
|
|
37911
|
+
const recorded = running?.process === null ? readRunRecord() : null;
|
|
37791
37912
|
clearRunRecord((record) => this.couldBeOurs(record.projectPath));
|
|
37792
37913
|
if (!running) {
|
|
37793
37914
|
return;
|
|
37794
37915
|
}
|
|
37916
|
+
running.endedHere = reason;
|
|
37795
37917
|
if (running.throughEditor) {
|
|
37796
37918
|
await this.handleViaBridge("stop_playing", {});
|
|
37797
37919
|
return;
|
|
@@ -37800,11 +37922,17 @@ class GodotServer {
|
|
|
37800
37922
|
running.process.kill();
|
|
37801
37923
|
return;
|
|
37802
37924
|
}
|
|
37803
|
-
if (running.pid
|
|
37804
|
-
|
|
37805
|
-
|
|
37806
|
-
|
|
37925
|
+
if (running.pid === null) {
|
|
37926
|
+
return;
|
|
37927
|
+
}
|
|
37928
|
+
if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
|
|
37929
|
+
running.endedHere = null;
|
|
37930
|
+
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.`);
|
|
37931
|
+
return;
|
|
37807
37932
|
}
|
|
37933
|
+
try {
|
|
37934
|
+
process.kill(running.pid);
|
|
37935
|
+
} catch {}
|
|
37808
37936
|
}
|
|
37809
37937
|
spawnGame(godotPath, cmdArgs, env) {
|
|
37810
37938
|
const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
|
|
@@ -37885,7 +38013,8 @@ class GodotServer {
|
|
|
37885
38013
|
transcript: transcript.path,
|
|
37886
38014
|
startedAt,
|
|
37887
38015
|
projectPath,
|
|
37888
|
-
arguments: cmdArgs
|
|
38016
|
+
arguments: cmdArgs,
|
|
38017
|
+
command: godotPath
|
|
37889
38018
|
});
|
|
37890
38019
|
}
|
|
37891
38020
|
sweepTranscripts();
|
|
@@ -37932,7 +38061,7 @@ class GodotServer {
|
|
|
37932
38061
|
brokeOn: null
|
|
37933
38062
|
};
|
|
37934
38063
|
this.drainTranscript(adopted);
|
|
37935
|
-
if (!alive(record.pid)) {
|
|
38064
|
+
if (!alive(record.pid) || !stillTheRecordedRun(record)) {
|
|
37936
38065
|
adopted.log.finish();
|
|
37937
38066
|
adopted.exitCode = null;
|
|
37938
38067
|
adopted.endedUnwatched = true;
|
|
@@ -37940,13 +38069,24 @@ class GodotServer {
|
|
|
37940
38069
|
this.activeProcess = adopted;
|
|
37941
38070
|
return adopted;
|
|
37942
38071
|
}
|
|
37943
|
-
|
|
37944
|
-
|
|
37945
|
-
|
|
38072
|
+
nothingOfOursIsRunning() {
|
|
38073
|
+
const record = readRunRecord();
|
|
38074
|
+
if (record === null) {
|
|
38075
|
+
return "No game is running. Start one with editor_run.";
|
|
37946
38076
|
}
|
|
37947
38077
|
const status = this.godotBridge.getStatus();
|
|
37948
38078
|
const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
|
|
37949
|
-
|
|
38079
|
+
const whose = record.projectPath === "" ? "a project it does not name" : record.projectPath;
|
|
38080
|
+
const ours = mine === null ? "this server was started without GDHARNESS_PROJECT and has no editor connected, so it cannot say whose that run is" : `this server serves ${mine}`;
|
|
38081
|
+
return `No game of this server's is running. A run started from ${whose} is recorded on this machine, and ${ours}, so it is not this server's to answer for or to end. Start one with editor_run.`;
|
|
38082
|
+
}
|
|
38083
|
+
couldBeOurs(project) {
|
|
38084
|
+
const status = this.godotBridge.getStatus();
|
|
38085
|
+
const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
|
|
38086
|
+
if (mine === null) {
|
|
38087
|
+
return false;
|
|
38088
|
+
}
|
|
38089
|
+
return project !== "" && samePath(mine, project);
|
|
37950
38090
|
}
|
|
37951
38091
|
currentRun() {
|
|
37952
38092
|
return this.activeProcess ?? this.adoptRecordedRun();
|
|
@@ -37986,7 +38126,7 @@ class GodotServer {
|
|
|
37986
38126
|
handleGetDebugOutput(args) {
|
|
37987
38127
|
const run = this.currentRun();
|
|
37988
38128
|
if (!run) {
|
|
37989
|
-
return this.createErrorResponse(
|
|
38129
|
+
return this.createErrorResponse(this.nothingOfOursIsRunning());
|
|
37990
38130
|
}
|
|
37991
38131
|
this.drainEditorOutput(run);
|
|
37992
38132
|
this.drainTranscript(run);
|
|
@@ -38006,8 +38146,13 @@ class GodotServer {
|
|
|
38006
38146
|
if (run.endedUnwatched === true) {
|
|
38007
38147
|
notes.push("This run outlived the server that started it and is over now, so its exit code was never collected. Everything it printed is below, read back from its transcript.");
|
|
38008
38148
|
}
|
|
38149
|
+
if (typeof run.endedHere === "string") {
|
|
38150
|
+
notes.push(`This run was ended here, by ${run.endedHere}.`);
|
|
38151
|
+
} else if (!stillRunning(run) && !run.throughEditor && run.exitCode !== null) {
|
|
38152
|
+
notes.push("Nothing here ended this run: it stopped on its own or something outside this server stopped it.");
|
|
38153
|
+
}
|
|
38009
38154
|
if (run.transcript !== null && selected.omitted > 0) {
|
|
38010
|
-
notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written.`);
|
|
38155
|
+
notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written. Read it for output, not for whether the run is alive: running above is asked of the operating system, and a transcript that stops growing is a run between prints.`);
|
|
38011
38156
|
}
|
|
38012
38157
|
return this.jsonTextResponse({
|
|
38013
38158
|
running: stillRunning(run),
|
|
@@ -38019,6 +38164,7 @@ class GodotServer {
|
|
|
38019
38164
|
clean: run.log.count("error") === 0,
|
|
38020
38165
|
heldAt: halt,
|
|
38021
38166
|
endedUnwatched: run.endedUnwatched === true ? true : undefined,
|
|
38167
|
+
endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
|
|
38022
38168
|
project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
|
|
38023
38169
|
transcript: run.transcript ?? undefined,
|
|
38024
38170
|
note: notes.length > 0 ? notes.join(" ") : undefined,
|
|
@@ -38029,12 +38175,12 @@ class GodotServer {
|
|
|
38029
38175
|
async handleStopProject() {
|
|
38030
38176
|
const stopped = this.currentRun();
|
|
38031
38177
|
if (!stopped) {
|
|
38032
|
-
return this.createErrorResponse(
|
|
38178
|
+
return this.createErrorResponse(this.nothingOfOursIsRunning());
|
|
38033
38179
|
}
|
|
38034
38180
|
this.drainEditorOutput(stopped);
|
|
38035
38181
|
this.drainTranscript(stopped);
|
|
38036
38182
|
this.logDebug("Stopping the running game");
|
|
38037
|
-
await this.endActiveGame();
|
|
38183
|
+
await this.endActiveGame("editor_run stop");
|
|
38038
38184
|
return this.jsonTextResponse({
|
|
38039
38185
|
stopped: true,
|
|
38040
38186
|
through: stopped.throughEditor ? "editor" : "gdharness",
|
|
@@ -39678,10 +39824,15 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
|
|
|
39678
39824
|
\`addonIsStale\` means they differ, and \`staleNote\` says which half is behind: an editor that
|
|
39679
39825
|
needs restarting, or a server that needs reconnecting in your harness.
|
|
39680
39826
|
- Start the game with \`editor_run start\`, never by spawning an engine. The editor plays it, so
|
|
39681
|
-
its debugger holds it, which is what gives the \`debug_*\` tools something to talk to.
|
|
39827
|
+
its debugger holds it, which is what gives the \`debug_*\` tools something to talk to. Its answer
|
|
39828
|
+
says under \`runtime\` whether the game can be talked to yet; when it cannot, \`mayYetAnnounce\`
|
|
39829
|
+
false is a runtime that is not coming and true is a game still on its way up.
|
|
39682
39830
|
- Read \`editor_output\` after every run. It returns the engine's errors and warnings as entries
|
|
39683
39831
|
with their backtraces, and a \`clean\` verdict, so a run that printed an error is one call away
|
|
39684
|
-
from being known.
|
|
39832
|
+
from being known. For a long run it also names a transcript file: read that for output and this
|
|
39833
|
+
answer for state. Never decide a run has ended by watching the file stop growing, or a process
|
|
39834
|
+
list, or a timeout. \`running\` is asked of the operating system and \`endedBy\` says whether
|
|
39835
|
+
gdharness ended it; a bench between prints looks exactly like a bench that died to everything else.
|
|
39685
39836
|
- One server, one editor. A second project is a second harness session with its own server, and
|
|
39686
39837
|
that works: each editor is opened on its own language server and debug adapter ports, and says
|
|
39687
39838
|
where it serves. Two editors on one project is the thing to refuse.
|
package/build/index.js
CHANGED
|
@@ -10140,7 +10140,7 @@ import {
|
|
|
10140
10140
|
rmSync as rmSync5
|
|
10141
10141
|
} from "node:fs";
|
|
10142
10142
|
import { tmpdir as tmpdir4 } from "node:os";
|
|
10143
|
-
import { basename, dirname as dirname4, join as join10, normalize as normalize2 } from "node:path";
|
|
10143
|
+
import { basename as basename2, dirname as dirname4, join as join10, normalize as normalize2 } from "node:path";
|
|
10144
10144
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
10145
10145
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
10146
10146
|
import { promisify as promisify3 } from "node:util";
|
|
@@ -27493,8 +27493,9 @@ function setupResourceHandlers(mcp, getProjectPath) {
|
|
|
27493
27493
|
}
|
|
27494
27494
|
|
|
27495
27495
|
// src/run-record.ts
|
|
27496
|
+
import { execFileSync } from "node:child_process";
|
|
27496
27497
|
import { mkdirSync as mkdirSync3, openSync, readdirSync as readdirSync5, readFileSync as readFileSync8, rmSync as rmSync3, statSync as statSync2, writeFileSync as writeFileSync4 } from "node:fs";
|
|
27497
|
-
import { join as join8 } from "node:path";
|
|
27498
|
+
import { basename, join as join8 } from "node:path";
|
|
27498
27499
|
|
|
27499
27500
|
// src/runtime-client.ts
|
|
27500
27501
|
import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, unlinkSync } from "node:fs";
|
|
@@ -27842,9 +27843,75 @@ function recordAt(path) {
|
|
|
27842
27843
|
transcript,
|
|
27843
27844
|
startedAt,
|
|
27844
27845
|
projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
|
|
27845
|
-
arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : []
|
|
27846
|
+
arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
|
|
27847
|
+
...typeof fields["command"] === "string" ? { command: fields["command"] } : {}
|
|
27846
27848
|
};
|
|
27847
27849
|
}
|
|
27850
|
+
function runningAs(pid) {
|
|
27851
|
+
if (process.platform === "win32") {
|
|
27852
|
+
const line = windowsCommandLine(pid);
|
|
27853
|
+
if (line !== null) {
|
|
27854
|
+
return { kind: "commandLine", text: line };
|
|
27855
|
+
}
|
|
27856
|
+
const image = windowsImage(pid);
|
|
27857
|
+
return image === null ? null : { kind: "image", text: image };
|
|
27858
|
+
}
|
|
27859
|
+
try {
|
|
27860
|
+
if (process.platform === "linux") {
|
|
27861
|
+
const raw = readFileSync8(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
|
|
27862
|
+
return raw === "" ? null : { kind: "commandLine", text: raw };
|
|
27863
|
+
}
|
|
27864
|
+
const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
27865
|
+
encoding: "utf8",
|
|
27866
|
+
timeout: 15000
|
|
27867
|
+
}).trim();
|
|
27868
|
+
return args === "" ? null : { kind: "commandLine", text: args };
|
|
27869
|
+
} catch {
|
|
27870
|
+
return null;
|
|
27871
|
+
}
|
|
27872
|
+
}
|
|
27873
|
+
function windowsCommandLine(pid) {
|
|
27874
|
+
try {
|
|
27875
|
+
const answer = execFileSync("powershell", [
|
|
27876
|
+
"-NoProfile",
|
|
27877
|
+
"-NonInteractive",
|
|
27878
|
+
"-Command",
|
|
27879
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`
|
|
27880
|
+
], { encoding: "utf8", timeout: 15000, windowsHide: true }).trim();
|
|
27881
|
+
return answer === "" ? null : answer;
|
|
27882
|
+
} catch {
|
|
27883
|
+
return null;
|
|
27884
|
+
}
|
|
27885
|
+
}
|
|
27886
|
+
function windowsImage(pid) {
|
|
27887
|
+
try {
|
|
27888
|
+
const csv = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
|
|
27889
|
+
encoding: "utf8",
|
|
27890
|
+
timeout: 15000,
|
|
27891
|
+
windowsHide: true
|
|
27892
|
+
});
|
|
27893
|
+
return /^"([^"]+)"/.exec(csv.trim())?.[1] ?? null;
|
|
27894
|
+
} catch {
|
|
27895
|
+
return null;
|
|
27896
|
+
}
|
|
27897
|
+
}
|
|
27898
|
+
function stillTheRecordedRun(record) {
|
|
27899
|
+
const running = runningAs(record.pid);
|
|
27900
|
+
if (running === null) {
|
|
27901
|
+
return false;
|
|
27902
|
+
}
|
|
27903
|
+
const engine = record.command === undefined ? null : basename(record.command);
|
|
27904
|
+
const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
|
|
27905
|
+
const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
|
|
27906
|
+
if (wanted !== null && !said.includes(wanted)) {
|
|
27907
|
+
return false;
|
|
27908
|
+
}
|
|
27909
|
+
if (running.kind === "image" || record.projectPath === "") {
|
|
27910
|
+
return wanted !== null;
|
|
27911
|
+
}
|
|
27912
|
+
const project = process.platform === "win32" ? record.projectPath.toLowerCase() : record.projectPath;
|
|
27913
|
+
return said.includes(project);
|
|
27914
|
+
}
|
|
27848
27915
|
function clearRunRecord(ours = () => true) {
|
|
27849
27916
|
for (const path of recordPaths()) {
|
|
27850
27917
|
const record = recordAt(path);
|
|
@@ -28492,19 +28559,34 @@ var TOOL_SPECS = [
|
|
|
28492
28559
|
},
|
|
28493
28560
|
{
|
|
28494
28561
|
name: "editor_run",
|
|
28495
|
-
description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice.",
|
|
28562
|
+
description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for.",
|
|
28496
28563
|
parameters: {
|
|
28497
28564
|
projectPath: PROJECT_PATH,
|
|
28498
|
-
scene: {
|
|
28565
|
+
scene: {
|
|
28566
|
+
type: "string",
|
|
28567
|
+
ops: ["start", "check"],
|
|
28568
|
+
description: "A scene to run instead of the main scene."
|
|
28569
|
+
},
|
|
28499
28570
|
args: {
|
|
28500
28571
|
type: "array",
|
|
28501
28572
|
items: { type: "string" },
|
|
28573
|
+
ops: ["start", "check"],
|
|
28502
28574
|
description: `The game's own arguments, what OS.get_cmdline_user_args() answers, such as ["--level=2"]. The separator is added here. A run with any is started by this server rather than by the editor, which fixes the game's command line when it opens the project, so the debug_* tools do not answer for it.`
|
|
28503
28575
|
},
|
|
28504
|
-
headless: { type: "boolean", description: "start: force a window or no window." },
|
|
28505
|
-
|
|
28576
|
+
headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
|
|
28577
|
+
runtimeWaitMs: {
|
|
28578
|
+
type: "number",
|
|
28579
|
+
ops: ["start"],
|
|
28580
|
+
description: "start: how long to wait for the game to announce its runtime before answering. Default 5000. Worth raising for a project that takes longer than that to reach its first frame, which is what leaves runtime listening false on a game whose runtime arrives a moment later."
|
|
28581
|
+
},
|
|
28582
|
+
frames: {
|
|
28583
|
+
type: "number",
|
|
28584
|
+
ops: ["check"],
|
|
28585
|
+
description: "check: frames to run before quitting. Default 3."
|
|
28586
|
+
},
|
|
28506
28587
|
timeoutMs: {
|
|
28507
28588
|
type: "number",
|
|
28589
|
+
ops: ["check"],
|
|
28508
28590
|
description: "check: how long to give the boot before it is called hung. Default 60000."
|
|
28509
28591
|
}
|
|
28510
28592
|
},
|
|
@@ -28518,7 +28600,7 @@ var TOOL_SPECS = [
|
|
|
28518
28600
|
},
|
|
28519
28601
|
{
|
|
28520
28602
|
name: "editor_output",
|
|
28521
|
-
description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away.",
|
|
28603
|
+
description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: running is asked of the operating system, endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over.",
|
|
28522
28604
|
parameters: {
|
|
28523
28605
|
severity: {
|
|
28524
28606
|
type: "string",
|
|
@@ -28970,6 +29052,38 @@ function stillRunning(run) {
|
|
|
28970
29052
|
}
|
|
28971
29053
|
return true;
|
|
28972
29054
|
}
|
|
29055
|
+
function runIsUp(run, editorSays) {
|
|
29056
|
+
if (run?.throughEditor !== true) {
|
|
29057
|
+
return stillRunning(run);
|
|
29058
|
+
}
|
|
29059
|
+
return editorSays ?? stillRunning(run);
|
|
29060
|
+
}
|
|
29061
|
+
function runtimeVerdict(endpoint, after) {
|
|
29062
|
+
if (endpoint !== null) {
|
|
29063
|
+
return { listening: true, pid: endpoint.pid, port: endpoint.port };
|
|
29064
|
+
}
|
|
29065
|
+
if (!after.addon) {
|
|
29066
|
+
return {
|
|
29067
|
+
listening: false,
|
|
29068
|
+
mayYetAnnounce: false,
|
|
29069
|
+
note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
|
|
29070
|
+
};
|
|
29071
|
+
}
|
|
29072
|
+
if (after.heldAt !== null) {
|
|
29073
|
+
return {
|
|
29074
|
+
listening: false,
|
|
29075
|
+
mayYetAnnounce: true,
|
|
29076
|
+
note: "the game stopped before its runtime came up, so the runtime_* tools have nothing to talk to yet: debug_control continue lets it carry on",
|
|
29077
|
+
heldAt: after.heldAt
|
|
29078
|
+
};
|
|
29079
|
+
}
|
|
29080
|
+
return {
|
|
29081
|
+
listening: false,
|
|
29082
|
+
mayYetAnnounce: after.running,
|
|
29083
|
+
note: after.running ? `nothing announced itself within ${after.budgetMs}ms and the game is still running, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this` : `nothing announced itself within ${after.budgetMs}ms and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
|
|
29084
|
+
heldAt: null
|
|
29085
|
+
};
|
|
29086
|
+
}
|
|
28973
29087
|
function alive(pid) {
|
|
28974
29088
|
if (pid === undefined || pid === null) {
|
|
28975
29089
|
return false;
|
|
@@ -29910,7 +30024,7 @@ class GodotServer {
|
|
|
29910
30024
|
const name = application["config/name"];
|
|
29911
30025
|
const mainScene = application["run/main_scene"];
|
|
29912
30026
|
const info = {
|
|
29913
|
-
name: typeof name === "string" && name !== "" ? name :
|
|
30027
|
+
name: typeof name === "string" && name !== "" ? name : basename2(project.value.path),
|
|
29914
30028
|
path: project.value.path,
|
|
29915
30029
|
mainScene: typeof mainScene === "string" && mainScene !== "" ? mainScene : null,
|
|
29916
30030
|
godotVersion: await this.godotVersion(engine.value),
|
|
@@ -30458,18 +30572,21 @@ class GodotServer {
|
|
|
30458
30572
|
if (op === "check") {
|
|
30459
30573
|
return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
|
|
30460
30574
|
}
|
|
30575
|
+
let ended = null;
|
|
30461
30576
|
if (stillRunning(this.currentRun())) {
|
|
30462
30577
|
this.logDebug("Ending the running game before starting another");
|
|
30463
|
-
|
|
30578
|
+
ended = this.currentRun()?.pid ?? null;
|
|
30579
|
+
await this.endActiveGame("editor_run start, which ends the run that was going before it starts another");
|
|
30464
30580
|
}
|
|
30465
30581
|
const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
|
|
30466
30582
|
const headless = resolveHeadless(args["headless"], {
|
|
30467
30583
|
platform: process.platform,
|
|
30468
30584
|
variables: process.env
|
|
30469
30585
|
});
|
|
30586
|
+
const runtimeWaitMs = readPositiveNumber(args, "runtimeWaitMs") ?? ANNOUNCE_BUDGET_MS;
|
|
30470
30587
|
const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
|
|
30471
30588
|
if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0) {
|
|
30472
|
-
return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying);
|
|
30589
|
+
return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
|
|
30473
30590
|
}
|
|
30474
30591
|
const cmdArgs = runArguments({
|
|
30475
30592
|
projectPath: project.value.path,
|
|
@@ -30481,37 +30598,39 @@ class GodotServer {
|
|
|
30481
30598
|
const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
|
|
30482
30599
|
this.activeProcess = started;
|
|
30483
30600
|
const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
|
|
30601
|
+
const endedForThis = ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`;
|
|
30484
30602
|
return this.jsonTextResponse({
|
|
30485
30603
|
started: true,
|
|
30486
30604
|
through: "gdharness",
|
|
30487
30605
|
pid: started.process.pid ?? null,
|
|
30488
30606
|
arguments: cmdArgs,
|
|
30489
30607
|
refreshedClasses: refreshed.value,
|
|
30490
|
-
|
|
30491
|
-
|
|
30608
|
+
endedPreviousRun: ended ?? undefined,
|
|
30609
|
+
runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs),
|
|
30610
|
+
message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}`
|
|
30492
30611
|
});
|
|
30493
30612
|
}
|
|
30494
|
-
async
|
|
30613
|
+
async gameIsUp() {
|
|
30614
|
+
const run = this.currentRun();
|
|
30615
|
+
const editorSays = run?.throughEditor === true ? (await this.editorPlayingState())?.playing ?? null : null;
|
|
30616
|
+
return runIsUp(run, editorSays);
|
|
30617
|
+
}
|
|
30618
|
+
async runtimeUp(projectPath, before, budgetMs) {
|
|
30495
30619
|
if (!existsSync7(join10(projectPath, RUNTIME_AUTOLOAD.path))) {
|
|
30496
|
-
return {
|
|
30497
|
-
listening: false,
|
|
30498
|
-
note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
|
|
30499
|
-
};
|
|
30620
|
+
return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false });
|
|
30500
30621
|
}
|
|
30501
30622
|
const endpoint = await announcedSince(projectPath, before, {
|
|
30502
|
-
|
|
30623
|
+
budgetMs,
|
|
30624
|
+
giveUp: () => this.dapClient?.isStopped() === true || !stillRunning(this.currentRun())
|
|
30625
|
+
});
|
|
30626
|
+
return runtimeVerdict(endpoint, {
|
|
30627
|
+
addon: true,
|
|
30628
|
+
budgetMs,
|
|
30629
|
+
heldAt: this.dapClient?.whereItStopped() ?? null,
|
|
30630
|
+
running: await this.gameIsUp()
|
|
30503
30631
|
});
|
|
30504
|
-
if (endpoint === null) {
|
|
30505
|
-
const held = this.dapClient?.whereItStopped() ?? null;
|
|
30506
|
-
return {
|
|
30507
|
-
listening: false,
|
|
30508
|
-
note: held === null ? `nothing announced itself within ${ANNOUNCE_BUDGET_MS}ms: the game may be slow to boot, or its runtime is not coming up` : "the game stopped before its runtime came up, so the runtime_* tools have nothing to talk to yet: debug_control continue lets it carry on",
|
|
30509
|
-
heldAt: held
|
|
30510
|
-
};
|
|
30511
|
-
}
|
|
30512
|
-
return { listening: true, pid: endpoint.pid, port: endpoint.port };
|
|
30513
30632
|
}
|
|
30514
|
-
async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying) {
|
|
30633
|
+
async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
|
|
30515
30634
|
const log = new GameLog;
|
|
30516
30635
|
try {
|
|
30517
30636
|
await this.dap().connect();
|
|
@@ -30544,8 +30663,9 @@ class GodotServer {
|
|
|
30544
30663
|
scene: scene === null ? "the main scene" : `res://${scene}`,
|
|
30545
30664
|
refreshedClasses,
|
|
30546
30665
|
debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
|
|
30547
|
-
|
|
30548
|
-
|
|
30666
|
+
endedPreviousRun: ended ?? undefined,
|
|
30667
|
+
runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs),
|
|
30668
|
+
message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it." + (ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`)
|
|
30549
30669
|
});
|
|
30550
30670
|
}
|
|
30551
30671
|
drainEditorOutput(game) {
|
|
@@ -30567,13 +30687,15 @@ class GodotServer {
|
|
|
30567
30687
|
game.brokeOn = halt.text;
|
|
30568
30688
|
game.log.record("error", halt.text);
|
|
30569
30689
|
}
|
|
30570
|
-
async endActiveGame() {
|
|
30690
|
+
async endActiveGame(reason) {
|
|
30571
30691
|
const running = this.activeProcess;
|
|
30572
30692
|
this.activeProcess = null;
|
|
30693
|
+
const recorded = running?.process === null ? readRunRecord() : null;
|
|
30573
30694
|
clearRunRecord((record) => this.couldBeOurs(record.projectPath));
|
|
30574
30695
|
if (!running) {
|
|
30575
30696
|
return;
|
|
30576
30697
|
}
|
|
30698
|
+
running.endedHere = reason;
|
|
30577
30699
|
if (running.throughEditor) {
|
|
30578
30700
|
await this.handleViaBridge("stop_playing", {});
|
|
30579
30701
|
return;
|
|
@@ -30582,11 +30704,17 @@ class GodotServer {
|
|
|
30582
30704
|
running.process.kill();
|
|
30583
30705
|
return;
|
|
30584
30706
|
}
|
|
30585
|
-
if (running.pid
|
|
30586
|
-
|
|
30587
|
-
|
|
30588
|
-
|
|
30707
|
+
if (running.pid === null) {
|
|
30708
|
+
return;
|
|
30709
|
+
}
|
|
30710
|
+
if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
|
|
30711
|
+
running.endedHere = null;
|
|
30712
|
+
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.`);
|
|
30713
|
+
return;
|
|
30589
30714
|
}
|
|
30715
|
+
try {
|
|
30716
|
+
process.kill(running.pid);
|
|
30717
|
+
} catch {}
|
|
30590
30718
|
}
|
|
30591
30719
|
spawnGame(godotPath, cmdArgs, env) {
|
|
30592
30720
|
const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
|
|
@@ -30667,7 +30795,8 @@ class GodotServer {
|
|
|
30667
30795
|
transcript: transcript.path,
|
|
30668
30796
|
startedAt,
|
|
30669
30797
|
projectPath,
|
|
30670
|
-
arguments: cmdArgs
|
|
30798
|
+
arguments: cmdArgs,
|
|
30799
|
+
command: godotPath
|
|
30671
30800
|
});
|
|
30672
30801
|
}
|
|
30673
30802
|
sweepTranscripts();
|
|
@@ -30714,7 +30843,7 @@ class GodotServer {
|
|
|
30714
30843
|
brokeOn: null
|
|
30715
30844
|
};
|
|
30716
30845
|
this.drainTranscript(adopted);
|
|
30717
|
-
if (!alive(record.pid)) {
|
|
30846
|
+
if (!alive(record.pid) || !stillTheRecordedRun(record)) {
|
|
30718
30847
|
adopted.log.finish();
|
|
30719
30848
|
adopted.exitCode = null;
|
|
30720
30849
|
adopted.endedUnwatched = true;
|
|
@@ -30722,13 +30851,24 @@ class GodotServer {
|
|
|
30722
30851
|
this.activeProcess = adopted;
|
|
30723
30852
|
return adopted;
|
|
30724
30853
|
}
|
|
30725
|
-
|
|
30726
|
-
|
|
30727
|
-
|
|
30854
|
+
nothingOfOursIsRunning() {
|
|
30855
|
+
const record = readRunRecord();
|
|
30856
|
+
if (record === null) {
|
|
30857
|
+
return "No game is running. Start one with editor_run.";
|
|
30728
30858
|
}
|
|
30729
30859
|
const status = this.godotBridge.getStatus();
|
|
30730
30860
|
const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
|
|
30731
|
-
|
|
30861
|
+
const whose = record.projectPath === "" ? "a project it does not name" : record.projectPath;
|
|
30862
|
+
const ours = mine === null ? "this server was started without GDHARNESS_PROJECT and has no editor connected, so it cannot say whose that run is" : `this server serves ${mine}`;
|
|
30863
|
+
return `No game of this server's is running. A run started from ${whose} is recorded on this machine, and ${ours}, so it is not this server's to answer for or to end. Start one with editor_run.`;
|
|
30864
|
+
}
|
|
30865
|
+
couldBeOurs(project) {
|
|
30866
|
+
const status = this.godotBridge.getStatus();
|
|
30867
|
+
const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
|
|
30868
|
+
if (mine === null) {
|
|
30869
|
+
return false;
|
|
30870
|
+
}
|
|
30871
|
+
return project !== "" && samePath(mine, project);
|
|
30732
30872
|
}
|
|
30733
30873
|
currentRun() {
|
|
30734
30874
|
return this.activeProcess ?? this.adoptRecordedRun();
|
|
@@ -30768,7 +30908,7 @@ class GodotServer {
|
|
|
30768
30908
|
handleGetDebugOutput(args) {
|
|
30769
30909
|
const run = this.currentRun();
|
|
30770
30910
|
if (!run) {
|
|
30771
|
-
return this.createErrorResponse(
|
|
30911
|
+
return this.createErrorResponse(this.nothingOfOursIsRunning());
|
|
30772
30912
|
}
|
|
30773
30913
|
this.drainEditorOutput(run);
|
|
30774
30914
|
this.drainTranscript(run);
|
|
@@ -30788,8 +30928,13 @@ class GodotServer {
|
|
|
30788
30928
|
if (run.endedUnwatched === true) {
|
|
30789
30929
|
notes.push("This run outlived the server that started it and is over now, so its exit code was never collected. Everything it printed is below, read back from its transcript.");
|
|
30790
30930
|
}
|
|
30931
|
+
if (typeof run.endedHere === "string") {
|
|
30932
|
+
notes.push(`This run was ended here, by ${run.endedHere}.`);
|
|
30933
|
+
} else if (!stillRunning(run) && !run.throughEditor && run.exitCode !== null) {
|
|
30934
|
+
notes.push("Nothing here ended this run: it stopped on its own or something outside this server stopped it.");
|
|
30935
|
+
}
|
|
30791
30936
|
if (run.transcript !== null && selected.omitted > 0) {
|
|
30792
|
-
notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written.`);
|
|
30937
|
+
notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written. Read it for output, not for whether the run is alive: running above is asked of the operating system, and a transcript that stops growing is a run between prints.`);
|
|
30793
30938
|
}
|
|
30794
30939
|
return this.jsonTextResponse({
|
|
30795
30940
|
running: stillRunning(run),
|
|
@@ -30801,6 +30946,7 @@ class GodotServer {
|
|
|
30801
30946
|
clean: run.log.count("error") === 0,
|
|
30802
30947
|
heldAt: halt,
|
|
30803
30948
|
endedUnwatched: run.endedUnwatched === true ? true : undefined,
|
|
30949
|
+
endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
|
|
30804
30950
|
project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
|
|
30805
30951
|
transcript: run.transcript ?? undefined,
|
|
30806
30952
|
note: notes.length > 0 ? notes.join(" ") : undefined,
|
|
@@ -30811,12 +30957,12 @@ class GodotServer {
|
|
|
30811
30957
|
async handleStopProject() {
|
|
30812
30958
|
const stopped = this.currentRun();
|
|
30813
30959
|
if (!stopped) {
|
|
30814
|
-
return this.createErrorResponse(
|
|
30960
|
+
return this.createErrorResponse(this.nothingOfOursIsRunning());
|
|
30815
30961
|
}
|
|
30816
30962
|
this.drainEditorOutput(stopped);
|
|
30817
30963
|
this.drainTranscript(stopped);
|
|
30818
30964
|
this.logDebug("Stopping the running game");
|
|
30819
|
-
await this.endActiveGame();
|
|
30965
|
+
await this.endActiveGame("editor_run stop");
|
|
30820
30966
|
return this.jsonTextResponse({
|
|
30821
30967
|
stopped: true,
|
|
30822
30968
|
through: stopped.throughEditor ? "editor" : "gdharness",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.5",
|
|
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",
|