@hydranium/core 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.
Files changed (108) hide show
  1. package/lib/documents/ast-document-manager.d.ts +8 -44
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +22 -41
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/client-session-errors.d.ts +4 -33
  6. package/lib/documents/client-session-errors.d.ts.map +1 -1
  7. package/lib/documents/client-session-errors.js +4 -46
  8. package/lib/documents/client-session-errors.js.map +1 -1
  9. package/lib/documents/client-session-registry.d.ts +40 -20
  10. package/lib/documents/client-session-registry.d.ts.map +1 -1
  11. package/lib/documents/client-session-registry.js +33 -20
  12. package/lib/documents/client-session-registry.js.map +1 -1
  13. package/lib/documents/file-system-task-queue.d.ts +49 -0
  14. package/lib/documents/file-system-task-queue.d.ts.map +1 -0
  15. package/lib/documents/file-system-task-queue.js +34 -0
  16. package/lib/documents/file-system-task-queue.js.map +1 -0
  17. package/lib/documents/hydranium-text-documents.d.ts +138 -13
  18. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  19. package/lib/documents/hydranium-text-documents.js +283 -40
  20. package/lib/documents/hydranium-text-documents.js.map +1 -1
  21. package/lib/documents/index.d.ts +1 -0
  22. package/lib/documents/index.d.ts.map +1 -1
  23. package/lib/documents/index.js +1 -0
  24. package/lib/documents/index.js.map +1 -1
  25. package/lib/langium/document-builder/document-builder.d.ts +15 -2
  26. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  27. package/lib/langium/document-builder/document-builder.js +15 -2
  28. package/lib/langium/document-builder/document-builder.js.map +1 -1
  29. package/lib/langium/integrity/integrity-service.d.ts +3 -2
  30. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  31. package/lib/langium/integrity/integrity-service.js +9 -6
  32. package/lib/langium/integrity/integrity-service.js.map +1 -1
  33. package/lib/langium/language-module.d.ts +2 -2
  34. package/lib/langium/language-module.js +2 -2
  35. package/lib/langium/language-module.js.map +1 -1
  36. package/lib/langium/model-service/client-session.d.ts +203 -24
  37. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  38. package/lib/langium/model-service/client-session.js +233 -29
  39. package/lib/langium/model-service/client-session.js.map +1 -1
  40. package/lib/langium/model-service/model-service.d.ts +62 -217
  41. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  42. package/lib/langium/model-service/model-service.js +54 -357
  43. package/lib/langium/model-service/model-service.js.map +1 -1
  44. package/lib/langium/module.d.ts +11 -1
  45. package/lib/langium/module.d.ts.map +1 -1
  46. package/lib/langium/module.js +5 -1
  47. package/lib/langium/module.js.map +1 -1
  48. package/lib/langium/update-rewrite/update-rewrite.d.ts +1 -1
  49. package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
  50. package/lib/langium/workspace/in-memory-file-system-provider.js +10 -2
  51. package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
  52. package/lib/langium/workspace/write-lock-scope.d.ts +1 -1
  53. package/lib/langium/workspace/write-lock-scope.js +1 -1
  54. package/lib/lsp/hydranium-document-update-handler.d.ts +69 -67
  55. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  56. package/lib/lsp/hydranium-document-update-handler.js +111 -89
  57. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  58. package/lib/lsp/shared-module.d.ts +4 -8
  59. package/lib/lsp/shared-module.d.ts.map +1 -1
  60. package/lib/lsp/shared-module.js.map +1 -1
  61. package/lib/lsp/start-language-server.d.ts +3 -4
  62. package/lib/lsp/start-language-server.d.ts.map +1 -1
  63. package/lib/lsp/start-language-server.js +3 -4
  64. package/lib/lsp/start-language-server.js.map +1 -1
  65. package/lib/testing/make-test-services.d.ts +16 -4
  66. package/lib/testing/make-test-services.d.ts.map +1 -1
  67. package/lib/testing/make-test-services.js +6 -2
  68. package/lib/testing/make-test-services.js.map +1 -1
  69. package/lib/testing/node/lsp-server-connection.d.ts +1 -1
  70. package/lib/testing/node/scratch-workspace.js +1 -1
  71. package/lib/testing/run-update-pipeline.d.ts +4 -4
  72. package/lib/testing/run-update-pipeline.d.ts.map +1 -1
  73. package/lib/testing/stub-ast-document-manager.d.ts +5 -5
  74. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  75. package/lib/testing/stub-ast-document-manager.js +6 -8
  76. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  77. package/lib/testing/stub-hydranium-text-documents.d.ts +10 -5
  78. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  79. package/lib/testing/stub-hydranium-text-documents.js +16 -9
  80. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  81. package/lib/testing/stub-model-service.d.ts +2 -2
  82. package/lib/testing/stub-model-service.js +2 -2
  83. package/package.json +5 -5
  84. package/src/documents/ast-document-manager.ts +27 -78
  85. package/src/documents/client-session-errors.ts +11 -43
  86. package/src/documents/client-session-registry.ts +54 -28
  87. package/src/documents/file-system-task-queue.ts +68 -0
  88. package/src/documents/hydranium-text-documents.ts +348 -44
  89. package/src/documents/index.ts +1 -0
  90. package/src/langium/document-builder/document-builder.ts +16 -2
  91. package/src/langium/integrity/integrity-service.ts +9 -6
  92. package/src/langium/language-module.ts +4 -4
  93. package/src/langium/model-service/client-session.ts +363 -39
  94. package/src/langium/model-service/model-service.ts +91 -420
  95. package/src/langium/module.ts +14 -2
  96. package/src/langium/update-rewrite/update-rewrite.ts +1 -1
  97. package/src/langium/workspace/in-memory-file-system-provider.ts +11 -2
  98. package/src/langium/workspace/write-lock-scope.ts +1 -1
  99. package/src/lsp/hydranium-document-update-handler.ts +138 -92
  100. package/src/lsp/shared-module.ts +4 -8
  101. package/src/lsp/start-language-server.ts +3 -4
  102. package/src/testing/make-test-services.ts +25 -6
  103. package/src/testing/node/lsp-server-connection.ts +1 -1
  104. package/src/testing/node/scratch-workspace.ts +1 -1
  105. package/src/testing/run-update-pipeline.ts +4 -4
  106. package/src/testing/stub-ast-document-manager.ts +10 -24
  107. package/src/testing/stub-hydranium-text-documents.ts +29 -16
  108. package/src/testing/stub-model-service.ts +2 -2
@@ -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 { type URI, UriUtils } from '@hydranium/langium';
23
+ import { URI, UriUtils } from '@hydranium/langium';
24
24
  import { type ServerSharedServices } from '../langium/module.js';
25
25
  import {
26
26
  type ApplyWorkspaceEditResult,
@@ -31,7 +31,7 @@ import {
31
31
  type DidOpenTextDocumentParams,
32
32
  type DidSaveTextDocumentParams,
33
33
  type Disposable,
34
- type Emitter,
34
+ Emitter,
35
35
  type Event,
36
36
  type HandlerResult,
37
37
  OptionalVersionedTextDocumentIdentifier,
@@ -46,11 +46,20 @@ import {
46
46
  type WillSaveTextDocumentParams
47
47
  } from 'vscode-languageserver';
48
48
  import { type DocumentUri, TextDocument, type TextDocumentContentChangeEvent } from 'vscode-languageserver-textdocument';
49
- import { type CanonicalUri, type LanguageClientUri, asLanguageClientUri, DisposableCollection, type Tracer } from '@hydranium/protocol';
49
+ import {
50
+ type CanonicalUri,
51
+ type LanguageClientUri,
52
+ asLanguageClientUri,
53
+ DisposableCollection,
54
+ type Stopwatch,
55
+ type Tracer
56
+ } from '@hydranium/protocol';
50
57
  import { type LogNameOptions } from '../langium/diagnostics/logger.js';
58
+ import { HYDRANIUM_BUILD_REASONS } from '../langium/document-builder/document-builder.js';
59
+ import { isConnectionGoneError } from '../util/connection-liveness.js';
51
60
  import { LANGUAGE_CLIENT_ID } from './client-ids.js';
52
61
  import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
53
- import { type ClientSessionClosedEvent, ClientSessionRegistry, type OpenOptions } from './client-session-registry.js';
62
+ import { type ClientSessionClosedEvent, ClientSessionRegistry, type SessionEndCause } from './client-session-registry.js';
54
63
  import { isFullReplace, LanguageClientTextShadow } from './language-client-text-shadow.js';
55
64
 
56
65
  /**
@@ -77,6 +86,39 @@ export interface HydraniumTextDocumentsOptions<T extends TextDocument = TextDocu
77
86
  * Adopters with a custom text-document type (rare) pass their factory.
78
87
  */
79
88
  readonly configuration?: TextDocumentsConfiguration<T>;
89
+ /**
90
+ * How long a document whose last open closed because its client's
91
+ * connection was lost keeps its text before it reverts to disk. An open by
92
+ * a client lost from the document, within this time of its own loss,
93
+ * cancels the revert, so a client that registers again under its id after
94
+ * a dropped connection finds its unsaved edits. Any other open releases the
95
+ * document first and then opens it as a first open does, from the text the
96
+ * opener supplies (an editor's own) or else from the file. Meanwhile the
97
+ * document counts as open for the integrity service, which therefore writes
98
+ * none of its unsaved text to disk. A close the client makes itself, or
99
+ * ending its session, reverts at once whatever this is.
100
+ *
101
+ * Defaults to 10 s. `0` reverts such a document at once as well, released
102
+ * in the close itself rather than on a timer.
103
+ */
104
+ readonly revertGraceMs?: number;
105
+ }
106
+
107
+ /**
108
+ * The default of {@link HydraniumTextDocumentsOptions.revertGraceMs}: long
109
+ * enough for a client whose connection dropped to register again and reopen
110
+ * its documents, which a data client does as soon as it has a connection.
111
+ */
112
+ const DEFAULT_REVERT_GRACE_MS = 10_000;
113
+
114
+ /** Delivered by {@link HydraniumTextDocuments.onDidSaveInLanguageClient}. */
115
+ export interface LanguageClientSavedEvent {
116
+ readonly uri: string;
117
+ }
118
+
119
+ /** Delivered by {@link HydraniumTextDocuments.onDidCloseLastOpen}. */
120
+ export interface LastOpenClosedEvent {
121
+ readonly uri: CanonicalUri;
80
122
  }
81
123
 
82
124
  /**
@@ -121,6 +163,22 @@ export interface DocumentTrackingRecord {
121
163
  * record on last close.
122
164
  */
123
165
  languageClientUris?: Set<LanguageClientUri>;
166
+ /**
167
+ * The clients whose close of this document was caused by a lost connection
168
+ * and that have not opened it again, each with a stopwatch started at its
169
+ * loss on the store's `Clock`. While the document waits out the revert grace, only an
170
+ * open by one of them within its own
171
+ * {@link HydraniumTextDocumentsOptions.revertGraceMs} of that loss cancels
172
+ * the revert. Every such client counts, not only the last to close: one
173
+ * connection's sessions all end lost together, and any of them may reopen
174
+ * first. Without the time limit, a client that returns late inherits the
175
+ * unsaved text of a holder lost after it. A stopwatch rather than a `now()`
176
+ * reading, because a wall-clock step would otherwise expire a claim early or
177
+ * revive one past its grace, against a grace timer that the step leaves
178
+ * alone. An entry past its grace counts as any other client's and is pruned
179
+ * at the next open of the document.
180
+ */
181
+ lostClients?: Map<string, Stopwatch>;
124
182
  }
125
183
 
126
184
  /**
@@ -225,6 +283,18 @@ export type LanguageClientChangeOrigin =
225
283
  */
226
284
  | { readonly kind: 'unreconstructable' };
227
285
 
286
+ /**
287
+ * Whether `err` reports that the file of `target` does not exist, as a Node
288
+ * file system and the framework's providers do: code `ENOENT`, with `target`'s
289
+ * path. A missing file of another document is a different failure.
290
+ */
291
+ function isFileNotFound(err: unknown, target: URI): boolean {
292
+ if (typeof err !== 'object' || err === null || !('code' in err) || err.code !== 'ENOENT') {
293
+ return false;
294
+ }
295
+ return !('path' in err) || err.path === target.fsPath;
296
+ }
297
+
228
298
  /**
229
299
  * Cheap, stable, non-cryptographic content hash (cyrb53) for the version
230
300
  * sequence's "did the content change across close/reopen?" question. Only
@@ -262,6 +332,9 @@ function contentHash(text: string): string {
262
332
  * document's content-revision number), and splicing them lets versions drift
263
333
  * silently past based-on gate holders.
264
334
  * - Version-author history so each edit is attributable to its originating client.
335
+ * - The revert to disk once no client has a document open, for every head
336
+ * ({@link revertToDisk}), deferred by
337
+ * {@link HydraniumTextDocumentsOptions.revertGraceMs} after a lost connection.
265
338
  * - Pending-content staging used by the integrity service to thread corrections
266
339
  * through `workspace/applyEdit` cycles for currently-closed documents.
267
340
  * - `didOpen` notifications arriving over the LSP connection wait on the
@@ -352,6 +425,10 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
352
425
 
353
426
  protected readonly tracer: Tracer;
354
427
  protected readonly configuration: TextDocumentsConfiguration<T>;
428
+ /** See {@link HydraniumTextDocumentsOptions.revertGraceMs}. */
429
+ protected readonly revertGraceMs: number;
430
+ protected readonly lastOpenClosedEmitter = new Emitter<LastOpenClosedEvent>();
431
+ protected readonly languageClientSavedEmitter = new Emitter<LanguageClientSavedEvent>();
355
432
 
356
433
  constructor(
357
434
  protected services: ServerSharedServices,
@@ -365,6 +442,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
365
442
  (uri, reason) => this.tracer.with(uri).warn(`Diff apply-verify fallback (${reason}) — using full-document replace`),
366
443
  this
367
444
  );
445
+ this.revertGraceMs = options.revertGraceMs ?? DEFAULT_REVERT_GRACE_MS;
446
+ this.onDidCloseLastOpen(event => void this.revertToDisk(event.uri));
368
447
  }
369
448
 
370
449
  // Re-exposed configuration factories — for framework-internal callers
@@ -465,7 +544,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
465
544
  );
466
545
  disposables.push(
467
546
  connection.onDidSaveTextDocument((event: DidSaveTextDocumentParams) => {
468
- this.notifyDidSaveTextDocument(event);
547
+ void this.notifyLanguageClientSave(event);
469
548
  })
470
549
  );
471
550
  return disposables;
@@ -613,12 +692,25 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
613
692
  return document.version;
614
693
  }
615
694
 
616
- public notifyDidCloseTextDocument(event: DidCloseTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
695
+ /**
696
+ * Close `clientId`'s open of the document. When it was the last open, the
697
+ * document is released and reverts to disk, at once or, for a `'lost'`
698
+ * close, after {@link HydraniumTextDocumentsOptions.revertGraceMs}.
699
+ */
700
+ public notifyDidCloseTextDocument(
701
+ event: DidCloseTextDocumentParams,
702
+ clientId = LANGUAGE_CLIENT_ID,
703
+ cause: SessionEndCause = 'closed'
704
+ ): void {
617
705
  const uri = this.documentKey(event.textDocument.uri);
618
706
  if (!this.__sessions.removeOpen(uri, clientId)) {
619
707
  return;
620
708
  }
621
- this.__documents.get(uri)?.clientVersions.delete(clientId);
709
+ const record = this.__documents.get(uri);
710
+ record?.clientVersions.delete(clientId);
711
+ if (record && cause === 'lost') {
712
+ (record.lostClients ??= new Map()).set(clientId, this.services.Clock.stopwatch());
713
+ }
622
714
  const syncedDocument = this.__syncedDocuments.get(uri);
623
715
  if (syncedDocument !== undefined) {
624
716
  this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
@@ -627,32 +719,159 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
627
719
  if (clientId === LANGUAGE_CLIENT_ID) {
628
720
  // Monaco closed the document; drop the URI it held and the shadow
629
721
  // baselined under it. (If this was the last URI/client the whole record
630
- // is deleted below, clearing the set anyway.)
722
+ // is deleted on release, clearing the set anyway.)
631
723
  const droppedUri = this.toLanguageClientUri(event.textDocument.uri);
632
724
  this.__documents.get(uri)?.languageClientUris?.delete(droppedUri);
633
725
  this.__shadow.invalidate(droppedUri);
634
726
  this.__pendingPushes.delete(droppedUri);
635
727
  }
636
728
  if (!this.__sessions.isOpen(uri)) {
637
- // Last client closed the document; delete sync state. The downstream
638
- // "rebuild from disk for file URIs / drop from index for ephemeral
639
- // URIs" decision lives in `HydraniumDocumentUpdateHandler.didCloseDocument`,
640
- // which subscribes to `onDidClose` and consults `isOpenInAnyClient`
641
- // to detect the last-close transition.
642
- this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
643
- // Persist where the shared version sequence left off (version +
644
- // content hash) so the next open CONTINUES the sequence instead of
645
- // restarting at the reopening client's declared id. Hashed once
646
- // here at close, not on every change.
647
- this.__versionSequences.set(uri, {
648
- version: syncedDocument.version,
649
- contentHash: contentHash(syncedDocument.getText())
650
- });
651
- this.__syncedDocuments.delete(uri);
652
- // One delete clears every per-URI axis (version history + any staged
653
- // pending content) so a future open with the same URI starts fresh.
654
- this.__documents.delete(uri);
729
+ if (cause === 'lost' && this.revertGraceMs > 0) {
730
+ this.deferRelease(uri);
731
+ } else {
732
+ this.releaseDocument(uri);
733
+ }
734
+ }
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Keep the document, text and all, for the revert grace, then release it.
740
+ * The document stays in the store meanwhile, so a lost client that opens it
741
+ * again within its own grace attaches to it and finds its unsaved text
742
+ * rather than reading disk; see {@link resolvePendingRevert} for any other
743
+ * open.
744
+ */
745
+ protected deferRelease(uri: CanonicalUri): void {
746
+ this.log(uri, `No client left; revert deferred for ${this.revertGraceMs} ms (connection lost)`);
747
+ const timer = this.services.Clock.setTimer(() => {
748
+ this.__sessions.cancelRevert(uri);
749
+ if (!this.__sessions.isOpen(uri)) {
750
+ this.releaseDocument(uri);
751
+ }
752
+ }, this.revertGraceMs);
753
+ this.__sessions.deferRevert(uri, timer);
754
+ }
755
+
756
+ /**
757
+ * Resolve the pending revert of `uri` for an open by `clientId`. An open by a
758
+ * client lost from the document within its grace cancels the revert, and
759
+ * the client finds its unsaved text. Any other open releases the document
760
+ * first, so it opens as a first open does: cancelling for every open hands
761
+ * a lost client's unsaved text to whoever opens next, a reloaded page or an
762
+ * editor, with nothing marking it unsaved.
763
+ */
764
+ protected resolvePendingRevert(uri: CanonicalUri, clientId: string): void {
765
+ const record = this.__documents.get(uri);
766
+ if (record) {
767
+ this.pruneLostClients(record);
768
+ }
769
+ const returning = record?.lostClients?.delete(clientId) ?? false;
770
+ if (!this.__sessions.isRevertPending(uri)) {
771
+ return;
772
+ }
773
+ this.__sessions.cancelRevert(uri);
774
+ if (!returning) {
775
+ this.releaseDocument(uri);
776
+ }
777
+ }
778
+
779
+ /** Drop the entries of {@link DocumentTrackingRecord.lostClients} whose grace has run out. */
780
+ protected pruneLostClients(record: DocumentTrackingRecord): void {
781
+ for (const [clientId, sinceLoss] of record.lostClients ?? []) {
782
+ if (sinceLoss.elapsedMs >= this.revertGraceMs) {
783
+ record.lostClients?.delete(clientId);
784
+ }
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Drop the document no client has open any more, then announce it on
790
+ * {@link onDidCloseLastOpen}, which is what reverts it to disk.
791
+ */
792
+ protected releaseDocument(uri: CanonicalUri): void {
793
+ const syncedDocument = this.__syncedDocuments.get(uri);
794
+ if (syncedDocument === undefined) {
795
+ return;
796
+ }
797
+ this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
798
+ // Persist where the shared version sequence left off (version +
799
+ // content hash) so the next open CONTINUES the sequence instead of
800
+ // restarting at the reopening client's declared id. Hashed once
801
+ // here at release, not on every change.
802
+ this.__versionSequences.set(uri, {
803
+ version: syncedDocument.version,
804
+ contentHash: contentHash(syncedDocument.getText())
805
+ });
806
+ this.__syncedDocuments.delete(uri);
807
+ // One delete clears every per-URI axis (version history + any staged
808
+ // pending content) so a future open with the same URI starts fresh.
809
+ this.__documents.delete(uri);
810
+ this.lastOpenClosedEmitter.fire(Object.freeze({ uri }));
811
+ }
812
+
813
+ /**
814
+ * Rebuild a released document from disk, so the build stops carrying the
815
+ * unsaved text of its last client.
816
+ *
817
+ * Only a `file:` document reverts. A document of another scheme may have
818
+ * been loaded by the adopter into the workspace index, and a rebuild would
819
+ * drop it and break every reference to it.
820
+ *
821
+ * The file's existence is read in its disk queue, so the rebuild follows
822
+ * any save still queued rather than reverting past it. A document with no
823
+ * file behind it — created and never saved, or deleted meanwhile — is
824
+ * removed from the workspace instead: there is no disk text to revert to,
825
+ * and one whose file goes after that read is removed when its rebuild
826
+ * finds none.
827
+ *
828
+ * Whether to revert at all is decided inside the write lock, as its holder:
829
+ * decided before waiting for the lock, a client that opens or re-creates the
830
+ * document meanwhile has its text rebuilt over from disk, or removed. A
831
+ * document some client has open again, or that waits out a new grace, is
832
+ * left to that client.
833
+ */
834
+ protected async revertToDisk(uri: CanonicalUri): Promise<void> {
835
+ if (URI.parse(uri).scheme !== 'file') {
836
+ return;
837
+ }
838
+ const workspace = this.services.workspace;
839
+ const target = UriUtils.toUri(uri);
840
+ const reopened = (): boolean => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri);
841
+ try {
842
+ // Read before the lock: every build and read waits while the lock is
843
+ // held, and this read waits on the file's save I/O.
844
+ const onDisk = await workspace.FileSystemTaskQueue.enqueue(uri, () => workspace.FileSystemProvider.exists(target));
845
+ await workspace.WorkspaceManager?.ready;
846
+ await workspace.WorkspaceLock.write(async token => {
847
+ if (reopened()) {
848
+ return;
849
+ }
850
+ workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
851
+ try {
852
+ await (onDisk
853
+ ? workspace.DocumentBuilder.update([target], [], token)
854
+ : workspace.DocumentBuilder.update([], [target], token));
855
+ } catch (err: unknown) {
856
+ if (!onDisk || !isFileNotFound(err, target)) {
857
+ throw err;
858
+ }
859
+ // The file went after its existence was read.
860
+ this.tracer.with(uri).debug('Revert found no file; removing the document instead');
861
+ workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
862
+ await workspace.DocumentBuilder.update([], [target], token);
863
+ }
864
+ });
865
+ } catch (err: unknown) {
866
+ // A revert that finishes after the LSP peer went away fails its
867
+ // diagnostics publish, and one that runs after its workspace was torn
868
+ // down finds no file: teardown races, not failed reverts.
869
+ if (isConnectionGoneError(err) || isFileNotFound(err, target)) {
870
+ this.tracer.with(uri).debug(`Revert on last close skipped: ${err instanceof Error ? err.message : String(err)}`);
871
+ return;
655
872
  }
873
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
874
+ this.tracer.with(uri).error(`Revert on last close dropped. ${detail}`);
656
875
  }
657
876
  }
658
877
 
@@ -675,17 +894,70 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
675
894
  }
676
895
  }
677
896
 
897
+ /**
898
+ * Handle a save the language client reports: fire
899
+ * {@link onDidSaveInLanguageClient}, then announce it on {@link onDidSave}
900
+ * only once the file read back from disk holds the store's current text.
901
+ */
902
+ protected async notifyLanguageClientSave(event: DidSaveTextDocumentParams): Promise<void> {
903
+ const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
904
+ if (syncedDocument === undefined) {
905
+ return;
906
+ }
907
+ const uri = syncedDocument.uri;
908
+ this.languageClientSavedEmitter.fire(Object.freeze({ uri }));
909
+ // `onDidSave` tells its listeners that the shared document is on disk: a
910
+ // diagram marks its save done, the data head broadcasts a save and its
911
+ // clients drop their dirty state. The editor wrote its own buffer,
912
+ // which lags the store while another client's edit is still on its way
913
+ // to it, so only the file says whether that holds. Read it rather than
914
+ // trust the editor: `didSave` carries text only for a client that
915
+ // honours `includeText`, and asking for it means rewriting Langium's
916
+ // initialize answer, which advertises `save` as a Boolean. The read goes
917
+ // through the disk queue and waits on nothing but the provider, so it
918
+ // follows a server save queued meanwhile. A file changed again between
919
+ // the editor's write and this read compares unequal and takes the safe
920
+ // side. A save not announced leaves every other client dirty, which is
921
+ // true of the store's text: a diagram keeps its save pending, and data
922
+ // clients keep their unsaved state until the next save of the document.
923
+ let onDisk: string | undefined;
924
+ try {
925
+ onDisk = await this.services.workspace.FileSystemTaskQueue.enqueue(uri, () =>
926
+ this.services.workspace.FileSystemProvider.readFile(UriUtils.toUri(uri))
927
+ );
928
+ } catch (err: unknown) {
929
+ this.tracer
930
+ .with(uri)
931
+ .debug(`Editor save not announced: reading the file back failed. ${err instanceof Error ? err.message : String(err)}`);
932
+ return;
933
+ }
934
+ // An editor that saves and then closes releases the document while the
935
+ // read is under way; its save is still a save of the text it held.
936
+ const document = this.__syncedDocuments.get(this.documentKey(uri)) ?? syncedDocument;
937
+ if (onDisk !== document.getText()) {
938
+ this.tracer.with(uri).debug(`Editor save not announced: the file does not hold the text of version ${document.version}`);
939
+ return;
940
+ }
941
+ this.announceSave(document, LANGUAGE_CLIENT_ID);
942
+ }
943
+
678
944
  public notifyDidSaveTextDocument(event: DidSaveTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
679
945
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
680
946
  if (syncedDocument !== undefined) {
681
- this.log(syncedDocument.uri, `Saved synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
682
- this.__onDidSave.fire(Object.freeze({ document: syncedDocument, clientId }));
947
+ this.announceSave(syncedDocument, clientId);
683
948
  }
684
949
  }
685
950
 
951
+ /** Fire {@link onDidSave} for `document`, which need not be synced any more. */
952
+ protected announceSave(document: T, clientId: string): void {
953
+ this.log(document.uri, `Saved synced document: ${document.version} by ${this.formatClientId(clientId)}`);
954
+ this.__onDidSave.fire(Object.freeze({ document, clientId }));
955
+ }
956
+
686
957
  public notifyDidOpenTextDocument(event: DidOpenTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
687
958
  const td = event.textDocument;
688
959
  const uri = this.documentKey(td.uri);
960
+ this.resolvePendingRevert(uri, clientId);
689
961
  if (this.isOpenInClient(uri, clientId)) {
690
962
  // Already open for this client under this canonical identity. If this is a
691
963
  // NEW client-facing URI for the same file (a second tab reached via a
@@ -787,13 +1059,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
787
1059
  * Unifying the two routes therefore rebaselines a textual client's guard to a
788
1060
  * version it never declared.
789
1061
  *
790
- * Returns whether a hold was added — `false` when `uri` is not open, or
791
- * `clientId` already holds it.
1062
+ * Returns whether a hold was added — `false` when `uri` is not open,
1063
+ * `clientId` already holds it, or the document was waiting out the revert
1064
+ * grace for other clients and was released instead (see
1065
+ * {@link resolvePendingRevert}); the caller then opens it anew.
792
1066
  */
793
1067
  attachClient(uri: DocumentUri, clientId: string): boolean {
794
1068
  const key = this.documentKey(uri);
1069
+ if (!this.__syncedDocuments.has(key) || this.isOpenInClient(key, clientId)) {
1070
+ return false;
1071
+ }
1072
+ this.resolvePendingRevert(key, clientId);
795
1073
  const document = this.__syncedDocuments.get(key);
796
- if (!document || this.isOpenInClient(key, clientId)) {
1074
+ if (!document) {
797
1075
  return false;
798
1076
  }
799
1077
  const existingClients = this.__sessions.clientsOf(key);
@@ -1050,6 +1328,35 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1050
1328
  return this.__sessions.onDidCloseSession;
1051
1329
  }
1052
1330
 
1331
+ /**
1332
+ * Fires once a document no client has open is released: at its last close,
1333
+ * or, for a lost client's last close, when its revert grace runs out,
1334
+ * another client opens it, or it is deleted. The document then reverts to
1335
+ * disk.
1336
+ */
1337
+ get onDidCloseLastOpen(): Event<LastOpenClosedEvent> {
1338
+ return this.lastOpenClosedEmitter.event;
1339
+ }
1340
+
1341
+ /**
1342
+ * Fires for every save the language client reports of a document it has
1343
+ * open: the editor has written the file. {@link onDidSave} follows only
1344
+ * when the file holds the store's text.
1345
+ */
1346
+ get onDidSaveInLanguageClient(): Event<LanguageClientSavedEvent> {
1347
+ return this.languageClientSavedEmitter.event;
1348
+ }
1349
+
1350
+ /**
1351
+ * Whether `uri` is waiting out the revert grace: its last open closed with a
1352
+ * lost connection, and it still holds its unsaved text. Such a document is
1353
+ * open for no client, yet not closed either, so a caller that would persist
1354
+ * a closed document's text to disk treats it as open.
1355
+ */
1356
+ isRevertPending(uri: DocumentUri): boolean {
1357
+ return this.__sessions.isRevertPending(this.documentKey(uri));
1358
+ }
1359
+
1053
1360
  /**
1054
1361
  * Start a client session under `clientId`. Throws `DuplicateClientIdError`
1055
1362
  * where {@link ClientSessionRegistry.register} refuses the id.
@@ -1063,15 +1370,16 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1063
1370
  /**
1064
1371
  * End the client session `clientId`: close every document it has open, then
1065
1372
  * 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.
1373
+ * this returns, with `cause` as its cause. A no-op for an id that is not a
1374
+ * registered session.
1067
1375
  */
1068
- closeSession(clientId: string): void {
1376
+ closeSession(clientId: string, cause: SessionEndCause = 'closed'): void {
1069
1377
  if (!this.__sessions.isRegistered(clientId)) {
1070
1378
  return;
1071
1379
  }
1072
1380
  try {
1073
1381
  for (const uri of this.__sessions.beginClose(clientId)) {
1074
- this.notifyDidCloseTextDocument({ textDocument: { uri } }, clientId);
1382
+ this.notifyDidCloseTextDocument({ textDocument: { uri } }, clientId, cause);
1075
1383
  }
1076
1384
  } finally {
1077
1385
  this.__sessions.unregister(clientId);
@@ -1080,16 +1388,6 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1080
1388
  }
1081
1389
  }
1082
1390
 
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
1391
  /**
1094
1392
  * The file behind `uri` was deleted: close every open of it except the
1095
1393
  * language client's.
@@ -1122,6 +1420,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1122
1420
  for (const clientId of this.__sessions.clientsOf(key)) {
1123
1421
  this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1124
1422
  }
1423
+ // A document waiting out the grace has no client left to close, and is
1424
+ // released now as its last close would have released it.
1425
+ if (this.__sessions.isRevertPending(key)) {
1426
+ this.__sessions.cancelRevert(key);
1427
+ this.releaseDocument(key);
1428
+ }
1125
1429
  super.delete(key);
1126
1430
  }
1127
1431
 
@@ -14,3 +14,4 @@ export * from './language-client-text-shadow.js';
14
14
  export * from './hydranium-text-documents.js';
15
15
  export * from './ast-document-manager.js';
16
16
  export * from './self-save-registry.js';
17
+ export * from './file-system-task-queue.js';
@@ -44,6 +44,20 @@ export const DEFAULT_LOGGED_PHASES: DocumentState[] = [
44
44
  DocumentState.Validated
45
45
  ];
46
46
 
47
+ /**
48
+ * The build reasons the framework stages through
49
+ * {@link HydraniumDocumentBuilder.markNextReason}: the LSP events the update
50
+ * handler dispatches on, and `didClose` for the revert the text store runs
51
+ * after a last close. Exposed so a subclass layering reasons of its own keeps
52
+ * the names the framework emits.
53
+ */
54
+ export const HYDRANIUM_BUILD_REASONS = Object.freeze({
55
+ didOpen: 'didOpen',
56
+ didChangeContent: 'didChangeContent',
57
+ didChangeWatchedFiles: 'didChangeWatchedFiles',
58
+ didClose: 'didClose'
59
+ } as const);
60
+
47
61
  /**
48
62
  * Consecutive re-queued builds that leave a waited-on document at the same state
49
63
  * before {@link HydraniumDocumentBuilder.awaitDocumentState} stops re-queuing it.
@@ -211,8 +225,8 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
211
225
 
212
226
  /**
213
227
  * Stage an LSP event name for the next `update()` call. Adopters call before
214
- * the update fires; `HydraniumDocumentUpdateHandler` does it for the
215
- * four LSP events.
228
+ * the update fires; `HydraniumDocumentUpdateHandler` does it for the LSP
229
+ * events, and the text store for the revert that follows a last close.
216
230
  *
217
231
  * The framework stages the value and never reads it back. The consumer is a
218
232
  * subclass overriding the build logging, which takes
@@ -456,7 +456,8 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
456
456
  * mutation rode the current build, so `ModelService.syncToLanguageClient`
457
457
  * (the persistent integrity-settled listener) mirrors it to the client by
458
458
  * *content* (shadow diff), and the editor shows it as an unsaved change.
459
- * - **Held only through the data or GLSP head** → the store is the
459
+ * - **Held only through the data or GLSP head**, or waiting out the revert
460
+ * grace after its last client's connection was lost → the store is the
460
461
  * authority, and those heads read the correction from the settled build.
461
462
  * In silent mode it is also written to disk when the text it was computed
462
463
  * from is what disk holds, since the repair is then the only difference;
@@ -497,7 +498,9 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
497
498
  return;
498
499
  }
499
500
 
500
- if (this.textDocuments.isOpenInAnyClient(document.uri)) {
501
+ // A document waiting out the revert grace still holds its lost client's
502
+ // unsaved text, so writing it as a closed file would persist that text.
503
+ if (this.textDocuments.isOpenInAnyClient(document.uri) || this.textDocuments.isRevertPending(document.uri)) {
501
504
  if (this.syncMode === 'silent' && parsedFrom !== undefined) {
502
505
  await this.persistIfDiskMatches(document, parsedFrom);
503
506
  }
@@ -508,9 +511,9 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
508
511
  if (this.syncMode === 'silent') {
509
512
  const repaired = document.getText();
510
513
  // Async write so the integrity pass does not block the event loop on a slow filesystem.
511
- // In the URI's disk queue, behind any save already queued: written
514
+ // In the file's task queue, behind any save already queued: written
512
515
  // beside one, the older repair can land last and stay on disk.
513
- await this.services.shared.workspace.AstDocumentManager.queueDiskTask(document.uri, () =>
516
+ await this.services.shared.workspace.FileSystemTaskQueue.enqueue(document.uri, () =>
514
517
  this.fileSystemProvider.writeFile(UriUtils.toUri(document.uri), repaired)
515
518
  );
516
519
  return;
@@ -525,7 +528,7 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
525
528
  * the text the repair was computed from — so the write carries the repair
526
529
  * and nothing a holder could still discard.
527
530
  *
528
- * The read, the compare and the write run as one task in the URI's disk
531
+ * The read, the compare and the write run as one task in the file's task
529
532
  * queue, so no save of the file lands between them. The store is compared
530
533
  * again once the read returns, and the write is skipped if it no longer
531
534
  * holds the repair: an edit does not wait for the queue, and writing the
@@ -537,7 +540,7 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
537
540
  protected async persistIfDiskMatches(document: TextDocument, parsedFrom: string): Promise<void> {
538
541
  const uri = UriUtils.toUri(document.uri);
539
542
  const repaired = document.getText();
540
- await this.services.shared.workspace.AstDocumentManager.queueDiskTask(document.uri, async () => {
543
+ await this.services.shared.workspace.FileSystemTaskQueue.enqueue(document.uri, async () => {
541
544
  let onDisk: string;
542
545
  try {
543
546
  onDisk = await this.fileSystemProvider.readFile(uri);
@@ -158,7 +158,7 @@ export interface ServerAddedServices {
158
158
  * multi-grammar workspaces route to the right serializer per file. The
159
159
  * framework default throws a clear "no Serializer registered" error;
160
160
  * adopters bind their own per-language subclass to enable
161
- * `ModelService.update` / `ModelService.save`.
161
+ * a client session's `update` / `save`.
162
162
  */
163
163
  Serializer: Serializer;
164
164
  };
@@ -211,7 +211,7 @@ export interface ServerAddedServices {
211
211
  updateRewrite: {
212
212
  /**
213
213
  * Per-language runner for transfer-model rewrites applied on the
214
- * structured write path of `ModelService.update` / `save`, before
214
+ * structured write path of a client session's `update` / `save`, before
215
215
  * serialisation. The RPC-update-stage sibling of the
216
216
  * {@link IntegrityService} / {@link AstExtensionService} registries.
217
217
  * Framework default is a no-op (zero rewrites registered) — adopters
@@ -263,12 +263,12 @@ export interface ServerAddedServices {
263
263
  class UnboundSerializer implements Serializer {
264
264
  serializeAst(_model: AstNode): string {
265
265
  throw new Error(
266
- 'No Serializer registered at services.serializer.Serializer — bind a Serializer<TAst, TTransfer> in your per-language module before calling ModelService.update / ModelService.save.'
266
+ "No Serializer registered at services.serializer.Serializer — bind a Serializer<TAst, TTransfer> in your per-language module before writing through a client session's update or save."
267
267
  );
268
268
  }
269
269
  serializeTransfer(_model: TransferElement): string {
270
270
  throw new Error(
271
- 'No Serializer registered at services.serializer.Serializer — bind a Serializer<TAst, TTransfer> in your per-language module before calling ModelService.update / ModelService.save.'
271
+ "No Serializer registered at services.serializer.Serializer — bind a Serializer<TAst, TTransfer> in your per-language module before writing through a client session's update or save."
272
272
  );
273
273
  }
274
274
  }