@hydranium/core 1.0.0-next.117 → 1.0.0-next.123

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 (108) hide show
  1. package/lib/documents/ast-document-manager.d.ts +8 -44
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +22 -41
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/client-session-errors.d.ts +4 -33
  6. package/lib/documents/client-session-errors.d.ts.map +1 -1
  7. package/lib/documents/client-session-errors.js +4 -46
  8. package/lib/documents/client-session-errors.js.map +1 -1
  9. package/lib/documents/client-session-registry.d.ts +40 -20
  10. package/lib/documents/client-session-registry.d.ts.map +1 -1
  11. package/lib/documents/client-session-registry.js +33 -20
  12. package/lib/documents/client-session-registry.js.map +1 -1
  13. package/lib/documents/file-system-task-queue.d.ts +49 -0
  14. package/lib/documents/file-system-task-queue.d.ts.map +1 -0
  15. package/lib/documents/file-system-task-queue.js +34 -0
  16. package/lib/documents/file-system-task-queue.js.map +1 -0
  17. package/lib/documents/hydranium-text-documents.d.ts +138 -13
  18. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  19. package/lib/documents/hydranium-text-documents.js +283 -40
  20. package/lib/documents/hydranium-text-documents.js.map +1 -1
  21. package/lib/documents/index.d.ts +1 -0
  22. package/lib/documents/index.d.ts.map +1 -1
  23. package/lib/documents/index.js +1 -0
  24. package/lib/documents/index.js.map +1 -1
  25. package/lib/langium/document-builder/document-builder.d.ts +15 -2
  26. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  27. package/lib/langium/document-builder/document-builder.js +15 -2
  28. package/lib/langium/document-builder/document-builder.js.map +1 -1
  29. package/lib/langium/integrity/integrity-service.d.ts +3 -2
  30. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  31. package/lib/langium/integrity/integrity-service.js +9 -6
  32. package/lib/langium/integrity/integrity-service.js.map +1 -1
  33. package/lib/langium/language-module.d.ts +2 -2
  34. package/lib/langium/language-module.js +2 -2
  35. package/lib/langium/language-module.js.map +1 -1
  36. package/lib/langium/model-service/client-session.d.ts +203 -24
  37. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  38. package/lib/langium/model-service/client-session.js +233 -29
  39. package/lib/langium/model-service/client-session.js.map +1 -1
  40. package/lib/langium/model-service/model-service.d.ts +62 -217
  41. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  42. package/lib/langium/model-service/model-service.js +54 -357
  43. package/lib/langium/model-service/model-service.js.map +1 -1
  44. package/lib/langium/module.d.ts +11 -1
  45. package/lib/langium/module.d.ts.map +1 -1
  46. package/lib/langium/module.js +5 -1
  47. package/lib/langium/module.js.map +1 -1
  48. package/lib/langium/update-rewrite/update-rewrite.d.ts +1 -1
  49. package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
  50. package/lib/langium/workspace/in-memory-file-system-provider.js +10 -2
  51. package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
  52. package/lib/langium/workspace/write-lock-scope.d.ts +1 -1
  53. package/lib/langium/workspace/write-lock-scope.js +1 -1
  54. package/lib/lsp/hydranium-document-update-handler.d.ts +69 -67
  55. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  56. package/lib/lsp/hydranium-document-update-handler.js +111 -89
  57. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  58. package/lib/lsp/shared-module.d.ts +4 -8
  59. package/lib/lsp/shared-module.d.ts.map +1 -1
  60. package/lib/lsp/shared-module.js.map +1 -1
  61. package/lib/lsp/start-language-server.d.ts +3 -4
  62. package/lib/lsp/start-language-server.d.ts.map +1 -1
  63. package/lib/lsp/start-language-server.js +3 -4
  64. package/lib/lsp/start-language-server.js.map +1 -1
  65. package/lib/testing/make-test-services.d.ts +16 -4
  66. package/lib/testing/make-test-services.d.ts.map +1 -1
  67. package/lib/testing/make-test-services.js +6 -2
  68. package/lib/testing/make-test-services.js.map +1 -1
  69. package/lib/testing/node/lsp-server-connection.d.ts +1 -1
  70. package/lib/testing/node/scratch-workspace.js +1 -1
  71. package/lib/testing/run-update-pipeline.d.ts +4 -4
  72. package/lib/testing/run-update-pipeline.d.ts.map +1 -1
  73. package/lib/testing/stub-ast-document-manager.d.ts +5 -5
  74. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  75. package/lib/testing/stub-ast-document-manager.js +6 -8
  76. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  77. package/lib/testing/stub-hydranium-text-documents.d.ts +10 -5
  78. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  79. package/lib/testing/stub-hydranium-text-documents.js +16 -9
  80. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  81. package/lib/testing/stub-model-service.d.ts +2 -2
  82. package/lib/testing/stub-model-service.js +2 -2
  83. package/package.json +5 -5
  84. package/src/documents/ast-document-manager.ts +27 -78
  85. package/src/documents/client-session-errors.ts +11 -43
  86. package/src/documents/client-session-registry.ts +54 -28
  87. package/src/documents/file-system-task-queue.ts +68 -0
  88. package/src/documents/hydranium-text-documents.ts +348 -44
  89. package/src/documents/index.ts +1 -0
  90. package/src/langium/document-builder/document-builder.ts +16 -2
  91. package/src/langium/integrity/integrity-service.ts +9 -6
  92. package/src/langium/language-module.ts +4 -4
  93. package/src/langium/model-service/client-session.ts +363 -39
  94. package/src/langium/model-service/model-service.ts +91 -420
  95. package/src/langium/module.ts +14 -2
  96. package/src/langium/update-rewrite/update-rewrite.ts +1 -1
  97. package/src/langium/workspace/in-memory-file-system-provider.ts +11 -2
  98. package/src/langium/workspace/write-lock-scope.ts +1 -1
  99. package/src/lsp/hydranium-document-update-handler.ts +138 -92
  100. package/src/lsp/shared-module.ts +4 -8
  101. package/src/lsp/start-language-server.ts +3 -4
  102. package/src/testing/make-test-services.ts +25 -6
  103. package/src/testing/node/lsp-server-connection.ts +1 -1
  104. package/src/testing/node/scratch-workspace.ts +1 -1
  105. package/src/testing/run-update-pipeline.ts +4 -4
  106. package/src/testing/stub-ast-document-manager.ts +10 -24
  107. package/src/testing/stub-hydranium-text-documents.ts +29 -16
  108. package/src/testing/stub-model-service.ts +2 -2
@@ -15,6 +15,7 @@ import { HydraniumLangiumProfiler } from './diagnostics/hydranium-langium-profil
15
15
  import { type AstNode, type Module } from '@hydranium/langium';
16
16
  import { type DefaultSharedModuleContext, type LangiumSharedServices, type PartialLangiumSharedServices } from '@hydranium/langium/lsp';
17
17
  import { type TextDocument } from 'vscode-languageserver-textdocument';
18
+ import { type ClientSessionFactory, DefaultClientSessionFactory } from './model-service/client-session.js';
18
19
  import { DefaultModelService, type ModelService } from './model-service/model-service.js';
19
20
  import { type ProjectManager } from './project/project-manager.js';
20
21
  import { SingleProjectManager } from './project/single-project-manager.js';
@@ -35,6 +36,7 @@ import { DefaultDocumentUriPolicy, type DocumentUriPolicy } from './workspace/do
35
36
  import { HydraniumTextDocuments } from '../documents/hydranium-text-documents.js';
36
37
  import { DefaultAstDocumentManager, type AstDocumentManager, type WritableFileSystemProvider } from '../documents/ast-document-manager.js';
37
38
  import { DefaultSelfSaveRegistry, type SelfSaveRegistry } from '../documents/self-save-registry.js';
39
+ import { DefaultFileSystemTaskQueue, type FileSystemTaskQueue } from '../documents/file-system-task-queue.js';
38
40
  import { DefaultEmptyFileSystemProvider } from './workspace/file-system-provider.js';
39
41
  import { type ServerLanguageServices } from './language-module.js';
40
42
  import { ExtendedServiceRegistry } from './service-registry.js';
@@ -177,7 +179,7 @@ export interface ServerAddedSharedServices<TProject extends Project = Project> {
177
179
  * Tighten Langium's read-only `FileSystemProvider` slot to
178
180
  * {@link WritableFileSystemProvider}. The framework's save path
179
181
  * ({@link AstDocumentManager.save}, integrity corrections,
180
- * `ModelService.save`) requires write semantics — a read-only slot
182
+ * a session's `save`) requires write semantics — a read-only slot
181
183
  * type forces an `as WritableFileSystemProvider` cast at the binding
182
184
  * line and defers the failure of a read-only implementation to the
183
185
  * first save. The `.`-entry default is
@@ -217,6 +219,12 @@ export interface ServerAddedSharedServices<TProject extends Project = Project> {
217
219
  * behaviour rebind this slot with a subclass.
218
220
  */
219
221
  AstDocumentManager: AstDocumentManager<AstNode>;
222
+ /**
223
+ * Orders the server's disk accesses of each file. One slot for every
224
+ * reader and writer: a service given a queue of its own does not order
225
+ * against the others.
226
+ */
227
+ FileSystemTaskQueue: FileSystemTaskQueue;
220
228
  /**
221
229
  * Wires the framework's build-time features (integrity, AST
222
230
  * enrichment) into Langium's build pipeline: owns the build-phase
@@ -273,6 +281,8 @@ export interface ServerAddedSharedServices<TProject extends Project = Project> {
273
281
  model: {
274
282
  TransferEncoder: TransferEncoder;
275
283
  ModelService: ModelService<AstNode>;
284
+ /** Builds the handle of every session `ModelService.createSession` starts. */
285
+ ClientSessionFactory: ClientSessionFactory;
276
286
  };
277
287
  /**
278
288
  * Shared contribution group for batch-level build-phase passes (see
@@ -507,6 +517,7 @@ export function createServerSharedModule(
507
517
  return new DefaultEmptyFileSystemProvider(services);
508
518
  },
509
519
  AstDocumentManager: services => new DefaultAstDocumentManager(services),
520
+ FileSystemTaskQueue: services => new DefaultFileSystemTaskQueue(services),
510
521
  // Eagerly constructed, so its build-phase listeners attach before the
511
522
  // first build.
512
523
  BuildPipelineIntegration: services => new DefaultBuildPipelineIntegration(services),
@@ -519,7 +530,8 @@ export function createServerSharedModule(
519
530
  // Generic walker — adopters with a typed `$type → wire shape` overlay
520
531
  // rebind this slot with a subclass.
521
532
  TransferEncoder: services => new DefaultTransferEncoder(services),
522
- ModelService: services => new DefaultModelService(services)
533
+ ModelService: services => new DefaultModelService(services),
534
+ ClientSessionFactory: services => new DefaultClientSessionFactory(services)
523
535
  },
524
536
  // Empty default so `services.buildPhasePasses` always resolves (Langium
525
537
  // throws on access to an unbound slot). The framework's own integrity /
@@ -13,7 +13,7 @@ import { type RegistryItem } from '../../util/registry.js';
13
13
 
14
14
  /**
15
15
  * A single transform applied to the incoming transfer model on the structured
16
- * (object) write path of `ModelService.update` / `ModelService.save`, before
16
+ * (object) write path of a client session's `update` / `save`, before
17
17
  * serialisation. The RPC-update-stage sibling of `IntegrityRule` /
18
18
  * `AstExtension` / `ValidationCheck` — same id-keyed,
19
19
  * priority-ordered registry shape, but it runs on the transfer model rather
@@ -43,6 +43,15 @@ export const UNSUPPORTED_WRITE = defineMessage(
43
43
  'Cannot write {uri}: the scheme names no location on disk'
44
44
  );
45
45
 
46
+ /**
47
+ * A missing-file error shaped as a Node file system raises it, with code
48
+ * `ENOENT` and the path: callers tell a missing file from any other failure by
49
+ * those two fields, whichever provider is bound.
50
+ */
51
+ function notFound(message: string, uri: URI): Error {
52
+ return Object.assign(new Error(message), { code: 'ENOENT', path: uri.fsPath });
53
+ }
54
+
46
55
  /**
47
56
  * File content keyed by path, for seeding an {@link InMemoryFileSystemProvider}.
48
57
  *
@@ -162,7 +171,7 @@ export class InMemoryFileSystemProvider implements WritableFileSystemProvider {
162
171
  }
163
172
  const content = this.files.get(normalize(uri));
164
173
  if (content === undefined) {
165
- throw new Error(NO_SUCH_FILE.format({ uri: uri.toString() }));
174
+ throw notFound(NO_SUCH_FILE.format({ uri: uri.toString() }), uri);
166
175
  }
167
176
  return content;
168
177
  }
@@ -192,7 +201,7 @@ export class InMemoryFileSystemProvider implements WritableFileSystemProvider {
192
201
  if (this.hasChildren(path)) {
193
202
  return { isFile: false, isDirectory: true, uri };
194
203
  }
195
- throw new Error(NO_SUCH_PATH.format({ uri: uri.toString() }));
204
+ throw notFound(NO_SUCH_PATH.format({ uri: uri.toString() }), uri);
196
205
  }
197
206
 
198
207
  exists(uri: URI): Promise<boolean> {
@@ -67,7 +67,7 @@ export function isInsideWriteLock(): boolean {
67
67
  /**
68
68
  * Thrown when the model facade is reached from inside a workspace write-lock
69
69
  * holder — typically an integrity rule or build-phase pass writing through
70
- * `ModelService.update` / `save` / `rebuild` during a build.
70
+ * a session's `update` / `save` or `ModelService.rebuild` during a build.
71
71
  *
72
72
  * Named rather than a bare `Error` because the remedy is specific and worth
73
73
  * pointing at: either move the write out of the build (integrity rules that
@@ -7,31 +7,27 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { Debouncer, type Logger, ObservableValue, type MaybeObservableValue } from '@hydranium/protocol';
10
+ import { Debouncer, Deferred, type Logger, ObservableValue, type MaybeObservableValue } from '@hydranium/protocol';
11
11
  import { URI, UriUtils } from '@hydranium/langium';
12
12
  import { DefaultDocumentUpdateHandler } from '@hydranium/langium/lsp';
13
- import { type HydraniumDocumentBuilder } from '../langium/document-builder/document-builder.js';
13
+ import { HYDRANIUM_BUILD_REASONS, type HydraniumDocumentBuilder } from '../langium/document-builder/document-builder.js';
14
14
  import { type ServerSharedServices } from '../langium/module.js';
15
15
  import { LANGUAGE_CLIENT_ID } from '../documents/client-ids.js';
16
16
  import { type SelfSaveRegistry } from '../documents/self-save-registry.js';
17
17
  import { type WritableFileSystemProvider } from '../documents/ast-document-manager.js';
18
18
  import { type HydraniumTextDocuments } from '../documents/hydranium-text-documents.js';
19
19
  import { isConnectionGoneError } from '../util/connection-liveness.js';
20
- import { type DidChangeWatchedFilesParams, type FileEvent, FileChangeType, type TextDocumentChangeEvent } from 'vscode-languageserver';
20
+ import {
21
+ type DidChangeWatchedFilesParams,
22
+ type FileEvent,
23
+ FileChangeType,
24
+ type TextDocumentChangeEvent,
25
+ type TextDocumentWillSaveEvent,
26
+ type TextEdit
27
+ } from 'vscode-languageserver';
21
28
  import { type TextDocument } from 'vscode-languageserver-textdocument';
22
29
 
23
- /**
24
- * Default LSP-event reasons stamped by {@link HydraniumDocumentUpdateHandler}
25
- * onto the next `documentBuilder.update` call via `markNextReason`. Exposed
26
- * so adopters subclassing the handler can layer additional reasons or rename
27
- * existing ones without losing the canonical names the framework emits.
28
- */
29
- export const HYDRANIUM_BUILD_REASONS = Object.freeze({
30
- didOpen: 'didOpen',
31
- didChangeContent: 'didChangeContent',
32
- didChangeWatchedFiles: 'didChangeWatchedFiles',
33
- didClose: 'didClose'
34
- } as const);
30
+ export { HYDRANIUM_BUILD_REASONS };
35
31
 
36
32
  /**
37
33
  * Construction options for {@link HydraniumDocumentUpdateHandler}.
@@ -57,6 +53,17 @@ export interface HydraniumDocumentUpdateHandlerOptions {
57
53
  * load tests).
58
54
  */
59
55
  readonly bypassNonLanguageClientChanges?: boolean;
56
+ /**
57
+ * Longest an editor's save waits on the server, in milliseconds, at each of
58
+ * its two steps: the `willSaveWaitUntil` answer waits for the server's disk
59
+ * writes of the document already queued, and server writes queued after the
60
+ * answer wait for the editor's `didSave`. Either wait that runs out is
61
+ * logged, and the editor's write and the server's can then land in either
62
+ * order. Default `1000`: VS Code gives up on an answer after about 1.5 s,
63
+ * and switches the listener off for the session once four answers, over
64
+ * all documents, timed out or failed.
65
+ */
66
+ readonly willSaveGateMs?: number;
60
67
  }
61
68
 
62
69
  /**
@@ -72,19 +79,25 @@ export interface HydraniumDocumentUpdateHandlerOptions {
72
79
  * changes and watched-file changes bypass the debounce (their
73
80
  * producers are already coalesced).
74
81
  * 3. **Build-reason stamping** — the LSP event overrides
75
- * (`didOpenDocument`, `didChangeContent`, `didChangeWatchedFiles`,
76
- * `didCloseDocument`) populate {@link nextReason} with a canonical
77
- * reason string. {@link fireDocumentUpdate} captures the reason;
78
- * {@link dispatch} stages it on the builder via
79
- * {@link HydraniumDocumentBuilder.markNextReason}, so a subclass that
80
- * formats build logs can tag its line with the event that caused the
81
- * build without per-adopter wiring to capture it.
82
- * 4. **Last-client-close rebuild trigger** ({@link didCloseDocument}) —
83
- * when the final client closes a document, dispatch a build so the
84
- * LangiumDocument's `textDocument` is refreshed (from disk for `file:`
85
- * URIs, or removed from the index for ephemeral URIs). Langium's
86
- * default handler does not implement `didCloseDocument`, so the
87
- * in-memory text of the last writer would otherwise persist.
82
+ * (`didOpenDocument`, `didChangeContent`, `didChangeWatchedFiles`)
83
+ * populate {@link nextReason} with a canonical reason string.
84
+ * {@link fireDocumentUpdate} captures the reason; {@link dispatch} stages
85
+ * it on the builder via {@link HydraniumDocumentBuilder.markNextReason},
86
+ * so a subclass that formats build logs can tag its line with the event
87
+ * that caused the build without per-adopter wiring to capture it.
88
+ * 4. **Editor save gate** ({@link willSaveDocumentWaitUntil}) — the editor
89
+ * writes the file itself, so the server's disk writes of that document
90
+ * are ordered around it: the editor's save waits for the ones already
91
+ * queued, and the ones queued during its save wait for its `didSave`.
92
+ * Both waits are capped by
93
+ * {@link HydraniumDocumentUpdateHandlerOptions.willSaveGateMs}.
94
+ *
95
+ * The revert to disk after a document's last close is not dispatched here:
96
+ * the text store runs it for every head, the LSP head included, so a
97
+ * `didCloseDocument` added in a subclass would build the document twice. A
98
+ * change still debounced for the document is dropped when the store releases
99
+ * it: a `file:` document's revert rebuilds it, and a document of another
100
+ * scheme, which does not revert, keeps its last build.
88
101
  *
89
102
  * Adopters with their own handler subclass should extend this class
90
103
  * (not Langium's `DefaultDocumentUpdateHandler`) so all of them are
@@ -133,6 +146,17 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
133
146
  */
134
147
  protected pendingReason?: string;
135
148
 
149
+ protected readonly willSaveGateMs: number;
150
+ /**
151
+ * Per document URI, the release of the hold an editor's save in progress
152
+ * keeps on the document's disk queue. A second save of the document
153
+ * releases the first: its hold would otherwise keep the second's answer
154
+ * waiting until the cap. The key is the document, not the save, so for a
155
+ * client that sends its next `willSaveWaitUntil` before the previous
156
+ * `didSave`, that late `didSave` releases the next save's hold.
157
+ */
158
+ protected readonly editorSaves = new Map<string, () => void>();
159
+
136
160
  constructor(
137
161
  protected readonly services: ServerSharedServices,
138
162
  options: HydraniumDocumentUpdateHandlerOptions = {}
@@ -145,6 +169,19 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
145
169
  this.debounce = ObservableValue.from(options.debounceMs ?? 0);
146
170
  this.flushDebouncer = new Debouncer(services.Clock, () => this.flushPending(), { delayMs: this.debounce });
147
171
  this.bypassNonLanguageClientChanges = options.bypassNonLanguageClientChanges ?? true;
172
+ this.willSaveGateMs = options.willSaveGateMs ?? 1000;
173
+ this.textDocuments.onDidSaveInLanguageClient(event => this.editorSaves.get(event.uri)?.());
174
+ // A change still debounced for a document the store has released would
175
+ // build it once more after the store's own revert: from disk again, or,
176
+ // for a file that never existed, by reading a file that is not there. A
177
+ // document of another scheme does not revert, so dropping its change
178
+ // leaves it at its last build.
179
+ this.textDocuments.onDidCloseLastOpen(event => {
180
+ this.pendingChanged.delete(URI.parse(event.uri).toString());
181
+ if (this.pendingChanged.size === 0 && this.pendingDeleted.size === 0) {
182
+ this.pendingReason = undefined;
183
+ }
184
+ });
148
185
  }
149
186
 
150
187
  didOpenDocument(_change: TextDocumentChangeEvent<TextDocument>): void {
@@ -169,70 +206,6 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
169
206
  super.didChangeContent(change);
170
207
  }
171
208
 
172
- /**
173
- * Last-client close is the missing rebuild trigger in Langium's
174
- * `DefaultDocumentUpdateHandler`: `documents.onDidClose` fires on every
175
- * per-client close, but the default handler ignores the event entirely.
176
- * For multi-client servers this leaves the LangiumDocument carrying the
177
- * unsaved in-memory `textDocument` of the last writer until something
178
- * else triggers a rebuild — integrity rules walking `langiumDocuments.all`
179
- * then see stale text long after the user discarded their edits.
180
- *
181
- * We funnel the last-client transition through the same dispatch path
182
- * the other LSP events use, so the rebuild gets debouncing, workspace
183
- * locking, build-reason stamping, and `markNextReason` for free. The
184
- * scheme split governs the `DocumentBuilder.update` shape:
185
- *
186
- * - **`file:` URI** → `update([uri], [])`. Routes through
187
- * `LangiumDocuments.invalidateDocument` (doc stays in the trie) and
188
- * `LangiumDocumentFactory.update`, which consults the LSP-tracked
189
- * open-documents map first. That map is cleared LATER in the same
190
- * synchronous `notifyDidCloseTextDocument` call that fires this event,
191
- * and {@link dispatch} defers the build behind `workspaceManager.ready`
192
- * — so by the time the factory looks, the URI is gone: it falls back to
193
- * `FileSystemProvider.readFile` and the doc rebuilds from disk,
194
- * Langium's canonical close-without-save semantic. A dispatch made
195
- * synchronously here would still see the open document and re-read the
196
- * in-memory text instead.
197
- * - **non-`file:` URI** (`builtin:`, `untitled:`) → no dispatch.
198
- * Adopter-loaded schemes — documents pulled in via
199
- * `loadAdditionalDocuments` — need to stay in the workspace index;
200
- * dropping them on close would break every cross-reference that
201
- * targets them. The framework cannot enumerate adopter schemes, so the
202
- * safe default is "leave alone unless it's a file." Adopters that need
203
- * ephemeral-URI cleanup handle it explicitly.
204
- *
205
- * Per-client closes (where other clients still hold the URI) are
206
- * suppressed via {@link HydraniumTextDocuments.isOpenInAnyClient}.
207
- *
208
- * The dispatch waits for the URI's disk queue to drain, because the rebuild
209
- * re-reads the file: dispatched while a save is still queued, it reads the
210
- * text from before that save, and the document reverts past it. A client
211
- * that opens the URI meanwhile holds the text, so no revert follows.
212
- */
213
- didCloseDocument(change: TextDocumentChangeEvent<TextDocument>): void {
214
- if (this.textDocuments.isOpenInAnyClient(change.document.uri)) {
215
- return;
216
- }
217
- const uri = URI.parse(change.document.uri);
218
- if (uri.scheme !== 'file') {
219
- return;
220
- }
221
- this.services.workspace.AstDocumentManager.queueDiskTask(change.document.uri, async () => undefined)
222
- .then(() => {
223
- if (this.textDocuments.isOpenInAnyClient(change.document.uri)) {
224
- return;
225
- }
226
- this.nextReason = HYDRANIUM_BUILD_REASONS.didClose;
227
- this.immediateFlush = true;
228
- this.fireDocumentUpdate([uri], []);
229
- })
230
- .catch((err: unknown) => {
231
- const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
232
- this.logger.error(`Revert on last close dropped for ${change.document.uri}. ${detail}`);
233
- });
234
- }
235
-
236
209
  override didChangeWatchedFiles(params: DidChangeWatchedFilesParams): void {
237
210
  void this.filterSelfSaves(params)
238
211
  .then(filtered => {
@@ -260,6 +233,79 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
260
233
  });
261
234
  }
262
235
 
236
+ /**
237
+ * Answer an editor's `willSaveWaitUntil` with no edits once the server's
238
+ * disk writes of the document already queued have landed, or after
239
+ * {@link willSaveGateMs}, whichever is first. From the moment the queue
240
+ * reaches this save, disk tasks of the document queued behind it wait for
241
+ * the editor's `didSave`, reported through
242
+ * `HydraniumTextDocuments.onDidSaveInLanguageClient`, or for
243
+ * {@link willSaveGateMs} more.
244
+ *
245
+ * The hold is a disk task that waits only for that signal or its timer:
246
+ * waiting on a build, a save or anything else queued for the document would
247
+ * wedge its queue. Resolves in every case, since a failed request counts
248
+ * against the listener in VS Code; Langium drops the request's cancellation,
249
+ * so an abandoned request still runs to the cap. A hold that runs out is
250
+ * logged at debug only: an editor skips `didSave` for a save it cancels or
251
+ * that changed nothing.
252
+ */
253
+ willSaveDocumentWaitUntil(event: TextDocumentWillSaveEvent<TextDocument>): Promise<TextEdit[]> {
254
+ const uri = event.document.uri;
255
+ this.editorSaves.get(uri)?.();
256
+ const hold = new Deferred();
257
+ const release = (): void => {
258
+ if (this.editorSaves.get(uri) === release) {
259
+ this.editorSaves.delete(uri);
260
+ }
261
+ hold.resolve();
262
+ };
263
+ this.editorSaves.set(uri, release);
264
+ return new Promise<TextEdit[]>(resolve => {
265
+ const clock = this.services.Clock;
266
+ const cap = clock.setTimer(() => {
267
+ this.logger.warn(
268
+ `willSaveWaitUntil for ${uri} answered after ${this.willSaveGateMs} ms with server writes of it still queued; the editor's save may race them.`
269
+ );
270
+ resolve([]);
271
+ }, this.willSaveGateMs);
272
+ try {
273
+ void this.services.workspace.FileSystemTaskQueue.enqueue(uri, async () => {
274
+ cap.dispose();
275
+ resolve([]);
276
+ const bound = clock.setTimer(() => {
277
+ this.logger.debug(
278
+ `No didSave for ${uri} within ${this.willSaveGateMs} ms; server writes of it no longer wait for the editor's save.`
279
+ );
280
+ release();
281
+ }, this.willSaveGateMs);
282
+ await hold.promise;
283
+ bound.dispose();
284
+ });
285
+ } catch (err) {
286
+ cap.dispose();
287
+ this.logger.error(
288
+ `willSaveWaitUntil for ${uri} answered without waiting. ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`
289
+ );
290
+ release();
291
+ resolve([]);
292
+ }
293
+ });
294
+ }
295
+
296
+ /**
297
+ * Present so that Langium advertises `save` and editors send `didSave`;
298
+ * without it every hold of {@link willSaveDocumentWaitUntil} waits out its
299
+ * cap. The hold is released on
300
+ * `HydraniumTextDocuments.onDidSaveInLanguageClient` instead: it fires as
301
+ * soon as the editor reports the save, while `onDidSave`, and so this
302
+ * method, follows only once the file has been read back, and not at all
303
+ * when the file does not hold the store's text.
304
+ */
305
+ didSaveDocument(_event: TextDocumentChangeEvent<TextDocument>): void {
306
+ // Nothing to do; see the doc comment.
307
+ }
308
+
263
309
  /**
264
310
  * Drop watched-file changes whose path + mtime match a recent self-save
265
311
  * record. Deletions always pass (no mtime); stat failures pass (the
@@ -28,14 +28,10 @@ import { HydraniumDocumentUpdateHandler } from './hydranium-document-update-hand
28
28
  * (an output channel, a structured logger) override the slot after this
29
29
  * module.
30
30
  *
31
- * - {@link HydraniumDocumentUpdateHandler} wraps `didChangeWatchedFiles` to
32
- * suppress echo events for the server's own writes via the shared
33
- * `SelfSaveRegistry` slot, debounces `didChangeContent`, stamps build
34
- * reasons through `markNextReason`, and dispatches a rebuild when the last
35
- * client closes a document.
36
- * Adopters with their own update-handler subclass should extend
37
- * `HydraniumDocumentUpdateHandler` (not Langium's
38
- * `DefaultDocumentUpdateHandler`) to preserve those behaviours.
31
+ * - {@link HydraniumDocumentUpdateHandler} carries the framework's handling of
32
+ * the LSP document events. Adopters with their own update-handler subclass
33
+ * should extend it (not Langium's `DefaultDocumentUpdateHandler`) to keep
34
+ * that handling.
39
35
  *
40
36
  * **Why this is a shared slot and not a per-language one.** Langium declares
41
37
  * `DocumentUpdateHandler` in `LangiumSharedLSPServices` — there is exactly one
@@ -22,10 +22,9 @@ import { guardDiagnosticsConnection } from './diagnostics-connection.js';
22
22
  * SHARED slot, so the head binds it in `createLspServerSharedModule` — a
23
23
  * separate module from `createLspServerLanguageModule`. An adopter who adds
24
24
  * only the language module gets a server that boots, links and serves
25
- * completion, and quietly runs Langium's `DefaultDocumentUpdateHandler`: echo
26
- * suppression for the server's own writes, the `didChangeContent` debounce,
27
- * `markNextReason` stamping and the last-client-close rebuild are all absent,
28
- * with nothing in the log to say so.
25
+ * completion, and quietly runs Langium's `DefaultDocumentUpdateHandler`: every
26
+ * behaviour `HydraniumDocumentUpdateHandler` adds, the editor save gate
27
+ * included, is absent, with nothing in the log to say so.
29
28
  *
30
29
  * The type system does not catch it. `createIntegrationServices` returns
31
30
  * `inject(...) as unknown as TShared`, so widening `TShared` with
@@ -23,12 +23,18 @@ import { DefaultServerLocale, type ServerLocale } from '../locale/server-locale.
23
23
  import { DefaultMessageRenderer, type MessageRenderer } from '../messages/renderer.js';
24
24
  import type { Harness } from '@hydranium/protocol/testing';
25
25
  import { type AstNode, type AstNodeDescription, type WorkspaceLock } from '@hydranium/langium';
26
+ import {
27
+ type ClientSessionFactory,
28
+ type ClientSessionFactoryOptions,
29
+ DefaultClientSessionFactory
30
+ } from '../langium/model-service/client-session.js';
26
31
  import type { ModelService, ModelServiceOptions } from '../langium/model-service/model-service.js';
27
32
  import type { ServerSharedServices } from '../langium/module.js';
28
33
  import type { AstDiagnostic } from '../langium/validation/document-validator.js';
29
34
  import { DefaultTransferEncoder, type TransferEncoder } from '../langium/transfer/transfer-encoder.js';
30
35
  import { DefaultDocumentUriPolicy, type DocumentUriPolicy } from '../langium/workspace/document-uri-policy.js';
31
36
  import { HydraniumWorkspaceLock } from '../langium/workspace/hydranium-workspace-lock.js';
37
+ import { DefaultFileSystemTaskQueue, type FileSystemTaskQueue } from '../documents/file-system-task-queue.js';
32
38
  import type { FakeDocumentOptions } from './fake-document.js';
33
39
  import { makeStubDocumentBuilder, type StubDocumentBuilder } from './stub-document-builder.js';
34
40
  import { makeStubIndexManager, type StubIndexManager } from './stub-index-manager.js';
@@ -85,6 +91,8 @@ export interface TestSharedServices<
85
91
  SelfSaveRegistry: StubSelfSaveRegistry;
86
92
  ProjectManager: StubProjectManager<TProject>;
87
93
  DocumentUriPolicy: DocumentUriPolicy;
94
+ /** The REAL queue: a stub that runs each task at once hides the save interleaving the queue exists to prevent. */
95
+ FileSystemTaskQueue: FileSystemTaskQueue;
88
96
  /**
89
97
  * Present only when {@link MakeTestServicesOptions.seedIndex} was passed.
90
98
  * Omitted otherwise for the same reason as `ServiceRegistry` — see that
@@ -105,6 +113,7 @@ export interface TestSharedServices<
105
113
  readonly model: {
106
114
  TransferEncoder: TransferEncoder<TTransferDiagnostic>;
107
115
  ModelService: ModelService<TAst, TDiagnostic, TTransfer>;
116
+ ClientSessionFactory: ClientSessionFactory;
108
117
  };
109
118
  /**
110
119
  * The REAL services, not stubs — the framework renderer's no-catalogue
@@ -151,7 +160,7 @@ export interface MakeTestServicesOptions<
151
160
  *
152
161
  * **Default: no `ServiceRegistry` slot at all.** Binding an empty registry
153
162
  * by default would silently change behaviour for the tests that rely on the
154
- * slot being absent — `ModelService.updateRewriteService` optional-chains it
163
+ * slot being absent — `ModelService.rewriteModel` optional-chains it
155
164
  * precisely for those bundles — so a registry appears only when a test asks
156
165
  * for one.
157
166
  */
@@ -209,11 +218,18 @@ export interface MakeTestServicesOptions<
209
218
  modelService?: (services: ServerSharedServices<TProject>) => ModelService<TAst, TDiagnostic, TTransfer>;
210
219
  /**
211
220
  * Framework {@link ModelServiceOptions} for the DEFAULT stub service, so a
212
- * test can reach an option-gated path (`serializeBuilds`, the slow-warn
213
- * threshold) without supplying a whole {@link modelService} factory. Ignored
214
- * when `modelService` is given — that factory owns its own construction.
221
+ * test can reach an option-gated path (`serializeBuilds`) without supplying
222
+ * a whole {@link modelService} factory. Ignored when `modelService` is
223
+ * given — that factory owns its own construction.
215
224
  */
216
225
  modelServiceOptions?: ModelServiceOptions;
226
+ /**
227
+ * Options of the {@link DefaultClientSessionFactory} bound on
228
+ * `model.ClientSessionFactory`, so a test can reach the slow-update warn.
229
+ * Its sessions serialise and rebuild through the `model.ModelService` slot,
230
+ * so a test of a model service subclass passes it as {@link modelService}.
231
+ */
232
+ clientSessionFactoryOptions?: ClientSessionFactoryOptions;
217
233
  /**
218
234
  * Override the {@link TransferEncoder} factory. Default: framework
219
235
  * {@link TransferEncoder} with no overrides.
@@ -326,7 +342,7 @@ export function makeTestServices<
326
342
  // is dependency-free (as `HydraniumWorkspaceLock` is) — a no-op stub of a
327
343
  // synchronisation primitive hides the very interleaving it exists to prevent.
328
344
  //
329
- // `model.TransferEncoder` / `model.ModelService` are populated below
345
+ // The `model` slots are populated below
330
346
  // (after the factories run); the literal uses unsafe casts to
331
347
  // partially-built objects to satisfy TestSharedServices, then patches
332
348
  // them in.
@@ -350,7 +366,8 @@ export function makeTestServices<
350
366
  // Spread for the same reason as `ServiceRegistry` above — the slot must
351
367
  // be genuinely ABSENT, not present-and-undefined, when no index was seeded.
352
368
  ...(indexManager ? { IndexManager: indexManager } : {}),
353
- WorkspaceLock: new HydraniumWorkspaceLock()
369
+ WorkspaceLock: new HydraniumWorkspaceLock(),
370
+ FileSystemTaskQueue: new DefaultFileSystemTaskQueue({ workspace: { DocumentUriPolicy: documentUriPolicy } })
354
371
  },
355
372
  model: {} as TestSharedServices<TAst, TDiagnostic, TTransfer, TProject, TTransferDiagnostic>['model'],
356
373
  ServerLocale: {} as ServerLocale,
@@ -380,9 +397,11 @@ export function makeTestServices<
380
397
  const mutableModel = services.model as {
381
398
  TransferEncoder: TransferEncoder<TTransferDiagnostic>;
382
399
  ModelService: ModelService<TAst, TDiagnostic, TTransfer>;
400
+ ClientSessionFactory: ClientSessionFactory;
383
401
  };
384
402
  mutableModel.TransferEncoder = transferEncoder;
385
403
  mutableModel.ModelService = modelService;
404
+ mutableModel.ClientSessionFactory = new DefaultClientSessionFactory(sharedServices, options.clientSessionFactoryOptions);
386
405
 
387
406
  return {
388
407
  services: sharedServices,
@@ -301,7 +301,7 @@ export interface LspServerConnection extends Harness {
301
301
  *
302
302
  * **An egress push is asynchronous well past the call that triggers it** —
303
303
  * the framework coalesces per URI and drives the sync from a build-phase
304
- * settle, so a `ModelService.update` resolves long before the push is sent.
304
+ * settle, so a session's `update` resolves long before the push is sent.
305
305
  * Record `appliedEdits.length`, drive the edit, then await this; reading the
306
306
  * capture on the line after the write proves nothing.
307
307
  */
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * Any test that drives a **write path** needs one, and the reason is not
16
16
  * obvious enough to rediscover per suite. The writes themselves are usually
17
- * in-memory — `ModelService.update` goes to the multi-client text store, and
17
+ * in-memory — a session's `update` goes to the multi-client text store, and
18
18
  * only `save` reaches disk — but a rebuild also runs the integrity rules, and
19
19
  * their default `'silent'` sync mode persists repairs through
20
20
  * `WritableFileSystemProvider.writeFile`. So a write-path test pointed at a
@@ -9,7 +9,7 @@
9
9
 
10
10
  /**
11
11
  * Driver for the structured-write transform chain — the rewrite registry plus
12
- * the serializer — run the way `ModelService.update` runs it.
12
+ * the serializer — run the way a session's `update` runs it.
13
13
  *
14
14
  * # Why a driver and not a documented recipe
15
15
  *
@@ -38,7 +38,7 @@
38
38
  * This runs rewrite → serialize and stops. It does NOT open the document, push
39
39
  * text into the multi-client store, drive a build or return an
40
40
  * {@link AstDocument}: those need a live workspace, and a test that wants them
41
- * should call `ModelService.update` itself rather than a driver standing in for
41
+ * should call a session's `update` itself rather than a driver standing in for
42
42
  * it. What is left is the part with no observable of its own — the text and the
43
43
  * rewritten model — which is why it needed lifting.
44
44
  *
@@ -61,7 +61,7 @@ export interface UpdatePipelineArgs<TTransfer extends TransferElement = Transfer
61
61
  * rather than merely labelling it.
62
62
  */
63
63
  readonly uri: string;
64
- /** Transfer-model payload, as an adopter caller would hand it to `ModelService.update`. */
64
+ /** Transfer-model payload, as an adopter caller would hand it to a session's `update`. */
65
65
  readonly model: TTransfer;
66
66
  /**
67
67
  * Previous AST root threaded into every rewrite, so a diff-based rewrite can
@@ -90,7 +90,7 @@ export interface UpdatePipelineResult<TTransfer extends TransferElement = Transf
90
90
  * surface but it makes the precise assertion unavailable.
91
91
  */
92
92
  readonly rewritten: TTransfer;
93
- /** The serialized text, which is what `ModelService.update` applies to the store. */
93
+ /** The serialized text, which is what a session's `update` applies to the store. */
94
94
  readonly text: string;
95
95
  }
96
96