@hydranium/core 1.0.0-next.135 → 1.0.0-next.140

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 (36) hide show
  1. package/lib/documents/ast-document-manager.d.ts +96 -9
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +129 -20
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/langium/document-builder/document-builder.d.ts +65 -12
  6. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  7. package/lib/langium/document-builder/document-builder.js +106 -24
  8. package/lib/langium/document-builder/document-builder.js.map +1 -1
  9. package/lib/langium/model-service/client-session.d.ts +9 -1
  10. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  11. package/lib/langium/model-service/client-session.js +19 -3
  12. package/lib/langium/model-service/client-session.js.map +1 -1
  13. package/lib/langium/model-service/model-service.d.ts +16 -15
  14. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  15. package/lib/langium/model-service/model-service.js +4 -5
  16. package/lib/langium/model-service/model-service.js.map +1 -1
  17. package/lib/langium/workspace/write-lock-scope.d.ts +3 -2
  18. package/lib/langium/workspace/write-lock-scope.d.ts.map +1 -1
  19. package/lib/langium/workspace/write-lock-scope.js +3 -2
  20. package/lib/langium/workspace/write-lock-scope.js.map +1 -1
  21. package/lib/testing/stub-ast-document-manager.d.ts +1 -1
  22. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  23. package/lib/testing/stub-ast-document-manager.js +9 -0
  24. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  25. package/lib/testing/stub-document-builder.d.ts +7 -6
  26. package/lib/testing/stub-document-builder.d.ts.map +1 -1
  27. package/lib/testing/stub-document-builder.js +26 -0
  28. package/lib/testing/stub-document-builder.js.map +1 -1
  29. package/package.json +5 -5
  30. package/src/documents/ast-document-manager.ts +158 -25
  31. package/src/langium/document-builder/document-builder.ts +115 -23
  32. package/src/langium/model-service/client-session.ts +29 -4
  33. package/src/langium/model-service/model-service.ts +18 -17
  34. package/src/langium/workspace/write-lock-scope.ts +3 -2
  35. package/src/testing/stub-ast-document-manager.ts +21 -1
  36. package/src/testing/stub-document-builder.ts +36 -9
@@ -19,7 +19,6 @@ import {
19
19
  } from '@hydranium/protocol';
20
20
  import {
21
21
  type AstNode,
22
- type DocumentBuilder,
23
22
  DocumentState,
24
23
  type FileSystemProvider,
25
24
  type LangiumDocument,
@@ -83,7 +82,7 @@ import { TextDocumentIdentifier, type TextDocumentItem } from 'vscode-languagese
83
82
  import { type TextDocument } from 'vscode-languageserver-textdocument';
84
83
  import { URI } from '@hydranium/langium';
85
84
  import { type ServerSharedServices } from '../langium/module.js';
86
- import { labelPhaseListener } from '../langium/document-builder/index.js';
85
+ import { type HydraniumDocumentBuilder, labelPhaseListener } from '../langium/document-builder/index.js';
87
86
  import { UNKNOWN_CLIENT_ID } from './client-ids.js';
88
87
  import { type HydraniumTextDocuments } from './hydranium-text-documents.js';
89
88
  import { type SelfSaveRegistry } from './self-save-registry.js';
@@ -177,6 +176,17 @@ export type AstDocumentSavedEvent<TAst extends AstNode, TDiagnostic extends AstD
177
176
  AstDocument<TAst, TDiagnostic>
178
177
  >;
179
178
 
179
+ /**
180
+ * Why an update event fired and whose write it echoes, as
181
+ * {@link AstDocumentManager.attributeUpdate} answers for a document. The
182
+ * fields mean what they mean on `TransferUpdatedEvent`.
183
+ */
184
+ export interface UpdateAttribution {
185
+ reason: 'changed' | 'rebuilt';
186
+ sourceClientId: string;
187
+ causedBy: string;
188
+ }
189
+
180
190
  /** Construction options for {@link AstDocumentManager}. */
181
191
  export interface AstDocumentManagerOptions extends LogNameOptions {
182
192
  /**
@@ -227,6 +237,17 @@ export interface AstDocumentManager<TAst extends AstNode, TDiagnostic extends As
227
237
 
228
238
  /** Client id that authored the document's current version, or `undefined` for a framework-internal build. */
229
239
  getAuthor(document: LangiumDocument): string | undefined;
240
+
241
+ /**
242
+ * The reason, source and cause of an update event for `document` emitted
243
+ * now, from a phase listener of the build that carries it. Every head takes
244
+ * its update events' attribution from here, so they name the same client
245
+ * for one build. For a document a client opened, while rebuilds validate,
246
+ * the answer changes once its `Validated` listeners have all run: a later
247
+ * event at that version is `'rebuilt'`. `TransferDocumentUpdateReason`
248
+ * names the cases that fall back to the last update.
249
+ */
250
+ attributeUpdate(document: LangiumDocument): UpdateAttribution;
230
251
  }
231
252
 
232
253
  /**
@@ -253,7 +274,40 @@ export class DefaultAstDocumentManager<
253
274
  TAst extends AstNode,
254
275
  TDiagnostic extends AstDiagnostic = AstDiagnostic
255
276
  > implements AstDocumentManager<TAst, TDiagnostic> {
277
+ /** The last `DocumentBuilder.update`'s URIs; the reason for a document the store does not track. */
256
278
  protected lastUpdate?: UpdateInfo;
279
+ /**
280
+ * Canonical URIs a client has opened since the file was last deleted,
281
+ * whose versions the store keeps. One no client has opened keeps Langium's
282
+ * version `0` through every change of its content, so its version says
283
+ * nothing. Kept past the last close, since the store continues the version
284
+ * sequence on the next open; a delivery can land after that close.
285
+ */
286
+ protected readonly trackedUris = new Set<CanonicalUri>();
287
+ /**
288
+ * Per tracked URI, the version whose `Validated` listeners last all ran,
289
+ * which is the version an update event was delivered for. Dropped on
290
+ * deletion, so a file created again with the same text is changed again.
291
+ */
292
+ protected readonly deliveredVersions = new Map<CanonicalUri, number>();
293
+ /**
294
+ * Whose writes the build under way carries, set when it starts: the author
295
+ * of every open document's undelivered version, and the unknown-client id
296
+ * for a change no write explains, such as a deletion, a document no client
297
+ * has open, or a write that changed nothing. An undelivered version is one
298
+ * whose build has not validated it yet, so a write whose build was
299
+ * cancelled stays a cause of the build that takes over.
300
+ *
301
+ * Errs toward more than one cause, and so toward the unknown client, which
302
+ * a reader treats as foreign: an answer it repeats, never one it misses.
303
+ * A change no write explains counts for its own build only, so a build
304
+ * that takes over from a cancelled one loses it. One field for every build:
305
+ * a build started outside the workspace lock, such as a re-queue of an
306
+ * orphaned document, reads the causes of whichever build started last.
307
+ * An open document no build delivers, one a `shouldValidate` override
308
+ * skips, stays undelivered, so it is a cause of every build while open.
309
+ */
310
+ protected readonly buildCauses = new Set<string>();
257
311
 
258
312
  /** Per canonical URI, the newest save queued for it, which {@link coalesceSaves} lets older queued saves defer to. */
259
313
  protected readonly newestSaves = new Map<CanonicalUri, Promise<void>>();
@@ -262,7 +316,7 @@ export class DefaultAstDocumentManager<
262
316
  protected readonly textDocuments: HydraniumTextDocuments<TextDocument>;
263
317
  protected readonly fileSystemProvider: WritableFileSystemProvider;
264
318
  protected readonly langiumDocs: LangiumDocuments;
265
- protected readonly documentBuilder: DocumentBuilder;
319
+ protected readonly documentBuilder: HydraniumDocumentBuilder;
266
320
  protected readonly uriPolicy: DocumentUriPolicy;
267
321
  protected readonly tracer: Tracer;
268
322
 
@@ -277,19 +331,40 @@ export class DefaultAstDocumentManager<
277
331
  this.uriPolicy = services.workspace.DocumentUriPolicy;
278
332
  this.tracer = services.Tracer.for(options.logName ?? 'AstDocumentManager').trace('instantiated');
279
333
  this.coalesceSaves = options.coalesceSaves ?? false;
280
- this.textDocuments.onDidOpen(event =>
281
- this.open({ clientId: event.clientId, uri: event.document.uri, languageId: event.document.languageId })
282
- );
334
+ this.textDocuments.onDidOpen(event => {
335
+ this.trackedUris.add(this.uriPolicy.canonicalUri(event.document.uri));
336
+ return this.open({ clientId: event.clientId, uri: event.document.uri, languageId: event.document.languageId });
337
+ });
283
338
  this.textDocuments.onDidClose(event => this.close({ clientId: event.clientId, uri: event.document.uri }));
284
339
  this.documentBuilder.onUpdate((changed, deleted) => {
285
340
  this.lastUpdate = { changed, deleted };
341
+ this.buildCauses.clear();
342
+ for (const textDocument of this.textDocuments.all()) {
343
+ if (!this.isDelivered(textDocument.uri, textDocument.version)) {
344
+ this.buildCauses.add(this.textDocuments.getAuthor(textDocument.uri, textDocument.version) ?? UNKNOWN_CLIENT_ID);
345
+ }
346
+ }
347
+ const unexplained = changed.some(uri => {
348
+ const textDocument = this.textDocuments.get(uri.toString());
349
+ return textDocument === undefined || this.isDelivered(textDocument.uri, textDocument.version);
350
+ });
351
+ if (unexplained || deleted.length > 0) {
352
+ this.buildCauses.add(UNKNOWN_CLIENT_ID);
353
+ }
286
354
  // The only place a deletion is observable: `DocumentBuilder.update`
287
355
  // removes a deleted document before it builds anything, so no phase
288
356
  // listener ever sees it.
289
357
  for (const uri of deleted) {
358
+ const key = this.uriPolicy.canonicalUri(uri.toString());
359
+ this.trackedUris.delete(key);
360
+ this.deliveredVersions.delete(key);
290
361
  this.textDocuments.notifyDocumentDeleted(uri.toString());
291
362
  }
292
363
  });
364
+ // Once every listener ran, not in a listener of its own: listeners after
365
+ // it could still be skipped by a cancel, and the next build would then
366
+ // report as rebuilt a version they never delivered.
367
+ this.documentBuilder.onDocumentPhaseDelivered(DocumentState.Validated, (document, version) => this.markDelivered(document, version));
293
368
  // Content transitions for CLOSED documents bypass the text store: the
294
369
  // last-close revert and watched-file changes rebuild from disk into a
295
370
  // factory-fresh text document carrying the factory's own version. At the
@@ -381,21 +456,16 @@ export class DefaultAstDocumentManager<
381
456
  if (this.uriPolicy.canonicalUri(document.uri) !== target) {
382
457
  return;
383
458
  }
384
- // Presentation boundary: the event field is a concrete `string`, so an
385
- // unauthored (framework-rebuilt) document surfaces the readable default
386
- // rather than `undefined` to subscribers and logs.
387
- const sourceClientId = this.getAuthor(document) ?? UNKNOWN_CLIENT_ID;
459
+ // No `'deleted'` reason: `DocumentBuilder.update` drops a deleted
460
+ // document from `LangiumDocuments` before deriving the rebuild set
461
+ // from it, so a deleted URI is never built and never reaches this
462
+ // phase listener. A subscriber needing deletions has to be told on
463
+ // a channel that does not require a built document.
388
464
  const event: TransferUpdatedEvent<AstDocument<TAst, TDiagnostic>> = {
389
465
  document: this.toAstDocument(document),
390
- sourceClientId,
391
- // No `'deleted'` arm: `DocumentBuilder.update` drops a deleted
392
- // document from `LangiumDocuments` before deriving the rebuild set
393
- // from it, so a deleted URI is never built and never reaches this
394
- // phase listener. A subscriber needing deletions has to be told on
395
- // a channel that does not require a built document.
396
- reason: this.lastUpdate?.changed.some(changed => UriUtils.equals(changed, document.uri)) ? 'changed' : 'rebuilt'
466
+ ...this.attributeUpdate(document)
397
467
  };
398
- this.tracer.with(uri).trace(`emitUpdate start: source=${sourceClientId}, reason=${event.reason}`);
468
+ this.tracer.with(uri).trace(`emitUpdate start: source=${event.sourceClientId}, reason=${event.reason}, cause=${event.causedBy}`);
399
469
  listener(event);
400
470
  this.tracer.with(uri).trace('emitUpdate listener returned');
401
471
  };
@@ -404,18 +474,81 @@ export class DefaultAstDocumentManager<
404
474
 
405
475
  /**
406
476
  * The client that authored `document`'s current version, or `undefined` when
407
- * no client wrote it — a framework-internal rebuild (workspace startup, a
408
- * cascade relink, a `didClose`-reload) or a genuine author gap. Honest about
409
- * absence so a routing consumer tests `undefined` directly rather than
410
- * against a sentinel; the presentation
411
- * default ({@link UNKNOWN_CLIENT_ID}) is applied at the boundary that needs a
412
- * concrete value (the `onUpdate` event's `sourceClientId`, the data-server's
413
- * `resolveSourceClientId`).
477
+ * no client wrote it: a document no client has open. A rebuild that keeps
478
+ * the version keeps its author, so this does not say who caused a build;
479
+ * {@link attributeUpdate} does.
480
+ * Honest about absence so a routing consumer tests `undefined` directly
481
+ * rather than against a sentinel; the presentation default
482
+ * ({@link UNKNOWN_CLIENT_ID}) is applied where an event needs a value.
414
483
  */
415
484
  getAuthor(document: LangiumDocument): string | undefined {
416
485
  return this.textDocuments.getAuthor(document.textDocument.uri, document.textDocument.version);
417
486
  }
418
487
 
488
+ /**
489
+ * `'changed'` for the first delivery of the document's version, crediting
490
+ * its author; `'rebuilt'` for a later one, crediting nobody, since it echoes
491
+ * no write, and naming the build's single cause if it has one.
492
+ *
493
+ * A document the rule does not apply to (see {@link isTracked}) falls back
494
+ * to the last update: it is `'changed'` when its URI was passed to
495
+ * `DocumentBuilder.update`. An open document that a validating build skips,
496
+ * through a `shouldValidate` override, is never delivered, so each of its
497
+ * events is its author's change.
498
+ */
499
+ attributeUpdate(document: LangiumDocument): UpdateAttribution {
500
+ const key = this.uriPolicy.canonicalUri(document.uri.toString());
501
+ const changed = this.isTracked(key)
502
+ ? this.deliveredVersions.get(key) !== document.textDocument.version
503
+ : this.lastUpdate?.changed.some(uri => UriUtils.equals(uri, document.uri)) === true;
504
+ if (changed) {
505
+ // Presentation boundary: the event field is a concrete `string`, so an
506
+ // unauthored document surfaces the readable default rather than
507
+ // `undefined` to subscribers and logs.
508
+ const author = this.getAuthor(document) ?? UNKNOWN_CLIENT_ID;
509
+ return { reason: 'changed', sourceClientId: author, causedBy: author };
510
+ }
511
+ const [cause] = this.buildCauses;
512
+ return { reason: 'rebuilt', sourceClientId: UNKNOWN_CLIENT_ID, causedBy: this.buildCauses.size === 1 ? cause : UNKNOWN_CLIENT_ID };
513
+ }
514
+
515
+ /**
516
+ * Whether the version rule applies to `uri`: the store keeps its version,
517
+ * and rebuilds validate. With `updateBuildOptions.validation` off no version
518
+ * is ever delivered, and the rule would report every event of an open
519
+ * document as its author's change.
520
+ */
521
+ protected isTracked(uri: CanonicalUri): boolean {
522
+ return this.keepsVersion(uri) && Boolean(this.documentBuilder.updateBuildOptions.validation);
523
+ }
524
+
525
+ /**
526
+ * Whether the store keeps `uri`'s version: a client opened it since its
527
+ * file was last deleted, or has it open still, as an editor keeps a
528
+ * deleted file's buffer.
529
+ */
530
+ protected keepsVersion(uri: CanonicalUri): boolean {
531
+ return this.trackedUris.has(uri) || this.textDocuments.isOpen(uri);
532
+ }
533
+
534
+ /**
535
+ * Record that `version` of `document` was delivered, when the store tracks
536
+ * it. The version the listeners saw, not the document's current one: a
537
+ * write during them has moved that on, and marking it would report the
538
+ * write's own build as rebuilt.
539
+ */
540
+ protected markDelivered(document: LangiumDocument, version: number): void {
541
+ const key = this.uriPolicy.canonicalUri(document.uri.toString());
542
+ if (this.keepsVersion(key)) {
543
+ this.deliveredVersions.set(key, version);
544
+ }
545
+ }
546
+
547
+ /** Whether `version` of `uri` was delivered. */
548
+ protected isDelivered(uri: string, version: number): boolean {
549
+ return this.deliveredVersions.get(this.uriPolicy.canonicalUri(uri)) === version;
550
+ }
551
+
419
552
  async open(args: OpenModelArgs): Promise<Disposable> {
420
553
  // `open()` is the RPC-level "ensure this document is loaded" call. It is issued
421
554
  // by a session opening the document, including by every additional client
@@ -15,16 +15,18 @@ import {
15
15
  type DocumentPhaseListener,
16
16
  DocumentState,
17
17
  type LangiumDocument,
18
+ MultiMap,
18
19
  OperationCancelled,
19
20
  type URI,
20
21
  UriUtils,
22
+ type WorkspaceLock,
21
23
  interruptAndCheck,
22
24
  isOperationCancelled
23
25
  } from '@hydranium/langium';
24
26
  // `Diagnostic` as a VALUE: `renderDiagnostics` needs its `getMessageString`
25
27
  // namespace helper to read the `string | MarkupContent` union without
26
28
  // restating it.
27
- import { CancellationToken, Diagnostic } from 'vscode-languageserver-protocol';
29
+ import { CancellationToken, Diagnostic, Disposable } from 'vscode-languageserver-protocol';
28
30
  import { type LogNameOptions } from '../diagnostics/logger.js';
29
31
  import type { MessageRenderer } from '../../messages/renderer.js';
30
32
  import { CST_REHYDRATION_RESET_STATE, isCstShed } from '../residency/cst-residency-service.js';
@@ -127,7 +129,8 @@ export interface DocumentBuilderOptions extends LogNameOptions {
127
129
  * Extends Langium's {@link DefaultDocumentBuilder} with:
128
130
  *
129
131
  * - **Bug-fixes** (always on): an {@link awaitDocumentState} that waits where
130
- * the default rejects, a {@link prepareBuild} that keeps a cancelled
132
+ * the default rejects (re-queuing, under the workspace lock, a document no
133
+ * build will carry), a {@link prepareBuild} that keeps a cancelled
131
134
  * non-validating build from suppressing validation, and a
132
135
  * {@link shouldRelink} that never judges a document unaffected on an index
133
136
  * that does not describe it.
@@ -173,6 +176,8 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
173
176
  protected readonly uriPolicy: DocumentUriPolicy;
174
177
  protected readonly clock: Clock;
175
178
  protected readonly messageRenderer: MessageRenderer;
179
+ /** The lock a re-queued build takes; see {@link requeueOrphaned}. */
180
+ protected readonly workspaceLock: WorkspaceLock;
176
181
  /** Narrower handle on the same registry as the inherited `serviceRegistry`, for {@link ExtendedServiceRegistry.registrations}. */
177
182
  protected readonly languageRegistry: ExtendedServiceRegistry;
178
183
  protected languageFileExtensions: string[] = [];
@@ -199,6 +204,8 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
199
204
  * that carries it is already gone by the time its successor is opened.
200
205
  */
201
206
  protected lastCancelledTraceId?: number;
207
+ /** Registered through {@link onDocumentPhaseDelivered}, by phase. */
208
+ protected readonly documentPhaseDeliveredListeners = new MultiMap<DocumentState, (document: LangiumDocument, version: number) => void>();
202
209
 
203
210
  constructor(services: ServerSharedServicesMinimal, options: DocumentBuilderOptions = {}) {
204
211
  super(services);
@@ -206,6 +213,7 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
206
213
  this.uriPolicy = services.workspace.DocumentUriPolicy;
207
214
  this.clock = services.Clock;
208
215
  this.messageRenderer = services.MessageRenderer;
216
+ this.workspaceLock = services.workspace.WorkspaceLock;
209
217
  this.tracer = services.Tracer.for(options.logName ?? 'DocumentBuilder').trace('instantiated');
210
218
  this.logLevel = options.logLevel ?? 'debug';
211
219
  this.loggedPhases = options.loggedPhases ?? DEFAULT_LOGGED_PHASES;
@@ -238,6 +246,24 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
238
246
  this.pendingUpdateReason = reason;
239
247
  }
240
248
 
249
+ /**
250
+ * Call `listener` with a document once every {@link onDocumentPhase}
251
+ * listener of `state` ran for it and none was skipped by cancellation, and
252
+ * with the text version the phase listeners were called at. A write during
253
+ * them moves the document's own version on, past what they saw.
254
+ *
255
+ * Only here is it known what the phase listeners delivered. Langium sets the
256
+ * document's state before it notifies, and a cancel between two listeners
257
+ * skips the rest, leaving the document at the phase with those listeners
258
+ * never run and nothing to run them later. A build-phase listener comes too
259
+ * late: a cancel after this document but before the batch ends skips it,
260
+ * although every listener of this document ran.
261
+ */
262
+ onDocumentPhaseDelivered(state: DocumentState, listener: (document: LangiumDocument, version: number) => void): Disposable {
263
+ this.documentPhaseDeliveredListeners.add(state, listener);
264
+ return Disposable.create(() => this.documentPhaseDeliveredListeners.delete(state, listener));
265
+ }
266
+
241
267
  /**
242
268
  * Diagnostic snapshot for use in timeout/error messages around document
243
269
  * state.
@@ -292,13 +318,24 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
292
318
  * {@link MAX_STALLED_REQUEUES} builds that fail to advance the document, so a
293
319
  * document the builder will never carry to `state` degrades to a pending wait
294
320
  * plus a warning rather than an endless build loop.
321
+ *
322
+ * A wait for `Validated` on a document that a validating build skips
323
+ * resolves once that build has indexed its references, without diagnostics:
324
+ * see {@link skipsValidation}. Waiting on would never end, and re-queuing
325
+ * would skip it again.
326
+ *
327
+ * A re-queued build takes the {@link WorkspaceLock} (see
328
+ * {@link requeueOrphaned}), so a caller that awaits this wait while it holds
329
+ * the lock, inside a read or write action, deadlocks on a document that needs
330
+ * a re-queue: the build waits for that action to end. Await it outside the
331
+ * lock.
295
332
  */
296
333
  protected override awaitDocumentState(state: DocumentState, uri: URI, cancelToken: CancellationToken): Promise<URI> {
297
334
  const document = this.langiumDocuments.getDocument(uri);
298
335
  if (!document) {
299
336
  return super.awaitDocumentState(state, uri, cancelToken);
300
337
  }
301
- if (document.state >= state) {
338
+ if (document.state >= state || (state === DocumentState.Validated && this.skipsValidation(document))) {
302
339
  return Promise.resolve(uri);
303
340
  }
304
341
  return new Promise<URI>((resolve, reject) => {
@@ -338,8 +375,22 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
338
375
  }
339
376
  }, 'awaitDocumentState')
340
377
  );
378
+ // The phase after which a validating build either validates the
379
+ // document or has skipped it.
380
+ const skipDisposable =
381
+ state === DocumentState.Validated
382
+ ? this.onDocumentPhase(
383
+ DocumentState.IndexedReferences,
384
+ labelPhaseListener((doc: LangiumDocument): void => {
385
+ if (UriUtils.equals(doc.uri, uri) && this.skipsValidation(doc)) {
386
+ cleanup();
387
+ resolve(doc.uri);
388
+ }
389
+ }, 'awaitDocumentState.skipsValidation')
390
+ )
391
+ : Disposable.create(() => undefined);
341
392
  const buildDisposable = this.onBuildPhase(DocumentState.Validated, () => {
342
- if (document.state >= state) {
393
+ if (document.state >= state || (state === DocumentState.Validated && this.skipsValidation(document))) {
343
394
  cleanup();
344
395
  resolve(uri);
345
396
  } else {
@@ -353,6 +404,7 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
353
404
  });
354
405
  const cleanup = (): void => {
355
406
  phaseDisposable.dispose();
407
+ skipDisposable.dispose();
356
408
  buildDisposable.dispose();
357
409
  cancelDisposable.dispose();
358
410
  };
@@ -366,6 +418,23 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
366
418
  });
367
419
  }
368
420
 
421
+ /**
422
+ * Whether the build that carries or last carried `document` has indexed its
423
+ * references, asks for validation, and still skips it, which is an override
424
+ * of `shouldValidate` excluding it. A build that does not ask for
425
+ * validation, such as the workspace's initial one, is not a skip: the next
426
+ * validating build validates the document. Nor is a document below
427
+ * `IndexedReferences`: the build writes its options before the first phase,
428
+ * so the document would pass for skipped before it is even parsed.
429
+ */
430
+ protected skipsValidation(document: LangiumDocument): boolean {
431
+ return (
432
+ document.state >= DocumentState.IndexedReferences &&
433
+ Boolean(this.getBuildOptions(document).validation) &&
434
+ !this.shouldValidate(document)
435
+ );
436
+ }
437
+
369
438
  /**
370
439
  * Whether no build will advance `document` to `state`, so a wait on it can
371
440
  * only be resolved by starting one.
@@ -395,14 +464,33 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
395
464
  * notification, so awaiting here would invert the dependency. A rejection is
396
465
  * logged rather than swallowed — it leaves the waiter pending until its own
397
466
  * cancellation token fires, which is worth a line in the log.
467
+ *
468
+ * The build is an ordinary {@link WorkspaceLock} write, entered from a read.
469
+ * The read waits until no write runs or is queued, so the build neither runs
470
+ * beside a locked one nor cancels the build a re-queue is fired from. Unlocked,
471
+ * it runs beside a locked build that cannot cancel it: both validate the same
472
+ * version and deliver it twice. Taking the write directly, without the read,
473
+ * cancels the running build and skips the rest of its build-phase listeners.
474
+ * The read must not await the write, which queues behind that read.
475
+ *
476
+ * A write that ran while the read waited may have carried the document to
477
+ * `state` already, and a second build would deliver that version again; the
478
+ * read skips the write then.
398
479
  */
399
480
  protected requeueOrphaned(document: LangiumDocument, state: DocumentState, reason: string): void {
400
481
  const tracer = this.tracer.withUri(document.uri.toString());
401
482
  tracer.info(`Re-queuing orphaned document (at '${DocumentState[document.state]}', needs '${DocumentState[state]}'): ${reason}`);
402
- this.update([document.uri], []).catch((err: unknown) => {
403
- if (!isOperationCancelled(err)) {
404
- tracer.error(`Re-queue build failed: ${err instanceof Error ? err.message : String(err)}`);
483
+ void this.workspaceLock.read(() => {
484
+ if (document.state >= state) {
485
+ return;
405
486
  }
487
+ this.workspaceLock
488
+ .write(token => this.update([document.uri], [], token))
489
+ .catch((err: unknown) => {
490
+ if (!isOperationCancelled(err)) {
491
+ tracer.error(`Re-queue build failed: ${err instanceof Error ? err.message : String(err)}`);
492
+ }
493
+ });
406
494
  });
407
495
  }
408
496
 
@@ -727,19 +815,20 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
727
815
  * readable by the time Langium's `update` consults `shouldRelink`.
728
816
  *
729
817
  * Teardown is preemption-correct, which is the reason this is framework code
730
- * rather than a recipe. Langium's write mutex cancels an in-flight build when
731
- * a later one arrives, so two sessions overlap: the successor installs itself
732
- * as {@link activeSession} while the predecessor is still unwinding, and the
733
- * predecessor's `finally` runs LAST. Clearing unconditionally there would
734
- * discard the winner's state mid-build. Only the session that is still
735
- * current clears — and the check is reference equality on the session object,
818
+ * rather than a recipe. A build outside the workspace lock overlaps another:
819
+ * the later one installs itself as {@link activeSession} while the earlier
820
+ * one is still running, and the earlier one's `finally` runs LAST. Clearing
821
+ * unconditionally there would discard the later build's state mid-build.
822
+ * Only the session that is still current clears — and the check is
823
+ * reference equality on the session object,
736
824
  * not on {@link BuildSession.traceId}, which is `undefined` for every build
737
825
  * whenever the timing level is suppressed and would compare equal to itself
738
826
  * across two different builds.
739
827
  *
740
- * Re-entrancy is not hypothetical even without an adopter: {@link
741
- * requeueOrphaned} calls `update` from inside a wait, while a build may be
742
- * running.
828
+ * Builds outside the lock are not hypothetical even without an adopter: the
829
+ * model service with `serializeBuilds` off starts one. Two locked builds
830
+ * never overlap, since the lock starts a write only once the write it
831
+ * cancelled has unwound.
743
832
  */
744
833
  protected runInSession(context: BuildSessionContext, label: string, body: () => Promise<void>): Promise<void> {
745
834
  // Read before installing the new session: the id being superseded belongs
@@ -904,7 +993,7 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
904
993
  }
905
994
 
906
995
  // ============================================================
907
- // Logging — slow-listener breakdown on notifyDocumentPhase
996
+ // notifyDocumentPhase — the delivery hook and the slow-listener breakdown
908
997
  // ============================================================
909
998
 
910
999
  override async notifyDocumentPhase(document: LangiumDocument, state: DocumentState, cancelToken: CancellationToken): Promise<void> {
@@ -922,13 +1011,8 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
922
1011
  this.dedupeDiagnostics(document);
923
1012
  this.renderDiagnostics(document);
924
1013
  }
925
- if (this.logLevel === 'off') {
926
- return super.notifyDocumentPhase(document, state, cancelToken);
927
- }
928
1014
  const listeners = this.documentPhaseListeners.get(state).slice();
929
- if (listeners.length === 0) {
930
- return;
931
- }
1015
+ const version = document.textDocument.version;
932
1016
  const perListenerMs: number[] = [];
933
1017
  let cancelledListeners = 0;
934
1018
  const { elapsedMs: totalMs } = await this.clock.measure(async () => {
@@ -947,6 +1031,14 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
947
1031
  perListenerMs.push(elapsedMs);
948
1032
  }
949
1033
  });
1034
+ if (cancelledListeners === 0) {
1035
+ for (const delivered of this.documentPhaseDeliveredListeners.get(state).slice()) {
1036
+ delivered(document, version);
1037
+ }
1038
+ }
1039
+ if (this.logLevel === 'off' || listeners.length === 0) {
1040
+ return;
1041
+ }
950
1042
  if (cancelledListeners > 0) {
951
1043
  this.tracer
952
1044
  .withUri(document.uri.toString())
@@ -18,12 +18,13 @@ import {
18
18
  type Tracer,
19
19
  type TransferElement
20
20
  } from '@hydranium/protocol';
21
- import { type AstNode, UriUtils } from '@hydranium/langium';
21
+ import { type AstNode, type DocumentBuilder, DocumentState, UriUtils } from '@hydranium/langium';
22
22
  import { type CancellationToken, type Disposable } from 'vscode-languageserver';
23
23
  import { type AstDocument } from '../../documents/ast-document-manager.js';
24
24
  import { DocumentNotOpenError, SessionClosedError } from '../../documents/client-session-errors.js';
25
25
  import { type OpenOptions, type SessionEndCause } from '../../documents/client-session-registry.js';
26
26
  import { type LogNameOptions } from '../diagnostics/logger.js';
27
+ import { IntegrityService } from '../integrity/integrity-service.js';
27
28
  import { type ServerSharedServices } from '../module.js';
28
29
  import { type AstDiagnostic } from '../validation/document-validator.js';
29
30
  import { type ModelService } from './model-service.js';
@@ -90,11 +91,19 @@ export interface ClientSession<
90
91
  * URI at most one succeeds.
91
92
  */
92
93
  create(uri: string, text: string): Promise<void>;
94
+ /**
95
+ * Write `args.model` into a document this session has open. Resolves to the
96
+ * rebuilt document once it is validated, in whichever build carried the
97
+ * write, so the answer holds every diagnostic its update event does: that
98
+ * event names this session, and a client dropping its own echo would
99
+ * otherwise never see them.
100
+ */
93
101
  update(args: ClientSessionWriteArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
94
102
  /**
95
103
  * Write several documents this session has open, all or none: a
96
104
  * `ConflictError` or `DocumentNotOpenError` for any of them is thrown before
97
- * any text applies. Resolves to the rebuilt documents, in the order given.
105
+ * any text applies. Resolves to the rebuilt documents, in the order given,
106
+ * each validated as {@link update}'s is.
98
107
  */
99
108
  updateAll(args: ClientSessionUpdateAllArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>[]>;
100
109
  /** Write `args.model` as {@link update} does, then persist the document. */
@@ -120,6 +129,21 @@ export interface ClientSession<
120
129
  dispose(cause?: SessionEndCause): void;
121
130
  }
122
131
 
132
+ /**
133
+ * The phase a session write waits for before it answers: `Validated`, or
134
+ * {@link IntegrityService.SettledState} when rebuilds do not validate, since
135
+ * no build then reaches `Validated` and the wait would never end. Read from
136
+ * `updateBuildOptions`, which every rebuild takes; a document a
137
+ * `shouldValidate` override skips is the builder's to resolve.
138
+ *
139
+ * Waiting for the write's own build is not enough: a later write cancels it,
140
+ * and the build that takes over validates the document after the answer was
141
+ * taken.
142
+ */
143
+ function answerState(builder: DocumentBuilder): DocumentState {
144
+ return builder.updateBuildOptions.validation ? DocumentState.Validated : IntegrityService.SettledState;
145
+ }
146
+
123
147
  /**
124
148
  * The framework's {@link ClientSession}, built by
125
149
  * {@link DefaultClientSessionFactory}.
@@ -308,7 +332,7 @@ export class DefaultClientSession<
308
332
  // An override that awaits before calling the base can outlast the build
309
333
  // this write already has, and then builds it twice; see
310
334
  // `ModelService.rebuild`.
311
- const doc = await run('rebuild', () => service.rebuild(uri, undefined, cancelToken));
335
+ const doc = await run('rebuild', () => service.rebuild(uri, answerState(this.services.workspace.DocumentBuilder), cancelToken));
312
336
  const finalVersion = textDocuments.version(uri);
313
337
  if (finalVersion > appliedVersion) {
314
338
  this.tracer.debug(`Update to v${appliedVersion} ready at v${finalVersion} (changed again before it settled)`);
@@ -363,7 +387,8 @@ export class DefaultClientSession<
363
387
  check();
364
388
  const applied = uris.map((uri, i) => this.services.workspace.AstDocumentManager.update(uri, texts[i], this.clientId));
365
389
  await Promise.all(applied);
366
- return Promise.all(uris.map(uri => service.rebuild(uri, undefined, cancelToken)));
390
+ const state = answerState(this.services.workspace.DocumentBuilder);
391
+ return Promise.all(uris.map(uri => service.rebuild(uri, state, cancelToken)));
367
392
  }
368
393
 
369
394
  /**