gdharness 0.9.1 → 0.9.3

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
@@ -173,6 +173,7 @@ function userDataIn(home, variables = process.env) {
173
173
  }
174
174
  return carried;
175
175
  }
176
+ var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER";
176
177
 
177
178
  // node_modules/yaml/dist/nodes/identity.js
178
179
  var require_identity = __commonJS(function(exports) {
@@ -14711,6 +14712,9 @@ var init_types = __esm(() => {
14711
14712
 
14712
14713
  // src/paths.ts
14713
14714
  import { isAbsolute, relative, resolve, sep } from "node:path";
14715
+ function isSameDirectory(onePath, otherPath) {
14716
+ return relative(resolve(onePath), resolve(otherPath)) === "";
14717
+ }
14714
14718
  function isWithinRoot(rootPath, candidatePath) {
14715
14719
  const step = relative(resolve(rootPath), resolve(candidatePath));
14716
14720
  return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
@@ -15845,7 +15849,11 @@ var init_tool_definitions = __esm(() => {
15845
15849
  blank: true,
15846
15850
  description: `set: the value, fitted to the property's type. "" writes an empty string.`
15847
15851
  },
15848
- method: { type: "string", ops: ["call"] },
15852
+ method: {
15853
+ type: "string",
15854
+ ops: ["call"],
15855
+ description: 'call: which one to call. Colons call through the objects a node holds, "_game:run:advance", the same way a property is written through them.'
15856
+ },
15849
15857
  args: {
15850
15858
  type: "array",
15851
15859
  ops: ["call"],
@@ -15858,7 +15866,10 @@ var init_tool_definitions = __esm(() => {
15858
15866
  summary: "set a property, on a node or on an object it holds",
15859
15867
  requires: ["property", "value"]
15860
15868
  },
15861
- call: { summary: "call a method and return its result", requires: ["method"] }
15869
+ call: {
15870
+ summary: "call a method, on a node or on an object it holds, and return its result",
15871
+ requires: ["method"]
15872
+ }
15862
15873
  }
15863
15874
  },
15864
15875
  {
@@ -30772,11 +30783,29 @@ var init_stdio2 = __esm(() => {
30772
30783
  });
30773
30784
 
30774
30785
  // src/bridge-announce.ts
30775
- import { mkdirSync as mkdirSync5, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
30786
+ import { mkdirSync as mkdirSync5, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
30776
30787
  import { dirname as dirname6, join as join11 } from "node:path";
30777
30788
  function announcementPath(projectPath) {
30778
30789
  return join11(projectPath, ".godot", "gdharness-bridge.json");
30779
30790
  }
30791
+ function readAnnouncement(path) {
30792
+ try {
30793
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
30794
+ if (typeof parsed !== "object" || parsed === null) {
30795
+ return null;
30796
+ }
30797
+ const said = parsed;
30798
+ if (typeof said.port !== "number" || typeof said.pid !== "number") {
30799
+ return null;
30800
+ }
30801
+ return said;
30802
+ } catch {
30803
+ return null;
30804
+ }
30805
+ }
30806
+ function announcementIsOurs(path) {
30807
+ return readAnnouncement(path)?.pid === process.pid;
30808
+ }
30780
30809
  function announceBridge(projectPath, bridge) {
30781
30810
  const path = announcementPath(projectPath);
30782
30811
  try {
@@ -30798,7 +30827,7 @@ function announceBridge(projectPath, bridge) {
30798
30827
  }
30799
30828
  }
30800
30829
  function withdrawBridge(path) {
30801
- if (path === null) {
30830
+ if (path === null || !announcementIsOurs(path)) {
30802
30831
  return;
30803
30832
  }
30804
30833
  try {
@@ -34324,14 +34353,15 @@ function resolveDefaultBridgeHost() {
34324
34353
  function servedPort(said) {
34325
34354
  return typeof said === "number" && Number.isInteger(said) && said > 0 && said <= 65535 ? said : undefined;
34326
34355
  }
34327
- function getDefaultBridge(mayMove = false) {
34328
- defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
34356
+ function getDefaultBridge(ownProject = null) {
34357
+ defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, ownProject);
34329
34358
  return defaultBridge;
34330
34359
  }
34331
- var DEFAULT_PORT = 6505, DEFAULT_HOST = "127.0.0.1", DEFAULT_TIMEOUT_MS = 30000, KEEPALIVE_INTERVAL_MS = 1e4, SECOND_CONNECTION_CLOSE_CODE = 4000, GodotBridge, defaultBridge = null;
34360
+ var DEFAULT_PORT = 6505, DEFAULT_HOST = "127.0.0.1", DEFAULT_TIMEOUT_MS = 30000, KEEPALIVE_INTERVAL_MS = 1e4, SECOND_CONNECTION_CLOSE_CODE = 4000, OTHER_PROJECT_CLOSE_CODE = 4001, GodotBridge, defaultBridge = null;
34332
34361
  var init_godot_bridge = __esm(() => {
34333
34362
  init_wrapper();
34334
34363
  init_errors();
34364
+ init_paths();
34335
34365
  init_ports();
34336
34366
  GodotBridge = class GodotBridge extends EventEmitter {
34337
34367
  httpServer = null;
@@ -34345,13 +34375,16 @@ var init_godot_bridge = __esm(() => {
34345
34375
  boundPort = null;
34346
34376
  host;
34347
34377
  timeoutMs;
34348
- mayMove;
34349
- constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, mayMove = false) {
34378
+ ownProject;
34379
+ constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, ownProject = null) {
34350
34380
  super();
34351
34381
  this.wantedPort = port;
34352
34382
  this.host = host;
34353
34383
  this.timeoutMs = timeoutMs;
34354
- this.mayMove = mayMove;
34384
+ this.ownProject = ownProject;
34385
+ }
34386
+ get mayMove() {
34387
+ return this.ownProject !== null;
34355
34388
  }
34356
34389
  get port() {
34357
34390
  return this.boundPort ?? this.wantedPort;
@@ -34467,6 +34500,7 @@ var init_godot_bridge = __esm(() => {
34467
34500
  lspPort: this.connectionInfo?.lspPort,
34468
34501
  dapPort: this.connectionInfo?.dapPort,
34469
34502
  debugPort: this.connectionInfo?.debugPort,
34503
+ openedByAServer: this.connectionInfo?.openedByAServer,
34470
34504
  pendingRequests: this.pendingRequests.size,
34471
34505
  queuedResources: this.resourceQueues.size
34472
34506
  };
@@ -34597,12 +34631,17 @@ var init_godot_bridge = __esm(() => {
34597
34631
  }
34598
34632
  case "godot_ready":
34599
34633
  if (this.connectionInfo) {
34634
+ if (this.belongsElsewhere(message.project_path)) {
34635
+ this.sendElsewhere(message.project_path);
34636
+ return;
34637
+ }
34600
34638
  this.connectionInfo.projectPath = message.project_path;
34601
34639
  this.connectionInfo.addonVersion = message.addon_version ?? "";
34602
34640
  this.connectionInfo.editorPid = message.editor_pid;
34603
34641
  this.connectionInfo.lspPort = servedPort(message.lsp_port);
34604
34642
  this.connectionInfo.dapPort = servedPort(message.dap_port);
34605
34643
  this.connectionInfo.debugPort = servedPort(message.debug_port);
34644
+ this.connectionInfo.openedByAServer = message.opened_by_a_server === true;
34606
34645
  this.log("info", `Godot ready: ${message.project_path}`);
34607
34646
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
34608
34647
  }
@@ -34679,6 +34718,21 @@ var init_godot_bridge = __esm(() => {
34679
34718
  clearInterval(this.pingInterval);
34680
34719
  this.pingInterval = null;
34681
34720
  }
34721
+ belongsElsewhere(theirProject) {
34722
+ if (this.ownProject === null || theirProject.trim() === "") {
34723
+ return false;
34724
+ }
34725
+ return !isSameDirectory(this.ownProject, theirProject);
34726
+ }
34727
+ sendElsewhere(theirProject) {
34728
+ const said = `this server serves ${this.ownProject ?? ""}, and that editor has ${theirProject} open`;
34729
+ this.log("warn", `Turning away an editor from another project: ${said}`);
34730
+ const stranger = this.socket;
34731
+ this.socket = null;
34732
+ this.connectionInfo = null;
34733
+ this.stopKeepalive();
34734
+ stranger?.close(OTHER_PROJECT_CLOSE_CODE, said);
34735
+ }
34682
34736
  handleDisconnect(disconnectedSocket, reason) {
34683
34737
  if (disconnectedSocket && this.socket && disconnectedSocket !== this.socket) {
34684
34738
  this.log("debug", "Ignoring stale Godot socket disconnect event");
@@ -35603,7 +35657,7 @@ var init_lsp_client = __esm(() => {
35603
35657
  });
35604
35658
 
35605
35659
  // src/project-scan.ts
35606
- import { readdirSync as readdirSync4, readFileSync as readFileSync9 } from "node:fs";
35660
+ import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "node:fs";
35607
35661
  import { join as join12 } from "node:path";
35608
35662
  function projectStructure(projectPath) {
35609
35663
  const structure = { scenes: 0, scripts: 0, assets: 0, other: 0 };
@@ -35666,7 +35720,7 @@ function searchProject(projectPath, options) {
35666
35720
  }
35667
35721
  result.summary.files_searched += 1;
35668
35722
  const matches = [];
35669
- for (const [index, line] of readFileSync9(entryPath, "utf8").split(`
35723
+ for (const [index, line] of readFileSync10(entryPath, "utf8").split(`
35670
35724
  `).entries()) {
35671
35725
  if (full()) {
35672
35726
  break;
@@ -35694,7 +35748,7 @@ var init_project_scan = __esm(() => {
35694
35748
  });
35695
35749
 
35696
35750
  // src/runtime-client.ts
35697
- import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync10, unlinkSync } from "node:fs";
35751
+ import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync11, unlinkSync } from "node:fs";
35698
35752
  import { createConnection as createConnection3 } from "node:net";
35699
35753
  import { tmpdir as tmpdir4 } from "node:os";
35700
35754
  import { join as join13, resolve as resolve4 } from "node:path";
@@ -35725,7 +35779,7 @@ function processAlive(pid) {
35725
35779
  function parseAnnouncement(file, pid) {
35726
35780
  let fields;
35727
35781
  try {
35728
- fields = asParams(JSON.parse(readFileSync10(file, "utf8")));
35782
+ fields = asParams(JSON.parse(readFileSync11(file, "utf8")));
35729
35783
  } catch {
35730
35784
  return { kind: "rubbish" };
35731
35785
  }
@@ -35918,14 +35972,14 @@ var init_runtime_client = __esm(() => {
35918
35972
  });
35919
35973
 
35920
35974
  // src/setup.ts
35921
- import { cpSync as cpSync2, existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync11, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "node:fs";
35975
+ import { cpSync as cpSync2, existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "node:fs";
35922
35976
  import { dirname as dirname8, join as join14 } from "node:path";
35923
35977
  function installedAddonVersion(projectPath) {
35924
35978
  const marker = join14(projectPath, "addons", ADDONS2[0], VERSION_MARKER2);
35925
35979
  if (!existsSync11(marker)) {
35926
35980
  return null;
35927
35981
  }
35928
- const said = readFileSync11(marker, "utf8").trim();
35982
+ const said = readFileSync12(marker, "utf8").trim();
35929
35983
  return said === "" ? null : said;
35930
35984
  }
35931
35985
  var ADDONS2, VERSION_MARKER2 = ".gdharness-version";
@@ -35939,7 +35993,7 @@ var init_setup = __esm(() => {
35939
35993
 
35940
35994
  // src/server.ts
35941
35995
  import { execFile as execFile5, spawn } from "node:child_process";
35942
- import { existsSync as existsSync12, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync12, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
35996
+ import { existsSync as existsSync12, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync13, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
35943
35997
  import { tmpdir as tmpdir5 } from "node:os";
35944
35998
  import { basename as basename2, dirname as dirname9, join as join15, normalize as normalize3 } from "node:path";
35945
35999
  import { setTimeout as delay2 } from "node:timers/promises";
@@ -35948,6 +36002,17 @@ import { promisify as promisify5 } from "node:util";
35948
36002
  function patienceForFrames(frames, atLeast) {
35949
36003
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
35950
36004
  }
36005
+ function alive(pid) {
36006
+ if (pid === undefined) {
36007
+ return false;
36008
+ }
36009
+ try {
36010
+ process.kill(pid, 0);
36011
+ return true;
36012
+ } catch {
36013
+ return false;
36014
+ }
36015
+ }
35951
36016
  function isOfType(value, kind) {
35952
36017
  switch (kind) {
35953
36018
  case "string":
@@ -36020,11 +36085,11 @@ function aboveTheRunner(entry) {
36020
36085
  };
36021
36086
  }
36022
36087
  function hasMainScene(projectFile) {
36023
- const scene = parseProjectGodot(readFileSync12(projectFile, "utf8"))["application"]?.["run/main_scene"];
36088
+ const scene = parseProjectGodot(readFileSync13(projectFile, "utf8"))["application"]?.["run/main_scene"];
36024
36089
  return typeof scene === "string" && scene !== "";
36025
36090
  }
36026
36091
  function editorPlaysHeadless(projectFile) {
36027
- const runArgs = parseProjectGodot(readFileSync12(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
36092
+ const runArgs = parseProjectGodot(readFileSync13(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
36028
36093
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
36029
36094
  }
36030
36095
  function camelCased(params) {
@@ -36052,13 +36117,14 @@ class GodotServer {
36052
36117
  dapClient = null;
36053
36118
  bridgeStartupError = null;
36054
36119
  bridgeRetry = null;
36120
+ successorWatch = null;
36055
36121
  lastProjectPath = null;
36056
36122
  shutdownInitiated = false;
36057
36123
  ownProject;
36058
36124
  announcedAt = null;
36059
36125
  constructor() {
36060
36126
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
36061
- this.godotBridge = getDefaultBridge(this.ownProject !== null);
36127
+ this.godotBridge = getDefaultBridge(this.ownProject);
36062
36128
  this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
36063
36129
  this.setupToolHandlers();
36064
36130
  setupResourceHandlers(this.mcp, () => this.lastProjectPath);
@@ -36151,6 +36217,7 @@ class GodotServer {
36151
36217
  port: status.port,
36152
36218
  version: SERVER_VERSION
36153
36219
  });
36220
+ this.watchForASuccessor();
36154
36221
  }
36155
36222
  stopTryingTheBridge() {
36156
36223
  if (this.bridgeRetry !== null) {
@@ -36158,9 +36225,41 @@ class GodotServer {
36158
36225
  this.bridgeRetry = null;
36159
36226
  }
36160
36227
  }
36228
+ watchForASuccessor() {
36229
+ if (this.ownProject === null || this.successorWatch !== null) {
36230
+ return;
36231
+ }
36232
+ const watch = setInterval(() => {
36233
+ const successor = this.supersededBy();
36234
+ if (successor === null) {
36235
+ return;
36236
+ }
36237
+ console.error(`[SERVER] pid ${successor} now serves ${this.ownProject}; this server is finished and is stopping.`);
36238
+ this.handleShutdown(`superseded by ${successor}`, 0);
36239
+ }, SUCCESSOR_CHECK_MS);
36240
+ watch.unref();
36241
+ this.successorWatch = watch;
36242
+ }
36243
+ supersededBy() {
36244
+ if (this.ownProject === null) {
36245
+ return null;
36246
+ }
36247
+ const announced = readAnnouncement(announcementPath(this.ownProject));
36248
+ if (announced === null || announced.pid === process.pid) {
36249
+ return null;
36250
+ }
36251
+ return alive(announced.pid) ? announced.pid : null;
36252
+ }
36253
+ stopWatchingForASuccessor() {
36254
+ if (this.successorWatch !== null) {
36255
+ clearInterval(this.successorWatch);
36256
+ this.successorWatch = null;
36257
+ }
36258
+ }
36161
36259
  async cleanup() {
36162
36260
  this.logDebug("Cleaning up resources");
36163
36261
  this.stopTryingTheBridge();
36262
+ this.stopWatchingForASuccessor();
36164
36263
  withdrawBridge(this.announcedAt);
36165
36264
  this.announcedAt = null;
36166
36265
  if (this.activeProcess) {
@@ -36742,7 +36841,7 @@ class GodotServer {
36742
36841
  if (!engine.ok) {
36743
36842
  return engine.response;
36744
36843
  }
36745
- const application = parseProjectGodot(readFileSync12(project.value.file, "utf8"))["application"] ?? {};
36844
+ const application = parseProjectGodot(readFileSync13(project.value.file, "utf8"))["application"] ?? {};
36746
36845
  const name = application["config/name"];
36747
36846
  const mainScene = application["run/main_scene"];
36748
36847
  const info = {
@@ -36917,7 +37016,7 @@ class GodotServer {
36917
37016
  const written = existsSync12(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
36918
37017
  const newest = written.at(-1);
36919
37018
  if (newest !== undefined) {
36920
- report = parseJUnit(readFileSync12(join15(reportsDir, newest, "results.xml"), "utf8"));
37019
+ report = parseJUnit(readFileSync13(join15(reportsDir, newest, "results.xml"), "utf8"));
36921
37020
  }
36922
37021
  } catch (error) {
36923
37022
  reportProblem = errorMessage(error);
@@ -37116,7 +37215,8 @@ class GodotServer {
37116
37215
  "editor_status says whether the bridge is up and what has reached it"
37117
37216
  ]);
37118
37217
  }
37119
- const asked = await this.handleViaBridge("restart_editor", {});
37218
+ const mine = before.openedByAServer === true && before.projectPath !== undefined;
37219
+ const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
37120
37220
  if (asked.isError === true) {
37121
37221
  return asked;
37122
37222
  }
@@ -37144,6 +37244,26 @@ class GodotServer {
37144
37244
  tookMs: Date.now() - began
37145
37245
  });
37146
37246
  }
37247
+ async startItAgain(projectPath, editorPid) {
37248
+ const engine = await this.engine();
37249
+ if (!engine.ok) {
37250
+ return engine.response;
37251
+ }
37252
+ const ports = {
37253
+ lsp: this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT),
37254
+ dap: this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT)
37255
+ };
37256
+ const asked = await this.handleViaBridge("quit_editor", {});
37257
+ if (asked.isError === true) {
37258
+ return asked;
37259
+ }
37260
+ await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
37261
+ const opened = await this.openAnEditor(engine.value, projectPath, ports);
37262
+ if (opened.error !== null) {
37263
+ return this.createErrorResponse(`The editor was asked to go and could not be started again: ${opened.error}`, [`editor_launch opens one on ${projectPath}`]);
37264
+ }
37265
+ return this.jsonTextResponse({ restarting: true, pid: opened.pid });
37266
+ }
37147
37267
  async waitForBridge(reached, deadline) {
37148
37268
  for (;; ) {
37149
37269
  if (reached()) {
@@ -37173,14 +37293,28 @@ class GodotServer {
37173
37293
  }
37174
37294
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
37175
37295
  const ports = await this.portsForAnEditor();
37176
- const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
37296
+ const opened = await this.openAnEditor(engine.value, project.value.path, ports);
37297
+ if (opened.error !== null) {
37298
+ return this.createErrorResponse(`Could not start the editor: ${opened.error}`);
37299
+ }
37300
+ return this.jsonTextResponse({
37301
+ launched: true,
37302
+ pid: opened.pid,
37303
+ projectPath: project.value.path,
37304
+ lspPort: ports.lsp,
37305
+ dapPort: ports.dap
37306
+ });
37307
+ }
37308
+ async openAnEditor(engine, projectPath, ports) {
37309
+ const editor = spawn(engine, editorArguments(projectPath, ports), {
37177
37310
  stdio: "ignore",
37178
37311
  detached: true,
37179
37312
  env: {
37180
37313
  ...process.env,
37181
37314
  GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
37182
37315
  GDHARNESS_LSP_PORT: String(ports.lsp),
37183
- GDHARNESS_DAP_PORT: String(ports.dap)
37316
+ GDHARNESS_DAP_PORT: String(ports.dap),
37317
+ [OPENED_BY_A_SERVER]: "1"
37184
37318
  }
37185
37319
  });
37186
37320
  const started = await new Promise((resolve) => {
@@ -37192,16 +37326,10 @@ class GodotServer {
37192
37326
  });
37193
37327
  });
37194
37328
  if (started !== null) {
37195
- return this.createErrorResponse(`Could not start the editor: ${started}`);
37329
+ return { pid: null, error: started };
37196
37330
  }
37197
37331
  editor.unref();
37198
- return this.jsonTextResponse({
37199
- launched: true,
37200
- pid: editor.pid ?? null,
37201
- projectPath: project.value.path,
37202
- lspPort: ports.lsp,
37203
- dapPort: ports.dap
37204
- });
37332
+ return { pid: editor.pid ?? null, error: null };
37205
37333
  }
37206
37334
  async portsForAnEditor() {
37207
37335
  const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
@@ -37507,7 +37635,7 @@ class GodotServer {
37507
37635
  return {
37508
37636
  content: [
37509
37637
  { type: "text", text: `Screenshot captured: ${dimensions}` },
37510
- { type: "image", data: readFileSync12(screenshotPath).toString("base64"), mimeType: "image/png" }
37638
+ { type: "image", data: readFileSync13(screenshotPath).toString("base64"), mimeType: "image/png" }
37511
37639
  ]
37512
37640
  };
37513
37641
  } finally {
@@ -37573,7 +37701,7 @@ async function runGodotServer() {
37573
37701
  const server = new GodotServer;
37574
37702
  await server.run();
37575
37703
  }
37576
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, HEADLESS_OPERATIONS, PROJECT_INFO_SECTIONS, RUNNER_DIRECTORY = "addons/gdUnit4/";
37704
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, HEADLESS_OPERATIONS, PROJECT_INFO_SECTIONS, RUNNER_DIRECTORY = "addons/gdUnit4/";
37577
37705
  var init_server3 = __esm(() => {
37578
37706
  init_mcp();
37579
37707
  init_stdio2();
@@ -38322,27 +38450,42 @@ function displayPath(harness, platform = process3.platform) {
38322
38450
  }
38323
38451
  return `${harness.scope === "home" ? "~/" : ""}${file.replaceAll("\\", "/")}`;
38324
38452
  }
38325
- function entryFor(shape, launch) {
38453
+ var OURS = ["GODOT_PATH", "GDHARNESS_PROJECT"];
38454
+ function environmentFor(launch, held) {
38455
+ const kept = {};
38456
+ if (typeof held === "object" && held !== null && !Array.isArray(held)) {
38457
+ for (const [name, value] of Object.entries(held)) {
38458
+ if (typeof value === "string" && !OURS.includes(name)) {
38459
+ kept[name] = value;
38460
+ }
38461
+ }
38462
+ }
38463
+ return { ...kept, ...launch.env };
38464
+ }
38465
+ function entryFor(shape, launch, held) {
38466
+ const before = typeof held === "object" && held !== null && !Array.isArray(held) ? { ...held } : {};
38326
38467
  if (shape === "opencode") {
38327
38468
  return {
38469
+ ...before,
38328
38470
  type: "local",
38329
38471
  command: [launch.command, ...launch.args],
38330
- environment: { ...launch.env },
38472
+ environment: environmentFor(launch, before["environment"]),
38331
38473
  enabled: true
38332
38474
  };
38333
38475
  }
38334
38476
  return {
38477
+ ...before,
38335
38478
  ...shape === "typed" ? { type: "stdio" } : {},
38336
38479
  command: launch.command,
38337
38480
  args: [...launch.args],
38338
- env: { ...launch.env }
38481
+ env: environmentFor(launch, before["env"])
38339
38482
  };
38340
38483
  }
38341
38484
  function merged(existing, harness, launch) {
38342
38485
  const root = typeof existing === "object" && existing !== null && !Array.isArray(existing) ? { ...existing } : {};
38343
38486
  const held = root[harness.container];
38344
38487
  const servers = typeof held === "object" && held !== null && !Array.isArray(held) ? { ...held } : {};
38345
- servers[SERVER_KEY] = entryFor(harness.shape, launch);
38488
+ servers[SERVER_KEY] = entryFor(harness.shape, launch, servers[SERVER_KEY]);
38346
38489
  root[harness.container] = servers;
38347
38490
  return root;
38348
38491
  }
@@ -18,9 +18,14 @@ const ANNOUNCE_PROTOCOL: int = 1
18
18
  const RECONNECT_DELAY: float = 3.0
19
19
  const MAX_RECONNECT_DELAY: float = 30.0
20
20
 
21
+ ## What a server closes with when the editor that said hello belongs to another project. Kept in
22
+ ## step with `OTHER_PROJECT_CLOSE_CODE` in src/godot-bridge.ts.
23
+ const ELSEWHERE_CLOSE_CODE: int = 4001
24
+
21
25
  ## Where Godot keeps the three ports an editor serves. None of them is per editor: the settings
22
- ## file is one for every editor on the machine, so two open at once want the same three and the
23
- ## second one binds nothing.
26
+ ## file is one for every editor of this engine version on the machine, so two open at once want the
27
+ ## same three and the second one binds nothing. Read here and never written, for the reason
28
+ ## [method _serves] gives.
24
29
  const LSP_SETTING: String = "network/language_server/remote_port"
25
30
  const DAP_SETTING: String = "network/debug_adapter/remote_port"
26
31
  const DEBUGGER_SETTING: String = "network/debug/remote_port"
@@ -28,6 +33,9 @@ const DEBUGGER_SETTING: String = "network/debug/remote_port"
28
33
  ## the command line. Kept in step with `editorArguments` in src/launch.ts.
29
34
  const LSP_ASKED: String = "GDHARNESS_LSP_PORT"
30
35
  const DAP_ASKED: String = "GDHARNESS_DAP_PORT"
36
+ ## What a server that opened this editor says about itself, so that being opened by one is stated
37
+ ## rather than guessed at from the ports. Kept in step with `OPENED_BY_A_SERVER` in src/launch.ts.
38
+ const OPENED_BY_A_SERVER: String = "GDHARNESS_OPENED_BY_A_SERVER"
31
39
 
32
40
  ## How long one attempt is given before the address is called a bad one.
33
41
  ##
@@ -67,13 +75,16 @@ var _since_looked: float = 0.0
67
75
  ## anything connects or the project names a different one.
68
76
  var _refused_url: String = ""
69
77
  var _tried_announced: bool = false
78
+
79
+ ## The address this editor has already been told is another project's, so a reconnect loop says it
80
+ ## once rather than every few seconds for the rest of the session.
81
+ var _told_about_url: String = ""
70
82
  var _connecting_for: float = 0.0
71
83
 
72
84
 
73
85
  func _ready() -> void:
74
86
  _project_path = ProjectSettings.globalize_path("res://")
75
87
  version_at_load = _loaded_version()
76
- _keep_the_ports_this_editor_was_given()
77
88
 
78
89
  _reconnect_timer = Timer.new()
79
90
  _reconnect_timer.one_shot = true
@@ -265,39 +276,41 @@ func _handle_connect() -> void:
265
276
  "editor_pid": OS.get_process_id(),
266
277
  "lsp_port": _serves(LSP_ASKED, LSP_SETTING),
267
278
  "dap_port": _serves(DAP_ASKED, DAP_SETTING),
268
- "debug_port": _serving(DEBUGGER_SETTING)
279
+ "debug_port": _serving(DEBUGGER_SETTING),
280
+ # Whether a server started this editor, which is the only kind that can be started again
281
+ # as itself: the engine hands back none of the arguments it was given, so the arguments
282
+ # have to come from whoever wrote them. See `restart_editor` in play_tools.gd.
283
+ "opened_by_a_server": _opened_by_a_server()
269
284
  }
270
285
  )
271
286
 
272
287
  connected.emit()
273
288
 
274
289
 
275
- ## Writes the ports this editor was started on into the settings it reads them from.
290
+ ## Whether a gdharness server started this editor, which it says in the environment.
276
291
  ##
277
- ## The command line moved the language server and the debug adapter for this run and the engine
278
- ## keeps that override to itself: the setting still reads whatever it read before, so an editor
279
- ## that restarts itself comes back on the old number and lands on top of whichever editor holds
280
- ## it. Writing it here is what makes the move survive a restart, and what leaves one place either
281
- ## side has to read.
282
- func _keep_the_ports_this_editor_was_given() -> void:
283
- if not Engine.is_editor_hint():
284
- return
285
- _keep_port(LSP_ASKED, LSP_SETTING)
286
- _keep_port(DAP_ASKED, DAP_SETTING)
287
-
288
-
289
- func _keep_port(variable: String, setting: String) -> void:
290
- var port: int = _asked_for(variable)
291
- if port < 1 or port == _serving(setting):
292
- return
293
- var settings: EditorSettings = EditorInterface.get_editor_settings()
294
- if settings != null:
295
- settings.set_setting(setting, port)
292
+ ## It decides who may restart this editor by starting it again. Only a server that wrote the
293
+ ## arguments can write them a second time, and only an editor started that way needs it: one opened
294
+ ## by hand is on the ports its own settings name and comes back on them by itself.
295
+ ##
296
+ ## Said outright rather than read off the two port variables, which a server also sets and anybody
297
+ ## else may: an editor started from a shell that exports them is one somebody opened themselves,
298
+ ## and taking it for a server's is taking an editor off somebody's desk and putting a new one there.
299
+ static func _opened_by_a_server() -> bool:
300
+ return not OS.get_environment(OPENED_BY_A_SERVER).is_empty()
296
301
 
297
302
 
298
- ## What this editor serves: what it was told to when a server opened it, and what its settings
299
- ## say otherwise. The first is the one that counts, because the settings are the thing the command
300
- ## line was overriding.
303
+ ## What this editor serves: what it was told to when a server opened it, and what its settings say
304
+ ## otherwise. The first is the one that counts, because the settings are what the command line was
305
+ ## overriding.
306
+ ##
307
+ ## The settings are read and never written. They were written for a while, because the engine keeps
308
+ ## a command-line port override to itself and hands none of it back, so an editor restarting itself
309
+ ## came up on whatever the settings said. But there is one settings file for every editor of this
310
+ ## engine version on the machine, so writing a port chosen for one project made it the number every
311
+ ## other project's editor came up on: an editor opened by hand for something else inherited it and
312
+ ## collided with the editor it was moved away from. Restarting is done by starting the editor again
313
+ ## with the same arguments now, which is `editor_launch restart`, so there is nothing to persist.
301
314
  func _serves(variable: String, setting: String) -> int:
302
315
  var asked: int = _asked_for(variable)
303
316
  return asked if asked > 0 else _serving(setting)
@@ -343,12 +356,35 @@ func _loaded_version() -> String:
343
356
 
344
357
  func _handle_disconnect() -> void:
345
358
  _is_connected = false
359
+ _said_elsewhere()
346
360
  disconnected.emit()
347
361
 
348
362
  if _should_reconnect:
349
363
  _schedule_reconnect()
350
364
 
351
365
 
366
+ ## Says so when the server turned this editor away as another project's.
367
+ ##
368
+ ## The fallback address is tried by every project whose own server has not run yet and so has
369
+ ## announced nothing, which means any server holding it took the connection and served it. It is
370
+ ## refused now, and the refusal is worth a person's attention rather than a quiet backoff: nothing
371
+ ## about waiting reaches a server that is not this project's, and the answer is to start one.
372
+ ##
373
+ ## Said once per address. This is a reconnect loop, and a line every few seconds for the rest of
374
+ ## the session is a line nobody reads.
375
+ func _said_elsewhere() -> void:
376
+ if socket.get_close_code() != ELSEWHERE_CLOSE_CODE:
377
+ return
378
+ if _told_about_url == server_url:
379
+ return
380
+ _told_about_url = server_url
381
+ var said: String = (
382
+ "[gdharness] %s is another project's server, so this editor is not connected: %s."
383
+ + " Start this project's own server, or point it at one with GDHARNESS_BRIDGE_PORT."
384
+ )
385
+ push_warning(said % [server_url, socket.get_close_reason()])
386
+
387
+
352
388
  func _schedule_reconnect() -> void:
353
389
  if _reconnect_timer == null:
354
390
  return
@@ -77,6 +77,7 @@ func _init_tools() -> void:
77
77
  # Animation tools
78
78
  "play_scene": [_play_tools, "play_scene"],
79
79
  "restart_editor": [_play_tools, "restart_editor"],
80
+ "quit_editor": [_play_tools, "quit_editor"],
80
81
  "stop_playing": [_play_tools, "stop_playing"],
81
82
  "playing_status": [_play_tools, "playing_status"],
82
83
  "create_animation": [_animation_tools, "create_animation"],