@hydranium/core 1.0.0-next.114 → 1.0.0-next.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +3 -1
  2. package/lib/documents/ast-document-manager.d.ts +52 -2
  3. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  4. package/lib/documents/ast-document-manager.js +67 -5
  5. package/lib/documents/ast-document-manager.js.map +1 -1
  6. package/lib/documents/client-session-errors.d.ts +44 -0
  7. package/lib/documents/client-session-errors.d.ts.map +1 -0
  8. package/lib/documents/client-session-errors.js +57 -0
  9. package/lib/documents/client-session-errors.js.map +1 -0
  10. package/lib/documents/client-session-registry.d.ts +118 -0
  11. package/lib/documents/client-session-registry.d.ts.map +1 -0
  12. package/lib/documents/client-session-registry.js +171 -0
  13. package/lib/documents/client-session-registry.js.map +1 -0
  14. package/lib/documents/hydranium-text-documents.d.ts +77 -25
  15. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  16. package/lib/documents/hydranium-text-documents.js +143 -52
  17. package/lib/documents/hydranium-text-documents.js.map +1 -1
  18. package/lib/documents/index.d.ts +2 -0
  19. package/lib/documents/index.d.ts.map +1 -1
  20. package/lib/documents/index.js +2 -0
  21. package/lib/documents/index.js.map +1 -1
  22. package/lib/langium/integrity/integrity-rule.d.ts +13 -5
  23. package/lib/langium/integrity/integrity-rule.d.ts.map +1 -1
  24. package/lib/langium/integrity/integrity-rule.js.map +1 -1
  25. package/lib/langium/integrity/integrity-service.d.ts +37 -6
  26. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  27. package/lib/langium/integrity/integrity-service.js +81 -17
  28. package/lib/langium/integrity/integrity-service.js.map +1 -1
  29. package/lib/langium/model-service/client-session.d.ts +120 -0
  30. package/lib/langium/model-service/client-session.d.ts.map +1 -0
  31. package/lib/langium/model-service/client-session.js +175 -0
  32. package/lib/langium/model-service/client-session.js.map +1 -0
  33. package/lib/langium/model-service/index.d.ts +1 -0
  34. package/lib/langium/model-service/index.d.ts.map +1 -1
  35. package/lib/langium/model-service/index.js +1 -0
  36. package/lib/langium/model-service/index.js.map +1 -1
  37. package/lib/langium/model-service/model-service.d.ts +76 -8
  38. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  39. package/lib/langium/model-service/model-service.js +148 -21
  40. package/lib/langium/model-service/model-service.js.map +1 -1
  41. package/lib/lsp/hydranium-document-update-handler.d.ts +6 -0
  42. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  43. package/lib/lsp/hydranium-document-update-handler.js +20 -3
  44. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  45. package/lib/testing/stub-ast-document-manager.d.ts +4 -3
  46. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  47. package/lib/testing/stub-ast-document-manager.js +3 -0
  48. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  49. package/lib/testing/stub-hydranium-text-documents.d.ts +13 -5
  50. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  51. package/lib/testing/stub-hydranium-text-documents.js +45 -14
  52. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  53. package/package.json +5 -5
  54. package/src/documents/ast-document-manager.ts +107 -6
  55. package/src/documents/client-session-errors.ts +54 -0
  56. package/src/documents/client-session-registry.ts +211 -0
  57. package/src/documents/hydranium-text-documents.ts +158 -55
  58. package/src/documents/index.ts +2 -0
  59. package/src/langium/integrity/integrity-rule.ts +13 -5
  60. package/src/langium/integrity/integrity-service.ts +84 -17
  61. package/src/langium/model-service/client-session.ts +257 -0
  62. package/src/langium/model-service/index.ts +1 -0
  63. package/src/langium/model-service/model-service.ts +176 -21
  64. package/src/lsp/hydranium-document-update-handler.ts +22 -4
  65. package/src/testing/stub-ast-document-manager.ts +7 -2
  66. package/src/testing/stub-hydranium-text-documents.ts +62 -18
@@ -0,0 +1,211 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { type CanonicalUri, FRAMEWORK_CLIENT_IDS } from '@hydranium/protocol';
11
+ import { Emitter, type Event } from 'vscode-languageserver';
12
+ import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
13
+ import { DuplicateClientIdError, SessionClosedError } from './client-session-errors.js';
14
+
15
+ /**
16
+ * What a client session states about one of its opens, kept per
17
+ * `(session, uri)` for as long as that open lasts.
18
+ *
19
+ * Any object type: an adopter declares the fields its own open path reads and
20
+ * passes that type as a session's `TOpenOptions`. Stored per open rather than
21
+ * per document, so two sessions opening one document with different options
22
+ * each keep their own.
23
+ */
24
+ export type OpenOptions = object;
25
+
26
+ /**
27
+ * `'closing'` covers the span in which a session's opens are being closed. The
28
+ * id is still taken then, and the session can open nothing more, so an open
29
+ * issued from a close listener cannot outlive the session that made it.
30
+ */
31
+ export type ClientSessionState = 'live' | 'closing';
32
+
33
+ /** Delivered by {@link ClientSessionRegistry.onDidCloseSession}. */
34
+ export interface ClientSessionClosedEvent {
35
+ readonly clientId: string;
36
+ }
37
+
38
+ /**
39
+ * The ids no client session may be started under: the wire's well-known ids and
40
+ * the integrity author. Each names a framework participant rather than a
41
+ * client, and a session holding one would have its writes read as that
42
+ * participant's.
43
+ */
44
+ export const RESERVED_CLIENT_IDS: readonly string[] = [...FRAMEWORK_CLIENT_IDS, INTEGRITY_CLIENT_ID];
45
+
46
+ /**
47
+ * Which client has which document open, and which client ids are registered
48
+ * sessions — the open facts every head shares, composed by
49
+ * `HydraniumTextDocuments`.
50
+ *
51
+ * Opens are recorded for every client id, registered or not. An id that was
52
+ * never registered is a client that predates sessions and writes through the
53
+ * flat `clientId` calls; its opens are tracked exactly as a session's are, and
54
+ * only the session table ignores it. Requiring registration before an open
55
+ * would refuse every such client.
56
+ *
57
+ * One open per `(client, uri)`, with no reference count: a repeat open changes
58
+ * nothing, and one close ends it.
59
+ *
60
+ * Pure bookkeeping. Closing a document is the store's job, because a close has
61
+ * to reach the text store and its listeners; the store asks this class what to
62
+ * close and records each close here.
63
+ */
64
+ export class ClientSessionRegistry {
65
+ protected readonly sessions = new Map<string, ClientSessionState>();
66
+ protected readonly clientsByUri = new Map<CanonicalUri, Set<string>>();
67
+ /** Each client's opens, with the options it opened them with. */
68
+ protected readonly opensByClient = new Map<string, Map<CanonicalUri, OpenOptions | undefined>>();
69
+ protected readonly reservedIds: ReadonlySet<string> = new Set(RESERVED_CLIENT_IDS);
70
+ protected readonly sessionClosedEmitter = new Emitter<ClientSessionClosedEvent>();
71
+
72
+ /** Fires once a session has been removed from the table, after all its opens closed. */
73
+ get onDidCloseSession(): Event<ClientSessionClosedEvent> {
74
+ return this.sessionClosedEmitter.event;
75
+ }
76
+
77
+ /**
78
+ * Enter `clientId` in the session table.
79
+ *
80
+ * Throws {@link DuplicateClientIdError} when the id is live anywhere in the
81
+ * process: reserved, registered (including by a session that is still
82
+ * closing), or holding opens as a client that is not a session. The last
83
+ * matters because the id is the author label and the echo key, and a
84
+ * session sharing one with another client would take that client's writes
85
+ * for its own.
86
+ */
87
+ register(clientId: string): void {
88
+ if (this.reservedIds.has(clientId) || this.sessions.has(clientId) || this.opensByClient.has(clientId)) {
89
+ throw new DuplicateClientIdError(clientId);
90
+ }
91
+ this.sessions.set(clientId, 'live');
92
+ }
93
+
94
+ /** Whether `clientId` is in the session table, live or closing. */
95
+ isRegistered(clientId: string): boolean {
96
+ return this.sessions.has(clientId);
97
+ }
98
+
99
+ /**
100
+ * Mark `clientId` as closing and return every URI it has open, as a copy the
101
+ * caller can close while this registry changes underneath it.
102
+ */
103
+ beginClose(clientId: string): CanonicalUri[] {
104
+ if (this.sessions.has(clientId)) {
105
+ this.sessions.set(clientId, 'closing');
106
+ }
107
+ return this.opensOf(clientId);
108
+ }
109
+
110
+ /** Remove `clientId` from the session table and announce it. A no-op for an id that is not registered. */
111
+ unregister(clientId: string): void {
112
+ if (!this.sessions.delete(clientId)) {
113
+ return;
114
+ }
115
+ this.sessionClosedEmitter.fire(Object.freeze({ clientId }));
116
+ }
117
+
118
+ /**
119
+ * Record that `clientId` opened `uri`, with no options. Returns `false` when
120
+ * it already had it open, in which case nothing changes.
121
+ *
122
+ * Throws {@link SessionClosedError} for a session that is closing.
123
+ */
124
+ addOpen(uri: CanonicalUri, clientId: string): boolean {
125
+ if (this.sessions.get(clientId) === 'closing') {
126
+ throw new SessionClosedError(clientId);
127
+ }
128
+ let opens = this.opensByClient.get(clientId);
129
+ if (opens?.has(uri)) {
130
+ return false;
131
+ }
132
+ if (!opens) {
133
+ opens = new Map();
134
+ this.opensByClient.set(clientId, opens);
135
+ }
136
+ opens.set(uri, undefined);
137
+ let clients = this.clientsByUri.get(uri);
138
+ if (!clients) {
139
+ clients = new Set();
140
+ this.clientsByUri.set(uri, clients);
141
+ }
142
+ clients.add(clientId);
143
+ return true;
144
+ }
145
+
146
+ /** Record that `clientId` closed `uri`. Returns `false` when it did not have it open. */
147
+ removeOpen(uri: CanonicalUri, clientId: string): boolean {
148
+ const opens = this.opensByClient.get(clientId);
149
+ if (!opens?.delete(uri)) {
150
+ return false;
151
+ }
152
+ if (opens.size === 0) {
153
+ this.opensByClient.delete(clientId);
154
+ }
155
+ const clients = this.clientsByUri.get(uri);
156
+ clients?.delete(clientId);
157
+ if (!clients?.size) {
158
+ this.clientsByUri.delete(uri);
159
+ }
160
+ return true;
161
+ }
162
+
163
+ /** Whether `clientId` has `uri` open. */
164
+ isOpenIn(uri: CanonicalUri, clientId: string): boolean {
165
+ return !!this.clientsByUri.get(uri)?.has(clientId);
166
+ }
167
+
168
+ /** Whether any client has `uri` open. */
169
+ isOpen(uri: CanonicalUri): boolean {
170
+ return this.clientsByUri.has(uri);
171
+ }
172
+
173
+ /** The clients that have `uri` open, in the order they opened it. */
174
+ clientsOf(uri: CanonicalUri): string[] {
175
+ return [...(this.clientsByUri.get(uri) ?? [])];
176
+ }
177
+
178
+ /** The URIs `clientId` has open, in the order it opened them. */
179
+ opensOf(clientId: string): CanonicalUri[] {
180
+ return [...(this.opensByClient.get(clientId)?.keys() ?? [])];
181
+ }
182
+
183
+ /** Every document open in at least one client, with the clients that have it open. */
184
+ openDocuments(): Array<{ readonly uri: CanonicalUri; readonly clients: string[] }> {
185
+ return [...this.clientsByUri].map(([uri, clients]) => ({ uri, clients: [...clients] }));
186
+ }
187
+
188
+ /** The options `clientId` opened `uri` with, or `undefined` when it gave none or does not have it open. */
189
+ openOptions(uri: CanonicalUri, clientId: string): OpenOptions | undefined {
190
+ return this.opensByClient.get(clientId)?.get(uri);
191
+ }
192
+
193
+ /** Replace the options of an existing open. Does nothing when `clientId` does not have `uri` open. */
194
+ setOpenOptions(uri: CanonicalUri, clientId: string, options: OpenOptions | undefined): void {
195
+ const opens = this.opensByClient.get(clientId);
196
+ if (opens?.has(uri)) {
197
+ opens.set(uri, options);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Forget every session and open without announcing anything. Subscriptions
203
+ * stay, so a listener registered before the clear hears the sessions that
204
+ * end after it.
205
+ */
206
+ clear(): void {
207
+ this.sessions.clear();
208
+ this.clientsByUri.clear();
209
+ this.opensByClient.clear();
210
+ }
211
+ }
@@ -20,7 +20,7 @@
20
20
  // on lsp-server, contradicting the peer architecture.
21
21
  // eslint-disable-next-line @typescript-eslint/no-restricted-imports
22
22
  import { NormalizedTextDocuments } from '@hydranium/langium/lsp';
23
- import { UriUtils } from '@hydranium/langium';
23
+ import { type URI, UriUtils } from '@hydranium/langium';
24
24
  import { type ServerSharedServices } from '../langium/module.js';
25
25
  import {
26
26
  type ApplyWorkspaceEditResult,
@@ -49,6 +49,8 @@ import { type DocumentUri, TextDocument, type TextDocumentContentChangeEvent } f
49
49
  import { type CanonicalUri, type LanguageClientUri, asLanguageClientUri, DisposableCollection, type Tracer } from '@hydranium/protocol';
50
50
  import { type LogNameOptions } from '../langium/diagnostics/logger.js';
51
51
  import { LANGUAGE_CLIENT_ID } from './client-ids.js';
52
+ import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
53
+ import { type ClientSessionClosedEvent, ClientSessionRegistry, type OpenOptions } from './client-session-registry.js';
52
54
  import { isFullReplace, LanguageClientTextShadow } from './language-client-text-shadow.js';
53
55
 
54
56
  /**
@@ -97,8 +99,6 @@ export interface HydraniumTextDocumentsOptions<T extends TextDocument = TextDocu
97
99
  * change that caused it.
98
100
  */
99
101
  export interface DocumentTrackingRecord {
100
- /** Client ids currently holding this document open (multi-client membership). */
101
- readonly clients: Set<string>;
102
102
  /** Author of each version, sparse-indexed by the SHARED (server-assigned) version number. */
103
103
  readonly versionAuthors: string[];
104
104
  /**
@@ -252,7 +252,8 @@ function contentHash(text: string): string {
252
252
  * Multi-client text-document tracking on top of Langium's `NormalizedTextDocuments`.
253
253
  *
254
254
  * Adds the framework features used by the integrity, model-server, and GLSP layers:
255
- * - Per-document client membership (multiple clients can attach to the same URI).
255
+ * - Per-document client membership (multiple clients can attach to the same
256
+ * URI) and the client-session table, both kept by {@link __sessions}.
256
257
  * - A SERVER-OWNED shared version sequence: per-URI, monotonic across
257
258
  * close/reopen cycles, advancing exactly when the synced content changes.
258
259
  * Client-declared version ids (Monaco's buffer numbering) feed only a
@@ -270,15 +271,20 @@ function contentHash(text: string): string {
270
271
  */
271
272
  export class HydraniumTextDocuments<T extends TextDocument = TextDocument> extends NormalizedTextDocuments<T> {
272
273
  /**
273
- * Per-URI client-facing tracking ({@link DocumentTrackingRecord}): client
274
- * membership, version-author history, and integrity-staged pending content,
275
- * keyed by normalized URI. One record per URI so the last-client close clears
276
- * every axis atomically. The language-client diff baseline stays in
277
- * {@link __shadow} (its own abstraction); the parsed-document store stays in
278
- * the inherited `__syncedDocuments`.
274
+ * Per-URI client-facing tracking ({@link DocumentTrackingRecord}), keyed by
275
+ * canonical URI. One record per URI, so dropping it clears every axis at
276
+ * once. Which client has the document open is kept apart, in
277
+ * {@link __sessions}.
279
278
  */
280
279
  protected __documents = new Map<CanonicalUri, DocumentTrackingRecord>();
281
280
 
281
+ /**
282
+ * Which client has which document open, and which client ids are registered
283
+ * sessions. Every open-state predicate on this class reads it, so an open
284
+ * recorded anywhere else is invisible to the last-close transition.
285
+ */
286
+ protected readonly __sessions = new ClientSessionRegistry();
287
+
282
288
  /**
283
289
  * Per-URI shared-version continuity across close/reopen cycles
284
290
  * ({@link VersionSequence}), written at last-client close and consulted by
@@ -496,7 +502,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
496
502
  // the update) from "incoming version older than ours" (stale race).
497
503
  const reason =
498
504
  lastSeen === td.version ? `already at version ${lastSeen}` : `incoming version ${td.version} older than current ${lastSeen}`;
499
- this.logUri(uri, `Ignore update by ${clientId}: ${reason}`, 'debug');
505
+ this.logUri(uri, `Ignore update by ${this.formatClientId(clientId)}: ${reason}`, 'debug');
500
506
  return;
501
507
  }
502
508
  record.clientVersions.set(clientId, td.version);
@@ -562,7 +568,10 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
562
568
  return;
563
569
  }
564
570
  }
565
- this.log(document.uri, `Update to version ${document.version} by ${clientId}${changed ? '' : ' (content unchanged)'}`);
571
+ this.log(
572
+ document.uri,
573
+ `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
574
+ );
566
575
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
567
576
  }
568
577
  }
@@ -596,22 +605,23 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
596
605
  this.__syncedDocuments.set(key, document);
597
606
  this.setAuthor(key, document.version, clientId);
598
607
  }
599
- this.log(document.uri, `Update to version ${document.version} by ${clientId}${changed ? '' : ' (content unchanged)'}`);
608
+ this.log(
609
+ document.uri,
610
+ `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
611
+ );
600
612
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
601
613
  return document.version;
602
614
  }
603
615
 
604
616
  public notifyDidCloseTextDocument(event: DidCloseTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
605
617
  const uri = this.documentKey(event.textDocument.uri);
606
- if (!this.isOpenInClient(uri, clientId)) {
618
+ if (!this.__sessions.removeOpen(uri, clientId)) {
607
619
  return;
608
620
  }
609
- const closingRecord = this.__documents.get(uri);
610
- closingRecord?.clients.delete(clientId);
611
- closingRecord?.clientVersions.delete(clientId);
621
+ this.__documents.get(uri)?.clientVersions.delete(clientId);
612
622
  const syncedDocument = this.__syncedDocuments.get(uri);
613
623
  if (syncedDocument !== undefined) {
614
- this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${clientId}`);
624
+ this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
615
625
  this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId }));
616
626
 
617
627
  if (clientId === LANGUAGE_CLIENT_ID) {
@@ -623,7 +633,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
623
633
  this.__shadow.invalidate(droppedUri);
624
634
  this.__pendingPushes.delete(droppedUri);
625
635
  }
626
- if (!this.__documents.get(uri)?.clients.size) {
636
+ if (!this.__sessions.isOpen(uri)) {
627
637
  // Last client closed the document; delete sync state. The downstream
628
638
  // "rebuild from disk for file URIs / drop from index for ephemeral
629
639
  // URIs" decision lives in `HydraniumDocumentUpdateHandler.didCloseDocument`,
@@ -668,7 +678,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
668
678
  public notifyDidSaveTextDocument(event: DidSaveTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
669
679
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
670
680
  if (syncedDocument !== undefined) {
671
- this.log(syncedDocument.uri, `Saved synced document: ${syncedDocument.version} by ${clientId}`);
681
+ this.log(syncedDocument.uri, `Saved synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
672
682
  this.__onDidSave.fire(Object.freeze({ document: syncedDocument, clientId }));
673
683
  }
674
684
  }
@@ -698,9 +708,9 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
698
708
  return;
699
709
  }
700
710
  let document = this.__syncedDocuments.get(uri);
711
+ const existingClients = this.__sessions.clientsOf(uri);
712
+ this.__sessions.addOpen(uri, clientId);
701
713
  const record = this.trackingFor(uri);
702
- const existingClients = [...record.clients];
703
- record.clients.add(clientId);
704
714
  // Baseline the per-client staleness guard at the version id the client
705
715
  // declared for its own buffer (client-owned per LSP).
706
716
  record.clientVersions.set(clientId, td.version);
@@ -723,7 +733,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
723
733
  const sequence = this.__versionSequences.get(uri);
724
734
  const version =
725
735
  sequence === undefined ? td.version : sequence.contentHash === contentHash(text) ? sequence.version : sequence.version + 1;
726
- this.log(uri, `Open document: Version ${version} by ${clientId} [first client${source}]`);
736
+ this.log(uri, `Open document: Version ${version} by ${this.formatClientId(clientId)} [first client${source}]`);
727
737
  document = this.configuration.create(uri, td.languageId, version, text);
728
738
  this.__syncedDocuments.set(uri, document);
729
739
  this.setAuthor(uri, version, clientId);
@@ -786,9 +796,9 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
786
796
  if (!document || this.isOpenInClient(key, clientId)) {
787
797
  return false;
788
798
  }
799
+ const existingClients = this.__sessions.clientsOf(key);
800
+ this.__sessions.addOpen(key, clientId);
789
801
  const record = this.trackingFor(key);
790
- const existingClients = [...record.clients];
791
- record.clients.add(clientId);
792
802
  record.clientVersions.set(clientId, document.version);
793
803
  this.logClientJoined(key, clientId, document.version, existingClients);
794
804
  return true;
@@ -797,8 +807,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
797
807
  protected logClientJoined(uri: DocumentUri, clientId: string, version: number, existingClients: readonly string[]): void {
798
808
  this.log(
799
809
  uri,
800
- `Attach client: ${clientId} joined existing document (version ${version}, ` +
801
- `now open in: ${[...existingClients, clientId].join(', ')})`
810
+ `Attach client: ${this.formatClientId(clientId)} joined existing document (version ${version}, ` +
811
+ `now open in: ${[...existingClients, clientId].map(id => this.formatClientId(id)).join(', ')})`
802
812
  );
803
813
  }
804
814
 
@@ -808,7 +818,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
808
818
  // Trigger a (re-)build by firing a change event.
809
819
  const timer = this.startTimerForUri(
810
820
  syncedDocument.uri,
811
- `Refresh synced document: Version ${syncedDocument.version} by ${clientId}`
821
+ `Refresh synced document: Version ${syncedDocument.version} by ${this.formatClientId(clientId)}`
812
822
  );
813
823
  this.__onDidChangeContent.fire(Object.freeze({ document: syncedDocument, clientId }));
814
824
  timer.dispose();
@@ -846,7 +856,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
846
856
  protected trackingFor(uri: CanonicalUri): DocumentTrackingRecord {
847
857
  let record = this.__documents.get(uri);
848
858
  if (!record) {
849
- record = { clients: new Set(), versionAuthors: [], clientVersions: new Map() };
859
+ record = { versionAuthors: [], clientVersions: new Map() };
850
860
  this.__documents.set(uri, record);
851
861
  }
852
862
  return record;
@@ -946,10 +956,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
946
956
  * its own numbering, so requiring the two to agree would refuse every commit
947
957
  * on the path this exists for. Content is the thing both sides can be held to.
948
958
  *
949
- * The version is KEPT rather than stepped, and no change event is fired: the
950
- * repair rides the build already under way, so minting a version would
951
- * invalidate the based-on token every watcher just took, and an event would
952
- * re-enter the build that is mid-flight.
959
+ * A changed text is a new version authored by {@link INTEGRITY_CLIENT_ID}.
960
+ * Kept at the old version, the repair is invisible to every reader keyed on
961
+ * versions: a write based on the unrepaired version passes the based-on gate
962
+ * and replaces the repair, and an echo filter credits the repair to the
963
+ * client whose edit it corrected. No change event is fired: the repair
964
+ * rides the build already under way, and an event would re-enter it.
953
965
  */
954
966
  commitRepair(uri: DocumentUri, parsedFrom: string, repaired: string): RepairCommit<T> {
955
967
  const key = this.documentKey(uri);
@@ -961,11 +973,16 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
961
973
  this.logUri(key, 'Refuse repair commit: the open document moved on from the text the AST was parsed from', 'debug');
962
974
  return { status: 'stale' };
963
975
  }
976
+ if (repaired === parsedFrom) {
977
+ return { status: 'committed', document };
978
+ }
964
979
  // Reassigned rather than mutated in place: the default configuration
965
980
  // updates and returns the SAME instance, but an adopter-supplied one may
966
981
  // return a new object, and the store must end up holding whichever it is.
967
- const updated = this.configuration.update(document, [{ text: repaired }], document.version);
982
+ const updated = this.configuration.update(document, [{ text: repaired }], document.version + 1);
968
983
  this.__syncedDocuments.set(key, updated);
984
+ this.setAuthor(key, updated.version, INTEGRITY_CLIENT_ID);
985
+ this.log(updated.uri, `Update to version ${updated.version} by ${this.formatClientId(INTEGRITY_CLIENT_ID)} (repair)`);
969
986
  return { status: 'committed', document: updated };
970
987
  }
971
988
 
@@ -989,17 +1006,17 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
989
1006
  * True iff any client still holds `uri` open. Distinct from {@link isOpen},
990
1007
  * which reads `__syncedDocuments` — that map is cleared only AFTER the
991
1008
  * `onDidClose` event fires for the last client, so `isOpen` returns `true`
992
- * during the close event itself. `isOpenInAnyClient` reads the tracking
993
- * record's `clients` set, which is updated BEFORE the fire, so an `onDidClose`
1009
+ * during the close event itself. `isOpenInAnyClient` reads the open table
1010
+ * ({@link __sessions}), which is updated BEFORE the fire, so an `onDidClose`
994
1011
  * subscriber that finds this `false` knows the last client just closed and a
995
1012
  * disk re-read / rebuild can proceed.
996
1013
  */
997
1014
  isOpenInAnyClient(uri: DocumentUri): boolean {
998
- return (this.__documents.get(this.documentKey(uri))?.clients.size ?? 0) > 0;
1015
+ return this.__sessions.isOpen(this.documentKey(uri));
999
1016
  }
1000
1017
 
1001
1018
  isOpenInClient(uri: DocumentUri, client: string): boolean {
1002
- return !!this.__documents.get(this.documentKey(uri))?.clients.has(client);
1019
+ return this.__sessions.isOpenIn(this.documentKey(uri), client);
1003
1020
  }
1004
1021
 
1005
1022
  isOpenInLanguageClient(uri: DocumentUri): boolean {
@@ -1010,14 +1027,14 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1010
1027
  }
1011
1028
 
1012
1029
  isOnlyOpenInClient(uri: DocumentUri, client: string): boolean {
1013
- const key = this.documentKey(uri);
1014
- return this.__documents.get(key)?.clients.size === 1 && this.isOpenInClient(key, client);
1030
+ const clients = this.__sessions.clientsOf(this.documentKey(uri));
1031
+ return clients.length === 1 && clients[0] === client;
1015
1032
  }
1016
1033
 
1017
1034
  /**
1018
1035
  * Every document currently held open by at least one client, with the client
1019
- * ids holding it. Reads the same per-URI tracking records as
1020
- * {@link isOpenInAnyClient}, so it reflects a last-client close immediately.
1036
+ * ids holding it. Reads the same open table as {@link isOpenInAnyClient}, so
1037
+ * it reflects a last-client close immediately.
1021
1038
  *
1022
1039
  * Diagnostics-oriented — the server-state snapshot lists these so an operator
1023
1040
  * can see WHY a document is pinned: a document that lingers here after its
@@ -1025,30 +1042,116 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1025
1042
  * under a shedding policy, explains why its CST is never shed).
1026
1043
  */
1027
1044
  openDocuments(): OpenDocument[] {
1028
- const result: OpenDocument[] = [];
1029
- for (const [uri, tracking] of this.__documents) {
1030
- if (tracking.clients.size > 0) {
1031
- result.push({ uri, clients: [...tracking.clients] });
1045
+ return this.__sessions.openDocuments();
1046
+ }
1047
+
1048
+ /** Fires once a client session has ended, after every document it had open was closed. */
1049
+ get onDidCloseSession(): Event<ClientSessionClosedEvent> {
1050
+ return this.__sessions.onDidCloseSession;
1051
+ }
1052
+
1053
+ /**
1054
+ * Start a client session under `clientId`. Throws `DuplicateClientIdError`
1055
+ * where {@link ClientSessionRegistry.register} refuses the id.
1056
+ */
1057
+ registerSession(clientId: string): void {
1058
+ this.__sessions.register(clientId);
1059
+ this.tracer.info(`Session started: ${this.formatClientId(clientId)}`);
1060
+ this.tracer.trace(`Session started: ${clientId}`);
1061
+ }
1062
+
1063
+ /**
1064
+ * End the client session `clientId`: close every document it has open, then
1065
+ * free the id. Immediate — each close runs the ordinary close path before
1066
+ * this returns. A no-op for an id that is not a registered session.
1067
+ */
1068
+ closeSession(clientId: string): void {
1069
+ if (!this.__sessions.isRegistered(clientId)) {
1070
+ return;
1071
+ }
1072
+ try {
1073
+ for (const uri of this.__sessions.beginClose(clientId)) {
1074
+ this.notifyDidCloseTextDocument({ textDocument: { uri } }, clientId);
1075
+ }
1076
+ } finally {
1077
+ this.__sessions.unregister(clientId);
1078
+ this.tracer.info(`Session closed: ${this.formatClientId(clientId)}`);
1079
+ this.tracer.trace(`Session closed: ${clientId}`);
1080
+ }
1081
+ }
1082
+
1083
+ /** The options `clientId` opened `uri` with, or `undefined` when it gave none or does not have it open. */
1084
+ openOptions(uri: DocumentUri, clientId: string): OpenOptions | undefined {
1085
+ return this.__sessions.openOptions(this.documentKey(uri), clientId);
1086
+ }
1087
+
1088
+ /** Replace the options of an existing open. Does nothing when `clientId` does not have `uri` open. */
1089
+ setOpenOptions(uri: DocumentUri, clientId: string, options: OpenOptions | undefined): void {
1090
+ this.__sessions.setOpenOptions(this.documentKey(uri), clientId, options);
1091
+ }
1092
+
1093
+ /**
1094
+ * The file behind `uri` was deleted: close every open of it except the
1095
+ * language client's.
1096
+ *
1097
+ * The editor's open is left alone because the editor owns it: it keeps the
1098
+ * buffer of a deleted file and goes on sending changes for it, and a store
1099
+ * that had closed the document would drop every one of them until the editor
1100
+ * reopened.
1101
+ */
1102
+ notifyDocumentDeleted(uri: DocumentUri): void {
1103
+ const key = this.documentKey(uri);
1104
+ for (const clientId of this.__sessions.clientsOf(key)) {
1105
+ if (clientId !== LANGUAGE_CLIENT_ID) {
1106
+ this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1032
1107
  }
1033
1108
  }
1034
- return result;
1035
1109
  }
1036
1110
 
1037
1111
  /**
1038
- * Stages integrity-updated content for a document no editor holds.
1112
+ * Remove the document for `uri`, closing every open of it first — the
1113
+ * editor's included, since the document it would keep editing is gone.
1114
+ *
1115
+ * The base removes the synced document without consulting the open table,
1116
+ * which leaves every open recorded against a document that no longer exists:
1117
+ * the store then answers that the URI is open, and the next open attaches to
1118
+ * nothing.
1119
+ */
1120
+ override delete(uri: string | URI | T): void {
1121
+ const key = this.documentKey((typeof uri === 'object' && 'uri' in uri ? uri.uri : uri).toString());
1122
+ for (const clientId of this.__sessions.clientsOf(key)) {
1123
+ this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1124
+ }
1125
+ super.delete(key);
1126
+ }
1127
+
1128
+ /**
1129
+ * A client id as log lines print it. An id containing `#` is cut eight
1130
+ * characters after its last `#`, which leaves a minted `label#uuid` as the
1131
+ * label and enough of the UUID to tell sessions apart; an id without `#`
1132
+ * prints whole. The full id goes out at trace level wherever a session
1133
+ * starts or ends.
1134
+ */
1135
+ protected formatClientId(clientId: string): string {
1136
+ const separator = clientId.lastIndexOf('#');
1137
+ return separator < 0 ? clientId : clientId.slice(0, separator + 9);
1138
+ }
1139
+
1140
+ /**
1141
+ * Stages integrity-updated content for a document no client holds.
1039
1142
  *
1040
1143
  * When `workspace/applyEdit` targets a closed file, the client opens it from
1041
1144
  * disk (stale) and sends `didOpen` before applying the edit. This staged
1042
1145
  * content is consumed by {@link notifyDidOpenTextDocument} to replace the
1043
1146
  * stale disk text, preventing a brief revert of the integrity update.
1044
1147
  *
1045
- * Only a FIRST open consumes it. The integrity service stages whenever the
1046
- * URI is not open in the language client, which includes a URI held only
1047
- * through another head; an editor attaching to that URI joins the existing
1048
- * entry and never reads the stage, and the last close discards it with the
1049
- * tracking record. For a closed URI an entry lingers only if
1050
- * `workspace/applyEdit` fails and the file is never opened — the memory cost
1051
- * is one serialised string per URI.
1148
+ * Only a FIRST open consumes it, so stage only for a URI no client holds
1149
+ * ({@link isOpenInAnyClient} is `false`). A URI held only through another
1150
+ * head is not closed: an editor attaching to it joins the existing entry and
1151
+ * never reads the stage, and the last close discards the stage with the
1152
+ * tracking record. An entry lingers only if `workspace/applyEdit` fails and
1153
+ * the file is never opened — the memory cost is one serialised string per
1154
+ * URI.
1052
1155
  */
1053
1156
  stagePendingContent(uri: DocumentUri, text: string): void {
1054
1157
  this.trackingFor(this.documentKey(uri)).pendingContent = text;
@@ -8,6 +8,8 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  export * from './client-ids.js';
11
+ export * from './client-session-errors.js';
12
+ export * from './client-session-registry.js';
11
13
  export * from './language-client-text-shadow.js';
12
14
  export * from './hydranium-text-documents.js';
13
15
  export * from './ast-document-manager.js';
@@ -49,14 +49,22 @@ export namespace IntegrityPhase {
49
49
  }
50
50
 
51
51
  /**
52
- * Controls how integrity corrections are persisted for *closed* files. The
53
- * mode does not reach open files: their corrected text is delivered by the
54
- * model service's settled-state sync, whatever the mode says.
52
+ * Controls how integrity corrections are persisted for files no editor holds.
53
+ * The mode does not reach a file open in the language client: the correction
54
+ * is delivered to the editor, which shows it as an unsaved change.
55
55
  *
56
- * - `silent`: write to disk immediately; no editor involvement.
56
+ * - `silent`: write to disk immediately; no editor involvement. For a file
57
+ * held only through the data or GLSP head, only while disk still holds the
58
+ * text the correction was computed from and the text store still holds the
59
+ * correction. Otherwise — the holder has unsaved edits, disk changed behind
60
+ * the server, or disk cannot be read — the correction waits in the text
61
+ * store for the next save.
57
62
  * - `editor`: open the file dirty via `applyEdit`. If the user closes without
58
63
  * saving, the correction is suppressed until the file changes or the
59
- * workspace reloads.
64
+ * workspace reloads. A file held only through the data or GLSP head gets
65
+ * neither: the correction stays in the text store, and since those heads
66
+ * mark only their own edits unsaved, nothing shows that store and disk
67
+ * differ until a save or the last close.
60
68
  */
61
69
  export type IntegritySyncMode = 'silent' | 'editor';
62
70