@hydranium/core 1.0.0-next.169 → 1.0.0-next.173

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 (37) hide show
  1. package/lib/langium/document-builder/document-builder.d.ts +8 -7
  2. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  3. package/lib/langium/document-builder/document-builder.js +9 -8
  4. package/lib/langium/document-builder/document-builder.js.map +1 -1
  5. package/lib/langium/integrity/integrity-service.d.ts +8 -7
  6. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  7. package/lib/langium/integrity/integrity-service.js +20 -12
  8. package/lib/langium/integrity/integrity-service.js.map +1 -1
  9. package/lib/langium/model-service/model-service.d.ts +27 -40
  10. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  11. package/lib/langium/model-service/model-service.js +16 -25
  12. package/lib/langium/model-service/model-service.js.map +1 -1
  13. package/lib/langium/module.d.ts +1 -1
  14. package/lib/langium/module.js +1 -1
  15. package/lib/langium/module.js.map +1 -1
  16. package/lib/langium/workspace/hydranium-workspace-lock.d.ts +31 -2
  17. package/lib/langium/workspace/hydranium-workspace-lock.d.ts.map +1 -1
  18. package/lib/langium/workspace/hydranium-workspace-lock.js +41 -2
  19. package/lib/langium/workspace/hydranium-workspace-lock.js.map +1 -1
  20. package/lib/langium/workspace/write-lock-scope.d.ts +19 -10
  21. package/lib/langium/workspace/write-lock-scope.d.ts.map +1 -1
  22. package/lib/langium/workspace/write-lock-scope.js +11 -4
  23. package/lib/langium/workspace/write-lock-scope.js.map +1 -1
  24. package/lib/testing/make-test-services.d.ts +1 -1
  25. package/lib/testing/make-test-services.js +4 -4
  26. package/lib/testing/make-test-services.js.map +1 -1
  27. package/lib/testing/stub-model-service.d.ts +1 -1
  28. package/lib/testing/stub-model-service.js +1 -1
  29. package/package.json +5 -5
  30. package/src/langium/document-builder/document-builder.ts +9 -8
  31. package/src/langium/integrity/integrity-service.ts +27 -13
  32. package/src/langium/model-service/model-service.ts +35 -57
  33. package/src/langium/module.ts +2 -2
  34. package/src/langium/workspace/hydranium-workspace-lock.ts +66 -3
  35. package/src/langium/workspace/write-lock-scope.ts +23 -11
  36. package/src/testing/make-test-services.ts +5 -5
  37. package/src/testing/stub-model-service.ts +1 -1
@@ -8,8 +8,16 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  import { isPromiseLike, Logger, type ProfileSession, type Tracer } from '@hydranium/protocol';
11
- import { type AstNode, AstUtils, DocumentState, interruptAndCheck, type LangiumDocument, UriUtils } from '@hydranium/langium';
12
- import { type CancellationToken, type Disposable } from 'vscode-languageserver';
11
+ import {
12
+ type AstNode,
13
+ AstUtils,
14
+ DocumentState,
15
+ interruptAndCheck,
16
+ isOperationCancelled,
17
+ type LangiumDocument,
18
+ UriUtils
19
+ } from '@hydranium/langium';
20
+ import { CancellationToken, type Disposable } from 'vscode-languageserver';
13
21
  import { type HydraniumDocumentBuilder } from '../document-builder/document-builder.js';
14
22
  import { type TextDocument } from 'vscode-languageserver-textdocument';
15
23
  import { type WritableFileSystemProvider } from '../../documents/ast-document-manager.js';
@@ -200,7 +208,8 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
200
208
  const changed = await this.enforceIntegrity(document, phase, cancelToken, session);
201
209
  if (changed) {
202
210
  mutatedCount++;
203
- await this.resyncDocument(document, cancelToken);
211
+ // Not cancellable: see `resyncDocument`.
212
+ await this.resyncDocument(document, CancellationToken.None);
204
213
  }
205
214
  await interruptAndCheck(cancelToken);
206
215
  }
@@ -261,8 +270,10 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
261
270
  * full rule sweep) — coarser than per (node × rule) to avoid a microtask
262
271
  * per inner iteration, finer than per-document so a long-running rule set
263
272
  * on a large file can still interrupt mid-stream. Throws
264
- * `OperationCancelled` when the token is cancelled; Langium's build
265
- * pipeline absorbs the throw at the lock-holder.
273
+ * `OperationCancelled` when the token is cancelled before any rule mutated
274
+ * the AST; Langium's build pipeline absorbs the throw at the lock-holder.
275
+ * After a mutation it stops the sweep and returns `true` instead, so the
276
+ * caller can resync what was mutated before the cancellation unwinds.
266
277
  */
267
278
  async enforceIntegrity(
268
279
  document: LangiumDocument,
@@ -296,7 +307,14 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
296
307
  }
297
308
  }
298
309
  if (cancelToken !== undefined) {
299
- await interruptAndCheck(cancelToken);
310
+ try {
311
+ await interruptAndCheck(cancelToken);
312
+ } catch (err: unknown) {
313
+ if (changed && isOperationCancelled(err)) {
314
+ return true;
315
+ }
316
+ throw err;
317
+ }
300
318
  }
301
319
  }
302
320
  return changed;
@@ -306,15 +324,11 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
306
324
  * Re-serialise the document text after AST mutations, propagate corrections,
307
325
  * and re-parse at Parsed phase (safe because linking hasn't occurred yet).
308
326
  *
309
- * `cancelToken` is checked at entry so a preempted build skips the
310
- * serialise / disk-write atomic; the steps inside resync are not further
311
- * subdivided because each is a single fast operation that does not yield
312
- * a natural interruption point.
327
+ * Runs to the end whatever `cancelToken` says: the AST is already mutated,
328
+ * and skipping the resync leaves it against unchanged text that the next
329
+ * build does not re-parse. `cancelToken` reaches only the re-parse.
313
330
  */
314
331
  protected async resyncDocument(document: LangiumDocument, cancelToken?: CancellationToken): Promise<void> {
315
- if (cancelToken?.isCancellationRequested) {
316
- return;
317
- }
318
332
  const root = document.parseResult.value as TRoot;
319
333
  // Extracted while `document` still holds the text being replaced — the
320
334
  // resync that follows overwrites it with `newText`. Absent on a test
@@ -20,7 +20,7 @@ import {
20
20
  import { type AstNode, DocumentState, type LangiumDocument, UriUtils, type URI } from '@hydranium/langium';
21
21
  import { type AstDiagnostic } from '../validation/document-validator.js';
22
22
  import { type DocumentUriPolicy } from '../workspace/document-uri-policy.js';
23
- import { ReentrantWriteLockError, isInsideWriteLock } from '../workspace/write-lock-scope.js';
23
+ import { ReentrantWriteLockError, isInsideWriteLock, isWriteLockScopeInstalled } from '../workspace/write-lock-scope.js';
24
24
  import { type CancellationToken, type Disposable } from 'vscode-languageserver';
25
25
  import { AstDocument, type AstDocumentSavedEvent, type AstDocumentUpdatedEvent } from '../../documents/ast-document-manager.js';
26
26
  import { isConnectionGoneError } from '../../util/connection-liveness.js';
@@ -58,45 +58,32 @@ const SAVE_SETTLE_TIMEOUT_MS = 10_000;
58
58
  */
59
59
  export interface ModelServiceOptions extends LogNameOptions {
60
60
  /**
61
- * Serialise the facade's own build under the workspace WRITE lock, through
62
- * `HydraniumDocumentBuilder.scheduleUpdate` as the LSP update handler
63
- * builds. Default `true`.
61
+ * Let the facade's build run without the workspace write lock when its
62
+ * caller already holds it: an integrity rule or build-phase pass that
63
+ * writes back through a session's `update` / `save`, or calls `rebuild`.
64
+ * Default `false`.
64
65
  *
65
- * **Why it defaults on.** Unlocked, the facade's build races the LSP bridge's
66
- * build of the same URI — both are legitimate (the bridge exists only under a
67
- * `Connection`, so the facade stands in for it headless), but nothing
68
- * serialises them, so both run a full validation pass and Langium appends the
69
- * second onto the first. Every diagnostic is then duplicated, and the
70
- * duplication compounds per rebuild. Serialised, the second request shares
71
- * the first build where it carries both, so the redundant work goes too.
66
+ * `WorkspaceLock` is not reentrant. Taking it from inside a holder cancels
67
+ * that holder and then waits for it to end, while the holder waits for this
68
+ * call, so neither completes. On `false` that call fails with
69
+ * {@link ReentrantWriteLockError} instead, wherever a host installs a
70
+ * write-lock scope tracker (`@hydranium/core/node` does at entry load); on
71
+ * `true` its build takes no lock. The wait after that build can still hang,
72
+ * because a document no build will carry is re-queued through the lock, and
73
+ * the re-queue waits for the holder to end.
72
74
  *
73
- * **What `false` costs.** The facade's build is then unserialised and can run
74
- * concurrently with the bridge's build of the same URI, so the affected
75
- * documents are validated twice per write — double the validation work.
76
- * Reported diagnostics stay correct even then, because
77
- * `HydraniumDocumentBuilder.dedupeDiagnostics` collapses the byte-identical
78
- * duplicates a repeated pass produces before any listener sees them; what this
79
- * option removes is the wasted pass, not just its visible symptom.
80
- *
81
- * **What `false` buys.** `WorkspaceLock` is not reentrant. A caller that
82
- * reaches a session's `update` / `save` or `rebuild` while already holding
83
- * the write lock — an integrity rule or build-phase pass that writes
84
- * back — deadlocks: acquiring the lock cancels the running holder, and the new
85
- * acquisition then waits for that holder to release while the holder waits
86
- * for this call. On `true` that shape is DETECTED and rejected with
87
- * {@link ReentrantWriteLockError} rather than hanging, wherever a host
88
- * installs a write-lock scope tracker (`@hydranium/core/node` does at entry
89
- * load; see {@link isInsideWriteLock}). Setting `false` is the escape hatch
90
- * for an adopter whose reentrant shape is unavoidable: the facade's own
91
- * build then takes no lock, so it cannot cancel the holder it runs inside.
92
- * The wait after it can still hang there, because a document no build will
93
- * carry is re-queued through the lock, and that re-queue waits for the
94
- * holder to end. Prefer `false` over serialised builds only in that case.
75
+ * Every other facade build takes the lock on either value. Unlocked, it can
76
+ * overlap another build of the same document: both run a full validation
77
+ * pass that Langium appends, and a caller can be handed a version that an
78
+ * integrity repair still running in the other build then moves past. Without
79
+ * a tracker the two cases cannot be told apart (see
80
+ * {@link isWriteLockScopeInstalled}), so `true` builds without the lock every
81
+ * time and accepts both.
95
82
  *
96
83
  * Accepts a {@link MaybeObservableValue} so it can be bound to a setting and
97
84
  * flipped without a restart.
98
85
  */
99
- readonly serializeBuilds?: MaybeObservableValue<boolean>;
86
+ readonly allowReentrantBuilds?: MaybeObservableValue<boolean>;
100
87
  }
101
88
 
102
89
  /**
@@ -289,8 +276,8 @@ export class DefaultModelService<
289
276
  * (see {@link DocumentUriPolicy}).
290
277
  */
291
278
  protected readonly uriPolicy: DocumentUriPolicy;
292
- /** See {@link ModelServiceOptions.serializeBuilds}. Defaults to `true`. */
293
- protected readonly serializeBuilds: ObservableValue<boolean>;
279
+ /** See {@link ModelServiceOptions.allowReentrantBuilds}. Defaults to `false`. */
280
+ protected readonly allowReentrantBuilds: ObservableValue<boolean>;
294
281
 
295
282
  /**
296
283
  * Per-URI applyEdit coalescing: at most one in-flight `applyEditToLanguageClient`
@@ -351,7 +338,7 @@ export class DefaultModelService<
351
338
  ) {
352
339
  this.tracer = services.Tracer.for(options.logName ?? 'ModelService').trace('instantiated');
353
340
  this.uriPolicy = services.workspace.DocumentUriPolicy;
354
- this.serializeBuilds = ObservableValue.from(options.serializeBuilds ?? true);
341
+ this.allowReentrantBuilds = ObservableValue.from(options.allowReentrantBuilds ?? false);
355
342
  // Optional-chain so a harness that binds no WorkspaceManager awaits
356
343
  // `undefined` and resolves immediately; production hosts always have it
357
344
  // bound.
@@ -483,9 +470,9 @@ export class DefaultModelService<
483
470
  * where that build carries it. Langium alone does not coalesce concurrent
484
471
  * builds of one URI: two unserialised builds each run a full validation
485
472
  * pass and Langium appends the second set onto the first, duplicating every
486
- * diagnostic. Opt out via
487
- * {@link ModelServiceOptions.serializeBuilds} — see there for the
488
- * non-reentrancy hazard that is the reason the opt-out exists.
473
+ * diagnostic. A caller that already holds the lock builds without it only
474
+ * under {@link ModelServiceOptions.allowReentrantBuilds} — see there for the
475
+ * non-reentrancy hazard.
489
476
  *
490
477
  * An override calls the base before it awaits. Under an LSP connection the
491
478
  * store's change event has already asked the update handler to build a
@@ -533,28 +520,19 @@ export class DefaultModelService<
533
520
  // meant for category-partitioned passes). The user-visible result is every
534
521
  // diagnostic duplicated, plus double the validation work per write.
535
522
  //
536
- // Opt out via `ModelServiceOptions.serializeBuilds` — see there for
537
- // the non-reentrancy hazard that opt-out exists for.
538
- if (this.serializeBuilds.value) {
539
- // Fail loudly on the one shape the lock cannot survive. `WorkspaceLock`
540
- // is not reentrant: acquiring the write lock cancels the running holder,
541
- // so a caller already inside one — an integrity rule or build-phase pass
542
- // writing back through this facade — would cancel its own enclosing
543
- // build and then likely stall in the phase wait below. The check is
544
- // gated on `serializeBuilds` deliberately, because acquiring the lock IS
545
- // the hazard: with serialisation off the build takes no lock, which
546
- // makes the existing opt-out the guard's opt-out too.
547
- // Detection needs async-context propagation, so it is inert until a host
548
- // installs a tracker (`@hydranium/core/node` does) — see
549
- // `isInsideWriteLock`.
550
- if (isInsideWriteLock()) {
523
+ // A caller already inside the lock would cancel its own build and stall in
524
+ // the wait below; `allowReentrantBuilds` builds it unlocked, and without a
525
+ // tracker it cannot be told apart from any other caller.
526
+ const inside = isInsideWriteLock();
527
+ if (this.allowReentrantBuilds.value && (inside || !isWriteLockScopeInstalled())) {
528
+ await this.services.workspace.DocumentBuilder.update([documentUri], [], cancelToken);
529
+ } else {
530
+ if (inside) {
551
531
  throw new ReentrantWriteLockError(uri);
552
532
  }
553
533
  // The build runs on the lock's own token, which a later write cancels;
554
534
  // the caller's token governs only the phase wait below.
555
535
  await this.services.workspace.DocumentBuilder.scheduleUpdate([documentUri], []);
556
- } else {
557
- await this.services.workspace.DocumentBuilder.update([documentUri], [], cancelToken);
558
536
  }
559
537
  return this.waitForDocumentStateCanonical(uri, state ?? IntegrityService.SettledState, cancelToken);
560
538
  }
@@ -169,7 +169,7 @@ export interface ServerAddedSharedServices<
169
169
  * `ModelService.rebuild` build through this slot's
170
170
  * `scheduleUpdate`, so narrowing here is a load-bearing contract:
171
171
  * a builder without it fails every LSP-driven build, and every
172
- * session write while `serializeBuilds` is on, at run time.
172
+ * session write that takes the lock, at run time.
173
173
  */
174
174
  /* override */ DocumentBuilder: HydraniumDocumentBuilder;
175
175
  /**
@@ -530,7 +530,7 @@ export function createServerSharedModule(
530
530
  DocumentBuilder: services => new HydraniumDocumentBuilder(services),
531
531
  // The write-lock scope this marks is inert unless a host installs a
532
532
  // scope tracker — `@hydranium/core/node` does.
533
- WorkspaceLock: () => new HydraniumWorkspaceLock(),
533
+ WorkspaceLock: services => new HydraniumWorkspaceLock(services),
534
534
  ProjectManager: services => new SingleProjectManager(services),
535
535
  SelfSaveRegistry: services => new DefaultSelfSaveRegistry(services),
536
536
  DocumentUriPolicy: () => new DefaultDocumentUriPolicy(),
@@ -7,10 +7,31 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
+ import { type Clock, type Tracer } from '@hydranium/protocol';
10
11
  import { DefaultWorkspaceLock, type MaybePromise } from '@hydranium/langium';
11
- import { type CancellationToken } from 'vscode-languageserver-protocol';
12
+ import { type CancellationToken, type Disposable } from 'vscode-languageserver-protocol';
13
+ import { type LogNameOptions } from '../diagnostics/logger.js';
14
+ import { type ServerSharedServicesMinimal } from '../shared-services.js';
12
15
  import { runInWriteLockScope } from './write-lock-scope.js';
13
16
 
17
+ /** Constructor options for {@link HydraniumWorkspaceLock}. */
18
+ export interface HydraniumWorkspaceLockOptions extends LogNameOptions {
19
+ /**
20
+ * How long a write may keep running after a later write cancelled it before
21
+ * the lock logs an error. Every write and read waits behind it meanwhile, so
22
+ * one that never ends stops the server. Defaults to 10 s; `0` turns the
23
+ * check off.
24
+ */
25
+ readonly stalledWriteWarnMs?: number;
26
+ }
27
+
28
+ const DEFAULT_STALLED_WRITE_WARN_MS = 10_000;
29
+
30
+ /** The write action a {@link HydraniumWorkspaceLock} is running, with the timer its cancellation started. */
31
+ export interface RunningWrite {
32
+ stallTimer?: Disposable;
33
+ }
34
+
14
35
  /**
15
36
  * Langium's {@link DefaultWorkspaceLock} with the write action marked as a
16
37
  * write-lock scope, so anything it reaches can be asked whether the lock is
@@ -31,6 +52,17 @@ import { runInWriteLockScope } from './write-lock-scope.js';
31
52
  export class HydraniumWorkspaceLock extends DefaultWorkspaceLock {
32
53
  /** Backs {@link writeCancellations}. */
33
54
  protected cancelledWriteCount = 0;
55
+ protected readonly clock: Clock;
56
+ protected readonly tracer: Tracer;
57
+ protected readonly stalledWriteWarnMs: number;
58
+ protected runningWrite?: RunningWrite;
59
+
60
+ constructor(services: Pick<ServerSharedServicesMinimal, 'Clock' | 'Tracer'>, options: HydraniumWorkspaceLockOptions = {}) {
61
+ super();
62
+ this.clock = services.Clock;
63
+ this.tracer = services.Tracer.for(options.logName ?? 'WorkspaceLock');
64
+ this.stalledWriteWarnMs = options.stalledWriteWarnMs ?? DEFAULT_STALLED_WRITE_WARN_MS;
65
+ }
34
66
 
35
67
  /**
36
68
  * How many calls have cancelled the lock's latest write: every `write`,
@@ -45,12 +77,43 @@ export class HydraniumWorkspaceLock extends DefaultWorkspaceLock {
45
77
  }
46
78
 
47
79
  override write(action: (token: CancellationToken) => MaybePromise<void>): Promise<void> {
48
- return super.write(token => runInWriteLockScope(() => action(token)));
80
+ return super.write(token => runInWriteLockScope(() => this.runWrite(action, token)));
81
+ }
82
+
83
+ /** Run `action` as {@link runningWrite}, so a cancellation while it runs can time it. */
84
+ protected async runWrite(action: (token: CancellationToken) => MaybePromise<void>, token: CancellationToken): Promise<void> {
85
+ const running: RunningWrite = {};
86
+ this.runningWrite = running;
87
+ try {
88
+ await action(token);
89
+ } finally {
90
+ running.stallTimer?.dispose();
91
+ if (this.runningWrite === running) {
92
+ this.runningWrite = undefined;
93
+ }
94
+ }
49
95
  }
50
96
 
51
- /** Counted here rather than in {@link write}, because Langium's `write` cancels through this method too. */
97
+ /**
98
+ * Counted here rather than in {@link write}, because Langium's `write` cancels
99
+ * through this method too. A write running when this is called has been
100
+ * cancelled, by this call or an earlier one, so its timer starts here.
101
+ */
52
102
  override cancelWrite(): void {
53
103
  this.cancelledWriteCount++;
104
+ const running = this.runningWrite;
105
+ if (running && !running.stallTimer && this.stalledWriteWarnMs > 0) {
106
+ running.stallTimer = this.clock.setTimer(() => this.reportStalledWrite(), this.stalledWriteWarnMs);
107
+ }
54
108
  super.cancelWrite();
55
109
  }
110
+
111
+ protected reportStalledWrite(): void {
112
+ this.tracer.error(
113
+ `A write is still running ${this.stalledWriteWarnMs} ms after it was cancelled, and every write and read waits behind it. ` +
114
+ 'Likely causes: a model-service write or rebuild from inside a build in a host with no write-lock scope tracker, ' +
115
+ 'which waits for the build it runs in, or a write action that ignores its cancellation token. ' +
116
+ 'See https://github.com/eclipse-emfcloud/hydranium/issues/245.'
117
+ );
118
+ }
56
119
  }
@@ -22,13 +22,17 @@
22
22
  * Answering it needs async-context propagation, which has no browser-neutral
23
23
  * implementation: Node's `AsyncLocalStorage` is a `node:async_hooks` import (so
24
24
  * it cannot appear on the portable `.` entry, which `check:neutral` gates), and
25
- * TC39's `AsyncContext` is unshipped. Hence the same seam shape the log file-tee
26
- * uses: this module declares the contract and defaults to inert, and
27
- * `@hydranium/core/node` installs a real implementation at entry load. A browser
28
- * bundle therefore gets NO detection rather than a broken approximation, which
29
- * is the right degradation — the reentrant shape is a server-side build concern,
30
- * and a false negative costs the pre-existing stall while a false positive would
31
- * reject a legitimate write.
25
+ * TC39's `AsyncContext` is unshipped. Userland polyfills lose the context at a
26
+ * native `await` unless every awaited path, Langium's included, is transpiled.
27
+ * Hence the same seam shape the log file-tee uses: this module declares the
28
+ * contract and defaults to inert, and `@hydranium/core/node` installs a real
29
+ * implementation at entry load. A browser bundle therefore gets NO detection
30
+ * rather than a broken approximation, which is the right degradation — the
31
+ * reentrant shape is a server-side build concern, and a false negative costs
32
+ * the stall, which `HydraniumWorkspaceLock` reports once the stalled write
33
+ * outlives its cancellation, while a false positive would reject a legitimate
34
+ * write. Detection without a tracker is tracked in
35
+ * https://github.com/eclipse-emfcloud/hydranium/issues/245.
32
36
  */
33
37
  export interface WriteLockScope {
34
38
  /** Run `fn` marked as executing inside a write-lock holder. */
@@ -64,6 +68,14 @@ export function isInsideWriteLock(): boolean {
64
68
  return writeLockScope?.isInside() ?? false;
65
69
  }
66
70
 
71
+ /**
72
+ * Whether a write-lock scope tracker is installed, which is what makes a
73
+ * `false` from {@link isInsideWriteLock} mean "outside" rather than "unknown".
74
+ */
75
+ export function isWriteLockScopeInstalled(): boolean {
76
+ return writeLockScope !== undefined;
77
+ }
78
+
67
79
  /**
68
80
  * Thrown when the model facade is reached from inside a workspace write-lock
69
81
  * holder — typically an integrity rule or build-phase pass writing through
@@ -72,9 +84,9 @@ export function isInsideWriteLock(): boolean {
72
84
  * Named rather than a bare `Error` because the remedy is specific and worth
73
85
  * pointing at: either move the write out of the build (integrity rules that
74
86
  * persist repairs should write through `FileSystemProvider.writeFile`, which
75
- * takes no lock), or set `ModelServiceOptions.serializeBuilds: false`, which
76
- * keeps the facade's build from cancelling the holder but not its wait from
77
- * stalling on a document that needs a re-queue.
87
+ * takes no lock), or set `ModelServiceOptions.allowReentrantBuilds: true`,
88
+ * which keeps the facade's build from cancelling the holder but not its wait
89
+ * from stalling on a document that needs a re-queue.
78
90
  */
79
91
  export class ReentrantWriteLockError extends Error {
80
92
  constructor(readonly uri: string) {
@@ -82,7 +94,7 @@ export class ReentrantWriteLockError extends Error {
82
94
  `Model facade reached for '${uri}' from inside a workspace write-lock holder. ` +
83
95
  'Acquiring the write lock would cancel the enclosing build and may stall this call. ' +
84
96
  'Write through FileSystemProvider.writeFile from a build-phase pass, or set ' +
85
- 'ModelServiceOptions.serializeBuilds: false to opt out of build serialisation.'
97
+ 'ModelServiceOptions.allowReentrantBuilds: true to build without the lock from inside it.'
86
98
  );
87
99
  this.name = 'ReentrantWriteLockError';
88
100
  }
@@ -218,7 +218,7 @@ export interface MakeTestServicesOptions<
218
218
  modelService?: (services: ServerSharedServices<TProject>) => ModelService<TAst, TDiagnostic, TTransfer>;
219
219
  /**
220
220
  * Framework {@link ModelServiceOptions} for the DEFAULT stub service, so a
221
- * test can reach an option-gated path (`serializeBuilds`) without supplying
221
+ * test can reach an option-gated path (`allowReentrantBuilds`) without supplying
222
222
  * a whole {@link modelService} factory. Ignored when `modelService` is
223
223
  * given — that factory owns its own construction.
224
224
  */
@@ -312,15 +312,15 @@ export function makeTestServices<
312
312
  ): TestServicesBundle<TAst, TDiagnostic, TTransfer, TProject, TTransferDiagnostic> {
313
313
  const documents = makeStubLangiumDocuments<TAst, TDiagnostic>(options.seedDocuments);
314
314
  const textDocuments = makeStubHydraniumTextDocuments();
315
- const workspaceLock = new HydraniumWorkspaceLock();
315
+ const logger = options.logger ?? new NoopLogger();
316
+ const clock = options.clock ?? new SystemClock();
317
+ const tracer = new DefaultTracer(logger, clock);
318
+ const workspaceLock = new HydraniumWorkspaceLock({ Clock: clock, Tracer: tracer });
316
319
  const documentBuilder = makeStubDocumentBuilder(workspaceLock);
317
320
  const selfSaveRegistry = makeStubSelfSaveRegistry();
318
321
  const fileSystem = makeStubWritableFileSystem(selfSaveRegistry);
319
322
  const projectManager = makeStubProjectManager<TProject>(options.seedProjects);
320
323
  const astDocumentManager = makeStubAstDocumentManager<TAst, TDiagnostic>(textDocuments, fileSystem, documents);
321
- const logger = options.logger ?? new NoopLogger();
322
- const clock = options.clock ?? new SystemClock();
323
- const tracer = new DefaultTracer(logger, clock);
324
324
  const documentUriPolicy = options.documentUriPolicy ?? new DefaultDocumentUriPolicy();
325
325
  const serviceRegistry = options.languages
326
326
  ? makeStubServiceRegistry(options.languages, { openLanguageIds: options.openLanguageIds })
@@ -53,7 +53,7 @@ export type StubModelService<
53
53
  * `makeTestServices` wires this stub automatically.
54
54
  *
55
55
  * `options` are the framework {@link ModelServiceOptions}, forwarded verbatim, so
56
- * a test can exercise an option-gated code path (`serializeBuilds`) without
56
+ * a test can exercise an option-gated code path (`allowReentrantBuilds`) without
57
57
  * hand-rolling a subclass just to reach the constructor.
58
58
  *
59
59
  * Adopters that need richer override behaviour — the framework's own extension