@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.
- package/lib/documents/ast-document-manager.d.ts +8 -44
- package/lib/documents/ast-document-manager.d.ts.map +1 -1
- package/lib/documents/ast-document-manager.js +22 -41
- package/lib/documents/ast-document-manager.js.map +1 -1
- package/lib/documents/client-session-errors.d.ts +4 -33
- package/lib/documents/client-session-errors.d.ts.map +1 -1
- package/lib/documents/client-session-errors.js +4 -46
- package/lib/documents/client-session-errors.js.map +1 -1
- package/lib/documents/client-session-registry.d.ts +40 -20
- package/lib/documents/client-session-registry.d.ts.map +1 -1
- package/lib/documents/client-session-registry.js +33 -20
- package/lib/documents/client-session-registry.js.map +1 -1
- package/lib/documents/file-system-task-queue.d.ts +49 -0
- package/lib/documents/file-system-task-queue.d.ts.map +1 -0
- package/lib/documents/file-system-task-queue.js +34 -0
- package/lib/documents/file-system-task-queue.js.map +1 -0
- package/lib/documents/hydranium-text-documents.d.ts +138 -13
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +283 -40
- package/lib/documents/hydranium-text-documents.js.map +1 -1
- package/lib/documents/index.d.ts +1 -0
- package/lib/documents/index.d.ts.map +1 -1
- package/lib/documents/index.js +1 -0
- package/lib/documents/index.js.map +1 -1
- package/lib/langium/document-builder/document-builder.d.ts +15 -2
- package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
- package/lib/langium/document-builder/document-builder.js +15 -2
- package/lib/langium/document-builder/document-builder.js.map +1 -1
- package/lib/langium/integrity/integrity-service.d.ts +3 -2
- package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
- package/lib/langium/integrity/integrity-service.js +9 -6
- package/lib/langium/integrity/integrity-service.js.map +1 -1
- package/lib/langium/language-module.d.ts +2 -2
- package/lib/langium/language-module.js +2 -2
- package/lib/langium/language-module.js.map +1 -1
- package/lib/langium/model-service/client-session.d.ts +203 -24
- package/lib/langium/model-service/client-session.d.ts.map +1 -1
- package/lib/langium/model-service/client-session.js +233 -29
- package/lib/langium/model-service/client-session.js.map +1 -1
- package/lib/langium/model-service/model-service.d.ts +62 -217
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +54 -357
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/lib/langium/module.d.ts +11 -1
- package/lib/langium/module.d.ts.map +1 -1
- package/lib/langium/module.js +5 -1
- package/lib/langium/module.js.map +1 -1
- package/lib/langium/update-rewrite/update-rewrite.d.ts +1 -1
- package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
- package/lib/langium/workspace/in-memory-file-system-provider.js +10 -2
- package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
- package/lib/langium/workspace/write-lock-scope.d.ts +1 -1
- package/lib/langium/workspace/write-lock-scope.js +1 -1
- package/lib/lsp/hydranium-document-update-handler.d.ts +69 -67
- package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.js +111 -89
- package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
- package/lib/lsp/shared-module.d.ts +4 -8
- package/lib/lsp/shared-module.d.ts.map +1 -1
- package/lib/lsp/shared-module.js.map +1 -1
- package/lib/lsp/start-language-server.d.ts +3 -4
- package/lib/lsp/start-language-server.d.ts.map +1 -1
- package/lib/lsp/start-language-server.js +3 -4
- package/lib/lsp/start-language-server.js.map +1 -1
- package/lib/testing/make-test-services.d.ts +16 -4
- package/lib/testing/make-test-services.d.ts.map +1 -1
- package/lib/testing/make-test-services.js +6 -2
- package/lib/testing/make-test-services.js.map +1 -1
- package/lib/testing/node/lsp-server-connection.d.ts +1 -1
- package/lib/testing/node/scratch-workspace.js +1 -1
- package/lib/testing/run-update-pipeline.d.ts +4 -4
- package/lib/testing/run-update-pipeline.d.ts.map +1 -1
- package/lib/testing/stub-ast-document-manager.d.ts +5 -5
- package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
- package/lib/testing/stub-ast-document-manager.js +6 -8
- package/lib/testing/stub-ast-document-manager.js.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.d.ts +10 -5
- package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.js +16 -9
- package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
- package/lib/testing/stub-model-service.d.ts +2 -2
- package/lib/testing/stub-model-service.js +2 -2
- package/package.json +5 -5
- package/src/documents/ast-document-manager.ts +27 -78
- package/src/documents/client-session-errors.ts +11 -43
- package/src/documents/client-session-registry.ts +54 -28
- package/src/documents/file-system-task-queue.ts +68 -0
- package/src/documents/hydranium-text-documents.ts +348 -44
- package/src/documents/index.ts +1 -0
- package/src/langium/document-builder/document-builder.ts +16 -2
- package/src/langium/integrity/integrity-service.ts +9 -6
- package/src/langium/language-module.ts +4 -4
- package/src/langium/model-service/client-session.ts +363 -39
- package/src/langium/model-service/model-service.ts +91 -420
- package/src/langium/module.ts +14 -2
- package/src/langium/update-rewrite/update-rewrite.ts +1 -1
- package/src/langium/workspace/in-memory-file-system-provider.ts +11 -2
- package/src/langium/workspace/write-lock-scope.ts +1 -1
- package/src/lsp/hydranium-document-update-handler.ts +138 -92
- package/src/lsp/shared-module.ts +4 -8
- package/src/lsp/start-language-server.ts +3 -4
- package/src/testing/make-test-services.ts +25 -6
- package/src/testing/node/lsp-server-connection.ts +1 -1
- package/src/testing/node/scratch-workspace.ts +1 -1
- package/src/testing/run-update-pipeline.ts +4 -4
- package/src/testing/stub-ast-document-manager.ts +10 -24
- package/src/testing/stub-hydranium-text-documents.ts +29 -16
- package/src/testing/stub-model-service.ts +2 -2
|
@@ -7,16 +7,45 @@
|
|
|
7
7
|
* SPDX-License-Identifier: MIT
|
|
8
8
|
********************************************************************************/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type BasedOn,
|
|
12
|
+
ConflictError,
|
|
13
|
+
isSnapshotVersion,
|
|
14
|
+
Logger,
|
|
15
|
+
type MaybeObservableValue,
|
|
16
|
+
type MaybePromise,
|
|
17
|
+
ObservableValue,
|
|
18
|
+
type Tracer,
|
|
19
|
+
type TransferElement
|
|
20
|
+
} from '@hydranium/protocol';
|
|
11
21
|
import { type AstNode, UriUtils } from '@hydranium/langium';
|
|
12
|
-
import { type CancellationToken } from 'vscode-languageserver';
|
|
22
|
+
import { type CancellationToken, type Disposable } from 'vscode-languageserver';
|
|
13
23
|
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';
|
|
24
|
+
import { DocumentNotOpenError, SessionClosedError } from '../../documents/client-session-errors.js';
|
|
25
|
+
import { type OpenOptions, type SessionEndCause } from '../../documents/client-session-registry.js';
|
|
26
|
+
import { type LogNameOptions } from '../diagnostics/logger.js';
|
|
16
27
|
import { type ServerSharedServices } from '../module.js';
|
|
17
28
|
import { type AstDiagnostic } from '../validation/document-validator.js';
|
|
18
29
|
import { type ModelService } from './model-service.js';
|
|
19
30
|
|
|
31
|
+
/** What a session's `update` and `save` write into one document; the session supplies its own client id. */
|
|
32
|
+
export interface ClientSessionWriteArgs<TTransfer> {
|
|
33
|
+
uri: string;
|
|
34
|
+
/** The whole structured model root, or its serialised textual form. */
|
|
35
|
+
model: TTransfer | string;
|
|
36
|
+
/**
|
|
37
|
+
* The version the write was authored against, or `'anything'` to write
|
|
38
|
+
* unconditionally. Required, so an ungated write is a decision the caller
|
|
39
|
+
* makes rather than a field it forgot.
|
|
40
|
+
*/
|
|
41
|
+
basedOn: BasedOn;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What a session's `updateAll` writes, all or none. */
|
|
45
|
+
export interface ClientSessionUpdateAllArgs<TTransfer> {
|
|
46
|
+
updates: ClientSessionWriteArgs<TTransfer>[];
|
|
47
|
+
}
|
|
48
|
+
|
|
20
49
|
/**
|
|
21
50
|
* One participant's handle on the documents it works on, started by
|
|
22
51
|
* `ModelService.createSession`.
|
|
@@ -47,19 +76,29 @@ export interface ClientSession<
|
|
|
47
76
|
|
|
48
77
|
/**
|
|
49
78
|
* Open `uri` for this session, reading it from disk unless some client has
|
|
50
|
-
* it open already
|
|
51
|
-
*
|
|
79
|
+
* it open already or it holds this client's unsaved text within its revert
|
|
80
|
+
* grace. `options` are kept for this open until it closes; a repeat open,
|
|
81
|
+
* concurrent or not, keeps the options of the first.
|
|
52
82
|
*/
|
|
53
83
|
open(uri: string, options?: TOpenOptions): Promise<void>;
|
|
84
|
+
/** The options this session opened `uri` with, or `undefined` when it gave none or does not have `uri` open. */
|
|
85
|
+
openOptions(uri: string): TOpenOptions | undefined;
|
|
54
86
|
/**
|
|
55
87
|
* Create a document with `text` and open it for this session. It reaches
|
|
56
|
-
* disk on the first `save`. Fails when the file exists
|
|
57
|
-
* URI open, and of two creates of one
|
|
88
|
+
* disk on the first `save`. Fails when the file exists, any client has the
|
|
89
|
+
* URI open, or the URI waits out the revert grace, and of two creates of one
|
|
90
|
+
* URI at most one succeeds.
|
|
58
91
|
*/
|
|
59
92
|
create(uri: string, text: string): Promise<void>;
|
|
60
|
-
update(args:
|
|
93
|
+
update(args: ClientSessionWriteArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
|
|
94
|
+
/**
|
|
95
|
+
* Write several documents this session has open, all or none: a
|
|
96
|
+
* `ConflictError` or `DocumentNotOpenError` for any of them is thrown before
|
|
97
|
+
* any text applies. Resolves to the rebuilt documents, in the order given.
|
|
98
|
+
*/
|
|
99
|
+
updateAll(args: ClientSessionUpdateAllArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>[]>;
|
|
61
100
|
/** Write `args.model` as {@link update} does, then persist the document. */
|
|
62
|
-
save(args:
|
|
101
|
+
save(args: ClientSessionWriteArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
|
|
63
102
|
/** Close this session's open of `uri`, at once. A no-op when it does not have `uri` open. */
|
|
64
103
|
close(uri: string): Promise<void>;
|
|
65
104
|
/**
|
|
@@ -71,20 +110,28 @@ export interface ClientSession<
|
|
|
71
110
|
withOpen<T>(uri: string, fn: () => MaybePromise<T>): Promise<T>;
|
|
72
111
|
/** Whether an event's `sourceClientId` names this session, i.e. the event echoes its own write. */
|
|
73
112
|
isOwnEcho(sourceClientId: string): boolean;
|
|
74
|
-
/**
|
|
75
|
-
|
|
113
|
+
/**
|
|
114
|
+
* End the session: close everything it has open and free its id.
|
|
115
|
+
* Idempotent. `cause` is `'lost'` when the session ends because its
|
|
116
|
+
* client's connection went away, which lets each document it was the last
|
|
117
|
+
* to have open wait out the store's revert grace; ending it for any other
|
|
118
|
+
* reason reverts such a document at once.
|
|
119
|
+
*/
|
|
120
|
+
dispose(cause?: SessionEndCause): void;
|
|
76
121
|
}
|
|
77
122
|
|
|
78
123
|
/**
|
|
79
124
|
* The framework's {@link ClientSession}, built by
|
|
80
|
-
*
|
|
125
|
+
* {@link DefaultClientSessionFactory}.
|
|
81
126
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
127
|
+
* Override the protected method behind a member to change how every session
|
|
128
|
+
* writes, and return the subclass from a {@link ClientSessionFactory} bound on
|
|
129
|
+
* `model.ClientSessionFactory`. Serialising a model and rebuilding a document
|
|
130
|
+
* are the {@link ModelService}'s.
|
|
85
131
|
*
|
|
86
|
-
* An override of {@link dispose} calls `super.dispose()`: that is what
|
|
87
|
-
* the session's opens and frees its id
|
|
132
|
+
* An override of {@link dispose} calls `super.dispose(cause)`: that is what
|
|
133
|
+
* closes the session's opens and frees its id, and dropping `cause` reverts
|
|
134
|
+
* a lost client's documents without their grace.
|
|
88
135
|
*/
|
|
89
136
|
export class DefaultClientSession<
|
|
90
137
|
TAst extends AstNode,
|
|
@@ -99,37 +146,80 @@ export class DefaultClientSession<
|
|
|
99
146
|
* than racing it, and `withOpen` can tell an open another call is making.
|
|
100
147
|
*/
|
|
101
148
|
protected readonly pendingOpens = new Map<string, Promise<void>>();
|
|
149
|
+
/**
|
|
150
|
+
* The options of this session's opens, by canonical URI. A first open
|
|
151
|
+
* records its entry before the store has the URI open, and the entry goes
|
|
152
|
+
* when that open closes, whoever ends it: this session, or the store for a
|
|
153
|
+
* deleted file. A first open that fails never opens, so nothing closes it
|
|
154
|
+
* and its entry stays until the next first open of the URI overwrites it;
|
|
155
|
+
* {@link openOptions} therefore reads an entry only while the store has the
|
|
156
|
+
* URI open for this session.
|
|
157
|
+
*/
|
|
158
|
+
protected readonly openOptionsByUri = new Map<string, TOpenOptions | undefined>();
|
|
159
|
+
/** Drops an open's options from {@link openOptionsByUri} when the store closes the open. */
|
|
160
|
+
protected readonly closeListener: Disposable;
|
|
161
|
+
/** Logs under `logName`, `ClientSession` when none is given, with this session's id in a bracket of its own, so a line names the session that wrote. */
|
|
162
|
+
protected readonly tracer: Tracer;
|
|
163
|
+
/** The service bound on `model.ModelService`, which serialises and rebuilds for this session. */
|
|
164
|
+
protected readonly modelService: ModelService<TAst, TDiagnostic, TTransfer>;
|
|
165
|
+
/** See {@link ClientSessionFactoryOptions.slowUpdateWarnMs}; `undefined` when not set. */
|
|
166
|
+
protected readonly slowUpdateWarn?: ObservableValue<number>;
|
|
167
|
+
readonly clientId: string;
|
|
168
|
+
readonly label: string;
|
|
102
169
|
|
|
103
170
|
constructor(
|
|
104
|
-
protected readonly modelService: ModelService<TAst, TDiagnostic, TTransfer>,
|
|
105
171
|
protected readonly services: ServerSharedServices,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
172
|
+
options: ClientSessionOptions
|
|
173
|
+
) {
|
|
174
|
+
this.clientId = options.clientId;
|
|
175
|
+
this.label = options.label ?? options.clientId;
|
|
176
|
+
// The slot is shared across grammars; this session's types hold for the
|
|
177
|
+
// grammar that started it, as `createSession`'s narrowing does.
|
|
178
|
+
this.modelService = services.model.ModelService as ModelService<TAst, TDiagnostic, TTransfer>;
|
|
179
|
+
this.slowUpdateWarn = options.slowUpdateWarnMs !== undefined ? ObservableValue.from(options.slowUpdateWarnMs) : undefined;
|
|
180
|
+
this.tracer = services.Tracer.for(options.logName ?? 'ClientSession').with(this.clientId);
|
|
181
|
+
this.closeListener = services.workspace.TextDocuments.onDidClose(event => {
|
|
182
|
+
if (event.clientId === this.clientId) {
|
|
183
|
+
this.openOptionsByUri.delete(event.document.uri);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
109
187
|
|
|
110
188
|
open(uri: string, options?: TOpenOptions): Promise<void> {
|
|
111
189
|
this.assertLive();
|
|
112
190
|
return this.openDocument(uri, options);
|
|
113
191
|
}
|
|
114
192
|
|
|
193
|
+
openOptions(uri: string): TOpenOptions | undefined {
|
|
194
|
+
this.assertLive();
|
|
195
|
+
return this.services.workspace.TextDocuments.isOpenInClient(uri, this.clientId)
|
|
196
|
+
? this.openOptionsByUri.get(this.canonicalKey(uri))
|
|
197
|
+
: undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
115
200
|
create(uri: string, text: string): Promise<void> {
|
|
116
201
|
this.assertLive();
|
|
117
202
|
return this.createDocument(uri, text);
|
|
118
203
|
}
|
|
119
204
|
|
|
120
|
-
update(args:
|
|
205
|
+
update(args: ClientSessionWriteArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
|
|
206
|
+
this.assertLive();
|
|
207
|
+
return this.updateDocument(args, cancelToken);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
updateAll(args: ClientSessionUpdateAllArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>[]> {
|
|
121
211
|
this.assertLive();
|
|
122
|
-
return this.
|
|
212
|
+
return this.updateDocuments(args, cancelToken);
|
|
123
213
|
}
|
|
124
214
|
|
|
125
|
-
save(args:
|
|
215
|
+
save(args: ClientSessionWriteArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
|
|
126
216
|
this.assertLive();
|
|
127
|
-
return this.
|
|
217
|
+
return this.saveDocument(args, cancelToken);
|
|
128
218
|
}
|
|
129
219
|
|
|
130
220
|
close(uri: string): Promise<void> {
|
|
131
221
|
this.assertLive();
|
|
132
|
-
return this.
|
|
222
|
+
return this.closeDocument(uri);
|
|
133
223
|
}
|
|
134
224
|
|
|
135
225
|
withOpen<T>(uri: string, fn: () => MaybePromise<T>): Promise<T> {
|
|
@@ -142,12 +232,13 @@ export class DefaultClientSession<
|
|
|
142
232
|
return sourceClientId === this.clientId;
|
|
143
233
|
}
|
|
144
234
|
|
|
145
|
-
dispose(): void {
|
|
235
|
+
dispose(cause?: SessionEndCause): void {
|
|
146
236
|
if (this.disposed) {
|
|
147
237
|
return;
|
|
148
238
|
}
|
|
149
239
|
this.disposed = true;
|
|
150
|
-
this.services.workspace.TextDocuments.closeSession(this.clientId);
|
|
240
|
+
this.services.workspace.TextDocuments.closeSession(this.clientId, cause);
|
|
241
|
+
this.closeListener.dispose();
|
|
151
242
|
}
|
|
152
243
|
|
|
153
244
|
protected assertLive(): void {
|
|
@@ -156,6 +247,151 @@ export class DefaultClientSession<
|
|
|
156
247
|
}
|
|
157
248
|
}
|
|
158
249
|
|
|
250
|
+
/** Throw `DocumentNotOpenError` unless this session has `uri` open. */
|
|
251
|
+
protected assertOpen(uri: string): void {
|
|
252
|
+
if (!this.services.workspace.TextDocuments.isOpenInClient(uri, this.clientId)) {
|
|
253
|
+
throw new DocumentNotOpenError(uri, this.clientId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Throw `ConflictError` when `basedOn` names a version other than
|
|
259
|
+
* `currentVersion`. A write calls it at the door and again in the
|
|
260
|
+
* synchronous step that applies the text, and has to call it synchronously
|
|
261
|
+
* there: a check separated from the apply by an await lets two writes based
|
|
262
|
+
* on one version both apply.
|
|
263
|
+
*/
|
|
264
|
+
protected assertBasedOn(uri: string, basedOn: BasedOn, currentVersion: number): void {
|
|
265
|
+
if (isSnapshotVersion(basedOn) && currentVersion !== basedOn) {
|
|
266
|
+
// Distinct from the post-build "superseded" debug line of an update: this
|
|
267
|
+
// is a based-on-stale rejection (the write never applies), not two
|
|
268
|
+
// writes racing.
|
|
269
|
+
this.tracer.debug(`Conflict on ${uri}: based-on v${basedOn} stale, server at v${currentVersion}`);
|
|
270
|
+
throw new ConflictError(uri, basedOn, currentVersion);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Serialise `args.model`, apply it to the store under this session's id,
|
|
276
|
+
* rebuild, and resolve to the rebuilt document.
|
|
277
|
+
*
|
|
278
|
+
* The open check and the `basedOn` gate run at the door, so a write that
|
|
279
|
+
* cannot land is refused before any adopter serialiser runs, and again in
|
|
280
|
+
* the synchronous step that applies the text: made only before the
|
|
281
|
+
* serialiser's await, a close during it lets the write land on a document
|
|
282
|
+
* this session no longer has open, and two writes based on one version
|
|
283
|
+
* both apply.
|
|
284
|
+
*
|
|
285
|
+
* Resolves with the latest build of the document, which may already carry
|
|
286
|
+
* a newer write; that is logged at debug, and a slow update at warn when
|
|
287
|
+
* `ClientSessionFactoryOptions.slowUpdateWarnMs` is set.
|
|
288
|
+
*/
|
|
289
|
+
protected async updateDocument(
|
|
290
|
+
args: ClientSessionWriteArgs<TTransfer>,
|
|
291
|
+
cancelToken?: CancellationToken
|
|
292
|
+
): Promise<AstDocument<TAst, TDiagnostic>> {
|
|
293
|
+
const service = this.modelService;
|
|
294
|
+
const slowWarn = this.slowUpdateWarn && { threshold: this.slowUpdateWarn, stopwatch: this.services.Clock.stopwatch() };
|
|
295
|
+
const uri = this.canonicalKey(args.uri);
|
|
296
|
+
const profile = Logger.isLevelEnabled('debug') ? this.tracer.profile(`model-update ${uri}`) : undefined;
|
|
297
|
+
const run = async <T>(stage: string, fn: () => MaybePromise<T>): Promise<T> => (profile ? profile.scope(stage, fn) : fn());
|
|
298
|
+
const textDocuments = this.services.workspace.TextDocuments;
|
|
299
|
+
this.assertOpen(uri);
|
|
300
|
+
this.assertBasedOn(uri, args.basedOn, textDocuments.version(uri));
|
|
301
|
+
const text = await run('serialize', () => service.modelToText(uri, args.model, cancelToken));
|
|
302
|
+
const appliedVersion = await run('apply', () => {
|
|
303
|
+
this.assertOpen(uri);
|
|
304
|
+
this.assertBasedOn(uri, args.basedOn, textDocuments.version(uri));
|
|
305
|
+
return this.services.workspace.AstDocumentManager.update(uri, text, this.clientId);
|
|
306
|
+
});
|
|
307
|
+
// Through the public `rebuild`, so an override of it stays in the path.
|
|
308
|
+
// An override that awaits before calling the base can outlast the build
|
|
309
|
+
// this write already has, and then builds it twice; see
|
|
310
|
+
// `ModelService.rebuild`.
|
|
311
|
+
const doc = await run('rebuild', () => service.rebuild(uri, undefined, cancelToken));
|
|
312
|
+
const finalVersion = textDocuments.version(uri);
|
|
313
|
+
if (finalVersion > appliedVersion) {
|
|
314
|
+
this.tracer.debug(`Update to v${appliedVersion} ready at v${finalVersion} (superseded)`);
|
|
315
|
+
} else {
|
|
316
|
+
this.tracer.debug(`Update to v${appliedVersion} ready`);
|
|
317
|
+
}
|
|
318
|
+
if (slowWarn) {
|
|
319
|
+
const elapsed = Math.round(slowWarn.stopwatch.elapsedMs);
|
|
320
|
+
const threshold = slowWarn.threshold.value;
|
|
321
|
+
if (elapsed >= threshold) {
|
|
322
|
+
this.tracer.withUri(uri).warn(`Slow update: ${elapsed}ms ≥ ${threshold}ms (v${appliedVersion}, client=${this.clientId})`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
profile?.report('debug');
|
|
326
|
+
return doc;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Write every document of `args.updates`, all or none: every model is
|
|
331
|
+
* serialised first, then every open check and `basedOn` gate runs and every
|
|
332
|
+
* text is applied in one synchronous step. Checked at the door as well, so
|
|
333
|
+
* a stale set is refused before any adopter serialiser runs.
|
|
334
|
+
*
|
|
335
|
+
* The apply step relies on `AstDocumentManager.update` applying its text
|
|
336
|
+
* before its first await, as the default does: an override that awaits first
|
|
337
|
+
* lets another write land between two documents of the set.
|
|
338
|
+
*/
|
|
339
|
+
protected async updateDocuments(
|
|
340
|
+
args: ClientSessionUpdateAllArgs<TTransfer>,
|
|
341
|
+
cancelToken?: CancellationToken
|
|
342
|
+
): Promise<AstDocument<TAst, TDiagnostic>[]> {
|
|
343
|
+
const service = this.modelService;
|
|
344
|
+
const { updates } = args;
|
|
345
|
+
const textDocuments = this.services.workspace.TextDocuments;
|
|
346
|
+
const uris = updates.map(update => this.canonicalKey(update.uri));
|
|
347
|
+
if (new Set(uris).size !== uris.length) {
|
|
348
|
+
throw new Error(`updateAll names a document more than once: ${uris.join(', ')}`);
|
|
349
|
+
}
|
|
350
|
+
const check = (): void =>
|
|
351
|
+
updates.forEach((update, i) => {
|
|
352
|
+
this.assertOpen(uris[i]);
|
|
353
|
+
this.assertBasedOn(uris[i], update.basedOn, textDocuments.version(uris[i]));
|
|
354
|
+
});
|
|
355
|
+
check();
|
|
356
|
+
const texts: string[] = [];
|
|
357
|
+
for (const [i, update] of updates.entries()) {
|
|
358
|
+
texts.push(await service.modelToText(uris[i], update.model, cancelToken));
|
|
359
|
+
}
|
|
360
|
+
// One synchronous step from the first check to the last apply: an await
|
|
361
|
+
// anywhere in it lets a close or another write land after its document
|
|
362
|
+
// was checked, and the set then half-applies before a later check fails.
|
|
363
|
+
check();
|
|
364
|
+
const applied = uris.map((uri, i) => this.services.workspace.AstDocumentManager.update(uri, texts[i], this.clientId));
|
|
365
|
+
await Promise.all(applied);
|
|
366
|
+
return Promise.all(uris.map(uri => service.rebuild(uri, undefined, cancelToken)));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Write `args.model` through {@link updateDocument}, so an override of it
|
|
371
|
+
* applies to saves too, then persist the document.
|
|
372
|
+
*
|
|
373
|
+
* Fails with `DocumentNotOpenError` and writes nothing when this session
|
|
374
|
+
* closes the URI while the text is being built: the check sits in the same
|
|
375
|
+
* synchronous step as the manager taking the text. Checked any earlier, a
|
|
376
|
+
* session that closes the URI during the rebuild still has the shared
|
|
377
|
+
* text, other clients' edits included, written in its name. Once taken,
|
|
378
|
+
* the write completes whatever the session does next.
|
|
379
|
+
*/
|
|
380
|
+
protected async saveDocument(
|
|
381
|
+
args: ClientSessionWriteArgs<TTransfer>,
|
|
382
|
+
cancelToken?: CancellationToken
|
|
383
|
+
): Promise<AstDocument<TAst, TDiagnostic>> {
|
|
384
|
+
const doc = await this.updateDocument(args, cancelToken);
|
|
385
|
+
const uri = this.canonicalKey(args.uri);
|
|
386
|
+
this.assertOpen(uri);
|
|
387
|
+
await this.services.workspace.AstDocumentManager.save(uri, this.clientId);
|
|
388
|
+
return doc;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
protected async closeDocument(uri: string): Promise<void> {
|
|
392
|
+
await this.services.workspace.AstDocumentManager.close({ uri, clientId: this.clientId });
|
|
393
|
+
}
|
|
394
|
+
|
|
159
395
|
/** Open `uri`, or join the open of it this session already has under way, whose options then stand. */
|
|
160
396
|
protected openDocument(uri: string, options: TOpenOptions | undefined): Promise<void> {
|
|
161
397
|
const key = this.canonicalKey(uri);
|
|
@@ -172,14 +408,22 @@ export class DefaultClientSession<
|
|
|
172
408
|
return opening;
|
|
173
409
|
}
|
|
174
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Open `uri` in the store for this session. The open path does not thread
|
|
413
|
+
* cancellation: the file read behind it takes no token, so a cancelled
|
|
414
|
+
* caller still completes the open.
|
|
415
|
+
*
|
|
416
|
+
* A first open records its options before the store opens the URI, so a
|
|
417
|
+
* listener of the store's open reads this open's options rather than an
|
|
418
|
+
* earlier one's.
|
|
419
|
+
*/
|
|
175
420
|
protected async registerOpen(uri: string, options: TOpenOptions | undefined): Promise<void> {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
await this.rejectIfEnded(uri, wasOpen);
|
|
180
|
-
if (!wasOpen && options !== undefined) {
|
|
181
|
-
textDocuments.setOpenOptions(uri, this.clientId, options);
|
|
421
|
+
const wasOpen = this.services.workspace.TextDocuments.isOpenInClient(uri, this.clientId);
|
|
422
|
+
if (!wasOpen) {
|
|
423
|
+
this.openOptionsByUri.set(this.canonicalKey(uri), options);
|
|
182
424
|
}
|
|
425
|
+
await this.services.workspace.AstDocumentManager.open({ uri, clientId: this.clientId });
|
|
426
|
+
await this.rejectIfEnded(uri, wasOpen);
|
|
183
427
|
}
|
|
184
428
|
|
|
185
429
|
protected canonicalKey(uri: string): string {
|
|
@@ -191,6 +435,9 @@ export class DefaultClientSession<
|
|
|
191
435
|
throw new Error(`Cannot create ${uri}: the file exists`);
|
|
192
436
|
}
|
|
193
437
|
this.assertLive();
|
|
438
|
+
if (this.services.workspace.TextDocuments.isRevertPending(uri)) {
|
|
439
|
+
throw new Error(`Cannot create ${uri}: it holds the unsaved text of a lost client, waiting out the revert grace`);
|
|
440
|
+
}
|
|
194
441
|
if (this.modelService.isOpen(uri)) {
|
|
195
442
|
throw new Error(`Cannot create ${uri}: it is open in a client`);
|
|
196
443
|
}
|
|
@@ -208,13 +455,13 @@ export class DefaultClientSession<
|
|
|
208
455
|
}
|
|
209
456
|
});
|
|
210
457
|
try {
|
|
211
|
-
await this.
|
|
458
|
+
await this.services.workspace.AstDocumentManager.open({ uri, clientId: this.clientId, text });
|
|
212
459
|
} finally {
|
|
213
460
|
listener.dispose();
|
|
214
461
|
}
|
|
215
462
|
await this.rejectIfEnded(uri, false);
|
|
216
463
|
if (!created) {
|
|
217
|
-
await this.
|
|
464
|
+
await this.closeDocument(uri);
|
|
218
465
|
throw new Error(`Cannot create ${uri}: it is open in a client`);
|
|
219
466
|
}
|
|
220
467
|
}
|
|
@@ -229,7 +476,7 @@ export class DefaultClientSession<
|
|
|
229
476
|
return await fn();
|
|
230
477
|
} finally {
|
|
231
478
|
if (!alreadyOpen && !this.disposed) {
|
|
232
|
-
await this.
|
|
479
|
+
await this.closeDocument(uri);
|
|
233
480
|
}
|
|
234
481
|
}
|
|
235
482
|
}
|
|
@@ -250,8 +497,85 @@ export class DefaultClientSession<
|
|
|
250
497
|
return;
|
|
251
498
|
}
|
|
252
499
|
if (!wasOpen && this.modelService.getSession(this.clientId) === undefined) {
|
|
253
|
-
await this.
|
|
500
|
+
await this.closeDocument(uri);
|
|
254
501
|
}
|
|
255
502
|
throw new SessionClosedError(this.clientId);
|
|
256
503
|
}
|
|
257
504
|
}
|
|
505
|
+
|
|
506
|
+
/** Constructor options for {@link DefaultClientSessionFactory}. */
|
|
507
|
+
export interface ClientSessionFactoryOptions extends LogNameOptions {
|
|
508
|
+
/**
|
|
509
|
+
* The name the factory and every session it builds log under, so one
|
|
510
|
+
* option renames both. Default: `ClientSessionFactory` for the factory,
|
|
511
|
+
* and `ClientSession` for a session, which adds its client id in a bracket
|
|
512
|
+
* of its own.
|
|
513
|
+
*/
|
|
514
|
+
readonly logName?: string;
|
|
515
|
+
/**
|
|
516
|
+
* When set, a session's `update` logs a `warn` line if its end-to-end wait
|
|
517
|
+
* (serialise, apply, rebuild) takes at least this many milliseconds. Default
|
|
518
|
+
* `undefined`: no warn line, and no per-update stopwatch.
|
|
519
|
+
*
|
|
520
|
+
* Observability only: the update is neither aborted nor resolved
|
|
521
|
+
* differently. A hard timeout that throws is an override of
|
|
522
|
+
* `DefaultClientSession.updateDocument` racing the parent call against a
|
|
523
|
+
* deadline.
|
|
524
|
+
*
|
|
525
|
+
* A recommended starting threshold is 2-5 seconds; workspaces with very
|
|
526
|
+
* large documents or slow validation can exceed 5 s on a cold build. A
|
|
527
|
+
* `Settings.number` binding retunes it live.
|
|
528
|
+
*/
|
|
529
|
+
readonly slowUpdateWarnMs?: MaybeObservableValue<number>;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Constructor options for {@link DefaultClientSession}: the factory's options,
|
|
534
|
+
* which it forwards, plus the session's identity.
|
|
535
|
+
*/
|
|
536
|
+
export interface ClientSessionOptions extends ClientSessionFactoryOptions {
|
|
537
|
+
readonly clientId: string;
|
|
538
|
+
/** What the session is. Pass one: the client id stands in for it otherwise. */
|
|
539
|
+
readonly label?: string;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Builds the handle for a session `ModelService.createSession` has already
|
|
544
|
+
* registered, so registration and the uniqueness check cannot be skipped by
|
|
545
|
+
* binding a factory of one's own. Bind one on `model.ClientSessionFactory` to
|
|
546
|
+
* use a session class of your own. A factory that does not pass
|
|
547
|
+
* {@link ClientSessionFactoryOptions.slowUpdateWarnMs} on to its sessions
|
|
548
|
+
* loses their slow-update warn; subclassing {@link DefaultClientSessionFactory}
|
|
549
|
+
* keeps it.
|
|
550
|
+
*
|
|
551
|
+
* Typed for every grammar, since the slot is shared: `createSession` narrows
|
|
552
|
+
* the result to its caller's types.
|
|
553
|
+
*/
|
|
554
|
+
export interface ClientSessionFactory {
|
|
555
|
+
create(clientId: string, label: string): ClientSession<AstNode>;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* The framework's {@link ClientSessionFactory}: a {@link DefaultClientSession}
|
|
560
|
+
* over the {@link ModelService} bound on `model.ModelService`, which the session
|
|
561
|
+
* serialises and rebuilds through. A model service constructed outside that
|
|
562
|
+
* slot therefore starts sessions that write through the bound one.
|
|
563
|
+
*
|
|
564
|
+
* A subclass that overrides {@link create} to build a session class of its
|
|
565
|
+
* own passes {@link options} on to that session's constructor; a session built
|
|
566
|
+
* without them has no slow-update warn and logs under the default name.
|
|
567
|
+
*/
|
|
568
|
+
export class DefaultClientSessionFactory implements ClientSessionFactory {
|
|
569
|
+
protected readonly tracer: Tracer;
|
|
570
|
+
|
|
571
|
+
constructor(
|
|
572
|
+
protected readonly services: ServerSharedServices,
|
|
573
|
+
protected readonly options: ClientSessionFactoryOptions = {}
|
|
574
|
+
) {
|
|
575
|
+
this.tracer = services.Tracer.for(options.logName ?? 'ClientSessionFactory').trace('instantiated');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
create(clientId: string, label: string): ClientSession<AstNode> {
|
|
579
|
+
return new DefaultClientSession(this.services, { ...this.options, clientId, label });
|
|
580
|
+
}
|
|
581
|
+
}
|