@hydranium/core 1.0.0-next.205 → 1.0.0-next.208

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 (99) hide show
  1. package/lib/documents/ast-document-manager.js +4 -4
  2. package/lib/documents/ast-document-manager.js.map +1 -1
  3. package/lib/documents/client-ids.d.ts +1 -1
  4. package/lib/documents/client-ids.d.ts.map +1 -1
  5. package/lib/documents/client-ids.js +1 -1
  6. package/lib/documents/client-ids.js.map +1 -1
  7. package/lib/documents/client-session-registry.d.ts +24 -17
  8. package/lib/documents/client-session-registry.d.ts.map +1 -1
  9. package/lib/documents/client-session-registry.js +49 -29
  10. package/lib/documents/client-session-registry.js.map +1 -1
  11. package/lib/documents/dirty-state-tracker.d.ts +79 -0
  12. package/lib/documents/dirty-state-tracker.d.ts.map +1 -0
  13. package/lib/documents/dirty-state-tracker.js +69 -0
  14. package/lib/documents/dirty-state-tracker.js.map +1 -0
  15. package/lib/documents/document-release-handler.d.ts +179 -0
  16. package/lib/documents/document-release-handler.d.ts.map +1 -0
  17. package/lib/documents/document-release-handler.js +283 -0
  18. package/lib/documents/document-release-handler.js.map +1 -0
  19. package/lib/documents/document-release-scheduler.d.ts +67 -0
  20. package/lib/documents/document-release-scheduler.d.ts.map +1 -0
  21. package/lib/documents/document-release-scheduler.js +80 -0
  22. package/lib/documents/document-release-scheduler.js.map +1 -0
  23. package/lib/documents/hydranium-text-documents.d.ts +137 -455
  24. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  25. package/lib/documents/hydranium-text-documents.js +373 -835
  26. package/lib/documents/hydranium-text-documents.js.map +1 -1
  27. package/lib/documents/index.d.ts +5 -1
  28. package/lib/documents/index.d.ts.map +1 -1
  29. package/lib/documents/index.js +5 -1
  30. package/lib/documents/index.js.map +1 -1
  31. package/lib/documents/language-client-shadow.d.ts +221 -0
  32. package/lib/documents/language-client-shadow.d.ts.map +1 -0
  33. package/lib/documents/language-client-shadow.js +288 -0
  34. package/lib/documents/language-client-shadow.js.map +1 -0
  35. package/lib/documents/text-ledger.d.ts +64 -0
  36. package/lib/documents/text-ledger.d.ts.map +1 -0
  37. package/lib/documents/text-ledger.js +63 -0
  38. package/lib/documents/text-ledger.js.map +1 -0
  39. package/lib/langium/document-builder/document-builder.d.ts +8 -7
  40. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  41. package/lib/langium/document-builder/document-builder.js +8 -7
  42. package/lib/langium/document-builder/document-builder.js.map +1 -1
  43. package/lib/langium/integrity/integrity-service.d.ts +1 -1
  44. package/lib/langium/integrity/integrity-service.js +4 -4
  45. package/lib/langium/integrity/integrity-service.js.map +1 -1
  46. package/lib/langium/model-service/client-session.d.ts +4 -4
  47. package/lib/langium/model-service/client-session.js +2 -2
  48. package/lib/langium/model-service/client-session.js.map +1 -1
  49. package/lib/langium/model-service/model-events.d.ts +8 -11
  50. package/lib/langium/model-service/model-events.d.ts.map +1 -1
  51. package/lib/langium/model-service/model-service.d.ts +18 -4
  52. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  53. package/lib/langium/model-service/model-service.js +4 -1
  54. package/lib/langium/model-service/model-service.js.map +1 -1
  55. package/lib/langium/module.d.ts +7 -0
  56. package/lib/langium/module.d.ts.map +1 -1
  57. package/lib/langium/module.js +3 -1
  58. package/lib/langium/module.js.map +1 -1
  59. package/lib/langium/workspace/file-not-found.d.ts +7 -1
  60. package/lib/langium/workspace/file-not-found.d.ts.map +1 -1
  61. package/lib/langium/workspace/file-not-found.js +14 -3
  62. package/lib/langium/workspace/file-not-found.js.map +1 -1
  63. package/lib/lsp/hydranium-document-update-handler.d.ts +6 -6
  64. package/lib/lsp/hydranium-document-update-handler.js +10 -10
  65. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  66. package/lib/testing/make-test-services.d.ts +3 -0
  67. package/lib/testing/make-test-services.d.ts.map +1 -1
  68. package/lib/testing/make-test-services.js +5 -2
  69. package/lib/testing/make-test-services.js.map +1 -1
  70. package/lib/testing/stub-hydranium-text-documents.d.ts +9 -6
  71. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  72. package/lib/testing/stub-hydranium-text-documents.js +25 -11
  73. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  74. package/package.json +5 -5
  75. package/src/documents/ast-document-manager.ts +4 -4
  76. package/src/documents/client-ids.ts +1 -1
  77. package/src/documents/client-session-registry.ts +55 -34
  78. package/src/documents/dirty-state-tracker.ts +130 -0
  79. package/src/documents/document-release-handler.ts +365 -0
  80. package/src/documents/document-release-scheduler.ts +121 -0
  81. package/src/documents/hydranium-text-documents.ts +407 -1033
  82. package/src/documents/index.ts +5 -1
  83. package/src/documents/language-client-shadow.ts +463 -0
  84. package/src/documents/text-ledger.ts +112 -0
  85. package/src/langium/document-builder/document-builder.ts +8 -7
  86. package/src/langium/integrity/integrity-service.ts +4 -4
  87. package/src/langium/model-service/client-session.ts +6 -6
  88. package/src/langium/model-service/model-events.ts +12 -11
  89. package/src/langium/model-service/model-service.ts +22 -4
  90. package/src/langium/module.ts +9 -1
  91. package/src/langium/workspace/file-not-found.ts +15 -3
  92. package/src/lsp/hydranium-document-update-handler.ts +10 -10
  93. package/src/testing/make-test-services.ts +7 -2
  94. package/src/testing/stub-hydranium-text-documents.ts +41 -21
  95. package/lib/documents/language-client-text-shadow.d.ts +0 -133
  96. package/lib/documents/language-client-text-shadow.d.ts.map +0 -1
  97. package/lib/documents/language-client-text-shadow.js +0 -201
  98. package/lib/documents/language-client-text-shadow.js.map +0 -1
  99. package/src/documents/language-client-text-shadow.ts +0 -223
@@ -19,15 +19,17 @@
19
19
  // eslint-disable-next-line @typescript-eslint/no-restricted-imports
20
20
  import { NormalizedTextDocuments } from '@hydranium/langium/lsp';
21
21
  import { UriUtils } from '@hydranium/langium';
22
- import { Emitter, OptionalVersionedTextDocumentIdentifier, TextDocumentContentChangeEvent as ContentChange, TextDocumentEdit, TextDocumentSyncKind } from 'vscode-languageserver';
22
+ import { Emitter, OptionalVersionedTextDocumentIdentifier, TextDocumentEdit, TextDocumentSyncKind } from 'vscode-languageserver';
23
23
  import { TextDocument } from 'vscode-languageserver-textdocument';
24
- import { asLanguageClientUri, DisposableCollection, textHash, STALE_VERSION, UNRECORDED_VERSION } from '@hydranium/protocol';
25
- import { HYDRANIUM_BUILD_REASONS } from '../langium/document-builder/document-builder.js';
26
- import { isConnectionGoneError } from '../util/connection-liveness.js';
24
+ import { asLanguageClientUri, DisposableCollection, STALE_VERSION, UNRECORDED_VERSION } from '@hydranium/protocol';
27
25
  import { LANGUAGE_CLIENT_ID } from './client-ids.js';
28
26
  import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
29
27
  import { ClientSessionRegistry } from './client-session-registry.js';
30
- import { isFullReplace, LanguageClientTextShadow } from './language-client-text-shadow.js';
28
+ import { DefaultLanguageClientShadow } from './language-client-shadow.js';
29
+ import { DefaultTextLedger } from './text-ledger.js';
30
+ import { isDocumentReleaseSkippedError } from './document-release-handler.js';
31
+ import { DefaultDocumentReleaseScheduler } from './document-release-scheduler.js';
32
+ import { DefaultDirtyStateTracker } from './dirty-state-tracker.js';
31
33
  /**
32
34
  * The LSP spec's "version is intentionally unknown" for an
33
35
  * {@link OptionalVersionedTextDocumentIdentifier} is `null`, but
@@ -37,168 +39,124 @@ import { isFullReplace, LanguageClientTextShadow } from './language-client-text-
37
39
  */
38
40
  const UNKNOWN_CLIENT_VERSION = null;
39
41
  /**
40
- * The default of {@link HydraniumTextDocumentsOptions.revertGraceMs}: long
42
+ * The default of {@link HydraniumTextDocumentsOptions.releaseGraceMs}: long
41
43
  * enough for a client whose connection dropped to register again and reopen
42
44
  * its documents, which a data client does as soon as it has a connection.
43
45
  */
44
- const DEFAULT_REVERT_GRACE_MS = 10_000;
46
+ const DEFAULT_RELEASE_GRACE_MS = 10_000;
45
47
  /**
46
- * Upper bound on {@link HydraniumTextDocuments.__pendingPushes} entries
47
- * per URI. Echoes normally return within milliseconds and consume their
48
- * entry; a queue this deep means the client stopped echoing — cap instead
49
- * of leaking.
50
- */
51
- const PENDING_ECHO_CAP = 32;
52
- /**
53
- * Whether `err` reports that the file of `target` does not exist, as a Node
54
- * file system and the framework's providers do: code `ENOENT`, with `target`'s
55
- * path. A missing file of another document is a different failure.
56
- */
57
- function isFileNotFound(err, target) {
58
- if (typeof err !== 'object' || err === null || !('code' in err) || err.code !== 'ENOENT') {
59
- return false;
60
- }
61
- return !('path' in err) || err.path === target.fsPath;
62
- }
63
- /**
64
- * Multi-client text-document tracking on top of Langium's `NormalizedTextDocuments`.
48
+ * The one text store every head writes to, on top of Langium's
49
+ * `NormalizedTextDocuments`, and the LSP text-sync endpoint: every
50
+ * `textDocument/*` notification and every `workspace/applyEdit` push goes
51
+ * through here.
65
52
  *
66
- * Adds the framework features used by the integrity, model-server, and GLSP layers:
67
- * - Per-document client membership (multiple clients can attach to the same
68
- * URI) and the client-session table, both kept by {@link __sessions}.
69
- * - A SERVER-OWNED shared version sequence: per-URI, monotonic across
70
- * close/reopen cycles, advancing exactly when the synced content changes.
71
- * Client-declared version ids (Monaco's buffer numbering) feed only a
72
- * per-client staleness guard and never leak into a running sequence —
73
- * the two are different things (an editor's edit-operation counter vs the
74
- * document's content-revision number), and splicing them lets versions drift
75
- * silently past base-version gate holders. A URI with no sequence, and no
76
- * root that records a version, starts at its opener's declared id: no version
77
- * was handed out for it.
78
- * - Version-author history so each edit is attributable to its originating client.
79
- * - The revert to disk once no client has a document open, for every head
80
- * ({@link revertToDisk}), deferred by
81
- * {@link HydraniumTextDocumentsOptions.revertGraceMs} after a lost connection.
82
- * - A disk baseline per open document, and whether its text differs from it
83
- * ({@link isDirty}).
84
- * - Pending-content staging used by the integrity service to thread corrections
85
- * through `workspace/applyEdit` cycles for currently-closed documents.
86
- * - `didOpen` notifications arriving over the LSP connection wait on the
87
- * workspace-ready promise, so a client's first open cannot race workspace
88
- * discovery. Direct {@link notifyDidOpenTextDocument} calls (the non-LSP
89
- * heads) do not pass that gate — their caller owns the ordering.
53
+ * Each open, change, close and save is one synchronous transition over the
54
+ * held document and the collaborators its `create…` methods build. A
55
+ * collaborator that defers its part lets a listener of the transition's event
56
+ * read state from before it. A document no client holds any more is
57
+ * released to the `DocumentReleaseHandler` slot, after
58
+ * {@link HydraniumTextDocumentsOptions.releaseGraceMs} when its last client's
59
+ * connection was lost.
60
+ *
61
+ * Client-declared version ids feed only the per-client staleness guard and
62
+ * never a running shared sequence: the two count different things, and
63
+ * splicing them lets versions drift past base-version gate holders. A URI
64
+ * with no ledger record and no built root starts at its opener's declared id:
65
+ * no version was handed out for it.
66
+ *
67
+ * `didOpen` notifications arriving over the LSP connection wait on the
68
+ * workspace-ready promise, so a client's first open cannot race workspace
69
+ * discovery. Direct {@link notifyDidOpenTextDocument} calls (the non-LSP
70
+ * heads) do not pass that gate — their caller owns the ordering.
90
71
  */
91
72
  export class HydraniumTextDocuments extends NormalizedTextDocuments {
92
73
  services;
74
+ options;
75
+ /** Content staged by integrity rules for a document no client holds, consumed by its first open. */
76
+ __pendingContent = new Map();
93
77
  /**
94
- * Per-URI client-facing tracking ({@link DocumentTrackingRecord}), keyed by
95
- * canonical URI. One record per URI, so dropping it clears every axis at
96
- * once. Which client has the document open is kept apart, in
97
- * {@link __sessions}.
98
- */
99
- __documents = new Map();
100
- /**
101
- * Which client has which document open, and which client ids are registered
102
- * sessions. Every open-state predicate on this class reads it, so an open
78
+ * Which client has which document open, which client ids are registered
79
+ * sessions, and each client's declared version, the staleness guard's
80
+ * baseline. Every open-state predicate on this class reads it, so an open
103
81
  * recorded anywhere else is invisible to the last-close transition.
104
82
  */
105
83
  __sessions = new ClientSessionRegistry();
106
- /**
107
- * Per-URI shared-version continuity across close/reopen cycles
108
- * ({@link VersionSequence}), kept for every document a build or a client
109
- * gave the store, and consulted by the next first-client open. A URI
110
- * with none, and no root that records a version, starts at its opener's
111
- * declared version.
112
- * DELIBERATELY outside {@link DocumentTrackingRecord}: that record is deleted on last close, while the version sequence must
113
- * survive it — the shared version is a server-owned, monotonic,
114
- * advances-iff-content-changes counter that never resets while the server
115
- * lives. That invariant is what makes an optimistic base-version gate
116
- * sound: "version unchanged ⇔ content unchanged", with no false conflicts
117
- * from close/reopen version resets and no false passes from a reopened
118
- * sequence coincidentally landing on a stale writer's number.
119
- *
120
- * Never pruned, not even when the file is deleted: a recreated file
121
- * restarting at `0` would let a write based on the deleted text pass. Two
122
- * small values per URI ever built, however often it changes.
123
- */
124
- __versionSequences = new Map();
125
- /**
126
- * Released documents last announced dirty. Their clean flip waits for the
127
- * revert, so it carries the reverted text's version; a first open before the
128
- * revert takes the entry over, and its own dirty answer decides the flip.
129
- * Each release enters a token of its own, so the revert of an earlier
130
- * release cannot announce a later one clean before that one's revert.
131
- */
132
- __releasedDirty = new Map();
133
- /** Per held document, the {@link textHash} of its text at the version it was taken. */
134
- __textHashes = new WeakMap();
135
- /**
136
- * Texts pushed to the LSP textual language client via
137
- * {@link applyEditToLanguageClient} whose echoes have not come back yet
138
- * ({@link PendingLanguageClientPush}), keyed like the shadow by language-client URI.
139
- * Outbound pushes and inbound echoes are uncorrelated on the wire; this
140
- * FIFO is the explicit correlation, and it is what
141
- * {@link classifyLanguageClientChange} reconstructs against.
142
- *
143
- * **Each entry keeps the client's PRE-push text, not only a hash of the
144
- * post-push one.** A hash alone can classify a full-text echo, whose
145
- * application is a no-op either way — and that is all it ever classified,
146
- * because a conforming client echoes INCREMENTAL ranges keyed to its
147
- * previous buffer. Those ranges cannot be applied to the synced text (which
148
- * the authored write already advanced) and cannot be reconstructed without
149
- * the baseline: the line they insert lands twice, validates cleanly, and
150
- * compounds on every later edit.
151
- *
152
- * Lifecycle: entries are consumed by the matching echo (together with any
153
- * older entries it supersedes), and the whole queue drops when the client
154
- * stops being a pure mirror — a divergent change, an `applyEdit`
155
- * failure/rejection (shadow invalidation), a close, or an explicit
156
- * shadow (re)baseline. {@link PENDING_ECHO_CAP} bounds the queue against
157
- * a pathological echo that never arrives; the memory cost until then is
158
- * one pre-push text per in-flight push, for milliseconds.
159
- */
160
- __pendingPushes = new Map();
161
- /**
162
- * Tracked text content per URI for the LSP textual language client (Monaco / VS Code).
163
- * Owned here so {@link applyEditToLanguageClient} can compute minimal `workspace/applyEdit`
164
- * diffs instead of full-document replaces (5–20 s → <500 ms on 20-30 KB YAML diagrams).
165
- *
166
- * Auto-tracked from the multi-client text-document events: open / change / close of
167
- * the language client (re)baseline or invalidate the shadow. Other client ids
168
- * (form editor, GLSP, integrity) do NOT touch the shadow — only what Monaco believes
169
- * it has matters for the diff.
170
- *
171
- * Apply-verify safety net is built in: if the diff doesn't reconstruct `newText`
172
- * exactly, {@link LanguageClientTextShadow.computeEdits} falls back to a full-range
173
- * replace and invokes the `onFallback` callback — a diff regression becomes log
174
- * noise, not a 0-byte save.
175
- *
176
- * Assigned in the constructor body so the `onFallback` callback can capture
177
- * `this.logger` after the parameter-property assignment has run (field
178
- * initializers fire BEFORE parameter-property assignment in TS).
179
- */
180
- __shadow;
84
+ /** The version each open document was opened at; see {@link openedVersion}. */
85
+ __openedVersions = new Map();
181
86
  tracer;
182
87
  configuration;
183
- /** See {@link HydraniumTextDocumentsOptions.revertGraceMs}. */
184
- revertGraceMs;
185
- lastOpenClosedEmitter = new Emitter();
88
+ __textLedger;
89
+ __languageClientShadow;
90
+ __dirtyStateTracker;
91
+ __documentReleaseScheduler;
92
+ documentReleasedEmitter = new Emitter();
186
93
  languageClientSavedEmitter = new Emitter();
187
- dirtyChangedEmitter = new Emitter();
188
94
  constructor(services, options = {}) {
189
95
  const configuration = options.configuration ?? TextDocument;
190
96
  super(configuration);
191
97
  this.services = services;
98
+ this.options = options;
192
99
  this.configuration = configuration;
193
100
  this.tracer = services.Tracer.for(options.logName ?? 'TextDocuments').trace('instantiated');
194
- this.__shadow = new LanguageClientTextShadow((uri, reason) => this.tracer.with(uri).warn(`Diff apply-verify fallback (${reason}) — using full-document replace`), this);
195
- this.revertGraceMs = options.revertGraceMs ?? DEFAULT_REVERT_GRACE_MS;
196
- this.onDidCloseLastOpen(event => void this.revertToDisk(event.uri));
197
- }
198
- // Re-exposed configuration factories — for framework-internal callers
199
- // (LanguageClientTextShadow's apply-verify probe; IntegrityService.resyncDocument)
200
- // that need to materialise documents outside the canonical didOpen/didChange flow
201
- // and must respect the adopter's custom text-document type.
101
+ }
102
+ // Each collaborator is built on first use, after every constructor has run,
103
+ // so a create method may read its subclass's fields and the other collaborators.
104
+ get textLedger() {
105
+ return (this.__textLedger ??= this.createTextLedger());
106
+ }
107
+ get languageClientShadow() {
108
+ return (this.__languageClientShadow ??= this.createLanguageClientShadow());
109
+ }
110
+ get dirtyStateTracker() {
111
+ return (this.__dirtyStateTracker ??= this.createDirtyStateTracker());
112
+ }
113
+ get documentReleaseScheduler() {
114
+ return (this.__documentReleaseScheduler ??= this.createDocumentReleaseScheduler());
115
+ }
116
+ createTextLedger() {
117
+ return new DefaultTextLedger();
118
+ }
119
+ createLanguageClientShadow() {
120
+ return new DefaultLanguageClientShadow(this, this.tracer);
121
+ }
122
+ createDirtyStateTracker() {
123
+ return new DefaultDirtyStateTracker(this.textLedger);
124
+ }
125
+ createDocumentReleaseScheduler() {
126
+ return new DefaultDocumentReleaseScheduler(this.services.Clock, this.options.releaseGraceMs ?? DEFAULT_RELEASE_GRACE_MS);
127
+ }
128
+ /** Hold `document` as the text of `key`, a new version authored by `author`. */
129
+ commitText(key, document, author) {
130
+ this.__syncedDocuments.set(key, document);
131
+ this.setAuthor(key, document.version, author);
132
+ this.dirtyStateTracker.refreshDirty(key, document);
133
+ }
134
+ /**
135
+ * Apply `changes` to `document`, the held text of `key`, and hold the
136
+ * result. The version steps only when the text changes, which is what a
137
+ * base-version gate relies on: unchanged text keeps its version and its
138
+ * author. The new text is known only once the changes are applied, so they
139
+ * go in at a tentative step that an identical result rolls back.
140
+ */
141
+ commitChange(key, document, changes, author) {
142
+ const previousText = document.getText();
143
+ const version = document.version;
144
+ let next = this.update(document, changes, version + 1);
145
+ const changed = next.getText() !== previousText;
146
+ if (changed) {
147
+ this.commitText(key, next, author);
148
+ }
149
+ else {
150
+ // An empty-changes update only re-stamps the version.
151
+ next = this.update(next, [], version);
152
+ this.__syncedDocuments.set(key, next);
153
+ }
154
+ return { document: next, changed };
155
+ }
156
+ // The configuration's factories. Every document the store makes or changes
157
+ // goes through them, so an override sees each call: the store's own writes,
158
+ // callers outside the didOpen/didChange flow, and the shadow's throwaway
159
+ // probes, at version 0 under a client URI.
202
160
  create(uri, languageId, version, content) {
203
161
  return this.configuration.create(uri, languageId, version, content);
204
162
  }
@@ -283,19 +241,18 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
283
241
  const uri = this.documentKey(td.uri);
284
242
  let document = this.__syncedDocuments.get(uri);
285
243
  if (document !== undefined) {
286
- // Per-client staleness guard: client version ids are CLIENT-owned per
287
- // LSP (Monaco numbers its own buffer), so an incoming id is compared
288
- // against THAT client's last declared id — never against the shared
289
- // version, which the server assigns and which routinely runs ahead of
290
- // a client's ids (authored ModelService writes advance it without the
291
- // client knowing). Gating on the shared version drops real edits in
292
- // exactly that lag window. A client with no baseline (never opened —
293
- // a protocol anomaly) falls back to the shared-version compare, the
294
- // conservative answer. The language client is checked per URI: each is
295
- // its own buffer, and one URI's higher id would drop the other's edits.
296
- const record = this.trackingFor(uri);
297
- const languageClientDocument = clientId === LANGUAGE_CLIENT_ID ? record.languageClientDocuments?.get(this.toLanguageClientUri(td.uri)) : undefined;
298
- const lastSeen = languageClientDocument?.declaredVersion ?? record.clientVersions.get(clientId) ?? document.version;
244
+ // Client version ids are the client's own, so compared against what
245
+ // that client declared, never against the shared version: an authored
246
+ // write advances that without the client knowing, and gating on it
247
+ // drops real edits. The editor is checked per URI, since each is its
248
+ // own buffer; its client-wide entry is only the fallback for a URI it
249
+ // never opened, and may name another URI's buffer. A client with no
250
+ // baseline falls back to the shared version.
251
+ const clientUri = this.toLanguageClientUri(td.uri);
252
+ const editor = clientId === LANGUAGE_CLIENT_ID;
253
+ const lastSeen = (editor ? this.languageClientShadow.declaredVersion(uri, clientUri) : undefined) ??
254
+ this.__sessions.clientVersionOf(uri, clientId) ??
255
+ document.version;
299
256
  if (lastSeen >= td.version) {
300
257
  // Distinguish "already at this version" (common: an echo from the client that triggered
301
258
  // the update) from "incoming version older than ours" (stale race).
@@ -303,71 +260,42 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
303
260
  this.logUri(uri, `Ignore update by ${this.formatClientId(clientId)}: ${reason}`, 'debug');
304
261
  return;
305
262
  }
306
- record.clientVersions.set(clientId, td.version);
307
- if (languageClientDocument) {
308
- languageClientDocument.declaredVersion = td.version;
309
- }
310
- // A language-client change is keyed to the buffer that client holds,
311
- // which is the synced text only while the two agree — an authored
312
- // write advances the synced text without the client knowing, and a
313
- // push in flight moves the client without the store knowing.
314
- // Resolve which it is, and against what, before touching anything.
315
- const origin = clientId === LANGUAGE_CLIENT_ID
316
- ? this.classifyLanguageClientChange(this.toLanguageClientUri(td.uri), document, changes)
317
- : undefined;
318
- if (origin?.kind === 'echo') {
319
- // The client is reporting a text we pushed it, so the synced
320
- // document is ALREADY there and the changes must not be applied a
321
- // second time. Nothing is minted and no rebuild fires. The
322
- // per-client baseline advanced above (the client's ids keep
323
- // counting); the shadow is deliberately NOT touched — it
324
- // optimistically tracks the NEWEST pushed text, and dragging it
325
- // back would make the next outbound diff wrong against what the
326
- // client actually holds.
263
+ this.__sessions.setClientVersion(uri, clientId, td.version);
264
+ // An editor change is keyed to the buffer the editor holds, which is
265
+ // the synced text only while the two agree — an authored write
266
+ // advances the synced text without the editor knowing, and a push in
267
+ // flight moves the editor without the store knowing.
268
+ const verdict = editor
269
+ ? this.languageClientShadow.acceptChange(uri, clientUri, td.version, document, changes)
270
+ : { kind: 'direct' };
271
+ if (verdict.kind === 'echo') {
272
+ // The synced document is already there, so the changes must not be
273
+ // applied a second time: nothing is minted and no rebuild fires. The
274
+ // shadow keeps the newest pushed text, which is what the next
275
+ // outbound diff has to be keyed to.
327
276
  this.logUri(uri, `Skip rebuild: echo of a server-authored push (client version ${td.version})`, 'debug');
328
277
  return;
329
278
  }
330
- if (origin?.kind === 'unreconstructable') {
279
+ if (verdict.kind === 'unreconstructable') {
331
280
  this.tracer.with(uri).warn(`Drop change: no known client buffer for its ranges (client version ${td.version})`);
332
281
  return;
333
282
  }
334
- // The SHARED version advances iff the content actually changes — the
335
- // invariant optimistic base-version gates rely on. The new text is
336
- // only known after applying the (possibly incremental) changes, so
337
- // apply at a tentative +1 and roll the version back on an identical
338
- // result (an empty-changes update only re-stamps the version).
339
- //
340
- // A divergent change is applied as its RECONSTRUCTED text rather than
341
- // as its own ranges: those ranges address the client's own buffer, so
283
+ // A divergent change is applied as its reconstructed text rather than
284
+ // as its own ranges: those ranges address the editor's own buffer, so
342
285
  // applying them here would splice the wrong lines.
343
- const previousText = document.getText();
344
- const sharedVersion = document.version;
345
- document = this.configuration.update(document, origin === undefined ? changes : [{ text: origin.text }], sharedVersion + 1);
346
- const changed = document.getText() !== previousText;
347
- if (!changed) {
348
- document = this.configuration.update(document, [], sharedVersion);
349
- }
350
- this.__syncedDocuments.set(uri, document);
351
- if (changed) {
352
- this.setAuthor(uri, document.version, clientId);
353
- this.refreshDirty(uri);
354
- }
355
- if (clientId === LANGUAGE_CLIENT_ID) {
356
- // Monaco just told us about its new content; record it so the next outbound
357
- // applyEditToLanguageClient diffs against the right baseline. Keyed by the
358
- // language-client URI (what Monaco holds), not the canonical document key.
359
- this.__shadow.set(this.toLanguageClientUri(td.uri), document.getText());
360
- // Content-identical echo: the language client is echoing text we already had
361
- // (e.g. Monaco re-emitting a server-pushed applyEditToLanguageClient). The model is
362
- // unchanged, so skip the rebuild. The per-client baseline + shadow still advanced
363
- // above so future staleness checks and diffs are correct. Restricted to the
364
- // language client: a ModelService-authored change is never skipped.
365
- if (!changed) {
286
+ const committed = this.commitChange(uri, document, verdict.kind === 'direct' ? changes : [{ text: verdict.text }], clientId);
287
+ document = committed.document;
288
+ if (editor) {
289
+ this.languageClientShadow.setClientText(clientUri, document.getText());
290
+ // Content-identical echo: the editor is echoing text we already had.
291
+ // The model is unchanged, so skip the rebuild. Restricted to the
292
+ // editor: a ModelService-authored change is never skipped.
293
+ if (!committed.changed) {
366
294
  this.logUri(uri, `Skip rebuild: content unchanged (echo at client version ${td.version})`, 'debug');
367
295
  return;
368
296
  }
369
297
  }
370
- this.log(document.uri, `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`);
298
+ this.log(document.uri, `Update to version ${document.version} by ${this.formatClientId(clientId)}${committed.changed ? '' : ' (content unchanged)'}`);
371
299
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
372
300
  }
373
301
  }
@@ -390,60 +318,49 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
390
318
  */
391
319
  applyContentChange(uri, text, clientId) {
392
320
  const key = this.documentKey(uri);
393
- let document = this.__syncedDocuments.get(key);
394
- if (document === undefined) {
321
+ const synced = this.__syncedDocuments.get(key);
322
+ if (synced === undefined) {
395
323
  throw new Error(`Document ${uri} is not open for content changes`);
396
324
  }
397
- const changed = document.getText() !== text;
398
- if (changed) {
399
- document = this.configuration.update(document, [{ text }], document.version + 1);
400
- this.__syncedDocuments.set(key, document);
401
- this.setAuthor(key, document.version, clientId);
402
- this.refreshDirty(key);
403
- }
325
+ // Unchanged text calls no update: a configuration may return a new document for one.
326
+ const { document, changed } = synced.getText() === text ? { document: synced, changed: false } : this.commitChange(key, synced, [{ text }], clientId);
404
327
  this.log(document.uri, `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`);
405
328
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
406
329
  return document.version;
407
330
  }
408
331
  /**
409
332
  * Close `clientId`'s open of the document. When it was the last open, the
410
- * document is released and reverts to disk, at once or, for a `'lost'`
411
- * close, after {@link HydraniumTextDocumentsOptions.revertGraceMs}.
333
+ * document is released, at once or, for a `'lost'`
334
+ * close, after {@link HydraniumTextDocumentsOptions.releaseGraceMs}.
412
335
  */
413
336
  notifyDidCloseTextDocument(event, clientId = LANGUAGE_CLIENT_ID, cause = 'closed') {
414
337
  const uri = this.documentKey(event.textDocument.uri);
415
338
  if (clientId === LANGUAGE_CLIENT_ID) {
416
- const clientFacing = this.toLanguageClientUri(event.textDocument.uri);
417
- const languageClientDocuments = this.__documents.get(uri)?.languageClientDocuments;
418
- if (languageClientDocuments?.delete(clientFacing) && languageClientDocuments.size > 0) {
419
- // Another URI still holds the document, so only this one's buffer goes.
420
- this.__shadow.invalidate(clientFacing);
421
- this.__pendingPushes.delete(clientFacing);
339
+ const clientUri = this.toLanguageClientUri(event.textDocument.uri);
340
+ // A close under a URI the editor never opened the document under ends its hold.
341
+ if (this.languageClientShadow.isOpen(uri, clientUri)) {
342
+ this.languageClientShadow.removeOpen(uri, clientUri);
343
+ }
344
+ else {
345
+ this.languageClientShadow.removeAllOpens(uri);
346
+ }
347
+ // Another URI still holds the document, so only this one's buffer goes.
348
+ if (this.languageClientShadow.isOpen(uri)) {
422
349
  return;
423
350
  }
424
351
  }
425
352
  if (!this.__sessions.removeOpen(uri, clientId)) {
426
353
  return;
427
354
  }
428
- const record = this.__documents.get(uri);
429
- record?.clientVersions.delete(clientId);
430
- if (record && cause === 'lost') {
431
- (record.lostClients ??= new Map()).set(clientId, this.services.Clock.stopwatch());
355
+ if (cause === 'lost') {
356
+ this.documentReleaseScheduler.recordLoss(uri, clientId);
432
357
  }
433
358
  const syncedDocument = this.__syncedDocuments.get(uri);
434
359
  if (syncedDocument !== undefined) {
435
360
  this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
436
361
  this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId }));
437
- if (clientId === LANGUAGE_CLIENT_ID) {
438
- // Monaco closed the document; drop the shadow baselined under the URI
439
- // it held. (If this was the last client the whole record is deleted on
440
- // release.)
441
- const droppedUri = this.toLanguageClientUri(event.textDocument.uri);
442
- this.__shadow.invalidate(droppedUri);
443
- this.__pendingPushes.delete(droppedUri);
444
- }
445
- if (!this.__sessions.isOpen(uri)) {
446
- if (cause === 'lost' && this.revertGraceMs > 0) {
362
+ if (!this.isOpenInAnyClient(uri)) {
363
+ if (cause === 'lost') {
447
364
  this.deferRelease(uri);
448
365
  }
449
366
  else {
@@ -453,55 +370,34 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
453
370
  }
454
371
  }
455
372
  /**
456
- * Keep the document, text and all, for the revert grace, then release it.
373
+ * Keep the document, text and all, for the release grace, then release it.
457
374
  * The document stays in the store meanwhile, so a lost client that opens it
458
375
  * again within its own grace attaches to it and finds its unsaved text
459
- * rather than reading disk; see {@link resolvePendingRevert} for any other
376
+ * rather than reading disk; see {@link resolveDeferredRelease} for any other
460
377
  * open.
461
378
  */
462
379
  deferRelease(uri) {
463
- this.log(uri, `No client left; revert deferred for ${this.revertGraceMs} ms (connection lost)`);
464
- const timer = this.services.Clock.setTimer(() => {
465
- this.__sessions.cancelRevert(uri);
466
- if (!this.__sessions.isOpen(uri)) {
380
+ this.documentReleaseScheduler.defer(uri, () => {
381
+ if (!this.isOpenInAnyClient(uri)) {
467
382
  this.releaseDocument(uri);
468
383
  }
469
- }, this.revertGraceMs);
470
- this.__sessions.deferRevert(uri, timer);
471
- }
472
- /**
473
- * Resolve the pending revert of `uri` for an open by `clientId`. An open by a
474
- * client lost from the document within its grace cancels the revert, and
475
- * the client finds its unsaved text. Any other open releases the document
476
- * first, so it opens as a first open does: cancelling for every open hands
477
- * a lost client's unsaved text to whoever opens next, a reloaded page or an
478
- * editor, with nothing marking it unsaved.
479
- */
480
- resolvePendingRevert(uri, clientId) {
481
- const record = this.__documents.get(uri);
482
- if (record) {
483
- this.pruneLostClients(record);
484
- }
485
- const returning = record?.lostClients?.delete(clientId) ?? false;
486
- if (!this.__sessions.isRevertPending(uri)) {
487
- return;
488
- }
489
- this.__sessions.cancelRevert(uri);
490
- if (!returning) {
491
- this.releaseDocument(uri);
384
+ });
385
+ if (this.documentReleaseScheduler.isDeferred(uri)) {
386
+ this.log(uri, `No client left; release deferred for ${this.documentReleaseScheduler.graceMs} ms (connection lost)`);
492
387
  }
493
388
  }
494
- /** Drop the entries of {@link DocumentTrackingRecord.lostClients} whose grace has run out. */
495
- pruneLostClients(record) {
496
- for (const [clientId, sinceLoss] of record.lostClients ?? []) {
497
- if (sinceLoss.elapsedMs >= this.revertGraceMs) {
498
- record.lostClients?.delete(clientId);
499
- }
389
+ /** Resolve a deferred release of `uri` for an open by `clientId`: a release the scheduler decides on runs now. */
390
+ resolveDeferredRelease(uri, clientId) {
391
+ if (this.documentReleaseScheduler.resolveOpen(uri, clientId) === 'release') {
392
+ this.releaseDocument(uri);
500
393
  }
501
394
  }
502
395
  /**
503
- * Drop the document no client has open any more, then announce it on
504
- * {@link onDidCloseLastOpen}, which is what reverts it to disk.
396
+ * Drop the document no client has open any more, announce it on
397
+ * {@link onDidReleaseDocument}, then hand it to the
398
+ * `DocumentReleaseHandler` slot: its listeners, such as the update
399
+ * handler dropping a change it still holds back, act before any build the
400
+ * handler runs.
505
401
  */
506
402
  releaseDocument(uri) {
507
403
  const syncedDocument = this.__syncedDocuments.get(uri);
@@ -509,175 +405,72 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
509
405
  return;
510
406
  }
511
407
  this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
512
- // Persist where the shared version sequence left off (version +
513
- // content hash) so the next open CONTINUES the sequence instead of
514
- // restarting at the reopening client's declared id. Hashed once
515
- // here at release, not on every change.
516
- this.__versionSequences.set(uri, {
517
- version: syncedDocument.version,
518
- contentHash: this.heldTextHash(syncedDocument)
519
- });
520
- if (this.__documents.get(uri)?.dirty === true) {
521
- this.__releasedDirty.set(uri, {});
522
- }
408
+ // The next open continues the sequence instead of restarting at the
409
+ // reopening client's declared id.
410
+ this.textLedger.record(uri, syncedDocument);
411
+ this.textLedger.clearAuthors(uri);
412
+ const cleanAnnouncement = this.dirtyStateTracker.release(uri);
523
413
  this.__syncedDocuments.delete(uri);
524
- // One delete clears every per-URI axis (version history + any staged
525
- // pending content) so a future open with the same URI starts fresh.
526
- this.__documents.delete(uri);
527
- this.lastOpenClosedEmitter.fire(Object.freeze({ uri }));
414
+ this.__openedVersions.delete(uri);
415
+ this.__sessions.forgetClientVersions(uri);
416
+ // A stage is for a first open; one released unconsumed is stale.
417
+ this.__pendingContent.delete(uri);
418
+ this.documentReleaseScheduler.clearLosses(uri);
419
+ this.documentReleasedEmitter.fire(Object.freeze({ uri }));
420
+ this.handOverRelease(this.toReleasedDocument(uri), cleanAnnouncement);
528
421
  }
529
422
  /**
530
- * Rebuild a released document from the file system provider, so the build
531
- * stops carrying the unsaved text of its last client.
532
- *
533
- * The provider decides, for every scheme, by `exists`: a document it can
534
- * serve is rebuilt from its text, and any other — an editor's `untitled:`
535
- * buffer, a file never saved, or one deleted meanwhile — is removed
536
- * from the workspace. A `virtual:` document survives, since the framework's
537
- * provider for that scheme serves it from the index, whatever provider the
538
- * host passes as `context.fileSystemProvider`; an edited one therefore keeps
539
- * its last client's text, and keeping it read-only is the client's job.
540
- *
541
- * The answer is read in the document's disk queue, so the rebuild follows
542
- * any save still queued rather than reverting past it; a file that goes
543
- * after that read is removed when its rebuild finds none.
544
- *
545
- * Whether to revert at all is decided inside the write lock, as its holder:
546
- * decided before waiting for the lock, a client that opens or re-creates the
547
- * document meanwhile would have its text rebuilt over, or the document
548
- * removed. A document some client has open again, or that waits out a new
549
- * grace, is left to that client.
550
- *
551
- * A document released dirty is announced clean, so a watcher is never left
552
- * holding it dirty: once the revert has parsed the file, even if it is then
553
- * cancelled, or else once the build it requests in its place has parsed it,
554
- * as the announcement names the store's text. One the revert or that build
555
- * removed, or that build failed, is announced without text. A reopen before
556
- * that takes the announcement over.
423
+ * Call the `DocumentReleaseHandler` slot, and announce a document released
424
+ * dirty clean once the promise it returns settles, which is after the
425
+ * release event. A failure is logged rather than thrown: the store has let
426
+ * go of the document by now, and a throw would abort the transition that
427
+ * released it, a session's close of its other documents included.
557
428
  */
558
- async revertToDisk(uri) {
559
- const workspace = this.services.workspace;
560
- const target = UriUtils.toUri(uri);
561
- const reopened = () => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri);
562
- const releasedDirty = this.__releasedDirty.get(uri);
563
- const owesFlip = () => releasedDirty !== undefined && this.__releasedDirty.get(uri) === releasedDirty;
564
- const announceClean = (withText = true) => {
565
- if (owesFlip()) {
566
- this.__releasedDirty.delete(uri);
567
- // The sequence of a removed document still names the discarded text.
568
- const text = !withText || workspace.LangiumDocuments.getDocument(target) === undefined ? undefined : this.textState(uri);
569
- this.dirtyChangedEmitter.fire(Object.freeze(text ? { uri, text } : { uri }));
570
- }
571
- };
572
- let parsedFromFile = false;
573
- let parses;
574
- let onDisk;
575
- let cancelled = false;
576
- let stopWaiting;
577
- // Without it, a revert stopped short leaves the root on the released text.
578
- const buildInstead = () => {
579
- void workspace.VersionSyncService.requestRecoveryBuild(target, {
580
- deleted: onDisk === false,
581
- reason: HYDRANIUM_BUILD_REASONS.didClose,
582
- // The update handler dropped the change it held back at the release.
583
- ignoreDeferred: true,
584
- stillNeeded: () => !reopened()
585
- }).then(built => {
586
- if (!built) {
587
- this.tracer.with(uri).error('Build after a revert that stopped short failed; the store keeps the released text');
588
- // No parse or removal is coming.
589
- stopWaiting?.();
590
- announceClean(false);
591
- }
592
- });
593
- };
429
+ handOverRelease(released, cleanAnnouncement) {
430
+ let settled;
594
431
  try {
595
- // Read before the lock: every build and read waits while the lock is
596
- // held, and this read waits on the file's save I/O.
597
- onDisk = await workspace.FileSystemTaskQueue.enqueue(uri, () => workspace.FileSystemProvider.exists(target));
598
- await workspace.WorkspaceManager?.ready;
599
- // Queuing the write cancels the running build, even when it then reverts nothing.
600
- if (reopened()) {
601
- return;
602
- }
603
- await workspace.WorkspaceLock.write(async (token) => {
604
- if (reopened()) {
605
- return;
606
- }
607
- // Observed rather than awaited: a build cancelled after its parse
608
- // returns early, and the build it yields to does not parse again.
609
- parses = workspace.VersionSyncService.onDidRecordModel(document => {
610
- parsedFromFile ||= this.documentKey(document.uri.toString()) === uri;
611
- });
612
- workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
613
- // Cleared once the build ends: a cancelled one throws, and the lock resolves.
614
- cancelled = true;
615
- try {
616
- await (onDisk
617
- ? workspace.DocumentBuilder.update([target], [], token)
618
- : workspace.DocumentBuilder.update([], [target], token));
619
- }
620
- catch (err) {
621
- if (!onDisk || !isFileNotFound(err, target)) {
622
- throw err;
623
- }
624
- // The file went after its existence was read.
625
- this.tracer.with(uri).debug('Revert found no file; removing the document instead');
626
- workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
627
- await workspace.DocumentBuilder.update([], [target], token);
628
- }
629
- cancelled = false;
630
- });
631
- // Cancelled before its parse: the write that cancelled it may build nothing.
632
- if (cancelled && !parsedFromFile && !reopened() && workspace.LangiumDocuments.getDocument(target) !== undefined) {
633
- buildInstead();
634
- }
432
+ // Read here: a lazily built slot whose factory throws throws on this read.
433
+ const handler = this.services.workspace.DocumentReleaseHandler;
434
+ settled =
435
+ handler === undefined
436
+ ? Promise.reject(new Error('no workspace.DocumentReleaseHandler bound'))
437
+ : Promise.resolve(handler.didReleaseDocument(released));
635
438
  }
636
439
  catch (err) {
637
- // A revert that finishes after the LSP peer went away fails its
638
- // diagnostics publish, and one that runs after its workspace was torn
639
- // down finds no file: teardown races, not failed reverts.
640
- if (isConnectionGoneError(err) || isFileNotFound(err, target)) {
641
- this.tracer.with(uri).debug(`Revert on last close skipped: ${err instanceof Error ? err.message : String(err)}`);
642
- return;
440
+ settled = Promise.reject(err);
441
+ }
442
+ // Names the text the build holds at the settle, so it follows anything a
443
+ // release listener did meanwhile: none when its build failed or it no
444
+ // longer has the document, since a removal leaves the record on the
445
+ // discarded text.
446
+ const announceClean = (built) => {
447
+ if (cleanAnnouncement?.isOwed()) {
448
+ const inBuild = built && this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(released.uri)) !== undefined;
449
+ cleanAnnouncement.announce(inBuild ? this.textState(released.uri) : undefined);
643
450
  }
644
- const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
645
- this.tracer.with(uri).error(`Revert on last close dropped. ${detail}`);
646
- // The update handler drops a change still debounced at release, so
647
- // without this build the root stays behind the store's version. A
648
- // document known to have no file is removed, as the revert would have.
649
- if (!reopened()) {
650
- buildInstead();
651
- }
652
- }
653
- finally {
654
- parses?.dispose();
655
- if (parsedFromFile || workspace.LangiumDocuments.getDocument(target) === undefined) {
656
- announceClean();
451
+ };
452
+ settled.then(() => announceClean(true), (err) => {
453
+ // A release skipped at teardown, its peer or its workspace gone, is
454
+ // routine, not a fault to investigate.
455
+ if (isDocumentReleaseSkippedError(err)) {
456
+ this.tracer.with(released.uri).debug(err.message);
657
457
  }
658
- else if (owesFlip()) {
659
- // Cancelled before its parse or failed, the revert left the released
660
- // text in the store: announced now, the clean flip would name it.
661
- stopWaiting = () => {
662
- parsed.dispose();
663
- deleted.dispose();
664
- };
665
- const done = () => {
666
- stopWaiting?.();
667
- announceClean();
668
- };
669
- const parsed = workspace.VersionSyncService.onDidRecordModel(document => {
670
- if (this.documentKey(document.uri.toString()) === uri) {
671
- done();
672
- }
673
- });
674
- const deleted = workspace.DocumentBuilder.onUpdate((_changed, deletedUris) => {
675
- if (deletedUris.some(deletedUri => this.documentKey(deletedUri.toString()) === uri)) {
676
- done();
677
- }
678
- });
458
+ else {
459
+ this.tracer
460
+ .with(released.uri)
461
+ .error(`Release handler failed. ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
679
462
  }
680
- }
463
+ // Left unsettled, a watcher keeps the document dirty though the store answers clean.
464
+ announceClean(false);
465
+ });
466
+ }
467
+ /** `uri` as the `DocumentReleaseHandler` slot receives it. */
468
+ toReleasedDocument(uri) {
469
+ return {
470
+ uri,
471
+ isFor: other => this.documentKey(other) === uri,
472
+ isReclaimed: () => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri)
473
+ };
681
474
  }
682
475
  notifyWillSaveTextDocument(event) {
683
476
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
@@ -728,7 +521,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
728
521
  }
729
522
  // What the file holds, not what the editor meant to write: an editor
730
523
  // that saved older text leaves the document dirty.
731
- this.updateDiskBaseline(uri, onDisk);
524
+ this.setDiskBaseline(uri, onDisk);
732
525
  // An editor that saves and then closes releases the document while the
733
526
  // read is under way; its save is still a save of the text it held.
734
527
  const document = this.__syncedDocuments.get(this.documentKey(uri)) ?? syncedDocument;
@@ -748,7 +541,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
748
541
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
749
542
  if (syncedDocument !== undefined) {
750
543
  if (event.text !== undefined) {
751
- this.updateDiskBaseline(syncedDocument.uri, event.text);
544
+ this.setDiskBaseline(syncedDocument.uri, event.text);
752
545
  }
753
546
  this.announceSave(syncedDocument, clientId);
754
547
  }
@@ -761,86 +554,46 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
761
554
  notifyDidOpenTextDocument(event, clientId = LANGUAGE_CLIENT_ID) {
762
555
  const td = event.textDocument;
763
556
  const uri = this.documentKey(td.uri);
764
- this.resolvePendingRevert(uri, clientId);
557
+ // Before the deferred release is resolved: refused after it, the open
558
+ // would leave a document whose grace it ended with no holder and no timer.
559
+ // A repeat open stays a no-op.
560
+ if (!this.__sessions.isOpenIn(uri, clientId)) {
561
+ this.__sessions.assertCanOpen(clientId);
562
+ }
563
+ this.resolveDeferredRelease(uri, clientId);
564
+ let document = this.__syncedDocuments.get(uri);
565
+ // The client's declared text, never the synced document: staged
566
+ // content this open consumes leaves the two different, and a baseline
567
+ // asserting the client already holds it suppresses the one sync that
568
+ // would deliver it. On an attach, another client may already have
569
+ // changed the synced text, so the baseline is equality-only; a refresh
570
+ // pushing the full text would dirty the file on open.
571
+ if (clientId === LANGUAGE_CLIENT_ID) {
572
+ this.languageClientShadow.addOpen(uri, this.toLanguageClientUri(td.uri), td.version, td.text, document === undefined);
573
+ }
765
574
  if (this.isOpenInClient(uri, clientId)) {
766
- // Already open for this client under this canonical identity. If this is a
767
- // NEW client-facing URI for the same file (a second tab reached via a
768
- // divergent path, e.g. a symlink and its real path), record it so outbound
769
- // edits reach this tab too — but do NOT re-fire open/rebuild; the document
770
- // is already live.
771
- if (clientId === LANGUAGE_CLIENT_ID) {
772
- const clientFacing = this.toLanguageClientUri(td.uri);
773
- const record = this.__documents.get(uri);
774
- if (record && !record.languageClientDocuments?.has(clientFacing)) {
775
- (record.languageClientDocuments ??= new Map()).set(clientFacing, { declaredVersion: td.version });
776
- // This tab's own buffer, NOT the synced text: a second tab is a second
777
- // client model, read from disk, so a server-authored write already
778
- // applied to the first tab leaves it BEHIND the synced document. Keying
779
- // a diff to the synced text here addresses lines this tab does not have
780
- // and drops content it does.
781
- this.__shadow.setOpenedText(clientFacing, td.text);
782
- }
783
- }
575
+ // A repeat open, or the editor's second URI for the file, e.g. a
576
+ // symlink and its real path: the shadow records it so pushes reach it,
577
+ // and nothing re-fires.
784
578
  return;
785
579
  }
786
- let document = this.__syncedDocuments.get(uri);
787
580
  const existingClients = this.__sessions.clientsOf(uri);
788
581
  this.__sessions.addOpen(uri, clientId);
789
- const record = this.trackingFor(uri);
790
- // Baseline the per-client staleness guard at the version id the client
791
- // declared for its own buffer (client-owned per LSP).
792
- record.clientVersions.set(clientId, td.version);
793
- if (clientId === LANGUAGE_CLIENT_ID) {
794
- // Remember the URI Monaco opened under (may differ from the canonical key)
795
- // so outbound applyEditToLanguageClient can address the URI it actually holds.
796
- // A fresh state, since a reopened buffer numbers its versions afresh.
797
- (record.languageClientDocuments ??= new Map()).set(this.toLanguageClientUri(td.uri), { declaredVersion: td.version });
798
- }
582
+ this.__sessions.setClientVersion(uri, clientId, td.version);
799
583
  if (!document) {
800
584
  // Use integrity-staged content if available, otherwise the client-provided (disk) text.
801
585
  const pendingText = this.consumePendingContent(uri);
802
586
  const text = pendingText ?? td.text;
803
587
  const source = pendingText ? ', source=pending' : '';
804
- // The SHARED version is server-assigned: continue the persisted
805
- // sequence — same version when the content is unchanged since the
806
- // last close (so watchers' base versions stay valid), one
807
- // step when it changed (so no stale pointer can coincidentally pass
808
- // the optimistic gate). An open with no sequence yet seeds it from the built root.
809
- const sequence = this.__versionSequences.get(uri);
810
- const version = sequence === undefined
811
- ? this.firstOpenVersion(uri, text, td.version)
812
- : sequence.contentHash === textHash(text)
813
- ? sequence.version
814
- : sequence.version + 1;
588
+ const version = this.textLedger.openingVersion(uri, text) ?? this.builtRootOpeningVersion(uri, text) ?? td.version;
815
589
  this.log(uri, `Open document: Version ${version} by ${this.formatClientId(clientId)} [first client${source}]`);
816
- document = this.configuration.create(uri, td.languageId, version, text);
817
- this.__syncedDocuments.set(uri, document);
818
- this.setAuthor(uri, version, clientId);
590
+ document = this.create(uri, td.languageId, version, text);
591
+ this.commitText(uri, document, clientId);
592
+ this.__openedVersions.set(uri, version);
819
593
  // The opener's text, not the staged content: a session's open read
820
594
  // it from the file, and an editor opened its buffer from there. An
821
595
  // editor that opens a buffer it never saved is taken as clean.
822
- record.diskBaseline = td.text;
823
- if (this.__releasedDirty.delete(uri)) {
824
- record.dirty = true;
825
- }
826
- this.refreshDirty(uri);
827
- if (clientId === LANGUAGE_CLIENT_ID) {
828
- // Baseline the shadow to what Monaco just opened so the next outbound
829
- // applyEditToLanguageClient diffs against the right starting point. Keyed by the
830
- // language-client URI (what Monaco holds), not the canonical document key.
831
- //
832
- // The CLIENT's declared text, never the synced document: staged content
833
- // consumed above leaves the two different, and a baseline asserting the
834
- // client already holds the staged text suppresses the one sync that would
835
- // deliver it. Skipped entirely when a push is already outstanding for this
836
- // URI — an `applyEdit` to a closed file has the client open from disk and
837
- // apply afterwards, so that shadow records what it is about to hold and
838
- // disk text would key the next diff to a buffer nobody has.
839
- const clientFacing = this.toLanguageClientUri(td.uri);
840
- if (!this.__shadow.isTracked(clientFacing)) {
841
- this.__shadow.set(clientFacing, td.text);
842
- }
843
- }
596
+ this.dirtyStateTracker.track(uri, document, td.text);
844
597
  const toFire = Object.freeze({ document, clientId });
845
598
  this.__onDidOpen.fire(toFire);
846
599
  this.__onDidChangeContent.fire(toFire);
@@ -848,14 +601,6 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
848
601
  else {
849
602
  // An additional client attaches to a document already open by another client.
850
603
  this.logClientJoined(uri, clientId, document.version, existingClients);
851
- if (clientId === LANGUAGE_CLIENT_ID) {
852
- // Monaco's own buffer, which on this path is NOT the synced text — another
853
- // client opened the document and may already have changed it. Recorded as
854
- // an equality-only baseline (never a diff one, see `setOpenedText`) so the
855
- // refresh below does not push a full replace of content Monaco already
856
- // holds, which would dirty the file on open.
857
- this.__shadow.setOpenedText(this.toLanguageClientUri(td.uri), td.text);
858
- }
859
604
  this.refreshContent(uri, clientId);
860
605
  }
861
606
  }
@@ -869,52 +614,31 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
869
614
  * and refreshing per attach turns each into a Langium rebuild and dependent
870
615
  * relink cascade.
871
616
  *
872
- * The staleness guard baselines at the SYNCED version, not a declared buffer
873
- * version, because a client arriving this way holds no buffer of its own.
874
- * Unifying the two routes therefore rebaselines a textual client's guard to a
875
- * version it never declared.
876
- *
877
617
  * Returns whether a hold was added — `false` when `uri` is not open,
878
- * `clientId` already holds it, or the document was waiting out the revert
618
+ * `clientId` already holds it, or the document was waiting out the release
879
619
  * grace for other clients and was released instead (see
880
- * {@link resolvePendingRevert}); the caller then opens it anew.
620
+ * {@link resolveDeferredRelease}); the caller then opens it anew.
881
621
  */
882
622
  attachClient(uri, clientId) {
883
623
  const key = this.documentKey(uri);
884
624
  if (!this.__syncedDocuments.has(key) || this.isOpenInClient(key, clientId)) {
885
625
  return false;
886
626
  }
887
- this.resolvePendingRevert(key, clientId);
627
+ // Before the deferred release is resolved, as for an open.
628
+ this.__sessions.assertCanOpen(clientId);
629
+ this.resolveDeferredRelease(key, clientId);
888
630
  const document = this.__syncedDocuments.get(key);
889
631
  if (!document) {
890
632
  return false;
891
633
  }
892
634
  const existingClients = this.__sessions.clientsOf(key);
893
635
  this.__sessions.addOpen(key, clientId);
894
- const record = this.trackingFor(key);
895
- record.clientVersions.set(clientId, document.version);
636
+ // A client arriving this way holds no buffer of its own, so its guard
637
+ // starts at the synced version.
638
+ this.__sessions.setClientVersion(key, clientId, document.version);
896
639
  this.logClientJoined(key, clientId, document.version, existingClients);
897
640
  return true;
898
641
  }
899
- /**
900
- * The built root's recorded version, one on when `text` differs from the root's;
901
- * `declared` when the root records no store version. Seeded from `declared`, a write based
902
- * on the root passes the gate over other text, and the same text looks newer than its model.
903
- * A built root has no sequence only under a `LangiumDocuments` that does not reconcile at registration.
904
- */
905
- firstOpenVersion(uri, text, declared) {
906
- const built = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
907
- if (built === undefined) {
908
- return declared;
909
- }
910
- const ledger = this.services.workspace.ModelLedger;
911
- const root = built.parseResult.value;
912
- const recorded = ledger.versionOf(root);
913
- if (recorded === UNRECORDED_VERSION || recorded === STALE_VERSION) {
914
- return declared;
915
- }
916
- return (ledger.textOf(root) ?? built.textDocument.getText()) === text ? recorded : recorded + 1;
917
- }
918
642
  logClientJoined(uri, clientId, version, existingClients) {
919
643
  this.log(uri, `Attach client: ${this.formatClientId(clientId)} joined existing document (version ${version}, ` +
920
644
  `now open in: ${[...existingClients, clientId].map(id => this.formatClientId(id)).join(', ')})`);
@@ -935,7 +659,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
935
659
  * two URIs for one physical file (a symlink path and its real path) into a
936
660
  * single registration — dedup at the editor layer, not just in
937
661
  * `LangiumDocuments`. The URI the client opened under is preserved separately
938
- * for egress addressing (see {@link DocumentTrackingRecord.languageClientDocuments}). The
662
+ * for egress addressing, by the {@link LanguageClientShadow}. The
939
663
  * policy is always bound (the framework defaults it to
940
664
  * `DefaultDocumentUriPolicy`, where canonical ≡ syntactic normalize).
941
665
  */
@@ -953,17 +677,8 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
953
677
  toLanguageClientUri(uri) {
954
678
  return asLanguageClientUri(UriUtils.normalize(uri));
955
679
  }
956
- /** Get-or-create the per-URI tracking record. `uri` must already be a {@link documentKey}. */
957
- trackingFor(uri) {
958
- let record = this.__documents.get(uri);
959
- if (!record) {
960
- record = { versionAuthors: [], clientVersions: new Map() };
961
- this.__documents.set(uri, record);
962
- }
963
- return record;
964
- }
965
680
  setAuthor(uri, version, author) {
966
- this.trackingFor(this.documentKey(uri)).versionAuthors[version] = author;
681
+ this.textLedger.setAuthor(this.documentKey(uri), version, author);
967
682
  }
968
683
  /**
969
684
  * Resolve the synced `TextDocument` for `uri`. The store keys documents by
@@ -994,7 +709,14 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
994
709
  * content unchanged.
995
710
  */
996
711
  version(uri) {
997
- return this.get(uri)?.version ?? this.__versionSequences.get(this.documentKey(uri))?.version ?? 0;
712
+ return this.get(uri)?.version ?? this.textLedger.recordOf(this.documentKey(uri))?.version ?? 0;
713
+ }
714
+ /**
715
+ * The version the document at `uri` was opened at, which a client's write
716
+ * or an integrity repair steps past; `undefined` while it is not open.
717
+ */
718
+ openedVersion(uri) {
719
+ return this.__openedVersions.get(this.documentKey(uri));
998
720
  }
999
721
  /**
1000
722
  * The text the store holds for `uri`: an open document's, or for a closed
@@ -1005,28 +727,36 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1005
727
  textState(uri) {
1006
728
  const document = this.get(uri);
1007
729
  if (document) {
1008
- return { version: document.version, hash: this.heldTextHash(document), dirty: this.isDirty(uri) };
730
+ return { version: document.version, hash: this.textLedger.hashOf(document), dirty: this.isDirty(uri) };
1009
731
  }
1010
- const sequence = this.__versionSequences.get(this.documentKey(uri));
1011
- return sequence && { version: sequence.version, hash: sequence.contentHash, dirty: false };
732
+ const recorded = this.textLedger.recordOf(this.documentKey(uri));
733
+ return recorded && { version: recorded.version, hash: recorded.hash, dirty: false };
1012
734
  }
1013
735
  /**
1014
- * {@link textHash} of `document`'s text, taken once per version: the store
1015
- * moves a document's version with every change of its text.
736
+ * The built root's recorded version for a first open of `key` with `text`,
737
+ * one on when the text differs from the root's; `undefined` when the root
738
+ * records no store version. Seeded from the opener's declared version
739
+ * instead, a write based on the built root passes the gate over other text,
740
+ * and the same text looks newer than its model. A built root records none
741
+ * only under a `LangiumDocuments` that does not reconcile at registration.
1016
742
  */
1017
- heldTextHash(document) {
1018
- const taken = this.__textHashes.get(document);
1019
- if (taken?.version === document.version) {
1020
- return taken.hash;
743
+ builtRootOpeningVersion(key, text) {
744
+ const built = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(key));
745
+ if (built === undefined) {
746
+ return undefined;
747
+ }
748
+ const ledger = this.services.workspace.ModelLedger;
749
+ const root = built.parseResult.value;
750
+ const recorded = ledger.versionOf(root);
751
+ if (recorded === UNRECORDED_VERSION || recorded === STALE_VERSION) {
752
+ return undefined;
1021
753
  }
1022
- const hash = textHash(document.getText());
1023
- this.__textHashes.set(document, { version: document.version, hash });
1024
- return hash;
754
+ return (ledger.textOf(root) ?? built.textDocument.getText()) === text ? recorded : recorded + 1;
1025
755
  }
1026
756
  /**
1027
757
  * Reconcile the persisted version sequence with content that reached the
1028
758
  * build OUTSIDE the store's write paths — a closed document rebuilt from
1029
- * disk (last-close revert) or replaced by a watched-file change. Steps the
759
+ * disk after its release, or replaced by a watched-file change. Steps the
1030
760
  * sequence iff `text` differs from the sequence's last-known content and
1031
761
  * returns the resulting sequence version so the caller can re-stamp the
1032
762
  * rebuilt document (`VersionSyncService.modelProduced`) —
@@ -1047,19 +777,12 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1047
777
  if (open !== undefined) {
1048
778
  return open.getText() === text ? open.version : undefined;
1049
779
  }
1050
- const hash = textHash(text);
1051
- const sequence = this.__versionSequences.get(key);
1052
- if (sequence === undefined) {
1053
- this.__versionSequences.set(key, { version: 0, contentHash: hash });
1054
- return 0;
780
+ const before = this.textLedger.recordOf(key)?.version;
781
+ const version = this.textLedger.reconcile(key, text);
782
+ if (before !== undefined && version !== before) {
783
+ this.logUri(key, `External content change while closed: sequence stepped to version ${version}`, 'debug');
1055
784
  }
1056
- if (hash === sequence.contentHash) {
1057
- return sequence.version;
1058
- }
1059
- const stepped = { version: sequence.version + 1, contentHash: hash };
1060
- this.__versionSequences.set(key, stepped);
1061
- this.logUri(key, `External content change while closed: sequence stepped to version ${stepped.version}`, 'debug');
1062
- return stepped.version;
785
+ return version;
1063
786
  }
1064
787
  /**
1065
788
  * Commit an integrity repair into the OPEN document for `uri`, returning the
@@ -1106,18 +829,14 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1106
829
  // Reassigned rather than mutated in place: the default configuration
1107
830
  // updates and returns the SAME instance, but an adopter-supplied one may
1108
831
  // return a new object, and the store must end up holding whichever it is.
1109
- const updated = this.configuration.update(document, [{ text: repaired }], document.version + 1);
1110
- this.__syncedDocuments.set(key, updated);
1111
- this.setAuthor(key, updated.version, INTEGRITY_CLIENT_ID);
1112
- this.refreshDirty(key);
832
+ const updated = this.commitChange(key, document, [{ text: repaired }], INTEGRITY_CLIENT_ID).document;
1113
833
  this.log(updated.uri, `Update to version ${updated.version} by ${this.formatClientId(INTEGRITY_CLIENT_ID)} (repair)`);
1114
834
  return { status: 'committed', document: updated };
1115
835
  }
1116
836
  getAuthor(uri, version) {
1117
- const history = this.__documents.get(this.documentKey(uri))?.versionAuthors;
1118
- // Either the requested version, or the latest. `version !== undefined` so we treat 0 correctly.
1119
- const clientId = version !== undefined ? history?.[version] : history?.at(-1);
1120
- if (!clientId && history) {
837
+ const key = this.documentKey(uri);
838
+ const clientId = this.textLedger.authorOf(key, version);
839
+ if (!clientId && this.textLedger.authorOf(key) !== undefined) {
1121
840
  // Only warn when there IS a history but the specific version is missing; no history at all
1122
841
  // means the document was rebuilt internally (e.g. by a project manager), not an error.
1123
842
  this.log(uri, `Could not detect author of version ${version}.`);
@@ -1133,8 +852,10 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1133
852
  * `onDidClose` event fires for the last client, so `isOpen` returns `true`
1134
853
  * during the close event itself. `isOpenInAnyClient` reads the open table
1135
854
  * ({@link __sessions}), which is updated BEFORE the fire, so an `onDidClose`
1136
- * subscriber that finds this `false` knows the last client just closed and a
1137
- * disk re-read / rebuild can proceed.
855
+ * subscriber that finds this `false` knows the last client just closed.
856
+ * What the build keeps is the release's: a subscriber that re-read or
857
+ * rebuilt the document would race the `DocumentReleaseHandler`, and after a
858
+ * lost connection the release waits out its grace.
1138
859
  */
1139
860
  isOpenInAnyClient(uri) {
1140
861
  return this.__sessions.isOpen(this.documentKey(uri));
@@ -1175,12 +896,12 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1175
896
  }
1176
897
  /**
1177
898
  * Fires once a document no client has open is released: at its last close,
1178
- * or, for a lost client's last close, when its revert grace runs out,
1179
- * another client opens it, or it is deleted. The document then reverts to
1180
- * disk.
899
+ * or, for a lost client's last close, when its release grace runs out,
900
+ * another client opens it, or it is deleted, before the
901
+ * `DocumentReleaseHandler` slot is handed the document.
1181
902
  */
1182
- get onDidCloseLastOpen() {
1183
- return this.lastOpenClosedEmitter.event;
903
+ get onDidReleaseDocument() {
904
+ return this.documentReleasedEmitter.event;
1184
905
  }
1185
906
  /**
1186
907
  * Fires for every save the language client reports of a document it has
@@ -1191,18 +912,18 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1191
912
  return this.languageClientSavedEmitter.event;
1192
913
  }
1193
914
  /**
1194
- * Whether `uri` is waiting out the revert grace: its last open closed with a
915
+ * Whether `uri` is waiting out the release grace: its last open closed with a
1195
916
  * lost connection, and it still holds its unsaved text. Such a document is
1196
917
  * open for no client, yet not closed either, so a caller that would persist
1197
918
  * a closed document's text to disk treats it as open.
1198
919
  */
1199
- isRevertPending(uri) {
1200
- return this.__sessions.isRevertPending(this.documentKey(uri));
920
+ isReleaseDeferred(uri) {
921
+ return this.documentReleaseScheduler.isDeferred(this.documentKey(uri));
1201
922
  }
1202
923
  /**
1203
924
  * Whether the store holds `uri` with text that differs from its disk
1204
925
  * baseline: what the server last knew the file to hold. `false` for a URI
1205
- * the store does not hold; a document waiting out the revert grace is still
926
+ * the store does not hold; a document waiting out the release grace is still
1206
927
  * held.
1207
928
  *
1208
929
  * The baseline is the text a first open brought, or what the server wrote,
@@ -1211,30 +932,29 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1211
932
  * must know the file reads it instead.
1212
933
  */
1213
934
  isDirty(uri) {
1214
- return this.__documents.get(this.documentKey(uri))?.dirty ?? false;
935
+ return this.dirtyStateTracker.isDirty(this.documentKey(uri));
1215
936
  }
1216
937
  /**
1217
- * Fires each time the answer of {@link isDirty} changes. A dirty document's
1218
- * release fires once a parse of the file reaches the store, at that text's
1219
- * version, or without text once its revert removed the document or could
1220
- * not rebuild it, though {@link isDirty} answers clean from the release on.
938
+ * Fires each time the answer of {@link isDirty} changes. For a document
939
+ * released dirty it fires once the `DocumentReleaseHandler` slot reports the
940
+ * release settled: at the version of the text the build then holds, or
941
+ * without text when the document is gone or the handler failed, though
942
+ * {@link isDirty} answers clean from the release on.
1221
943
  */
1222
944
  get onDidChangeDirty() {
1223
- return this.dirtyChangedEmitter.event;
945
+ return this.dirtyStateTracker.onDidChangeDirty;
1224
946
  }
1225
947
  /**
1226
948
  * Record that the file behind `uri` holds `text`, or no file at all for
1227
949
  * `undefined`. A no-op for a URI the store does not hold: the next first
1228
950
  * open sets the baseline from its own text.
1229
951
  */
1230
- updateDiskBaseline(uri, text) {
952
+ setDiskBaseline(uri, text) {
1231
953
  const key = this.documentKey(uri);
1232
- const record = this.__documents.get(key);
1233
- if (!record || !this.__syncedDocuments.has(key)) {
1234
- return;
954
+ const document = this.__syncedDocuments.get(key);
955
+ if (document !== undefined) {
956
+ this.dirtyStateTracker.setDiskBaseline(key, document, text);
1235
957
  }
1236
- record.diskBaseline = text;
1237
- this.refreshDirty(key);
1238
958
  }
1239
959
  /**
1240
960
  * Read the file behind `uri` through its disk queue and take it as the
@@ -1253,20 +973,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1253
973
  catch (err) {
1254
974
  this.tracer.with(key).debug(`Disk baseline: the file cannot be read. ${err instanceof Error ? err.message : String(err)}`);
1255
975
  }
1256
- this.updateDiskBaseline(key, onDisk);
1257
- }
1258
- /** Compare the held text of `uri` with its baseline, and announce a changed answer. */
1259
- refreshDirty(uri) {
1260
- const record = this.__documents.get(uri);
1261
- const document = this.__syncedDocuments.get(uri);
1262
- if (!record || !document) {
1263
- return;
1264
- }
1265
- const dirty = document.getText() !== record.diskBaseline;
1266
- if (dirty !== (record.dirty ?? false)) {
1267
- record.dirty = dirty;
1268
- this.dirtyChangedEmitter.fire(Object.freeze({ uri, text: { version: document.version, hash: this.heldTextHash(document), dirty } }));
1269
- }
976
+ this.setDiskBaseline(key, onDisk);
1270
977
  }
1271
978
  /**
1272
979
  * Start a client session under `clientId`. Throws where
@@ -1300,7 +1007,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1300
1007
  }
1301
1008
  /**
1302
1009
  * Close every document the language client has open, as a `didClose` for
1303
- * each would, so each last close reverts. For a host whose editor connection
1010
+ * each would, so each last close releases its document. For a host whose editor connection
1304
1011
  * can end while the process lives on, such as a worker whose port's peer
1305
1012
  * closed: the language client is no session, so nothing else closes them.
1306
1013
  *
@@ -1312,7 +1019,8 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1312
1019
  async closeLanguageClientDocuments() {
1313
1020
  await this.initialBuildFinished();
1314
1021
  for (const uri of this.__sessions.opensOf(LANGUAGE_CLIENT_ID)) {
1315
- this.untrackLanguageClientDocuments(uri);
1022
+ // A close for one URI while others remain keeps the client's hold.
1023
+ this.languageClientShadow.removeAllOpens(uri);
1316
1024
  this.notifyDidCloseTextDocument({ textDocument: { uri } });
1317
1025
  }
1318
1026
  }
@@ -1326,20 +1034,6 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1326
1034
  async initialBuildFinished() {
1327
1035
  await this.services.workspace.WorkspaceManager.workspaceInitialized.catch(() => undefined);
1328
1036
  }
1329
- /**
1330
- * Stop tracking every URI the language client holds the document `key`
1331
- * under, with its shadow and pending pushes. Call before a close by
1332
- * canonical key that means all of them: a close for one URI while others
1333
- * remain drops only that URI's and keeps the client's hold.
1334
- */
1335
- untrackLanguageClientDocuments(key) {
1336
- const languageClientDocuments = this.__documents.get(key)?.languageClientDocuments;
1337
- for (const clientUri of languageClientDocuments?.keys() ?? []) {
1338
- this.__shadow.invalidate(clientUri);
1339
- this.__pendingPushes.delete(clientUri);
1340
- }
1341
- languageClientDocuments?.clear();
1342
- }
1343
1037
  /**
1344
1038
  * The file behind `uri` was deleted: close every open of it except the
1345
1039
  * language client's.
@@ -1368,14 +1062,14 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1368
1062
  */
1369
1063
  delete(uri) {
1370
1064
  const key = this.documentKey((typeof uri === 'object' && 'uri' in uri ? uri.uri : uri).toString());
1371
- this.untrackLanguageClientDocuments(key);
1065
+ this.languageClientShadow.removeAllOpens(key);
1372
1066
  for (const clientId of this.__sessions.clientsOf(key)) {
1373
1067
  this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1374
1068
  }
1375
1069
  // A document waiting out the grace has no client left to close, and is
1376
1070
  // released now as its last close would have released it.
1377
- if (this.__sessions.isRevertPending(key)) {
1378
- this.__sessions.cancelRevert(key);
1071
+ if (this.documentReleaseScheduler.isDeferred(key)) {
1072
+ this.documentReleaseScheduler.cancel(key);
1379
1073
  this.releaseDocument(key);
1380
1074
  }
1381
1075
  super.delete(key);
@@ -1402,13 +1096,12 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1402
1096
  * Only a FIRST open consumes it, so stage only for a URI no client holds
1403
1097
  * ({@link isOpenInAnyClient} is `false`). A URI held only through another
1404
1098
  * head is not closed: an editor attaching to it joins the existing entry and
1405
- * never reads the stage, and the last close discards the stage with the
1406
- * tracking record. An entry lingers only if `workspace/applyEdit` fails and
1407
- * the file is never opened — the memory cost is one serialised string per
1408
- * URI.
1099
+ * never reads the stage, and the release discards it. An entry lingers
1100
+ * only if `workspace/applyEdit` fails and the file is never opened — the
1101
+ * memory cost is one serialised string per URI.
1409
1102
  */
1410
1103
  stagePendingContent(uri, text) {
1411
- this.trackingFor(this.documentKey(uri)).pendingContent = text;
1104
+ this.__pendingContent.set(this.documentKey(uri), text);
1412
1105
  }
1413
1106
  /**
1414
1107
  * Send `newText` to the LSP textual language client (Monaco / VS Code) via
@@ -1427,20 +1120,19 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1427
1120
  *
1428
1121
  * The diff path is apply-verify-safe: the shadow internally checks that
1429
1122
  * `TextDocument.applyEdits(old, edits) === newText` and falls back to a
1430
- * full replace on mismatch (logged via the warn-callback wired in the
1431
- * constructor), so a diff regression becomes log noise, not data loss.
1123
+ * full replace on mismatch, logged, so a diff regression becomes log noise,
1124
+ * not data loss.
1432
1125
  *
1433
1126
  * That safety net verifies the diff against the SHADOW, which is what the
1434
1127
  * client is *believed* to hold — so it cannot see the client's buffer moving
1435
1128
  * underneath a push. A line-keyed edit is position-dependent: if a genuine
1436
- * client keystroke lands between {@link LanguageClientTextShadow.computeEdits}
1437
- * and the client applying, the ranges address the wrong lines and splice the
1438
- * buffer (observed as a duplicated declaration, which the integrity tier then
1129
+ * client keystroke lands between computing the edits and the client
1130
+ * applying them, the ranges address the wrong lines and splice the buffer
1131
+ * (observed as a duplicated declaration, which the integrity tier then
1439
1132
  * "repairs" into a suffixed name and persists). The edit is therefore
1440
- * addressed at the language client's last known version for that URI (see
1441
- * {@link languageClientVersion}) rather than at `null` ("version
1442
- * intentionally unknown"), which is what lets the client
1443
- * reject a push its buffer has outrun. On rejection the shadow is invalidated,
1133
+ * addressed at the language client's last known version for that URI rather
1134
+ * than at `null` ("version intentionally unknown"), which is what lets the
1135
+ * client reject a push its buffer has outrun. On rejection the shadow is invalidated,
1444
1136
  * so the caller's retry is a full-range replace — position-independent, and
1445
1137
  * safe to apply to whatever the client now holds.
1446
1138
  */
@@ -1449,38 +1141,19 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1449
1141
  if (!connection) {
1450
1142
  return undefined;
1451
1143
  }
1452
- // The document is keyed by its canonical identity, but Monaco holds it under
1453
- // the URI(s) it opened. Address each of those language-client URIs — the one
1454
- // R→S translation, kept here at the egress. Usually one; more than one only
1455
- // when the same file was opened under a symlink and its real path. Falls back
1456
- // to the normalized URI when nothing is tracked. The shadow is keyed by each
1457
- // URI, so every diff is against the right baseline.
1458
- const recorded = this.__documents.get(this.documentKey(uri))?.languageClientDocuments;
1459
- const targets = recorded && recorded.size > 0 ? [...recorded.keys()] : [this.toLanguageClientUri(uri)];
1144
+ // The document is keyed by its canonical identity, but the client holds it
1145
+ // under each URI it opened, and each is diffed against its own baseline.
1146
+ const key = this.documentKey(uri);
1460
1147
  let lastResult;
1461
- for (const targetUri of targets) {
1462
- // Read BEFORE computeEdits, which overwrites the baseline and drops
1463
- // the opened snapshot. This is the text the client holds, and
1464
- // therefore the only text its echo of this push can be reconstructed
1465
- // against — whether or not the push below is keyed to it.
1466
- const before = this.__shadow.clientText(targetUri);
1467
- const edits = this.__shadow.computeEdits(targetUri, newText);
1468
- if (edits.length === 0) {
1148
+ for (const clientUri of this.languageClientShadow.pushTargets(key, this.toLanguageClientUri(uri))) {
1149
+ // Prepared per target, after the previous target's reply: prepared up
1150
+ // front, a later target is diffed against what it held before a change
1151
+ // that arrived meanwhile.
1152
+ const push = this.languageClientShadow.preparePush(key, clientUri, newText);
1153
+ if (push === undefined) {
1469
1154
  continue;
1470
1155
  }
1471
- // Record the push for echo correlation BEFORE the RPC — the client's
1472
- // echo can race the applyEdit response. See `__pendingPushes`.
1473
- this.recordPendingPush(targetUri, before, newText);
1474
1156
  try {
1475
- // Version the push only when it is position-DEPENDENT. A full-range
1476
- // replace lands correctly on any buffer, so gating it would turn a
1477
- // stale-by-one version into a refused update for no safety gain — and
1478
- // it is exactly what the caller retries with after a rejection, so
1479
- // gating it there would refuse the recovery too.
1480
- const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri, targetUri);
1481
- // Captured before the await: a reopen replaces the state, so a late
1482
- // reply then writes to the old one instead of the new buffer's.
1483
- const languageClientDocument = recorded?.get(targetUri);
1484
1157
  // A full `ApplyWorkspaceEditParams`, `edit` and all — NOT a bare
1485
1158
  // `WorkspaceEdit` with a `label` beside it. `applyEdit` takes
1486
1159
  // `ApplyWorkspaceEditParams | WorkspaceEdit` and discriminates on
@@ -1490,180 +1163,45 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
1490
1163
  // The union is also what hides it at compile time: excess-property
1491
1164
  // checking admits a property present in EITHER member, so an object
1492
1165
  // matching neither type-checks against the union.
1166
+ const version = push.version ?? UNKNOWN_CLIENT_VERSION;
1493
1167
  const result = await connection.workspace.applyEdit({
1494
1168
  label: options?.label,
1495
1169
  edit: {
1496
- documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1170
+ documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(clientUri, version), push.edits)]
1497
1171
  }
1498
1172
  });
1499
1173
  if (result && result.applied === false) {
1500
- this.__shadow.invalidate(targetUri);
1501
- this.__pendingPushes.delete(targetUri);
1502
- if (version !== UNKNOWN_CLIENT_VERSION) {
1503
- this.tracer
1504
- .with(uri)
1505
- .warn(`Language client refused applyEdit addressed at version ${version} (it last declared version ${languageClientDocument?.declaredVersion})`);
1506
- }
1174
+ push.notifyOutcome('refused');
1507
1175
  }
1508
- else if (result?.applied && version !== UNKNOWN_CLIENT_VERSION && languageClientDocument) {
1509
- // A client steps once per applied edit that changes its buffer. A
1510
- // line edit does while the shadow is right; a full replace may be a
1511
- // no-op the client drops. Left to the echo, a push sent first is
1512
- // refused; advanced after a no-op, one could pass at a version a
1513
- // keystroke reached.
1514
- languageClientDocument.pushedVersion = version + 1;
1176
+ else if (result?.applied) {
1177
+ push.notifyOutcome('applied');
1515
1178
  }
1516
1179
  lastResult = result;
1517
1180
  }
1518
1181
  catch (err) {
1519
- this.__shadow.invalidate(targetUri);
1520
- this.__pendingPushes.delete(targetUri);
1182
+ push.notifyOutcome('failed');
1521
1183
  throw err;
1522
1184
  }
1523
1185
  }
1524
1186
  return lastResult;
1525
1187
  }
1526
1188
  /**
1527
- * The version the LSP textual language client holds `uri` at under
1528
- * `targetUri`, for addressing an outgoing `workspace/applyEdit`.
1529
- *
1530
- * Client version ids are CLIENT-owned per LSP, so this is the id the client
1531
- * itself stamped on its last `didOpen` / `didChange` for `targetUri`, or the
1532
- * one an applied push moved it to ahead of the push's echo
1533
- * ({@link LanguageClientDocumentState}) — never the shared server version,
1534
- * which advances on authored writes the client knows nothing about and would
1535
- * therefore reject every push.
1536
- *
1537
- * Falls back to {@link UNKNOWN_CLIENT_VERSION} when the client has not opened
1538
- * the document under `targetUri`, which is the honest answer. That is also
1539
- * the case in which there is no shadow, so the push is already a
1540
- * position-independent full replace and has nothing to gain from a gate.
1541
- */
1542
- languageClientVersion(uri, targetUri = this.toLanguageClientUri(uri)) {
1543
- const state = this.__documents.get(this.documentKey(uri))?.languageClientDocuments?.get(targetUri);
1544
- if (state === undefined) {
1545
- return UNKNOWN_CLIENT_VERSION;
1546
- }
1547
- return Math.max(state.declaredVersion, state.pushedVersion ?? state.declaredVersion);
1548
- }
1549
- /**
1550
- * Append a push to the in-flight queue for `targetUri`
1551
- * (see {@link __pendingPushes}). Bounded: beyond
1552
- * {@link PENDING_ECHO_CAP} the oldest entry drops with a debug log — an
1553
- * echo that far outstanding means the client is not echoing at all, and
1554
- * an unbounded queue must not become the leak.
1555
- */
1556
- recordPendingPush(targetUri, before, newText) {
1557
- let pending = this.__pendingPushes.get(targetUri);
1558
- if (!pending) {
1559
- pending = [];
1560
- this.__pendingPushes.set(targetUri, pending);
1561
- }
1562
- pending.push({ before, afterHash: textHash(newText) });
1563
- if (pending.length > PENDING_ECHO_CAP) {
1564
- pending.shift();
1565
- this.logUri(targetUri, `Pending-echo queue exceeded ${PENDING_ECHO_CAP} entries; dropped the oldest`, 'debug');
1566
- }
1567
- }
1568
- /**
1569
- * Decide what an incoming language-client change actually is, by
1570
- * reconstructing the client's resulting buffer against the text its ranges
1571
- * address — the pre-push buffer of the OLDEST push still in flight for
1572
- * `clientFacing`, else whatever that client is believed to hold.
1573
- *
1574
- * `undefined` when the client's ranges address the synced text itself —
1575
- * nothing in flight, and no evidence the client holds anything else. That
1576
- * is the ordinary path, and the caller then applies the ranges directly.
1577
- * The two texts part company without a push in flight whenever a client
1578
- * attaches to a document another client has already written: it opened
1579
- * from disk, so applying its ranges to the synced text splices lines they
1580
- * never addressed.
1581
- *
1582
- * **Why the oldest, and why reconstruct at all.** The client applies our
1583
- * pushes in order and echoes each against the buffer it held before that
1584
- * push, so the first echo to arrive belongs to the oldest entry — a FIFO
1585
- * correspondence the queue preserves by consuming from the front. Comparing
1586
- * the reconstruction against every pending hash, not only the oldest, is
1587
- * what recognises an echo that a newer write already superseded: a rapid
1588
- * write sequence can deliver the echo of push N after the store applied
1589
- * push N+1, and everything older is then accounted for too.
1590
- *
1591
- * A reconstruction matching NO pending push means the client's buffer went
1592
- * somewhere we did not send it — it coalesced a keystroke into the echo, or
1593
- * typed before the push landed. That text is authoritative, and the queue
1594
- * drops: the client has stopped being a pure mirror, so no outstanding echo
1595
- * can match again. (A push still in flight at that point will be refused by
1596
- * the client's own version gate, which invalidates the shadow and makes the
1597
- * next sync a position-independent full replace.)
1598
- *
1599
- * Content equality is a sound echo proof because entries live only between a
1600
- * push and its echo — a milliseconds window, never history — and the
1601
- * client's `didChange` stream is ordered, so every buffer state arrives in
1602
- * mutation order. An UNDO returning the buffer to previously-pushed text is
1603
- * therefore never swallowed: the edit that preceded it already emptied the
1604
- * queue, so undo revisits PAST states while the queue holds IN-FLIGHT ones.
1605
- */
1606
- classifyLanguageClientChange(clientFacing, document, changes) {
1607
- const queued = this.__pendingPushes.get(clientFacing);
1608
- const pending = queued?.length ? queued : undefined;
1609
- // The text the client's ranges address: the buffer it held before the
1610
- // oldest push still in flight, else the buffer it is believed to hold.
1611
- const clientText = pending?.[0].before ?? this.__shadow.clientText(clientFacing);
1612
- if (pending === undefined && (clientText === undefined || clientText === document.getText())) {
1613
- return undefined;
1614
- }
1615
- if (pending !== undefined && pending[0].before === undefined && changes.some(change => ContentChange.isIncremental(change))) {
1616
- // A push sent to a client whose buffer was unknown — a first sync, or
1617
- // the recovery push after a rejection. The shadow now holds the text
1618
- // that push MOVES the client to, which is the one text the echo's
1619
- // ranges provably do not address, so the fallback above is a baseline
1620
- // known to be wrong rather than merely unverified. Drop the queue and
1621
- // the shadow: the next sync is then a full replace, which lands on
1622
- // whatever the client holds. A full-text change is exempt because it
1623
- // reconstructs identically against any baseline.
1624
- pending.length = 0;
1625
- this.__shadow.invalidate(clientFacing);
1626
- return { kind: 'unreconstructable' };
1627
- }
1628
- // Through the configured factories, not `TextDocument` directly, so an
1629
- // adopter's custom text-document type governs how the ranges are applied
1630
- // here exactly as it does on the synced document.
1631
- const probe = this.create(clientFacing, document.languageId, 0, clientText ?? document.getText());
1632
- const reconstructed = this.update(probe, changes, 0).getText();
1633
- if (pending !== undefined) {
1634
- const matchIndex = pending.findIndex(push => push.afterHash === textHash(reconstructed));
1635
- if (matchIndex >= 0) {
1636
- pending.splice(0, matchIndex + 1);
1637
- return { kind: 'echo' };
1638
- }
1639
- pending.length = 0;
1640
- }
1641
- return { kind: 'divergent', text: reconstructed };
1642
- }
1643
- /**
1644
- * Explicitly baseline the language-client text shadow for a URI. Useful in
1189
+ * Explicitly baseline the language-client shadow for a URI. Useful in
1645
1190
  * tests and for adopters that need to seed the shadow without going through
1646
1191
  * a `didOpen` event (e.g. after a sideband save). Normal didOpen / didChange
1647
1192
  * paths from the LSP language client already auto-track the shadow.
1648
1193
  */
1649
1194
  setLanguageClientText(uri, text) {
1650
- const clientFacing = this.toLanguageClientUri(uri);
1651
- this.__shadow.set(clientFacing, text);
1652
- // An explicit rebaseline supersedes whatever was in flight.
1653
- this.__pendingPushes.delete(clientFacing);
1195
+ this.languageClientShadow.setClientText(this.toLanguageClientUri(uri), text);
1654
1196
  }
1655
1197
  /** Drop the shadow baseline for a URI; the next applyEditToLanguageClient sends a full replace. */
1656
1198
  invalidateLanguageClientText(uri) {
1657
- const clientFacing = this.toLanguageClientUri(uri);
1658
- this.__shadow.invalidate(clientFacing);
1659
- this.__pendingPushes.delete(clientFacing);
1199
+ this.languageClientShadow.invalidateClientText(this.toLanguageClientUri(uri));
1660
1200
  }
1661
1201
  consumePendingContent(uri) {
1662
- const record = this.__documents.get(this.documentKey(uri));
1663
- const content = record?.pendingContent;
1664
- if (record) {
1665
- record.pendingContent = undefined;
1666
- }
1202
+ const key = this.documentKey(uri);
1203
+ const content = this.__pendingContent.get(key);
1204
+ this.__pendingContent.delete(key);
1667
1205
  return content;
1668
1206
  }
1669
1207
  log(uri, message) {