@hydranium/protocol 1.0.0-next.123 → 1.0.0-next.127
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/client/data-connection.d.ts +35 -4
- package/lib/client/data-connection.d.ts.map +1 -1
- package/lib/client/data-connection.js +67 -5
- package/lib/client/data-connection.js.map +1 -1
- package/lib/client/data-events.d.ts +5 -1
- package/lib/client/data-events.d.ts.map +1 -1
- package/lib/client/data-events.js +7 -0
- package/lib/client/data-events.js.map +1 -1
- package/lib/client/data-session.d.ts +76 -8
- package/lib/client/data-session.d.ts.map +1 -1
- package/lib/client/data-session.js +108 -19
- package/lib/client/data-session.js.map +1 -1
- package/lib/client/index.d.ts +3 -1
- package/lib/client/index.d.ts.map +1 -1
- package/lib/client/index.js +3 -1
- package/lib/client/index.js.map +1 -1
- package/lib/client/message-relay.d.ts +1 -1
- package/lib/client/message-relay.js +1 -1
- package/lib/client/post-message-transport.d.ts +64 -3
- package/lib/client/post-message-transport.d.ts.map +1 -1
- package/lib/client/post-message-transport.js +175 -1
- package/lib/client/post-message-transport.js.map +1 -1
- package/lib/client/rpc-connection.d.ts +6 -0
- package/lib/client/rpc-connection.d.ts.map +1 -1
- package/lib/client/rpc-connection.js +9 -1
- package/lib/client/rpc-connection.js.map +1 -1
- package/lib/clock.d.ts +38 -0
- package/lib/clock.d.ts.map +1 -1
- package/lib/clock.js +36 -1
- package/lib/clock.js.map +1 -1
- package/lib/data/data-protocol-methods.d.ts +2 -2
- package/lib/data/data-protocol-methods.d.ts.map +1 -1
- package/lib/data/data-protocol-methods.js +1 -0
- package/lib/data/data-protocol-methods.js.map +1 -1
- package/lib/data/data-server-protocol.d.ts +11 -1
- package/lib/data/data-server-protocol.d.ts.map +1 -1
- package/lib/data/events.d.ts +14 -0
- package/lib/data/events.d.ts.map +1 -1
- package/lib/errors.d.ts +20 -9
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +18 -9
- package/lib/errors.js.map +1 -1
- package/lib/glsp-save-model-actions.d.ts +50 -0
- package/lib/glsp-save-model-actions.d.ts.map +1 -0
- package/lib/glsp-save-model-actions.js +28 -0
- package/lib/glsp-save-model-actions.js.map +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/messages/index.d.ts +1 -1
- package/lib/messages/index.d.ts.map +1 -1
- package/lib/messages/index.js +2 -1
- package/lib/messages/index.js.map +1 -1
- package/lib/testing/data-doubles.d.ts +3 -1
- package/lib/testing/data-doubles.d.ts.map +1 -1
- package/lib/testing/data-doubles.js +6 -1
- package/lib/testing/data-doubles.js.map +1 -1
- package/lib/testing/fake-clock.d.ts +9 -1
- package/lib/testing/fake-clock.d.ts.map +1 -1
- package/lib/testing/fake-clock.js +54 -45
- package/lib/testing/fake-clock.js.map +1 -1
- package/lib/testing/node/index.d.ts +1 -0
- package/lib/testing/node/index.d.ts.map +1 -1
- package/lib/testing/node/index.js +2 -2
- package/lib/testing/node/index.js.map +1 -1
- package/lib/testing/node/message-port-pair.d.ts +25 -0
- package/lib/testing/node/message-port-pair.d.ts.map +1 -0
- package/lib/testing/node/message-port-pair.js +26 -0
- package/lib/testing/node/message-port-pair.js.map +1 -0
- package/lib/transfer-document.d.ts +8 -0
- package/lib/transfer-document.d.ts.map +1 -1
- package/lib/transfer-document.js.map +1 -1
- package/package.json +1 -1
- package/src/client/data-connection.ts +72 -6
- package/src/client/data-events.ts +9 -0
- package/src/client/data-session.ts +125 -21
- package/src/client/index.ts +3 -1
- package/src/client/message-relay.ts +1 -1
- package/src/client/post-message-transport.ts +219 -4
- package/src/client/rpc-connection.ts +10 -1
- package/src/clock.ts +56 -0
- package/src/data/data-protocol-methods.ts +1 -0
- package/src/data/data-server-protocol.ts +12 -0
- package/src/data/events.ts +16 -0
- package/src/errors.ts +24 -9
- package/src/glsp-save-model-actions.ts +59 -0
- package/src/index.ts +1 -0
- package/src/messages/index.ts +1 -1
- package/src/testing/data-doubles.ts +10 -1
- package/src/testing/fake-clock.ts +62 -47
- package/src/testing/node/index.ts +2 -2
- package/src/testing/node/message-port-pair.ts +40 -0
- package/src/transfer-document.ts +8 -0
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* SPDX-License-Identifier: MIT
|
|
8
8
|
********************************************************************************/
|
|
9
9
|
|
|
10
|
-
import type
|
|
10
|
+
import { type Disposable, Emitter, type Event } from 'vscode-jsonrpc';
|
|
11
|
+
import { type Clock, SystemClock } from '../clock';
|
|
12
|
+
import type { DataServerProtocol, DiagnosticOf, TransferDocumentDirtyChangedEvent } from '../data';
|
|
13
|
+
import { SessionClosedError } from '../errors';
|
|
11
14
|
import type { SnapshotVersion } from '../model-service/based-on';
|
|
12
15
|
import { type ResolvedMessage, defineMessage, describeError, resolve } from '../messages/primitives';
|
|
13
16
|
import type { OpenModelArgs } from '../model-server';
|
|
@@ -104,9 +107,20 @@ export type DataSessionDocument<
|
|
|
104
107
|
export interface DataSessionHost<TTransfer extends TransferElement, TServer extends DataServerProtocol<TTransfer, DiagnosticOf<TServer>>> {
|
|
105
108
|
/** The connected, READY proxy of the current connection; a new object after each reconnect. */
|
|
106
109
|
connected(): Promise<RpcProxy<TServer>>;
|
|
107
|
-
releaseSession(session: DataSession<TTransfer, TServer>): void;
|
|
108
110
|
/** Surface a failure no caller is waiting on, such as restoring a document after a reconnect. */
|
|
109
111
|
reportError?(error: unknown, reported: ResolvedMessage): void;
|
|
112
|
+
/**
|
|
113
|
+
* Tell the connection's client the dirty state a restore read, unless it
|
|
114
|
+
* is what the client was last told of the URI since the URI's last open.
|
|
115
|
+
*/
|
|
116
|
+
restoreDirty?(event: TransferDocumentDirtyChangedEvent): void;
|
|
117
|
+
/**
|
|
118
|
+
* Forget what the client was told of `uri`'s dirty state: an open just
|
|
119
|
+
* made answered its caller afresh, and the client heard none of it, or a
|
|
120
|
+
* close leaves nothing to restore. Forgetting a URI another session still
|
|
121
|
+
* has open costs at most one repeat of a state the client already has.
|
|
122
|
+
*/
|
|
123
|
+
forgetDirty?(uri: string): void;
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
/**
|
|
@@ -139,8 +153,9 @@ export type DataSessionFactory<TTransfer extends TransferElement, TServer extend
|
|
|
139
153
|
* otherwise reach the server after it, and fail as not open.
|
|
140
154
|
*
|
|
141
155
|
* After the connection drops, the session registers again under the same id,
|
|
142
|
-
* re-opens and re-watches what it had open,
|
|
143
|
-
*
|
|
156
|
+
* re-opens and re-watches what it had open, tells the client of their dirty
|
|
157
|
+
* state where it changed, and reports the documents whose unsaved edits did
|
|
158
|
+
* not survive; see {@link restore}. The connection does this at
|
|
144
159
|
* once for a session with documents open, and any session's next call does it
|
|
145
160
|
* too.
|
|
146
161
|
*
|
|
@@ -156,7 +171,7 @@ export type DataSessionFactory<TTransfer extends TransferElement, TServer extend
|
|
|
156
171
|
export class DataSession<
|
|
157
172
|
TTransfer extends TransferElement,
|
|
158
173
|
TServer extends DataServerProtocol<TTransfer, DiagnosticOf<TServer>> = DataServerProtocol<TTransfer>
|
|
159
|
-
> {
|
|
174
|
+
> implements Disposable {
|
|
160
175
|
/**
|
|
161
176
|
* How long {@link closeDocument} and {@link dispose} wait for this session's
|
|
162
177
|
* calls in flight before closing anyway. Long, because the wait only runs
|
|
@@ -164,8 +179,20 @@ export class DataSession<
|
|
|
164
179
|
* fails that save.
|
|
165
180
|
*/
|
|
166
181
|
protected readonly settleBeforeCloseMs: number = 10_000;
|
|
182
|
+
/**
|
|
183
|
+
* The clock {@link settleBeforeCloseMs} runs on. {@link DataSessionHost}
|
|
184
|
+
* carries no {@link Clock}, so a subclass replaces this field as it
|
|
185
|
+
* replaces the bound.
|
|
186
|
+
*/
|
|
187
|
+
protected readonly clock: Clock = new SystemClock();
|
|
167
188
|
/** URIs this session has open, re-opened after a reconnect. */
|
|
168
189
|
protected readonly openUris = new Set<string>();
|
|
190
|
+
/**
|
|
191
|
+
* Per URI in {@link openUris}, the URI the server's last answer to its open
|
|
192
|
+
* or re-open named. The server keys its notifications by it, and so does
|
|
193
|
+
* the host's record of what its client was told.
|
|
194
|
+
*/
|
|
195
|
+
protected readonly serverUris = new Map<string, string>();
|
|
169
196
|
/**
|
|
170
197
|
* Per URI, how many opens of it this session has under way. Such a URI
|
|
171
198
|
* counts as open for {@link withOpenDocument}, which would otherwise close
|
|
@@ -179,6 +206,21 @@ export class DataSession<
|
|
|
179
206
|
protected readonly unsavedWrites = new Map<string, SnapshotVersion>();
|
|
180
207
|
/** Per URI, this session's calls still in flight on it, which a close waits for. */
|
|
181
208
|
protected readonly inFlight = new Map<string, Set<Promise<unknown>>>();
|
|
209
|
+
/** This session's saves still in flight, which a host's exit waits for; see {@link hasSavesInFlight}. */
|
|
210
|
+
protected readonly savesInFlight = new Set<Promise<unknown>>();
|
|
211
|
+
protected readonly disposeEmitter = new Emitter<void>();
|
|
212
|
+
/**
|
|
213
|
+
* Fires once when the session ends, by {@link dispose} or {@link detach}, as
|
|
214
|
+
* soon as it rejects further calls and before any close is sent. For a
|
|
215
|
+
* session its connection created, the connection's listener runs first, so
|
|
216
|
+
* the id is free again on that connection by the time any other listener
|
|
217
|
+
* runs; the server still holds it until the close arrives, and refuses a new
|
|
218
|
+
* session under it until then.
|
|
219
|
+
*
|
|
220
|
+
* A listener subscribed once the session has ended is never called, so a
|
|
221
|
+
* late subscriber checks {@link isDisposed} first.
|
|
222
|
+
*/
|
|
223
|
+
readonly onDidDispose: Event<void> = this.disposeEmitter.event;
|
|
182
224
|
/**
|
|
183
225
|
* Sent with every registration, so the server lets this session register
|
|
184
226
|
* its id again while the dropped connection's session is still live there:
|
|
@@ -280,6 +322,10 @@ export class DataSession<
|
|
|
280
322
|
throw error;
|
|
281
323
|
}
|
|
282
324
|
this.openUris.add(uri);
|
|
325
|
+
this.serverUris.set(uri, document.uri);
|
|
326
|
+
// Keyed as the server keys its notifications, which may not be how the
|
|
327
|
+
// caller spelled the URI.
|
|
328
|
+
this.host.forgetDirty?.(document.uri);
|
|
283
329
|
return document;
|
|
284
330
|
}
|
|
285
331
|
|
|
@@ -294,8 +340,11 @@ export class DataSession<
|
|
|
294
340
|
// Forgotten before the close is sent, so a reconnect in between does not
|
|
295
341
|
// re-open a document the caller has closed; after the wait, so an open
|
|
296
342
|
// of it that was still in flight does not record it again.
|
|
343
|
+
const serverUri = this.serverUris.get(args.uri) ?? args.uri;
|
|
297
344
|
this.openUris.delete(args.uri);
|
|
345
|
+
this.serverUris.delete(args.uri);
|
|
298
346
|
this.unsavedWrites.delete(args.uri);
|
|
347
|
+
this.host.forgetDirty?.(serverUri);
|
|
299
348
|
const server = await this.connected();
|
|
300
349
|
await server.closeModelDocument({ ...args, clientId: this.clientId });
|
|
301
350
|
}
|
|
@@ -350,12 +399,39 @@ export class DataSession<
|
|
|
350
399
|
|
|
351
400
|
/** Persist `args.model` to disk as this session. The session must have `args.uri` open. */
|
|
352
401
|
saveDocument(args: DataSessionSaveArgs<TTransfer, TServer>): Promise<DataSessionDocument<TTransfer, TServer>> {
|
|
353
|
-
|
|
402
|
+
const saving = this.track([args.uri], async () => {
|
|
354
403
|
const server = await this.connected();
|
|
355
404
|
const document = await server.saveModelDocument({ ...args, clientId: this.clientId });
|
|
356
405
|
this.unsavedWrites.delete(args.uri);
|
|
357
406
|
return document;
|
|
358
407
|
});
|
|
408
|
+
this.savesInFlight.add(saving);
|
|
409
|
+
const done = (): boolean => this.savesInFlight.delete(saving);
|
|
410
|
+
saving.then(done, done);
|
|
411
|
+
return saving;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Whether the session has ended, by {@link dispose} or {@link detach}. */
|
|
415
|
+
get isDisposed(): boolean {
|
|
416
|
+
return this.disposed;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Whether a save of this session has not answered yet. Synchronous, for a
|
|
421
|
+
* host whose exit veto must decide within the tick, such as Theia's
|
|
422
|
+
* `onWillStop`.
|
|
423
|
+
*/
|
|
424
|
+
get hasSavesInFlight(): boolean {
|
|
425
|
+
return this.savesInFlight.size > 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Resolves once the saves in flight now have answered, or
|
|
430
|
+
* {@link settleBeforeCloseMs} has passed. Never rejects: a failed save has
|
|
431
|
+
* answered too.
|
|
432
|
+
*/
|
|
433
|
+
whenSavesSettled(): Promise<void> {
|
|
434
|
+
return this.settle(this.savesInFlight);
|
|
359
435
|
}
|
|
360
436
|
|
|
361
437
|
/**
|
|
@@ -375,16 +451,17 @@ export class DataSession<
|
|
|
375
451
|
* end it on the server, which closes everything it has open. Idempotent,
|
|
376
452
|
* and leaves the connection usable by its other sessions.
|
|
377
453
|
*
|
|
378
|
-
* Every later call rejects
|
|
379
|
-
*
|
|
380
|
-
*
|
|
454
|
+
* Every later call rejects, and so does a call made earlier in the same
|
|
455
|
+
* tick, which has not reached the wire yet and is never sent. The server
|
|
456
|
+
* close is not awaited, because a `Disposable` cannot be; a close that fails
|
|
457
|
+
* leaves the session to the server's connection-close cleanup.
|
|
381
458
|
*/
|
|
382
459
|
dispose(): void {
|
|
383
460
|
if (this.disposed) {
|
|
384
461
|
return;
|
|
385
462
|
}
|
|
386
463
|
this.disposed = true;
|
|
387
|
-
this.
|
|
464
|
+
this.fireDispose();
|
|
388
465
|
const pending = [...this.inFlight.values()].flatMap(calls => [...calls]);
|
|
389
466
|
void (async () => {
|
|
390
467
|
await this.settle(pending);
|
|
@@ -403,12 +480,27 @@ export class DataSession<
|
|
|
403
480
|
* Sends nothing, unlike {@link dispose}: the server ends every session on a
|
|
404
481
|
* connection it sees close, and the close would travel over the very
|
|
405
482
|
* connection being disposed.
|
|
483
|
+
*
|
|
484
|
+
* Public because the connection calls it; anyone else ends a session with
|
|
485
|
+
* {@link dispose}. It fires {@link onDidDispose} only if the session has not
|
|
486
|
+
* already ended, and after {@link dispose} it still cancels the close that
|
|
487
|
+
* dispose has not sent yet.
|
|
406
488
|
*/
|
|
407
489
|
detach(): void {
|
|
408
|
-
this.disposed = true;
|
|
409
490
|
this.detached = true;
|
|
410
491
|
this.openUris.clear();
|
|
492
|
+
this.serverUris.clear();
|
|
411
493
|
this.unsavedWrites.clear();
|
|
494
|
+
if (!this.disposed) {
|
|
495
|
+
this.disposed = true;
|
|
496
|
+
this.fireDispose();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Fire {@link onDidDispose} and dispose its emitter, so a second call fires nothing. */
|
|
501
|
+
protected fireDispose(): void {
|
|
502
|
+
this.disposeEmitter.fire(undefined);
|
|
503
|
+
this.disposeEmitter.dispose();
|
|
412
504
|
}
|
|
413
505
|
|
|
414
506
|
/**
|
|
@@ -452,6 +544,11 @@ export class DataSession<
|
|
|
452
544
|
* naming every document whose unsaved text is gone, whose record is then
|
|
453
545
|
* dropped, and one per document that could not be re-opened, which is
|
|
454
546
|
* forgotten.
|
|
547
|
+
*
|
|
548
|
+
* Each document's dirty state goes to the host too, read once the watch is
|
|
549
|
+
* in place: a flip while the connection was down, or between the re-open
|
|
550
|
+
* and the watch, reached no one, and the re-open's own answer misses the
|
|
551
|
+
* second.
|
|
455
552
|
*/
|
|
456
553
|
protected async restore(server: RpcProxy<TServer>): Promise<void> {
|
|
457
554
|
const lost: string[] = [];
|
|
@@ -459,6 +556,20 @@ export class DataSession<
|
|
|
459
556
|
try {
|
|
460
557
|
const document = await server.openModelDocument({ uri, clientId: this.clientId });
|
|
461
558
|
await server.watchModelDocument({ uri, clientId: this.clientId });
|
|
559
|
+
if (this.openUris.has(uri)) {
|
|
560
|
+
this.serverUris.set(uri, document.uri);
|
|
561
|
+
}
|
|
562
|
+
// A failed read leaves the client's dirty state as it was; the
|
|
563
|
+
// document is restored all the same.
|
|
564
|
+
const current = await server.getModelDocument({ uri }).catch(() => undefined);
|
|
565
|
+
if (current?.dirty !== undefined) {
|
|
566
|
+
try {
|
|
567
|
+
this.host.restoreDirty?.({ uri: current.uri, dirty: current.dirty });
|
|
568
|
+
} catch {
|
|
569
|
+
// The client's listener failed, not the restore: the server
|
|
570
|
+
// has the document open and watched, so it stays restored.
|
|
571
|
+
}
|
|
572
|
+
}
|
|
462
573
|
const written = this.unsavedWrites.get(uri);
|
|
463
574
|
if (written !== undefined && written !== document.version) {
|
|
464
575
|
lost.push(uri);
|
|
@@ -466,6 +577,7 @@ export class DataSession<
|
|
|
466
577
|
}
|
|
467
578
|
} catch (error: unknown) {
|
|
468
579
|
this.openUris.delete(uri);
|
|
580
|
+
this.serverUris.delete(uri);
|
|
469
581
|
this.unsavedWrites.delete(uri);
|
|
470
582
|
this.host.reportError?.(error, resolve(DATA_SESSION_RESTORE_FAILED, { uri, detail: describeError(error) }));
|
|
471
583
|
}
|
|
@@ -509,20 +621,12 @@ export class DataSession<
|
|
|
509
621
|
if (pending.length === 0) {
|
|
510
622
|
return;
|
|
511
623
|
}
|
|
512
|
-
|
|
513
|
-
const bound = new Promise<void>(resolveBound => {
|
|
514
|
-
timer = setTimeout(resolveBound, this.settleBeforeCloseMs);
|
|
515
|
-
});
|
|
516
|
-
try {
|
|
517
|
-
await Promise.race([Promise.allSettled(pending), bound]);
|
|
518
|
-
} finally {
|
|
519
|
-
clearTimeout(timer);
|
|
520
|
-
}
|
|
624
|
+
await this.clock.raceTimer(Promise.allSettled(pending), this.settleBeforeCloseMs);
|
|
521
625
|
}
|
|
522
626
|
|
|
523
627
|
protected assertLive(): void {
|
|
524
628
|
if (this.disposed) {
|
|
525
|
-
throw new
|
|
629
|
+
throw new SessionClosedError(this.clientId);
|
|
526
630
|
}
|
|
527
631
|
}
|
|
528
632
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
* identity, open/watch ordering, echo recognition), the inbound
|
|
19
19
|
* fan-out (`DataEvents`), and the two halves of the hop for hosts whose
|
|
20
20
|
* client cannot hold a socket — `createPostMessageTransport` on the client
|
|
21
|
-
* side and `relayToPostMessageChannel` on the side that does hold it
|
|
21
|
+
* side and `relayToPostMessageChannel` on the side that does hold it, and
|
|
22
|
+
* `createMessagePortTransport` for a head's worker `MessagePort`, at either
|
|
23
|
+
* end, which the GLSP worker head uses too.
|
|
22
24
|
*
|
|
23
25
|
* **Neutral, and gate-enforced so.** Nothing here imports a host package or a
|
|
24
26
|
* Node builtin, which is what lets one client tier serve a Theia frontend, a VS
|
|
@@ -110,7 +110,7 @@ export interface MessageRelay extends Disposable {
|
|
|
110
110
|
* imports the entrypoint that frames its own transport (`vscode-jsonrpc/node`
|
|
111
111
|
* for a socket) and the RAL that comes with it; this tier stays neutral and is
|
|
112
112
|
* gated so by `npm run check:neutral`. Contrast
|
|
113
|
-
* `@hydranium/
|
|
113
|
+
* `@hydranium/client-theia`'s `SocketChannelForwarder`, which does build a
|
|
114
114
|
* connection only to borrow its `onClose`, and pays a Theia dependency for the
|
|
115
115
|
* byte coding this shape does not need.
|
|
116
116
|
*
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
AbstractMessageReader,
|
|
12
12
|
AbstractMessageWriter,
|
|
13
13
|
Emitter,
|
|
14
|
+
RAL,
|
|
14
15
|
type DataCallback,
|
|
15
16
|
type Disposable,
|
|
16
17
|
type Message,
|
|
@@ -36,7 +37,8 @@ export interface PostMessageChannel {
|
|
|
36
37
|
/**
|
|
37
38
|
* Hand one JSON-RPC message to the other side. Fire-and-forget: delivery
|
|
38
39
|
* failures surface through {@link onClose}, never as a rejection, because
|
|
39
|
-
* `postMessage` has no completion to report.
|
|
40
|
+
* `postMessage` has no completion to report. A value the pipe refuses may
|
|
41
|
+
* throw, and the writer turns that into a rejected write and a writer error.
|
|
40
42
|
*/
|
|
41
43
|
post(message: Message): void;
|
|
42
44
|
|
|
@@ -67,12 +69,18 @@ export interface PostMessageChannel {
|
|
|
67
69
|
*
|
|
68
70
|
* Keeping the factory out of this module is also what lets the module stay
|
|
69
71
|
* browser-neutral: it touches only `AbstractMessageReader` /
|
|
70
|
-
* `AbstractMessageWriter` / `Emitter`, none of which need a RAL
|
|
72
|
+
* `AbstractMessageWriter` / `Emitter`, none of which need a RAL, and the RAL's
|
|
73
|
+
* timer, which {@link createMessagePortTransport} reads only once a message
|
|
74
|
+
* arrives, by when the host that built the connection has installed one in
|
|
75
|
+
* the same copy of `vscode-jsonrpc`.
|
|
71
76
|
*/
|
|
72
77
|
export interface PostMessageTransport {
|
|
73
78
|
readonly reader: MessageReader;
|
|
74
79
|
readonly writer: MessageWriter;
|
|
75
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Release the channel subscriptions. A {@link createMessagePortTransport}
|
|
82
|
+
* transport also signals its end to the other side.
|
|
83
|
+
*/
|
|
76
84
|
dispose(): void;
|
|
77
85
|
}
|
|
78
86
|
|
|
@@ -112,6 +120,7 @@ class PostMessageReader extends AbstractMessageReader implements MessageReader {
|
|
|
112
120
|
/** The dual of {@link PostMessageReader} — one `post` per JSON-RPC message. */
|
|
113
121
|
class PostMessageWriter extends AbstractMessageWriter implements MessageWriter {
|
|
114
122
|
protected readonly subscriptions: Disposable[] = [];
|
|
123
|
+
protected errorCount = 0;
|
|
115
124
|
|
|
116
125
|
constructor(protected readonly channel: PostMessageChannel) {
|
|
117
126
|
super();
|
|
@@ -122,7 +131,17 @@ class PostMessageWriter extends AbstractMessageWriter implements MessageWriter {
|
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
write(message: Message): Promise<void> {
|
|
125
|
-
|
|
134
|
+
try {
|
|
135
|
+
this.channel.post(message);
|
|
136
|
+
} catch (error: unknown) {
|
|
137
|
+
// `postMessage` throws synchronously on a value structured clone
|
|
138
|
+
// refuses. Thrown on, it would escape from the caller's
|
|
139
|
+
// `sendNotification` rather than reject it, and the connection would
|
|
140
|
+
// never see a write error.
|
|
141
|
+
this.errorCount++;
|
|
142
|
+
this.fireError(error, message, this.errorCount);
|
|
143
|
+
return Promise.reject(error);
|
|
144
|
+
}
|
|
126
145
|
// `postMessage` reports no completion, so the resolved promise means
|
|
127
146
|
// "handed over", not "delivered". vscode-jsonrpc only needs the former.
|
|
128
147
|
return Promise.resolve();
|
|
@@ -165,3 +184,199 @@ export function createPostMessageTransport(channel: PostMessageChannel): PostMes
|
|
|
165
184
|
}
|
|
166
185
|
};
|
|
167
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A `MessagePort` the host transferred into or out of a worker, described
|
|
190
|
+
* structurally.
|
|
191
|
+
*
|
|
192
|
+
* This package compiles without the DOM lib, so `MessagePort` has no name here
|
|
193
|
+
* and the contract has to be spelled out.
|
|
194
|
+
*
|
|
195
|
+
* **The member set admits a `MessagePort` and REJECTS a `Worker` or the worker
|
|
196
|
+
* global**, both of which would otherwise fit a `postMessage` pipe. Only a port
|
|
197
|
+
* needs starting, so `start` is what tells the three apart, and naming it here
|
|
198
|
+
* turns "never bind a head to the global" into a compile error at the call
|
|
199
|
+
* site. A head on the global receives every other head's traffic.
|
|
200
|
+
*
|
|
201
|
+
* **That compile error happens at the ADOPTER, not here.** This package
|
|
202
|
+
* resolves neither `MessagePort` nor `Worker` as a type, so nothing here can
|
|
203
|
+
* demonstrate the rejection; a host compiling its worker against
|
|
204
|
+
* `lib.webworker` (or a page against `lib.dom`) is where the names resolve and
|
|
205
|
+
* the guard bites. Measured there: passing the worker global fails with
|
|
206
|
+
* `Property 'start' is missing in type 'DedicatedWorkerGlobalScope'`.
|
|
207
|
+
*/
|
|
208
|
+
export interface TransferredMessagePort {
|
|
209
|
+
postMessage(message: unknown): void;
|
|
210
|
+
addEventListener(type: 'message', listener: (event: unknown) => void, options?: unknown): void;
|
|
211
|
+
removeEventListener(type: 'message', listener: (event: unknown) => void, options?: unknown): void;
|
|
212
|
+
start(): void;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The value {@link createMessagePortTransport} posts in place of a JSON-RPC
|
|
217
|
+
* message to say its end is going away. A string, so no JSON-RPC message can
|
|
218
|
+
* be mistaken for it.
|
|
219
|
+
*/
|
|
220
|
+
const MESSAGE_PORT_CLOSE_SIGNAL = 'hydranium/message-port-closed';
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A {@link PostMessageChannel} over a {@link TransferredMessagePort}, whose
|
|
224
|
+
* {@link close} posts {@link MESSAGE_PORT_CLOSE_SIGNAL}.
|
|
225
|
+
*
|
|
226
|
+
* **A port reports nothing when its peer goes away**: Chromium ships no `close`
|
|
227
|
+
* event on `MessagePort`, Firefox and WebKit have not committed to one, and
|
|
228
|
+
* `messageerror` fires only for a message that cannot be deserialized. So the
|
|
229
|
+
* end is signalled in band.
|
|
230
|
+
*
|
|
231
|
+
* The port is never `close()`d. A later transport can still be built on it, at
|
|
232
|
+
* both ends, and whether messages posted just before a `close()` are delivered
|
|
233
|
+
* is not something the platform settles.
|
|
234
|
+
*/
|
|
235
|
+
class MessagePortChannel implements PostMessageChannel {
|
|
236
|
+
protected readonly messageEmitter = new Emitter<Message>();
|
|
237
|
+
protected readonly closeEmitter = new Emitter<void>();
|
|
238
|
+
/** Set once either end has signalled; this end then delivers nothing more and never signals again. */
|
|
239
|
+
protected closed = false;
|
|
240
|
+
/** Set when the peer's signal has arrived and its close waits on {@link backlog}. */
|
|
241
|
+
protected closePending = false;
|
|
242
|
+
/** Messages handed to the connection that its queue may not have dispatched yet. */
|
|
243
|
+
protected backlog = 0;
|
|
244
|
+
// A `MessageEvent`, which the listener's type cannot name without the DOM lib.
|
|
245
|
+
protected readonly listener = (event: unknown): void => this.receive((event as { readonly data: unknown }).data);
|
|
246
|
+
|
|
247
|
+
constructor(protected readonly port: TransferredMessagePort) {
|
|
248
|
+
port.addEventListener('message', this.listener);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
post(message: Message): void {
|
|
252
|
+
if (!this.closed) {
|
|
253
|
+
this.port.postMessage(message);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
onMessage(listener: (message: Message) => void): Disposable {
|
|
258
|
+
return this.messageEmitter.event(listener);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
onClose(listener: () => void): Disposable {
|
|
262
|
+
return this.closeEmitter.event(listener);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Signal this end's close to the peer, once, after everything posted before it. */
|
|
266
|
+
close(): void {
|
|
267
|
+
if (this.closed) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
this.end();
|
|
271
|
+
this.port.postMessage(MESSAGE_PORT_CLOSE_SIGNAL);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
protected end(): void {
|
|
275
|
+
this.closed = true;
|
|
276
|
+
this.port.removeEventListener('message', this.listener);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Deliver one inbound value, or take the peer's signal.
|
|
281
|
+
*
|
|
282
|
+
* **The close waits until the connection has dispatched every message that
|
|
283
|
+
* came before it.** Firing it at once is safe under `vscode-jsonrpc`'s
|
|
284
|
+
* browser runtime, which drains the connection's queue on microtasks before
|
|
285
|
+
* the signal's own task runs. Under its Node runtime the queue gives out one
|
|
286
|
+
* message per `setImmediate` turn, so a close fired at once would overtake a
|
|
287
|
+
* `closeSession` still queued, and end that session as lost. So each
|
|
288
|
+
* delivered message is counted down on the same RAL timer, one per turn,
|
|
289
|
+
* scheduled after the connection's own, and the close fires when the count
|
|
290
|
+
* reaches zero: under either runtime, after the last dispatch.
|
|
291
|
+
*/
|
|
292
|
+
protected receive(data: unknown): void {
|
|
293
|
+
if (this.closed) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (data === MESSAGE_PORT_CLOSE_SIGNAL) {
|
|
297
|
+
this.end();
|
|
298
|
+
this.closePending = true;
|
|
299
|
+
if (this.backlog === 0) {
|
|
300
|
+
this.closeEmitter.fire();
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.messageEmitter.fire(data as Message);
|
|
305
|
+
this.backlog++;
|
|
306
|
+
if (this.backlog === 1) {
|
|
307
|
+
this.countDownBacklog();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Mirrors the connection's queue at its default of one message per turn. A
|
|
312
|
+
// connection built with a `maxParallelism`, or with a `messageStrategy` that
|
|
313
|
+
// defers `next`, can hold messages longer than this counts, and its close
|
|
314
|
+
// can then overtake them.
|
|
315
|
+
protected countDownBacklog(): void {
|
|
316
|
+
RAL().timer.setImmediate(() => {
|
|
317
|
+
this.backlog--;
|
|
318
|
+
if (this.backlog > 0) {
|
|
319
|
+
this.countDownBacklog();
|
|
320
|
+
} else if (this.closePending) {
|
|
321
|
+
this.closeEmitter.fire();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A {@link PostMessageWriter} whose dispose, which a `MessageConnection`'s
|
|
329
|
+
* `dispose` and `end` both reach, signals this end's close to the peer.
|
|
330
|
+
*/
|
|
331
|
+
class MessagePortWriter extends PostMessageWriter {
|
|
332
|
+
constructor(protected override readonly channel: MessagePortChannel) {
|
|
333
|
+
super(channel);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
override dispose(): void {
|
|
337
|
+
this.channel.close();
|
|
338
|
+
super.dispose();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Build the reader/writer pair for a head's `MessagePort`, at either end.
|
|
344
|
+
*
|
|
345
|
+
* Disposing the writer (which the connection's own `dispose` does) posts a
|
|
346
|
+
* close signal after everything it wrote, and the other end's reader and writer
|
|
347
|
+
* then fire close, so a head tears down on its client's dispose as it does on a
|
|
348
|
+
* socket's close. For a connection at its default parallelism and message
|
|
349
|
+
* strategy, the close fires only once that end's connection has dispatched
|
|
350
|
+
* every message sent before the signal, under either `vscode-jsonrpc` runtime,
|
|
351
|
+
* so a `closeSession` sent just before the dispose still ends its session as
|
|
352
|
+
* closed. Nothing reports a page or a worker that dies, because the port
|
|
353
|
+
* cannot.
|
|
354
|
+
*
|
|
355
|
+
* **The transport and the connection must come from the same copy of
|
|
356
|
+
* `vscode-jsonrpc`.** The close is scheduled through the runtime abstraction
|
|
357
|
+
* layer of the copy this package imports. If the host builds its connection
|
|
358
|
+
* from another copy, that layer was never installed: the first message it
|
|
359
|
+
* delivers throws, and the peer's close never fires.
|
|
360
|
+
*
|
|
361
|
+
* **Both ends must use this.** A plain `BrowserMessageReader` peer hands the
|
|
362
|
+
* signal to its connection as a message it does not understand, and its own
|
|
363
|
+
* dispose posts nothing, so this end never learns that it went.
|
|
364
|
+
*
|
|
365
|
+
* Starts the port, because a listener added with `addEventListener` does not.
|
|
366
|
+
* From then on a message that arrives before the connection listens is dropped,
|
|
367
|
+
* so listen in the same task.
|
|
368
|
+
*/
|
|
369
|
+
export function createMessagePortTransport(port: TransferredMessagePort): PostMessageTransport {
|
|
370
|
+
const channel = new MessagePortChannel(port);
|
|
371
|
+
const reader = new PostMessageReader(channel);
|
|
372
|
+
const writer = new MessagePortWriter(channel);
|
|
373
|
+
port.start();
|
|
374
|
+
return {
|
|
375
|
+
reader,
|
|
376
|
+
writer,
|
|
377
|
+
dispose(): void {
|
|
378
|
+
reader.dispose();
|
|
379
|
+
writer.dispose();
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -189,13 +189,22 @@ export class RpcConnection<TServer extends ReadyServer, TClient extends object>
|
|
|
189
189
|
);
|
|
190
190
|
const server = createRpcProxy<TServer, TClient>(connection, {
|
|
191
191
|
methodNamespace: this.methodNamespace,
|
|
192
|
-
localTarget: this.
|
|
192
|
+
localTarget: this.localTarget(),
|
|
193
193
|
localMethods: this.clientMethods
|
|
194
194
|
});
|
|
195
195
|
this.generation = { connection, server };
|
|
196
196
|
return this.generation;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* The object whose {@link clientMethods} answer the server on each new
|
|
201
|
+
* generation: {@link client} itself. A subclass that must see a call before
|
|
202
|
+
* the client does returns an object that forwards each of them to it.
|
|
203
|
+
*/
|
|
204
|
+
protected localTarget(): TClient {
|
|
205
|
+
return this.client;
|
|
206
|
+
}
|
|
207
|
+
|
|
199
208
|
/** Await the connection and the server's startup gate for one generation. */
|
|
200
209
|
protected async awaitReady(generation: RpcConnectionGeneration<TServer>): Promise<void> {
|
|
201
210
|
try {
|
package/src/clock.ts
CHANGED
|
@@ -76,8 +76,30 @@ export interface Clock {
|
|
|
76
76
|
*/
|
|
77
77
|
measure<T>(callback: () => Promise<T>): Promise<Timed<T>>;
|
|
78
78
|
measure<T>(callback: () => T): Timed<T>;
|
|
79
|
+
/**
|
|
80
|
+
* Settle with `promise`, or with {@link TIMED_OUT} once `ms` have elapsed
|
|
81
|
+
* first. A promise that rejects first rejects this one; one that rejects
|
|
82
|
+
* after the timer is ignored. The timer is disposed the moment `promise`
|
|
83
|
+
* settles, so a won race leaves nothing scheduled, but `promise` itself
|
|
84
|
+
* runs on: nothing here can cancel it.
|
|
85
|
+
*
|
|
86
|
+
* Unlike VS Code's `raceTimeout`, this settles with a sentinel rather than
|
|
87
|
+
* `undefined`, so a promise resolving to `undefined` is not taken for a
|
|
88
|
+
* timeout; the caller branches on the result instead of passing an
|
|
89
|
+
* `onTimeout`.
|
|
90
|
+
*
|
|
91
|
+
* A custom implementation extends {@link SystemClock}, whose race runs over
|
|
92
|
+
* the subclass's {@link setTimer}.
|
|
93
|
+
*/
|
|
94
|
+
raceTimer<T>(promise: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT>;
|
|
79
95
|
}
|
|
80
96
|
|
|
97
|
+
/**
|
|
98
|
+
* What {@link Clock.raceTimer} settles with when its timer fires first.
|
|
99
|
+
* Registered under a global key, so two copies of this package agree on it.
|
|
100
|
+
*/
|
|
101
|
+
export const TIMED_OUT = Symbol.for('hydranium/protocol/timed-out');
|
|
102
|
+
|
|
81
103
|
/**
|
|
82
104
|
* The result of {@link Clock.measure}: a callback's return value paired with
|
|
83
105
|
* its elapsed wall-time in milliseconds. A plain value object — no methods, no
|
|
@@ -144,6 +166,10 @@ function unrefTimer(handle: { unref?: () => void }): void {
|
|
|
144
166
|
* Default {@link Clock}: delegates to the platform's `Date.now`,
|
|
145
167
|
* `performance.now`, and `setTimeout`/`clearTimeout`. Bound everywhere in
|
|
146
168
|
* production; swapped for `makeFakeClock` in tests.
|
|
169
|
+
*
|
|
170
|
+
* {@link measure} and {@link raceTimer} run over {@link stopwatch} and
|
|
171
|
+
* {@link setTimer}, so a clock that extends this one and replaces those
|
|
172
|
+
* measures and races on its own time, as the test clock does.
|
|
147
173
|
*/
|
|
148
174
|
export class SystemClock implements Clock {
|
|
149
175
|
now(): number {
|
|
@@ -170,4 +196,34 @@ export class SystemClock implements Clock {
|
|
|
170
196
|
}
|
|
171
197
|
return { result: result as T, elapsedMs: stopwatch.elapsedMs };
|
|
172
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The timer settles the race one microtask after it fires. A real timer
|
|
202
|
+
* runs only once pending resolutions are delivered, while a fake one fires
|
|
203
|
+
* inside its `advance`, so settling at once would let a promise already
|
|
204
|
+
* resolved when `advance` runs lose to a timer it beats in production. The
|
|
205
|
+
* deferral covers only that promise: one that settles through further
|
|
206
|
+
* chained reactions, such as a `.then` chain, an async function or
|
|
207
|
+
* `Promise.allSettled`, can still lose to an `advance` in the same turn,
|
|
208
|
+
* while it wins on the platform's timer. The handlers go on `promise`
|
|
209
|
+
* before {@link setTimer} runs, so a `setTimer` that throws still leaves
|
|
210
|
+
* `promise` observed.
|
|
211
|
+
*/
|
|
212
|
+
raceTimer<T>(promise: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
|
|
213
|
+
return new Promise<T | typeof TIMED_OUT>((resolve, reject) => {
|
|
214
|
+
// eslint-disable-next-line prefer-const -- with a `const`, a `setTimer` that throws leaves it uninitialised, and the handlers' later read of it is an unhandled ReferenceError
|
|
215
|
+
let timer: Disposable | undefined;
|
|
216
|
+
promise.then(
|
|
217
|
+
value => {
|
|
218
|
+
timer?.dispose();
|
|
219
|
+
resolve(value);
|
|
220
|
+
},
|
|
221
|
+
(err: unknown) => {
|
|
222
|
+
timer?.dispose();
|
|
223
|
+
reject(err);
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
timer = this.setTimer(() => void Promise.resolve().then(() => resolve(TIMED_OUT)), ms);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
173
229
|
}
|
|
@@ -66,6 +66,7 @@ export const REFERENCE_SERVER_PROTOCOL_METHODS = [
|
|
|
66
66
|
export const DOCUMENT_CLIENT_PROTOCOL_METHODS = [
|
|
67
67
|
'onDocumentUpdated',
|
|
68
68
|
'onDocumentSaved',
|
|
69
|
+
'onDocumentDirtyChanged',
|
|
69
70
|
'onDocumentDeleted',
|
|
70
71
|
'onDocumentsBuilt'
|
|
71
72
|
] as const satisfies ReadonlyArray<keyof DocumentClientProtocol<TransferElement> & string>;
|