@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.
Files changed (94) hide show
  1. package/lib/client/data-connection.d.ts +35 -4
  2. package/lib/client/data-connection.d.ts.map +1 -1
  3. package/lib/client/data-connection.js +67 -5
  4. package/lib/client/data-connection.js.map +1 -1
  5. package/lib/client/data-events.d.ts +5 -1
  6. package/lib/client/data-events.d.ts.map +1 -1
  7. package/lib/client/data-events.js +7 -0
  8. package/lib/client/data-events.js.map +1 -1
  9. package/lib/client/data-session.d.ts +76 -8
  10. package/lib/client/data-session.d.ts.map +1 -1
  11. package/lib/client/data-session.js +108 -19
  12. package/lib/client/data-session.js.map +1 -1
  13. package/lib/client/index.d.ts +3 -1
  14. package/lib/client/index.d.ts.map +1 -1
  15. package/lib/client/index.js +3 -1
  16. package/lib/client/index.js.map +1 -1
  17. package/lib/client/message-relay.d.ts +1 -1
  18. package/lib/client/message-relay.js +1 -1
  19. package/lib/client/post-message-transport.d.ts +64 -3
  20. package/lib/client/post-message-transport.d.ts.map +1 -1
  21. package/lib/client/post-message-transport.js +175 -1
  22. package/lib/client/post-message-transport.js.map +1 -1
  23. package/lib/client/rpc-connection.d.ts +6 -0
  24. package/lib/client/rpc-connection.d.ts.map +1 -1
  25. package/lib/client/rpc-connection.js +9 -1
  26. package/lib/client/rpc-connection.js.map +1 -1
  27. package/lib/clock.d.ts +38 -0
  28. package/lib/clock.d.ts.map +1 -1
  29. package/lib/clock.js +36 -1
  30. package/lib/clock.js.map +1 -1
  31. package/lib/data/data-protocol-methods.d.ts +2 -2
  32. package/lib/data/data-protocol-methods.d.ts.map +1 -1
  33. package/lib/data/data-protocol-methods.js +1 -0
  34. package/lib/data/data-protocol-methods.js.map +1 -1
  35. package/lib/data/data-server-protocol.d.ts +11 -1
  36. package/lib/data/data-server-protocol.d.ts.map +1 -1
  37. package/lib/data/events.d.ts +14 -0
  38. package/lib/data/events.d.ts.map +1 -1
  39. package/lib/errors.d.ts +20 -9
  40. package/lib/errors.d.ts.map +1 -1
  41. package/lib/errors.js +18 -9
  42. package/lib/errors.js.map +1 -1
  43. package/lib/glsp-save-model-actions.d.ts +50 -0
  44. package/lib/glsp-save-model-actions.d.ts.map +1 -0
  45. package/lib/glsp-save-model-actions.js +28 -0
  46. package/lib/glsp-save-model-actions.js.map +1 -0
  47. package/lib/index.d.ts +1 -0
  48. package/lib/index.d.ts.map +1 -1
  49. package/lib/index.js +1 -0
  50. package/lib/index.js.map +1 -1
  51. package/lib/messages/index.d.ts +1 -1
  52. package/lib/messages/index.d.ts.map +1 -1
  53. package/lib/messages/index.js +2 -1
  54. package/lib/messages/index.js.map +1 -1
  55. package/lib/testing/data-doubles.d.ts +3 -1
  56. package/lib/testing/data-doubles.d.ts.map +1 -1
  57. package/lib/testing/data-doubles.js +6 -1
  58. package/lib/testing/data-doubles.js.map +1 -1
  59. package/lib/testing/fake-clock.d.ts +9 -1
  60. package/lib/testing/fake-clock.d.ts.map +1 -1
  61. package/lib/testing/fake-clock.js +54 -45
  62. package/lib/testing/fake-clock.js.map +1 -1
  63. package/lib/testing/node/index.d.ts +1 -0
  64. package/lib/testing/node/index.d.ts.map +1 -1
  65. package/lib/testing/node/index.js +2 -2
  66. package/lib/testing/node/index.js.map +1 -1
  67. package/lib/testing/node/message-port-pair.d.ts +25 -0
  68. package/lib/testing/node/message-port-pair.d.ts.map +1 -0
  69. package/lib/testing/node/message-port-pair.js +26 -0
  70. package/lib/testing/node/message-port-pair.js.map +1 -0
  71. package/lib/transfer-document.d.ts +8 -0
  72. package/lib/transfer-document.d.ts.map +1 -1
  73. package/lib/transfer-document.js.map +1 -1
  74. package/package.json +1 -1
  75. package/src/client/data-connection.ts +72 -6
  76. package/src/client/data-events.ts +9 -0
  77. package/src/client/data-session.ts +125 -21
  78. package/src/client/index.ts +3 -1
  79. package/src/client/message-relay.ts +1 -1
  80. package/src/client/post-message-transport.ts +219 -4
  81. package/src/client/rpc-connection.ts +10 -1
  82. package/src/clock.ts +56 -0
  83. package/src/data/data-protocol-methods.ts +1 -0
  84. package/src/data/data-server-protocol.ts +12 -0
  85. package/src/data/events.ts +16 -0
  86. package/src/errors.ts +24 -9
  87. package/src/glsp-save-model-actions.ts +59 -0
  88. package/src/index.ts +1 -0
  89. package/src/messages/index.ts +1 -1
  90. package/src/testing/data-doubles.ts +10 -1
  91. package/src/testing/fake-clock.ts +62 -47
  92. package/src/testing/node/index.ts +2 -2
  93. package/src/testing/node/message-port-pair.ts +40 -0
  94. package/src/transfer-document.ts +8 -0
@@ -7,7 +7,10 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import type { DataServerProtocol, DiagnosticOf } from '../data';
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, and reports the documents whose
143
- * unsaved edits did not survive; see {@link restore}. The connection does this at
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
- return this.track([args.uri], async () => {
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. The server close is not awaited, because a
379
- * `Disposable` cannot be; a close that fails leaves the session to the
380
- * server's connection-close cleanup.
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.host.releaseSession(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
- let timer: ReturnType<typeof setTimeout> | undefined;
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 Error('DataSession is disposed');
629
+ throw new SessionClosedError(this.clientId);
526
630
  }
527
631
  }
528
632
  }
@@ -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/data-client-theia`'s `SocketChannelForwarder`, which does build a
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
- /** Release the channel subscriptions. */
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
- this.channel.post(message);
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.client,
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>;