@hydranium/data-server 1.0.0-next.117 → 1.0.0-next.123

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.
@@ -16,6 +16,7 @@ import {
16
16
  isSyntheticSource,
17
17
  messageError,
18
18
  ReferenceSource,
19
+ SessionClosedError,
19
20
  type CloseModelArgs,
20
21
  type HydraniumResponseError,
21
22
  type Disposable,
@@ -46,13 +47,17 @@ import {
46
47
  type StartProfilingArgs,
47
48
  type StopProfilingArgs,
48
49
  type WriteServerHeapSnapshotArgs,
50
+ type CloseSessionArgs,
51
+ type CreateModelDocumentArgs,
52
+ type CreateSessionArgs,
49
53
  type GetModelDocumentArgs,
50
54
  type GetProjectForUriArgs,
51
55
  type TransferSaveDocumentArgs,
52
56
  type WatchModelDocumentArgs,
53
57
  type TransferDocumentSavedEvent,
54
58
  type TransferDocumentUpdatedEvent,
55
- type TransferUpdateDocumentArgs
59
+ type TransferUpdateDocumentArgs,
60
+ type TransferUpdateDocumentsArgs
56
61
  } from '@hydranium/protocol/data';
57
62
  import { REVERT_ON_CLOSE_CLIENT_ID, UNKNOWN_CLIENT_ID } from '@hydranium/core';
58
63
  import { defaultDataServerDiagnostics } from './default-diagnostics.js';
@@ -99,6 +104,8 @@ export const referenceSettleTimeoutError = (elapsedMs: number): HydraniumRespons
99
104
 
100
105
  import type { DataServerDiagnosticsProvider, DataServerProfileCapture } from './diagnostics-provider.js';
101
106
  import type {
107
+ ClientSession,
108
+ ClientSessionWriteArgs,
102
109
  ClientTextDocumentChangeEvent,
103
110
  HydraniumLanguageServices,
104
111
  LogNameOptions,
@@ -106,6 +113,7 @@ import type {
106
113
  ModelService,
107
114
  ProjectChangeEvent,
108
115
  ServerSharedServices,
116
+ SessionEndCause,
109
117
  TransferEncoder
110
118
  } from '@hydranium/core';
111
119
  import type { TextDocument } from 'vscode-languageserver-textdocument';
@@ -150,6 +158,18 @@ function fingerprintHash(parts: readonly string[]): string {
150
158
  return high + low;
151
159
  }
152
160
 
161
+ /**
162
+ * A session a data connection registered with a resume token, and how to end
163
+ * it from another connection. See {@link DataServer.resumableSessions}.
164
+ */
165
+ export interface ResumableSession {
166
+ readonly token: string;
167
+ /** End the session as its connection closing would. */
168
+ readonly end: () => void;
169
+ }
170
+
171
+ const resumableByModelService = new WeakMap<object, Map<string, ResumableSession>>();
172
+
153
173
  /**
154
174
  * Which observable state {@link DataServer.computeDocumentFingerprint} hashes to
155
175
  * de-dup `onDocumentUpdated` emissions.
@@ -410,17 +430,15 @@ export class DataServer<
410
430
  /** Subscription bookkeeping: URI → set of clientIds that subscribed for that URI. */
411
431
  protected readonly subscriptions = new Map<string, Set<string>>();
412
432
  /**
413
- * Open-document bookkeeping: URI → set of clientIds that opened it over THIS
414
- * connection.
433
+ * The client sessions this connection registered, by client id. A document
434
+ * request carrying one of these ids acts as that session; one carrying any
435
+ * other id fails.
415
436
  *
416
- * Kept because the document store releases a client's hold only from an
417
- * explicit close, and a client that dies without closing would otherwise keep
418
- * the document open forever — resident, and with its last-close revert
419
- * suppressed. The store has no notion of which connection a clientId reached
420
- * it over, so the connection-scoped set has to live here; teaching it a client
421
- * identity is the more robust and much larger alternative.
437
+ * Entries stay after {@link dispose} has ended their sessions, so a request
438
+ * still running from before the connection closed reaches the ended handle
439
+ * and fails with the session's own error.
422
440
  */
423
- protected readonly openedDocuments = new Map<string, Set<string>>();
441
+ protected readonly clientSessions = new Map<string, ClientSession<AstNode, AstDiagnostic, TTransfer>>();
424
442
  /** Typed client proxy — sends `data-server/on*` notifications back over the same wire. */
425
443
  protected readonly clientProxy: DataClientProtocol<TTransfer, TDiagnostic, TProject>;
426
444
  protected readonly disposables = new DisposableCollection();
@@ -472,12 +490,12 @@ export class DataServer<
472
490
  */
473
491
  protected readonly lastEmittedFingerprint = new Map<string, string>();
474
492
  /**
475
- * URIs whose LAST client just closed. The update handler rebuilds such a
476
- * document from its disk content (discarding unsaved in-session edits),
477
- * but {@link dispatchPhaseEvent} gates on the subscription map — and the
478
- * last close typically also removed the last watcher, so consumers that
479
- * only ever fetch via `getModelDocument` would keep showing the
480
- * discarded state forever. Marked on the last-close transition (see
493
+ * URIs the text store just released after their last close. The store
494
+ * rebuilds such a document from its disk content (discarding unsaved
495
+ * in-session edits), but {@link dispatchPhaseEvent} gates on the
496
+ * subscription map — and the last close typically also removed the last
497
+ * watcher, so consumers that only ever fetch via `getModelDocument` would
498
+ * keep showing the discarded state forever. Marked on the release (see
481
499
  * {@link subscribeToTextDocumentCloses}) and consumed by
482
500
  * {@link dispatchPhaseEvent}, which broadcasts the following rebuild's
483
501
  * phase event even without a subscription — de-duplicated against
@@ -556,8 +574,10 @@ export class DataServer<
556
574
  this.subscribeToProjectManager();
557
575
  // Self-register teardown so an adopter that keeps no reference to the
558
576
  // server still releases per-connection state, with no lifecycle hook of
559
- // its own. See `dispose`.
560
- this.disposables.push(connection.onClose(() => this.dispose()));
577
+ // its own. The client did not close anything itself, so its sessions end
578
+ // as lost and each document it was the last to have open waits out the
579
+ // store's revert grace. See `dispose`.
580
+ this.disposables.push(connection.onClose(() => this.dispose('lost')));
561
581
  }
562
582
 
563
583
  /**
@@ -565,16 +585,19 @@ export class DataServer<
565
585
  * handlers and every listener — and clear the subscription map and the
566
586
  * per-URI emission-fingerprint cache, so a long-lived shared services
567
587
  * bundle does not retain per-connection memory after the connection
568
- * closes. Also closes every document still open over this connection (see
569
- * {@link closeOpenDocuments}), which is the SHARED store's state rather than
570
- * this server's and so outlives the connection unless released here.
588
+ * closes. Also ends every session this connection registered, which closes
589
+ * every document it has open: the SHARED store's state rather than this
590
+ * server's, which outlives the connection unless released here.
571
591
  *
572
- * Idempotent: subsequent calls are no-ops. Self-fires on
573
- * `connection.onClose` so adopters who don't hold a reference still
574
- * get per-connection cleanup; adopters that DO hold a reference may
575
- * call `dispose()` directly for early teardown.
592
+ * Idempotent: subsequent calls are no-ops. Runs by itself when the
593
+ * connection closes, so adopters who don't hold a reference still get
594
+ * per-connection cleanup; adopters that DO hold a reference may call
595
+ * `dispose()` directly for early teardown. The sessions end with `cause`:
596
+ * `'closed'` reverts every document whose last open they close at once,
597
+ * and `'lost'`, the connection's own close, lets each wait out the revert
598
+ * grace.
576
599
  */
577
- dispose(): void {
600
+ dispose(cause: SessionEndCause = 'closed'): void {
578
601
  this.disposed = true;
579
602
  // Release an in-flight interactive capture so the process-wide inspector
580
603
  // singleton is not left active after the connection closes.
@@ -583,129 +606,184 @@ export class DataServer<
583
606
  this.activeProfile = undefined;
584
607
  void capture.stop({}).catch(() => undefined);
585
608
  }
586
- // AFTER `disposables.dispose()`, deliberately: closing a document fires
587
- // `onDidClose`, and this server's own close listener would otherwise mark a
588
- // revert broadcast for a connection that is already gone.
609
+ // AFTER `disposables.dispose()`, deliberately: ending a session releases
610
+ // each document it was the last to hold, and this server's own
611
+ // `onDidCloseLastOpen` listener would otherwise mark a revert broadcast
612
+ // for a connection that is already gone.
589
613
  this.disposables.dispose();
590
- this.closeOpenDocuments();
614
+ for (const [clientId, session] of this.clientSessions) {
615
+ this.endSession(clientId, session, cause);
616
+ }
591
617
  this.subscriptions.clear();
592
618
  this.lastEmittedFingerprint.clear();
593
619
  this.pendingRevertBroadcasts.clear();
594
620
  }
595
621
 
596
622
  // ============================================================
597
- // DataServerProtocol implementation — delegates to ModelService.
623
+ // DataServerProtocol implementation
598
624
  // ============================================================
599
625
  //
600
- // The lifecycle methods (`get` / `update` / `save` / `ready`) forward
601
- // straight to `ModelService` and encode the returned `AstDocument` on
602
- // the wire boundary via `encoder.astDocumentToTransferDocument`.
603
- // Adopters customising lifecycle behaviour (normalisation, supersession,
604
- // settled-phase choice, etc.) override on the ModelService subclass —
605
- // no override on DataServer is needed.
606
-
607
- async openModelDocument(args: OpenModelArgs): Promise<TransferDocument<TTransfer, TDiagnostic>> {
608
- // Register the editor session (idempotent — an already-open document is
609
- // attached without rebuilding), then return a fresh snapshot at the
610
- // configured target phase. Subsequent build-phase events arrive via
611
- // `watchModelDocument`.
612
- await this.modelService.open(args);
613
- // Record the hold so `dispose` can release it for a client that never
614
- // closes. Keyed by the URI as given, because that is what `close` takes.
615
- let holders = this.openedDocuments.get(args.uri);
616
- if (!holders) {
617
- holders = new Set<string>();
618
- this.openedDocuments.set(args.uri, holders);
626
+ // Reads go to `ModelService`; a request that opens or writes a document
627
+ // goes to the session this connection registered under its `clientId`.
628
+ // Each answer is encoded on the wire boundary via
629
+ // `encoder.astDocumentToTransferDocument`. To change how documents are
630
+ // read, override on the ModelService subclass; to change how they are
631
+ // written, on the session class the bound `ClientSessionFactory` builds.
632
+
633
+ async createSession(args: CreateSessionArgs): Promise<void> {
634
+ // After teardown nothing would ever end a session registered now, and its
635
+ // id would stay taken for the life of the process.
636
+ if (this.disposed) {
637
+ throw new SessionClosedError(args.clientId);
619
638
  }
620
- const heldBefore = holders.has(args.clientId);
621
- holders.add(args.clientId);
622
- let document: TransferDocument<TTransfer, TDiagnostic>;
623
- try {
624
- document = await this.getModelDocument({ uri: args.uri });
625
- } catch (error: unknown) {
626
- // The snapshot is the last step, and failing it fails the RPC — so the
627
- // caller sees no open and has no reason to close. Release what this
628
- // call took rather than leaving a hold nothing on either side is
629
- // tracking. A repeat open by a client that already held the URI keeps
630
- // it: the hold is one per `(uri, clientId)`, so releasing here would
631
- // revoke an earlier open that is still legitimately in use.
632
- if (!heldBefore) {
633
- try {
634
- // Untracked only once the close has actually LANDED. Dropping the
635
- // record first gives a failed close the same effect as a
636
- // successful one: the hold survives on the document store while
637
- // the connection-close drain — the only thing left that would
638
- // release it — has no record of it.
639
- await this.modelService.close({ uri: args.uri, clientId: args.clientId });
640
- this.forgetOpenDocument(args.uri, args.clientId);
641
- } catch {
642
- // Swallowed so the caller receives the reason the OPEN failed
643
- // rather than a secondary one about the cleanup. The record stays
644
- // behind deliberately, so teardown retries the release.
645
- }
646
- }
647
- throw error;
639
+ const resumable = this.resumableSessions();
640
+ const previous = resumable.get(args.clientId);
641
+ if (previous && args.resumeToken !== undefined && previous.token === args.resumeToken) {
642
+ previous.end();
643
+ }
644
+ const session = this.modelService.createSession(args.label, args.clientId);
645
+ this.clientSessions.set(args.clientId, session);
646
+ if (args.resumeToken !== undefined) {
647
+ // Taken over by a client registering again after its connection
648
+ // dropped, so the old session ends as lost: the documents it was the
649
+ // last to have open wait out the grace, and the new session's opens
650
+ // find their unsaved text.
651
+ resumable.set(args.clientId, { token: args.resumeToken, end: () => this.endSession(args.clientId, session, 'lost') });
648
652
  }
649
- // Project the authoritative client-facing version onto the open snapshot.
650
- // `getModelDocument` encodes the freshly-built AST snapshot, whose version
651
- // is the `LangiumDocument`'s own `textDocument.version`. That lags the
652
- // multi-client synced version whenever the open seeded the synced document
653
- // with a caller-supplied version id — and the synced version is what
654
- // `ModelService.update`'s optimistic-concurrency gate compares
655
- // `basedOn` against, so reporting the snapshot's would make the
656
- // caller's first tagged write self-conflict. A genuine concurrent edit
657
- // still trips the gate and is reconciled by the caller's replay rather
658
- // than predicted here.
659
- return { ...document, version: asSnapshotVersion(this.services.workspace.TextDocuments.version(args.uri)) };
660
653
  }
661
654
 
662
- async closeModelDocument(args: CloseModelArgs): Promise<void> {
663
- // Closing a session also releases its watch for (uri, clientId) — a
664
- // forgotten unwatch would otherwise leak phase-event dispatch until the
665
- // connection closes. Idempotent: a close without a prior watch is a no-op.
666
- await this.unwatchModelDocument({ uri: args.uri, clientId: args.clientId });
667
- this.forgetOpenDocument(args.uri, args.clientId);
668
- await this.modelService.close(args);
655
+ /**
656
+ * End a session this connection registered. The ended handle stays in
657
+ * {@link clientSessions}, as it does after {@link dispose}, so a request for
658
+ * the id still arriving fails with the ended session's own error.
659
+ */
660
+ async closeSession(args: CloseSessionArgs): Promise<void> {
661
+ const session = this.clientSessions.get(args.clientId);
662
+ if (session) {
663
+ this.endSession(args.clientId, session);
664
+ }
665
+ }
666
+
667
+ /** End `session`, registered here under `clientId`, with `cause`, and drop its watches on this connection. */
668
+ protected endSession(
669
+ clientId: string,
670
+ session: ClientSession<AstNode, AstDiagnostic, TTransfer>,
671
+ cause: SessionEndCause = 'closed'
672
+ ): void {
673
+ for (const [uri, watchers] of this.subscriptions) {
674
+ if (watchers.delete(clientId) && watchers.size === 0) {
675
+ this.subscriptions.delete(uri);
676
+ this.lastEmittedFingerprint.delete(uri);
677
+ }
678
+ }
679
+ session.dispose(cause);
669
680
  }
670
681
 
671
682
  /**
672
- * Drop the recorded hold for `(uri, clientId)` so {@link dispose} does not
673
- * close it a second time. Idempotent.
683
+ * The resumable sessions of this services tree, by client id, across all of
684
+ * its data connections: a client resumes on another connection than the one
685
+ * that registered it. An entry leaves when its session ends, by any path, so
686
+ * it keeps no ended connection alive.
687
+ *
688
+ * A takeover by resume token ends the old session even when its connection
689
+ * is still alive, so two clients that shared an id and its token would end
690
+ * each other's sessions. And the token is a guard against colliding with a
691
+ * session the server has not yet seen end, not a secret: the wire carries no
692
+ * authentication, and a peer that learns the token can end the session.
674
693
  */
675
- protected forgetOpenDocument(uri: string, clientId: string): void {
676
- const holders = this.openedDocuments.get(uri);
677
- if (!holders) {
678
- return;
694
+ protected resumableSessions(): Map<string, ResumableSession> {
695
+ let sessions = resumableByModelService.get(this.modelService);
696
+ if (!sessions) {
697
+ const created = new Map<string, ResumableSession>();
698
+ // Subscribed once per services tree, and never disposed: it lives as
699
+ // long as the text store it listens to.
700
+ this.services.workspace.TextDocuments.onDidCloseSession(event => created.delete(event.clientId));
701
+ resumableByModelService.set(this.modelService, created);
702
+ sessions = created;
679
703
  }
680
- holders.delete(clientId);
681
- if (holders.size === 0) {
682
- this.openedDocuments.delete(uri);
704
+ return sessions;
705
+ }
706
+
707
+ async createModelDocument(args: CreateModelDocumentArgs): Promise<TransferDocument<TTransfer, TDiagnostic>> {
708
+ const session = this.requireSession(args.clientId);
709
+ await session.create(args.uri, args.text);
710
+ return this.openedSnapshot(args.uri, () => session.close(args.uri));
711
+ }
712
+
713
+ async updateModelDocuments(args: TransferUpdateDocumentsArgs<TTransfer>): Promise<TransferDocument<TTransfer, TDiagnostic>[]> {
714
+ const astDocuments = await this.requireSession(args.clientId).updateAll({
715
+ updates: args.updates.map(update => this.toSessionWrite(update))
716
+ });
717
+ return astDocuments.map(
718
+ astDocument =>
719
+ this.encoder.astDocumentToTransferDocument(astDocument as never) as unknown as TransferDocument<TTransfer, TDiagnostic>
720
+ );
721
+ }
722
+
723
+ /**
724
+ * The session write a wire write request carries. Only the fields a session
725
+ * takes: a field an adopter adds to a request reaches the session only
726
+ * through an override of this.
727
+ */
728
+ protected toSessionWrite(request: Omit<TransferUpdateDocumentArgs<TTransfer>, 'clientId'>): ClientSessionWriteArgs<TTransfer> {
729
+ return { uri: request.uri, model: request.model, basedOn: request.basedOn };
730
+ }
731
+
732
+ /**
733
+ * The session this connection registered under `clientId`, which every
734
+ * document request acts as. An id that is not one fails with the
735
+ * closed-session code: opening or writing under it would leave an open that
736
+ * no session end ever closes.
737
+ */
738
+ protected requireSession(clientId: string): ClientSession<AstNode, AstDiagnostic, TTransfer> {
739
+ const session = this.clientSessions.get(clientId);
740
+ if (!session) {
741
+ throw new SessionClosedError(clientId, `Client session ${clientId} was never registered on this connection`);
683
742
  }
743
+ return session;
744
+ }
745
+
746
+ async openModelDocument(args: Pick<OpenModelArgs, 'uri' | 'clientId' | 'options'>): Promise<TransferDocument<TTransfer, TDiagnostic>> {
747
+ const session = this.requireSession(args.clientId);
748
+ const wasOpen = this.services.workspace.TextDocuments.isOpenInClient(args.uri, args.clientId);
749
+ await session.open(args.uri, args.options);
750
+ // A repeat open keeps the earlier one, which is still in use.
751
+ return this.openedSnapshot(args.uri, wasOpen ? undefined : () => session.close(args.uri));
684
752
  }
685
753
 
686
754
  /**
687
- * Close every document still open over this connection, for the clients that
688
- * opened it here.
755
+ * The snapshot a session's open or create answers with. When the read
756
+ * fails, `rollback` undoes the open this call made before the failure is
757
+ * rethrown: the caller sees no open, so nothing on its side would ever close
758
+ * it.
689
759
  *
690
- * The document store releases a per-URI hold only from an explicit close, so
691
- * without this a client that dies mid-session keeps its documents open for the
692
- * lifetime of the process: `isOpenInAnyClient` stays true, the document stays
693
- * resident, and the last-close revert never runs. A long-lived multi-client
694
- * head is the configuration where a dead client is normal rather than
695
- * exceptional, so the leak accumulates there.
696
- *
697
- * Runs from {@link dispose}, which is synchronous, so each close is fired and
698
- * its failure swallowed — a teardown must not reject, and a URI whose close
699
- * fails is no worse off than it was before this drain existed.
760
+ * Versioned with the store's shared version rather than the snapshot's own:
761
+ * the built document's `textDocument.version` lags the store's whenever the
762
+ * store assigned the version, and the store's is what a write's `basedOn` is
763
+ * compared against, so reporting the snapshot's would make the caller's
764
+ * first based-on write conflict with itself.
700
765
  */
701
- protected closeOpenDocuments(): void {
702
- const held = [...this.openedDocuments];
703
- this.openedDocuments.clear();
704
- for (const [uri, clientIds] of held) {
705
- for (const clientId of clientIds) {
706
- void Promise.resolve(this.modelService.close({ uri, clientId })).catch(() => undefined);
707
- }
766
+ protected async openedSnapshot(uri: string, rollback?: () => Promise<void>): Promise<TransferDocument<TTransfer, TDiagnostic>> {
767
+ let document: TransferDocument<TTransfer, TDiagnostic>;
768
+ try {
769
+ document = await this.getModelDocument({ uri });
770
+ } catch (error: unknown) {
771
+ // The open's failure is the one the caller needs, not the cleanup's.
772
+ await rollback?.().catch(() => undefined);
773
+ throw error;
708
774
  }
775
+ return { ...document, version: asSnapshotVersion(this.services.workspace.TextDocuments.version(uri)) };
776
+ }
777
+
778
+ async closeModelDocument(args: CloseModelArgs): Promise<void> {
779
+ // Through the handle, so a close under an ended session fails as that
780
+ // session's calls do, and a session class's own close is honoured.
781
+ const session = this.requireSession(args.clientId);
782
+ // Closing also releases the watch for (uri, clientId) — a forgotten
783
+ // unwatch would otherwise leak phase-event dispatch until the connection
784
+ // closes. Idempotent: a close without a prior watch is a no-op.
785
+ await this.unwatchModelDocument({ uri: args.uri, clientId: args.clientId });
786
+ await session.close(args.uri);
709
787
  }
710
788
 
711
789
  async getModelDocument(args: GetModelDocumentArgs): Promise<TransferDocument<TTransfer, TDiagnostic>> {
@@ -725,12 +803,12 @@ export class DataServer<
725
803
  }
726
804
 
727
805
  async updateModelDocument(args: TransferUpdateDocumentArgs<TTransfer>): Promise<TransferDocument<TTransfer, TDiagnostic>> {
728
- const astDocument = await this.modelService.update(args);
806
+ const astDocument = await this.requireSession(args.clientId).update(this.toSessionWrite(args));
729
807
  return this.encoder.astDocumentToTransferDocument(astDocument as never) as unknown as TransferDocument<TTransfer, TDiagnostic>;
730
808
  }
731
809
 
732
810
  async saveModelDocument(args: TransferSaveDocumentArgs<TTransfer>): Promise<TransferDocument<TTransfer, TDiagnostic>> {
733
- const astDocument = await this.modelService.save(args);
811
+ const astDocument = await this.requireSession(args.clientId).save(this.toSessionWrite(args));
734
812
  return this.encoder.astDocumentToTransferDocument(astDocument as never) as unknown as TransferDocument<TTransfer, TDiagnostic>;
735
813
  }
736
814
 
@@ -1196,18 +1274,17 @@ export class DataServer<
1196
1274
  }
1197
1275
 
1198
1276
  /**
1199
- * Mark the last-close transition per URI (see
1200
- * {@link pendingRevertBroadcasts}). The listener consults
1201
- * `isOpenInAnyClient` AFTER the store decremented the closing client's
1202
- * hold, so a `false` answer means this close was the last one.
1277
+ * Mark each document the store releases after its last close (see
1278
+ * {@link pendingRevertBroadcasts}). A release, not the close itself: a
1279
+ * document whose last client lost its connection is released only once
1280
+ * the revert grace runs out, or when another client opens it meanwhile, and
1281
+ * not at all when a client lost from it opens it again within its own
1282
+ * grace.
1203
1283
  */
1204
1284
  protected subscribeToTextDocumentCloses(): void {
1205
- const textDocuments = this.services.workspace.TextDocuments;
1206
1285
  this.disposables.push(
1207
- textDocuments.onDidClose(event => {
1208
- if (!textDocuments.isOpenInAnyClient(event.document.uri)) {
1209
- this.pendingRevertBroadcasts.add(this.canonicalKey(event.document.uri));
1210
- }
1286
+ this.services.workspace.TextDocuments.onDidCloseLastOpen(event => {
1287
+ this.pendingRevertBroadcasts.add(this.canonicalKey(event.uri));
1211
1288
  })
1212
1289
  );
1213
1290
  }