gdharness 1.1.11 → 1.1.13

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
@@ -17091,7 +17091,7 @@ var init_tool_definitions = __esm(() => {
17091
17091
  requires: []
17092
17092
  },
17093
17093
  rect: {
17094
- summary: "one node's rectangle or position, in canvas and in window pixels. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
17094
+ summary: "one node's rectangle or position, in canvas and in window pixels. Canvas is the node's own viewport; window is where it is drawn in the game's window, through any embedded dialog or SubViewport it sits in, which is what runtime_input mouse_click and mouse_motion take. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
17095
17095
  requires: ["nodePath"]
17096
17096
  },
17097
17097
  property: {
@@ -17209,7 +17209,7 @@ var init_tool_definitions = __esm(() => {
17209
17209
  says: {
17210
17210
  type: "string",
17211
17211
  ops: ["click"],
17212
- description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. Several matches are refused with each one listed, and index picks one."
17212
+ description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it, is not on screen. The matches are ranked the way a person picks what to press: a button before text that cannot be pressed, then a control whose whole text is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one."
17213
17213
  },
17214
17214
  action: {
17215
17215
  type: "string",
@@ -17247,7 +17247,7 @@ var init_tool_definitions = __esm(() => {
17247
17247
  index: {
17248
17248
  type: "number",
17249
17249
  ops: ["click", "choose"],
17250
- description: "click: which of several controls saying the words, counting from 0 in the order a refusal lists them. choose: the item to take, by where it is in the list, when text will not do."
17250
+ description: "click: which of several controls saying the words, counting from 0 in the order a refusal lists them, best matches first. choose: the item to take, by where it is in the list, when text will not do."
17251
17251
  },
17252
17252
  shift: { type: "boolean", ops: ["key"], description: "key: hold Shift with it. Default false." },
17253
17253
  ctrl: { type: "boolean", ops: ["key"], description: "key: hold Ctrl with it. Default false." },
@@ -17290,7 +17290,7 @@ var init_tool_definitions = __esm(() => {
17290
17290
  requires: []
17291
17291
  },
17292
17292
  choose: {
17293
- summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen and what the button in front of it shows now",
17293
+ summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen, read before the press, what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick), and what the button in front of it shows now when it is still there",
17294
17294
  requires: ["nodePath"]
17295
17295
  },
17296
17296
  action: { summary: "press an action, or hold it", requires: ["action"] },
@@ -17340,7 +17340,7 @@ var init_tool_definitions = __esm(() => {
17340
17340
  says: {
17341
17341
  type: "string",
17342
17342
  ops: ["until"],
17343
- description: "until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property. It looks every frame while a look is cheap and less often on a screen large enough for a look to cost a real share of a frame, so the game keeps its speed; the answer counts the frames the wait spanned and the looks it took."
17343
+ description: "until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Matched the way runtime_inspect find matches says: case-insensitive, a plain word anywhere in the text, a pattern with * or ? a glob over the whole of it; on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property. It looks every frame while a look is cheap and less often on a screen large enough for a look to cost a real share of a frame, so the game keeps its speed; the answer counts the frames the wait spanned and the looks it took."
17344
17344
  },
17345
17345
  includeHidden: {
17346
17346
  type: "boolean",
@@ -32197,6 +32197,28 @@ function errorReportOf(pid, directories = runtimeDirectories()) {
32197
32197
  }
32198
32198
  return null;
32199
32199
  }
32200
+ function consoleBoundaryAt(bytes) {
32201
+ const wanted = Buffer.from(CONSOLE_BOUNDARY);
32202
+ for (let at = bytes.indexOf(wanted);at !== -1; at = bytes.indexOf(wanted, at + 1)) {
32203
+ if (at === 0 || bytes[at - 1] === 10) {
32204
+ return at;
32205
+ }
32206
+ }
32207
+ return -1;
32208
+ }
32209
+ function reportAlreadyInTranscript(transcript, report) {
32210
+ const boundary = consoleBoundaryAt(report);
32211
+ if (boundary === -1) {
32212
+ return 0;
32213
+ }
32214
+ const inTranscript = consoleBoundaryAt(transcript);
32215
+ if (inTranscript === -1) {
32216
+ return boundary;
32217
+ }
32218
+ const copied = transcript.subarray(inTranscript);
32219
+ const end = boundary + copied.length;
32220
+ return end <= report.length && report.subarray(boundary, end).equals(copied) ? end : report.length;
32221
+ }
32200
32222
  function processAlive(pid) {
32201
32223
  try {
32202
32224
  process.kill(pid, 0);
@@ -32641,7 +32663,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs, keepListening = fa
32641
32663
  });
32642
32664
  });
32643
32665
  }
32644
- var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, ERROR_REPORT_PATTERN, ERROR_REPORT_KEEP_MS, GONE_WINDOW_MS = 120000, GONE_KEPT = 32, gone, STARTED_AFTER_ANNOUNCING_MS = 2000, CONFIRMED_FOR_MS = 60000, JUDGED_AFTER_MS = 60000, verdicts, ENDED_WITHIN_MS = 3000, LOOK_EVERY_MS = 50, ANNOUNCE_BUDGET_MS = 5000, nextRequestId = 1, LATE_ANSWER_MS, LATE_KEPT = 50, lateAnswers;
32666
+ var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, ERROR_REPORT_PATTERN, CONSOLE_BOUNDARY = "[gdharness] runtime listening on ", ERROR_REPORT_KEEP_MS, GONE_WINDOW_MS = 120000, GONE_KEPT = 32, gone, STARTED_AFTER_ANNOUNCING_MS = 2000, CONFIRMED_FOR_MS = 60000, JUDGED_AFTER_MS = 60000, verdicts, ENDED_WITHIN_MS = 3000, LOOK_EVERY_MS = 50, ANNOUNCE_BUDGET_MS = 5000, nextRequestId = 1, LATE_ANSWER_MS, LATE_KEPT = 50, lateAnswers;
32645
32667
  var init_runtime_client = __esm(() => {
32646
32668
  init_paths();
32647
32669
  init_process_children();
@@ -39230,6 +39252,7 @@ class GodotServer {
39230
39252
  bridgeStartupError = null;
39231
39253
  bridgeRetry = null;
39232
39254
  successorWatch = null;
39255
+ consoleWatch = null;
39233
39256
  lastProjectPath = null;
39234
39257
  shutdownInitiated = false;
39235
39258
  launchedEditor = null;
@@ -39402,10 +39425,30 @@ class GodotServer {
39402
39425
  this.successorWatch = null;
39403
39426
  }
39404
39427
  }
39428
+ watchThePlayedConsole() {
39429
+ if (this.consoleWatch !== null) {
39430
+ return;
39431
+ }
39432
+ const watch = setInterval(() => {
39433
+ const run = this.activeProcess;
39434
+ if (run?.throughEditor !== true || run.transcript === null) {
39435
+ clearInterval(watch);
39436
+ this.consoleWatch = null;
39437
+ return;
39438
+ }
39439
+ this.drainErrorReport(run);
39440
+ }, CONSOLE_WATCH_MS);
39441
+ watch.unref();
39442
+ this.consoleWatch = watch;
39443
+ }
39405
39444
  async cleanup() {
39406
39445
  this.logDebug("Cleaning up resources");
39407
39446
  this.stopTryingTheBridge();
39408
39447
  this.stopWatchingForASuccessor();
39448
+ if (this.consoleWatch !== null) {
39449
+ clearInterval(this.consoleWatch);
39450
+ this.consoleWatch = null;
39451
+ }
39409
39452
  withdrawBridge(this.announcedAt);
39410
39453
  this.announcedAt = null;
39411
39454
  this.activeProcess = null;
@@ -40973,7 +41016,16 @@ class GodotServer {
40973
41016
  }
40974
41017
  writeToTranscript(line) {
40975
41018
  const run = this.activeProcess;
40976
- if (run?.throughEditor !== true || run.transcript === null) {
41019
+ if (run?.throughEditor !== true) {
41020
+ return;
41021
+ }
41022
+ if (run.consoleFromReport === true || line.startsWith(CONSOLE_BOUNDARY)) {
41023
+ this.drainErrorReport(run, line);
41024
+ if (run.consoleFromReport === true) {
41025
+ return;
41026
+ }
41027
+ }
41028
+ if (run.transcript === null) {
40977
41029
  return;
40978
41030
  }
40979
41031
  try {
@@ -41526,6 +41578,7 @@ class GodotServer {
41526
41578
  seenPlaying: readBoolean(playAnswer, "playing") === true
41527
41579
  };
41528
41580
  this.activeProcess = played;
41581
+ this.watchThePlayedConsole();
41529
41582
  writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
41530
41583
  sweepTranscripts();
41531
41584
  return this.jsonTextResponse({
@@ -41543,7 +41596,7 @@ class GodotServer {
41543
41596
  reason: one.reason
41544
41597
  })),
41545
41598
  runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs, false),
41546
- 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." + endedToStartThis(ended)
41599
+ 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 the runtime's report of it, and editor_run stop ends it." + endedToStartThis(ended)
41547
41600
  });
41548
41601
  }
41549
41602
  drainEditorOutput(game) {
@@ -41555,26 +41608,35 @@ class GodotServer {
41555
41608
  return;
41556
41609
  }
41557
41610
  if (game.transcript === null) {
41558
- for (const line of this.dapClient.getOutput(true)) {
41559
- game.log.append("stdout", line.endsWith(`
41611
+ const heard = this.dapClient.getOutput(true);
41612
+ if (game.consoleFromReport !== true) {
41613
+ for (const line of heard) {
41614
+ game.log.append("stdout", line.endsWith(`
41560
41615
  `) ? line : `${line}
41561
41616
  `);
41617
+ }
41562
41618
  }
41563
41619
  }
41564
- if (!this.dapClient.isConnected()) {
41620
+ if (!this.dapClient.isConnected() && game.consoleFromReport !== true) {
41565
41621
  game.consoleLost = true;
41566
41622
  }
41567
41623
  this.recordWhatItBrokeOn(game);
41568
41624
  }
41569
- drainErrorReport(game) {
41625
+ drainErrorReport(game, heard) {
41626
+ if (game.errorReport === null) {
41627
+ return;
41628
+ }
41570
41629
  if (game.errorReport === undefined) {
41571
- const its = this.announcedPidOf(game);
41630
+ const its = this.announcedPidOf(game) ?? (heard?.startsWith(CONSOLE_BOUNDARY) === true ? this.announcedPidNamedBy(game, heard) : undefined);
41572
41631
  const found = its === undefined ? null : errorReportOf(its);
41573
41632
  if (found === null) {
41633
+ if (heard?.startsWith(CONSOLE_BOUNDARY) === true) {
41634
+ game.errorReport = null;
41635
+ }
41574
41636
  return;
41575
41637
  }
41576
41638
  game.errorReport = found;
41577
- game.errorReportOffset = 0;
41639
+ game.errorReportOffset = this.reportTakenBefore(game, found);
41578
41640
  }
41579
41641
  let handle;
41580
41642
  try {
@@ -41590,8 +41652,22 @@ class GodotServer {
41590
41652
  }
41591
41653
  const buffer = Buffer.alloc(size - from);
41592
41654
  const read = readSync2(handle, buffer, 0, buffer.length, from);
41593
- game.errorReportOffset = from + read;
41594
- const reported = buffer.subarray(0, read);
41655
+ let reported = buffer.subarray(0, read);
41656
+ if (game.consoleFromReport !== true) {
41657
+ const boundary = consoleBoundaryAt(reported);
41658
+ if (boundary !== -1) {
41659
+ game.boundaryInReportSince ??= Date.now();
41660
+ if (heard?.startsWith(CONSOLE_BOUNDARY) === true || game.pickedUpPlaying === true || this.dapClient?.isConnected() !== true || Date.now() - game.boundaryInReportSince >= BOUNDARY_WAIT_MS) {
41661
+ game.consoleFromReport = true;
41662
+ } else {
41663
+ reported = reported.subarray(0, boundary);
41664
+ }
41665
+ }
41666
+ }
41667
+ game.errorReportOffset = from + reported.length;
41668
+ if (reported.length === 0) {
41669
+ return;
41670
+ }
41595
41671
  if (game.transcript !== null) {
41596
41672
  appendFileSync(game.transcript, reported);
41597
41673
  } else {
@@ -41601,9 +41677,32 @@ class GodotServer {
41601
41677
  closeSync2(handle);
41602
41678
  }
41603
41679
  }
41680
+ announcedPidNamedBy(run, line) {
41681
+ const named = /runtime-(\d+)\.json$/.exec(line.trim())?.[1];
41682
+ if (named === undefined) {
41683
+ return;
41684
+ }
41685
+ const before = run.announcedBefore ?? new Set;
41686
+ const theOne = this.announcedOfTheRunsProject(run, runtimesAnnounced().running).find((one) => one.pid === Number(named) && !before.has(one.pid) && this.isTheGameOf(run, one, run.projectPath));
41687
+ if (theOne !== undefined) {
41688
+ this.tie(run, theOne);
41689
+ }
41690
+ return run.announcedPid;
41691
+ }
41692
+ reportTakenBefore(game, report) {
41693
+ if (game.pickedUpPlaying !== true || game.transcript === null) {
41694
+ return 0;
41695
+ }
41696
+ try {
41697
+ return reportAlreadyInTranscript(readFileSync19(game.transcript), readFileSync19(report));
41698
+ } catch (error) {
41699
+ this.logDebug(`Could not compare a picked-up run's report with its transcript: ${errorMessage(error)}`);
41700
+ return 0;
41701
+ }
41702
+ }
41604
41703
  recordWhatItBrokeOn(game) {
41605
41704
  const halt = this.dapClient?.whereItStopped() ?? null;
41606
- if (halt?.reason !== "exception" || halt.text === "" || game.brokeOn === halt.text || game.errorReport !== undefined) {
41705
+ if (halt?.reason !== "exception" || halt.text === "" || game.brokeOn === halt.text || typeof game.errorReport === "string") {
41607
41706
  return;
41608
41707
  }
41609
41708
  game.brokeOn = halt.text;
@@ -41815,6 +41914,7 @@ class GodotServer {
41815
41914
  pickedUpPlaying: true
41816
41915
  };
41817
41916
  this.activeProcess = picked;
41917
+ this.watchThePlayedConsole();
41818
41918
  }
41819
41919
  announcedPidOf(run, announced) {
41820
41920
  if (run.announcedPid !== undefined) {
@@ -42233,7 +42333,7 @@ class GodotServer {
42233
42333
  }));
42234
42334
  }
42235
42335
  if (run.pickedUpPlaying === true) {
42236
- notes.push(run.transcript === null ? "The editor was already playing this when this server reached it, and the server that started it left no transcript, so this is not the run from its start: what it printed before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for." : "The editor was already playing this when this server reached it, and what it printed before that has been read back from its transcript. It is the run the editor is holding now, which is the one the debug_* tools answer for.");
42336
+ notes.push(run.transcript !== null ? "The editor was already playing this when this server reached it, and what it printed before that has been read back from its transcript. It is the run the editor is holding now, which is the one the debug_* tools answer for." : run.consoleFromReport === true ? "The editor was already playing this when this server reached it, and the server that started it left no transcript, so what is below was read back from the file the game's runtime writes its console to, which begins where the runtime started: the engine's own start-up before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for." : "The editor was already playing this when this server reached it, and the server that started it left no transcript, so this is not the run from its start: what it printed before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for.");
42237
42337
  }
42238
42338
  if (run.consoleLost === true) {
42239
42339
  const back = this.dapClient?.isConnected() === true;
@@ -42961,7 +43061,7 @@ async function runGodotServer() {
42961
43061
  const server = new GodotServer;
42962
43062
  await server.run();
42963
43063
  }
42964
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, DEPENDED_FAILED = "Failed to compile depended scripts", WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
43064
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, DEPENDED_FAILED = "Failed to compile depended scripts", WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
42965
43065
  var init_server3 = __esm(() => {
42966
43066
  init_mcp();
42967
43067
  init_stdio2();
@@ -1,7 +1,7 @@
1
1
  extends Logger
2
2
 
3
- ## Every error the engine reports inside this game, written to a file beside the announcement,
4
- ## in the lines the engine itself prints to stderr.
3
+ ## The console of a game the editor plays, errors and prints in the order the engine made them,
4
+ ## written to a file beside the announcement in the lines the engine itself prints.
5
5
  ##
6
6
  ## A game the editor plays prints to the editor's own stderr, which nobody reads: a `push_error`
7
7
  ## raised in one reached neither `editor_output` nor the run's transcript, and the run was
@@ -9,14 +9,49 @@ extends Logger
9
9
  ## what the game prints and not what it reports, so the report is taken where it is made, in
10
10
  ## the game, and left where the server already looks. The lines are the engine's own so the
11
11
  ## server reads them the way it reads a run it started itself.
12
+ ##
13
+ ## Prints as well, once [method take_the_console] is called: the adapter's prints and this file's
14
+ ## errors are two streams, and read side by side a warning raised before a print was put after
15
+ ## it. One file written from inside the game has one order, the engine's.
12
16
 
13
17
  var _path: String
18
+ var _file: FileAccess = null
19
+ var _console: bool = false
20
+ ## Held across a write, because the engine logs from whichever thread printed.
21
+ var _mutex: Mutex = Mutex.new()
22
+ ## Set while writing, so a failure the write itself logs does not come straight back in here.
23
+ var _writing: bool = false
14
24
 
15
25
 
16
26
  func _init(path: String) -> void:
17
27
  _path = path
18
28
 
19
29
 
30
+ ## Writes every print from here on as well as the errors, and prints [param boundary], which is
31
+ ## therefore the first print in the file and the last one the server takes from the adapter.
32
+ func take_the_console(boundary: String) -> void:
33
+ _mutex.lock()
34
+ _console = true
35
+ _mutex.unlock()
36
+ print(boundary)
37
+
38
+
39
+ ## Lets go of the file, once the Logger has been removed and nothing more will be written.
40
+ func close() -> void:
41
+ _mutex.lock()
42
+ if _file != null:
43
+ _file.close()
44
+ _file = null
45
+ _mutex.unlock()
46
+
47
+
48
+ func _log_message(message: String, _error: bool) -> void:
49
+ _mutex.lock()
50
+ if _console:
51
+ _write(message if message.ends_with("\n") else message + "\n")
52
+ _mutex.unlock()
53
+
54
+
20
55
  # The engine's own signature for a Logger, eight parameters and not this project's to shape.
21
56
  # gdlint:ignore = function-arguments-number
22
57
  func _log_error(
@@ -45,18 +80,29 @@ func _log_error(
45
80
  ]
46
81
  for backtrace: ScriptBacktrace in script_backtraces:
47
82
  lines.append(backtrace.format(3))
48
- # Opened for each report rather than held, so the file is whole on disk after every one:
49
- # the last error a game reports is the one a caller most needs, and the game may be gone the
50
- # moment after. Nothing here may report an error of its own, which would come straight back.
51
- var report: FileAccess = (
52
- FileAccess.open(_path, FileAccess.READ_WRITE)
53
- if FileAccess.file_exists(_path)
54
- else FileAccess.open(_path, FileAccess.WRITE)
55
- )
56
- if report == null:
83
+ _mutex.lock()
84
+ _write("\n".join(lines) + "\n")
85
+ _mutex.unlock()
86
+
87
+
88
+ ## Appends [param text] and flushes it, so the file is whole on disk after every entry: the last
89
+ ## thing a game reports is the one a caller most needs, and the game may be gone the moment after.
90
+ ## Held open rather than opened for each entry, because a game printing every frame would open the
91
+ ## file sixty times a second. Called with the mutex held.
92
+ func _write(text: String) -> void:
93
+ if _writing:
57
94
  return
58
- report.seek_end()
59
- for each: String in lines:
95
+ _writing = true
96
+ if _file == null:
97
+ _file = (
98
+ FileAccess.open(_path, FileAccess.READ_WRITE)
99
+ if FileAccess.file_exists(_path)
100
+ else FileAccess.open(_path, FileAccess.WRITE)
101
+ )
102
+ if _file != null:
103
+ _file.seek_end()
104
+ if _file != null:
60
105
  # A line the disk would not take is a report lost, and nothing here may report that.
61
- var _stored: bool = report.store_line(each)
62
- report.close()
106
+ var _stored: bool = _file.store_string(text)
107
+ _file.flush()
108
+ _writing = false
@@ -114,11 +114,13 @@ func _ready() -> void:
114
114
  func _exit_tree() -> void:
115
115
  if _errors != null:
116
116
  OS.remove_logger(_errors)
117
+ _errors.close()
117
118
  _errors = null
118
119
  _cleanup()
119
120
 
120
121
 
121
- ## The engine's error reports, written to a file for the server, for a game the editor plays.
122
+ ## The engine's error reports, written to a file for the server, for a game the editor plays, and
123
+ ## its prints as well from the line announcing the runtime on.
122
124
  ##
123
125
  ## Only for one the editor plays: a game the server starts itself has its stderr in the run's
124
126
  ## transcript already, and a game somebody runs from a terminal has it on the terminal. The
@@ -235,7 +237,16 @@ func _start_server() -> void:
235
237
 
236
238
  _port = _server.get_local_port()
237
239
  _announce(bind_address)
238
- print("[gdharness] runtime listening on %s:%d, announced at %s" % [bind_address, _port, _announcement])
240
+ var listening: String = (
241
+ "[gdharness] runtime listening on %s:%d, announced at %s" % [bind_address, _port, _announcement]
242
+ )
243
+ # The line the server tells the two streams apart by: it takes the prints before it from the
244
+ # debug adapter and everything from it on from the file, which has it first. After the
245
+ # announcement, so a server reading the line finds the file beside it.
246
+ if _errors != null:
247
+ _errors.take_the_console(listening)
248
+ else:
249
+ print(listening)
239
250
 
240
251
 
241
252
  ## Whether the engine was told to run a script rather than the game.