gdharness 0.6.3 → 0.6.4

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
@@ -157,8 +157,8 @@ function runArguments(options) {
157
157
  }
158
158
  return args;
159
159
  }
160
- function editorArguments(projectPath) {
161
- return ["-e", "--path", projectPath];
160
+ function editorArguments(projectPath, ports) {
161
+ return ["-e", "--path", projectPath, "--lsp-port", String(ports.lsp), "--dap-port", String(ports.dap)];
162
162
  }
163
163
  function userDataIn(home, variables = process.env) {
164
164
  const moved = ["APPDATA", "XDG_DATA_HOME"];
@@ -30791,10 +30791,11 @@ var init_framing = __esm(() => {
30791
30791
  });
30792
30792
 
30793
30793
  // src/ports.ts
30794
- function portFromEnv(variable, fallback) {
30794
+ import { createServer } from "node:net";
30795
+ function portFromEnvOrNull(variable) {
30795
30796
  const raw = process.env[variable]?.trim();
30796
30797
  if (!raw) {
30797
- return fallback;
30798
+ return null;
30798
30799
  }
30799
30800
  const parsed = Number.parseInt(raw, 10);
30800
30801
  if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535 || String(parsed) !== raw) {
@@ -30802,6 +30803,36 @@ function portFromEnv(variable, fallback) {
30802
30803
  }
30803
30804
  return parsed;
30804
30805
  }
30806
+ function portFromEnv(variable, fallback) {
30807
+ return portFromEnvOrNull(variable) ?? fallback;
30808
+ }
30809
+ async function freePort(preferred, host = "127.0.0.1") {
30810
+ const asked = await bindable(preferred, host);
30811
+ if (asked !== null) {
30812
+ return asked;
30813
+ }
30814
+ const spare = await bindable(0, host);
30815
+ if (spare === null) {
30816
+ throw new Refusal2(`No port could be bound on ${host}.`);
30817
+ }
30818
+ return spare;
30819
+ }
30820
+ function bindable(port, host) {
30821
+ return new Promise((resolve) => {
30822
+ const probe = createServer();
30823
+ probe.once("error", () => {
30824
+ resolve(null);
30825
+ });
30826
+ probe.once("listening", () => {
30827
+ const bound = probe.address();
30828
+ const taken = typeof bound === "object" && bound !== null ? bound.port : port;
30829
+ probe.close(() => {
30830
+ resolve(taken);
30831
+ });
30832
+ });
30833
+ probe.listen(port, host);
30834
+ });
30835
+ }
30805
30836
  var init_ports = __esm(() => {
30806
30837
  init_errors();
30807
30838
  });
@@ -34189,6 +34220,9 @@ function resolveDefaultBridgeHost() {
34189
34220
  const host = process.env["GDHARNESS_BRIDGE_HOST"]?.trim();
34190
34221
  return host === undefined || host === "" ? DEFAULT_HOST : host;
34191
34222
  }
34223
+ function servedPort(said) {
34224
+ return typeof said === "number" && Number.isInteger(said) && said > 0 && said <= 65535 ? said : undefined;
34225
+ }
34192
34226
  function getDefaultBridge(mayMove = false) {
34193
34227
  defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
34194
34228
  return defaultBridge;
@@ -34329,6 +34363,9 @@ var init_godot_bridge = __esm(() => {
34329
34363
  lastPongAt: this.connectionInfo?.lastPongAt,
34330
34364
  addonVersion: this.connectionInfo?.addonVersion,
34331
34365
  editorPid: this.connectionInfo?.editorPid,
34366
+ lspPort: this.connectionInfo?.lspPort,
34367
+ dapPort: this.connectionInfo?.dapPort,
34368
+ debugPort: this.connectionInfo?.debugPort,
34332
34369
  pendingRequests: this.pendingRequests.size,
34333
34370
  queuedResources: this.resourceQueues.size
34334
34371
  };
@@ -34462,6 +34499,9 @@ var init_godot_bridge = __esm(() => {
34462
34499
  this.connectionInfo.projectPath = message.project_path;
34463
34500
  this.connectionInfo.addonVersion = message.addon_version ?? "";
34464
34501
  this.connectionInfo.editorPid = message.editor_pid;
34502
+ this.connectionInfo.lspPort = servedPort(message.lsp_port);
34503
+ this.connectionInfo.dapPort = servedPort(message.dap_port);
34504
+ this.connectionInfo.debugPort = servedPort(message.debug_port);
34465
34505
  this.log("info", `Godot ready: ${message.project_path}`);
34466
34506
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
34467
34507
  }
@@ -35368,10 +35408,10 @@ function asToolResponse(payload) {
35368
35408
  ]
35369
35409
  };
35370
35410
  }
35371
- function normalizeLSPError(error) {
35411
+ function normalizeLSPError(error, port) {
35372
35412
  if (error instanceof Error) {
35373
35413
  if (error.message.includes("ECONNREFUSED") || error.message.includes("Failed to connect to Godot LSP") || error.message.includes("socket closed")) {
35374
- return `Godot LSP is unavailable on port ${portFromEnv("GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
35414
+ return `Godot LSP is unavailable on port ${port}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
35375
35415
  }
35376
35416
  return error.message;
35377
35417
  }
@@ -35449,7 +35489,7 @@ async function handleLSPTool(client, toolName, args) {
35449
35489
  }
35450
35490
  } catch (error) {
35451
35491
  return asToolResponse({
35452
- error: normalizeLSPError(error)
35492
+ error: normalizeLSPError(error, client.port)
35453
35493
  });
35454
35494
  }
35455
35495
  }
@@ -36412,7 +36452,7 @@ class GodotServer {
36412
36452
  return {
36413
36453
  ok: false,
36414
36454
  response: this.createErrorResponse(`The editor is playing the game but its debug adapter did not answer: ${errorMessage(error)}`, [
36415
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
36455
+ `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
36416
36456
  "GDHARNESS_DAP_PORT points this server at another one"
36417
36457
  ])
36418
36458
  };
@@ -36779,7 +36819,8 @@ class GodotServer {
36779
36819
  if (payload["error"] !== undefined) {
36780
36820
  const reason = payload["error"];
36781
36821
  return this.createErrorResponse(`Diagnostics unavailable: ${typeof reason === "string" ? reason : JSON.stringify(reason)}`, [
36782
- "Ensure the Godot editor is running with its language server enabled, on port 6005 or on the port GDHARNESS_LSP_PORT names"
36822
+ `Ensure the Godot editor is running with its language server enabled, on port ${this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}`,
36823
+ "GDHARNESS_LSP_PORT points this server at another one"
36783
36824
  ]);
36784
36825
  }
36785
36826
  const diagnostics = readArray(payload, "diagnostics") ?? [];
@@ -36796,16 +36837,33 @@ class GodotServer {
36796
36837
  });
36797
36838
  }
36798
36839
  async handleLSP(toolName, args) {
36799
- this.lspClient ??= new GodotLSPClient;
36840
+ const port = this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT);
36841
+ if (this.lspClient !== null && this.lspClient.port !== port) {
36842
+ await this.lspClient.disconnect();
36843
+ this.lspClient = null;
36844
+ }
36845
+ this.lspClient ??= new GodotLSPClient(port);
36800
36846
  return handleLSPTool(this.lspClient, toolName, args);
36801
36847
  }
36802
36848
  async handleDAP(toolName, args) {
36803
36849
  return handleDAPTool(this.dap(), toolName, args);
36804
36850
  }
36805
36851
  dap() {
36806
- this.dapClient ??= new GodotDAPClient;
36852
+ const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
36853
+ if (this.dapClient !== null && this.dapClient.port !== port) {
36854
+ this.dapClient = null;
36855
+ }
36856
+ this.dapClient ??= new GodotDAPClient(port);
36807
36857
  return this.dapClient;
36808
36858
  }
36859
+ editorServes(field, variable, fallback) {
36860
+ const named = portFromEnvOrNull(variable);
36861
+ if (named !== null) {
36862
+ return named;
36863
+ }
36864
+ const status = this.godotBridge.getStatus();
36865
+ return (status.connected ? status[field] : undefined) ?? fallback;
36866
+ }
36809
36867
  getEditorStatusPayload() {
36810
36868
  const status = this.godotBridge.getStatus();
36811
36869
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
@@ -36833,7 +36891,8 @@ class GodotServer {
36833
36891
  const answer = await this.godotBridge.invokeTool("playing_status", {});
36834
36892
  return {
36835
36893
  playing: readBoolean(asParams(answer), "playing") ?? false,
36836
- scene: readString(asParams(answer), "scenePath") ?? ""
36894
+ scene: readString(asParams(answer), "scenePath") ?? "",
36895
+ debugPort: readNumber(asParams(answer), "debugPort")
36837
36896
  };
36838
36897
  } catch {
36839
36898
  return null;
@@ -36851,15 +36910,19 @@ class GodotServer {
36851
36910
  problem: reply.ok ? null : reply.message
36852
36911
  };
36853
36912
  }));
36913
+ const playing = await this.editorPlayingState();
36854
36914
  return this.jsonTextResponse({
36855
- editor: this.getEditorStatusPayload(),
36915
+ editor: {
36916
+ ...this.getEditorStatusPayload(),
36917
+ debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
36918
+ },
36856
36919
  godot: {
36857
36920
  path: godotPath,
36858
36921
  version: godotPath === null ? null : await this.godotVersion(godotPath)
36859
36922
  },
36860
36923
  game: {
36861
36924
  processActive: this.activeProcess !== null,
36862
- playingInEditor: await this.editorPlayingState(),
36925
+ playingInEditor: playing,
36863
36926
  runtimeConnected: games.some((game) => game.reachable),
36864
36927
  runtimes: games
36865
36928
  }
@@ -36918,10 +36981,10 @@ class GodotServer {
36918
36981
  return project.response;
36919
36982
  }
36920
36983
  if (this.godotBridge.isConnected()) {
36921
- return this.createErrorResponse("An editor is already connected to this server, and a second one would take the language server and debug adapter ports from it.", [
36984
+ return this.createErrorResponse("An editor is already connected to this server.", [
36922
36985
  "editor_status says which editor is answering, and for which project",
36923
36986
  "editor_launch restart replaces the connected editor rather than joining it",
36924
- "Close the open editor first if the new project is the one you want"
36987
+ "Another project wants its own gdharness server, which is its own harness session"
36925
36988
  ]);
36926
36989
  }
36927
36990
  const engine = await this.engine();
@@ -36929,10 +36992,16 @@ class GodotServer {
36929
36992
  return engine.response;
36930
36993
  }
36931
36994
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
36932
- const editor = spawn(engine.value, editorArguments(project.value.path), {
36995
+ const ports = await this.portsForAnEditor();
36996
+ const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
36933
36997
  stdio: "ignore",
36934
36998
  detached: true,
36935
- env: { ...process.env, GDHARNESS_RUNTIME_DIR: runtimeDirectory() }
36999
+ env: {
37000
+ ...process.env,
37001
+ GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
37002
+ GDHARNESS_LSP_PORT: String(ports.lsp),
37003
+ GDHARNESS_DAP_PORT: String(ports.dap)
37004
+ }
36936
37005
  });
36937
37006
  const started = await new Promise((resolve) => {
36938
37007
  editor.once("spawn", () => {
@@ -36949,9 +37018,16 @@ class GodotServer {
36949
37018
  return this.jsonTextResponse({
36950
37019
  launched: true,
36951
37020
  pid: editor.pid ?? null,
36952
- projectPath: project.value.path
37021
+ projectPath: project.value.path,
37022
+ lspPort: ports.lsp,
37023
+ dapPort: ports.dap
36953
37024
  });
36954
37025
  }
37026
+ async portsForAnEditor() {
37027
+ const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
37028
+ const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
37029
+ return { lsp, dap };
37030
+ }
36955
37031
  async handleRunProject(args, op) {
36956
37032
  const project = this.project(args);
36957
37033
  if (!project.ok) {
@@ -37019,7 +37095,7 @@ class GodotServer {
37019
37095
  await this.dap().connect();
37020
37096
  } catch (error) {
37021
37097
  return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
37022
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
37098
+ `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
37023
37099
  "GDHARNESS_DAP_PORT points this server at another one"
37024
37100
  ]);
37025
37101
  }
@@ -37041,6 +37117,7 @@ class GodotServer {
37041
37117
  through: "editor",
37042
37118
  scene: scene === null ? "the main scene" : `res://${scene}`,
37043
37119
  refreshedClasses,
37120
+ debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
37044
37121
  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."
37045
37122
  });
37046
37123
  }
@@ -37223,7 +37300,7 @@ class GodotServer {
37223
37300
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
37224
37301
  const { op: _op, projectPath, ...params } = asParams(args);
37225
37302
  const announced = runtimesAnnounced();
37226
- const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : undefined, announced.unspoken);
37303
+ const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken);
37227
37304
  if ("problem" in choice) {
37228
37305
  return this.createErrorResponse(choice.problem);
37229
37306
  }
@@ -37331,6 +37408,7 @@ var init_server3 = __esm(() => {
37331
37408
  init_junit();
37332
37409
  init_lsp_client();
37333
37410
  init_paths();
37411
+ init_ports();
37334
37412
  init_project_scan();
37335
37413
  init_resources();
37336
37414
  init_runtime_client();
@@ -38654,6 +38732,9 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
38654
38732
  - Read \`editor_output\` after every run. It returns the engine's errors and warnings as entries
38655
38733
  with their backtraces, and a \`clean\` verdict, so a run that printed an error is one call away
38656
38734
  from being known.
38735
+ - One server, one editor. A second project is a second harness session with its own server, and
38736
+ that works: each editor is opened on its own language server and debug adapter ports, and says
38737
+ where it serves. Two editors on one project is the thing to refuse.
38657
38738
 
38658
38739
  ## Measuring a running game
38659
38740
 
@@ -18,6 +18,17 @@ 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
+ ## 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.
24
+ const LSP_SETTING: String = "network/language_server/remote_port"
25
+ const DAP_SETTING: String = "network/debug_adapter/remote_port"
26
+ const DEBUGGER_SETTING: String = "network/debug/remote_port"
27
+ ## What a server that opened this editor put in the environment, matching the ports it named on
28
+ ## the command line. Kept in step with `editorArguments` in src/launch.ts.
29
+ const LSP_ASKED: String = "GDHARNESS_LSP_PORT"
30
+ const DAP_ASKED: String = "GDHARNESS_DAP_PORT"
31
+
21
32
  ## How long one attempt is given before the address is called a bad one.
22
33
  ##
23
34
  ## A socket pointed at a port nothing holds gives up by itself, in thirty seconds on Windows. One
@@ -62,6 +73,7 @@ var _connecting_for: float = 0.0
62
73
  func _ready() -> void:
63
74
  _project_path = ProjectSettings.globalize_path("res://")
64
75
  version_at_load = _loaded_version()
76
+ _keep_the_ports_this_editor_was_given()
65
77
 
66
78
  _reconnect_timer = Timer.new()
67
79
  _reconnect_timer.one_shot = true
@@ -243,18 +255,73 @@ func _handle_connect() -> void:
243
255
  # somebody restarts it, and nothing else can tell the two apart.
244
256
  # The process id with it, because a restarted editor is a different process from the one
245
257
  # whoever started it is holding, and nothing else says which one is now on the other end.
258
+ # The three ports with that, because a server that assumes the defaults talks to whichever
259
+ # editor took them, which on a machine running two is not this one.
246
260
  _send_message(
247
261
  {
248
262
  "type": "godot_ready",
249
263
  "project_path": _project_path,
250
264
  "addon_version": version_at_load,
251
- "editor_pid": OS.get_process_id()
265
+ "editor_pid": OS.get_process_id(),
266
+ "lsp_port": _serves(LSP_ASKED, LSP_SETTING),
267
+ "dap_port": _serves(DAP_ASKED, DAP_SETTING),
268
+ "debug_port": _serving(DEBUGGER_SETTING)
252
269
  }
253
270
  )
254
271
 
255
272
  connected.emit()
256
273
 
257
274
 
275
+ ## Writes the ports this editor was started on into the settings it reads them from.
276
+ ##
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)
296
+
297
+
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.
301
+ func _serves(variable: String, setting: String) -> int:
302
+ var asked: int = _asked_for(variable)
303
+ return asked if asked > 0 else _serving(setting)
304
+
305
+
306
+ ## The port this variable names, or 0 for anything that is not one.
307
+ static func _asked_for(variable: String) -> int:
308
+ var said: String = OS.get_environment(variable)
309
+ if not said.is_valid_int():
310
+ return 0
311
+ var port: int = int(said)
312
+ return port if port >= 1 and port <= 65535 else 0
313
+
314
+
315
+ ## What the settings say this editor serves on, or 0 when there is nothing to ask.
316
+ func _serving(setting: String) -> int:
317
+ if not Engine.is_editor_hint():
318
+ return 0
319
+ var settings: EditorSettings = EditorInterface.get_editor_settings()
320
+ if settings == null or not settings.has_setting(setting):
321
+ return 0
322
+ return int(settings.get_setting(setting))
323
+
324
+
258
325
  ## The version marker beside this addon, or "" when the copy was not installed by gdharness.
259
326
  ##
260
327
  ## Read once, when this copy loads, and never again: an upgrade replaces the files under a
@@ -10,6 +10,10 @@ extends Node
10
10
 
11
11
  ## What the display server calls itself when the engine was started with no display at all.
12
12
  const HEADLESS_DISPLAY: String = "headless"
13
+ ## Where Godot keeps the port its own debugger listens on for a game it is playing. One setting
14
+ ## for every editor on the machine, and bound only while a game runs.
15
+ const DEBUGGER_SETTING: String = "network/debug/remote_port"
16
+ const LOOPBACK: String = "127.0.0.1"
13
17
 
14
18
  var _editor_plugin: EditorPlugin = null
15
19
 
@@ -24,6 +28,8 @@ func play_scene(args: Dictionary) -> Dictionary:
24
28
  if EditorInterface.is_playing_scene():
25
29
  EditorInterface.stop_playing_scene()
26
30
 
31
+ var debugger: int = _a_port_for_the_debugger()
32
+
27
33
  if scene_path.is_empty():
28
34
  EditorInterface.play_main_scene()
29
35
  else:
@@ -35,10 +41,43 @@ func play_scene(args: Dictionary) -> Dictionary:
35
41
  return {
36
42
  "ok": true,
37
43
  "playing": EditorInterface.is_playing_scene(),
38
- "scenePath": EditorInterface.get_playing_scene()
44
+ "scenePath": EditorInterface.get_playing_scene(),
45
+ "debugPort": debugger
39
46
  }
40
47
 
41
48
 
49
+ ## Gives this editor's debugger a port of its own, and answers the one the game will be sent to.
50
+ ##
51
+ ## The editor binds this while a game runs, out of a setting shared by every editor on the
52
+ ## machine, and Godot takes --lsp-port and --dap-port on its command line but nothing at all for
53
+ ## this one. So two editors playing at once want the same number, and both ways that can go are
54
+ ## wrong: a bind that fails leaves a game with no debugger behind it, which is every debug tool
55
+ ## and the whole console gone, and a bind that succeeds anyway leaves two editors on one port with
56
+ ## the games going to whichever one the operating system picks.
57
+ ##
58
+ ## Asked of the operating system each time rather than tested first, which is how the runtime
59
+ ## addon takes its own port: a port that was free a moment ago is not a port that is still free,
60
+ ## and nothing this can ask distinguishes the editor's own debugger from another editor's.
61
+ func _a_port_for_the_debugger() -> int:
62
+ var settings: EditorSettings = EditorInterface.get_editor_settings()
63
+ if settings == null or not settings.has_setting(DEBUGGER_SETTING):
64
+ return 0
65
+ var spare: int = _any_free_port()
66
+ if spare > 0:
67
+ settings.set_setting(DEBUGGER_SETTING, spare)
68
+ return spare
69
+
70
+
71
+ ## A port the operating system handed out, or 0 when it would not give one.
72
+ static func _any_free_port() -> int:
73
+ var probe: TCPServer = TCPServer.new()
74
+ if probe.listen(0, LOOPBACK) != OK:
75
+ return 0
76
+ var port: int = probe.get_local_port()
77
+ probe.stop()
78
+ return port
79
+
80
+
42
81
  func stop_playing(_args: Dictionary) -> Dictionary:
43
82
  var was_playing: bool = EditorInterface.is_playing_scene()
44
83
  if was_playing:
@@ -88,6 +127,17 @@ func restart_editor(_args: Dictionary) -> Dictionary:
88
127
 
89
128
  func playing_status(_args: Dictionary) -> Dictionary:
90
129
  var playing: bool = EditorInterface.is_playing_scene()
130
+ # The debugger's port with it, read now rather than remembered: it is taken again before every
131
+ # play, so the one from when this editor greeted the server is a number it has moved off.
132
+ var settings: EditorSettings = EditorInterface.get_editor_settings()
133
+ var debugger: int = (
134
+ int(settings.get_setting(DEBUGGER_SETTING))
135
+ if settings != null and settings.has_setting(DEBUGGER_SETTING)
136
+ else 0
137
+ )
91
138
  return {
92
- "ok": true, "playing": playing, "scenePath": EditorInterface.get_playing_scene() if playing else ""
139
+ "ok": true,
140
+ "playing": playing,
141
+ "scenePath": EditorInterface.get_playing_scene() if playing else "",
142
+ "debugPort": debugger
93
143
  }
@@ -435,6 +435,16 @@ func choose(params: Dictionary) -> Dictionary:
435
435
  )
436
436
  }
437
437
 
438
+ if not params.has("index") and str(params.get("text", "")).is_empty():
439
+ return {
440
+ "type": "error",
441
+ "message":
442
+ (
443
+ "%s needs the item named, by text or index. It holds: %s"
444
+ % [node_path, ", ".join(_items_of(menu))]
445
+ )
446
+ }
447
+
438
448
  var index: int = _wanted_item(menu, params)
439
449
  if index < 0:
440
450
  return {
package/build/index.js CHANGED
@@ -24861,10 +24861,11 @@ class FrameReader {
24861
24861
  }
24862
24862
 
24863
24863
  // src/ports.ts
24864
- function portFromEnv(variable, fallback) {
24864
+ import { createServer } from "node:net";
24865
+ function portFromEnvOrNull(variable) {
24865
24866
  const raw = process.env[variable]?.trim();
24866
24867
  if (!raw) {
24867
- return fallback;
24868
+ return null;
24868
24869
  }
24869
24870
  const parsed = Number.parseInt(raw, 10);
24870
24871
  if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535 || String(parsed) !== raw) {
@@ -24872,6 +24873,36 @@ function portFromEnv(variable, fallback) {
24872
24873
  }
24873
24874
  return parsed;
24874
24875
  }
24876
+ function portFromEnv(variable, fallback) {
24877
+ return portFromEnvOrNull(variable) ?? fallback;
24878
+ }
24879
+ async function freePort(preferred, host = "127.0.0.1") {
24880
+ const asked = await bindable(preferred, host);
24881
+ if (asked !== null) {
24882
+ return asked;
24883
+ }
24884
+ const spare = await bindable(0, host);
24885
+ if (spare === null) {
24886
+ throw new Refusal(`No port could be bound on ${host}.`);
24887
+ }
24888
+ return spare;
24889
+ }
24890
+ function bindable(port, host) {
24891
+ return new Promise((resolve) => {
24892
+ const probe = createServer();
24893
+ probe.once("error", () => {
24894
+ resolve(null);
24895
+ });
24896
+ probe.once("listening", () => {
24897
+ const bound = probe.address();
24898
+ const taken = typeof bound === "object" && bound !== null ? bound.port : port;
24899
+ probe.close(() => {
24900
+ resolve(taken);
24901
+ });
24902
+ });
24903
+ probe.listen(port, host);
24904
+ });
24905
+ }
24875
24906
 
24876
24907
  // src/dap_client.ts
24877
24908
  var DEFAULT_DAP_PORT = 6006;
@@ -25476,6 +25507,9 @@ function resolveDefaultBridgeHost() {
25476
25507
  const host = process.env["GDHARNESS_BRIDGE_HOST"]?.trim();
25477
25508
  return host === undefined || host === "" ? DEFAULT_HOST : host;
25478
25509
  }
25510
+ function servedPort(said) {
25511
+ return typeof said === "number" && Number.isInteger(said) && said > 0 && said <= 65535 ? said : undefined;
25512
+ }
25479
25513
 
25480
25514
  class GodotBridge extends EventEmitter {
25481
25515
  httpServer = null;
@@ -25608,6 +25642,9 @@ class GodotBridge extends EventEmitter {
25608
25642
  lastPongAt: this.connectionInfo?.lastPongAt,
25609
25643
  addonVersion: this.connectionInfo?.addonVersion,
25610
25644
  editorPid: this.connectionInfo?.editorPid,
25645
+ lspPort: this.connectionInfo?.lspPort,
25646
+ dapPort: this.connectionInfo?.dapPort,
25647
+ debugPort: this.connectionInfo?.debugPort,
25611
25648
  pendingRequests: this.pendingRequests.size,
25612
25649
  queuedResources: this.resourceQueues.size
25613
25650
  };
@@ -25741,6 +25778,9 @@ class GodotBridge extends EventEmitter {
25741
25778
  this.connectionInfo.projectPath = message.project_path;
25742
25779
  this.connectionInfo.addonVersion = message.addon_version ?? "";
25743
25780
  this.connectionInfo.editorPid = message.editor_pid;
25781
+ this.connectionInfo.lspPort = servedPort(message.lsp_port);
25782
+ this.connectionInfo.dapPort = servedPort(message.dap_port);
25783
+ this.connectionInfo.debugPort = servedPort(message.debug_port);
25744
25784
  this.log("info", `Godot ready: ${message.project_path}`);
25745
25785
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
25746
25786
  }
@@ -26013,8 +26053,8 @@ function runArguments(options) {
26013
26053
  }
26014
26054
  return args;
26015
26055
  }
26016
- function editorArguments(projectPath) {
26017
- return ["-e", "--path", projectPath];
26056
+ function editorArguments(projectPath, ports) {
26057
+ return ["-e", "--path", projectPath, "--lsp-port", String(ports.lsp), "--dap-port", String(ports.dap)];
26018
26058
  }
26019
26059
  function userDataIn(home, variables = process.env) {
26020
26060
  const moved = ["APPDATA", "XDG_DATA_HOME"];
@@ -26911,10 +26951,10 @@ function asToolResponse(payload) {
26911
26951
  ]
26912
26952
  };
26913
26953
  }
26914
- function normalizeLSPError(error) {
26954
+ function normalizeLSPError(error, port) {
26915
26955
  if (error instanceof Error) {
26916
26956
  if (error.message.includes("ECONNREFUSED") || error.message.includes("Failed to connect to Godot LSP") || error.message.includes("socket closed")) {
26917
- return `Godot LSP is unavailable on port ${portFromEnv("GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
26957
+ return `Godot LSP is unavailable on port ${port}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
26918
26958
  }
26919
26959
  return error.message;
26920
26960
  }
@@ -26992,7 +27032,7 @@ async function handleLSPTool(client, toolName, args) {
26992
27032
  }
26993
27033
  } catch (error) {
26994
27034
  return asToolResponse({
26995
- error: normalizeLSPError(error)
27035
+ error: normalizeLSPError(error, client.port)
26996
27036
  });
26997
27037
  }
26998
27038
  }
@@ -29185,7 +29225,7 @@ class GodotServer {
29185
29225
  return {
29186
29226
  ok: false,
29187
29227
  response: this.createErrorResponse(`The editor is playing the game but its debug adapter did not answer: ${errorMessage(error)}`, [
29188
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
29228
+ `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
29189
29229
  "GDHARNESS_DAP_PORT points this server at another one"
29190
29230
  ])
29191
29231
  };
@@ -29552,7 +29592,8 @@ class GodotServer {
29552
29592
  if (payload["error"] !== undefined) {
29553
29593
  const reason = payload["error"];
29554
29594
  return this.createErrorResponse(`Diagnostics unavailable: ${typeof reason === "string" ? reason : JSON.stringify(reason)}`, [
29555
- "Ensure the Godot editor is running with its language server enabled, on port 6005 or on the port GDHARNESS_LSP_PORT names"
29595
+ `Ensure the Godot editor is running with its language server enabled, on port ${this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}`,
29596
+ "GDHARNESS_LSP_PORT points this server at another one"
29556
29597
  ]);
29557
29598
  }
29558
29599
  const diagnostics = readArray(payload, "diagnostics") ?? [];
@@ -29569,16 +29610,33 @@ class GodotServer {
29569
29610
  });
29570
29611
  }
29571
29612
  async handleLSP(toolName, args) {
29572
- this.lspClient ??= new GodotLSPClient;
29613
+ const port = this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT);
29614
+ if (this.lspClient !== null && this.lspClient.port !== port) {
29615
+ await this.lspClient.disconnect();
29616
+ this.lspClient = null;
29617
+ }
29618
+ this.lspClient ??= new GodotLSPClient(port);
29573
29619
  return handleLSPTool(this.lspClient, toolName, args);
29574
29620
  }
29575
29621
  async handleDAP(toolName, args) {
29576
29622
  return handleDAPTool(this.dap(), toolName, args);
29577
29623
  }
29578
29624
  dap() {
29579
- this.dapClient ??= new GodotDAPClient;
29625
+ const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
29626
+ if (this.dapClient !== null && this.dapClient.port !== port) {
29627
+ this.dapClient = null;
29628
+ }
29629
+ this.dapClient ??= new GodotDAPClient(port);
29580
29630
  return this.dapClient;
29581
29631
  }
29632
+ editorServes(field, variable, fallback) {
29633
+ const named = portFromEnvOrNull(variable);
29634
+ if (named !== null) {
29635
+ return named;
29636
+ }
29637
+ const status = this.godotBridge.getStatus();
29638
+ return (status.connected ? status[field] : undefined) ?? fallback;
29639
+ }
29582
29640
  getEditorStatusPayload() {
29583
29641
  const status = this.godotBridge.getStatus();
29584
29642
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
@@ -29606,7 +29664,8 @@ class GodotServer {
29606
29664
  const answer = await this.godotBridge.invokeTool("playing_status", {});
29607
29665
  return {
29608
29666
  playing: readBoolean(asParams(answer), "playing") ?? false,
29609
- scene: readString(asParams(answer), "scenePath") ?? ""
29667
+ scene: readString(asParams(answer), "scenePath") ?? "",
29668
+ debugPort: readNumber(asParams(answer), "debugPort")
29610
29669
  };
29611
29670
  } catch {
29612
29671
  return null;
@@ -29624,15 +29683,19 @@ class GodotServer {
29624
29683
  problem: reply.ok ? null : reply.message
29625
29684
  };
29626
29685
  }));
29686
+ const playing = await this.editorPlayingState();
29627
29687
  return this.jsonTextResponse({
29628
- editor: this.getEditorStatusPayload(),
29688
+ editor: {
29689
+ ...this.getEditorStatusPayload(),
29690
+ debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
29691
+ },
29629
29692
  godot: {
29630
29693
  path: godotPath,
29631
29694
  version: godotPath === null ? null : await this.godotVersion(godotPath)
29632
29695
  },
29633
29696
  game: {
29634
29697
  processActive: this.activeProcess !== null,
29635
- playingInEditor: await this.editorPlayingState(),
29698
+ playingInEditor: playing,
29636
29699
  runtimeConnected: games.some((game) => game.reachable),
29637
29700
  runtimes: games
29638
29701
  }
@@ -29691,10 +29754,10 @@ class GodotServer {
29691
29754
  return project.response;
29692
29755
  }
29693
29756
  if (this.godotBridge.isConnected()) {
29694
- return this.createErrorResponse("An editor is already connected to this server, and a second one would take the language server and debug adapter ports from it.", [
29757
+ return this.createErrorResponse("An editor is already connected to this server.", [
29695
29758
  "editor_status says which editor is answering, and for which project",
29696
29759
  "editor_launch restart replaces the connected editor rather than joining it",
29697
- "Close the open editor first if the new project is the one you want"
29760
+ "Another project wants its own gdharness server, which is its own harness session"
29698
29761
  ]);
29699
29762
  }
29700
29763
  const engine = await this.engine();
@@ -29702,10 +29765,16 @@ class GodotServer {
29702
29765
  return engine.response;
29703
29766
  }
29704
29767
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
29705
- const editor = spawn(engine.value, editorArguments(project.value.path), {
29768
+ const ports = await this.portsForAnEditor();
29769
+ const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
29706
29770
  stdio: "ignore",
29707
29771
  detached: true,
29708
- env: { ...process.env, GDHARNESS_RUNTIME_DIR: runtimeDirectory() }
29772
+ env: {
29773
+ ...process.env,
29774
+ GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
29775
+ GDHARNESS_LSP_PORT: String(ports.lsp),
29776
+ GDHARNESS_DAP_PORT: String(ports.dap)
29777
+ }
29709
29778
  });
29710
29779
  const started = await new Promise((resolve) => {
29711
29780
  editor.once("spawn", () => {
@@ -29722,9 +29791,16 @@ class GodotServer {
29722
29791
  return this.jsonTextResponse({
29723
29792
  launched: true,
29724
29793
  pid: editor.pid ?? null,
29725
- projectPath: project.value.path
29794
+ projectPath: project.value.path,
29795
+ lspPort: ports.lsp,
29796
+ dapPort: ports.dap
29726
29797
  });
29727
29798
  }
29799
+ async portsForAnEditor() {
29800
+ const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
29801
+ const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
29802
+ return { lsp, dap };
29803
+ }
29728
29804
  async handleRunProject(args, op) {
29729
29805
  const project = this.project(args);
29730
29806
  if (!project.ok) {
@@ -29792,7 +29868,7 @@ class GodotServer {
29792
29868
  await this.dap().connect();
29793
29869
  } catch (error) {
29794
29870
  return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
29795
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
29871
+ `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
29796
29872
  "GDHARNESS_DAP_PORT points this server at another one"
29797
29873
  ]);
29798
29874
  }
@@ -29814,6 +29890,7 @@ class GodotServer {
29814
29890
  through: "editor",
29815
29891
  scene: scene === null ? "the main scene" : `res://${scene}`,
29816
29892
  refreshedClasses,
29893
+ debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
29817
29894
  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."
29818
29895
  });
29819
29896
  }
@@ -29996,7 +30073,7 @@ class GodotServer {
29996
30073
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
29997
30074
  const { op: _op, projectPath, ...params } = asParams(args);
29998
30075
  const announced = runtimesAnnounced();
29999
- const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : undefined, announced.unspoken);
30076
+ const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken);
30000
30077
  if ("problem" in choice) {
30001
30078
  return this.createErrorResponse(choice.problem);
30002
30079
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
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",