@hydranium/core 1.0.0-next.187 → 1.0.0-next.188

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 (96) hide show
  1. package/lib/documents/ast-document-manager.d.ts +26 -44
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +20 -50
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/hydranium-text-documents.d.ts +87 -30
  6. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  7. package/lib/documents/hydranium-text-documents.js +201 -37
  8. package/lib/documents/hydranium-text-documents.js.map +1 -1
  9. package/lib/documents/index.d.ts +2 -0
  10. package/lib/documents/index.d.ts.map +1 -1
  11. package/lib/documents/index.js +2 -0
  12. package/lib/documents/index.js.map +1 -1
  13. package/lib/documents/model-ledger.d.ts +47 -0
  14. package/lib/documents/model-ledger.d.ts.map +1 -0
  15. package/lib/documents/model-ledger.js +30 -0
  16. package/lib/documents/model-ledger.js.map +1 -0
  17. package/lib/documents/version-sync-service.d.ts +134 -0
  18. package/lib/documents/version-sync-service.d.ts.map +1 -0
  19. package/lib/documents/version-sync-service.js +171 -0
  20. package/lib/documents/version-sync-service.js.map +1 -0
  21. package/lib/langium/bootstrap.d.ts.map +1 -1
  22. package/lib/langium/bootstrap.js +16 -0
  23. package/lib/langium/bootstrap.js.map +1 -1
  24. package/lib/langium/document-builder/document-builder.d.ts +31 -25
  25. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  26. package/lib/langium/document-builder/document-builder.js +43 -39
  27. package/lib/langium/document-builder/document-builder.js.map +1 -1
  28. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  29. package/lib/langium/integrity/integrity-service.js +6 -15
  30. package/lib/langium/integrity/integrity-service.js.map +1 -1
  31. package/lib/langium/model-service/client-session.d.ts +14 -10
  32. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  33. package/lib/langium/model-service/client-session.js +18 -15
  34. package/lib/langium/model-service/client-session.js.map +1 -1
  35. package/lib/langium/model-service/model-service.d.ts +91 -41
  36. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  37. package/lib/langium/model-service/model-service.js +127 -50
  38. package/lib/langium/model-service/model-service.js.map +1 -1
  39. package/lib/langium/module.d.ts +6 -0
  40. package/lib/langium/module.d.ts.map +1 -1
  41. package/lib/langium/module.js +5 -1
  42. package/lib/langium/module.js.map +1 -1
  43. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  44. package/lib/langium/residency/cst-residency-service.js +10 -1
  45. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  46. package/lib/langium/shared-services.d.ts +6 -2
  47. package/lib/langium/shared-services.d.ts.map +1 -1
  48. package/lib/langium/shared-services.js.map +1 -1
  49. package/lib/langium/transfer/transfer-encoder.d.ts +19 -10
  50. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  51. package/lib/langium/transfer/transfer-encoder.js +8 -7
  52. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  53. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +31 -1
  54. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
  55. package/lib/langium/workspace/hydranium-langium-document-factory.js +54 -0
  56. package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
  57. package/lib/langium/workspace/langium-documents.d.ts +7 -0
  58. package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
  59. package/lib/langium/workspace/langium-documents.js +10 -0
  60. package/lib/langium/workspace/langium-documents.js.map +1 -1
  61. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  62. package/lib/lsp/hydranium-document-update-handler.js +1 -0
  63. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  64. package/lib/testing/make-test-services.d.ts +7 -0
  65. package/lib/testing/make-test-services.d.ts.map +1 -1
  66. package/lib/testing/make-test-services.js +10 -2
  67. package/lib/testing/make-test-services.js.map +1 -1
  68. package/lib/testing/stub-ast-document-manager.d.ts +3 -2
  69. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  70. package/lib/testing/stub-ast-document-manager.js +7 -1
  71. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  72. package/lib/testing/stub-hydranium-text-documents.d.ts +2 -2
  73. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  74. package/lib/testing/stub-hydranium-text-documents.js +6 -1
  75. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  76. package/package.json +5 -5
  77. package/src/documents/ast-document-manager.ts +45 -77
  78. package/src/documents/hydranium-text-documents.ts +225 -46
  79. package/src/documents/index.ts +2 -0
  80. package/src/documents/model-ledger.ts +66 -0
  81. package/src/documents/version-sync-service.ts +267 -0
  82. package/src/langium/bootstrap.ts +16 -0
  83. package/src/langium/document-builder/document-builder.ts +76 -37
  84. package/src/langium/integrity/integrity-service.ts +6 -15
  85. package/src/langium/model-service/client-session.ts +29 -21
  86. package/src/langium/model-service/model-service.ts +182 -64
  87. package/src/langium/module.ts +9 -1
  88. package/src/langium/residency/cst-residency-service.ts +10 -1
  89. package/src/langium/shared-services.ts +6 -2
  90. package/src/langium/transfer/transfer-encoder.ts +25 -18
  91. package/src/langium/workspace/hydranium-langium-document-factory.ts +83 -0
  92. package/src/langium/workspace/langium-documents.ts +11 -0
  93. package/src/lsp/hydranium-document-update-handler.ts +3 -0
  94. package/src/testing/make-test-services.ts +15 -2
  95. package/src/testing/stub-ast-document-manager.ts +10 -2
  96. package/src/testing/stub-hydranium-text-documents.ts +8 -3
@@ -0,0 +1,267 @@
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 { DocumentState, type LangiumDocument, type URI } from '@hydranium/langium';
11
+ import { type Tracer, UNRECORDED_VERSION } from '@hydranium/protocol';
12
+ import { Disposable, Emitter, type Event } from 'vscode-languageserver-protocol';
13
+ import { type LogNameOptions } from '../langium/diagnostics/logger.js';
14
+ import { type ServerSharedServices } from '../langium/module.js';
15
+ import { HydraniumWorkspaceLock } from '../langium/workspace/hydranium-workspace-lock.js';
16
+ import { HydraniumTextDocuments } from './hydranium-text-documents.js';
17
+
18
+ /** A build {@link VersionSyncService.requestRecoveryBuild} schedules. */
19
+ export interface RecoveryBuildRequest {
20
+ /** Remove the document instead of building it. */
21
+ readonly deleted?: boolean;
22
+ /** Staged for the build, as `HydraniumDocumentBuilder.scheduleUpdate` takes it. */
23
+ readonly reason?: string;
24
+ /** Asked again inside the lock, where a build that ran meanwhile may have made the request moot. */
25
+ readonly stillNeeded: () => boolean;
26
+ /** Build even while a {@link VersionSyncService.registerDeferredBuilds} check holds the URI back. */
27
+ readonly ignoreDeferred?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Keeps each workspace root's `ModelLedger` record in step with the
32
+ * text store, and owns every build that syncs a root to its text or recovers
33
+ * one a build left behind.
34
+ */
35
+ export interface VersionSyncService {
36
+ /**
37
+ * Report a root a factory, registry, integrity repair or other producer
38
+ * made. Below `Parsed` it is marked a placeholder. Otherwise `origin.version`,
39
+ * the store's version read before the parse, is recorded; a registered
40
+ * document is then reconciled with the store, re-versioned and recorded at
41
+ * the store's version of its text, built if behind, and announced on
42
+ * {@link onDidRecordModel}. `origin.text` stands for a root whose CST is not
43
+ * the text it describes.
44
+ */
45
+ modelProduced(document: LangiumDocument, origin?: { version?: number; text?: string }): void;
46
+ /**
47
+ * Build `uri` from a `WorkspaceLock` read, so the build queues behind a
48
+ * running one rather than cancelling it. Requests made while one for `uri`
49
+ * is pending share its next build. Resolves `true` once a build that carried
50
+ * this request completed, or once a check before a build found it no longer
51
+ * needed or deferred. A build a later write cancelled does not count: the
52
+ * request rides the next one. Resolves `false` once builds carrying this
53
+ * request have failed more often than it may retry, so a caller waiting on
54
+ * the build can stop. Never rejects.
55
+ */
56
+ requestRecoveryBuild(uri: URI, request: RecoveryBuildRequest): Promise<boolean>;
57
+ /**
58
+ * Build `uri` unless it is {@link isSyncedTo synced to} `textVersion`, asked
59
+ * again inside the lock. `undefined` when no build is needed, else the
60
+ * {@link requestRecoveryBuild} answer.
61
+ */
62
+ syncTo(uri: URI, textVersion: number): Promise<boolean> | undefined;
63
+ /**
64
+ * Whether `uri`'s root was parsed from text at `textVersion` or later; requests
65
+ * nothing. A root with no recorded version or a placeholder counts as synced,
66
+ * since no build records a version for it, and so does a URI with no document.
67
+ */
68
+ isSyncedTo(uri: URI, textVersion: number): boolean;
69
+ /**
70
+ * Register `isDeferred`, which answers whether its caller holds back a build
71
+ * of a URI it will request later, as the update handler does for a debounced
72
+ * editor change. A caller that drops a held change has to leave the URI to
73
+ * another build.
74
+ */
75
+ registerDeferredBuilds(isDeferred: (uri: URI) => boolean): Disposable;
76
+ /**
77
+ * Fires for every reconciled root. A cancel cannot skip it, unlike a
78
+ * `Parsed` phase listener. It can fire inside a build's write lock, so a
79
+ * listener that requests a build must not await it.
80
+ */
81
+ readonly onDidRecordModel: Event<LangiumDocument>;
82
+ }
83
+
84
+ /** A request waiting in {@link PendingRecoveryBuilds}, with the failed builds that carried it. */
85
+ export interface QueuedRecoveryBuild {
86
+ readonly request: RecoveryBuildRequest;
87
+ failures: number;
88
+ readonly answer: (built: boolean) => void;
89
+ }
90
+
91
+ /** The requests waiting on one URI's builds in {@link DefaultVersionSyncService}. */
92
+ export interface PendingRecoveryBuilds {
93
+ readonly queued: QueuedRecoveryBuild[];
94
+ }
95
+
96
+ /**
97
+ * Collaborators are resolved per call: the registry and its document factory,
98
+ * which construct each other, report roots to this service.
99
+ */
100
+ export class DefaultVersionSyncService implements VersionSyncService {
101
+ protected readonly tracer: Tracer;
102
+ protected readonly recordedEmitter = new Emitter<LangiumDocument>();
103
+ protected readonly deferredBuildChecks = new Set<(uri: URI) => boolean>();
104
+ protected readonly pendingBuilds = new Map<string, PendingRecoveryBuilds>();
105
+ /** Retries of each request after a failed build before it is answered `false`. */
106
+ protected readonly maxBuildRetries: number = 1;
107
+
108
+ constructor(
109
+ protected readonly services: ServerSharedServices,
110
+ options: LogNameOptions = {}
111
+ ) {
112
+ this.tracer = services.Tracer.for(options.logName ?? 'VersionSyncService').trace('instantiated');
113
+ }
114
+
115
+ get onDidRecordModel(): Event<LangiumDocument> {
116
+ return this.recordedEmitter.event;
117
+ }
118
+
119
+ modelProduced(document: LangiumDocument, origin: { version?: number; text?: string } = {}): void {
120
+ const workspace = this.services.workspace;
121
+ const ledger = workspace.ModelLedger;
122
+ const root = document.parseResult.value;
123
+ if (document.state < DocumentState.Parsed) {
124
+ ledger.markPlaceholder(root);
125
+ return;
126
+ }
127
+ if (origin.version !== undefined) {
128
+ ledger.record(root, origin.version, origin.text);
129
+ }
130
+ const store = workspace.TextDocuments;
131
+ // Unregistered, it is a probe or a load whose registration reports it again.
132
+ if (!(store instanceof HydraniumTextDocuments) || workspace.LangiumDocuments.getDocument(document.uri) !== document) {
133
+ return;
134
+ }
135
+ const uri = document.uri.toString();
136
+ // Against the parsed text: the text document may already hold a newer edit.
137
+ const version = store.reconcileExternalContent(uri, origin.text ?? this.parsedText(document));
138
+ if (version !== undefined) {
139
+ if (document.textDocument.version !== version) {
140
+ // An empty-changes update only re-stamps the version.
141
+ store.update(document.textDocument, [], version);
142
+ }
143
+ ledger.record(root, version, origin.text);
144
+ }
145
+ void this.syncTo(document.uri, store.version(uri));
146
+ this.recordedEmitter.fire(document);
147
+ }
148
+
149
+ syncTo(uri: URI, textVersion: number): Promise<boolean> | undefined {
150
+ const behind = (): boolean => !this.isSyncedTo(uri, textVersion);
151
+ return behind() ? this.requestRecoveryBuild(uri, { stillNeeded: behind }) : undefined;
152
+ }
153
+
154
+ requestRecoveryBuild(uri: URI, request: RecoveryBuildRequest): Promise<boolean> {
155
+ return new Promise<boolean>(answer => {
156
+ const key = uri.toString();
157
+ const queued: QueuedRecoveryBuild = { request, failures: 0, answer };
158
+ const pending = this.pendingBuilds.get(key);
159
+ if (pending) {
160
+ // Not dropped: the pending build may have read the text before this request's change.
161
+ pending.queued.push(queued);
162
+ return;
163
+ }
164
+ const entry: PendingRecoveryBuilds = { queued: [queued] };
165
+ this.pendingBuilds.set(key, entry);
166
+ void this.runBuilds(key, uri, entry);
167
+ });
168
+ }
169
+
170
+ registerDeferredBuilds(isDeferred: (uri: URI) => boolean): Disposable {
171
+ this.deferredBuildChecks.add(isDeferred);
172
+ return Disposable.create(() => this.deferredBuildChecks.delete(isDeferred));
173
+ }
174
+
175
+ isSyncedTo(uri: URI, textVersion: number): boolean {
176
+ const root = this.services.workspace.LangiumDocuments.getDocument(uri)?.parseResult.value;
177
+ if (root === undefined) {
178
+ return true;
179
+ }
180
+ const ledger = this.services.workspace.ModelLedger;
181
+ const recorded = ledger.versionOf(root);
182
+ return recorded === UNRECORDED_VERSION || ledger.isPlaceholder(root) || recorded >= textVersion;
183
+ }
184
+
185
+ /** The text `document`'s root was parsed from, or for a root built without either record or CST, the document's own. */
186
+ protected parsedText(document: LangiumDocument): string {
187
+ return this.services.workspace.ModelLedger.textOf(document.parseResult.value) ?? document.textDocument.getText();
188
+ }
189
+
190
+ /**
191
+ * Run `entry`'s requests, one build per batch, until none is queued, and
192
+ * answer each. A request a failed build carried is queued again
193
+ * {@link maxBuildRetries} times, then answered `false`; one a cancelled
194
+ * build carried is queued again without counting. The entry is dropped
195
+ * in the same tick as the last check of the queue, so no request lands in
196
+ * an entry nothing runs any more.
197
+ */
198
+ protected async runBuilds(key: string, uri: URI, entry: PendingRecoveryBuilds): Promise<void> {
199
+ const tracer = this.tracer.withUri(key);
200
+ const lock = this.services.workspace.WorkspaceLock;
201
+ // Any other lock resolves a cancelled write as if it had completed, unnoticed.
202
+ const counting = lock instanceof HydraniumWorkspaceLock ? lock : undefined;
203
+ while (entry.queued.length > 0) {
204
+ let carried: QueuedRecoveryBuild[] = [];
205
+ let build: Promise<void> | undefined;
206
+ let cancellations: number | undefined;
207
+ try {
208
+ // The read waits until no write runs or is queued. It must not await
209
+ // the build, a write that queues behind it.
210
+ await lock.read(() => {
211
+ carried = entry.queued.splice(0);
212
+ const needed = carried.filter(queued => this.isStillNeeded(uri, queued.request));
213
+ carried.filter(queued => !needed.includes(queued)).forEach(queued => queued.answer(true));
214
+ carried = needed;
215
+ if (needed.length > 0) {
216
+ build = this.scheduleBatch(
217
+ uri,
218
+ needed.map(queued => queued.request)
219
+ );
220
+ // Read after the build is queued, which cancels the write before it.
221
+ cancellations = counting?.writeCancellations;
222
+ }
223
+ });
224
+ await build;
225
+ // A write cancelled the build, before it ran or while it did.
226
+ if (cancellations !== undefined && counting?.writeCancellations !== cancellations) {
227
+ entry.queued.unshift(...carried);
228
+ continue;
229
+ }
230
+ carried.forEach(queued => queued.answer(true));
231
+ } catch (err: unknown) {
232
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
233
+ const retried = carried.filter(queued => queued.failures++ < this.maxBuildRetries);
234
+ const givenUp = carried.filter(queued => !retried.includes(queued));
235
+ if (retried.length > 0) {
236
+ tracer.warn(`Requested build failed; retrying. ${detail}`);
237
+ entry.queued.unshift(...retried);
238
+ }
239
+ if (givenUp.length > 0) {
240
+ tracer.error(`Requested build failed again; giving up. ${detail}`);
241
+ givenUp.forEach(queued => queued.answer(false));
242
+ }
243
+ }
244
+ }
245
+ this.pendingBuilds.delete(key);
246
+ }
247
+
248
+ /** Whether `request` still needs its build, and no deferral it heeds holds it back. Runs inside the lock read. */
249
+ protected isStillNeeded(uri: URI, request: RecoveryBuildRequest): boolean {
250
+ return request.stillNeeded() && (request.ignoreDeferred === true || !this.isDeferred(uri));
251
+ }
252
+
253
+ /**
254
+ * One build for every request of `batch`, removing the document when the
255
+ * latest of them asks to, as `HydraniumDocumentBuilder.scheduleUpdate`
256
+ * merges a change and a deletion. Runs inside the lock read.
257
+ */
258
+ protected scheduleBatch(uri: URI, batch: readonly RecoveryBuildRequest[]): Promise<void> {
259
+ const deleted = batch.at(-1)?.deleted === true;
260
+ const reason = batch.find(request => request.reason !== undefined)?.reason;
261
+ return this.services.workspace.DocumentBuilder.scheduleUpdate(deleted ? [] : [uri], deleted ? [uri] : [], reason);
262
+ }
263
+
264
+ protected isDeferred(uri: URI): boolean {
265
+ return [...this.deferredBuildChecks].some(isDeferred => isDeferred(uri));
266
+ }
267
+ }
@@ -21,6 +21,8 @@ import { HydraniumScopeComputation } from './scope/hydranium-scope-computation.j
21
21
  import { HydraniumScopeProvider } from './scope/hydranium-scope-provider.js';
22
22
  import { processEnv } from '../util/environment.js';
23
23
  import { DefaultTransferEncoder } from './transfer/transfer-encoder.js';
24
+ import { HydraniumLangiumDocumentFactory } from './workspace/hydranium-langium-document-factory.js';
25
+ import { HydraniumLangiumDocuments } from './workspace/langium-documents.js';
24
26
  import { HydraniumWorkspaceManager } from './workspace/hydranium-workspace-manager.js';
25
27
 
26
28
  /**
@@ -191,6 +193,20 @@ interface StrictSlot {
191
193
 
192
194
  const STRICT_SHARED_SLOTS: readonly StrictSlot[] = [
193
195
  { side: 'shared', path: 'workspace.TextDocuments', base: HydraniumTextDocuments, get: s => nested(s, 'workspace', 'TextDocuments') },
196
+ // Every root it builds records no version, so every write gated on one conflicts.
197
+ {
198
+ side: 'shared',
199
+ path: 'workspace.LangiumDocumentFactory',
200
+ base: HydraniumLangiumDocumentFactory,
201
+ get: s => nested(s, 'workspace', 'LangiumDocumentFactory')
202
+ },
203
+ // It reports no registered root, so none is reconciled with the text store or announced.
204
+ {
205
+ side: 'shared',
206
+ path: 'workspace.LangiumDocuments',
207
+ base: HydraniumLangiumDocuments,
208
+ get: s => nested(s, 'workspace', 'LangiumDocuments')
209
+ },
194
210
  {
195
211
  side: 'shared',
196
212
  path: 'workspace.WorkspaceManager',
@@ -17,7 +17,7 @@ import {
17
17
  type LangiumDocument,
18
18
  MultiMap,
19
19
  OperationCancelled,
20
- type URI,
20
+ URI,
21
21
  UriUtils,
22
22
  type WorkspaceLock,
23
23
  interruptAndCheck,
@@ -27,6 +27,7 @@ import {
27
27
  // namespace helper to read the `string | MarkupContent` union without
28
28
  // restating it.
29
29
  import { CancellationToken, Diagnostic, Disposable } from 'vscode-languageserver-protocol';
30
+ import { type VersionSyncService } from '../../documents/version-sync-service.js';
30
31
  import { type LogNameOptions } from '../diagnostics/logger.js';
31
32
  import type { MessageRenderer } from '../../messages/renderer.js';
32
33
  import { CST_REHYDRATION_RESET_STATE, isCstShed } from '../residency/cst-residency-service.js';
@@ -126,6 +127,15 @@ export interface DocumentBuilderOptions extends LogNameOptions {
126
127
  readonly refreshCrossDocumentComputedScopes?: boolean;
127
128
  }
128
129
 
130
+ /** Options for a {@link HydraniumDocumentBuilder.waitUntil} wait on one document. */
131
+ export interface WaitUntilOptions {
132
+ /**
133
+ * Reject once the builder gives up carrying the document to the target
134
+ * state. Without it, such a wait stays pending until its token is cancelled.
135
+ */
136
+ readonly rejectWhenStuck?: boolean;
137
+ }
138
+
129
139
  /**
130
140
  * One locked build {@link HydraniumDocumentBuilder.scheduleUpdate} queued,
131
141
  * which later requests merge into while it waits and join while it runs.
@@ -237,6 +247,8 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
237
247
  protected readonly buildEndedListeners = new Set<(drained: boolean) => void>();
238
248
  /** Set while the lock read {@link checkWaitsOnceDrained} queued has not run. */
239
249
  protected drainCheckQueued = false;
250
+ /** Resolved per call: the sync service reaches this builder. */
251
+ protected readonly versionSyncService: () => VersionSyncService;
240
252
 
241
253
  constructor(services: ServerSharedServicesMinimal, options: DocumentBuilderOptions = {}) {
242
254
  super(services);
@@ -245,6 +257,7 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
245
257
  this.clock = services.Clock;
246
258
  this.messageRenderer = services.MessageRenderer;
247
259
  this.workspaceLock = services.workspace.WorkspaceLock;
260
+ this.versionSyncService = () => services.workspace.VersionSyncService;
248
261
  this.tracer = services.Tracer.for(options.logName ?? 'DocumentBuilder').trace('instantiated');
249
262
  this.logLevel = options.logLevel ?? 'debug';
250
263
  this.loggedPhases = options.loggedPhases ?? DEFAULT_LOGGED_PHASES;
@@ -342,6 +355,24 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
342
355
  // Bug-fixes (always on)
343
356
  // ============================================================
344
357
 
358
+ /**
359
+ * Resolve once the document at `uri` reaches `state`, or without `uri`, once
360
+ * the workspace does. A wait on `uri` re-queues a document no build is
361
+ * carrying, as {@link awaitDocumentState} describes.
362
+ */
363
+ override waitUntil(state: DocumentState, cancelToken?: CancellationToken): Promise<void>;
364
+ override waitUntil(state: DocumentState, uri?: URI, cancelToken?: CancellationToken, options?: WaitUntilOptions): Promise<URI>;
365
+ override waitUntil(
366
+ state: DocumentState,
367
+ uriOrToken?: URI | CancellationToken,
368
+ cancelToken?: CancellationToken,
369
+ options: WaitUntilOptions = {}
370
+ ): Promise<URI | void> {
371
+ return URI.isUri(uriOrToken)
372
+ ? this.awaitDocumentState(state, uriOrToken, cancelToken ?? CancellationToken.None, options)
373
+ : super.waitUntil(state, uriOrToken);
374
+ }
375
+
345
376
  /**
346
377
  * Two edge cases the default Langium implementation rejects on:
347
378
  * - Document below target state with no build active (newly-created file):
@@ -367,7 +398,9 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
367
398
  * whatever cancelled it may build nothing. Re-queuing stops after
368
399
  * {@link MAX_STALLED_REQUEUES} builds that fail to advance the document, so a
369
400
  * document the builder will never carry to `state` degrades to a pending wait
370
- * plus a warning rather than an endless build loop.
401
+ * plus a warning rather than an endless build loop. With `rejectWhenStuck`,
402
+ * the wait rejects instead, both then and once
403
+ * `VersionSyncService.requestRecoveryBuild` gives up a re-queued build.
371
404
  *
372
405
  * A wait for `Validated` on a document that a validating build skips
373
406
  * resolves once that build has indexed its references, without diagnostics:
@@ -380,7 +413,12 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
380
413
  * a re-queue: the build waits for that action to end. Await it outside the
381
414
  * lock.
382
415
  */
383
- protected override awaitDocumentState(state: DocumentState, uri: URI, cancelToken: CancellationToken): Promise<URI> {
416
+ protected override awaitDocumentState(
417
+ state: DocumentState,
418
+ uri: URI,
419
+ cancelToken: CancellationToken,
420
+ { rejectWhenStuck = false }: WaitUntilOptions = {}
421
+ ): Promise<URI> {
384
422
  const document = this.langiumDocuments.getDocument(uri);
385
423
  if (!document) {
386
424
  return super.awaitDocumentState(state, uri, cancelToken);
@@ -398,15 +436,28 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
398
436
  // land while a busy workspace keeps cancelling builds.
399
437
  let stalledRequeues = 0;
400
438
  let lastRequeueState: DocumentState | undefined;
439
+ const giveUp = (reason: string): void => {
440
+ cleanup();
441
+ reject(
442
+ new Error(
443
+ `Gave up building ${this.formatUri(uri)} to '${DocumentState[state]}' ` +
444
+ `(at '${DocumentState[document.state]}'): ${reason}`
445
+ )
446
+ );
447
+ };
401
448
  const requeue = (reason: string): void => {
402
449
  if (document.state === lastRequeueState) {
403
450
  if (++stalledRequeues > MAX_STALLED_REQUEUES) {
451
+ const stalled = `${stalledRequeues} builds did not advance it`;
452
+ if (rejectWhenStuck) {
453
+ giveUp(stalled);
454
+ return;
455
+ }
404
456
  this.tracer
405
457
  .withUri(uri.toString())
406
458
  .warn(
407
459
  `Giving up re-queuing ${this.formatUri(uri)}: stuck at '${DocumentState[document.state]}', needs ` +
408
- `'${DocumentState[state]}' after ${stalledRequeues} builds that did not advance it. ` +
409
- 'The wait now depends on its cancellation token.'
460
+ `'${DocumentState[state]}' after ${stalled}. The wait now depends on its cancellation token.`
410
461
  );
411
462
  return;
412
463
  }
@@ -414,7 +465,11 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
414
465
  stalledRequeues = 0;
415
466
  lastRequeueState = document.state;
416
467
  }
417
- this.requeueOrphaned(document, state, reason);
468
+ void this.requeueOrphaned(document, state, reason).then(built => {
469
+ if (!built && rejectWhenStuck) {
470
+ giveUp('its recovery build failed');
471
+ }
472
+ });
418
473
  };
419
474
  const phaseDisposable = this.onDocumentPhase(
420
475
  state,
@@ -465,7 +520,9 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
465
520
  // advanced this document has already finished, so none of these
466
521
  // listeners fires until some other build runs. Re-queue now — the listeners are
467
522
  // registered, so the resulting build resolves this wait.
468
- if (this.isOrphaned(document, state)) {
523
+ // With `rejectWhenStuck`, also a document a failed build left behind:
524
+ // no build ends to re-queue it, so the wait would never give up.
525
+ if (this.isOrphaned(document, state) || (rejectWhenStuck && this.activeSession === undefined)) {
469
526
  requeue('quiescent builder');
470
527
  }
471
528
  });
@@ -514,39 +571,21 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
514
571
  }
515
572
 
516
573
  /**
517
- * Schedule a build for a document no in-flight build will advance. Deliberately
518
- * fire-and-forget: the caller is a waiter that resolves off the resulting phase
519
- * notification, so awaiting here would invert the dependency. A rejection is
520
- * logged rather than swallowed — it leaves the waiter pending until its own
521
- * cancellation token fires, which is worth a line in the log.
522
- *
523
- * The build is a {@link scheduleUpdate}, entered from a {@link WorkspaceLock}
524
- * read. The read waits until no write runs or is queued, so the build neither
525
- * runs beside a locked one nor cancels the build a re-queue is fired from.
526
- * Unlocked, it runs beside a locked build that cannot cancel it: both
527
- * validate the same version and deliver it twice. Taking the write directly,
528
- * without the read, cancels the running build and skips the rest of its
529
- * build-phase listeners. The read must not await the write, which queues
530
- * behind that read. A build scheduled in the gap between the read batch
531
- * starting and this action running is still queued, so the re-queue merges
532
- * into it rather than cancelling it.
533
- *
534
- * A write that ran while the read waited may have carried the document to
535
- * `state` already, and a second build would deliver that version again; the
536
- * read skips the write then.
574
+ * Schedule a build for a document no in-flight build will advance, through
575
+ * `VersionSyncService.requestRecoveryBuild`: from a {@link WorkspaceLock} read, so
576
+ * the build neither runs beside a locked one nor cancels the build a
577
+ * re-queue is fired from, and skipped if a write that ran meanwhile carried
578
+ * the document to `state`. The waiter resolves off the resulting phase
579
+ * notification; the answer only tells it whether the build was given up.
537
580
  */
538
- protected requeueOrphaned(document: LangiumDocument, state: DocumentState, reason: string): void {
581
+ protected requeueOrphaned(document: LangiumDocument, state: DocumentState, reason: string): Promise<boolean> {
539
582
  const tracer = this.tracer.withUri(document.uri.toString());
540
583
  tracer.info(`Re-queuing orphaned document (at '${DocumentState[document.state]}', needs '${DocumentState[state]}'): ${reason}`);
541
- void this.workspaceLock.read(() => {
542
- if (document.state >= state) {
543
- return;
544
- }
545
- this.scheduleUpdate([document.uri], []).catch((err: unknown) => {
546
- if (!isOperationCancelled(err)) {
547
- tracer.error(`Re-queue build failed: ${err instanceof Error ? err.message : String(err)}`);
548
- }
549
- });
584
+ return this.versionSyncService().requestRecoveryBuild(document.uri, {
585
+ // Skipped on purpose: a waiting caller is answered sooner, at the cost
586
+ // of a possible duplicate build once the debounce fires.
587
+ ignoreDeferred: true,
588
+ stillNeeded: () => document.state < state
550
589
  });
551
590
  }
552
591
 
@@ -399,28 +399,19 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
399
399
  // during the re-parse saw the text on disk, which in `'editor'` sync mode
400
400
  // is the PRE-repair text. Leave the sequence describing that and the next
401
401
  // open hashes the repair, finds a mismatch and steps the version again, so
402
- // every based-on version taken from this build is stale before it is used.
403
- // Falls back to the pre-re-parse number for a URI the store never tracked,
404
- // where there is no sequence to advance; an OPEN document answers
405
- // `undefined` and keeps the store's own version, which is not this
402
+ // every base version taken from this build is stale before it is used.
403
+ // An open document keeps the store's own version, which is not this
406
404
  // method's to move.
407
405
  //
408
406
  // Deliberately NOT gated on cancellation, unlike the entry to this method:
409
407
  // `syncCorrections` has already written or staged the repair by now, so a
410
408
  // preempted build that skipped this would leave the sequence describing
411
409
  // text that is no longer there.
412
- //
413
- // Gated on the document being closed, because the fallback restores a
414
- // number this method captured off whatever object it started with — and
415
- // for a separately created document that is its own seeded numbering, not
416
- // the store's. Applying it to the store's document (which the re-parse has
417
- // by then made `textDocument`) would roll the shared version backwards.
418
- // The store's own answer for an open document is `undefined` either way.
419
410
  if (!this.textDocuments.isOpen(document.textDocument.uri)) {
420
- const reconciled = this.textDocuments.reconcileExternalContent(document.textDocument.uri, newText) ?? version;
421
- if (document.textDocument.version !== reconciled) {
422
- this.textDocuments.update(document.textDocument, [], reconciled);
423
- }
411
+ // The root describes the repair too; left at the re-parse's number, a
412
+ // syncing read waits for a build nothing requests. The CST is still
413
+ // the unrepaired text, so the repair is reported as the parsed text.
414
+ this.services.shared.workspace.VersionSyncService.modelProduced(document, { text: newText });
424
415
  }
425
416
  }
426
417