@hydranium/core 1.0.0-next.266 → 1.0.0-next.268

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 (126) hide show
  1. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  2. package/lib/documents/ast-document-manager.js +4 -3
  3. package/lib/documents/ast-document-manager.js.map +1 -1
  4. package/lib/documents/document-release-handler.d.ts +2 -2
  5. package/lib/documents/document-release-handler.d.ts.map +1 -1
  6. package/lib/documents/document-release-handler.js +14 -19
  7. package/lib/documents/document-release-handler.js.map +1 -1
  8. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  9. package/lib/documents/hydranium-text-documents.js +7 -10
  10. package/lib/documents/hydranium-text-documents.js.map +1 -1
  11. package/lib/documents/language-client-shadow.d.ts.map +1 -1
  12. package/lib/documents/language-client-shadow.js +2 -1
  13. package/lib/documents/language-client-shadow.js.map +1 -1
  14. package/lib/documents/version-sync-service.d.ts +18 -14
  15. package/lib/documents/version-sync-service.d.ts.map +1 -1
  16. package/lib/documents/version-sync-service.js +15 -13
  17. package/lib/documents/version-sync-service.js.map +1 -1
  18. package/lib/langium/diagnostics/lsp-logger.d.ts.map +1 -1
  19. package/lib/langium/diagnostics/lsp-logger.js +2 -2
  20. package/lib/langium/diagnostics/lsp-logger.js.map +1 -1
  21. package/lib/langium/document-builder/document-builder.d.ts +2 -2
  22. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  23. package/lib/langium/document-builder/document-builder.js +23 -10
  24. package/lib/langium/document-builder/document-builder.js.map +1 -1
  25. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  26. package/lib/langium/integrity/integrity-service.js +5 -1
  27. package/lib/langium/integrity/integrity-service.js.map +1 -1
  28. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  29. package/lib/langium/model-service/client-session.js +20 -3
  30. package/lib/langium/model-service/client-session.js.map +1 -1
  31. package/lib/langium/model-service/model-service.d.ts +4 -3
  32. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  33. package/lib/langium/model-service/model-service.js +49 -19
  34. package/lib/langium/model-service/model-service.js.map +1 -1
  35. package/lib/langium/project/abstract-project-manager.d.ts.map +1 -1
  36. package/lib/langium/project/abstract-project-manager.js +4 -4
  37. package/lib/langium/project/abstract-project-manager.js.map +1 -1
  38. package/lib/langium/scope/hydranium-scope-computation.d.ts +6 -0
  39. package/lib/langium/scope/hydranium-scope-computation.d.ts.map +1 -1
  40. package/lib/langium/scope/hydranium-scope-computation.js +6 -10
  41. package/lib/langium/scope/hydranium-scope-computation.js.map +1 -1
  42. package/lib/langium/validation/document-validator.d.ts.map +1 -1
  43. package/lib/langium/validation/document-validator.js +5 -3
  44. package/lib/langium/validation/document-validator.js.map +1 -1
  45. package/lib/langium/workspace/hydranium-workspace-lock.d.ts +1 -1
  46. package/lib/langium/workspace/hydranium-workspace-lock.js +1 -1
  47. package/lib/langium/workspace/hydranium-workspace-lock.js.map +1 -1
  48. package/lib/langium/workspace/hydranium-workspace-manager.d.ts.map +1 -1
  49. package/lib/langium/workspace/hydranium-workspace-manager.js +4 -5
  50. package/lib/langium/workspace/hydranium-workspace-manager.js.map +1 -1
  51. package/lib/langium/workspace/persistent-file-system-provider.d.ts.map +1 -1
  52. package/lib/langium/workspace/persistent-file-system-provider.js +2 -1
  53. package/lib/langium/workspace/persistent-file-system-provider.js.map +1 -1
  54. package/lib/launcher/integrated-server.d.ts +3 -1
  55. package/lib/launcher/integrated-server.d.ts.map +1 -1
  56. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  57. package/lib/lsp/hydranium-document-update-handler.js +5 -5
  58. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  59. package/lib/node/event-loop-monitor.d.ts.map +1 -1
  60. package/lib/node/event-loop-monitor.js +2 -1
  61. package/lib/node/event-loop-monitor.js.map +1 -1
  62. package/lib/node/log-preamble-node.d.ts +5 -3
  63. package/lib/node/log-preamble-node.d.ts.map +1 -1
  64. package/lib/node/log-preamble-node.js +13 -10
  65. package/lib/node/log-preamble-node.js.map +1 -1
  66. package/lib/node/memory-monitor.d.ts +2 -2
  67. package/lib/node/memory-monitor.d.ts.map +1 -1
  68. package/lib/node/memory-monitor.js +3 -5
  69. package/lib/node/memory-monitor.js.map +1 -1
  70. package/lib/node/node-file-system-provider.d.ts +5 -2
  71. package/lib/node/node-file-system-provider.d.ts.map +1 -1
  72. package/lib/node/node-file-system-provider.js +5 -2
  73. package/lib/node/node-file-system-provider.js.map +1 -1
  74. package/lib/node/server-diagnostics.js +1 -1
  75. package/lib/node/server-diagnostics.js.map +1 -1
  76. package/lib/node/socket-launcher.d.ts.map +1 -1
  77. package/lib/node/socket-launcher.js +4 -4
  78. package/lib/node/socket-launcher.js.map +1 -1
  79. package/lib/node/stdio-launcher.d.ts.map +1 -1
  80. package/lib/node/stdio-launcher.js +12 -3
  81. package/lib/node/stdio-launcher.js.map +1 -1
  82. package/lib/testing/playwright/server-log-capture.d.ts +7 -0
  83. package/lib/testing/playwright/server-log-capture.d.ts.map +1 -1
  84. package/lib/testing/playwright/server-log-capture.js +12 -0
  85. package/lib/testing/playwright/server-log-capture.js.map +1 -1
  86. package/lib/testing/playwright/server-log-fixture.d.ts +16 -0
  87. package/lib/testing/playwright/server-log-fixture.d.ts.map +1 -1
  88. package/lib/testing/playwright/server-log-fixture.js +25 -6
  89. package/lib/testing/playwright/server-log-fixture.js.map +1 -1
  90. package/lib/testing/playwright/server-log-region.d.ts +15 -0
  91. package/lib/testing/playwright/server-log-region.d.ts.map +1 -0
  92. package/lib/testing/playwright/server-log-region.js +30 -0
  93. package/lib/testing/playwright/server-log-region.js.map +1 -0
  94. package/lib/util/connection-liveness.d.ts.map +1 -1
  95. package/lib/util/connection-liveness.js +3 -2
  96. package/lib/util/connection-liveness.js.map +1 -1
  97. package/package.json +5 -5
  98. package/src/documents/ast-document-manager.ts +7 -3
  99. package/src/documents/document-release-handler.ts +15 -21
  100. package/src/documents/hydranium-text-documents.ts +13 -12
  101. package/src/documents/language-client-shadow.ts +2 -1
  102. package/src/documents/version-sync-service.ts +32 -24
  103. package/src/langium/diagnostics/lsp-logger.ts +2 -1
  104. package/src/langium/document-builder/document-builder.ts +30 -11
  105. package/src/langium/integrity/integrity-service.ts +4 -1
  106. package/src/langium/model-service/client-session.ts +20 -3
  107. package/src/langium/model-service/model-service.ts +55 -24
  108. package/src/langium/project/abstract-project-manager.ts +4 -4
  109. package/src/langium/scope/hydranium-scope-computation.ts +12 -12
  110. package/src/langium/validation/document-validator.ts +5 -3
  111. package/src/langium/workspace/hydranium-workspace-lock.ts +2 -2
  112. package/src/langium/workspace/hydranium-workspace-manager.ts +4 -5
  113. package/src/langium/workspace/persistent-file-system-provider.ts +2 -1
  114. package/src/launcher/integrated-server.ts +3 -1
  115. package/src/lsp/hydranium-document-update-handler.ts +15 -7
  116. package/src/node/event-loop-monitor.ts +2 -1
  117. package/src/node/log-preamble-node.ts +20 -10
  118. package/src/node/memory-monitor.ts +4 -5
  119. package/src/node/node-file-system-provider.ts +5 -2
  120. package/src/node/server-diagnostics.ts +1 -1
  121. package/src/node/socket-launcher.ts +4 -4
  122. package/src/node/stdio-launcher.ts +11 -3
  123. package/src/testing/playwright/server-log-capture.ts +14 -0
  124. package/src/testing/playwright/server-log-fixture.ts +46 -6
  125. package/src/testing/playwright/server-log-region.ts +32 -0
  126. package/src/util/connection-liveness.ts +4 -2
@@ -20,7 +20,9 @@
20
20
  * primarily by tests waiting for tidy teardown.
21
21
  *
22
22
  * Either promise may reject if startup or shutdown encounters an error
23
- * the head can't recover from.
23
+ * the head can't recover from. A launcher given a logger logs its own startup
24
+ * failure, so a rejected `started` is control flow: the caller stops what
25
+ * depends on it without logging the failure again.
24
26
  */
25
27
  export interface IntegratedServer {
26
28
  readonly started: Promise<void>;
@@ -7,7 +7,15 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { Debouncer, Deferred, type Logger, ObservableValue, type MaybeObservableValue, TIMED_OUT } from '@hydranium/protocol';
10
+ import {
11
+ Debouncer,
12
+ Deferred,
13
+ type Logger,
14
+ ObservableValue,
15
+ type MaybeObservableValue,
16
+ TIMED_OUT,
17
+ describeErrorWithStack
18
+ } from '@hydranium/protocol';
11
19
  import { URI, UriUtils } from '@hydranium/langium';
12
20
  import { DefaultDocumentUpdateHandler } from '@hydranium/langium/lsp';
13
21
  import { HYDRANIUM_BUILD_REASONS, type HydraniumDocumentBuilder } from '../langium/document-builder/document-builder.js';
@@ -221,7 +229,9 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
221
229
  for (const change of filtered.changes) {
222
230
  this.textDocuments
223
231
  .reloadDiskBaseline(change.uri)
224
- .catch((err: unknown) => this.logger.error(`Disk baseline not reloaded for ${change.uri}. ${String(err)}`));
232
+ .catch((err: unknown) =>
233
+ this.logger.error(`Disk baseline not reloaded for ${change.uri}. ${describeErrorWithStack(err)}`)
234
+ );
225
235
  }
226
236
  // External file-system changes are authoritative — overwrite any
227
237
  // pending reason (typically empty here; could be `didChangeContent`
@@ -238,7 +248,7 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
238
248
  // `unhandledRejection` listener, which reports neither the URIs nor
239
249
  // the fact that a watched-file rebuild was dropped — leaving the
240
250
  // workspace diverged from disk with no diagnostic and no retry.
241
- const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
251
+ const detail = describeErrorWithStack(err);
242
252
  const uris = params.changes.map(change => change.uri).join(', ');
243
253
  this.logger.error(`Watched-file update dropped for ${uris}. ${detail}`);
244
254
  });
@@ -293,9 +303,7 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
293
303
  });
294
304
  } catch (err) {
295
305
  cap.dispose();
296
- this.logger.error(
297
- `willSaveWaitUntil for ${uri} answered without waiting. ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`
298
- );
306
+ this.logger.error(`willSaveWaitUntil for ${uri} answered without waiting. ${describeErrorWithStack(err)}`);
299
307
  release();
300
308
  resolve([]);
301
309
  }
@@ -417,7 +425,7 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
417
425
  if (isConnectionGoneError(err)) {
418
426
  return;
419
427
  }
420
- const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
428
+ const detail = describeErrorWithStack(err);
421
429
  this.logger.error(`Workspace initialization failed. Could not perform document update. ${detail}`);
422
430
  });
423
431
  }
@@ -8,6 +8,7 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  import { monitorEventLoopDelay } from 'node:perf_hooks';
11
+ import { describeErrorWithStack } from '@hydranium/protocol';
11
12
  import { formatMemory } from '../langium/diagnostics/logger.js';
12
13
  import { type ServerSharedServicesMinimal } from '../langium/shared-services.js';
13
14
  import { formatServerState } from './server-state-snapshot.js';
@@ -74,7 +75,7 @@ export function startEventLoopMonitor(shared: ServerSharedServicesMinimal, optio
74
75
  try {
75
76
  onStallSnapshot(maxMs);
76
77
  } catch (error) {
77
- logger.warn(`Stall snapshot failed: ${error instanceof Error ? error.message : String(error)}`);
78
+ logger.warn(`Stall snapshot failed: ${describeErrorWithStack(error)}`);
78
79
  }
79
80
  }
80
81
  }
@@ -17,6 +17,7 @@ import {
17
17
  } from './event-loop-monitor.js';
18
18
  import { MEMORY_DELTA_BYTES } from '../langium/diagnostics/logger.js';
19
19
  import { DEFAULT_MEMORY_CRITICAL_THRESHOLD, DEFAULT_MEMORY_WARN_THRESHOLD } from './memory-monitor.js';
20
+ import { type ServerDiagnosticsOptions } from './server-diagnostics.js';
20
21
  import { type LogPreambleOptions } from '../langium/diagnostics/log-preamble.js';
21
22
 
22
23
  /**
@@ -37,19 +38,25 @@ export function nodeSystemInfoLines(): string[] {
37
38
  * Node monitor + memory-suffix convention lines for
38
39
  * {@link LogPreambleOptions.conventionLines}. These describe the `[EventLoop]`
39
40
  * / `[Memory]` monitors and the heap/rss suffix — all Node-only behaviour, so
40
- * they're omitted from the portable preamble.
41
+ * they're omitted from the portable preamble. Pass the options the monitors
42
+ * were started with, or the lines state the defaults.
41
43
  */
42
- export function nodeConventionLines(phaseDetailThresholdMs = 50): string[] {
44
+ export function nodeConventionLines(
45
+ phaseDetailThresholdMs = 50,
46
+ monitors: Pick<ServerDiagnosticsOptions, 'eventLoop' | 'memory'> = {}
47
+ ): string[] {
43
48
  const memoryDeltaMb = MEMORY_DELTA_BYTES / (1024 * 1024);
44
- const intervalSeconds = DEFAULT_EVENT_LOOP_INTERVAL_MS / 1000;
45
- const warnPercent = Math.round(DEFAULT_MEMORY_WARN_THRESHOLD * 100);
46
- const criticalPercent = Math.round(DEFAULT_MEMORY_CRITICAL_THRESHOLD * 100);
49
+ const thresholdMs = monitors.eventLoop?.thresholdMs ?? DEFAULT_EVENT_LOOP_THRESHOLD_MS;
50
+ const intervalSeconds = (monitors.eventLoop?.intervalMs ?? DEFAULT_EVENT_LOOP_INTERVAL_MS) / 1000;
51
+ const snapshotAboveMs = monitors.eventLoop?.snapshotAboveMs ?? DEFAULT_EVENT_LOOP_SNAPSHOT_ABOVE_MS;
52
+ const warnPercent = Math.round((monitors.memory?.warnThreshold ?? DEFAULT_MEMORY_WARN_THRESHOLD) * 100);
53
+ const criticalPercent = Math.round((monitors.memory?.criticalThreshold ?? DEFAULT_MEMORY_CRITICAL_THRESHOLD) * 100);
47
54
  return [
48
55
  `• Memory suffix: heap/rss included on [done] only when heap moved >${memoryDeltaMb}MB since last log,`,
49
56
  ` always included on rebuilds >=${phaseDetailThresholdMs}ms and on [EventLoop] stall warnings`,
50
- `• [EventLoop]: warns when a stall >=${DEFAULT_EVENT_LOOP_THRESHOLD_MS}ms is observed within a ${intervalSeconds}s window;`,
51
- ` stalls >=${DEFAULT_EVENT_LOOP_SNAPSHOT_ABOVE_MS}ms also trigger a [Process] server-state snapshot`,
52
- `• [Memory]: warns >=${warnPercent}% of V8 heap_size_limit, errors >=${criticalPercent}%, info on recovery`
57
+ `• [EventLoop]: warns when a stall >=${thresholdMs}ms is observed within a ${intervalSeconds}s window;`,
58
+ ` stalls >=${snapshotAboveMs}ms also trigger a [Process] server-state snapshot`,
59
+ `• [Memory]: warns >=${warnPercent}% of V8 heap_size_limit, again as imminent OOM >=${criticalPercent}%, info on recovery`
53
60
  ];
54
61
  }
55
62
 
@@ -58,6 +65,9 @@ export function nodeConventionLines(phaseDetailThresholdMs = 50): string[] {
58
65
  * the portable `logPreamble`'s options on a Node host:
59
66
  * `logPreamble(logger, { productName, version, ...nodeLogPreambleSections() })`.
60
67
  */
61
- export function nodeLogPreambleSections(phaseDetailThresholdMs = 50): Pick<LogPreambleOptions, 'systemInfoLines' | 'conventionLines'> {
62
- return { systemInfoLines: nodeSystemInfoLines(), conventionLines: nodeConventionLines(phaseDetailThresholdMs) };
68
+ export function nodeLogPreambleSections(
69
+ phaseDetailThresholdMs = 50,
70
+ monitors: Pick<ServerDiagnosticsOptions, 'eventLoop' | 'memory'> = {}
71
+ ): Pick<LogPreambleOptions, 'systemInfoLines' | 'conventionLines'> {
72
+ return { systemInfoLines: nodeSystemInfoLines(), conventionLines: nodeConventionLines(phaseDetailThresholdMs, monitors) };
63
73
  }
@@ -13,7 +13,7 @@ import { type ServerSharedServicesMinimal } from '../langium/shared-services.js'
13
13
 
14
14
  /** Heap usage ratio (heapUsed / heap_size_limit) above which we emit a warning. */
15
15
  export const DEFAULT_MEMORY_WARN_THRESHOLD = 0.85;
16
- /** Heap usage ratio above which we emit an error-level warning (imminent OOM likely). */
16
+ /** Heap usage ratio above which the warning says out-of-memory is imminent. */
17
17
  export const DEFAULT_MEMORY_CRITICAL_THRESHOLD = 0.95;
18
18
  /** Cooldown between repeated warnings at the same level, in ms. */
19
19
  export const DEFAULT_COOLDOWN_MS = 30_000;
@@ -25,7 +25,7 @@ type Severity = 'ok' | 'warn' | 'critical';
25
25
  export interface MemoryMonitorOptions {
26
26
  /** Heap-usage ratio (0..1) above which a warning is emitted. Default {@link DEFAULT_MEMORY_WARN_THRESHOLD}. */
27
27
  warnThreshold?: number;
28
- /** Heap-usage ratio above which an error is emitted (imminent OOM likely). Default {@link DEFAULT_MEMORY_CRITICAL_THRESHOLD}. */
28
+ /** Heap-usage ratio above which the warning says out-of-memory is imminent. Default {@link DEFAULT_MEMORY_CRITICAL_THRESHOLD}. */
29
29
  criticalThreshold?: number;
30
30
  /** Sample interval, in ms. Default {@link DEFAULT_MEMORY_INTERVAL_MS}. */
31
31
  intervalMs?: number;
@@ -86,9 +86,8 @@ export function startMemoryMonitor(shared: ServerSharedServicesMinimal, options:
86
86
  : severity === 'warn'
87
87
  ? ' - garbage collection pressure may slow operations'
88
88
  : ' - recovered below warning threshold');
89
- if (severity === 'critical') {
90
- logger.error(message);
91
- } else if (severity === 'warn') {
89
+ // Critical is still a warning: nothing has failed yet.
90
+ if (severity !== 'ok') {
92
91
  logger.warn(message);
93
92
  } else {
94
93
  logger.info(message);
@@ -23,7 +23,7 @@ import { NO_SUCH_FILE, NO_SUCH_PATH, UNSUPPORTED_WRITE } from '../langium/worksp
23
23
  /**
24
24
  * Node-based default {@link WritableFileSystemProvider}. Extends Langium's
25
25
  * {@link NodeFileSystemProvider} with an async {@link writeFile} that
26
- * creates parent directories on demand, replaces the target indivisibly unless
26
+ * creates parent directories on demand, replaces the target whole unless
27
27
  * that would break a hard link, and registers the resulting mtime with the
28
28
  * {@link SelfSaveRegistry} bound at `services.workspace.SelfSaveRegistry` — so
29
29
  * consumers can suppress the `didChangeWatchedFiles` echo for their own writes.
@@ -143,7 +143,10 @@ export class DefaultFileSystemProvider extends NodeFileSystemProvider implements
143
143
  * overwrite cannot offer that, because the target is truncated before the
144
144
  * first byte lands, so a reader in that window gets a short or empty file —
145
145
  * and a model file that fails to parse is worse than one holding a previous
146
- * good revision. It does NOT serialise writers: two writers still race, and
146
+ * good revision. On Windows the replacing rename also leaves the name
147
+ * unresolvable for a moment, so a reader there can find the file missing
148
+ * (`ENOENT`) or refused (`EPERM`), never torn, and must not take either for
149
+ * a deletion. It does NOT serialise writers: two writers still race, and
147
150
  * the loser's content is replaced wholesale rather than interleaved.
148
151
  *
149
152
  * The destination is the RESOLVED path, via {@link realpath}: a `rename`
@@ -49,7 +49,7 @@ export function startServerDiagnostics(shared: ServerSharedServicesMinimal, opti
49
49
  productName,
50
50
  version,
51
51
  phaseDetailThresholdMs,
52
- ...nodeLogPreambleSections(phaseDetailThresholdMs)
52
+ ...nodeLogPreambleSections(phaseDetailThresholdMs, options)
53
53
  });
54
54
  if (options.logInitialMemory ?? true) {
55
55
  shared.Tracer.for('Process').memory('Initial Memory');
@@ -8,7 +8,7 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  import * as net from 'node:net';
11
- import { Deferred, type Disposable, type Logger } from '@hydranium/protocol';
11
+ import { Deferred, describeErrorWithStack, type Disposable, type Logger } from '@hydranium/protocol';
12
12
  import { createMessageConnection, SocketMessageReader, SocketMessageWriter } from 'vscode-jsonrpc/node';
13
13
  import type { MessageConnection } from 'vscode-jsonrpc';
14
14
  import type { IntegratedServer } from '../launcher/integrated-server.js';
@@ -131,7 +131,7 @@ export function startSocketServer(options: SocketServerOptions, onClientConnecti
131
131
  try {
132
132
  adopterDisposable.dispose();
133
133
  } catch (err) {
134
- logger?.warn(`[${tag}] Adopter disposable threw on cleanup: ${(err as Error)?.message ?? err}`);
134
+ logger?.warn(`[${tag}] Adopter disposable threw on cleanup: ${describeErrorWithStack(err)}`);
135
135
  }
136
136
  connection.dispose();
137
137
  const idx = activeConnections.indexOf(entry);
@@ -164,7 +164,7 @@ export function startSocketServer(options: SocketServerOptions, onClientConnecti
164
164
  started.resolve();
165
165
  });
166
166
  netServer.on('error', err => {
167
- logger?.error(`[${tag}] Error: ${err.message}`);
167
+ logger?.error(`[${tag}] Error: ${describeErrorWithStack(err)}`);
168
168
  started.reject(err);
169
169
  });
170
170
  netServer.on('close', () => {
@@ -174,7 +174,7 @@ export function startSocketServer(options: SocketServerOptions, onClientConnecti
174
174
  try {
175
175
  entry.adopterDisposable.dispose();
176
176
  } catch (err) {
177
- logger?.warn(`[${tag}] Adopter disposable threw on shutdown: ${(err as Error)?.message ?? err}`);
177
+ logger?.warn(`[${tag}] Adopter disposable threw on shutdown: ${describeErrorWithStack(err)}`);
178
178
  }
179
179
  entry.connection.dispose();
180
180
  }
@@ -7,7 +7,7 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { Deferred, type Disposable, type Logger } from '@hydranium/protocol';
10
+ import { Deferred, type Disposable, type Logger, describeError, describeErrorWithStack } from '@hydranium/protocol';
11
11
  import type { MessageConnection } from 'vscode-jsonrpc';
12
12
  import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from 'vscode-jsonrpc/node';
13
13
  import type { IntegratedServer } from '../launcher/integrated-server.js';
@@ -115,17 +115,21 @@ export function startStdioServer(
115
115
  try {
116
116
  adopterDisposable.dispose();
117
117
  } catch (err: unknown) {
118
- logger?.warn(`[${tag}] Adopter disposable threw on cleanup: ${err instanceof Error ? err.message : String(err)}`);
118
+ logger?.warn(`[${tag}] Adopter disposable threw on cleanup: ${describeErrorWithStack(err)}`);
119
119
  }
120
120
  connection.dispose();
121
121
  stopped.resolve();
122
122
  };
123
123
  connection.onClose(() => close());
124
124
 
125
+ // The workspace manager logs a failed initialization itself.
126
+ let initializing = false;
125
127
  const begin = async (): Promise<void> => {
126
128
  if (options.workspace !== undefined) {
127
129
  logger?.info(`[${tag}] Initializing workspace`);
130
+ initializing = true;
128
131
  await initializeWorkspaceProgrammatically(options.shared, options.workspace);
132
+ initializing = false;
129
133
  }
130
134
  // Only now attach the reader — see the ordering note in the doc above.
131
135
  connection.listen();
@@ -138,7 +142,11 @@ export function startStdioServer(
138
142
  // A failed startup must not leave a half-live head: report it on
139
143
  // `started` and tear the connection down rather than listening over an
140
144
  // uninitialized workspace.
141
- logger?.error(`[${tag}] Startup failed: ${err instanceof Error ? err.message : String(err)}`);
145
+ if (initializing) {
146
+ logger?.debug(`[${tag}] Startup failed: the workspace did not initialize. ${describeError(err)}`);
147
+ } else {
148
+ logger?.error(`[${tag}] Startup failed: ${describeErrorWithStack(err)}`);
149
+ }
142
150
  started.reject(err);
143
151
  close();
144
152
  }
@@ -415,6 +415,20 @@ export function forwardBrowserConsole(
415
415
  });
416
416
  }
417
417
 
418
+ /** The label a `Warn` or `Error` entry starts with; continuation lines of a stack never do. */
419
+ const WARN_OR_ERROR_LINE = /^\[(Warn|Error)\s*-/;
420
+
421
+ /**
422
+ * The `Warn` and `Error` lines of `text` that no pattern of `allowlist`
423
+ * matches; its keys are the reasons the lines are expected. A clean run logs
424
+ * neither: `warn` is an anomaly that was handled and `error` is work that
425
+ * failed, so either one in a passing spec is a defect or a missing allowance.
426
+ */
427
+ export function findUnexpectedServerLogLines(text: string, allowlist: Readonly<Record<string, RegExp>> = {}): string[] {
428
+ const patterns = Object.values(allowlist);
429
+ return text.split('\n').filter(line => WARN_OR_ERROR_LINE.test(line) && !patterns.some(pattern => pattern.test(line)));
430
+ }
431
+
418
432
  /**
419
433
  * Per-test server-log capture, called from a spec's `afterEach` with the test's
420
434
  * workspace path:
@@ -11,7 +11,14 @@
11
11
  // The plain functions in `server-log-capture.ts` stay playwright-free; only
12
12
  // adopters that use this fixture need `@playwright/test` installed.
13
13
  import { test as base, type Fixtures, type PlaywrightTestArgs, type PlaywrightTestOptions } from '@playwright/test';
14
- import { attachServerLog, markServerLog } from './server-log-capture.js';
14
+ import {
15
+ attachServerLog,
16
+ DEFAULT_SERVER_LOG_ATTACH_ENV,
17
+ findUnexpectedServerLogLines,
18
+ markServerLog,
19
+ resolveServerLogPath
20
+ } from './server-log-capture.js';
21
+ import { readServerLogRegion } from './server-log-region.js';
15
22
 
16
23
  export interface ServerLogFixtures {
17
24
  /**
@@ -22,6 +29,21 @@ export interface ServerLogFixtures {
22
29
  * `attachServerLog` in `beforeEach` / `afterEach` instead.
23
30
  */
24
31
  serverLogWorkspace: string;
32
+ /**
33
+ * Fail a test whose part of the server log holds a `Warn` or `Error` line
34
+ * that {@link serverLogAllowlist} does not match. Off by default; reads
35
+ * nothing when capture is off.
36
+ */
37
+ serverLogClean: boolean;
38
+ /**
39
+ * The `Warn` and `Error` lines {@link serverLogClean} lets through: each key
40
+ * is why the line is expected, and its pattern is matched against the whole
41
+ * line, label and timestamp included. A pattern takes neither the `g` nor
42
+ * the `y` flag, which make `test` resume from its last match. An object
43
+ * rather than a list, because Playwright reads an array whose second entry
44
+ * is an object as a fixture's `[value, options]`.
45
+ */
46
+ serverLogAllowlist: Readonly<Record<string, RegExp>>;
25
47
  }
26
48
 
27
49
  /** Full fixture set including the internal side-effect-only auto fixture. */
@@ -36,17 +58,35 @@ interface ServerLogTestFixtures extends ServerLogFixtures {
36
58
  * (teardown) — i.e. the `beforeEach` + `afterEach` pair fused into one auto
37
59
  * fixture. Compose onto any test base, or use the pre-extended {@link test}.
38
60
  * Override `serverLogWorkspace` to wire the workspace; otherwise it's inert.
61
+ * Set `serverLogClean` to also fail a test on an unexpected `Warn` or `Error`.
39
62
  */
40
63
  export const serverLogFixtures: Fixtures<ServerLogTestFixtures, object, PlaywrightTestArgs & PlaywrightTestOptions> = {
41
64
  serverLogWorkspace: ['', { option: true }],
65
+ serverLogClean: [false, { option: true }],
66
+ serverLogAllowlist: [{}, { option: true }],
42
67
  serverLogCapture: [
43
- async ({ serverLogWorkspace }, use): Promise<void> => {
44
- if (serverLogWorkspace) {
45
- markServerLog(base.info(), serverLogWorkspace);
68
+ async ({ serverLogWorkspace, serverLogClean, serverLogAllowlist }, use): Promise<void> => {
69
+ if (!serverLogWorkspace) {
70
+ await use(undefined);
71
+ return;
46
72
  }
73
+ markServerLog(base.info(), serverLogWorkspace);
47
74
  await use(undefined);
48
- if (serverLogWorkspace) {
49
- await attachServerLog(base.info(), serverLogWorkspace);
75
+ const file = resolveServerLogPath(serverLogWorkspace);
76
+ const region = file ? readServerLogRegion(file) : '';
77
+ let problem: string | undefined;
78
+ if (serverLogClean) {
79
+ const unexpected = findUnexpectedServerLogLines(region, serverLogAllowlist);
80
+ if (unexpected.length > 0) {
81
+ problem = `The server logged ${unexpected.length} unexpected Warn or Error line(s):\n${unexpected.join('\n')}`;
82
+ }
83
+ }
84
+ // The failure-only policy reads the test's status, which failing here
85
+ // does not change; a run set to attach nothing still attaches nothing.
86
+ const attachOn = process.env[DEFAULT_SERVER_LOG_ATTACH_ENV] === 'never' ? 'never' : 'always';
87
+ await attachServerLog(base.info(), serverLogWorkspace, problem ? { attachOn } : {});
88
+ if (problem) {
89
+ throw new Error(problem);
50
90
  }
51
91
  },
52
92
  { auto: true }
@@ -0,0 +1,32 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+
12
+ /**
13
+ * How far each log has been read. A suite starts its app before its first
14
+ * test's marker, so reading from the marker would leave startup unread.
15
+ */
16
+ const readUpTo = new Map<string, number>();
17
+
18
+ /**
19
+ * The part of `file` written since the last read of it, up to its last complete
20
+ * line, since the server may be mid-write. Every test reads, checked or not, so
21
+ * a test that is not checked leaves no lines to the next one.
22
+ */
23
+ export function readServerLogRegion(file: string): string {
24
+ if (!existsSync(file)) {
25
+ return '';
26
+ }
27
+ const log = readFileSync(file);
28
+ const end = log.lastIndexOf('\n') + 1;
29
+ const text = log.subarray(readUpTo.get(file) ?? 0, end).toString('utf-8');
30
+ readUpTo.set(file, end);
31
+ return text;
32
+ }
@@ -7,6 +7,8 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
+ import { describeError } from '@hydranium/protocol';
11
+
10
12
  /**
11
13
  * Phrases that mean a call lost its connection. Matched on the message because
12
14
  * the condition surfaces in shapes that share no discriminator:
@@ -64,7 +66,7 @@ export function isConnectionGoneError(err: unknown): boolean {
64
66
  if (isDestroyedStreamError(err)) {
65
67
  return true;
66
68
  }
67
- const message = err instanceof Error ? err.message : String(err);
69
+ const message = describeError(err);
68
70
  const normalized = message.toLowerCase();
69
71
  return CONNECTION_GONE_PATTERNS.some(pattern => pattern.test(normalized));
70
72
  }
@@ -92,6 +94,6 @@ export function isClientGoneError(err: unknown): boolean {
92
94
  if (err instanceof Error && 'code' in err && typeof err.code === 'string' && SOCKET_GONE_CODES.has(err.code)) {
93
95
  return true;
94
96
  }
95
- const message = err instanceof Error ? err.message : String(err);
97
+ const message = describeError(err);
96
98
  return SOCKET_GONE_PATTERN.test(message.toLowerCase());
97
99
  }