@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.
- package/README.md +3 -1
- package/lib/documents/ast-document-manager.d.ts +52 -2
- package/lib/documents/ast-document-manager.d.ts.map +1 -1
- package/lib/documents/ast-document-manager.js +67 -5
- package/lib/documents/ast-document-manager.js.map +1 -1
- package/lib/documents/client-session-errors.d.ts +44 -0
- package/lib/documents/client-session-errors.d.ts.map +1 -0
- package/lib/documents/client-session-errors.js +57 -0
- package/lib/documents/client-session-errors.js.map +1 -0
- package/lib/documents/client-session-registry.d.ts +118 -0
- package/lib/documents/client-session-registry.d.ts.map +1 -0
- package/lib/documents/client-session-registry.js +171 -0
- package/lib/documents/client-session-registry.js.map +1 -0
- package/lib/documents/hydranium-text-documents.d.ts +69 -17
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +135 -44
- package/lib/documents/hydranium-text-documents.js.map +1 -1
- package/lib/documents/index.d.ts +2 -0
- package/lib/documents/index.d.ts.map +1 -1
- package/lib/documents/index.js +2 -0
- package/lib/documents/index.js.map +1 -1
- package/lib/langium/integrity/integrity-service.d.ts +8 -7
- package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
- package/lib/langium/integrity/integrity-service.js +35 -27
- package/lib/langium/integrity/integrity-service.js.map +1 -1
- package/lib/langium/model-service/client-session.d.ts +120 -0
- package/lib/langium/model-service/client-session.d.ts.map +1 -0
- package/lib/langium/model-service/client-session.js +175 -0
- package/lib/langium/model-service/client-session.js.map +1 -0
- package/lib/langium/model-service/index.d.ts +1 -0
- package/lib/langium/model-service/index.d.ts.map +1 -1
- package/lib/langium/model-service/index.js +1 -0
- package/lib/langium/model-service/index.js.map +1 -1
- package/lib/langium/model-service/model-service.d.ts +57 -1
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +123 -9
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.d.ts +6 -0
- package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.js +20 -3
- package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
- package/lib/testing/stub-ast-document-manager.d.ts +4 -3
- package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
- package/lib/testing/stub-ast-document-manager.js +3 -0
- package/lib/testing/stub-ast-document-manager.js.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.d.ts +13 -5
- package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.js +45 -14
- package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
- package/package.json +5 -5
- package/src/documents/ast-document-manager.ts +107 -6
- package/src/documents/client-session-errors.ts +54 -0
- package/src/documents/client-session-registry.ts +211 -0
- package/src/documents/hydranium-text-documents.ts +150 -47
- package/src/documents/index.ts +2 -0
- package/src/langium/integrity/integrity-service.ts +36 -26
- package/src/langium/model-service/client-session.ts +257 -0
- package/src/langium/model-service/index.ts +1 -0
- package/src/langium/model-service/model-service.ts +151 -9
- package/src/lsp/hydranium-document-update-handler.ts +22 -4
- package/src/testing/stub-ast-document-manager.ts +7 -2
- 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,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
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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(
|
|
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.
|
|
214
|
-
|
|
215
|
-
|
|
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`)
|
|
28
|
-
* underlying stub text
|
|
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;
|