@hydranium/protocol 1.0.0-next.285 → 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. */
@@ -379,6 +392,13 @@ export class DataSession<
379
392
  * over a connection that is going away.
380
393
  */
381
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>();
382
402
  /** The subscription to the host's `onDidChangeDirty`, disposed with the session. */
383
403
  protected readonly dirtySubscription?: Disposable;
384
404
  /** The subscription to the host's `onDidUpdateDocument`, disposed with the session. */
@@ -918,7 +938,10 @@ export class DataSession<
918
938
  * let a restore re-open the file that is gone. Unwatched rather than
919
939
  * closed: the server keeps the watch, and a close could end a re-open of a
920
940
  * re-created file still in flight. Unsaved writes are logged, not
921
- * 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}.
922
945
  *
923
946
  * This session's own deletion is neither unwatched nor logged as dropping
924
947
  * unsaved text: its delete released the watch, and its caller chose to
@@ -935,7 +958,18 @@ export class DataSession<
935
958
  this.forgetDocument(uri);
936
959
  continue;
937
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;
938
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
+ }
939
973
  this.connected()
940
974
  .then(server => server.unwatchModelDocument({ uri, clientId: this.clientId }))
941
975
  .catch(() => undefined);
@@ -988,7 +1022,7 @@ export class DataSession<
988
1022
  * write, so an edit arriving in between conflicts, and a conflict is not
989
1023
  * retried. Documents last written by one {@link updateDocuments} are sent
990
1024
  * again by one, and only when every one of them may be; a document that
991
- * could not be re-opened blocks its set.
1025
+ * could not be re-opened, or that another client deleted, blocks its set.
992
1026
  *
993
1027
  * No caller is waiting, so the outcomes go through the host: one report
994
1028
  * naming every document whose unsaved text is gone, whose record is then
@@ -998,8 +1032,13 @@ export class DataSession<
998
1032
  * restore to decide again. A document closed while the restore runs, or
999
1033
  * whose file is deleted then, is neither restored nor reported. One gone
1000
1034
  * while the connection was down, which the server reads back without a
1001
- * model, is forgotten, and goes into the report of lost unsaved text when
1002
- * 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.
1003
1042
  *
1004
1043
  * Each document's dirty state goes to the host too, read once the watch is
1005
1044
  * in place and any write is sent: a flip while the connection was down, or
@@ -1021,6 +1060,9 @@ export class DataSession<
1021
1060
  }
1022
1061
  try {
1023
1062
  const document = await server.openModelDocument({ uri, clientId: this.clientId });
1063
+ if (this.disposed) {
1064
+ return;
1065
+ }
1024
1066
  if (!this.openUris.has(uri)) {
1025
1067
  // Forgotten while it re-opened. A close of the caller's would
1026
1068
  // follow, but a deletion sends none.
@@ -1037,6 +1079,9 @@ export class DataSession<
1037
1079
  await server.closeModelDocument({ uri, clientId: this.clientId }).catch(() => undefined);
1038
1080
  throw error;
1039
1081
  });
1082
+ if (this.disposed) {
1083
+ return;
1084
+ }
1040
1085
  if (this.openUris.has(uri)) {
1041
1086
  this.recordText(uri, document);
1042
1087
  }
@@ -1045,22 +1090,42 @@ export class DataSession<
1045
1090
  // Gone while the connection was down, which no deletion told the
1046
1091
  // session of: the server answers a read of it without a model.
1047
1092
  const read = await server.getModelDocument({ uri }).catch(() => undefined);
1093
+ if (this.disposed) {
1094
+ return;
1095
+ }
1048
1096
  // Forgotten meanwhile, so nothing is lost.
1049
1097
  if (!this.openUris.has(uri)) {
1050
1098
  continue;
1051
1099
  }
1052
1100
  if (read && !read.model) {
1053
- // No deletion reached the client either, so unsaved text the
1054
- // document took with it is reported, as a drop's other losses are.
1055
1101
  const write = this.unsavedWrites.get(uri);
1056
- if (this.forgetGoneDocument(uri)) {
1057
- lost.push(uri);
1058
- }
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);
1059
1107
  // Kept for `reapply`, where it blocks its set as a document
1060
1108
  // that could not be re-opened does, and goes.
1061
1109
  if (write) {
1062
1110
  this.unsavedWrites.set(uri, write);
1063
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
+ }
1064
1129
  continue;
1065
1130
  }
1066
1131
  // Its record goes with its set's, in `reapply`, which it blocks.
@@ -1074,11 +1139,14 @@ export class DataSession<
1074
1139
  }
1075
1140
  const sets = new Map<object, [string, DataSessionUnsavedWrite<TTransfer, TServer>][]>();
1076
1141
  for (const [uri, write] of writes) {
1077
- const call = 'update' in write.call ? write.call.update : write.call.updates;
1078
- sets.set(call, [...(sets.get(call) ?? []), [uri, write]]);
1142
+ const set = setOf(write);
1143
+ sets.set(set, [...(sets.get(set) ?? []), [uri, write]]);
1079
1144
  }
1080
1145
  for (const members of sets.values()) {
1081
1146
  lost.push(...(await this.reapply(server, members, reopened)));
1147
+ if (this.disposed) {
1148
+ return;
1149
+ }
1082
1150
  }
1083
1151
  for (const uri of reopened.keys()) {
1084
1152
  if (!this.openUris.has(uri)) {
@@ -1087,6 +1155,9 @@ export class DataSession<
1087
1155
  // A failed read, or one without text, leaves the client's dirty
1088
1156
  // state as it was; the document is restored all the same.
1089
1157
  const current = await server.getModelDocument({ uri }).catch(() => undefined);
1158
+ if (this.disposed) {
1159
+ return;
1160
+ }
1090
1161
  // Again after the read: one closed or deleted meanwhile has no
1091
1162
  // state to tell.
1092
1163
  if (current?.text !== undefined && this.openUris.has(uri)) {
@@ -1140,7 +1211,8 @@ export class DataSession<
1140
1211
  *
1141
1212
  * A member whose record is no longer the one the restore started with was
1142
1213
  * closed, saved or disposed meanwhile, and is left out: writing it would
1143
- * put back text its caller discarded.
1214
+ * put back text its caller discarded. A set in {@link brokenSets} is
1215
+ * not written again.
1144
1216
  */
1145
1217
  protected async reapply(
1146
1218
  server: RpcProxy<TServer>,
@@ -1172,7 +1244,7 @@ export class DataSession<
1172
1244
  });
1173
1245
  return gone.filter(member => member.outcome === 'lost' || member.outcome === 'resend').map(member => member.uri);
1174
1246
  };
1175
- 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')) {
1176
1248
  return lose();
1177
1249
  }
1178
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 {
@@ -183,15 +183,19 @@ export interface DocumentServerProtocol<TTransfer extends TransferElement, TDiag
183
183
  * {@link DocumentClientProtocol.onDocumentUpdated} on the paired wire
184
184
  * for every build-phase event on `uri`.
185
185
  *
186
- * Returns `Promise<void>` — the bidirectional pattern means the event
187
- * channel is the client interface, not a returned handle. Callers fan
188
- * out a single inbound `onDocumentUpdated` to multiple local listeners
189
- * with their own `Emitter<T>` (matching Theia's pattern). Pair every
190
- * `watchModelDocument` with {@link unwatchModelDocument} when no longer
191
- * needed; the server holds the watch record until the matching unwatch
192
- * 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.
193
197
  */
194
- watchModelDocument(args: WatchModelDocumentArgs): Promise<void>;
198
+ watchModelDocument(args: WatchModelDocumentArgs): Promise<TransferWatchedDocument>;
195
199
 
196
200
  /**
197
201
  * Stop watching `(uri, clientId)` previously started by
@@ -62,6 +62,12 @@ export interface TransferDocumentUpdatedEvent<
62
62
  */
63
63
  sourceClientId: string;
64
64
  reason: TransferDocumentUpdateReason;
65
+ /**
66
+ * The position of this event among the update and deletion events the
67
+ * server sent on this connection, which a watch's answer carries as of
68
+ * the moment it was taken: a greater one was sent later.
69
+ */
70
+ sequence: number;
65
71
  }
66
72
 
67
73
  /** Callback shape for `DataClientProtocol.onDocumentUpdated`. */
@@ -158,9 +164,16 @@ export interface TransferDocumentDeletedEvent {
158
164
  /**
159
165
  * The session whose `deleteModelDocument` removed the file, so it can tell
160
166
  * its own deletion from another's; the unknown-client id for a file
161
- * removed outside any session, as a file watcher reports one.
167
+ * removed outside any session, as a file watcher reports one, and for one
168
+ * a `DataConnection` found gone after a reconnect, which no event named.
162
169
  */
163
170
  readonly sourceClientId: string;
171
+ /**
172
+ * The position of this event among the update and deletion events the
173
+ * server sent on this connection, as on an update; absent on a deletion a
174
+ * `DataConnection` found after a reconnect, which the server never sent.
175
+ */
176
+ readonly sequence?: number;
164
177
  }
165
178
 
166
179
  /** Callback shape for `DataClientProtocol.onDocumentDeleted`. */
@@ -98,6 +98,16 @@ export type TransferSavedDocument<TTransfer extends TransferElement, TDiagnostic
98
98
  persisted: { version: TextVersion };
99
99
  };
100
100
 
101
+ /** What a watch answers, once it is in place. */
102
+ export interface TransferWatchedDocument {
103
+ /** The key the watch's events name the document by, which a symlinked URI does not spell. */
104
+ readonly uri: string;
105
+ /** Whether a document exists there; one the server fails to load counts, only a missing one does not. */
106
+ readonly exists: boolean;
107
+ /** The position of the last update or deletion event sent before `exists` was taken; an event past it was sent after. */
108
+ readonly sequence: number;
109
+ }
110
+
101
111
  export namespace TransferDocument {
102
112
  /**
103
113
  * Whether the server had this document. Branch on it where absence is an