@hydranium/protocol 1.0.0-next.284 → 1.0.0-next.287

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.
@@ -8,7 +8,7 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  import { Disposable, Emitter, type Event } from 'vscode-jsonrpc';
11
- import { FRAMEWORK_CLIENT_IDS } from '../client-ids';
11
+ import { FRAMEWORK_CLIENT_IDS, UNKNOWN_CLIENT_ID } from '../client-ids';
12
12
  import {
13
13
  DATA_CLIENT_PROTOCOL_METHODS,
14
14
  DATA_SERVER_WIRE_PREFIX,
@@ -20,6 +20,7 @@ import {
20
20
  type TransferDocumentDirtyChangedEvent,
21
21
  type TransferDocumentUpdatedEvent
22
22
  } from '../data';
23
+ import type { TransferWatchedDocument } from '../transfer-document';
23
24
  import { DuplicateClientIdError, ReservedClientIdError } from '../errors';
24
25
  import type { Logger } from '../logger';
25
26
  import { defineMessage, describeError, describeErrorWithStack, resolve } from '../messages/primitives';
@@ -49,6 +50,31 @@ export const DATA_CONNECTION_SESSION_RESTORE_FAILED = defineMessage(
49
50
  'Could not restore a session after reconnecting to the data server: {detail}'
50
51
  );
51
52
 
53
+ /**
54
+ * A watch {@link DataConnection.watchDocument} keeps, with what the client last
55
+ * heard of its document: a reconnect whose watch answers that no document
56
+ * exists tells the client of a deletion only where it had heard one did.
57
+ */
58
+ export interface DataConnectionWatch {
59
+ /** The URI as the caller named it, which the watch is sent under. */
60
+ readonly uri: string;
61
+ /** The key a watch of it last answered with, which events name; absent until one answered. */
62
+ readonly serverUri?: string;
63
+ /** Whether the client last heard that the document exists. */
64
+ readonly exists: boolean;
65
+ }
66
+
67
+ /** What an event heard while a watch was in flight said of its document, and the event's position. */
68
+ export interface DataConnectionHeardEvent {
69
+ readonly exists: boolean;
70
+ readonly sequence: number;
71
+ }
72
+
73
+ /** Whether the document exists, by the newer of a watch's answer and an event heard while the watch was in flight. */
74
+ function existsAfter(heard: DataConnectionHeardEvent | undefined, answer: TransferWatchedDocument): boolean {
75
+ return heard !== undefined && heard.sequence > answer.sequence ? heard.exists : answer.exists;
76
+ }
77
+
52
78
  /** Options for {@link DataConnection}. */
53
79
  export interface DataConnectionOptions<
54
80
  TTransfer extends TransferElement = TransferElement,
@@ -143,11 +169,11 @@ export class DataConnection<
143
169
  /** Backs each session's {@link DataSessionHost.onDidDeleteDocument}. */
144
170
  protected readonly documentDeletedEmitter = new Emitter<TransferDocumentDeletedEvent>();
145
171
  /**
146
- * Per id {@link watchDocument} watches under, the URI it watches, which
172
+ * Per id {@link watchDocument} watches under, the watch, which
147
173
  * {@link generationReady} sends again to every later generation until the
148
174
  * watch's handle is disposed.
149
175
  */
150
- protected readonly watches = new Map<string, string>();
176
+ protected readonly watches = new Map<string, DataConnectionWatch>();
151
177
  /**
152
178
  * Per id, the URI of a watch whose handle was disposed with no ready
153
179
  * generation to unwatch on. {@link generationReady} sends the unwatch to the
@@ -155,6 +181,19 @@ export class DataConnection<
155
181
  * watches were sent leaves them on a connection the port may hand back.
156
182
  */
157
183
  protected readonly pendingUnwatches = new Map<string, string>();
184
+ /**
185
+ * The keys of the deletions the current generation's reconnect told the
186
+ * client of, so a URI several sessions and watches follow is told once.
187
+ */
188
+ protected readonly toldDeletions = new Set<string>();
189
+ /**
190
+ * One map per watch in flight, keyed by document, of what the last event
191
+ * heard since the watch was sent said of its existence, and the event's
192
+ * position. The server may take the answer before an event goes out, so
193
+ * an event heard in flight wins over the answer where its position is
194
+ * past the one the answer carries.
195
+ */
196
+ protected readonly heardInFlight = new Set<Map<string, DataConnectionHeardEvent>>();
158
197
  /** The port's logger, or one that logs nothing. */
159
198
  protected readonly logger: Logger;
160
199
  /** Backs {@link onDidReconnect}. */
@@ -230,6 +269,8 @@ export class DataConnection<
230
269
  connected: () => this.connected(),
231
270
  reportError: (error, reported) => this.reportError(error, reported),
232
271
  restoreDirty: event => this.restoreDirty(event),
272
+ // Without it the session reports the unsaved text it loses.
273
+ restoreDeletion: this.hearsDeletions ? event => this.restoreDeletion(event) : undefined,
233
274
  forgetDirty: uri => this.dirtyStates.delete(uri),
234
275
  onDidChangeDirty: this.dirtyChangedEmitter.event,
235
276
  onDidUpdateDocument: this.documentUpdatedEmitter.event,
@@ -255,22 +296,28 @@ export class DataConnection<
255
296
  * for as long as the caller follows it. The watch runs under an id of its
256
297
  * own, `label` plus `#` plus a random UUID, and needs no session: a session
257
298
  * closing the document would also end its own watch of it. Every later
258
- * connection that becomes ready gets the watch again. Disposing the handle
259
- * unwatches.
299
+ * connection that becomes ready gets the watch again, and tells the client
300
+ * of a deletion while it was down. Disposing the handle unwatches.
260
301
  */
261
302
  async watchDocument(uri: string, label = 'watch'): Promise<Disposable> {
262
303
  const clientId = `${label}#${randomUuid()}`;
263
304
  const server = await this.connected();
264
305
  // Kept only from here: kept during the wait, a generation that became
265
306
  // ready meanwhile would send the watch a second time.
266
- this.watches.set(clientId, uri);
307
+ this.watches.set(clientId, { uri, exists: false });
308
+ const heard = new Map<string, DataConnectionHeardEvent>();
309
+ this.heardInFlight.add(heard);
310
+ let answer: TransferWatchedDocument;
267
311
  try {
268
- await server.watchModelDocument({ uri, clientId });
312
+ answer = await server.watchModelDocument({ uri, clientId });
269
313
  } catch (error: unknown) {
270
314
  this.watches.delete(clientId);
271
315
  throw error;
316
+ } finally {
317
+ this.heardInFlight.delete(heard);
272
318
  }
273
319
  this.logger.debug(`Watch ${uri} as ${clientId}`);
320
+ this.watches.set(clientId, { uri, serverUri: answer.uri, exists: existsAfter(heard.get(answer.uri), answer) });
274
321
  return Disposable.create(() => {
275
322
  if (!this.watches.delete(clientId)) {
276
323
  return;
@@ -307,9 +354,9 @@ export class DataConnection<
307
354
  */
308
355
  protected override localTarget(): TClient {
309
356
  const client = this.client as unknown as Record<string, unknown>;
310
- const hearsDirty = typeof client.onDocumentDirtyChanged === 'function';
311
- const hearsUpdates = typeof client.onDocumentUpdated === 'function';
312
- const hearsDeletions = typeof client.onDocumentDeleted === 'function';
357
+ const hearsDirty = this.bindsClientMethod('onDocumentDirtyChanged');
358
+ const hearsUpdates = this.bindsClientMethod('onDocumentUpdated');
359
+ const hearsDeletions = this.hearsDeletions;
313
360
  if (!hearsDirty && !hearsUpdates && !hearsDeletions) {
314
361
  return this.client;
315
362
  }
@@ -329,6 +376,9 @@ export class DataConnection<
329
376
  if (hearsUpdates) {
330
377
  const forward = target.onDocumentUpdated as (event: TransferDocumentUpdatedEvent<TTransfer, DiagnosticOf<TServer>>) => void;
331
378
  target.onDocumentUpdated = (event: TransferDocumentUpdatedEvent<TTransfer, DiagnosticOf<TServer>>): void => {
379
+ if (event.document.model) {
380
+ this.markWatches(event.document.uri, true, event.sequence);
381
+ }
332
382
  this.documentUpdatedEmitter.fire(event);
333
383
  forward(event);
334
384
  };
@@ -336,6 +386,10 @@ export class DataConnection<
336
386
  if (hearsDeletions) {
337
387
  const forward = target.onDocumentDeleted as (event: TransferDocumentDeletedEvent) => void;
338
388
  target.onDocumentDeleted = (event: TransferDocumentDeletedEvent): void => {
389
+ // Held gone, so a reconnect tells the client of it no second time.
390
+ // A server sending no position has its deletion count as sent
391
+ // before any answer.
392
+ this.markWatches(event.uri, false, event.sequence ?? 0);
339
393
  this.documentDeletedEmitter.fire(event);
340
394
  forward(event);
341
395
  };
@@ -343,6 +397,50 @@ export class DataConnection<
343
397
  return target as unknown as TClient;
344
398
  }
345
399
 
400
+ /**
401
+ * Whether the client binds `onDocumentDeleted`. Without it no deletion
402
+ * reaches the client or the sessions, the server's or one a reconnect
403
+ * found, and a session's restore reports what a gone document took.
404
+ */
405
+ protected get hearsDeletions(): boolean {
406
+ return this.bindsClientMethod('onDocumentDeleted');
407
+ }
408
+
409
+ /** Whether `name` is among the methods bound for the client, and the client implements it. */
410
+ protected bindsClientMethod(name: string): boolean {
411
+ const client = this.client as unknown as Record<string, unknown>;
412
+ return (this.clientMethods as readonly string[]).includes(name) && typeof client[name] === 'function';
413
+ }
414
+
415
+ /**
416
+ * Hand the client a deletion a session's restore or a watch sent again
417
+ * found, which no event brought while the connection was down, unless
418
+ * {@link toldDeletions} says this generation told it already. The sessions
419
+ * do not hear it: each finds what it lost in its own restore.
420
+ */
421
+ protected restoreDeletion(event: TransferDocumentDeletedEvent): void {
422
+ if (this.toldDeletions.has(event.uri)) {
423
+ return;
424
+ }
425
+ this.toldDeletions.add(event.uri);
426
+ const client = this.client as unknown as Pick<DataClientProtocol<TTransfer>, 'onDocumentDeleted'>;
427
+ client.onDocumentDeleted(event);
428
+ }
429
+
430
+ /**
431
+ * Record in every watch whose answer named `uri` whether the document
432
+ * exists, and in {@link heardInFlight} for the watches not answered yet,
433
+ * with the event's position.
434
+ */
435
+ protected markWatches(uri: string, exists: boolean, sequence: number): void {
436
+ this.watches.forEach((watch, clientId) => {
437
+ if (watch.serverUri === uri) {
438
+ this.watches.set(clientId, { ...watch, exists });
439
+ }
440
+ });
441
+ this.heardInFlight.forEach(heard => heard.set(uri, { exists, sequence }));
442
+ }
443
+
346
444
  /**
347
445
  * Hand a dirty state a restore read to the client, unless it is what
348
446
  * {@link dirtyStates} says the client was last told; a URI it holds
@@ -357,8 +455,10 @@ export class DataConnection<
357
455
  /** Record `event` in {@link dirtyStates} and hand it to the client. */
358
456
  protected deliverDirty(event: TransferDocumentDirtyChangedEvent): void {
359
457
  this.dirtyStates.set(event.uri, event.text?.dirty ?? false);
360
- const client = this.client as unknown as Partial<Pick<DataClientProtocol<TTransfer>, 'onDocumentDirtyChanged'>>;
361
- client.onDocumentDirtyChanged?.(event);
458
+ if (this.bindsClientMethod('onDocumentDirtyChanged')) {
459
+ const client = this.client as unknown as Pick<DataClientProtocol<TTransfer>, 'onDocumentDirtyChanged'>;
460
+ client.onDocumentDirtyChanged(event);
461
+ }
362
462
  }
363
463
 
364
464
  /**
@@ -414,8 +514,9 @@ export class DataConnection<
414
514
  */
415
515
  protected override generationReady(generation: RpcConnectionGeneration<TServer>): void {
416
516
  super.generationReady(generation);
517
+ this.toldDeletions.clear();
417
518
  this.reconnectSessions();
418
- this.watches.forEach((uri, clientId) => this.watchAgain(generation, clientId, uri));
519
+ this.watches.forEach((watch, clientId) => this.watchAgain(generation, clientId, watch.uri));
419
520
  this.pendingUnwatches.forEach((uri, clientId) => this.unwatch(generation, clientId, uri));
420
521
  this.pendingUnwatches.clear();
421
522
  if (this.readyBefore) {
@@ -438,41 +539,58 @@ export class DataConnection<
438
539
  * `generation` is still current: otherwise the next generation sends it
439
540
  * again.
440
541
  *
441
- * Once the watch is in place, the document's dirty state is read and goes
442
- * through {@link restoreDirty}, as a session's restore does: a flip while
443
- * the connection was down reached no one.
542
+ * A document the client last heard exists that the watch's answer says
543
+ * is gone goes to the client as a deletion; once the watch is in place,
544
+ * the document's dirty state is read and goes through
545
+ * {@link restoreDirty}, as a session's restore does. Neither reached
546
+ * anyone while the connection was down.
444
547
  */
445
548
  protected watchAgain(generation: RpcConnectionGeneration<TServer>, clientId: string, uri: string): void {
446
- generation.server.watchModelDocument({ uri, clientId }).then(
447
- () => {
448
- if (!this.watches.has(clientId)) {
449
- return;
450
- }
451
- this.logger.debug(`Watch ${uri} again as ${clientId}`);
452
- generation.server.getModelDocument({ uri }).then(
453
- current => {
454
- if (current.text && this.watches.has(clientId)) {
455
- try {
456
- this.restoreDirty({ uri: current.uri, text: current.text });
457
- } catch {
458
- // The client's listener failed, not the watch, which stays kept.
459
- }
549
+ const heard = new Map<string, DataConnectionHeardEvent>();
550
+ this.heardInFlight.add(heard);
551
+ generation.server
552
+ .watchModelDocument({ uri, clientId })
553
+ .finally(() => this.heardInFlight.delete(heard))
554
+ .then(
555
+ answer => {
556
+ const known = this.watches.get(clientId);
557
+ if (!known) {
558
+ return;
559
+ }
560
+ this.logger.debug(`Watch ${uri} again as ${clientId}`);
561
+ const exists = existsAfter(heard.get(answer.uri), answer);
562
+ this.watches.set(clientId, { uri, serverUri: answer.uri, exists });
563
+ if (known.exists && !exists && this.hearsDeletions) {
564
+ try {
565
+ this.restoreDeletion({ uri: answer.uri, sourceClientId: UNKNOWN_CLIENT_ID });
566
+ } catch {
567
+ // The client's listener failed, not the watch, which stays kept.
460
568
  }
461
- },
462
- () => undefined
463
- );
464
- },
465
- (error: unknown) => {
466
- if (!this.watches.has(clientId)) {
467
- return;
468
- }
469
- if (this.generation !== generation) {
470
- this.logger.debug(`Watch of ${uri} as ${clientId} cut short by a lost connection; the next one sends it again`);
471
- return;
569
+ }
570
+ generation.server.getModelDocument({ uri }).then(
571
+ current => {
572
+ if (current.text && this.watches.has(clientId)) {
573
+ try {
574
+ this.restoreDirty({ uri: current.uri, text: current.text });
575
+ } catch {
576
+ // The client's listener failed, not the watch, which stays kept.
577
+ }
578
+ }
579
+ },
580
+ () => undefined
581
+ );
582
+ },
583
+ (error: unknown) => {
584
+ if (!this.watches.has(clientId)) {
585
+ return;
586
+ }
587
+ if (this.generation !== generation) {
588
+ this.logger.debug(`Watch of ${uri} as ${clientId} cut short by a lost connection; the next one sends it again`);
589
+ return;
590
+ }
591
+ this.reportError(error, resolve(DATA_CONNECTION_WATCH_RESTORE_FAILED, { uri, detail: describeError(error) }));
472
592
  }
473
- this.reportError(error, resolve(DATA_CONNECTION_WATCH_RESTORE_FAILED, { uri, detail: describeError(error) }));
474
- }
475
- );
593
+ );
476
594
  }
477
595
 
478
596
  /**
@@ -59,7 +59,11 @@ export class DataEvents<
59
59
  readonly onDidSaveDocument: Event<TransferDocumentSavedEvent<TTransfer, TDiagnostic>> = this.documentSavedEmitter.event;
60
60
  /** A watched document's text started or stopped differing from its file. */
61
61
  readonly onDidChangeDocumentDirty: Event<TransferDocumentDirtyChangedEvent> = this.documentDirtyChangedEmitter.event;
62
- /** A document's backing file was removed, watched or not. Any watch survives. */
62
+ /**
63
+ * A document's backing file was removed, watched or not. Any watch
64
+ * survives. After a reconnect, also a document a session had open or a
65
+ * watch followed that went while the connection was down.
66
+ */
63
67
  readonly onDidDeleteDocument: Event<TransferDocumentDeletedEvent> = this.documentDeletedEmitter.event;
64
68
  /** Documents built that nobody watches — re-read anything derived from them. */
65
69
  readonly onDidBuildDocuments: Event<TransferDocumentsBuiltEvent> = this.documentsBuiltEmitter.event;
@@ -8,6 +8,7 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  import { type Disposable, Emitter, type Event } from 'vscode-jsonrpc';
11
+ import { UNKNOWN_CLIENT_ID } from '../client-ids';
11
12
  import { type Clock, SystemClock } from '../clock';
12
13
  import type {
13
14
  DataServerProtocol,
@@ -190,6 +191,11 @@ function indexOf(write: DataSessionUnsavedWrite<TransferElement, DataServerProto
190
191
  return 'index' in write.call ? write.call.index : 0;
191
192
  }
192
193
 
194
+ /** The call `write` was last written by, which a restore writes again as one set. */
195
+ function setOf(write: DataSessionUnsavedWrite<TransferElement, DataServerProtocol<TransferElement>>): object {
196
+ return 'update' in write.call ? write.call.update : write.call.updates;
197
+ }
198
+
193
199
  /**
194
200
  * What a {@link DataSession} needs from the connection that minted it.
195
201
  *
@@ -206,6 +212,13 @@ export interface DataSessionHost<TTransfer extends TransferElement, TServer exte
206
212
  * is what the client was last told of the URI since the URI's last open.
207
213
  */
208
214
  restoreDirty?(event: TransferDocumentDirtyChangedEvent): void;
215
+ /**
216
+ * Tell the connection's client of a document a restore found gone, whose
217
+ * deletion reached no one while the connection was down, as the server
218
+ * tells it of a deletion over a live connection. Without it, the restore
219
+ * reports the unsaved text such a document took with it as lost.
220
+ */
221
+ restoreDeletion?(event: TransferDocumentDeletedEvent): void;
209
222
  /**
210
223
  * Forget what the client was told of `uri`'s dirty state: an open just
211
224
  * made answered its caller afresh, and the client heard none of it, or a
@@ -229,7 +242,7 @@ export interface DataSessionHost<TTransfer extends TransferElement, TServer exte
229
242
  /**
230
243
  * Fires with each deletion the server sends. A session forgets a document
231
244
  * whose file was deleted; without this event it keeps it as open until a
232
- * restore finds it gone, and reports its unsaved text lost only then.
245
+ * restore finds it gone.
233
246
  */
234
247
  readonly onDidDeleteDocument?: Event<TransferDocumentDeletedEvent>;
235
248
  /** Where the session logs the documents it forgets as gone. */
@@ -292,8 +305,9 @@ export class DataSession<
292
305
  /**
293
306
  * How long {@link closeDocument}, {@link deleteDocument} and {@link dispose}
294
307
  * wait for this session's calls in flight before going on anyway. Long,
295
- * because the wait only runs out when a call hangs, and a close sent while
296
- * a save is still running fails that save.
308
+ * because a close sent while a save is still writing fails that save. It
309
+ * need not outlast the server's language-client settle after the write,
310
+ * which a close does not fail.
297
311
  */
298
312
  protected readonly settleBeforeCloseMs: number = 10_000;
299
313
  /**
@@ -378,6 +392,13 @@ export class DataSession<
378
392
  * over a connection that is going away.
379
393
  */
380
394
  protected detached = false;
395
+ /**
396
+ * Write sets, keyed as a restore groups them, that lost a document to
397
+ * another client's deletion. A restore cannot write such a set again
398
+ * whole, so it writes none of it. Weak, so a set goes once no write
399
+ * record holds it.
400
+ */
401
+ protected readonly brokenSets = new WeakSet<object>();
381
402
  /** The subscription to the host's `onDidChangeDirty`, disposed with the session. */
382
403
  protected readonly dirtySubscription?: Disposable;
383
404
  /** The subscription to the host's `onDidUpdateDocument`, disposed with the session. */
@@ -917,7 +938,10 @@ export class DataSession<
917
938
  * let a restore re-open the file that is gone. Unwatched rather than
918
939
  * closed: the server keeps the watch, and a close could end a re-open of a
919
940
  * re-created file still in flight. Unsaved writes are logged, not
920
- * reported: the client hears the deletion too.
941
+ * reported: the client hears the deletion too. A document this session
942
+ * created and never saved, deleted by no session, is reported as well: a
943
+ * drop released it. An unsaved write breaks the set it was written with,
944
+ * see {@link brokenSets}.
921
945
  *
922
946
  * This session's own deletion is neither unwatched nor logged as dropping
923
947
  * unsaved text: its delete released the watch, and its caller chose to
@@ -934,7 +958,18 @@ export class DataSession<
934
958
  this.forgetDocument(uri);
935
959
  continue;
936
960
  }
961
+ const write = this.unsavedWrites.get(uri);
962
+ if (write) {
963
+ this.brokenSets.add(setOf(write));
964
+ }
965
+ // Never saved, so no file a watcher could see removed: a deletion
966
+ // naming no session is the release of the text a drop left behind.
967
+ const released = this.createdUris.has(uri) && event.sourceClientId === UNKNOWN_CLIENT_ID;
937
968
  this.forgetGoneDocument(uri);
969
+ if (released) {
970
+ const reported = resolve(DATA_SESSION_UNSAVED_LOST, { uris: uri });
971
+ this.host.reportError?.(new Error(reported.text), reported);
972
+ }
938
973
  this.connected()
939
974
  .then(server => server.unwatchModelDocument({ uri, clientId: this.clientId }))
940
975
  .catch(() => undefined);
@@ -987,7 +1022,7 @@ export class DataSession<
987
1022
  * write, so an edit arriving in between conflicts, and a conflict is not
988
1023
  * retried. Documents last written by one {@link updateDocuments} are sent
989
1024
  * again by one, and only when every one of them may be; a document that
990
- * could not be re-opened blocks its set.
1025
+ * could not be re-opened, or that another client deleted, blocks its set.
991
1026
  *
992
1027
  * No caller is waiting, so the outcomes go through the host: one report
993
1028
  * naming every document whose unsaved text is gone, whose record is then
@@ -997,8 +1032,13 @@ export class DataSession<
997
1032
  * restore to decide again. A document closed while the restore runs, or
998
1033
  * whose file is deleted then, is neither restored nor reported. One gone
999
1034
  * while the connection was down, which the server reads back without a
1000
- * model, is forgotten, and goes into the report of lost unsaved text when
1001
- * the session wrote it since its last save, or created it and never saved it.
1035
+ * model, is forgotten, and its deletion goes to the host as the deletion
1036
+ * no event brought; unsaved text it took with it is logged, as for a
1037
+ * deletion heard live, and reported as well for a document the session
1038
+ * created and never saved, which the drop released. A host that cannot
1039
+ * tell the client has the unsaved text of either reported. Once the session
1040
+ * is disposed, its caller has ended it, and the restore returns at its
1041
+ * next await: nothing more goes to the server or the host.
1002
1042
  *
1003
1043
  * Each document's dirty state goes to the host too, read once the watch is
1004
1044
  * in place and any write is sent: a flip while the connection was down, or
@@ -1020,6 +1060,9 @@ export class DataSession<
1020
1060
  }
1021
1061
  try {
1022
1062
  const document = await server.openModelDocument({ uri, clientId: this.clientId });
1063
+ if (this.disposed) {
1064
+ return;
1065
+ }
1023
1066
  if (!this.openUris.has(uri)) {
1024
1067
  // Forgotten while it re-opened. A close of the caller's would
1025
1068
  // follow, but a deletion sends none.
@@ -1036,6 +1079,9 @@ export class DataSession<
1036
1079
  await server.closeModelDocument({ uri, clientId: this.clientId }).catch(() => undefined);
1037
1080
  throw error;
1038
1081
  });
1082
+ if (this.disposed) {
1083
+ return;
1084
+ }
1039
1085
  if (this.openUris.has(uri)) {
1040
1086
  this.recordText(uri, document);
1041
1087
  }
@@ -1044,22 +1090,42 @@ export class DataSession<
1044
1090
  // Gone while the connection was down, which no deletion told the
1045
1091
  // session of: the server answers a read of it without a model.
1046
1092
  const read = await server.getModelDocument({ uri }).catch(() => undefined);
1093
+ if (this.disposed) {
1094
+ return;
1095
+ }
1047
1096
  // Forgotten meanwhile, so nothing is lost.
1048
1097
  if (!this.openUris.has(uri)) {
1049
1098
  continue;
1050
1099
  }
1051
1100
  if (read && !read.model) {
1052
- // No deletion reached the client either, so unsaved text the
1053
- // document took with it is reported, as a drop's other losses are.
1054
1101
  const write = this.unsavedWrites.get(uri);
1055
- if (this.forgetGoneDocument(uri)) {
1056
- lost.push(uri);
1057
- }
1102
+ // Named as the client last heard it, by the open's answer.
1103
+ const serverUri = this.opens.get(uri)?.serverUri ?? uri;
1104
+ // Never saved, so the drop released it and no one deleted it.
1105
+ const created = this.createdUris.has(uri);
1106
+ const unsaved = this.forgetGoneDocument(uri);
1058
1107
  // Kept for `reapply`, where it blocks its set as a document
1059
1108
  // that could not be re-opened does, and goes.
1060
1109
  if (write) {
1061
1110
  this.unsavedWrites.set(uri, write);
1062
1111
  }
1112
+ if (!this.host.restoreDeletion) {
1113
+ // No client hears of the deletion, so the loss is reported.
1114
+ if (unsaved) {
1115
+ lost.push(uri);
1116
+ }
1117
+ continue;
1118
+ }
1119
+ // The client hears of the deletion, so unsaved text is logged,
1120
+ // as for one heard live; the drop's release is reported too.
1121
+ if (created) {
1122
+ lost.push(uri);
1123
+ }
1124
+ try {
1125
+ this.host.restoreDeletion({ uri: serverUri, sourceClientId: UNKNOWN_CLIENT_ID });
1126
+ } catch {
1127
+ // The client's listener failed, not the restore.
1128
+ }
1063
1129
  continue;
1064
1130
  }
1065
1131
  // Its record goes with its set's, in `reapply`, which it blocks.
@@ -1073,11 +1139,14 @@ export class DataSession<
1073
1139
  }
1074
1140
  const sets = new Map<object, [string, DataSessionUnsavedWrite<TTransfer, TServer>][]>();
1075
1141
  for (const [uri, write] of writes) {
1076
- const call = 'update' in write.call ? write.call.update : write.call.updates;
1077
- sets.set(call, [...(sets.get(call) ?? []), [uri, write]]);
1142
+ const set = setOf(write);
1143
+ sets.set(set, [...(sets.get(set) ?? []), [uri, write]]);
1078
1144
  }
1079
1145
  for (const members of sets.values()) {
1080
1146
  lost.push(...(await this.reapply(server, members, reopened)));
1147
+ if (this.disposed) {
1148
+ return;
1149
+ }
1081
1150
  }
1082
1151
  for (const uri of reopened.keys()) {
1083
1152
  if (!this.openUris.has(uri)) {
@@ -1086,6 +1155,9 @@ export class DataSession<
1086
1155
  // A failed read, or one without text, leaves the client's dirty
1087
1156
  // state as it was; the document is restored all the same.
1088
1157
  const current = await server.getModelDocument({ uri }).catch(() => undefined);
1158
+ if (this.disposed) {
1159
+ return;
1160
+ }
1089
1161
  // Again after the read: one closed or deleted meanwhile has no
1090
1162
  // state to tell.
1091
1163
  if (current?.text !== undefined && this.openUris.has(uri)) {
@@ -1139,7 +1211,8 @@ export class DataSession<
1139
1211
  *
1140
1212
  * A member whose record is no longer the one the restore started with was
1141
1213
  * closed, saved or disposed meanwhile, and is left out: writing it would
1142
- * put back text its caller discarded.
1214
+ * put back text its caller discarded. A set in {@link brokenSets} is
1215
+ * not written again.
1143
1216
  */
1144
1217
  protected async reapply(
1145
1218
  server: RpcProxy<TServer>,
@@ -1171,7 +1244,7 @@ export class DataSession<
1171
1244
  });
1172
1245
  return gone.filter(member => member.outcome === 'lost' || member.outcome === 'resend').map(member => member.uri);
1173
1246
  };
1174
- if (outcomes.some(member => member.outcome === 'lost' || member.outcome === 'failed')) {
1247
+ if (this.brokenSets.has(setOf(members[0][1])) || outcomes.some(member => member.outcome === 'lost' || member.outcome === 'failed')) {
1175
1248
  return lose();
1176
1249
  }
1177
1250
  // In the order of the call's updates, which is the order of its answers.
@@ -10,7 +10,7 @@
10
10
  import type { TransferDiagnostic } from '../transfer-diagnostic';
11
11
  import type { TransferElement } from '../transfer-element';
12
12
  import type { Project } from '../project';
13
- import type { TransferDocument, TransferSavedDocument } from '../transfer-document';
13
+ import type { TransferDocument, TransferSavedDocument, TransferWatchedDocument } from '../transfer-document';
14
14
  import type { CloseModelArgs, FindNextNameArgs, OpenModelArgs, ReferenceContext, ReferenceRequest } from '../model-server';
15
15
  import type { ReferenceCandidate, ReferenceTarget } from '../model-service/reference-candidate';
16
16
  import type {
@@ -141,10 +141,13 @@ export interface DocumentServerProtocol<TTransfer extends TransferElement, TDiag
141
141
 
142
142
  /**
143
143
  * Write a document as the session `clientId`, which must have it open, and
144
- * persist it to disk. The response is the latest build of the document,
145
- * with the version of the text written in `persisted`. Save semantics
146
- * depend on the filesystem-provider wiring; rejection paths surface as
147
- * awaited promise rejections.
144
+ * persist it to disk. The response is the build of the text written, with
145
+ * its version in `persisted`, sent once the language client has answered
146
+ * the push of that text, applied or refused, or a server-side bound on
147
+ * that wait passes; it does not wait again for a push that wait already
148
+ * gave up on and the client has not answered since. Save semantics depend
149
+ * on the filesystem-provider wiring; rejection paths surface as awaited
150
+ * promise rejections.
148
151
  */
149
152
  saveModelDocument(args: TransferSaveDocumentArgs<TTransfer>): Promise<TransferSavedDocument<TTransfer, TDiagnostic>>;
150
153
 
@@ -154,7 +157,9 @@ export interface DocumentServerProtocol<TTransfer extends TransferElement, TDiag
154
157
  * unsaved text keeps its formatting. `baseVersion` is checked in the step
155
158
  * that takes the text, so text that moved on fails with a `ConflictError`
156
159
  * code. The saved event names this session, whoever wrote the text.
157
- * Responds as {@link saveModelDocument} does.
160
+ * Answers after the same language-client wait as {@link saveModelDocument},
161
+ * with the document's latest build, read after that wait, so another
162
+ * session's write during it shows; `persisted` carries the version written.
158
163
  */
159
164
  persistModelDocument(args: TransferPersistDocumentArgs): Promise<TransferSavedDocument<TTransfer, TDiagnostic>>;
160
165
 
@@ -178,15 +183,19 @@ export interface DocumentServerProtocol<TTransfer extends TransferElement, TDiag
178
183
  * {@link DocumentClientProtocol.onDocumentUpdated} on the paired wire
179
184
  * for every build-phase event on `uri`.
180
185
  *
181
- * Returns `Promise<void>` — the bidirectional pattern means the event
182
- * channel is the client interface, not a returned handle. Callers fan
183
- * out a single inbound `onDocumentUpdated` to multiple local listeners
184
- * with their own `Emitter<T>` (matching Theia's pattern). Pair every
185
- * `watchModelDocument` with {@link unwatchModelDocument} when no longer
186
- * needed; the server holds the watch record until the matching unwatch
187
- * lands.
186
+ * Answers with the key the watch's events name the document by, whether
187
+ * a document exists, taken once the watch is in place and the document
188
+ * is loaded as a read loads it, and the position of the last update or
189
+ * deletion event sent as of that moment, so an event the client heard
190
+ * while the watch was in flight tells by its own position whether it is
191
+ * newer than the answer. No handle: the event channel is the client
192
+ * interface. Callers fan out a single inbound `onDocumentUpdated` to
193
+ * multiple local listeners with their own `Emitter<T>` (matching Theia's
194
+ * pattern). Pair every `watchModelDocument` with
195
+ * {@link unwatchModelDocument} when no longer needed; the server holds the
196
+ * watch record until the matching unwatch lands.
188
197
  */
189
- watchModelDocument(args: WatchModelDocumentArgs): Promise<void>;
198
+ watchModelDocument(args: WatchModelDocumentArgs): Promise<TransferWatchedDocument>;
190
199
 
191
200
  /**
192
201
  * Stop watching `(uri, clientId)` previously started by