godot-mcp-runtime 3.0.0 → 3.1.1

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.
Files changed (47) hide show
  1. package/README.md +55 -67
  2. package/dist/dispatch.js +27 -27
  3. package/dist/dispatch.js.map +1 -1
  4. package/dist/index.js +5 -5
  5. package/dist/index.js.map +1 -1
  6. package/dist/scripts/godot_operations.gd +150 -115
  7. package/dist/scripts/mcp_bridge.gd +19 -24
  8. package/dist/tools/autoload-tools.d.ts +8 -8
  9. package/dist/tools/autoload-tools.d.ts.map +1 -1
  10. package/dist/tools/autoload-tools.js +15 -11
  11. package/dist/tools/autoload-tools.js.map +1 -1
  12. package/dist/tools/node-tools.d.ts.map +1 -1
  13. package/dist/tools/node-tools.js +105 -25
  14. package/dist/tools/node-tools.js.map +1 -1
  15. package/dist/tools/project-tools.d.ts.map +1 -1
  16. package/dist/tools/project-tools.js +52 -15
  17. package/dist/tools/project-tools.js.map +1 -1
  18. package/dist/tools/runtime-tools.d.ts.map +1 -1
  19. package/dist/tools/runtime-tools.js +224 -28
  20. package/dist/tools/runtime-tools.js.map +1 -1
  21. package/dist/tools/scene-tools.d.ts.map +1 -1
  22. package/dist/tools/scene-tools.js +30 -12
  23. package/dist/tools/scene-tools.js.map +1 -1
  24. package/dist/tools/validate-tools.d.ts.map +1 -1
  25. package/dist/tools/validate-tools.js +89 -30
  26. package/dist/tools/validate-tools.js.map +1 -1
  27. package/dist/utils/autoload-ini.d.ts +6 -0
  28. package/dist/utils/autoload-ini.d.ts.map +1 -1
  29. package/dist/utils/autoload-ini.js +13 -0
  30. package/dist/utils/autoload-ini.js.map +1 -1
  31. package/dist/utils/bridge-manager.d.ts +18 -1
  32. package/dist/utils/bridge-manager.d.ts.map +1 -1
  33. package/dist/utils/bridge-manager.js +61 -11
  34. package/dist/utils/bridge-manager.js.map +1 -1
  35. package/dist/utils/bridge-protocol.d.ts +6 -3
  36. package/dist/utils/bridge-protocol.d.ts.map +1 -1
  37. package/dist/utils/bridge-protocol.js +28 -15
  38. package/dist/utils/bridge-protocol.js.map +1 -1
  39. package/dist/utils/godot-runner.d.ts +47 -3
  40. package/dist/utils/godot-runner.d.ts.map +1 -1
  41. package/dist/utils/godot-runner.js +239 -56
  42. package/dist/utils/godot-runner.js.map +1 -1
  43. package/dist/utils/logger.d.ts +1 -0
  44. package/dist/utils/logger.d.ts.map +1 -1
  45. package/dist/utils/logger.js +1 -1
  46. package/dist/utils/logger.js.map +1 -1
  47. package/package.json +2 -1
@@ -1,12 +1,12 @@
1
1
  import { fileURLToPath } from 'url';
2
- import { join, dirname, normalize } from 'path';
2
+ import { join, dirname, normalize, resolve, sep } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import { spawn } from 'child_process';
5
5
  import * as net from 'net';
6
6
  import { randomBytes } from 'crypto';
7
7
  import { BridgeManager } from './bridge-manager.js';
8
- import { encodeFrame, getBridgePort, parseFrames } from './bridge-protocol.js';
9
- import { logDebug, logError } from './logger.js';
8
+ import { DEFAULT_BRIDGE_PORT, encodeFrame, findFreePort, parseFrames, FRAME_HEADER_BYTES, MAX_FRAME_BYTES, } from './bridge-protocol.js';
9
+ import { logDebug, logError, DEBUG_MODE } from './logger.js';
10
10
  /**
11
11
  * Thrown when the bridge socket closes (Godot exited, port closed, or peer
12
12
  * dropped the connection mid-flight). Lets callers distinguish
@@ -21,7 +21,6 @@ export class BridgeDisconnectedError extends Error {
21
21
  // Derive __filename and __dirname in ESM
22
22
  const __filename = fileURLToPath(import.meta.url);
23
23
  const __dirname = dirname(__filename);
24
- const DEBUG_MODE = process.env.DEBUG === 'true';
25
24
  // Bridge readiness polling
26
25
  export const BRIDGE_WAIT_SPAWNED_TIMEOUT_MS = 8000;
27
26
  const BRIDGE_WAIT_SPAWNED_INTERVAL_MS = 300;
@@ -31,6 +30,7 @@ const BRIDGE_PING_TIMEOUT_MS = 1000;
31
30
  const BRIDGE_SHUTDOWN_SPAWNED_TIMEOUT_MS = 500;
32
31
  const BRIDGE_SHUTDOWN_ATTACHED_TIMEOUT_MS = 1500;
33
32
  const BRIDGE_PROCESS_EXIT_TIMEOUT_MS = 2000;
33
+ const BRIDGE_RECONNECT_DELAY_MS = 1000;
34
34
  /**
35
35
  * Normalize a path for cross-platform comparison.
36
36
  * Folds Windows backslashes to forward slashes and strips trailing slashes,
@@ -117,15 +117,22 @@ export function cleanStdout(stdout) {
117
117
  return cleanOutput(stdout);
118
118
  }
119
119
  // Parameter mappings between snake_case and camelCase
120
+ // Add new entries whenever a tool surfaces a new compound parameter — the
121
+ // strict converter throws in test env on unmapped keys to catch oversights.
120
122
  const parameterMappings = {
121
123
  project_path: 'projectPath',
122
124
  scene_path: 'scenePath',
123
125
  root_node_type: 'rootNodeType',
124
126
  parent_node_path: 'parentNodePath',
127
+ parent_path: 'parentPath',
125
128
  node_type: 'nodeType',
126
129
  node_name: 'nodeName',
127
130
  texture_path: 'texturePath',
128
131
  node_path: 'nodePath',
132
+ node_paths: 'nodePaths',
133
+ target_node_path: 'targetNodePath',
134
+ target_parent_path: 'targetParentPath',
135
+ new_name: 'newName',
129
136
  output_path: 'outputPath',
130
137
  mesh_item_names: 'meshItemNames',
131
138
  new_path: 'newPath',
@@ -134,6 +141,13 @@ const parameterMappings = {
134
141
  response_mode: 'responseMode',
135
142
  preview_max_width: 'previewMaxWidth',
136
143
  preview_max_height: 'previewMaxHeight',
144
+ bridge_port: 'bridgePort',
145
+ abort_on_error: 'abortOnError',
146
+ max_depth: 'maxDepth',
147
+ changed_only: 'changedOnly',
148
+ case_sensitive: 'caseSensitive',
149
+ file_types: 'fileTypes',
150
+ max_results: 'maxResults',
137
151
  };
138
152
  // Reverse mapping from camelCase to snake_case
139
153
  const reverseParameterMappings = {};
@@ -162,29 +176,101 @@ export function normalizeParameters(params) {
162
176
  }
163
177
  return result;
164
178
  }
179
+ function convertCamelToSnakeValue(value) {
180
+ if (Array.isArray(value)) {
181
+ return value.map((entry) => convertCamelToSnakeValue(entry));
182
+ }
183
+ if (typeof value === 'object' && value !== null) {
184
+ return convertCamelToSnakeCase(value);
185
+ }
186
+ return value;
187
+ }
165
188
  export function convertCamelToSnakeCase(params) {
166
189
  const result = {};
190
+ const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST === 'true';
167
191
  for (const key in params) {
168
192
  if (Object.prototype.hasOwnProperty.call(params, key)) {
169
- const snakeKey = reverseParameterMappings[key] ||
170
- key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
171
- const value = params[key];
172
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
173
- result[snakeKey] = convertCamelToSnakeCase(value);
193
+ const mapped = reverseParameterMappings[key];
194
+ let snakeKey;
195
+ if (mapped) {
196
+ snakeKey = mapped;
197
+ }
198
+ else if (/[A-Z]/.test(key)) {
199
+ // Unmapped camelCase key — tolerated in production via regex fallback,
200
+ // but in tests we throw to catch missing entries in parameterMappings.
201
+ if (isTestEnv) {
202
+ throw new Error(`convertCamelToSnakeCase: unmapped camelCase key '${key}'. ` +
203
+ `Add it to parameterMappings in src/utils/godot-runner.ts so snake/camel conversion stays explicit.`);
204
+ }
205
+ snakeKey = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
174
206
  }
175
207
  else {
176
- result[snakeKey] = value;
208
+ snakeKey = key;
177
209
  }
210
+ result[snakeKey] = convertCamelToSnakeValue(params[key]);
178
211
  }
179
212
  }
180
213
  return result;
181
214
  }
215
+ /**
216
+ * Check whether a display server (X11 / Wayland) is available on the current
217
+ * platform. On macOS and Windows the display subsystem is always present;
218
+ * on Linux we probe the standard environment variables.
219
+ */
220
+ export function checkDisplayAvailable() {
221
+ if (process.platform !== 'linux')
222
+ return true;
223
+ return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
224
+ }
182
225
  export function validatePath(path) {
183
226
  if (!path || path.includes('..')) {
184
227
  return false;
185
228
  }
186
229
  return true;
187
230
  }
231
+ /**
232
+ * Stricter check for paths that must stay inside `projectPath`. Rejects `..`
233
+ * (via `validatePath`) and absolute paths that escape the project root.
234
+ * `path.join('/project', '/etc/passwd')` resolves to `/etc/passwd`, so the
235
+ * basic `..`-substring check alone permits absolute-path traversal.
236
+ *
237
+ * Tolerates a leading `res://` (Godot's project-root URI) by stripping it
238
+ * before resolving — autoload entries and resource paths use this prefix.
239
+ */
240
+ export function validateSubPath(projectPath, userPath) {
241
+ if (!validatePath(userPath))
242
+ return false;
243
+ const stripped = userPath.startsWith('res://') ? userPath.slice('res://'.length) : userPath;
244
+ if (!stripped)
245
+ return false;
246
+ const projectRoot = resolve(projectPath);
247
+ const resolved = resolve(projectRoot, stripped);
248
+ const tail = projectRoot === sep ? sep : projectRoot + sep;
249
+ return resolved === projectRoot || resolved.startsWith(tail);
250
+ }
251
+ /**
252
+ * Validate a Godot scene-tree path (NodePath). Scene-tree paths are a
253
+ * separate namespace from filesystem paths — they address nodes inside
254
+ * a scene, not files on disk, so the project-root containment check
255
+ * in `validateSubPath` does not apply.
256
+ *
257
+ * Rejects empty strings and `..` segments. Accepts both relative
258
+ * (`root/Player`) and absolute (`/root/Player`) Godot forms; the
259
+ * codebase convention is the relative form.
260
+ */
261
+ export function validateNodePath(path) {
262
+ return typeof path === 'string' && path.length > 0 && !path.includes('..');
263
+ }
264
+ /**
265
+ * True when `child` resolves to `parent` or a path beneath it. Used by
266
+ * defense-in-depth checks on bridge-returned paths (e.g. screenshot files
267
+ * that must live under `.mcp/screenshots/`).
268
+ */
269
+ export function isUnderDir(parent, child) {
270
+ const parentResolved = resolve(parent);
271
+ const childResolved = resolve(child);
272
+ return childResolved === parentResolved || childResolved.startsWith(parentResolved + sep);
273
+ }
188
274
  /**
189
275
  * Return `error.message` when `error` is an `Error`, otherwise `'Unknown error'`.
190
276
  * Centralizes the catch-block boilerplate so handlers can build error responses
@@ -259,9 +345,9 @@ export function validateSceneArgs(args, opts) {
259
345
  }
260
346
  return { projectPath: projectResult.projectPath, scenePath: '' };
261
347
  }
262
- if (!validatePath(args.scenePath)) {
348
+ if (!validateSubPath(projectResult.projectPath, args.scenePath)) {
263
349
  return createErrorResponse('Invalid scene path', [
264
- 'Provide a valid path without ".." or other potentially unsafe characters',
350
+ 'Provide a valid relative path without ".." that stays inside the project directory',
265
351
  ]);
266
352
  }
267
353
  if (sceneRequired) {
@@ -275,6 +361,26 @@ export function validateSceneArgs(args, opts) {
275
361
  }
276
362
  return { projectPath: projectResult.projectPath, scenePath: args.scenePath };
277
363
  }
364
+ /**
365
+ * Read the first `n` bytes from a chunk array without concatenating the
366
+ * entire array. If the first chunk already has enough bytes, returns a
367
+ * zero-copy subarray; otherwise copies just `n` bytes into a fresh buffer.
368
+ * Caller must ensure total length across chunks is >= n.
369
+ */
370
+ function readBytesFromChunks(chunks, n) {
371
+ if (chunks[0].length >= n)
372
+ return chunks[0].subarray(0, n);
373
+ const result = Buffer.allocUnsafe(n);
374
+ let copied = 0;
375
+ for (const c of chunks) {
376
+ const take = Math.min(c.length, n - copied);
377
+ c.copy(result, copied, 0, take);
378
+ copied += take;
379
+ if (copied >= n)
380
+ break;
381
+ }
382
+ return result;
383
+ }
278
384
  export class GodotRunner {
279
385
  godotPath = null;
280
386
  operationsScriptPath;
@@ -284,8 +390,14 @@ export class GodotRunner {
284
390
  activeProcess = null;
285
391
  activeProjectPath = null;
286
392
  activeSessionMode = null;
393
+ activeBridgePort = null;
287
394
  socket = null;
288
- rxBuffer = Buffer.alloc(0);
395
+ // Receive buffer kept as an array of chunks until at least one complete frame
396
+ // is available. Avoids re-copying accumulated bytes on every TCP data event
397
+ // (the old `Buffer.concat([rxBuffer, chunk])` pattern was O(n²) on large
398
+ // frames split across many chunks).
399
+ rxChunks = [];
400
+ rxTotal = 0;
289
401
  inFlight = null;
290
402
  constructor(config) {
291
403
  this.operationsScriptPath = join(__dirname, '..', 'scripts', 'godot_operations.gd');
@@ -370,8 +482,17 @@ export class GodotRunner {
370
482
  }
371
483
  }
372
484
  async detectGodotPath() {
373
- if (this.godotPath && (await this.isValidGodotPath(this.godotPath))) {
374
- logDebug(`Using existing Godot path: ${this.godotPath}`);
485
+ // Explicit paths (constructor config or GODOT_PATH) are authoritative — leave
486
+ // godotPath null on failure rather than fabricating a platform default, so
487
+ // callers can produce actionable errors.
488
+ if (this.godotPath) {
489
+ if (await this.isValidGodotPath(this.godotPath)) {
490
+ logDebug(`Using existing Godot path: ${this.godotPath}`);
491
+ return;
492
+ }
493
+ logError(`Configured Godot path "${this.godotPath}" is not a working Godot executable. ` +
494
+ `Pass a valid Godot 4.x binary via the godotPath config option.`);
495
+ this.godotPath = null;
375
496
  return;
376
497
  }
377
498
  if (process.env.GODOT_PATH) {
@@ -382,6 +503,9 @@ export class GodotRunner {
382
503
  logDebug(`Using Godot path from environment: ${this.godotPath}`);
383
504
  return;
384
505
  }
506
+ logError(`GODOT_PATH is set to "${normalizedPath}" but no working Godot executable was found there. ` +
507
+ `Update GODOT_PATH to your Godot 4.x binary or unset it to auto-detect.`);
508
+ return;
385
509
  }
386
510
  const osPlatform = process.platform;
387
511
  logDebug(`Auto-detecting Godot path for platform: ${osPlatform}`);
@@ -403,22 +527,20 @@ export class GodotRunner {
403
527
  logDebug(`Found Godot at: ${winner.path}`);
404
528
  return;
405
529
  }
406
- logDebug(`Warning: Could not find Godot in common locations for ${osPlatform}`);
407
- logError(`Could not find Godot in common locations for ${osPlatform}`);
408
- if (osPlatform === 'win32') {
409
- this.godotPath = normalize('C:\\Program Files\\Godot\\Godot.exe');
410
- }
411
- else if (osPlatform === 'darwin') {
412
- this.godotPath = normalize('/Applications/Godot.app/Contents/MacOS/Godot');
413
- }
414
- else {
415
- this.godotPath = normalize('/usr/bin/godot');
416
- }
417
- logDebug(`Using default path: ${this.godotPath}, but this may not work.`);
530
+ logError(`Could not find Godot in common locations for ${osPlatform}. ` +
531
+ `Set GODOT_PATH to your Godot 4.x executable.`);
418
532
  }
419
533
  getGodotPath() {
420
534
  return this.godotPath;
421
535
  }
536
+ /**
537
+ * Read the port currently baked into the project's bridge script. Returns
538
+ * null if the file is missing or malformed. Thin pass-through to
539
+ * BridgeManager — used by bridge-wait-timeout race detection.
540
+ */
541
+ readBakedBridgePort(projectPath) {
542
+ return this.bridge.readBakedPort(projectPath);
543
+ }
422
544
  async getVersion() {
423
545
  if (this.cachedVersion !== null) {
424
546
  return this.cachedVersion;
@@ -484,13 +606,13 @@ export class GodotRunner {
484
606
  }
485
607
  launchEditor(projectPath) {
486
608
  if (!this.godotPath) {
487
- throw new Error('Godot path not set. Call detectGodotPath first.');
609
+ throw new Error('No Godot executable resolved. Set GODOT_PATH to a Godot 4.x binary, or pass godotPath via config.');
488
610
  }
489
611
  return spawn(this.godotPath, ['-e', '--path', projectPath], { stdio: 'pipe' });
490
612
  }
491
- runProject(projectPath, scene, background = false) {
613
+ async runProject(projectPath, scene, background = false, bridgePort) {
492
614
  if (!this.godotPath) {
493
- throw new Error('Godot path not set. Call detectGodotPath first.');
615
+ throw new Error('No Godot executable resolved. Set GODOT_PATH to a Godot 4.x binary, or pass godotPath via config.');
494
616
  }
495
617
  if (this.activeSessionMode === 'spawned' && this.activeProcess) {
496
618
  logDebug('Killing existing Godot process before starting a new one');
@@ -506,8 +628,14 @@ export class GodotRunner {
506
628
  this.closeConnection();
507
629
  this.bridge.cleanup(this.activeProjectPath);
508
630
  }
631
+ if (!checkDisplayAvailable()) {
632
+ throw new Error('No display server available (DISPLAY and WAYLAND_DISPLAY are both unset). ' +
633
+ 'Godot requires a display to run a project window.');
634
+ }
635
+ const port = bridgePort ?? (await findFreePort());
636
+ this.activeBridgePort = port;
509
637
  try {
510
- this.bridge.inject(projectPath);
638
+ this.bridge.inject(projectPath, port);
511
639
  }
512
640
  catch (err) {
513
641
  logDebug(`Non-fatal: Failed to inject bridge autoload: ${err}`);
@@ -515,15 +643,19 @@ export class GodotRunner {
515
643
  this.activeProjectPath = projectPath;
516
644
  this.activeSessionMode = 'spawned';
517
645
  const cmdArgs = ['--path', projectPath];
518
- if (scene && validatePath(scene)) {
646
+ if (scene && validateSubPath(projectPath, scene)) {
519
647
  logDebug(`Adding scene parameter: ${scene}`);
520
648
  cmdArgs.push(scene);
521
649
  }
522
- logDebug(`Running Godot project: ${projectPath}`);
650
+ const portSource = bridgePort !== undefined ? 'explicit' : 'auto';
651
+ logDebug(`Running Godot project: ${projectPath} (bridge port ${port}, ${portSource})`);
523
652
  const sessionToken = randomBytes(16).toString('hex');
524
653
  const spawnOptions = {
525
654
  stdio: 'pipe',
526
- env: { ...process.env, MCP_SESSION_TOKEN: sessionToken },
655
+ env: {
656
+ ...process.env,
657
+ MCP_SESSION_TOKEN: sessionToken,
658
+ },
527
659
  };
528
660
  if (background) {
529
661
  spawnOptions.env = { ...spawnOptions.env, MCP_BACKGROUND: '1' };
@@ -545,12 +677,10 @@ export class GodotRunner {
545
677
  output.push(...lines);
546
678
  if (output.length > 500)
547
679
  output.splice(0, output.length - 500);
548
- if (DEBUG_MODE) {
549
- lines.forEach((line) => {
550
- if (line.trim())
551
- logDebug(`[Godot stdout] ${line}`);
552
- });
553
- }
680
+ lines.forEach((line) => {
681
+ if (line.trim())
682
+ logDebug(`[Godot stdout] ${line}`);
683
+ });
554
684
  });
555
685
  proc.stderr?.on('data', (data) => {
556
686
  const lines = data.toString().split('\n');
@@ -558,12 +688,10 @@ export class GodotRunner {
558
688
  errors.push(...lines);
559
689
  if (errors.length > 500)
560
690
  errors.splice(0, errors.length - 500);
561
- if (DEBUG_MODE) {
562
- lines.forEach((line) => {
563
- if (line.trim())
564
- logDebug(`[Godot stderr] ${line}`);
565
- });
566
- }
691
+ lines.forEach((line) => {
692
+ if (line.trim())
693
+ logDebug(`[Godot stderr] ${line}`);
694
+ });
567
695
  });
568
696
  proc.on('exit', (code) => {
569
697
  logDebug(`Godot process exited with code ${code}`);
@@ -579,7 +707,7 @@ export class GodotRunner {
579
707
  this.activeProcess = godotProcess;
580
708
  return this.activeProcess;
581
709
  }
582
- async attachProject(projectPath) {
710
+ async attachProject(projectPath, bridgePort) {
583
711
  if (this.activeSessionMode === 'spawned' && this.activeProcess) {
584
712
  await this.stopProject();
585
713
  }
@@ -599,7 +727,11 @@ export class GodotRunner {
599
727
  this.activeProjectPath = null;
600
728
  this.activeSessionMode = null;
601
729
  }
602
- this.bridge.inject(projectPath);
730
+ const port = bridgePort ?? (await findFreePort());
731
+ this.activeBridgePort = port;
732
+ this.bridge.inject(projectPath, port);
733
+ const portSource = bridgePort !== undefined ? 'explicit' : 'auto';
734
+ logDebug(`Attaching to Godot project: ${projectPath} (bridge port ${port}, ${portSource})`);
603
735
  this.activeProjectPath = projectPath;
604
736
  this.activeSessionMode = 'attached';
605
737
  this.activeProcess = null;
@@ -625,6 +757,7 @@ export class GodotRunner {
625
757
  }
626
758
  this.activeProjectPath = null;
627
759
  this.activeSessionMode = null;
760
+ this.activeBridgePort = null;
628
761
  this.activeProcess = null;
629
762
  return {
630
763
  mode: 'attached',
@@ -677,6 +810,7 @@ export class GodotRunner {
677
810
  this.activeProjectPath = null;
678
811
  }
679
812
  this.activeSessionMode = null;
813
+ this.activeBridgePort = null;
680
814
  return result;
681
815
  }
682
816
  hasActiveRuntimeSession() {
@@ -727,7 +861,7 @@ export class GodotRunner {
727
861
  sock.removeAllListeners();
728
862
  sock.destroy();
729
863
  }
730
- this.rxBuffer = Buffer.alloc(0);
864
+ this.resetRxBuffer();
731
865
  settle(new Error(`Command '${command}' timed out after ${timeoutMs}ms. Is the game running?`));
732
866
  }, timeoutMs);
733
867
  this.inFlight = { command, resolve, reject, timer };
@@ -736,18 +870,47 @@ export class GodotRunner {
736
870
  cb();
737
871
  return;
738
872
  }
739
- const port = getBridgePort();
873
+ // Fallback to DEFAULT_BRIDGE_PORT is defensive — every entry point
874
+ // (runProject, attachProject) sets activeBridgePort before sendCommand
875
+ // can be reached, so this branch is not expected in practice.
876
+ const port = this.activeBridgePort ?? DEFAULT_BRIDGE_PORT;
740
877
  const sock = net.connect(port, '127.0.0.1');
741
878
  const onConnect = () => {
742
879
  sock.setNoDelay(true);
743
880
  sock.removeListener('error', onConnectError);
744
881
  this.socket = sock;
745
- this.rxBuffer = Buffer.alloc(0);
882
+ this.resetRxBuffer();
746
883
  sock.on('data', (chunk) => {
747
- this.rxBuffer = Buffer.concat([this.rxBuffer, chunk]);
884
+ this.rxChunks.push(chunk);
885
+ this.rxTotal += chunk.length;
886
+ // Defer the (potentially expensive) concat until we know at least
887
+ // one complete frame is ready. Peek the 4-byte header without
888
+ // copying all accumulated chunks first.
889
+ if (this.rxTotal < FRAME_HEADER_BYTES)
890
+ return;
891
+ const header = readBytesFromChunks(this.rxChunks, FRAME_HEADER_BYTES);
892
+ const firstLen = header.readUInt32BE(0);
893
+ if (firstLen > MAX_FRAME_BYTES) {
894
+ this.socket = null;
895
+ sock.destroy();
896
+ settle(new BridgeDisconnectedError(`Bridge frame header advertises ${firstLen} bytes, exceeds limit ${MAX_FRAME_BYTES}`));
897
+ return;
898
+ }
899
+ if (this.rxTotal < FRAME_HEADER_BYTES + firstLen)
900
+ return;
748
901
  try {
749
- const { frames, remainder } = parseFrames(this.rxBuffer);
750
- this.rxBuffer = remainder;
902
+ const buffer = this.rxChunks.length === 1
903
+ ? this.rxChunks[0]
904
+ : Buffer.concat(this.rxChunks, this.rxTotal);
905
+ const { frames, remainder } = parseFrames(buffer);
906
+ if (remainder.length === 0) {
907
+ this.rxChunks = [];
908
+ this.rxTotal = 0;
909
+ }
910
+ else {
911
+ this.rxChunks = [remainder];
912
+ this.rxTotal = remainder.length;
913
+ }
751
914
  for (const frame of frames) {
752
915
  settle(null, frame.toString('utf8'));
753
916
  }
@@ -814,7 +977,11 @@ export class GodotRunner {
814
977
  sock.removeAllListeners();
815
978
  sock.destroy();
816
979
  }
817
- this.rxBuffer = Buffer.alloc(0);
980
+ this.resetRxBuffer();
981
+ }
982
+ resetRxBuffer() {
983
+ this.rxChunks = [];
984
+ this.rxTotal = 0;
818
985
  }
819
986
  getErrorCount() {
820
987
  return this.activeProcess?.totalErrorsWritten ?? 0;
@@ -832,12 +999,28 @@ export class GodotRunner {
832
999
  // Only the explicit `SCRIPT ERROR:` / `USER SCRIPT ERROR:` markers belong here — the looser
833
1000
  // `GDScript error` substring also matches user printerr output and produces false positives.
834
1001
  static SCRIPT_ERROR_PATTERNS = ['SCRIPT ERROR:', 'USER SCRIPT ERROR:'];
1002
+ static RETRYABLE_BRIDGE_COMMANDS = new Set(['get_ui_elements', 'screenshot']);
835
1003
  extractRuntimeErrors(lines) {
836
1004
  return lines.filter((line) => GodotRunner.SCRIPT_ERROR_PATTERNS.some((p) => line.includes(p)));
837
1005
  }
1006
+ async sendCommandWithReconnect(command, params = {}, timeoutMs = 10000) {
1007
+ try {
1008
+ return await this.sendCommand(command, params, timeoutMs);
1009
+ }
1010
+ catch (err) {
1011
+ if (err instanceof BridgeDisconnectedError &&
1012
+ this.activeSessionMode &&
1013
+ GodotRunner.RETRYABLE_BRIDGE_COMMANDS.has(command)) {
1014
+ this.closeConnection();
1015
+ await new Promise((r) => setTimeout(r, BRIDGE_RECONNECT_DELAY_MS));
1016
+ return this.sendCommand(command, params, timeoutMs);
1017
+ }
1018
+ throw err;
1019
+ }
1020
+ }
838
1021
  async sendCommandWithErrors(command, params = {}, timeoutMs = 10000) {
839
1022
  const marker = this.getErrorCount();
840
- const response = await this.sendCommand(command, params, timeoutMs);
1023
+ const response = await this.sendCommandWithReconnect(command, params, timeoutMs);
841
1024
  const newErrors = this.getErrorsSince(marker);
842
1025
  const runtimeErrors = this.activeSessionMode === 'spawned' ? this.extractRuntimeErrors(newErrors) : [];
843
1026
  return { response, runtimeErrors };