@hydranium/core 1.0.0-next.115 → 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 (62) 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 +69 -17
  15. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  16. package/lib/documents/hydranium-text-documents.js +135 -44
  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-service.d.ts +8 -7
  23. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  24. package/lib/langium/integrity/integrity-service.js +35 -27
  25. package/lib/langium/integrity/integrity-service.js.map +1 -1
  26. package/lib/langium/model-service/client-session.d.ts +120 -0
  27. package/lib/langium/model-service/client-session.d.ts.map +1 -0
  28. package/lib/langium/model-service/client-session.js +175 -0
  29. package/lib/langium/model-service/client-session.js.map +1 -0
  30. package/lib/langium/model-service/index.d.ts +1 -0
  31. package/lib/langium/model-service/index.d.ts.map +1 -1
  32. package/lib/langium/model-service/index.js +1 -0
  33. package/lib/langium/model-service/index.js.map +1 -1
  34. package/lib/langium/model-service/model-service.d.ts +57 -1
  35. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  36. package/lib/langium/model-service/model-service.js +123 -9
  37. package/lib/langium/model-service/model-service.js.map +1 -1
  38. package/lib/lsp/hydranium-document-update-handler.d.ts +6 -0
  39. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  40. package/lib/lsp/hydranium-document-update-handler.js +20 -3
  41. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  42. package/lib/testing/stub-ast-document-manager.d.ts +4 -3
  43. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  44. package/lib/testing/stub-ast-document-manager.js +3 -0
  45. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  46. package/lib/testing/stub-hydranium-text-documents.d.ts +13 -5
  47. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  48. package/lib/testing/stub-hydranium-text-documents.js +45 -14
  49. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  50. package/package.json +5 -5
  51. package/src/documents/ast-document-manager.ts +107 -6
  52. package/src/documents/client-session-errors.ts +54 -0
  53. package/src/documents/client-session-registry.ts +211 -0
  54. package/src/documents/hydranium-text-documents.ts +150 -47
  55. package/src/documents/index.ts +2 -0
  56. package/src/langium/integrity/integrity-service.ts +36 -26
  57. package/src/langium/model-service/client-session.ts +257 -0
  58. package/src/langium/model-service/index.ts +1 -0
  59. package/src/langium/model-service/model-service.ts +151 -9
  60. package/src/lsp/hydranium-document-update-handler.ts +22 -4
  61. package/src/testing/stub-ast-document-manager.ts +7 -2
  62. package/src/testing/stub-hydranium-text-documents.ts +62 -18
@@ -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,13 +1042,99 @@ 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;
1109
+ }
1110
+
1111
+ /**
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);
1035
1138
  }
1036
1139
 
1037
1140
  /**
@@ -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';
@@ -368,9 +368,11 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
368
368
  return;
369
369
  }
370
370
 
371
- // Keep the same version so HydraniumTextDocuments doesn't reject subsequent client edits.
372
- // Use manager.update so the `instanceof FullTextDocument` gate in the bare
373
- // `TextDocument.update` doesn't trip on adopter-custom text-document types.
371
+ // A committed repair is a new version; a document no client has open keeps
372
+ // its number here, and the re-versioning below moves it once the build
373
+ // has reconciled. Through the store's `update`, so the `instanceof
374
+ // FullTextDocument` gate in the bare `TextDocument.update` does not trip
375
+ // on adopter-custom text-document types.
374
376
  const textDocument =
375
377
  commit.status === 'committed' ? commit.document : this.textDocuments.update(document.textDocument, [{ text: newText }], version);
376
378
 
@@ -504,8 +506,13 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
504
506
 
505
507
  // Closed files, silent mode: write to disk directly.
506
508
  if (this.syncMode === 'silent') {
509
+ const repaired = document.getText();
507
510
  // Async write so the integrity pass does not block the event loop on a slow filesystem.
508
- await this.fileSystemProvider.writeFile(UriUtils.toUri(document.uri), document.getText());
511
+ // In the URI's disk queue, behind any save already queued: written
512
+ // beside one, the older repair can land last and stay on disk.
513
+ await this.services.shared.workspace.AstDocumentManager.queueDiskTask(document.uri, () =>
514
+ this.fileSystemProvider.writeFile(UriUtils.toUri(document.uri), repaired)
515
+ );
509
516
  return;
510
517
  }
511
518
 
@@ -518,32 +525,35 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
518
525
  * the text the repair was computed from — so the write carries the repair
519
526
  * and nothing a holder could still discard.
520
527
  *
521
- * The store is compared again once the read returns, and the write is
522
- * skipped if it no longer holds the repair. A save does not wait for the
523
- * build this repair belongs to, so an edit and its save can land while the
524
- * read is pending; writing the older repair then would overwrite a saved
525
- * edit on disk. The skipped repair loses nothing: the edit drives a build
526
- * of its own, and a save writes the store's text. A file that cannot be read
527
- * is left alone too: there is no evidence it matches.
528
+ * The read, the compare and the write run as one task in the URI's disk
529
+ * queue, so no save of the file lands between them. The store is compared
530
+ * again once the read returns, and the write is skipped if it no longer
531
+ * holds the repair: an edit does not wait for the queue, and writing the
532
+ * older repair then puts text on disk that no participant holds. The
533
+ * skipped repair loses nothing: the edit drives a build of its own, and a
534
+ * save writes the store's text. A file that cannot be read is left alone
535
+ * too: there is no evidence it matches.
528
536
  */
529
537
  protected async persistIfDiskMatches(document: TextDocument, parsedFrom: string): Promise<void> {
530
538
  const uri = UriUtils.toUri(document.uri);
531
539
  const repaired = document.getText();
532
- let onDisk: string;
533
- try {
534
- onDisk = await this.fileSystemProvider.readFile(uri);
535
- } catch (error: unknown) {
536
- this.tracer.with(document.uri).debug(`Held document unreadable on disk, repair left to the next save: ${String(error)}`);
537
- return;
538
- }
539
- if (onDisk !== parsedFrom) {
540
- return;
541
- }
542
- if (this.textDocuments.get(document.uri)?.getText() !== repaired) {
543
- this.tracer.with(document.uri).debug('Held document changed while disk was read, repair left to that change');
544
- return;
545
- }
546
- await this.fileSystemProvider.writeFile(uri, repaired);
540
+ await this.services.shared.workspace.AstDocumentManager.queueDiskTask(document.uri, async () => {
541
+ let onDisk: string;
542
+ try {
543
+ onDisk = await this.fileSystemProvider.readFile(uri);
544
+ } catch (error: unknown) {
545
+ this.tracer.with(document.uri).debug(`Held document unreadable on disk, repair left to the next save: ${String(error)}`);
546
+ return;
547
+ }
548
+ if (onDisk !== parsedFrom) {
549
+ return;
550
+ }
551
+ if (this.textDocuments.get(document.uri)?.getText() !== repaired) {
552
+ this.tracer.with(document.uri).debug('Held document changed while disk was read, repair left to that change');
553
+ return;
554
+ }
555
+ await this.fileSystemProvider.writeFile(uri, repaired);
556
+ });
547
557
  }
548
558
 
549
559
  /**