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

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 (62) hide show
  1. package/README.md +3 -1
  2. package/lib/documents/ast-document-manager.d.ts +52 -2
  3. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  4. package/lib/documents/ast-document-manager.js +67 -5
  5. package/lib/documents/ast-document-manager.js.map +1 -1
  6. package/lib/documents/client-session-errors.d.ts +44 -0
  7. package/lib/documents/client-session-errors.d.ts.map +1 -0
  8. package/lib/documents/client-session-errors.js +57 -0
  9. package/lib/documents/client-session-errors.js.map +1 -0
  10. package/lib/documents/client-session-registry.d.ts +118 -0
  11. package/lib/documents/client-session-registry.d.ts.map +1 -0
  12. package/lib/documents/client-session-registry.js +171 -0
  13. package/lib/documents/client-session-registry.js.map +1 -0
  14. package/lib/documents/hydranium-text-documents.d.ts +69 -17
  15. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  16. package/lib/documents/hydranium-text-documents.js +135 -44
  17. package/lib/documents/hydranium-text-documents.js.map +1 -1
  18. package/lib/documents/index.d.ts +2 -0
  19. package/lib/documents/index.d.ts.map +1 -1
  20. package/lib/documents/index.js +2 -0
  21. package/lib/documents/index.js.map +1 -1
  22. package/lib/langium/integrity/integrity-service.d.ts +8 -7
  23. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  24. package/lib/langium/integrity/integrity-service.js +35 -27
  25. package/lib/langium/integrity/integrity-service.js.map +1 -1
  26. package/lib/langium/model-service/client-session.d.ts +120 -0
  27. package/lib/langium/model-service/client-session.d.ts.map +1 -0
  28. package/lib/langium/model-service/client-session.js +175 -0
  29. package/lib/langium/model-service/client-session.js.map +1 -0
  30. package/lib/langium/model-service/index.d.ts +1 -0
  31. package/lib/langium/model-service/index.d.ts.map +1 -1
  32. package/lib/langium/model-service/index.js +1 -0
  33. package/lib/langium/model-service/index.js.map +1 -1
  34. package/lib/langium/model-service/model-service.d.ts +57 -1
  35. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  36. package/lib/langium/model-service/model-service.js +123 -9
  37. package/lib/langium/model-service/model-service.js.map +1 -1
  38. package/lib/lsp/hydranium-document-update-handler.d.ts +6 -0
  39. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  40. package/lib/lsp/hydranium-document-update-handler.js +20 -3
  41. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  42. package/lib/testing/stub-ast-document-manager.d.ts +4 -3
  43. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  44. package/lib/testing/stub-ast-document-manager.js +3 -0
  45. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  46. package/lib/testing/stub-hydranium-text-documents.d.ts +13 -5
  47. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  48. package/lib/testing/stub-hydranium-text-documents.js +45 -14
  49. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  50. package/package.json +5 -5
  51. package/src/documents/ast-document-manager.ts +107 -6
  52. package/src/documents/client-session-errors.ts +54 -0
  53. package/src/documents/client-session-registry.ts +211 -0
  54. package/src/documents/hydranium-text-documents.ts +150 -47
  55. package/src/documents/index.ts +2 -0
  56. package/src/langium/integrity/integrity-service.ts +36 -26
  57. package/src/langium/model-service/client-session.ts +257 -0
  58. package/src/langium/model-service/index.ts +1 -0
  59. package/src/langium/model-service/model-service.ts +151 -9
  60. package/src/lsp/hydranium-document-update-handler.ts +22 -4
  61. package/src/testing/stub-ast-document-manager.ts +7 -2
  62. package/src/testing/stub-hydranium-text-documents.ts +62 -18
@@ -0,0 +1,257 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, 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 { type MaybePromise, type TransferElement, type TransferSaveArgs, type TransferUpdateArgs } from '@hydranium/protocol';
11
+ import { type AstNode, UriUtils } from '@hydranium/langium';
12
+ import { type CancellationToken } from 'vscode-languageserver';
13
+ import { type AstDocument } from '../../documents/ast-document-manager.js';
14
+ import { SessionClosedError } from '../../documents/client-session-errors.js';
15
+ import { type OpenOptions } from '../../documents/client-session-registry.js';
16
+ import { type ServerSharedServices } from '../module.js';
17
+ import { type AstDiagnostic } from '../validation/document-validator.js';
18
+ import { type ModelService } from './model-service.js';
19
+
20
+ /**
21
+ * One participant's handle on the documents it works on, started by
22
+ * `ModelService.createSession`.
23
+ *
24
+ * The session writes only what it has open: `update` and `save` fail with
25
+ * `DocumentNotOpenError` unless this session has the URI open when the text is
26
+ * applied, and the check and the apply are one synchronous step, so a write
27
+ * either lands while the document is open or fails. A document stays open until
28
+ * this session closes it, the session ends, or the file is deleted.
29
+ *
30
+ * One open per URI, without reference counting: opening a URI the session
31
+ * already has open changes nothing, and one `close` ends it.
32
+ *
33
+ * After `dispose`, every other member throws `SessionClosedError`
34
+ * synchronously, before returning a promise.
35
+ *
36
+ * Reads need no session and live on `ModelService`.
37
+ */
38
+ export interface ClientSession<
39
+ TAst extends AstNode,
40
+ TDiagnostic extends AstDiagnostic = AstDiagnostic,
41
+ TTransfer extends TransferElement = TransferElement,
42
+ TOpenOptions extends OpenOptions = OpenOptions
43
+ > {
44
+ /** Unique in the process while the session is live: the author label on its writes and the key of its opens. */
45
+ readonly clientId: string;
46
+ readonly label: string;
47
+
48
+ /**
49
+ * Open `uri` for this session, reading it from disk unless some client has
50
+ * it open already. `options` are kept for this open until it closes; a
51
+ * repeat open, concurrent or not, keeps the options of the first.
52
+ */
53
+ open(uri: string, options?: TOpenOptions): Promise<void>;
54
+ /**
55
+ * Create a document with `text` and open it for this session. It reaches
56
+ * disk on the first `save`. Fails when the file exists or any client has the
57
+ * URI open, and of two creates of one URI at most one succeeds.
58
+ */
59
+ create(uri: string, text: string): Promise<void>;
60
+ update(args: Omit<TransferUpdateArgs<TTransfer>, 'clientId'>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
61
+ /** Write `args.model` as {@link update} does, then persist the document. */
62
+ save(args: Omit<TransferSaveArgs<TTransfer>, 'clientId'>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
63
+ /** Close this session's open of `uri`, at once. A no-op when it does not have `uri` open. */
64
+ close(uri: string): Promise<void>;
65
+ /**
66
+ * Open `uri`, run `fn`, and close `uri` when `fn` settles, whether it
67
+ * returned or threw. A URI the session already had open, or is still
68
+ * opening through another call, stays open: the close undoes only the open
69
+ * this call made.
70
+ */
71
+ withOpen<T>(uri: string, fn: () => MaybePromise<T>): Promise<T>;
72
+ /** Whether an event's `sourceClientId` names this session, i.e. the event echoes its own write. */
73
+ isOwnEcho(sourceClientId: string): boolean;
74
+ /** End the session: close everything it has open and free its id. Idempotent. */
75
+ dispose(): void;
76
+ }
77
+
78
+ /**
79
+ * The framework's {@link ClientSession}, built by
80
+ * `DefaultModelService.newSession`.
81
+ *
82
+ * Writes go through the owning `ModelService`, so its overrides apply to a
83
+ * session's writes as they do to anyone's. The open rule is enforced there, not
84
+ * here, because only the service reaches the point where the text is applied.
85
+ *
86
+ * An override of {@link dispose} calls `super.dispose()`: that is what closes
87
+ * the session's opens and frees its id.
88
+ */
89
+ export class DefaultClientSession<
90
+ TAst extends AstNode,
91
+ TDiagnostic extends AstDiagnostic = AstDiagnostic,
92
+ TTransfer extends TransferElement = TransferElement,
93
+ TOpenOptions extends OpenOptions = OpenOptions
94
+ > implements ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions> {
95
+ protected disposed = false;
96
+ /**
97
+ * This session's opens still under way, by canonical URI. Recorded before
98
+ * the first await, so a second open of the same URI joins the first rather
99
+ * than racing it, and `withOpen` can tell an open another call is making.
100
+ */
101
+ protected readonly pendingOpens = new Map<string, Promise<void>>();
102
+
103
+ constructor(
104
+ protected readonly modelService: ModelService<TAst, TDiagnostic, TTransfer>,
105
+ protected readonly services: ServerSharedServices,
106
+ readonly clientId: string,
107
+ readonly label: string
108
+ ) {}
109
+
110
+ open(uri: string, options?: TOpenOptions): Promise<void> {
111
+ this.assertLive();
112
+ return this.openDocument(uri, options);
113
+ }
114
+
115
+ create(uri: string, text: string): Promise<void> {
116
+ this.assertLive();
117
+ return this.createDocument(uri, text);
118
+ }
119
+
120
+ update(args: Omit<TransferUpdateArgs<TTransfer>, 'clientId'>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
121
+ this.assertLive();
122
+ return this.modelService.update({ ...args, clientId: this.clientId }, cancelToken);
123
+ }
124
+
125
+ save(args: Omit<TransferSaveArgs<TTransfer>, 'clientId'>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
126
+ this.assertLive();
127
+ return this.modelService.save({ ...args, clientId: this.clientId }, cancelToken);
128
+ }
129
+
130
+ close(uri: string): Promise<void> {
131
+ this.assertLive();
132
+ return this.modelService.close({ uri, clientId: this.clientId });
133
+ }
134
+
135
+ withOpen<T>(uri: string, fn: () => MaybePromise<T>): Promise<T> {
136
+ this.assertLive();
137
+ return this.runWithOpen(uri, fn);
138
+ }
139
+
140
+ isOwnEcho(sourceClientId: string): boolean {
141
+ this.assertLive();
142
+ return sourceClientId === this.clientId;
143
+ }
144
+
145
+ dispose(): void {
146
+ if (this.disposed) {
147
+ return;
148
+ }
149
+ this.disposed = true;
150
+ this.services.workspace.TextDocuments.closeSession(this.clientId);
151
+ }
152
+
153
+ protected assertLive(): void {
154
+ if (this.disposed) {
155
+ throw new SessionClosedError(this.clientId);
156
+ }
157
+ }
158
+
159
+ /** Open `uri`, or join the open of it this session already has under way, whose options then stand. */
160
+ protected openDocument(uri: string, options: TOpenOptions | undefined): Promise<void> {
161
+ const key = this.canonicalKey(uri);
162
+ const pending = this.pendingOpens.get(key);
163
+ if (pending) {
164
+ return pending;
165
+ }
166
+ const opening = this.registerOpen(uri, options).finally(() => {
167
+ if (this.pendingOpens.get(key) === opening) {
168
+ this.pendingOpens.delete(key);
169
+ }
170
+ });
171
+ this.pendingOpens.set(key, opening);
172
+ return opening;
173
+ }
174
+
175
+ protected async registerOpen(uri: string, options: TOpenOptions | undefined): Promise<void> {
176
+ const textDocuments = this.services.workspace.TextDocuments;
177
+ const wasOpen = textDocuments.isOpenInClient(uri, this.clientId);
178
+ await this.modelService.open({ uri, clientId: this.clientId });
179
+ await this.rejectIfEnded(uri, wasOpen);
180
+ if (!wasOpen && options !== undefined) {
181
+ textDocuments.setOpenOptions(uri, this.clientId, options);
182
+ }
183
+ }
184
+
185
+ protected canonicalKey(uri: string): string {
186
+ return this.services.workspace.DocumentUriPolicy.canonicalUri(uri);
187
+ }
188
+
189
+ protected async createDocument(uri: string, text: string): Promise<void> {
190
+ if (await this.services.workspace.FileSystemProvider.exists(UriUtils.toUri(uri))) {
191
+ throw new Error(`Cannot create ${uri}: the file exists`);
192
+ }
193
+ this.assertLive();
194
+ if (this.modelService.isOpen(uri)) {
195
+ throw new Error(`Cannot create ${uri}: it is open in a client`);
196
+ }
197
+ // The check above cannot see an open that registers while this one is
198
+ // under way, and opening an already-open URI only attaches, dropping
199
+ // `text`. Only an open that creates the document fires `onDidOpen`, so
200
+ // hearing it for this session, during this open, is what proves the
201
+ // document holds `text`; the client list afterwards cannot, since a
202
+ // client that registered first may have closed again by then.
203
+ const key = this.canonicalKey(uri);
204
+ let created = false;
205
+ const listener = this.services.workspace.TextDocuments.onDidOpen(event => {
206
+ if (event.clientId === this.clientId && event.document.uri === key) {
207
+ created = true;
208
+ }
209
+ });
210
+ try {
211
+ await this.modelService.open({ uri, clientId: this.clientId, text });
212
+ } finally {
213
+ listener.dispose();
214
+ }
215
+ await this.rejectIfEnded(uri, false);
216
+ if (!created) {
217
+ await this.modelService.close({ uri, clientId: this.clientId });
218
+ throw new Error(`Cannot create ${uri}: it is open in a client`);
219
+ }
220
+ }
221
+
222
+ protected async runWithOpen<T>(uri: string, fn: () => MaybePromise<T>): Promise<T> {
223
+ // An open another call of this session is still making counts as made:
224
+ // closing it afterwards would take away an open that call asked for.
225
+ const alreadyOpen =
226
+ this.services.workspace.TextDocuments.isOpenInClient(uri, this.clientId) || this.pendingOpens.has(this.canonicalKey(uri));
227
+ await this.openDocument(uri, undefined);
228
+ try {
229
+ return await fn();
230
+ } finally {
231
+ if (!alreadyOpen && !this.disposed) {
232
+ await this.modelService.close({ uri, clientId: this.clientId });
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Undo an open that completed after the session ended, then fail it.
239
+ *
240
+ * Ending the session closed only what it had open at that moment. An open
241
+ * still reading from disk registers afterwards, under an id that is no
242
+ * longer a session, and nothing would ever close it.
243
+ *
244
+ * Opens are keyed by id, so once a new session holds the same id this open
245
+ * is indistinguishable from one of its own, and closing it would close the
246
+ * new session's. It is left to that session, which releases it at its end.
247
+ */
248
+ protected async rejectIfEnded(uri: string, wasOpen: boolean): Promise<void> {
249
+ if (!this.disposed) {
250
+ return;
251
+ }
252
+ if (!wasOpen && this.modelService.getSession(this.clientId) === undefined) {
253
+ await this.modelService.close({ uri, clientId: this.clientId });
254
+ }
255
+ throw new SessionClosedError(this.clientId);
256
+ }
257
+ }
@@ -8,3 +8,4 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  export * from './model-service.js';
11
+ export * from './client-session.js';
@@ -8,6 +8,7 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  import {
11
+ type BasedOn,
11
12
  type CanonicalUri,
12
13
  type CloseModelArgs,
13
14
  ConflictError,
@@ -34,7 +35,10 @@ import { type LogNameOptions } from '../diagnostics/logger.js';
34
35
  import { IntegrityService } from '../integrity/integrity-service.js';
35
36
  import { labelPhaseListener } from '../document-builder/labeled-phase-listener.js';
36
37
  import { LANGUAGE_CLIENT_ID } from '../../documents/client-ids.js';
38
+ import { DocumentNotOpenError } from '../../documents/client-session-errors.js';
39
+ import { type OpenOptions } from '../../documents/client-session-registry.js';
37
40
  import { type ServerSharedServices } from '../module.js';
41
+ import { type ClientSession, DefaultClientSession } from './client-session.js';
38
42
 
39
43
  /**
40
44
  * The undo-stack entry for a server-authored write pushed to the editor.
@@ -274,6 +278,21 @@ export interface ModelService<
274
278
  onModelUpdated(uri: string, listener: (event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void): Disposable;
275
279
  onModelSaved(uri: string, listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void): Disposable;
276
280
  onClientClosed(uri: string, clientId: string, listener: () => void): Disposable;
281
+
282
+ /**
283
+ * Start a client session. The id defaults to `label#` plus a random UUID;
284
+ * a fixed `clientId` is taken as given. Throws `DuplicateClientIdError` when
285
+ * the id is reserved by the framework, held by another live session, or has
286
+ * documents open under it as a client that is not a session.
287
+ *
288
+ * `TOpenOptions` types the options the session's `open` takes.
289
+ */
290
+ createSession<TOpenOptions extends OpenOptions = OpenOptions>(
291
+ label?: string,
292
+ clientId?: string
293
+ ): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>;
294
+ /** The live session started under `clientId`, or `undefined` once it has ended or was never started. */
295
+ getSession(clientId: string): ClientSession<TAst, TDiagnostic, TTransfer> | undefined;
277
296
  }
278
297
 
279
298
  export class DefaultModelService<
@@ -318,6 +337,20 @@ export class DefaultModelService<
318
337
  protected readonly syncChains = new Map<string, Promise<void>>();
319
338
  protected readonly pendingSync = new Map<string, string>();
320
339
 
340
+ /**
341
+ * The live sessions this service started, by client id. {@link update}
342
+ * reads it to tell a session's write, which must find its document open,
343
+ * from a write under a plain client id, which opens it.
344
+ */
345
+ protected readonly sessions = new Map<string, ClientSession<TAst, TDiagnostic, TTransfer>>();
346
+ /**
347
+ * Drops an ended session from {@link sessions}. Subscribed by the first
348
+ * {@link createSession} rather than at construction, so a services tree with
349
+ * no text store, or one that knows nothing of sessions, still constructs
350
+ * this service.
351
+ */
352
+ protected sessionCloseListener?: Disposable;
353
+
321
354
  /**
322
355
  * Workspace-level readiness gate. Resolves when the framework has
323
356
  * finished its first workspace build cycle and the `ModelService`
@@ -681,8 +714,19 @@ export class DefaultModelService<
681
714
  * with vN's specific settled snapshot, intermediate-phase
682
715
  * observability, slow-warn / hard-timeout behaviour) override this
683
716
  * method.
717
+ *
718
+ * **Upsert, except for a session.** Under a plain client id the write opens
719
+ * the document for that client first, creating it from the payload when no
720
+ * file exists. Under the id of a live session from {@link createSession} it
721
+ * opens nothing, and fails with `DocumentNotOpenError` unless the session
722
+ * has the URI open at the moment the text is applied.
684
723
  */
685
724
  async update(args: TransferUpdateArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
725
+ // Read before the first await, while the call is still the caller's: a
726
+ // session that ends during the serialise would otherwise read as a plain
727
+ // client id here, and the write would open the document for an id no
728
+ // session owns any more and land there.
729
+ const bySession = this.sessions.has(args.clientId);
686
730
  const stopwatch = this.slowUpdateWarn !== undefined ? this.services.Clock.stopwatch() : undefined;
687
731
  // Per-stage self-time breakdown of the update/reconcile chain (serialise →
688
732
  // open → apply → rebuild), opt-in at debug — the default path skips the
@@ -731,16 +775,34 @@ export class DefaultModelService<
731
775
  // reason to close a document it never asked to open. Serialisation is
732
776
  // held back too; it is the adopter's code and need not be side-effect
733
777
  // free.
734
- const currentVersion = this.services.workspace.TextDocuments.version(uri);
735
- if (isSnapshotVersion(args.basedOn) && currentVersion !== args.basedOn) {
736
- // Distinct from the post-build "superseded" debug line below: this is a
737
- // based-on-stale rejection (the write never applies), not two writes racing.
738
- this.tracer.debug(`Conflict on ${uri}: based-on v${args.basedOn} stale, server at v${currentVersion}`);
739
- throw new ConflictError(uri, args.basedOn, currentVersion);
740
- }
778
+ const textDocuments = this.services.workspace.TextDocuments;
779
+ this.checkBasedOn(uri, args.basedOn, textDocuments.version(uri));
780
+ // Whether this call's own open is the one that creates the document, read
781
+ // in the same step as the check above so no other open can land between.
782
+ const opensCold = !bySession && textDocuments.get(uri) === undefined;
741
783
  const text = await run('serialize', () => this.modelToText(uri, args.model, cancelToken));
742
- await run('open', () => this.open({ uri, clientId: args.clientId, text }));
743
- const appliedVersion = await run('apply', () => this.services.workspace.AstDocumentManager.update(uri, text, args.clientId));
784
+ if (!bySession) {
785
+ await run('open', () => this.open({ uri, clientId: args.clientId, text }));
786
+ }
787
+ const appliedVersion = await run('apply', () => {
788
+ // A session's write never opens its document. The check sits in the
789
+ // same synchronous step as the apply: made before the serialize await
790
+ // instead, it passes for a write whose session closes the document
791
+ // during that await, and the write then lands on a document the
792
+ // session no longer has open.
793
+ if (bySession && !textDocuments.isOpenInClient(uri, args.clientId)) {
794
+ throw new DocumentNotOpenError(uri, args.clientId);
795
+ }
796
+ // The gate again, in the same step as the apply: two writes based on
797
+ // one version both pass the check at the door while they serialise,
798
+ // and without this both apply. The version a cold open assigned from
799
+ // this write's own text is not a conflict; the store holding exactly
800
+ // that text is how it shows.
801
+ if (!(opensCold && textDocuments.get(uri)?.getText() === text)) {
802
+ this.checkBasedOn(uri, args.basedOn, textDocuments.version(uri));
803
+ }
804
+ return this.services.workspace.AstDocumentManager.update(uri, text, args.clientId);
805
+ });
744
806
  // Dispatch through the public `rebuild` (which re-canonicalizes the already-
745
807
  // canonical `uri` once, idempotently) rather than `rebuildCanonical`, so an
746
808
  // adopter `rebuild` override stays in the update path. The redundant call is
@@ -765,6 +827,23 @@ export class DefaultModelService<
765
827
  return doc;
766
828
  }
767
829
 
830
+ /**
831
+ * Throw {@link ConflictError} when `basedOn` names a version other than
832
+ * `currentVersion`. {@link update} calls it at the door and again in the
833
+ * synchronous step that applies the text, and has to call it synchronously
834
+ * there: a check separated from the apply by an await lets two writes based
835
+ * on one version both apply.
836
+ */
837
+ protected checkBasedOn(uri: string, basedOn: BasedOn, currentVersion: number): void {
838
+ if (isSnapshotVersion(basedOn) && currentVersion !== basedOn) {
839
+ // Distinct from the post-build "superseded" debug line in `update`: this
840
+ // is a based-on-stale rejection (the write never applies), not two
841
+ // writes racing.
842
+ this.tracer.debug(`Conflict on ${uri}: based-on v${basedOn} stale, server at v${currentVersion}`);
843
+ throw new ConflictError(uri, basedOn, currentVersion);
844
+ }
845
+ }
846
+
768
847
  /**
769
848
  * Persist `uri` to disk. Same content-change + settled-phase flow as
770
849
  * {@link update}, then writes via the
@@ -773,8 +852,16 @@ export class DefaultModelService<
773
852
  * the `onDidSave` event regardless of who originated the persist).
774
853
  *
775
854
  * Returns the post-save AST snapshot.
855
+ *
856
+ * Under the id of a live session the save also fails with
857
+ * `DocumentNotOpenError` when the session closes the URI while the text is
858
+ * being built, and writes nothing. Once the manager has taken the text, the
859
+ * write completes whatever the session does next.
776
860
  */
777
861
  async save(args: TransferSaveArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
862
+ // Read before the first await, as in `update`: a session that ends during
863
+ // the rebuild would otherwise read as a plain client id below.
864
+ const bySession = this.sessions.has(args.clientId);
778
865
  // Dispatch through `update` (not its internals) so an adopter `update`
779
866
  // override — version-matched resolution, etc. — applies to saves too.
780
867
  const doc = await this.update(args, cancelToken);
@@ -782,6 +869,12 @@ export class DefaultModelService<
782
869
  // the canonical (real) path follows any symlink to the same file, and the
783
870
  // `onDidSave` keys the one canonical registration.
784
871
  const uri = this.uriPolicy.canonicalUri(args.uri);
872
+ // In the same synchronous step as the manager taking the text: checked
873
+ // any earlier, a session that closes the URI during the rebuild still has
874
+ // the shared text, other clients' edits included, written in its name.
875
+ if (bySession && !this.services.workspace.TextDocuments.isOpenInClient(uri, args.clientId)) {
876
+ throw new DocumentNotOpenError(uri, args.clientId);
877
+ }
785
878
  await this.services.workspace.AstDocumentManager.save(uri, args.clientId);
786
879
  return doc;
787
880
  }
@@ -906,6 +999,55 @@ export class DefaultModelService<
906
999
  return this.services.workspace.AstDocumentManager.getDocument(uri);
907
1000
  }
908
1001
 
1002
+ // ============================================================
1003
+ // Client sessions
1004
+ // ============================================================
1005
+
1006
+ createSession<TOpenOptions extends OpenOptions = OpenOptions>(
1007
+ label?: string,
1008
+ clientId?: string
1009
+ ): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions> {
1010
+ const sessionLabel = label ?? 'session';
1011
+ const id = clientId ?? `${sessionLabel}#${globalThis.crypto.randomUUID()}`;
1012
+ const textDocuments = this.services.workspace.TextDocuments;
1013
+ textDocuments.registerSession(id);
1014
+ this.sessionCloseListener ??= textDocuments.onDidCloseSession(event => {
1015
+ // Also reached when the store ends a session directly; disposing the
1016
+ // handle makes its later calls fail rather than write under an id
1017
+ // this service no longer treats as a session.
1018
+ this.sessions.get(event.clientId)?.dispose();
1019
+ this.sessions.delete(event.clientId);
1020
+ });
1021
+ let session: ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>;
1022
+ try {
1023
+ session = this.newSession<TOpenOptions>(id, sessionLabel);
1024
+ } catch (err: unknown) {
1025
+ textDocuments.closeSession(id);
1026
+ throw err;
1027
+ }
1028
+ this.sessions.set(id, session);
1029
+ return session;
1030
+ }
1031
+
1032
+ getSession(clientId: string): ClientSession<TAst, TDiagnostic, TTransfer> | undefined {
1033
+ return this.sessions.get(clientId);
1034
+ }
1035
+
1036
+ /**
1037
+ * Build the handle for a session {@link createSession} has already
1038
+ * registered. The override point for a custom session class; narrow the
1039
+ * return type to it.
1040
+ *
1041
+ * Registration and the uniqueness check have already happened when this
1042
+ * runs, so an override cannot skip them.
1043
+ */
1044
+ protected newSession<TOpenOptions extends OpenOptions = OpenOptions>(
1045
+ clientId: string,
1046
+ label: string
1047
+ ): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions> {
1048
+ return new DefaultClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>(this, this.services, clientId, label);
1049
+ }
1050
+
909
1051
  // ============================================================
910
1052
  // LSP-client sync (mirror non-LSP-client changes back to Monaco)
911
1053
  // ============================================================
@@ -133,7 +133,10 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
133
133
  */
134
134
  protected pendingReason?: string;
135
135
 
136
- constructor(services: ServerSharedServices, options: HydraniumDocumentUpdateHandlerOptions = {}) {
136
+ constructor(
137
+ protected readonly services: ServerSharedServices,
138
+ options: HydraniumDocumentUpdateHandlerOptions = {}
139
+ ) {
137
140
  super(services);
138
141
  this.logger = services.Logger;
139
142
  this.selfSaveRegistry = services.workspace.SelfSaveRegistry;
@@ -201,6 +204,11 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
201
204
  *
202
205
  * Per-client closes (where other clients still hold the URI) are
203
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.
204
212
  */
205
213
  didCloseDocument(change: TextDocumentChangeEvent<TextDocument>): void {
206
214
  if (this.textDocuments.isOpenInAnyClient(change.document.uri)) {
@@ -210,9 +218,19 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
210
218
  if (uri.scheme !== 'file') {
211
219
  return;
212
220
  }
213
- this.nextReason = HYDRANIUM_BUILD_REASONS.didClose;
214
- this.immediateFlush = true;
215
- this.fireDocumentUpdate([uri], []);
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
+ });
216
234
  }
217
235
 
218
236
  override didChangeWatchedFiles(params: DidChangeWatchedFilesParams): void {
@@ -24,8 +24,9 @@ import type { StubLangiumDocuments } from './stub-langium-documents.js';
24
24
  * Minimal stub of {@link AstDocumentManager} for use in test
25
25
  * harnesses. Implements just the methods the framework's
26
26
  * `ModelService` calls (`open` / `close` / `update` / `save` /
27
- * `isOpen` / `getDocument`); `onSave` / `onClientClosed` forward to the
28
- * underlying stub text-document manager, `onUpdate` is driven explicitly
27
+ * `isOpen` / `getDocument`) and a disk queue that runs each task at once;
28
+ * `onSave` / `onClientClosed` forward to the underlying stub text store,
29
+ * `onUpdate` is driven explicitly
29
30
  * through {@link StubAstDocumentManager.emitUpdate} because the stub tree runs
30
31
  * no build phases to emit from, and the remaining read helpers
31
32
  * (`isTriggeringEdit`, `getAuthor`) throw if invoked — tests that need them
@@ -52,6 +53,7 @@ export interface StubAstDocumentManager<TAst extends AstNode, TDiagnostic extend
52
53
  | 'isOpen'
53
54
  | 'update'
54
55
  | 'save'
56
+ | 'queueDiskTask'
55
57
  | 'onUpdate'
56
58
  | 'onSave'
57
59
  | 'onClientClosed'
@@ -151,6 +153,9 @@ export function makeStubAstDocumentManager<TAst extends AstNode, TDiagnostic ext
151
153
  await fileSystem.writeFile(UriUtils.toUri(uri), text);
152
154
  textDocuments.notifyDidSaveTextDocument({ textDocument: { uri }, text }, clientId);
153
155
  },
156
+ queueDiskTask<T>(_uri: string, task: () => Promise<T>): Promise<T> {
157
+ return task();
158
+ },
154
159
  // Registers the listener but never calls it by itself — the real manager
155
160
  // emits from a `DocumentBuilder.onDocumentPhase(Validated)` hook and the
156
161
  // stub tree runs no phases. `emitUpdate` is the only thing that fires it;