@hydranium/protocol 1.0.0-next.267 → 1.0.0-next.269

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.
@@ -9,11 +9,19 @@
9
9
 
10
10
  import { type Disposable, Emitter, type Event } from 'vscode-jsonrpc';
11
11
  import { type Clock, SystemClock } from '../clock';
12
- import type { DataServerProtocol, DiagnosticOf, TransferDocumentDirtyChangedEvent, TransferDocumentUpdatedEvent } from '../data';
12
+ import type {
13
+ DataServerProtocol,
14
+ DiagnosticOf,
15
+ TransferDocumentDeletedEvent,
16
+ TransferDocumentDirtyChangedEvent,
17
+ TransferDocumentUpdatedEvent
18
+ } from '../data';
13
19
  import { isConflictError, SessionClosedError } from '../errors';
20
+ import type { Logger } from '../logger';
14
21
  import { type BaseVersion, isModelVersion, type ModelVersion, type TextVersion, UNRECORDED_VERSION } from '../model-service/base-version';
15
22
  import { type ResolvedMessage, defineMessage, describeError, resolve } from '../messages/primitives';
16
23
  import type { OpenModelArgs } from '../model-server';
24
+ import { NoopLogger } from '../noop-logger';
17
25
  import { randomUuid } from '../random-uuid';
18
26
  import type { MaybePromise } from '../util';
19
27
  import type { RpcProxy } from '../rpc';
@@ -146,6 +154,31 @@ export interface DataSessionUnsavedWrite<
146
154
  | { readonly updates: DataSessionUpdatesArgs<TTransfer, TServer>; readonly index: number };
147
155
  }
148
156
 
157
+ /**
158
+ * One open of a URI by a {@link DataSession}, from the answer to its open,
159
+ * create or re-open until a close or a deletion ends it. A call records its
160
+ * answer only while the open it was sent under is still the URI's: checking
161
+ * that the URI is open accepts an answer from before a close or deletion
162
+ * once the URI is open again.
163
+ */
164
+ export interface DataSessionOpen {
165
+ /** The URI the open's answer named, which the server keys its notifications by. */
166
+ readonly serverUri: string;
167
+ /** Whether a deletion ended the open, which an open still watching sends again; a close does not. */
168
+ deleted: boolean;
169
+ }
170
+
171
+ /**
172
+ * One open or create of a URI by a {@link DataSession}, from its call until it
173
+ * settles. A close of the URI while it is under way marks it: it then records
174
+ * nothing and is not sent again, so the close stands. Without the mark, a
175
+ * deletion during its watch has it sent again after the close.
176
+ */
177
+ export interface DataSessionOpening {
178
+ /** Whether a close of the URI came while this open was under way. */
179
+ closed: boolean;
180
+ }
181
+
149
182
  /** Where `write` stands in the `updateDocuments` call it was last written by; `0` for a single update. */
150
183
  function indexOf(write: DataSessionUnsavedWrite<TransferElement, DataServerProtocol<TransferElement>>): number {
151
184
  return 'index' in write.call ? write.call.index : 0;
@@ -187,6 +220,14 @@ export interface DataSessionHost<TTransfer extends TransferElement, TServer exte
187
220
  * this event it keeps it, and a restore reports the write lost.
188
221
  */
189
222
  readonly onDidUpdateDocument?: Event<TransferDocumentUpdatedEvent<TTransfer, DiagnosticOf<TServer>>>;
223
+ /**
224
+ * Fires with each deletion the server sends. A session forgets a document
225
+ * whose file was deleted; without this event it keeps it as open until a
226
+ * restore finds it gone, and reports its unsaved text lost only then.
227
+ */
228
+ readonly onDidDeleteDocument?: Event<TransferDocumentDeletedEvent>;
229
+ /** Where the session logs the documents it forgets as gone. */
230
+ readonly logger?: Logger;
190
231
  }
191
232
 
192
233
  /**
@@ -255,17 +296,17 @@ export class DataSession<
255
296
  /** URIs this session has open, re-opened after a reconnect. */
256
297
  protected readonly openUris = new Set<string>();
257
298
  /**
258
- * Per URI in {@link openUris}, the URI the server's last answer to its open
259
- * or re-open named. The server keys its notifications by it, and so does
260
- * the host's record of what its client was told.
299
+ * Per URI in {@link openUris}, or whose open answered and whose watch is
300
+ * still under way, the open it is under. A repeat open keeps it, as the
301
+ * server keeps its earlier open.
261
302
  */
262
- protected readonly serverUris = new Map<string, string>();
303
+ protected readonly opens = new Map<string, DataSessionOpen>();
263
304
  /**
264
- * Per URI, how many opens of it this session has under way. Such a URI
265
- * counts as open for {@link withOpenDocument}, which would otherwise close
266
- * it under the open that is still being made.
305
+ * Per URI, the opens of it this session has under way. Such a URI counts
306
+ * as open for {@link withOpenDocument}, which would otherwise close it
307
+ * under the open that is still being made.
267
308
  */
268
- protected readonly openingUris = new Map<string, number>();
309
+ protected readonly openings = new Map<string, Set<DataSessionOpening>>();
269
310
  /** Per URI written since its last save, what {@link restore} needs to write it again. */
270
311
  protected readonly unsavedWrites = new Map<string, DataSessionUnsavedWrite<TTransfer, TServer>>();
271
312
  /**
@@ -282,6 +323,13 @@ export class DataSession<
282
323
  * version of a write still in flight as well, and that write is not saved.
283
324
  */
284
325
  protected readonly savedVersions = new Map<string, TextVersion>();
326
+ /**
327
+ * URIs this session created whose whole text is still its own and unsaved,
328
+ * even with no write recorded: no save, clean flip, other client's write or
329
+ * reported loss has come since the create answered; see
330
+ * {@link forgetGoneDocument}.
331
+ */
332
+ protected readonly createdUris = new Set<string>();
285
333
  /** Per URI, this session's calls still in flight on it, which a close waits for. */
286
334
  protected readonly inFlight = new Map<string, Set<Promise<unknown>>>();
287
335
  /** This session's saves still in flight, which a host's exit waits for; see {@link hasSavesInFlight}. */
@@ -320,14 +368,20 @@ export class DataSession<
320
368
  protected readonly dirtySubscription?: Disposable;
321
369
  /** The subscription to the host's `onDidUpdateDocument`, disposed with the session. */
322
370
  protected readonly updateSubscription?: Disposable;
371
+ /** The subscription to the host's `onDidDeleteDocument`, disposed with the session. */
372
+ protected readonly deletionSubscription?: Disposable;
373
+ /** The host's logger, or one that logs nothing. */
374
+ protected readonly logger: Logger;
323
375
 
324
376
  constructor(
325
377
  readonly clientId: string,
326
378
  protected readonly host: DataSessionHost<TTransfer, TServer>,
327
379
  readonly label: string
328
380
  ) {
381
+ this.logger = host.logger ?? new NoopLogger();
329
382
  this.dirtySubscription = host.onDidChangeDirty?.(event => this.forgetSavedWrite(event));
330
383
  this.updateSubscription = host.onDidUpdateDocument?.(event => this.forgetSupersededWrite(event));
384
+ this.deletionSubscription = host.onDidDeleteDocument?.(event => this.forgetDeletedDocument(event));
331
385
  }
332
386
 
333
387
  /**
@@ -375,10 +429,9 @@ export class DataSession<
375
429
  * from `getModelDocument({ includeDiagnostics: true })`.
376
430
  */
377
431
  openDocument(args: DataSessionOpenArgs<TTransfer, TServer>): Promise<DataSessionDocument<TTransfer, TServer>> {
378
- return this.trackOpen(args.uri, async () => {
379
- const server = await this.connected();
380
- return this.watchOpened(server, args.uri, await server.openModelDocument({ ...args, clientId: this.clientId }));
381
- });
432
+ return this.trackOpen(args.uri, opening =>
433
+ this.openAndWatch(args.uri, opening, false, server => server.openModelDocument({ ...args, clientId: this.clientId }))
434
+ );
382
435
  }
383
436
 
384
437
  /**
@@ -387,30 +440,76 @@ export class DataSession<
387
440
  * refuses a URI that exists on disk or that any client has open.
388
441
  */
389
442
  createDocument(args: DataSessionCreateArgs<TTransfer, TServer>): Promise<DataSessionDocument<TTransfer, TServer>> {
390
- return this.trackOpen(args.uri, async () => {
443
+ return this.trackOpen(args.uri, opening =>
444
+ this.openAndWatch(args.uri, opening, true, server => server.createModelDocument({ ...args, clientId: this.clientId }))
445
+ );
446
+ }
447
+
448
+ /**
449
+ * Send `open` and watch what it answered, again for as long as a deletion
450
+ * heard while the watch is under way closes that open: sent after the
451
+ * deletion, the open fails as an open of a gone file does, or opens the
452
+ * file that is back. `created` marks the document in {@link createdUris}
453
+ * once the open answers, before the watch, so an event that clears the mark
454
+ * meanwhile clears it for good.
455
+ *
456
+ * An `opening` a close marked answers with the document, recording nothing
457
+ * and sending nothing again; one it marked before the open answered is
458
+ * undone, since the server may have taken the close first. A failed watch
459
+ * is undone too: the caller sees a failed open, and so would never close it.
460
+ */
461
+ protected async openAndWatch(
462
+ uri: string,
463
+ opening: DataSessionOpening,
464
+ created: boolean,
465
+ open: (server: RpcProxy<TServer>) => Promise<DataSessionDocument<TTransfer, TServer>>
466
+ ): Promise<DataSessionDocument<TTransfer, TServer>> {
467
+ for (;;) {
391
468
  const server = await this.connected();
392
- return this.watchOpened(server, args.uri, await server.createModelDocument({ ...args, clientId: this.clientId }));
393
- });
469
+ const document = await open(server);
470
+ if (opening.closed) {
471
+ await this.undoOpen(server, uri, opening);
472
+ return document;
473
+ }
474
+ if (created) {
475
+ this.createdUris.add(uri);
476
+ }
477
+ let watched: DataSessionDocument<TTransfer, TServer> | undefined;
478
+ try {
479
+ watched = await this.watchOpened(server, uri, document);
480
+ } catch (error: unknown) {
481
+ await this.undoOpen(server, uri, opening);
482
+ throw error;
483
+ }
484
+ if (watched || opening.closed) {
485
+ return watched ?? document;
486
+ }
487
+ }
394
488
  }
395
489
 
396
490
  /**
397
- * Watch a document this session just opened, and record it as open. A
398
- * failed watch closes it again: the caller sees a failed open and so would
399
- * never close it.
491
+ * Watch a document this session just opened, and record it as open.
492
+ * Answers `undefined`, recording nothing, when a deletion heard while the
493
+ * watch is under way has ended the open already, and the document,
494
+ * recording nothing, when a close has. A failed watch rejects with the open
495
+ * still recorded, for its caller to undo, see {@link undoOpen}.
400
496
  */
401
497
  protected async watchOpened(
402
498
  server: RpcProxy<TServer>,
403
499
  uri: string,
404
500
  document: DataSessionDocument<TTransfer, TServer>
405
- ): Promise<DataSessionDocument<TTransfer, TServer>> {
406
- try {
407
- await server.watchModelDocument({ uri, clientId: this.clientId });
408
- } catch (error: unknown) {
409
- await server.closeModelDocument({ uri, clientId: this.clientId }).catch(() => undefined);
410
- throw error;
501
+ ): Promise<DataSessionDocument<TTransfer, TServer> | undefined> {
502
+ // Recorded before the watch, so a deletion heard while it is under way
503
+ // finds this open, which the server made before the deletion.
504
+ const open = this.opens.get(uri) ?? { serverUri: document.uri, deleted: false };
505
+ this.opens.set(uri, open);
506
+ await server.watchModelDocument({ uri, clientId: this.clientId });
507
+ if (this.opens.get(uri) !== open) {
508
+ // Sent again, it ends as an open sent after the deletion would; a
509
+ // close is the caller's later call, and stands.
510
+ return open.deleted ? undefined : document;
411
511
  }
412
512
  this.openUris.add(uri);
413
- this.serverUris.set(uri, document.uri);
414
513
  this.recordText(uri, document);
415
514
  // Keyed as the server keys its notifications, which may not be how the
416
515
  // caller spelled the URI.
@@ -427,19 +526,53 @@ export class DataSession<
427
526
  this.assertLive();
428
527
  await this.settle(this.inFlight.get(args.uri));
429
528
  // Forgotten before the close is sent, so a reconnect in between does not
430
- // re-open a document the caller has closed; after the wait, so an open
431
- // of it that was still in flight does not record it again.
432
- const serverUri = this.serverUris.get(args.uri) ?? args.uri;
433
- this.openUris.delete(args.uri);
434
- this.serverUris.delete(args.uri);
435
- this.unsavedWrites.delete(args.uri);
436
- this.lastAnswers.delete(args.uri);
437
- this.savedVersions.delete(args.uri);
438
- this.host.forgetDirty?.(serverUri);
529
+ // re-open a document the caller has closed. Every open of it under way
530
+ // by now is marked, even one called after this close, so it neither
531
+ // records it again nor is sent again.
532
+ this.openings.get(args.uri)?.forEach(opening => {
533
+ opening.closed = true;
534
+ });
535
+ this.forgetDocument(args.uri);
439
536
  const server = await this.connected();
440
537
  await server.closeModelDocument({ ...args, clientId: this.clientId });
441
538
  }
442
539
 
540
+ /**
541
+ * Close `uri` again after `opening` failed or was closed while under way,
542
+ * and drop what it recorded, unless the session still relies on the open,
543
+ * see {@link hasOpen}: the server holds one open per session and URI, so
544
+ * the close would end that one too.
545
+ */
546
+ protected async undoOpen(server: RpcProxy<TServer>, uri: string, opening: DataSessionOpening): Promise<void> {
547
+ if (this.hasOpen(uri, opening)) {
548
+ return;
549
+ }
550
+ this.opens.delete(uri);
551
+ this.createdUris.delete(uri);
552
+ await server.closeModelDocument({ uri, clientId: this.clientId }).catch(() => undefined);
553
+ }
554
+
555
+ /** Drop everything this session keeps of `uri`, which it no longer has open. */
556
+ protected forgetDocument(uri: string): void {
557
+ const serverUri = this.opens.get(uri)?.serverUri ?? uri;
558
+ this.openUris.delete(uri);
559
+ this.opens.delete(uri);
560
+ this.unsavedWrites.delete(uri);
561
+ this.lastAnswers.delete(uri);
562
+ this.savedVersions.delete(uri);
563
+ this.createdUris.delete(uri);
564
+ this.host.forgetDirty?.(serverUri);
565
+ }
566
+
567
+ /**
568
+ * Whether this session has `uri` open, or has an open of it under way that
569
+ * no close has marked, other than `except`. A marked open records nothing,
570
+ * so nothing can rely on it.
571
+ */
572
+ protected hasOpen(uri: string, except?: DataSessionOpening): boolean {
573
+ return this.openUris.has(uri) || [...(this.openings.get(uri) ?? [])].some(opening => opening !== except && !opening.closed);
574
+ }
575
+
443
576
  /**
444
577
  * Open `args.uri`, run `fn` with the opened snapshot, and close it once
445
578
  * `fn` settles, whether it returned or threw. A URI this session already
@@ -450,7 +583,7 @@ export class DataSession<
450
583
  args: DataSessionOpenArgs<TTransfer, TServer>,
451
584
  fn: (document: DataSessionDocument<TTransfer, TServer>) => MaybePromise<T>
452
585
  ): Promise<T> {
453
- const alreadyOpen = this.openUris.has(args.uri) || this.openingUris.has(args.uri);
586
+ const alreadyOpen = this.hasOpen(args.uri);
454
587
  const document = await this.openDocument(args);
455
588
  try {
456
589
  return await fn(document);
@@ -467,8 +600,9 @@ export class DataSession<
467
600
  const server = await this.connected();
468
601
  const update = { ...args };
469
602
  const baseHash = await this.baseHashOf(server, args.uri, args.baseVersion);
603
+ const open = this.opens.get(args.uri);
470
604
  const document = await server.updateModelDocument({ ...args, clientId: this.clientId });
471
- this.recordWrite(args.uri, document, baseHash, { update });
605
+ this.recordWrite(args.uri, open, document, baseHash, { update });
472
606
  return document;
473
607
  });
474
608
  }
@@ -487,8 +621,11 @@ export class DataSession<
487
621
  const updates = { ...args, updates: args.updates.map(update => ({ ...update })) };
488
622
  const server = await this.connected();
489
623
  const baseHashes = await Promise.all(args.updates.map(update => this.baseHashOf(server, update.uri, update.baseVersion)));
624
+ const opens = args.updates.map(update => this.opens.get(update.uri));
490
625
  const documents = await server.updateModelDocuments({ ...args, clientId: this.clientId });
491
- args.updates.forEach((update, index) => this.recordWrite(update.uri, documents[index], baseHashes[index], { updates, index }));
626
+ args.updates.forEach((update, index) =>
627
+ this.recordWrite(update.uri, opens[index], documents[index], baseHashes[index], { updates, index })
628
+ );
492
629
  return documents;
493
630
  }
494
631
  );
@@ -514,7 +651,13 @@ export class DataSession<
514
651
  ): Promise<TransferSavedDocument<TTransfer, DiagnosticOf<TServer>>> {
515
652
  const saving = this.track([uri], async () => {
516
653
  const server = await this.connected();
654
+ const open = this.opens.get(uri);
517
655
  const document = await send(server);
656
+ // Sent under no open, or under one a close or deletion has ended
657
+ // since: recorded, it would outlive that open.
658
+ if (!open || this.opens.get(uri) !== open) {
659
+ return document;
660
+ }
518
661
  // Kept only when its last answer is newer than the text written. Kept
519
662
  // whenever it changed while the save ran, a record the save covered
520
663
  // survives, and a restore then reports it lost.
@@ -523,6 +666,7 @@ export class DataSession<
523
666
  this.unsavedWrites.delete(uri);
524
667
  }
525
668
  this.recordText(uri, document);
669
+ this.createdUris.delete(uri);
526
670
  // The higher of two saves' answers, which may arrive out of order.
527
671
  const saved = this.savedVersions.get(uri);
528
672
  if (saved === undefined || document.persisted.version > saved) {
@@ -614,10 +758,11 @@ export class DataSession<
614
758
  detach(): void {
615
759
  this.detached = true;
616
760
  this.openUris.clear();
617
- this.serverUris.clear();
761
+ this.opens.clear();
618
762
  this.unsavedWrites.clear();
619
763
  this.lastAnswers.clear();
620
764
  this.savedVersions.clear();
765
+ this.createdUris.clear();
621
766
  if (!this.disposed) {
622
767
  this.disposed = true;
623
768
  this.fireDispose();
@@ -625,13 +770,14 @@ export class DataSession<
625
770
  }
626
771
 
627
772
  /**
628
- * Dispose {@link dirtySubscription} and {@link updateSubscription}, fire
629
- * {@link onDidDispose} and dispose its emitter, so a second call fires
630
- * nothing.
773
+ * Dispose {@link dirtySubscription}, {@link updateSubscription} and
774
+ * {@link deletionSubscription}, fire {@link onDidDispose} and dispose its
775
+ * emitter, so a second call fires nothing.
631
776
  */
632
777
  protected fireDispose(): void {
633
778
  this.dirtySubscription?.dispose();
634
779
  this.updateSubscription?.dispose();
780
+ this.deletionSubscription?.dispose();
635
781
  this.disposeEmitter.fire(undefined);
636
782
  this.disposeEmitter.dispose();
637
783
  }
@@ -651,9 +797,10 @@ export class DataSession<
651
797
 
652
798
  /**
653
799
  * Drop the record in {@link unsavedWrites} of a document this session has
654
- * open once the server says it turned clean: it holds no unsaved text
655
- * then, and a record kept would have a restore report the write lost once
656
- * another client's save changed the file.
800
+ * open once the server says it turned clean, and its mark in
801
+ * {@link createdUris}: it holds no unsaved text then, and a record kept
802
+ * would have a restore report the write lost once another client's save
803
+ * changed the file.
657
804
  *
658
805
  * The server sends a flip only for a document someone watches, so a
659
806
  * document the session writes without watching it keeps its record, and
@@ -665,9 +812,10 @@ export class DataSession<
665
812
  }
666
813
  // The flip names the server's key, which is not always the caller's
667
814
  // spelling that the records are kept under.
668
- for (const [uri, serverUri] of this.serverUris) {
669
- if (serverUri === event.uri) {
815
+ for (const [uri, open] of this.opens) {
816
+ if (open.serverUri === event.uri) {
670
817
  this.unsavedWrites.delete(uri);
818
+ this.createdUris.delete(uri);
671
819
  }
672
820
  }
673
821
  }
@@ -686,19 +834,63 @@ export class DataSession<
686
834
  * `'rebuilt'` event carries no new text. Like {@link forgetSavedWrite}, it
687
835
  * depends on the server sending the event, which it does only for a
688
836
  * document someone watches.
837
+ *
838
+ * The mark in {@link createdUris} goes on any such event: the unsaved text
839
+ * is then the other client's, and a record still kept reports the
840
+ * session's own.
689
841
  */
690
842
  protected forgetSupersededWrite(event: TransferDocumentUpdatedEvent<TTransfer, DiagnosticOf<TServer>>): void {
691
843
  if (event.reason !== 'changed' || this.isOwnEcho(event.sourceClientId)) {
692
844
  return;
693
845
  }
694
- for (const [uri, serverUri] of this.serverUris) {
846
+ for (const [uri, open] of this.opens) {
847
+ if (open.serverUri !== event.document.uri) {
848
+ continue;
849
+ }
850
+ this.createdUris.delete(uri);
695
851
  const write = this.unsavedWrites.get(uri);
696
- if (serverUri === event.document.uri && write && this.restoreOutcome(write, event.document) !== 'kept') {
852
+ if (write && this.restoreOutcome(write, event.document) !== 'kept') {
697
853
  this.unsavedWrites.delete(uri);
698
854
  }
699
855
  }
700
856
  }
701
857
 
858
+ /**
859
+ * Forget a document whose file was deleted, which closed this session's
860
+ * open. At once rather than after the calls in flight settle, which would
861
+ * let a restore re-open the file that is gone. Unwatched rather than
862
+ * closed: the server keeps the watch, and a close could end a re-open of a
863
+ * re-created file still in flight. Unsaved writes are logged, not
864
+ * reported: the client hears the deletion too.
865
+ */
866
+ protected forgetDeletedDocument(event: TransferDocumentDeletedEvent): void {
867
+ for (const [uri, open] of this.opens) {
868
+ if (open.serverUri !== event.uri) {
869
+ continue;
870
+ }
871
+ open.deleted = true;
872
+ this.forgetGoneDocument(uri);
873
+ this.connected()
874
+ .then(server => server.unwatchModelDocument({ uri, clientId: this.clientId }))
875
+ .catch(() => undefined);
876
+ }
877
+ }
878
+
879
+ /**
880
+ * {@link forgetDocument} a document that is gone, logging any unsaved text
881
+ * it drops, written or created; returns whether it dropped some.
882
+ */
883
+ protected forgetGoneDocument(uri: string): boolean {
884
+ const unsaved = this.unsavedWrites.has(uri) || this.createdUris.has(uri);
885
+ if (unsaved) {
886
+ this.logger.withUri(uri).warn('Dropped unsaved text: the document is gone');
887
+ } else {
888
+ this.logger.withUri(uri).debug('Forgot the document: it is gone');
889
+ }
890
+ this.forgetDocument(uri);
891
+ return unsaved;
892
+ }
893
+
702
894
  /**
703
895
  * Register on `server`, and after a reconnect restore what the ended session
704
896
  * had. Calls issued meanwhile wait for this.
@@ -737,7 +929,11 @@ export class DataSession<
737
929
  * dropped, and one per document that could not be re-opened or written
738
930
  * again for another reason than a conflict. A document that could not be
739
931
  * re-opened is forgotten; a failed write keeps its record, for the next
740
- * restore to decide again.
932
+ * restore to decide again. A document closed while the restore runs, or
933
+ * whose file is deleted then, is neither restored nor reported. One gone
934
+ * while the connection was down, which the server reads back without a
935
+ * model, is forgotten, and goes into the report of lost unsaved text when
936
+ * the session wrote it since its last save, or created it and never saved it.
741
937
  *
742
938
  * Each document's dirty state goes to the host too, read once the watch is
743
939
  * in place and any write is sent: a flip while the connection was down, or
@@ -751,21 +947,62 @@ export class DataSession<
751
947
  // server numbers afresh, below the versions saved before it.
752
948
  this.savedVersions.clear();
753
949
  const reopened = new Map<string, DataSessionDocument<TTransfer, TServer>>();
950
+ const lost: string[] = [];
754
951
  for (const uri of [...this.openUris]) {
952
+ // Closed, or its file deleted, while the restore ran.
953
+ if (!this.openUris.has(uri)) {
954
+ continue;
955
+ }
755
956
  try {
756
957
  const document = await server.openModelDocument({ uri, clientId: this.clientId });
757
- await server.watchModelDocument({ uri, clientId: this.clientId });
958
+ if (!this.openUris.has(uri)) {
959
+ // Forgotten while it re-opened. A close of the caller's would
960
+ // follow, but a deletion sends none.
961
+ await server.closeModelDocument({ uri, clientId: this.clientId }).catch(() => undefined);
962
+ continue;
963
+ }
964
+ // Recorded before the watch, so a deletion heard while it is under
965
+ // way finds the re-open under the URI it answered with, which a
966
+ // restarted server may key differently.
967
+ this.opens.set(uri, { serverUri: document.uri, deleted: false });
968
+ await server.watchModelDocument({ uri, clientId: this.clientId }).catch(async (error: unknown) => {
969
+ // Closed, or the server holds the re-open until the session
970
+ // ends; no other call relies on it while the restore runs.
971
+ await server.closeModelDocument({ uri, clientId: this.clientId }).catch(() => undefined);
972
+ throw error;
973
+ });
758
974
  if (this.openUris.has(uri)) {
759
- this.serverUris.set(uri, document.uri);
760
975
  this.recordText(uri, document);
761
976
  }
762
977
  reopened.set(uri, document);
763
978
  } catch (error: unknown) {
979
+ // Gone while the connection was down, which no deletion told the
980
+ // session of: the server answers a read of it without a model.
981
+ const read = await server.getModelDocument({ uri }).catch(() => undefined);
982
+ // Forgotten meanwhile, so nothing is lost.
983
+ if (!this.openUris.has(uri)) {
984
+ continue;
985
+ }
986
+ if (read && !read.model) {
987
+ // No deletion reached the client either, so unsaved text the
988
+ // document took with it is reported, as a drop's other losses are.
989
+ const write = this.unsavedWrites.get(uri);
990
+ if (this.forgetGoneDocument(uri)) {
991
+ lost.push(uri);
992
+ }
993
+ // Kept for `reapply`, where it blocks its set as a document
994
+ // that could not be re-opened does, and goes.
995
+ if (write) {
996
+ this.unsavedWrites.set(uri, write);
997
+ }
998
+ continue;
999
+ }
764
1000
  // Its record goes with its set's, in `reapply`, which it blocks.
765
1001
  this.openUris.delete(uri);
766
- this.serverUris.delete(uri);
1002
+ this.opens.delete(uri);
767
1003
  this.lastAnswers.delete(uri);
768
1004
  this.savedVersions.delete(uri);
1005
+ this.createdUris.delete(uri);
769
1006
  this.host.reportError?.(error, resolve(DATA_SESSION_RESTORE_FAILED, { uri, detail: describeError(error) }));
770
1007
  }
771
1008
  }
@@ -774,7 +1011,6 @@ export class DataSession<
774
1011
  const call = 'update' in write.call ? write.call.update : write.call.updates;
775
1012
  sets.set(call, [...(sets.get(call) ?? []), [uri, write]]);
776
1013
  }
777
- const lost: string[] = [];
778
1014
  for (const members of sets.values()) {
779
1015
  lost.push(...(await this.reapply(server, members, reopened)));
780
1016
  }
@@ -785,7 +1021,15 @@ export class DataSession<
785
1021
  // A failed read, or one without text, leaves the client's dirty
786
1022
  // state as it was; the document is restored all the same.
787
1023
  const current = await server.getModelDocument({ uri }).catch(() => undefined);
788
- if (current?.text !== undefined) {
1024
+ // Again after the read: one closed or deleted meanwhile has no
1025
+ // state to tell.
1026
+ if (current?.text !== undefined && this.openUris.has(uri)) {
1027
+ // A created document reads clean only once a save gave it a
1028
+ // baseline, so its text is no longer unsaved. A write record
1029
+ // stays: a reverted document reads clean too, its write unsent.
1030
+ if (!current.text.dirty) {
1031
+ this.createdUris.delete(uri);
1032
+ }
789
1033
  try {
790
1034
  this.host.restoreDirty?.({ uri: current.uri, text: current.text });
791
1035
  } catch {
@@ -853,10 +1097,14 @@ export class DataSession<
853
1097
  }
854
1098
  }
855
1099
  // A document that could not be re-opened is forgotten, and was reported
856
- // as such.
1100
+ // as such; one closed, saved or deleted while a write ran lost nothing.
857
1101
  const lose = (): string[] => {
858
- outcomes.filter(member => member.outcome !== 'kept').forEach(member => this.unsavedWrites.delete(member.uri));
859
- return outcomes.filter(member => member.outcome === 'lost' || member.outcome === 'resend').map(member => member.uri);
1102
+ const gone = outcomes.filter(member => member.outcome !== 'kept' && current(member.uri, member.write));
1103
+ gone.forEach(member => {
1104
+ this.unsavedWrites.delete(member.uri);
1105
+ this.createdUris.delete(member.uri);
1106
+ });
1107
+ return gone.filter(member => member.outcome === 'lost' || member.outcome === 'resend').map(member => member.uri);
860
1108
  };
861
1109
  if (outcomes.some(member => member.outcome === 'lost' || member.outcome === 'failed')) {
862
1110
  return lose();
@@ -870,6 +1118,7 @@ export class DataSession<
870
1118
  if (resend.length === 0) {
871
1119
  return [];
872
1120
  }
1121
+ const opens = resend.map(member => this.opens.get(member.uri));
873
1122
  let documents: DataSessionDocument<TTransfer, TServer>[];
874
1123
  try {
875
1124
  const { call } = resend[0].write;
@@ -891,13 +1140,15 @@ export class DataSession<
891
1140
  return lose();
892
1141
  }
893
1142
  for (const { uri, write, baseVersion } of resend) {
1143
+ // Closed, saved or deleted while the write ran: nothing to restore.
1144
+ if (!current(uri, write)) {
1145
+ continue;
1146
+ }
894
1147
  this.host.reportError?.(error, resolve(DATA_SESSION_RESTORE_FAILED, { uri, detail: describeError(error) }));
895
1148
  // Kept for the next restore, and numbered from the re-opened
896
1149
  // version, as the kept records are: a record numbered by a server
897
1150
  // that has since restarted would have later answers ignored.
898
- if (current(uri, write)) {
899
- this.unsavedWrites.set(uri, { ...write, answer: { ...write.answer, version: baseVersion } });
900
- }
1151
+ this.unsavedWrites.set(uri, { ...write, answer: { ...write.answer, version: baseVersion } });
901
1152
  }
902
1153
  return [];
903
1154
  }
@@ -906,7 +1157,7 @@ export class DataSession<
906
1157
  // Recorded afresh, since the answer of a restarted server may be
907
1158
  // numbered below the one it replaces.
908
1159
  this.unsavedWrites.delete(member.uri);
909
- this.recordWrite(member.uri, documents[index], member.write.baseHash, member.write.call);
1160
+ this.recordWrite(member.uri, opens[index], documents[index], member.write.baseHash, member.write.call);
910
1161
  }
911
1162
  });
912
1163
  return [];
@@ -935,10 +1186,11 @@ export class DataSession<
935
1186
  }
936
1187
 
937
1188
  /**
938
- * Record `document` as the answer to a write of `uri`, keeping the base of
939
- * the URI's record if it has one. An answer numbered below the record's is
940
- * ignored: two writes of `uri` in flight at once may answer out of order,
941
- * and the record holds the one applied last.
1189
+ * Record `document` as the answer to a write of `uri` sent under `open`,
1190
+ * keeping the base of the URI's record if it has one. An answer sent under
1191
+ * no open, or under one that has ended, is ignored. So is one numbered below
1192
+ * the record's: two writes of `uri` in flight at once may answer out of
1193
+ * order, and the record holds the one applied last.
942
1194
  *
943
1195
  * So is one numbered at or below the URI's entry in {@link savedVersions}.
944
1196
  * The save came after that write was applied, so the save persisted its
@@ -947,10 +1199,15 @@ export class DataSession<
947
1199
  */
948
1200
  protected recordWrite(
949
1201
  uri: string,
1202
+ open: DataSessionOpen | undefined,
950
1203
  document: DataSessionDocument<TTransfer, TServer>,
951
1204
  baseHash: string | undefined,
952
1205
  call: DataSessionUnsavedWrite<TTransfer, TServer>['call']
953
1206
  ): void {
1207
+ // Recorded, it would outlive that open.
1208
+ if (!open || this.opens.get(uri) !== open) {
1209
+ return;
1210
+ }
954
1211
  const answer = this.answerOf(uri, document);
955
1212
  const kept = this.unsavedWrites.get(uri);
956
1213
  if (kept && answer.version < kept.answer.version) {
@@ -1001,18 +1258,18 @@ export class DataSession<
1001
1258
  return running;
1002
1259
  }
1003
1260
 
1004
- /** {@link track} an open of `uri`, counted in {@link openingUris} until it settles. */
1005
- protected trackOpen<T>(uri: string, call: () => Promise<T>): Promise<T> {
1006
- this.openingUris.set(uri, (this.openingUris.get(uri) ?? 0) + 1);
1261
+ /** {@link track} an open of `uri`, kept in {@link openings} until it settles. */
1262
+ protected trackOpen<T>(uri: string, call: (opening: DataSessionOpening) => Promise<T>): Promise<T> {
1263
+ const opening: DataSessionOpening = { closed: false };
1264
+ const openings = this.openings.get(uri) ?? new Set<DataSessionOpening>();
1265
+ this.openings.set(uri, openings.add(opening));
1007
1266
  const done = (): void => {
1008
- const remaining = (this.openingUris.get(uri) ?? 1) - 1;
1009
- if (remaining > 0) {
1010
- this.openingUris.set(uri, remaining);
1011
- } else {
1012
- this.openingUris.delete(uri);
1267
+ openings.delete(opening);
1268
+ if (openings.size === 0 && this.openings.get(uri) === openings) {
1269
+ this.openings.delete(uri);
1013
1270
  }
1014
1271
  };
1015
- const running = this.track([uri], call);
1272
+ const running = this.track([uri], () => call(opening));
1016
1273
  running.then(done, done);
1017
1274
  return running;
1018
1275
  }