@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
@@ -37,7 +37,6 @@ import {
37
37
  OptionalVersionedTextDocumentIdentifier,
38
38
  type RequestHandler,
39
39
  type TextDocumentChangeEvent,
40
- TextDocumentContentChangeEvent as ContentChange,
41
40
  TextDocumentEdit,
42
41
  TextDocumentSyncKind,
43
42
  type TextDocumentWillSaveEvent,
@@ -51,21 +50,26 @@ import {
51
50
  type LanguageClientUri,
52
51
  asLanguageClientUri,
53
52
  DisposableCollection,
54
- type Stopwatch,
55
53
  type TextState,
56
54
  type TextVersion,
57
- textHash,
58
- type Tracer,
59
55
  STALE_VERSION,
56
+ type Tracer,
60
57
  UNRECORDED_VERSION
61
58
  } from '@hydranium/protocol';
62
59
  import { type LogNameOptions } from '../langium/diagnostics/logger.js';
63
- import { HYDRANIUM_BUILD_REASONS } from '../langium/document-builder/document-builder.js';
64
- import { isConnectionGoneError } from '../util/connection-liveness.js';
65
60
  import { LANGUAGE_CLIENT_ID } from './client-ids.js';
66
61
  import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
67
62
  import { type ClientSessionClosedEvent, ClientSessionRegistry, type SessionEndCause } from './client-session-registry.js';
68
- import { isFullReplace, LanguageClientTextShadow } from './language-client-text-shadow.js';
63
+ import { DefaultLanguageClientShadow, type LanguageClientChangeVerdict, type LanguageClientShadow } from './language-client-shadow.js';
64
+ import { DefaultTextLedger, type TextLedger } from './text-ledger.js';
65
+ import { isDocumentReleaseSkippedError, type ReleasedDocument } from './document-release-handler.js';
66
+ import { DefaultDocumentReleaseScheduler, type DocumentReleaseScheduler } from './document-release-scheduler.js';
67
+ import {
68
+ type CleanAnnouncement,
69
+ DefaultDirtyStateTracker,
70
+ type DirtyStateTracker,
71
+ type DocumentDirtyChangedEvent
72
+ } from './dirty-state-tracker.js';
69
73
 
70
74
  /**
71
75
  * The LSP spec's "version is intentionally unknown" for an
@@ -93,134 +97,37 @@ export interface HydraniumTextDocumentsOptions<T extends TextDocument = TextDocu
93
97
  readonly configuration?: TextDocumentsConfiguration<T>;
94
98
  /**
95
99
  * How long a document whose last open closed because its client's
96
- * connection was lost keeps its text before it reverts to disk. An open by
97
- * a client lost from the document, within this time of its own loss,
98
- * cancels the revert, so a client that registers again under its id after
100
+ * connection was lost keeps its text before the store releases it. An open
101
+ * by a client lost from the document, within this time of its own loss,
102
+ * cancels the release, so a client that registers again under its id after
99
103
  * a dropped connection finds its unsaved edits. Any other open releases the
100
104
  * document first and then opens it as a first open does, from the text the
101
105
  * opener supplies (an editor's own) or else from the file. Meanwhile the
102
106
  * document counts as open for the integrity service, which therefore writes
103
107
  * none of its unsaved text to disk. A close the client makes itself, or
104
- * ending its session, reverts at once whatever this is.
108
+ * ending its session, releases at once whatever this is.
105
109
  *
106
- * Defaults to 10 s. `0` reverts such a document at once as well, released
107
- * in the close itself rather than on a timer.
110
+ * Defaults to 10 s. `0` releases such a document at once as well, in the
111
+ * close itself rather than on a timer.
108
112
  */
109
- readonly revertGraceMs?: number;
113
+ readonly releaseGraceMs?: number;
110
114
  }
111
115
 
112
116
  /**
113
- * The default of {@link HydraniumTextDocumentsOptions.revertGraceMs}: long
117
+ * The default of {@link HydraniumTextDocumentsOptions.releaseGraceMs}: long
114
118
  * enough for a client whose connection dropped to register again and reopen
115
119
  * its documents, which a data client does as soon as it has a connection.
116
120
  */
117
- const DEFAULT_REVERT_GRACE_MS = 10_000;
121
+ const DEFAULT_RELEASE_GRACE_MS = 10_000;
118
122
 
119
123
  /** Delivered by {@link HydraniumTextDocuments.onDidSaveInLanguageClient}. */
120
124
  export interface LanguageClientSavedEvent {
121
125
  readonly uri: string;
122
126
  }
123
127
 
124
- /** Delivered by {@link HydraniumTextDocuments.onDidCloseLastOpen}. */
125
- export interface LastOpenClosedEvent {
126
- readonly uri: CanonicalUri;
127
- }
128
-
129
- /** Delivered by {@link HydraniumTextDocuments.onDidChangeDirty}. */
130
- export interface DocumentDirtyChangedEvent {
128
+ /** Delivered by {@link HydraniumTextDocuments.onDidReleaseDocument}. */
129
+ export interface DocumentReleasedEvent {
131
130
  readonly uri: CanonicalUri;
132
- /**
133
- * The text the new answer of {@link HydraniumTextDocuments.isDirty} was
134
- * decided on. The answer changes with the text, before any build, so it can
135
- * name text whose model has not been sent yet. Absent when the document no
136
- * longer exists, or when the build that follows its release failed.
137
- */
138
- readonly text?: TextState;
139
- }
140
-
141
- /**
142
- * The language client's open of a document under one URI, from its didOpen to
143
- * its didClose. Each URI is its own editor buffer with its own version counter,
144
- * so a file reached through a symlink and its real path has one of these each.
145
- */
146
- export interface LanguageClientDocumentState {
147
- /** The version the client last declared for this URI. */
148
- declaredVersion: number;
149
- /**
150
- * The version an applied versioned push moved this URI to, ahead of its
151
- * echo. Apart from `declaredVersion`, whose staleness guard would drop that
152
- * echo and strand its pending push.
153
- */
154
- pushedVersion?: number;
155
- }
156
-
157
- /**
158
- * All per-URI client-facing tracking the manager keys by normalized URI,
159
- * collapsed into one record so a URI's full state lives in one place and the
160
- * last-client close clears every axis in a single delete. (Parallel per-axis
161
- * maps are the substrate of a close-on-stale-state desync — one map can be
162
- * cleared while another lingers.)
163
- *
164
- * Two neighbours deliberately stay separate:
165
- * - The inherited `__syncedDocuments` (Langium's parsed `TextDocument` store).
166
- * - {@link LanguageClientTextShadow} (`__shadow`), a self-contained,
167
- * separately-tested diff/apply-verify abstraction that owns its own baseline
168
- * text; folding its storage here would couple a clean utility to this record
169
- * for no real gain.
170
- *
171
- * Returned by the `protected` {@link HydraniumTextDocuments.trackingFor}, so an
172
- * override has to name it. Restating the shape structurally instead compiles
173
- * until a field is added here, and then fails at the adopter rather than at the
174
- * change that caused it.
175
- */
176
- export interface DocumentTrackingRecord {
177
- /** Author of each version, sparse-indexed by the SHARED (server-assigned) version number. */
178
- readonly versionAuthors: string[];
179
- /**
180
- * Last version id each client declared for this document (didOpen baseline,
181
- * advanced by every accepted didChange), for the per-client staleness guard.
182
- * Client version ids are CLIENT-owned per LSP (Monaco numbers its own
183
- * buffer) — they never leak into the shared version sequence, which the
184
- * server assigns (see {@link HydraniumTextDocuments.__versionSequences}).
185
- * The language client's entry is the latest any of its URIs declared; the
186
- * store checks and addresses it per URI, through
187
- * {@link DocumentTrackingRecord.languageClientDocuments}.
188
- */
189
- readonly clientVersions: Map<string, number>;
190
- /** Content staged by integrity rules for a closed document. Consumed on next open. */
191
- pendingContent?: string;
192
- /**
193
- * Each URI the LSP textual language client opened this (canonically-keyed)
194
- * document under, with that open's state. Usually one; more when the same
195
- * file is opened under a symlink path and its real path. These are the
196
- * egress addresses: the document is *keyed* by its canonical identity, but
197
- * Monaco holds it under the URI it opened. The language client holds the
198
- * document while any entry remains.
199
- */
200
- languageClientDocuments?: Map<LanguageClientUri, LanguageClientDocumentState>;
201
- /**
202
- * The clients whose close of this document was caused by a lost connection
203
- * and that have not opened it again, each with a stopwatch started at its
204
- * loss on the store's `Clock`. While the document waits out the revert grace, only an
205
- * open by one of them within its own
206
- * {@link HydraniumTextDocumentsOptions.revertGraceMs} of that loss cancels
207
- * the revert. Every such client counts, not only the last to close: one
208
- * connection's sessions all end lost together, and any of them may reopen
209
- * first. Without the time limit, a client that returns late inherits the
210
- * unsaved text of a holder lost after it. A stopwatch rather than a `now()`
211
- * reading, because a wall-clock step would otherwise expire a claim early or
212
- * revive one past its grace, against a grace timer that the step leaves
213
- * alone. An entry past its grace counts as any other client's and is pruned
214
- * at the next open of the document.
215
- */
216
- lostClients?: Map<string, Stopwatch>;
217
- /**
218
- * The text the server last knew the file to hold, `undefined` for no file.
219
- * Set by the first open and moved by {@link HydraniumTextDocuments.updateDiskBaseline}.
220
- */
221
- diskBaseline?: string;
222
- /** The last answer {@link HydraniumTextDocuments.onDidChangeDirty} announced. */
223
- dirty?: boolean;
224
131
  }
225
132
 
226
133
  /**
@@ -250,29 +157,6 @@ export type RepairCommit<T extends TextDocument> =
250
157
  | { readonly status: 'stale' }
251
158
  | { readonly status: 'not-open' };
252
159
 
253
- /**
254
- * Where a URI's shared version sequence left off while no client holds it —
255
- * started by its first build, stepped by builds of changed text, written at
256
- * last-client close, and consulted at the next open so the sequence CONTINUES
257
- * instead of restarting at whatever version id the opening client declares. One entry
258
- * of {@link HydraniumTextDocuments.__versionSequences} — a `protected` field, so
259
- * a subclass reading the map has to name what it holds.
260
- */
261
- export interface VersionSequence {
262
- /** The shared version of the text last closed or built. */
263
- readonly version: number;
264
- /** {@link textHash} of that text. */
265
- readonly contentHash: string;
266
- }
267
-
268
- /**
269
- * Upper bound on {@link HydraniumTextDocuments.__pendingPushes} entries
270
- * per URI. Echoes normally return within milliseconds and consume their
271
- * entry; a queue this deep means the client stopped echoing — cap instead
272
- * of leaking.
273
- */
274
- const PENDING_ECHO_CAP = 32;
275
-
276
160
  /**
277
161
  * The one field of `vscode-languageserver`'s `Connection` this class has to
278
162
  * reach that its public type does not declare. Named here rather than cast
@@ -283,215 +167,137 @@ interface ConnectionWithTextDocumentSync {
283
167
  }
284
168
 
285
169
  /**
286
- * One text pushed to the LSP textual language client by
287
- * {@link HydraniumTextDocuments.applyEditToLanguageClient} whose echo has not
288
- * come back yet. One entry of {@link HydraniumTextDocuments.__pendingPushes} —
289
- * a `protected` field, so a subclass reading the queue has to name what it holds.
290
- */
291
- export interface PendingLanguageClientPush {
292
- /**
293
- * The text the client held BEFORE this push, and therefore the text its
294
- * echo addresses with its ranges. Wider than the baseline the push's edits
295
- * were diffed against: a full replace is sent with no diff baseline and
296
- * still lands on a buffer the echo is keyed to.
297
- *
298
- * `undefined` only when that buffer is unknown — the client never declared
299
- * one, or a rejection invalidated what was tracked. The echo is then
300
- * reconstructed against the synced text, which is sound only for a
301
- * position-independent (full-text) change.
302
- */
303
- readonly before: string | undefined;
304
- /** {@link textHash} of the text this push moves the client to. */
305
- readonly afterHash: string;
306
- }
307
-
308
- /**
309
- * What an incoming language-client change turns out to be once reconstructed
310
- * against the buffer its ranges address — the return of
311
- * {@link HydraniumTextDocuments.classifyLanguageClientChange}. That method is
312
- * `protected`, so an override has to name every arm it can return.
313
- */
314
- export type LanguageClientChangeOrigin =
315
- /** The client is reporting a text we pushed it. The synced document is already there. */
316
- | { readonly kind: 'echo' }
317
- /**
318
- * The client's buffer holds a text we did not push it — a keystroke that
319
- * raced a push, or an edit to a buffer the store has already been written
320
- * past. The reconstructed text is what it now holds, and is authoritative.
321
- */
322
- | { readonly kind: 'divergent'; readonly text: string }
323
- /**
324
- * The change carries ranges and no known text addresses them, so no
325
- * reconstruction is offered. Adopting one anyway splices the document and
326
- * stores an edit nobody made; dropping costs at most the one keystroke the
327
- * client still holds and the next push contradicts.
328
- */
329
- | { readonly kind: 'unreconstructable' };
330
-
331
- /**
332
- * Whether `err` reports that the file of `target` does not exist, as a Node
333
- * file system and the framework's providers do: code `ENOENT`, with `target`'s
334
- * path. A missing file of another document is a different failure.
335
- */
336
- function isFileNotFound(err: unknown, target: URI): boolean {
337
- if (typeof err !== 'object' || err === null || !('code' in err) || err.code !== 'ENOENT') {
338
- return false;
339
- }
340
- return !('path' in err) || err.path === target.fsPath;
341
- }
342
-
343
- /**
344
- * Multi-client text-document tracking on top of Langium's `NormalizedTextDocuments`.
170
+ * The one text store every head writes to, on top of Langium's
171
+ * `NormalizedTextDocuments`, and the LSP text-sync endpoint: every
172
+ * `textDocument/*` notification and every `workspace/applyEdit` push goes
173
+ * through here.
174
+ *
175
+ * Each open, change, close and save is one synchronous transition over the
176
+ * held document and the collaborators its `create…` methods build. A
177
+ * collaborator that defers its part lets a listener of the transition's event
178
+ * read state from before it. A document no client holds any more is
179
+ * released to the `DocumentReleaseHandler` slot, after
180
+ * {@link HydraniumTextDocumentsOptions.releaseGraceMs} when its last client's
181
+ * connection was lost.
182
+ *
183
+ * Client-declared version ids feed only the per-client staleness guard and
184
+ * never a running shared sequence: the two count different things, and
185
+ * splicing them lets versions drift past base-version gate holders. A URI
186
+ * with no ledger record and no built root starts at its opener's declared id:
187
+ * no version was handed out for it.
345
188
  *
346
- * Adds the framework features used by the integrity, model-server, and GLSP layers:
347
- * - Per-document client membership (multiple clients can attach to the same
348
- * URI) and the client-session table, both kept by {@link __sessions}.
349
- * - A SERVER-OWNED shared version sequence: per-URI, monotonic across
350
- * close/reopen cycles, advancing exactly when the synced content changes.
351
- * Client-declared version ids (Monaco's buffer numbering) feed only a
352
- * per-client staleness guard and never leak into a running sequence —
353
- * the two are different things (an editor's edit-operation counter vs the
354
- * document's content-revision number), and splicing them lets versions drift
355
- * silently past base-version gate holders. A URI with no sequence, and no
356
- * root that records a version, starts at its opener's declared id: no version
357
- * was handed out for it.
358
- * - Version-author history so each edit is attributable to its originating client.
359
- * - The revert to disk once no client has a document open, for every head
360
- * ({@link revertToDisk}), deferred by
361
- * {@link HydraniumTextDocumentsOptions.revertGraceMs} after a lost connection.
362
- * - A disk baseline per open document, and whether its text differs from it
363
- * ({@link isDirty}).
364
- * - Pending-content staging used by the integrity service to thread corrections
365
- * through `workspace/applyEdit` cycles for currently-closed documents.
366
- * - `didOpen` notifications arriving over the LSP connection wait on the
367
- * workspace-ready promise, so a client's first open cannot race workspace
368
- * discovery. Direct {@link notifyDidOpenTextDocument} calls (the non-LSP
369
- * heads) do not pass that gate — their caller owns the ordering.
189
+ * `didOpen` notifications arriving over the LSP connection wait on the
190
+ * workspace-ready promise, so a client's first open cannot race workspace
191
+ * discovery. Direct {@link notifyDidOpenTextDocument} calls (the non-LSP
192
+ * heads) do not pass that gate — their caller owns the ordering.
370
193
  */
371
194
  export class HydraniumTextDocuments<T extends TextDocument = TextDocument> extends NormalizedTextDocuments<T> {
372
- /**
373
- * Per-URI client-facing tracking ({@link DocumentTrackingRecord}), keyed by
374
- * canonical URI. One record per URI, so dropping it clears every axis at
375
- * once. Which client has the document open is kept apart, in
376
- * {@link __sessions}.
377
- */
378
- protected __documents = new Map<CanonicalUri, DocumentTrackingRecord>();
195
+ /** Content staged by integrity rules for a document no client holds, consumed by its first open. */
196
+ protected readonly __pendingContent = new Map<CanonicalUri, string>();
379
197
 
380
198
  /**
381
- * Which client has which document open, and which client ids are registered
382
- * sessions. Every open-state predicate on this class reads it, so an open
199
+ * Which client has which document open, which client ids are registered
200
+ * sessions, and each client's declared version, the staleness guard's
201
+ * baseline. Every open-state predicate on this class reads it, so an open
383
202
  * recorded anywhere else is invisible to the last-close transition.
384
203
  */
385
204
  protected readonly __sessions = new ClientSessionRegistry();
386
205
 
387
- /**
388
- * Per-URI shared-version continuity across close/reopen cycles
389
- * ({@link VersionSequence}), kept for every document a build or a client
390
- * gave the store, and consulted by the next first-client open. A URI
391
- * with none, and no root that records a version, starts at its opener's
392
- * declared version.
393
- * DELIBERATELY outside {@link DocumentTrackingRecord}: that record is deleted on last close, while the version sequence must
394
- * survive it — the shared version is a server-owned, monotonic,
395
- * advances-iff-content-changes counter that never resets while the server
396
- * lives. That invariant is what makes an optimistic base-version gate
397
- * sound: "version unchanged ⇔ content unchanged", with no false conflicts
398
- * from close/reopen version resets and no false passes from a reopened
399
- * sequence coincidentally landing on a stale writer's number.
400
- *
401
- * Never pruned, not even when the file is deleted: a recreated file
402
- * restarting at `0` would let a write based on the deleted text pass. Two
403
- * small values per URI ever built, however often it changes.
404
- */
405
- protected readonly __versionSequences = new Map<CanonicalUri, VersionSequence>();
406
-
407
- /**
408
- * Released documents last announced dirty. Their clean flip waits for the
409
- * revert, so it carries the reverted text's version; a first open before the
410
- * revert takes the entry over, and its own dirty answer decides the flip.
411
- * Each release enters a token of its own, so the revert of an earlier
412
- * release cannot announce a later one clean before that one's revert.
413
- */
414
- protected readonly __releasedDirty = new Map<CanonicalUri, object>();
415
-
416
- /** Per held document, the {@link textHash} of its text at the version it was taken. */
417
- protected readonly __textHashes = new WeakMap<TextDocument, { readonly version: number; readonly hash: string }>();
418
-
419
- /**
420
- * Texts pushed to the LSP textual language client via
421
- * {@link applyEditToLanguageClient} whose echoes have not come back yet
422
- * ({@link PendingLanguageClientPush}), keyed like the shadow by language-client URI.
423
- * Outbound pushes and inbound echoes are uncorrelated on the wire; this
424
- * FIFO is the explicit correlation, and it is what
425
- * {@link classifyLanguageClientChange} reconstructs against.
426
- *
427
- * **Each entry keeps the client's PRE-push text, not only a hash of the
428
- * post-push one.** A hash alone can classify a full-text echo, whose
429
- * application is a no-op either way — and that is all it ever classified,
430
- * because a conforming client echoes INCREMENTAL ranges keyed to its
431
- * previous buffer. Those ranges cannot be applied to the synced text (which
432
- * the authored write already advanced) and cannot be reconstructed without
433
- * the baseline: the line they insert lands twice, validates cleanly, and
434
- * compounds on every later edit.
435
- *
436
- * Lifecycle: entries are consumed by the matching echo (together with any
437
- * older entries it supersedes), and the whole queue drops when the client
438
- * stops being a pure mirror — a divergent change, an `applyEdit`
439
- * failure/rejection (shadow invalidation), a close, or an explicit
440
- * shadow (re)baseline. {@link PENDING_ECHO_CAP} bounds the queue against
441
- * a pathological echo that never arrives; the memory cost until then is
442
- * one pre-push text per in-flight push, for milliseconds.
443
- */
444
- protected readonly __pendingPushes = new Map<LanguageClientUri, PendingLanguageClientPush[]>();
445
-
446
- /**
447
- * Tracked text content per URI for the LSP textual language client (Monaco / VS Code).
448
- * Owned here so {@link applyEditToLanguageClient} can compute minimal `workspace/applyEdit`
449
- * diffs instead of full-document replaces (5–20 s → <500 ms on 20-30 KB YAML diagrams).
450
- *
451
- * Auto-tracked from the multi-client text-document events: open / change / close of
452
- * the language client (re)baseline or invalidate the shadow. Other client ids
453
- * (form editor, GLSP, integrity) do NOT touch the shadow — only what Monaco believes
454
- * it has matters for the diff.
455
- *
456
- * Apply-verify safety net is built in: if the diff doesn't reconstruct `newText`
457
- * exactly, {@link LanguageClientTextShadow.computeEdits} falls back to a full-range
458
- * replace and invokes the `onFallback` callback — a diff regression becomes log
459
- * noise, not a 0-byte save.
460
- *
461
- * Assigned in the constructor body so the `onFallback` callback can capture
462
- * `this.logger` after the parameter-property assignment has run (field
463
- * initializers fire BEFORE parameter-property assignment in TS).
464
- */
465
- protected readonly __shadow: LanguageClientTextShadow;
206
+ /** The version each open document was opened at; see {@link openedVersion}. */
207
+ protected readonly __openedVersions = new Map<CanonicalUri, TextVersion>();
466
208
 
467
209
  protected readonly tracer: Tracer;
468
210
  protected readonly configuration: TextDocumentsConfiguration<T>;
469
- /** See {@link HydraniumTextDocumentsOptions.revertGraceMs}. */
470
- protected readonly revertGraceMs: number;
471
- protected readonly lastOpenClosedEmitter = new Emitter<LastOpenClosedEvent>();
211
+ protected __textLedger: TextLedger | undefined;
212
+ protected __languageClientShadow: LanguageClientShadow | undefined;
213
+ protected __dirtyStateTracker: DirtyStateTracker | undefined;
214
+ protected __documentReleaseScheduler: DocumentReleaseScheduler | undefined;
215
+ protected readonly documentReleasedEmitter = new Emitter<DocumentReleasedEvent>();
472
216
  protected readonly languageClientSavedEmitter = new Emitter<LanguageClientSavedEvent>();
473
- protected readonly dirtyChangedEmitter = new Emitter<DocumentDirtyChangedEvent>();
474
217
 
475
218
  constructor(
476
219
  protected services: ServerSharedServices,
477
- options: HydraniumTextDocumentsOptions<T> = {}
220
+ protected readonly options: HydraniumTextDocumentsOptions<T> = {}
478
221
  ) {
479
222
  const configuration = options.configuration ?? (TextDocument as unknown as TextDocumentsConfiguration<T>);
480
223
  super(configuration);
481
224
  this.configuration = configuration;
482
225
  this.tracer = services.Tracer.for(options.logName ?? 'TextDocuments').trace('instantiated');
483
- this.__shadow = new LanguageClientTextShadow(
484
- (uri, reason) => this.tracer.with(uri).warn(`Diff apply-verify fallback (${reason}) — using full-document replace`),
485
- this
486
- );
487
- this.revertGraceMs = options.revertGraceMs ?? DEFAULT_REVERT_GRACE_MS;
488
- this.onDidCloseLastOpen(event => void this.revertToDisk(event.uri));
489
226
  }
490
227
 
491
- // Re-exposed configuration factories — for framework-internal callers
492
- // (LanguageClientTextShadow's apply-verify probe; IntegrityService.resyncDocument)
493
- // that need to materialise documents outside the canonical didOpen/didChange flow
494
- // and must respect the adopter's custom text-document type.
228
+ // Each collaborator is built on first use, after every constructor has run,
229
+ // so a create method may read its subclass's fields and the other collaborators.
230
+
231
+ protected get textLedger(): TextLedger {
232
+ return (this.__textLedger ??= this.createTextLedger());
233
+ }
234
+
235
+ protected get languageClientShadow(): LanguageClientShadow {
236
+ return (this.__languageClientShadow ??= this.createLanguageClientShadow());
237
+ }
238
+
239
+ protected get dirtyStateTracker(): DirtyStateTracker {
240
+ return (this.__dirtyStateTracker ??= this.createDirtyStateTracker());
241
+ }
242
+
243
+ protected get documentReleaseScheduler(): DocumentReleaseScheduler {
244
+ return (this.__documentReleaseScheduler ??= this.createDocumentReleaseScheduler());
245
+ }
246
+
247
+ protected createTextLedger(): TextLedger {
248
+ return new DefaultTextLedger();
249
+ }
250
+
251
+ protected createLanguageClientShadow(): LanguageClientShadow {
252
+ return new DefaultLanguageClientShadow(this, this.tracer);
253
+ }
254
+
255
+ protected createDirtyStateTracker(): DirtyStateTracker {
256
+ return new DefaultDirtyStateTracker(this.textLedger);
257
+ }
258
+
259
+ protected createDocumentReleaseScheduler(): DocumentReleaseScheduler {
260
+ return new DefaultDocumentReleaseScheduler(this.services.Clock, this.options.releaseGraceMs ?? DEFAULT_RELEASE_GRACE_MS);
261
+ }
262
+
263
+ /** Hold `document` as the text of `key`, a new version authored by `author`. */
264
+ protected commitText(key: CanonicalUri, document: T, author: string): void {
265
+ this.__syncedDocuments.set(key, document);
266
+ this.setAuthor(key, document.version, author);
267
+ this.dirtyStateTracker.refreshDirty(key, document);
268
+ }
269
+
270
+ /**
271
+ * Apply `changes` to `document`, the held text of `key`, and hold the
272
+ * result. The version steps only when the text changes, which is what a
273
+ * base-version gate relies on: unchanged text keeps its version and its
274
+ * author. The new text is known only once the changes are applied, so they
275
+ * go in at a tentative step that an identical result rolls back.
276
+ */
277
+ protected commitChange(
278
+ key: CanonicalUri,
279
+ document: T,
280
+ changes: TextDocumentContentChangeEvent[],
281
+ author: string
282
+ ): { document: T; changed: boolean } {
283
+ const previousText = document.getText();
284
+ const version = document.version;
285
+ let next = this.update(document, changes, version + 1);
286
+ const changed = next.getText() !== previousText;
287
+ if (changed) {
288
+ this.commitText(key, next, author);
289
+ } else {
290
+ // An empty-changes update only re-stamps the version.
291
+ next = this.update(next, [], version);
292
+ this.__syncedDocuments.set(key, next);
293
+ }
294
+ return { document: next, changed };
295
+ }
296
+
297
+ // The configuration's factories. Every document the store makes or changes
298
+ // goes through them, so an override sees each call: the store's own writes,
299
+ // callers outside the didOpen/didChange flow, and the shadow's throwaway
300
+ // probes, at version 0 under a client URI.
495
301
 
496
302
  public create(uri: string, languageId: string, version: number, content: string): T {
497
303
  return this.configuration.create(uri, languageId, version, content);
@@ -607,20 +413,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
607
413
  const uri = this.documentKey(td.uri);
608
414
  let document = this.__syncedDocuments.get(uri);
609
415
  if (document !== undefined) {
610
- // Per-client staleness guard: client version ids are CLIENT-owned per
611
- // LSP (Monaco numbers its own buffer), so an incoming id is compared
612
- // against THAT client's last declared id — never against the shared
613
- // version, which the server assigns and which routinely runs ahead of
614
- // a client's ids (authored ModelService writes advance it without the
615
- // client knowing). Gating on the shared version drops real edits in
616
- // exactly that lag window. A client with no baseline (never opened —
617
- // a protocol anomaly) falls back to the shared-version compare, the
618
- // conservative answer. The language client is checked per URI: each is
619
- // its own buffer, and one URI's higher id would drop the other's edits.
620
- const record = this.trackingFor(uri);
621
- const languageClientDocument =
622
- clientId === LANGUAGE_CLIENT_ID ? record.languageClientDocuments?.get(this.toLanguageClientUri(td.uri)) : undefined;
623
- const lastSeen = languageClientDocument?.declaredVersion ?? record.clientVersions.get(clientId) ?? document.version;
416
+ // Client version ids are the client's own, so compared against what
417
+ // that client declared, never against the shared version: an authored
418
+ // write advances that without the client knowing, and gating on it
419
+ // drops real edits. The editor is checked per URI, since each is its
420
+ // own buffer; its client-wide entry is only the fallback for a URI it
421
+ // never opened, and may name another URI's buffer. A client with no
422
+ // baseline falls back to the shared version.
423
+ const clientUri = this.toLanguageClientUri(td.uri);
424
+ const editor = clientId === LANGUAGE_CLIENT_ID;
425
+ const lastSeen =
426
+ (editor ? this.languageClientShadow.declaredVersion(uri, clientUri) : undefined) ??
427
+ this.__sessions.clientVersionOf(uri, clientId) ??
428
+ document.version;
624
429
  if (lastSeen >= td.version) {
625
430
  // Distinguish "already at this version" (common: an echo from the client that triggered
626
431
  // the update) from "incoming version older than ours" (stale race).
@@ -629,76 +434,46 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
629
434
  this.logUri(uri, `Ignore update by ${this.formatClientId(clientId)}: ${reason}`, 'debug');
630
435
  return;
631
436
  }
632
- record.clientVersions.set(clientId, td.version);
633
- if (languageClientDocument) {
634
- languageClientDocument.declaredVersion = td.version;
635
- }
636
-
637
- // A language-client change is keyed to the buffer that client holds,
638
- // which is the synced text only while the two agree — an authored
639
- // write advances the synced text without the client knowing, and a
640
- // push in flight moves the client without the store knowing.
641
- // Resolve which it is, and against what, before touching anything.
642
- const origin =
643
- clientId === LANGUAGE_CLIENT_ID
644
- ? this.classifyLanguageClientChange(this.toLanguageClientUri(td.uri), document, changes)
645
- : undefined;
646
- if (origin?.kind === 'echo') {
647
- // The client is reporting a text we pushed it, so the synced
648
- // document is ALREADY there and the changes must not be applied a
649
- // second time. Nothing is minted and no rebuild fires. The
650
- // per-client baseline advanced above (the client's ids keep
651
- // counting); the shadow is deliberately NOT touched — it
652
- // optimistically tracks the NEWEST pushed text, and dragging it
653
- // back would make the next outbound diff wrong against what the
654
- // client actually holds.
437
+ this.__sessions.setClientVersion(uri, clientId, td.version);
438
+
439
+ // An editor change is keyed to the buffer the editor holds, which is
440
+ // the synced text only while the two agree — an authored write
441
+ // advances the synced text without the editor knowing, and a push in
442
+ // flight moves the editor without the store knowing.
443
+ const verdict: LanguageClientChangeVerdict = editor
444
+ ? this.languageClientShadow.acceptChange(uri, clientUri, td.version, document, changes)
445
+ : { kind: 'direct' };
446
+ if (verdict.kind === 'echo') {
447
+ // The synced document is already there, so the changes must not be
448
+ // applied a second time: nothing is minted and no rebuild fires. The
449
+ // shadow keeps the newest pushed text, which is what the next
450
+ // outbound diff has to be keyed to.
655
451
  this.logUri(uri, `Skip rebuild: echo of a server-authored push (client version ${td.version})`, 'debug');
656
452
  return;
657
453
  }
658
- if (origin?.kind === 'unreconstructable') {
454
+ if (verdict.kind === 'unreconstructable') {
659
455
  this.tracer.with(uri).warn(`Drop change: no known client buffer for its ranges (client version ${td.version})`);
660
456
  return;
661
457
  }
662
458
 
663
- // The SHARED version advances iff the content actually changes — the
664
- // invariant optimistic base-version gates rely on. The new text is
665
- // only known after applying the (possibly incremental) changes, so
666
- // apply at a tentative +1 and roll the version back on an identical
667
- // result (an empty-changes update only re-stamps the version).
668
- //
669
- // A divergent change is applied as its RECONSTRUCTED text rather than
670
- // as its own ranges: those ranges address the client's own buffer, so
459
+ // A divergent change is applied as its reconstructed text rather than
460
+ // as its own ranges: those ranges address the editor's own buffer, so
671
461
  // applying them here would splice the wrong lines.
672
- const previousText = document.getText();
673
- const sharedVersion = document.version;
674
- document = this.configuration.update(document, origin === undefined ? changes : [{ text: origin.text }], sharedVersion + 1);
675
- const changed = document.getText() !== previousText;
676
- if (!changed) {
677
- document = this.configuration.update(document, [], sharedVersion);
678
- }
679
- this.__syncedDocuments.set(uri, document);
680
- if (changed) {
681
- this.setAuthor(uri, document.version, clientId);
682
- this.refreshDirty(uri);
683
- }
684
- if (clientId === LANGUAGE_CLIENT_ID) {
685
- // Monaco just told us about its new content; record it so the next outbound
686
- // applyEditToLanguageClient diffs against the right baseline. Keyed by the
687
- // language-client URI (what Monaco holds), not the canonical document key.
688
- this.__shadow.set(this.toLanguageClientUri(td.uri), document.getText());
689
- // Content-identical echo: the language client is echoing text we already had
690
- // (e.g. Monaco re-emitting a server-pushed applyEditToLanguageClient). The model is
691
- // unchanged, so skip the rebuild. The per-client baseline + shadow still advanced
692
- // above so future staleness checks and diffs are correct. Restricted to the
693
- // language client: a ModelService-authored change is never skipped.
694
- if (!changed) {
462
+ const committed = this.commitChange(uri, document, verdict.kind === 'direct' ? changes : [{ text: verdict.text }], clientId);
463
+ document = committed.document;
464
+ if (editor) {
465
+ this.languageClientShadow.setClientText(clientUri, document.getText());
466
+ // Content-identical echo: the editor is echoing text we already had.
467
+ // The model is unchanged, so skip the rebuild. Restricted to the
468
+ // editor: a ModelService-authored change is never skipped.
469
+ if (!committed.changed) {
695
470
  this.logUri(uri, `Skip rebuild: content unchanged (echo at client version ${td.version})`, 'debug');
696
471
  return;
697
472
  }
698
473
  }
699
474
  this.log(
700
475
  document.uri,
701
- `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
476
+ `Update to version ${document.version} by ${this.formatClientId(clientId)}${committed.changed ? '' : ' (content unchanged)'}`
702
477
  );
703
478
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
704
479
  }
@@ -723,17 +498,13 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
723
498
  */
724
499
  applyContentChange(uri: DocumentUri, text: string, clientId: string): TextVersion {
725
500
  const key = this.documentKey(uri);
726
- let document = this.__syncedDocuments.get(key);
727
- if (document === undefined) {
501
+ const synced = this.__syncedDocuments.get(key);
502
+ if (synced === undefined) {
728
503
  throw new Error(`Document ${uri} is not open for content changes`);
729
504
  }
730
- const changed = document.getText() !== text;
731
- if (changed) {
732
- document = this.configuration.update(document, [{ text }], document.version + 1);
733
- this.__syncedDocuments.set(key, document);
734
- this.setAuthor(key, document.version, clientId);
735
- this.refreshDirty(key);
736
- }
505
+ // Unchanged text calls no update: a configuration may return a new document for one.
506
+ const { document, changed } =
507
+ synced.getText() === text ? { document: synced, changed: false } : this.commitChange(key, synced, [{ text }], clientId);
737
508
  this.log(
738
509
  document.uri,
739
510
  `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
@@ -744,8 +515,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
744
515
 
745
516
  /**
746
517
  * Close `clientId`'s open of the document. When it was the last open, the
747
- * document is released and reverts to disk, at once or, for a `'lost'`
748
- * close, after {@link HydraniumTextDocumentsOptions.revertGraceMs}.
518
+ * document is released, at once or, for a `'lost'`
519
+ * close, after {@link HydraniumTextDocumentsOptions.releaseGraceMs}.
749
520
  */
750
521
  public notifyDidCloseTextDocument(
751
522
  event: DidCloseTextDocumentParams,
@@ -754,38 +525,30 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
754
525
  ): void {
755
526
  const uri = this.documentKey(event.textDocument.uri);
756
527
  if (clientId === LANGUAGE_CLIENT_ID) {
757
- const clientFacing = this.toLanguageClientUri(event.textDocument.uri);
758
- const languageClientDocuments = this.__documents.get(uri)?.languageClientDocuments;
759
- if (languageClientDocuments?.delete(clientFacing) && languageClientDocuments.size > 0) {
760
- // Another URI still holds the document, so only this one's buffer goes.
761
- this.__shadow.invalidate(clientFacing);
762
- this.__pendingPushes.delete(clientFacing);
528
+ const clientUri = this.toLanguageClientUri(event.textDocument.uri);
529
+ // A close under a URI the editor never opened the document under ends its hold.
530
+ if (this.languageClientShadow.isOpen(uri, clientUri)) {
531
+ this.languageClientShadow.removeOpen(uri, clientUri);
532
+ } else {
533
+ this.languageClientShadow.removeAllOpens(uri);
534
+ }
535
+ // Another URI still holds the document, so only this one's buffer goes.
536
+ if (this.languageClientShadow.isOpen(uri)) {
763
537
  return;
764
538
  }
765
539
  }
766
540
  if (!this.__sessions.removeOpen(uri, clientId)) {
767
541
  return;
768
542
  }
769
- const record = this.__documents.get(uri);
770
- record?.clientVersions.delete(clientId);
771
- if (record && cause === 'lost') {
772
- (record.lostClients ??= new Map()).set(clientId, this.services.Clock.stopwatch());
543
+ if (cause === 'lost') {
544
+ this.documentReleaseScheduler.recordLoss(uri, clientId);
773
545
  }
774
546
  const syncedDocument = this.__syncedDocuments.get(uri);
775
547
  if (syncedDocument !== undefined) {
776
548
  this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
777
549
  this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId }));
778
-
779
- if (clientId === LANGUAGE_CLIENT_ID) {
780
- // Monaco closed the document; drop the shadow baselined under the URI
781
- // it held. (If this was the last client the whole record is deleted on
782
- // release.)
783
- const droppedUri = this.toLanguageClientUri(event.textDocument.uri);
784
- this.__shadow.invalidate(droppedUri);
785
- this.__pendingPushes.delete(droppedUri);
786
- }
787
- if (!this.__sessions.isOpen(uri)) {
788
- if (cause === 'lost' && this.revertGraceMs > 0) {
550
+ if (!this.isOpenInAnyClient(uri)) {
551
+ if (cause === 'lost') {
789
552
  this.deferRelease(uri);
790
553
  } else {
791
554
  this.releaseDocument(uri);
@@ -795,58 +558,36 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
795
558
  }
796
559
 
797
560
  /**
798
- * Keep the document, text and all, for the revert grace, then release it.
561
+ * Keep the document, text and all, for the release grace, then release it.
799
562
  * The document stays in the store meanwhile, so a lost client that opens it
800
563
  * again within its own grace attaches to it and finds its unsaved text
801
- * rather than reading disk; see {@link resolvePendingRevert} for any other
564
+ * rather than reading disk; see {@link resolveDeferredRelease} for any other
802
565
  * open.
803
566
  */
804
567
  protected deferRelease(uri: CanonicalUri): void {
805
- this.log(uri, `No client left; revert deferred for ${this.revertGraceMs} ms (connection lost)`);
806
- const timer = this.services.Clock.setTimer(() => {
807
- this.__sessions.cancelRevert(uri);
808
- if (!this.__sessions.isOpen(uri)) {
568
+ this.documentReleaseScheduler.defer(uri, () => {
569
+ if (!this.isOpenInAnyClient(uri)) {
809
570
  this.releaseDocument(uri);
810
571
  }
811
- }, this.revertGraceMs);
812
- this.__sessions.deferRevert(uri, timer);
813
- }
814
-
815
- /**
816
- * Resolve the pending revert of `uri` for an open by `clientId`. An open by a
817
- * client lost from the document within its grace cancels the revert, and
818
- * the client finds its unsaved text. Any other open releases the document
819
- * first, so it opens as a first open does: cancelling for every open hands
820
- * a lost client's unsaved text to whoever opens next, a reloaded page or an
821
- * editor, with nothing marking it unsaved.
822
- */
823
- protected resolvePendingRevert(uri: CanonicalUri, clientId: string): void {
824
- const record = this.__documents.get(uri);
825
- if (record) {
826
- this.pruneLostClients(record);
827
- }
828
- const returning = record?.lostClients?.delete(clientId) ?? false;
829
- if (!this.__sessions.isRevertPending(uri)) {
830
- return;
831
- }
832
- this.__sessions.cancelRevert(uri);
833
- if (!returning) {
834
- this.releaseDocument(uri);
572
+ });
573
+ if (this.documentReleaseScheduler.isDeferred(uri)) {
574
+ this.log(uri, `No client left; release deferred for ${this.documentReleaseScheduler.graceMs} ms (connection lost)`);
835
575
  }
836
576
  }
837
577
 
838
- /** Drop the entries of {@link DocumentTrackingRecord.lostClients} whose grace has run out. */
839
- protected pruneLostClients(record: DocumentTrackingRecord): void {
840
- for (const [clientId, sinceLoss] of record.lostClients ?? []) {
841
- if (sinceLoss.elapsedMs >= this.revertGraceMs) {
842
- record.lostClients?.delete(clientId);
843
- }
578
+ /** Resolve a deferred release of `uri` for an open by `clientId`: a release the scheduler decides on runs now. */
579
+ protected resolveDeferredRelease(uri: CanonicalUri, clientId: string): void {
580
+ if (this.documentReleaseScheduler.resolveOpen(uri, clientId) === 'release') {
581
+ this.releaseDocument(uri);
844
582
  }
845
583
  }
846
584
 
847
585
  /**
848
- * Drop the document no client has open any more, then announce it on
849
- * {@link onDidCloseLastOpen}, which is what reverts it to disk.
586
+ * Drop the document no client has open any more, announce it on
587
+ * {@link onDidReleaseDocument}, then hand it to the
588
+ * `DocumentReleaseHandler` slot: its listeners, such as the update
589
+ * handler dropping a change it still holds back, act before any build the
590
+ * handler runs.
850
591
  */
851
592
  protected releaseDocument(uri: CanonicalUri): void {
852
593
  const syncedDocument = this.__syncedDocuments.get(uri);
@@ -854,172 +595,75 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
854
595
  return;
855
596
  }
856
597
  this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
857
- // Persist where the shared version sequence left off (version +
858
- // content hash) so the next open CONTINUES the sequence instead of
859
- // restarting at the reopening client's declared id. Hashed once
860
- // here at release, not on every change.
861
- this.__versionSequences.set(uri, {
862
- version: syncedDocument.version,
863
- contentHash: this.heldTextHash(syncedDocument)
864
- });
865
- if (this.__documents.get(uri)?.dirty === true) {
866
- this.__releasedDirty.set(uri, {});
867
- }
598
+ // The next open continues the sequence instead of restarting at the
599
+ // reopening client's declared id.
600
+ this.textLedger.record(uri, syncedDocument);
601
+ this.textLedger.clearAuthors(uri);
602
+ const cleanAnnouncement = this.dirtyStateTracker.release(uri);
868
603
  this.__syncedDocuments.delete(uri);
869
- // One delete clears every per-URI axis (version history + any staged
870
- // pending content) so a future open with the same URI starts fresh.
871
- this.__documents.delete(uri);
872
- this.lastOpenClosedEmitter.fire(Object.freeze({ uri }));
604
+ this.__openedVersions.delete(uri);
605
+ this.__sessions.forgetClientVersions(uri);
606
+ // A stage is for a first open; one released unconsumed is stale.
607
+ this.__pendingContent.delete(uri);
608
+ this.documentReleaseScheduler.clearLosses(uri);
609
+ this.documentReleasedEmitter.fire(Object.freeze({ uri }));
610
+ this.handOverRelease(this.toReleasedDocument(uri), cleanAnnouncement);
873
611
  }
874
612
 
875
613
  /**
876
- * Rebuild a released document from the file system provider, so the build
877
- * stops carrying the unsaved text of its last client.
878
- *
879
- * The provider decides, for every scheme, by `exists`: a document it can
880
- * serve is rebuilt from its text, and any other — an editor's `untitled:`
881
- * buffer, a file never saved, or one deleted meanwhile — is removed
882
- * from the workspace. A `virtual:` document survives, since the framework's
883
- * provider for that scheme serves it from the index, whatever provider the
884
- * host passes as `context.fileSystemProvider`; an edited one therefore keeps
885
- * its last client's text, and keeping it read-only is the client's job.
886
- *
887
- * The answer is read in the document's disk queue, so the rebuild follows
888
- * any save still queued rather than reverting past it; a file that goes
889
- * after that read is removed when its rebuild finds none.
890
- *
891
- * Whether to revert at all is decided inside the write lock, as its holder:
892
- * decided before waiting for the lock, a client that opens or re-creates the
893
- * document meanwhile would have its text rebuilt over, or the document
894
- * removed. A document some client has open again, or that waits out a new
895
- * grace, is left to that client.
896
- *
897
- * A document released dirty is announced clean, so a watcher is never left
898
- * holding it dirty: once the revert has parsed the file, even if it is then
899
- * cancelled, or else once the build it requests in its place has parsed it,
900
- * as the announcement names the store's text. One the revert or that build
901
- * removed, or that build failed, is announced without text. A reopen before
902
- * that takes the announcement over.
614
+ * Call the `DocumentReleaseHandler` slot, and announce a document released
615
+ * dirty clean once the promise it returns settles, which is after the
616
+ * release event. A failure is logged rather than thrown: the store has let
617
+ * go of the document by now, and a throw would abort the transition that
618
+ * released it, a session's close of its other documents included.
903
619
  */
904
- protected async revertToDisk(uri: CanonicalUri): Promise<void> {
905
- const workspace = this.services.workspace;
906
- const target = UriUtils.toUri(uri);
907
- const reopened = (): boolean => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri);
908
- const releasedDirty = this.__releasedDirty.get(uri);
909
- const owesFlip = (): boolean => releasedDirty !== undefined && this.__releasedDirty.get(uri) === releasedDirty;
910
- const announceClean = (withText = true): void => {
911
- if (owesFlip()) {
912
- this.__releasedDirty.delete(uri);
913
- // The sequence of a removed document still names the discarded text.
914
- const text = !withText || workspace.LangiumDocuments.getDocument(target) === undefined ? undefined : this.textState(uri);
915
- this.dirtyChangedEmitter.fire(Object.freeze(text ? { uri, text } : { uri }));
916
- }
917
- };
918
- let parsedFromFile = false;
919
- let parses: Disposable | undefined;
920
- let onDisk: boolean | undefined;
921
- let cancelled = false;
922
- let stopWaiting: (() => void) | undefined;
923
- // Without it, a revert stopped short leaves the root on the released text.
924
- const buildInstead = (): void => {
925
- void workspace.VersionSyncService.requestRecoveryBuild(target, {
926
- deleted: onDisk === false,
927
- reason: HYDRANIUM_BUILD_REASONS.didClose,
928
- // The update handler dropped the change it held back at the release.
929
- ignoreDeferred: true,
930
- stillNeeded: () => !reopened()
931
- }).then(built => {
932
- if (!built) {
933
- this.tracer.with(uri).error('Build after a revert that stopped short failed; the store keeps the released text');
934
- // No parse or removal is coming.
935
- stopWaiting?.();
936
- announceClean(false);
937
- }
938
- });
939
- };
620
+ protected handOverRelease(released: ReleasedDocument, cleanAnnouncement: CleanAnnouncement | undefined): void {
621
+ let settled: Promise<void>;
940
622
  try {
941
- // Read before the lock: every build and read waits while the lock is
942
- // held, and this read waits on the file's save I/O.
943
- onDisk = await workspace.FileSystemTaskQueue.enqueue(uri, () => workspace.FileSystemProvider.exists(target));
944
- await workspace.WorkspaceManager?.ready;
945
- // Queuing the write cancels the running build, even when it then reverts nothing.
946
- if (reopened()) {
947
- return;
948
- }
949
- await workspace.WorkspaceLock.write(async token => {
950
- if (reopened()) {
951
- return;
952
- }
953
- // Observed rather than awaited: a build cancelled after its parse
954
- // returns early, and the build it yields to does not parse again.
955
- parses = workspace.VersionSyncService.onDidRecordModel(document => {
956
- parsedFromFile ||= this.documentKey(document.uri.toString()) === uri;
957
- });
958
- workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
959
- // Cleared once the build ends: a cancelled one throws, and the lock resolves.
960
- cancelled = true;
961
- try {
962
- await (onDisk
963
- ? workspace.DocumentBuilder.update([target], [], token)
964
- : workspace.DocumentBuilder.update([], [target], token));
965
- } catch (err: unknown) {
966
- if (!onDisk || !isFileNotFound(err, target)) {
967
- throw err;
968
- }
969
- // The file went after its existence was read.
970
- this.tracer.with(uri).debug('Revert found no file; removing the document instead');
971
- workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
972
- await workspace.DocumentBuilder.update([], [target], token);
973
- }
974
- cancelled = false;
975
- });
976
- // Cancelled before its parse: the write that cancelled it may build nothing.
977
- if (cancelled && !parsedFromFile && !reopened() && workspace.LangiumDocuments.getDocument(target) !== undefined) {
978
- buildInstead();
979
- }
623
+ // Read here: a lazily built slot whose factory throws throws on this read.
624
+ const handler = this.services.workspace.DocumentReleaseHandler;
625
+ settled =
626
+ handler === undefined
627
+ ? Promise.reject(new Error('no workspace.DocumentReleaseHandler bound'))
628
+ : Promise.resolve(handler.didReleaseDocument(released));
980
629
  } catch (err: unknown) {
981
- // A revert that finishes after the LSP peer went away fails its
982
- // diagnostics publish, and one that runs after its workspace was torn
983
- // down finds no file: teardown races, not failed reverts.
984
- if (isConnectionGoneError(err) || isFileNotFound(err, target)) {
985
- this.tracer.with(uri).debug(`Revert on last close skipped: ${err instanceof Error ? err.message : String(err)}`);
986
- return;
987
- }
988
- const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
989
- this.tracer.with(uri).error(`Revert on last close dropped. ${detail}`);
990
- // The update handler drops a change still debounced at release, so
991
- // without this build the root stays behind the store's version. A
992
- // document known to have no file is removed, as the revert would have.
993
- if (!reopened()) {
994
- buildInstead();
630
+ settled = Promise.reject(err);
631
+ }
632
+ // Names the text the build holds at the settle, so it follows anything a
633
+ // release listener did meanwhile: none when its build failed or it no
634
+ // longer has the document, since a removal leaves the record on the
635
+ // discarded text.
636
+ const announceClean = (built: boolean): void => {
637
+ if (cleanAnnouncement?.isOwed()) {
638
+ const inBuild = built && this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(released.uri)) !== undefined;
639
+ cleanAnnouncement.announce(inBuild ? this.textState(released.uri) : undefined);
995
640
  }
996
- } finally {
997
- parses?.dispose();
998
- if (parsedFromFile || workspace.LangiumDocuments.getDocument(target) === undefined) {
999
- announceClean();
1000
- } else if (owesFlip()) {
1001
- // Cancelled before its parse or failed, the revert left the released
1002
- // text in the store: announced now, the clean flip would name it.
1003
- stopWaiting = (): void => {
1004
- parsed.dispose();
1005
- deleted.dispose();
1006
- };
1007
- const done = (): void => {
1008
- stopWaiting?.();
1009
- announceClean();
1010
- };
1011
- const parsed = workspace.VersionSyncService.onDidRecordModel(document => {
1012
- if (this.documentKey(document.uri.toString()) === uri) {
1013
- done();
1014
- }
1015
- });
1016
- const deleted = workspace.DocumentBuilder.onUpdate((_changed, deletedUris) => {
1017
- if (deletedUris.some(deletedUri => this.documentKey(deletedUri.toString()) === uri)) {
1018
- done();
1019
- }
1020
- });
641
+ };
642
+ settled.then(
643
+ () => announceClean(true),
644
+ (err: unknown) => {
645
+ // A release skipped at teardown, its peer or its workspace gone, is
646
+ // routine, not a fault to investigate.
647
+ if (isDocumentReleaseSkippedError(err)) {
648
+ this.tracer.with(released.uri).debug(err.message);
649
+ } else {
650
+ this.tracer
651
+ .with(released.uri)
652
+ .error(`Release handler failed. ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
653
+ }
654
+ // Left unsettled, a watcher keeps the document dirty though the store answers clean.
655
+ announceClean(false);
1021
656
  }
1022
- }
657
+ );
658
+ }
659
+
660
+ /** `uri` as the `DocumentReleaseHandler` slot receives it. */
661
+ protected toReleasedDocument(uri: CanonicalUri): ReleasedDocument {
662
+ return {
663
+ uri,
664
+ isFor: other => this.documentKey(other) === uri,
665
+ isReclaimed: () => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri)
666
+ };
1023
667
  }
1024
668
 
1025
669
  public notifyWillSaveTextDocument(event: WillSaveTextDocumentParams): void {
@@ -1076,7 +720,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1076
720
  }
1077
721
  // What the file holds, not what the editor meant to write: an editor
1078
722
  // that saved older text leaves the document dirty.
1079
- this.updateDiskBaseline(uri, onDisk);
723
+ this.setDiskBaseline(uri, onDisk);
1080
724
  // An editor that saves and then closes releases the document while the
1081
725
  // read is under way; its save is still a save of the text it held.
1082
726
  const document = this.__syncedDocuments.get(this.documentKey(uri)) ?? syncedDocument;
@@ -1097,7 +741,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1097
741
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
1098
742
  if (syncedDocument !== undefined) {
1099
743
  if (event.text !== undefined) {
1100
- this.updateDiskBaseline(syncedDocument.uri, event.text);
744
+ this.setDiskBaseline(syncedDocument.uri, event.text);
1101
745
  }
1102
746
  this.announceSave(syncedDocument, clientId);
1103
747
  }
@@ -1112,101 +756,52 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1112
756
  public notifyDidOpenTextDocument(event: DidOpenTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
1113
757
  const td = event.textDocument;
1114
758
  const uri = this.documentKey(td.uri);
1115
- this.resolvePendingRevert(uri, clientId);
759
+ // Before the deferred release is resolved: refused after it, the open
760
+ // would leave a document whose grace it ended with no holder and no timer.
761
+ // A repeat open stays a no-op.
762
+ if (!this.__sessions.isOpenIn(uri, clientId)) {
763
+ this.__sessions.assertCanOpen(clientId);
764
+ }
765
+ this.resolveDeferredRelease(uri, clientId);
766
+ let document = this.__syncedDocuments.get(uri);
767
+ // The client's declared text, never the synced document: staged
768
+ // content this open consumes leaves the two different, and a baseline
769
+ // asserting the client already holds it suppresses the one sync that
770
+ // would deliver it. On an attach, another client may already have
771
+ // changed the synced text, so the baseline is equality-only; a refresh
772
+ // pushing the full text would dirty the file on open.
773
+ if (clientId === LANGUAGE_CLIENT_ID) {
774
+ this.languageClientShadow.addOpen(uri, this.toLanguageClientUri(td.uri), td.version, td.text, document === undefined);
775
+ }
1116
776
  if (this.isOpenInClient(uri, clientId)) {
1117
- // Already open for this client under this canonical identity. If this is a
1118
- // NEW client-facing URI for the same file (a second tab reached via a
1119
- // divergent path, e.g. a symlink and its real path), record it so outbound
1120
- // edits reach this tab too — but do NOT re-fire open/rebuild; the document
1121
- // is already live.
1122
- if (clientId === LANGUAGE_CLIENT_ID) {
1123
- const clientFacing = this.toLanguageClientUri(td.uri);
1124
- const record = this.__documents.get(uri);
1125
- if (record && !record.languageClientDocuments?.has(clientFacing)) {
1126
- (record.languageClientDocuments ??= new Map()).set(clientFacing, { declaredVersion: td.version });
1127
- // This tab's own buffer, NOT the synced text: a second tab is a second
1128
- // client model, read from disk, so a server-authored write already
1129
- // applied to the first tab leaves it BEHIND the synced document. Keying
1130
- // a diff to the synced text here addresses lines this tab does not have
1131
- // and drops content it does.
1132
- this.__shadow.setOpenedText(clientFacing, td.text);
1133
- }
1134
- }
777
+ // A repeat open, or the editor's second URI for the file, e.g. a
778
+ // symlink and its real path: the shadow records it so pushes reach it,
779
+ // and nothing re-fires.
1135
780
  return;
1136
781
  }
1137
- let document = this.__syncedDocuments.get(uri);
1138
782
  const existingClients = this.__sessions.clientsOf(uri);
1139
783
  this.__sessions.addOpen(uri, clientId);
1140
- const record = this.trackingFor(uri);
1141
- // Baseline the per-client staleness guard at the version id the client
1142
- // declared for its own buffer (client-owned per LSP).
1143
- record.clientVersions.set(clientId, td.version);
1144
- if (clientId === LANGUAGE_CLIENT_ID) {
1145
- // Remember the URI Monaco opened under (may differ from the canonical key)
1146
- // so outbound applyEditToLanguageClient can address the URI it actually holds.
1147
- // A fresh state, since a reopened buffer numbers its versions afresh.
1148
- (record.languageClientDocuments ??= new Map()).set(this.toLanguageClientUri(td.uri), { declaredVersion: td.version });
1149
- }
784
+ this.__sessions.setClientVersion(uri, clientId, td.version);
1150
785
  if (!document) {
1151
786
  // Use integrity-staged content if available, otherwise the client-provided (disk) text.
1152
787
  const pendingText = this.consumePendingContent(uri);
1153
788
  const text = pendingText ?? td.text;
1154
789
  const source = pendingText ? ', source=pending' : '';
1155
- // The SHARED version is server-assigned: continue the persisted
1156
- // sequence — same version when the content is unchanged since the
1157
- // last close (so watchers' base versions stay valid), one
1158
- // step when it changed (so no stale pointer can coincidentally pass
1159
- // the optimistic gate). An open with no sequence yet seeds it from the built root.
1160
- const sequence = this.__versionSequences.get(uri);
1161
- const version =
1162
- sequence === undefined
1163
- ? this.firstOpenVersion(uri, text, td.version)
1164
- : sequence.contentHash === textHash(text)
1165
- ? sequence.version
1166
- : sequence.version + 1;
790
+ const version = this.textLedger.openingVersion(uri, text) ?? this.builtRootOpeningVersion(uri, text) ?? td.version;
1167
791
  this.log(uri, `Open document: Version ${version} by ${this.formatClientId(clientId)} [first client${source}]`);
1168
- document = this.configuration.create(uri, td.languageId, version, text);
1169
- this.__syncedDocuments.set(uri, document);
1170
- this.setAuthor(uri, version, clientId);
792
+ document = this.create(uri, td.languageId, version, text);
793
+ this.commitText(uri, document, clientId);
794
+ this.__openedVersions.set(uri, version);
1171
795
  // The opener's text, not the staged content: a session's open read
1172
796
  // it from the file, and an editor opened its buffer from there. An
1173
797
  // editor that opens a buffer it never saved is taken as clean.
1174
- record.diskBaseline = td.text;
1175
- if (this.__releasedDirty.delete(uri)) {
1176
- record.dirty = true;
1177
- }
1178
- this.refreshDirty(uri);
1179
- if (clientId === LANGUAGE_CLIENT_ID) {
1180
- // Baseline the shadow to what Monaco just opened so the next outbound
1181
- // applyEditToLanguageClient diffs against the right starting point. Keyed by the
1182
- // language-client URI (what Monaco holds), not the canonical document key.
1183
- //
1184
- // The CLIENT's declared text, never the synced document: staged content
1185
- // consumed above leaves the two different, and a baseline asserting the
1186
- // client already holds the staged text suppresses the one sync that would
1187
- // deliver it. Skipped entirely when a push is already outstanding for this
1188
- // URI — an `applyEdit` to a closed file has the client open from disk and
1189
- // apply afterwards, so that shadow records what it is about to hold and
1190
- // disk text would key the next diff to a buffer nobody has.
1191
- const clientFacing = this.toLanguageClientUri(td.uri);
1192
- if (!this.__shadow.isTracked(clientFacing)) {
1193
- this.__shadow.set(clientFacing, td.text);
1194
- }
1195
- }
798
+ this.dirtyStateTracker.track(uri, document, td.text);
1196
799
  const toFire = Object.freeze({ document, clientId });
1197
800
  this.__onDidOpen.fire(toFire);
1198
801
  this.__onDidChangeContent.fire(toFire);
1199
802
  } else {
1200
803
  // An additional client attaches to a document already open by another client.
1201
804
  this.logClientJoined(uri, clientId, document.version, existingClients);
1202
- if (clientId === LANGUAGE_CLIENT_ID) {
1203
- // Monaco's own buffer, which on this path is NOT the synced text — another
1204
- // client opened the document and may already have changed it. Recorded as
1205
- // an equality-only baseline (never a diff one, see `setOpenedText`) so the
1206
- // refresh below does not push a full replace of content Monaco already
1207
- // holds, which would dirty the file on open.
1208
- this.__shadow.setOpenedText(this.toLanguageClientUri(td.uri), td.text);
1209
- }
1210
805
  this.refreshContent(uri, clientId);
1211
806
  }
1212
807
  }
@@ -1221,54 +816,32 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1221
816
  * and refreshing per attach turns each into a Langium rebuild and dependent
1222
817
  * relink cascade.
1223
818
  *
1224
- * The staleness guard baselines at the SYNCED version, not a declared buffer
1225
- * version, because a client arriving this way holds no buffer of its own.
1226
- * Unifying the two routes therefore rebaselines a textual client's guard to a
1227
- * version it never declared.
1228
- *
1229
819
  * Returns whether a hold was added — `false` when `uri` is not open,
1230
- * `clientId` already holds it, or the document was waiting out the revert
820
+ * `clientId` already holds it, or the document was waiting out the release
1231
821
  * grace for other clients and was released instead (see
1232
- * {@link resolvePendingRevert}); the caller then opens it anew.
822
+ * {@link resolveDeferredRelease}); the caller then opens it anew.
1233
823
  */
1234
824
  attachClient(uri: DocumentUri, clientId: string): boolean {
1235
825
  const key = this.documentKey(uri);
1236
826
  if (!this.__syncedDocuments.has(key) || this.isOpenInClient(key, clientId)) {
1237
827
  return false;
1238
828
  }
1239
- this.resolvePendingRevert(key, clientId);
829
+ // Before the deferred release is resolved, as for an open.
830
+ this.__sessions.assertCanOpen(clientId);
831
+ this.resolveDeferredRelease(key, clientId);
1240
832
  const document = this.__syncedDocuments.get(key);
1241
833
  if (!document) {
1242
834
  return false;
1243
835
  }
1244
836
  const existingClients = this.__sessions.clientsOf(key);
1245
837
  this.__sessions.addOpen(key, clientId);
1246
- const record = this.trackingFor(key);
1247
- record.clientVersions.set(clientId, document.version);
838
+ // A client arriving this way holds no buffer of its own, so its guard
839
+ // starts at the synced version.
840
+ this.__sessions.setClientVersion(key, clientId, document.version);
1248
841
  this.logClientJoined(key, clientId, document.version, existingClients);
1249
842
  return true;
1250
843
  }
1251
844
 
1252
- /**
1253
- * The built root's recorded version, one on when `text` differs from the root's;
1254
- * `declared` when the root records no store version. Seeded from `declared`, a write based
1255
- * on the root passes the gate over other text, and the same text looks newer than its model.
1256
- * A built root has no sequence only under a `LangiumDocuments` that does not reconcile at registration.
1257
- */
1258
- protected firstOpenVersion(uri: CanonicalUri, text: string, declared: number): number {
1259
- const built = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
1260
- if (built === undefined) {
1261
- return declared;
1262
- }
1263
- const ledger = this.services.workspace.ModelLedger;
1264
- const root = built.parseResult.value;
1265
- const recorded = ledger.versionOf(root);
1266
- if (recorded === UNRECORDED_VERSION || recorded === STALE_VERSION) {
1267
- return declared;
1268
- }
1269
- return (ledger.textOf(root) ?? built.textDocument.getText()) === text ? recorded : recorded + 1;
1270
- }
1271
-
1272
845
  protected logClientJoined(uri: DocumentUri, clientId: string, version: number, existingClients: readonly string[]): void {
1273
846
  this.log(
1274
847
  uri,
@@ -1297,7 +870,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1297
870
  * two URIs for one physical file (a symlink path and its real path) into a
1298
871
  * single registration — dedup at the editor layer, not just in
1299
872
  * `LangiumDocuments`. The URI the client opened under is preserved separately
1300
- * for egress addressing (see {@link DocumentTrackingRecord.languageClientDocuments}). The
873
+ * for egress addressing, by the {@link LanguageClientShadow}. The
1301
874
  * policy is always bound (the framework defaults it to
1302
875
  * `DefaultDocumentUriPolicy`, where canonical ≡ syntactic normalize).
1303
876
  */
@@ -1317,18 +890,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1317
890
  return asLanguageClientUri(UriUtils.normalize(uri));
1318
891
  }
1319
892
 
1320
- /** Get-or-create the per-URI tracking record. `uri` must already be a {@link documentKey}. */
1321
- protected trackingFor(uri: CanonicalUri): DocumentTrackingRecord {
1322
- let record = this.__documents.get(uri);
1323
- if (!record) {
1324
- record = { versionAuthors: [], clientVersions: new Map() };
1325
- this.__documents.set(uri, record);
1326
- }
1327
- return record;
1328
- }
1329
-
1330
893
  setAuthor(uri: DocumentUri, version: number, author: string): void {
1331
- this.trackingFor(this.documentKey(uri)).versionAuthors[version] = author;
894
+ this.textLedger.setAuthor(this.documentKey(uri), version, author);
1332
895
  }
1333
896
 
1334
897
  /**
@@ -1361,7 +924,15 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1361
924
  * content unchanged.
1362
925
  */
1363
926
  version(uri: DocumentUri): TextVersion {
1364
- return this.get(uri)?.version ?? this.__versionSequences.get(this.documentKey(uri))?.version ?? 0;
927
+ return this.get(uri)?.version ?? this.textLedger.recordOf(this.documentKey(uri))?.version ?? 0;
928
+ }
929
+
930
+ /**
931
+ * The version the document at `uri` was opened at, which a client's write
932
+ * or an integrity repair steps past; `undefined` while it is not open.
933
+ */
934
+ openedVersion(uri: DocumentUri): TextVersion | undefined {
935
+ return this.__openedVersions.get(this.documentKey(uri));
1365
936
  }
1366
937
 
1367
938
  /**
@@ -1373,30 +944,38 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1373
944
  textState(uri: DocumentUri): TextState | undefined {
1374
945
  const document = this.get(uri);
1375
946
  if (document) {
1376
- return { version: document.version, hash: this.heldTextHash(document), dirty: this.isDirty(uri) };
947
+ return { version: document.version, hash: this.textLedger.hashOf(document), dirty: this.isDirty(uri) };
1377
948
  }
1378
- const sequence = this.__versionSequences.get(this.documentKey(uri));
1379
- return sequence && { version: sequence.version, hash: sequence.contentHash, dirty: false };
949
+ const recorded = this.textLedger.recordOf(this.documentKey(uri));
950
+ return recorded && { version: recorded.version, hash: recorded.hash, dirty: false };
1380
951
  }
1381
952
 
1382
953
  /**
1383
- * {@link textHash} of `document`'s text, taken once per version: the store
1384
- * moves a document's version with every change of its text.
954
+ * The built root's recorded version for a first open of `key` with `text`,
955
+ * one on when the text differs from the root's; `undefined` when the root
956
+ * records no store version. Seeded from the opener's declared version
957
+ * instead, a write based on the built root passes the gate over other text,
958
+ * and the same text looks newer than its model. A built root records none
959
+ * only under a `LangiumDocuments` that does not reconcile at registration.
1385
960
  */
1386
- protected heldTextHash(document: T): string {
1387
- const taken = this.__textHashes.get(document);
1388
- if (taken?.version === document.version) {
1389
- return taken.hash;
961
+ protected builtRootOpeningVersion(key: CanonicalUri, text: string): TextVersion | undefined {
962
+ const built = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(key));
963
+ if (built === undefined) {
964
+ return undefined;
1390
965
  }
1391
- const hash = textHash(document.getText());
1392
- this.__textHashes.set(document, { version: document.version, hash });
1393
- return hash;
966
+ const ledger = this.services.workspace.ModelLedger;
967
+ const root = built.parseResult.value;
968
+ const recorded = ledger.versionOf(root);
969
+ if (recorded === UNRECORDED_VERSION || recorded === STALE_VERSION) {
970
+ return undefined;
971
+ }
972
+ return (ledger.textOf(root) ?? built.textDocument.getText()) === text ? recorded : recorded + 1;
1394
973
  }
1395
974
 
1396
975
  /**
1397
976
  * Reconcile the persisted version sequence with content that reached the
1398
977
  * build OUTSIDE the store's write paths — a closed document rebuilt from
1399
- * disk (last-close revert) or replaced by a watched-file change. Steps the
978
+ * disk after its release, or replaced by a watched-file change. Steps the
1400
979
  * sequence iff `text` differs from the sequence's last-known content and
1401
980
  * returns the resulting sequence version so the caller can re-stamp the
1402
981
  * rebuilt document (`VersionSyncService.modelProduced`) —
@@ -1417,19 +996,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1417
996
  if (open !== undefined) {
1418
997
  return open.getText() === text ? open.version : undefined;
1419
998
  }
1420
- const hash = textHash(text);
1421
- const sequence = this.__versionSequences.get(key);
1422
- if (sequence === undefined) {
1423
- this.__versionSequences.set(key, { version: 0, contentHash: hash });
1424
- return 0;
1425
- }
1426
- if (hash === sequence.contentHash) {
1427
- return sequence.version;
999
+ const before = this.textLedger.recordOf(key)?.version;
1000
+ const version = this.textLedger.reconcile(key, text);
1001
+ if (before !== undefined && version !== before) {
1002
+ this.logUri(key, `External content change while closed: sequence stepped to version ${version}`, 'debug');
1428
1003
  }
1429
- const stepped: VersionSequence = { version: sequence.version + 1, contentHash: hash };
1430
- this.__versionSequences.set(key, stepped);
1431
- this.logUri(key, `External content change while closed: sequence stepped to version ${stepped.version}`, 'debug');
1432
- return stepped.version;
1004
+ return version;
1433
1005
  }
1434
1006
 
1435
1007
  /**
@@ -1477,19 +1049,15 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1477
1049
  // Reassigned rather than mutated in place: the default configuration
1478
1050
  // updates and returns the SAME instance, but an adopter-supplied one may
1479
1051
  // return a new object, and the store must end up holding whichever it is.
1480
- const updated = this.configuration.update(document, [{ text: repaired }], document.version + 1);
1481
- this.__syncedDocuments.set(key, updated);
1482
- this.setAuthor(key, updated.version, INTEGRITY_CLIENT_ID);
1483
- this.refreshDirty(key);
1052
+ const updated = this.commitChange(key, document, [{ text: repaired }], INTEGRITY_CLIENT_ID).document;
1484
1053
  this.log(updated.uri, `Update to version ${updated.version} by ${this.formatClientId(INTEGRITY_CLIENT_ID)} (repair)`);
1485
1054
  return { status: 'committed', document: updated };
1486
1055
  }
1487
1056
 
1488
1057
  getAuthor(uri: DocumentUri, version?: number): string | undefined {
1489
- const history = this.__documents.get(this.documentKey(uri))?.versionAuthors;
1490
- // Either the requested version, or the latest. `version !== undefined` so we treat 0 correctly.
1491
- const clientId = version !== undefined ? history?.[version] : history?.at(-1);
1492
- if (!clientId && history) {
1058
+ const key = this.documentKey(uri);
1059
+ const clientId = this.textLedger.authorOf(key, version);
1060
+ if (!clientId && this.textLedger.authorOf(key) !== undefined) {
1493
1061
  // Only warn when there IS a history but the specific version is missing; no history at all
1494
1062
  // means the document was rebuilt internally (e.g. by a project manager), not an error.
1495
1063
  this.log(uri, `Could not detect author of version ${version}.`);
@@ -1507,8 +1075,10 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1507
1075
  * `onDidClose` event fires for the last client, so `isOpen` returns `true`
1508
1076
  * during the close event itself. `isOpenInAnyClient` reads the open table
1509
1077
  * ({@link __sessions}), which is updated BEFORE the fire, so an `onDidClose`
1510
- * subscriber that finds this `false` knows the last client just closed and a
1511
- * disk re-read / rebuild can proceed.
1078
+ * subscriber that finds this `false` knows the last client just closed.
1079
+ * What the build keeps is the release's: a subscriber that re-read or
1080
+ * rebuilt the document would race the `DocumentReleaseHandler`, and after a
1081
+ * lost connection the release waits out its grace.
1512
1082
  */
1513
1083
  isOpenInAnyClient(uri: DocumentUri): boolean {
1514
1084
  return this.__sessions.isOpen(this.documentKey(uri));
@@ -1556,12 +1126,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1556
1126
 
1557
1127
  /**
1558
1128
  * Fires once a document no client has open is released: at its last close,
1559
- * or, for a lost client's last close, when its revert grace runs out,
1560
- * another client opens it, or it is deleted. The document then reverts to
1561
- * disk.
1129
+ * or, for a lost client's last close, when its release grace runs out,
1130
+ * another client opens it, or it is deleted, before the
1131
+ * `DocumentReleaseHandler` slot is handed the document.
1562
1132
  */
1563
- get onDidCloseLastOpen(): Event<LastOpenClosedEvent> {
1564
- return this.lastOpenClosedEmitter.event;
1133
+ get onDidReleaseDocument(): Event<DocumentReleasedEvent> {
1134
+ return this.documentReleasedEmitter.event;
1565
1135
  }
1566
1136
 
1567
1137
  /**
@@ -1574,19 +1144,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1574
1144
  }
1575
1145
 
1576
1146
  /**
1577
- * Whether `uri` is waiting out the revert grace: its last open closed with a
1147
+ * Whether `uri` is waiting out the release grace: its last open closed with a
1578
1148
  * lost connection, and it still holds its unsaved text. Such a document is
1579
1149
  * open for no client, yet not closed either, so a caller that would persist
1580
1150
  * a closed document's text to disk treats it as open.
1581
1151
  */
1582
- isRevertPending(uri: DocumentUri): boolean {
1583
- return this.__sessions.isRevertPending(this.documentKey(uri));
1152
+ isReleaseDeferred(uri: DocumentUri): boolean {
1153
+ return this.documentReleaseScheduler.isDeferred(this.documentKey(uri));
1584
1154
  }
1585
1155
 
1586
1156
  /**
1587
1157
  * Whether the store holds `uri` with text that differs from its disk
1588
1158
  * baseline: what the server last knew the file to hold. `false` for a URI
1589
- * the store does not hold; a document waiting out the revert grace is still
1159
+ * the store does not hold; a document waiting out the release grace is still
1590
1160
  * held.
1591
1161
  *
1592
1162
  * The baseline is the text a first open brought, or what the server wrote,
@@ -1595,17 +1165,18 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1595
1165
  * must know the file reads it instead.
1596
1166
  */
1597
1167
  isDirty(uri: DocumentUri): boolean {
1598
- return this.__documents.get(this.documentKey(uri))?.dirty ?? false;
1168
+ return this.dirtyStateTracker.isDirty(this.documentKey(uri));
1599
1169
  }
1600
1170
 
1601
1171
  /**
1602
- * Fires each time the answer of {@link isDirty} changes. A dirty document's
1603
- * release fires once a parse of the file reaches the store, at that text's
1604
- * version, or without text once its revert removed the document or could
1605
- * not rebuild it, though {@link isDirty} answers clean from the release on.
1172
+ * Fires each time the answer of {@link isDirty} changes. For a document
1173
+ * released dirty it fires once the `DocumentReleaseHandler` slot reports the
1174
+ * release settled: at the version of the text the build then holds, or
1175
+ * without text when the document is gone or the handler failed, though
1176
+ * {@link isDirty} answers clean from the release on.
1606
1177
  */
1607
1178
  get onDidChangeDirty(): Event<DocumentDirtyChangedEvent> {
1608
- return this.dirtyChangedEmitter.event;
1179
+ return this.dirtyStateTracker.onDidChangeDirty;
1609
1180
  }
1610
1181
 
1611
1182
  /**
@@ -1613,14 +1184,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1613
1184
  * `undefined`. A no-op for a URI the store does not hold: the next first
1614
1185
  * open sets the baseline from its own text.
1615
1186
  */
1616
- updateDiskBaseline(uri: DocumentUri, text: string | undefined): void {
1187
+ setDiskBaseline(uri: DocumentUri, text: string | undefined): void {
1617
1188
  const key = this.documentKey(uri);
1618
- const record = this.__documents.get(key);
1619
- if (!record || !this.__syncedDocuments.has(key)) {
1620
- return;
1189
+ const document = this.__syncedDocuments.get(key);
1190
+ if (document !== undefined) {
1191
+ this.dirtyStateTracker.setDiskBaseline(key, document, text);
1621
1192
  }
1622
- record.diskBaseline = text;
1623
- this.refreshDirty(key);
1624
1193
  }
1625
1194
 
1626
1195
  /**
@@ -1641,23 +1210,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1641
1210
  } catch (err: unknown) {
1642
1211
  this.tracer.with(key).debug(`Disk baseline: the file cannot be read. ${err instanceof Error ? err.message : String(err)}`);
1643
1212
  }
1644
- this.updateDiskBaseline(key, onDisk);
1645
- }
1646
-
1647
- /** Compare the held text of `uri` with its baseline, and announce a changed answer. */
1648
- protected refreshDirty(uri: CanonicalUri): void {
1649
- const record = this.__documents.get(uri);
1650
- const document = this.__syncedDocuments.get(uri);
1651
- if (!record || !document) {
1652
- return;
1653
- }
1654
- const dirty = document.getText() !== record.diskBaseline;
1655
- if (dirty !== (record.dirty ?? false)) {
1656
- record.dirty = dirty;
1657
- this.dirtyChangedEmitter.fire(
1658
- Object.freeze({ uri, text: { version: document.version, hash: this.heldTextHash(document), dirty } })
1659
- );
1660
- }
1213
+ this.setDiskBaseline(key, onDisk);
1661
1214
  }
1662
1215
 
1663
1216
  /**
@@ -1693,7 +1246,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1693
1246
 
1694
1247
  /**
1695
1248
  * Close every document the language client has open, as a `didClose` for
1696
- * each would, so each last close reverts. For a host whose editor connection
1249
+ * each would, so each last close releases its document. For a host whose editor connection
1697
1250
  * can end while the process lives on, such as a worker whose port's peer
1698
1251
  * closed: the language client is no session, so nothing else closes them.
1699
1252
  *
@@ -1705,7 +1258,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1705
1258
  async closeLanguageClientDocuments(): Promise<void> {
1706
1259
  await this.initialBuildFinished();
1707
1260
  for (const uri of this.__sessions.opensOf(LANGUAGE_CLIENT_ID)) {
1708
- this.untrackLanguageClientDocuments(uri);
1261
+ // A close for one URI while others remain keeps the client's hold.
1262
+ this.languageClientShadow.removeAllOpens(uri);
1709
1263
  this.notifyDidCloseTextDocument({ textDocument: { uri } });
1710
1264
  }
1711
1265
  }
@@ -1721,21 +1275,6 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1721
1275
  await this.services.workspace.WorkspaceManager.workspaceInitialized.catch(() => undefined);
1722
1276
  }
1723
1277
 
1724
- /**
1725
- * Stop tracking every URI the language client holds the document `key`
1726
- * under, with its shadow and pending pushes. Call before a close by
1727
- * canonical key that means all of them: a close for one URI while others
1728
- * remain drops only that URI's and keeps the client's hold.
1729
- */
1730
- protected untrackLanguageClientDocuments(key: CanonicalUri): void {
1731
- const languageClientDocuments = this.__documents.get(key)?.languageClientDocuments;
1732
- for (const clientUri of languageClientDocuments?.keys() ?? []) {
1733
- this.__shadow.invalidate(clientUri);
1734
- this.__pendingPushes.delete(clientUri);
1735
- }
1736
- languageClientDocuments?.clear();
1737
- }
1738
-
1739
1278
  /**
1740
1279
  * The file behind `uri` was deleted: close every open of it except the
1741
1280
  * language client's.
@@ -1765,14 +1304,14 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1765
1304
  */
1766
1305
  override delete(uri: string | URI | T): void {
1767
1306
  const key = this.documentKey((typeof uri === 'object' && 'uri' in uri ? uri.uri : uri).toString());
1768
- this.untrackLanguageClientDocuments(key);
1307
+ this.languageClientShadow.removeAllOpens(key);
1769
1308
  for (const clientId of this.__sessions.clientsOf(key)) {
1770
1309
  this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1771
1310
  }
1772
1311
  // A document waiting out the grace has no client left to close, and is
1773
1312
  // released now as its last close would have released it.
1774
- if (this.__sessions.isRevertPending(key)) {
1775
- this.__sessions.cancelRevert(key);
1313
+ if (this.documentReleaseScheduler.isDeferred(key)) {
1314
+ this.documentReleaseScheduler.cancel(key);
1776
1315
  this.releaseDocument(key);
1777
1316
  }
1778
1317
  super.delete(key);
@@ -1801,13 +1340,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1801
1340
  * Only a FIRST open consumes it, so stage only for a URI no client holds
1802
1341
  * ({@link isOpenInAnyClient} is `false`). A URI held only through another
1803
1342
  * head is not closed: an editor attaching to it joins the existing entry and
1804
- * never reads the stage, and the last close discards the stage with the
1805
- * tracking record. An entry lingers only if `workspace/applyEdit` fails and
1806
- * the file is never opened — the memory cost is one serialised string per
1807
- * URI.
1343
+ * never reads the stage, and the release discards it. An entry lingers
1344
+ * only if `workspace/applyEdit` fails and the file is never opened — the
1345
+ * memory cost is one serialised string per URI.
1808
1346
  */
1809
1347
  stagePendingContent(uri: DocumentUri, text: string): void {
1810
- this.trackingFor(this.documentKey(uri)).pendingContent = text;
1348
+ this.__pendingContent.set(this.documentKey(uri), text);
1811
1349
  }
1812
1350
 
1813
1351
  /**
@@ -1827,20 +1365,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1827
1365
  *
1828
1366
  * The diff path is apply-verify-safe: the shadow internally checks that
1829
1367
  * `TextDocument.applyEdits(old, edits) === newText` and falls back to a
1830
- * full replace on mismatch (logged via the warn-callback wired in the
1831
- * constructor), so a diff regression becomes log noise, not data loss.
1368
+ * full replace on mismatch, logged, so a diff regression becomes log noise,
1369
+ * not data loss.
1832
1370
  *
1833
1371
  * That safety net verifies the diff against the SHADOW, which is what the
1834
1372
  * client is *believed* to hold — so it cannot see the client's buffer moving
1835
1373
  * underneath a push. A line-keyed edit is position-dependent: if a genuine
1836
- * client keystroke lands between {@link LanguageClientTextShadow.computeEdits}
1837
- * and the client applying, the ranges address the wrong lines and splice the
1838
- * buffer (observed as a duplicated declaration, which the integrity tier then
1374
+ * client keystroke lands between computing the edits and the client
1375
+ * applying them, the ranges address the wrong lines and splice the buffer
1376
+ * (observed as a duplicated declaration, which the integrity tier then
1839
1377
  * "repairs" into a suffixed name and persists). The edit is therefore
1840
- * addressed at the language client's last known version for that URI (see
1841
- * {@link languageClientVersion}) rather than at `null` ("version
1842
- * intentionally unknown"), which is what lets the client
1843
- * reject a push its buffer has outrun. On rejection the shadow is invalidated,
1378
+ * addressed at the language client's last known version for that URI rather
1379
+ * than at `null` ("version intentionally unknown"), which is what lets the
1380
+ * client reject a push its buffer has outrun. On rejection the shadow is invalidated,
1844
1381
  * so the caller's retry is a full-range replace — position-independent, and
1845
1382
  * safe to apply to whatever the client now holds.
1846
1383
  */
@@ -1853,38 +1390,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1853
1390
  if (!connection) {
1854
1391
  return undefined;
1855
1392
  }
1856
- // The document is keyed by its canonical identity, but Monaco holds it under
1857
- // the URI(s) it opened. Address each of those language-client URIs — the one
1858
- // R→S translation, kept here at the egress. Usually one; more than one only
1859
- // when the same file was opened under a symlink and its real path. Falls back
1860
- // to the normalized URI when nothing is tracked. The shadow is keyed by each
1861
- // URI, so every diff is against the right baseline.
1862
- const recorded = this.__documents.get(this.documentKey(uri))?.languageClientDocuments;
1863
- const targets: Iterable<LanguageClientUri> = recorded && recorded.size > 0 ? [...recorded.keys()] : [this.toLanguageClientUri(uri)];
1393
+ // The document is keyed by its canonical identity, but the client holds it
1394
+ // under each URI it opened, and each is diffed against its own baseline.
1395
+ const key = this.documentKey(uri);
1864
1396
  let lastResult: ApplyWorkspaceEditResult | undefined;
1865
- for (const targetUri of targets) {
1866
- // Read BEFORE computeEdits, which overwrites the baseline and drops
1867
- // the opened snapshot. This is the text the client holds, and
1868
- // therefore the only text its echo of this push can be reconstructed
1869
- // against — whether or not the push below is keyed to it.
1870
- const before = this.__shadow.clientText(targetUri);
1871
- const edits = this.__shadow.computeEdits(targetUri, newText);
1872
- if (edits.length === 0) {
1397
+ for (const clientUri of this.languageClientShadow.pushTargets(key, this.toLanguageClientUri(uri))) {
1398
+ // Prepared per target, after the previous target's reply: prepared up
1399
+ // front, a later target is diffed against what it held before a change
1400
+ // that arrived meanwhile.
1401
+ const push = this.languageClientShadow.preparePush(key, clientUri, newText);
1402
+ if (push === undefined) {
1873
1403
  continue;
1874
1404
  }
1875
- // Record the push for echo correlation BEFORE the RPC — the client's
1876
- // echo can race the applyEdit response. See `__pendingPushes`.
1877
- this.recordPendingPush(targetUri, before, newText);
1878
1405
  try {
1879
- // Version the push only when it is position-DEPENDENT. A full-range
1880
- // replace lands correctly on any buffer, so gating it would turn a
1881
- // stale-by-one version into a refused update for no safety gain — and
1882
- // it is exactly what the caller retries with after a rejection, so
1883
- // gating it there would refuse the recovery too.
1884
- const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri, targetUri);
1885
- // Captured before the await: a reopen replaces the state, so a late
1886
- // reply then writes to the old one instead of the new buffer's.
1887
- const languageClientDocument = recorded?.get(targetUri);
1888
1406
  // A full `ApplyWorkspaceEditParams`, `edit` and all — NOT a bare
1889
1407
  // `WorkspaceEdit` with a `label` beside it. `applyEdit` takes
1890
1408
  // `ApplyWorkspaceEditParams | WorkspaceEdit` and discriminates on
@@ -1894,34 +1412,21 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1894
1412
  // The union is also what hides it at compile time: excess-property
1895
1413
  // checking admits a property present in EITHER member, so an object
1896
1414
  // matching neither type-checks against the union.
1415
+ const version = push.version ?? UNKNOWN_CLIENT_VERSION;
1897
1416
  const result = await connection.workspace.applyEdit({
1898
1417
  label: options?.label,
1899
1418
  edit: {
1900
- documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1419
+ documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(clientUri, version), push.edits)]
1901
1420
  }
1902
1421
  });
1903
1422
  if (result && result.applied === false) {
1904
- this.__shadow.invalidate(targetUri);
1905
- this.__pendingPushes.delete(targetUri);
1906
- if (version !== UNKNOWN_CLIENT_VERSION) {
1907
- this.tracer
1908
- .with(uri)
1909
- .warn(
1910
- `Language client refused applyEdit addressed at version ${version} (it last declared version ${languageClientDocument?.declaredVersion})`
1911
- );
1912
- }
1913
- } else if (result?.applied && version !== UNKNOWN_CLIENT_VERSION && languageClientDocument) {
1914
- // A client steps once per applied edit that changes its buffer. A
1915
- // line edit does while the shadow is right; a full replace may be a
1916
- // no-op the client drops. Left to the echo, a push sent first is
1917
- // refused; advanced after a no-op, one could pass at a version a
1918
- // keystroke reached.
1919
- languageClientDocument.pushedVersion = version + 1;
1423
+ push.notifyOutcome('refused');
1424
+ } else if (result?.applied) {
1425
+ push.notifyOutcome('applied');
1920
1426
  }
1921
1427
  lastResult = result;
1922
- } catch (err) {
1923
- this.__shadow.invalidate(targetUri);
1924
- this.__pendingPushes.delete(targetUri);
1428
+ } catch (err: unknown) {
1429
+ push.notifyOutcome('failed');
1925
1430
  throw err;
1926
1431
  }
1927
1432
  }
@@ -1929,155 +1434,24 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1929
1434
  }
1930
1435
 
1931
1436
  /**
1932
- * The version the LSP textual language client holds `uri` at under
1933
- * `targetUri`, for addressing an outgoing `workspace/applyEdit`.
1934
- *
1935
- * Client version ids are CLIENT-owned per LSP, so this is the id the client
1936
- * itself stamped on its last `didOpen` / `didChange` for `targetUri`, or the
1937
- * one an applied push moved it to ahead of the push's echo
1938
- * ({@link LanguageClientDocumentState}) — never the shared server version,
1939
- * which advances on authored writes the client knows nothing about and would
1940
- * therefore reject every push.
1941
- *
1942
- * Falls back to {@link UNKNOWN_CLIENT_VERSION} when the client has not opened
1943
- * the document under `targetUri`, which is the honest answer. That is also
1944
- * the case in which there is no shadow, so the push is already a
1945
- * position-independent full replace and has nothing to gain from a gate.
1946
- */
1947
- protected languageClientVersion(uri: DocumentUri, targetUri: LanguageClientUri = this.toLanguageClientUri(uri)): number {
1948
- const state = this.__documents.get(this.documentKey(uri))?.languageClientDocuments?.get(targetUri);
1949
- if (state === undefined) {
1950
- return UNKNOWN_CLIENT_VERSION;
1951
- }
1952
- return Math.max(state.declaredVersion, state.pushedVersion ?? state.declaredVersion);
1953
- }
1954
-
1955
- /**
1956
- * Append a push to the in-flight queue for `targetUri`
1957
- * (see {@link __pendingPushes}). Bounded: beyond
1958
- * {@link PENDING_ECHO_CAP} the oldest entry drops with a debug log — an
1959
- * echo that far outstanding means the client is not echoing at all, and
1960
- * an unbounded queue must not become the leak.
1961
- */
1962
- protected recordPendingPush(targetUri: LanguageClientUri, before: string | undefined, newText: string): void {
1963
- let pending = this.__pendingPushes.get(targetUri);
1964
- if (!pending) {
1965
- pending = [];
1966
- this.__pendingPushes.set(targetUri, pending);
1967
- }
1968
- pending.push({ before, afterHash: textHash(newText) });
1969
- if (pending.length > PENDING_ECHO_CAP) {
1970
- pending.shift();
1971
- this.logUri(targetUri, `Pending-echo queue exceeded ${PENDING_ECHO_CAP} entries; dropped the oldest`, 'debug');
1972
- }
1973
- }
1974
-
1975
- /**
1976
- * Decide what an incoming language-client change actually is, by
1977
- * reconstructing the client's resulting buffer against the text its ranges
1978
- * address — the pre-push buffer of the OLDEST push still in flight for
1979
- * `clientFacing`, else whatever that client is believed to hold.
1980
- *
1981
- * `undefined` when the client's ranges address the synced text itself —
1982
- * nothing in flight, and no evidence the client holds anything else. That
1983
- * is the ordinary path, and the caller then applies the ranges directly.
1984
- * The two texts part company without a push in flight whenever a client
1985
- * attaches to a document another client has already written: it opened
1986
- * from disk, so applying its ranges to the synced text splices lines they
1987
- * never addressed.
1988
- *
1989
- * **Why the oldest, and why reconstruct at all.** The client applies our
1990
- * pushes in order and echoes each against the buffer it held before that
1991
- * push, so the first echo to arrive belongs to the oldest entry — a FIFO
1992
- * correspondence the queue preserves by consuming from the front. Comparing
1993
- * the reconstruction against every pending hash, not only the oldest, is
1994
- * what recognises an echo that a newer write already superseded: a rapid
1995
- * write sequence can deliver the echo of push N after the store applied
1996
- * push N+1, and everything older is then accounted for too.
1997
- *
1998
- * A reconstruction matching NO pending push means the client's buffer went
1999
- * somewhere we did not send it — it coalesced a keystroke into the echo, or
2000
- * typed before the push landed. That text is authoritative, and the queue
2001
- * drops: the client has stopped being a pure mirror, so no outstanding echo
2002
- * can match again. (A push still in flight at that point will be refused by
2003
- * the client's own version gate, which invalidates the shadow and makes the
2004
- * next sync a position-independent full replace.)
2005
- *
2006
- * Content equality is a sound echo proof because entries live only between a
2007
- * push and its echo — a milliseconds window, never history — and the
2008
- * client's `didChange` stream is ordered, so every buffer state arrives in
2009
- * mutation order. An UNDO returning the buffer to previously-pushed text is
2010
- * therefore never swallowed: the edit that preceded it already emptied the
2011
- * queue, so undo revisits PAST states while the queue holds IN-FLIGHT ones.
2012
- */
2013
- protected classifyLanguageClientChange(
2014
- clientFacing: LanguageClientUri,
2015
- document: T,
2016
- changes: TextDocumentContentChangeEvent[]
2017
- ): LanguageClientChangeOrigin | undefined {
2018
- const queued = this.__pendingPushes.get(clientFacing);
2019
- const pending = queued?.length ? queued : undefined;
2020
- // The text the client's ranges address: the buffer it held before the
2021
- // oldest push still in flight, else the buffer it is believed to hold.
2022
- const clientText = pending?.[0].before ?? this.__shadow.clientText(clientFacing);
2023
- if (pending === undefined && (clientText === undefined || clientText === document.getText())) {
2024
- return undefined;
2025
- }
2026
- if (pending !== undefined && pending[0].before === undefined && changes.some(change => ContentChange.isIncremental(change))) {
2027
- // A push sent to a client whose buffer was unknown — a first sync, or
2028
- // the recovery push after a rejection. The shadow now holds the text
2029
- // that push MOVES the client to, which is the one text the echo's
2030
- // ranges provably do not address, so the fallback above is a baseline
2031
- // known to be wrong rather than merely unverified. Drop the queue and
2032
- // the shadow: the next sync is then a full replace, which lands on
2033
- // whatever the client holds. A full-text change is exempt because it
2034
- // reconstructs identically against any baseline.
2035
- pending.length = 0;
2036
- this.__shadow.invalidate(clientFacing);
2037
- return { kind: 'unreconstructable' };
2038
- }
2039
- // Through the configured factories, not `TextDocument` directly, so an
2040
- // adopter's custom text-document type governs how the ranges are applied
2041
- // here exactly as it does on the synced document.
2042
- const probe = this.create(clientFacing, document.languageId, 0, clientText ?? document.getText());
2043
- const reconstructed = this.update(probe, changes, 0).getText();
2044
- if (pending !== undefined) {
2045
- const matchIndex = pending.findIndex(push => push.afterHash === textHash(reconstructed));
2046
- if (matchIndex >= 0) {
2047
- pending.splice(0, matchIndex + 1);
2048
- return { kind: 'echo' };
2049
- }
2050
- pending.length = 0;
2051
- }
2052
- return { kind: 'divergent', text: reconstructed };
2053
- }
2054
-
2055
- /**
2056
- * Explicitly baseline the language-client text shadow for a URI. Useful in
1437
+ * Explicitly baseline the language-client shadow for a URI. Useful in
2057
1438
  * tests and for adopters that need to seed the shadow without going through
2058
1439
  * a `didOpen` event (e.g. after a sideband save). Normal didOpen / didChange
2059
1440
  * paths from the LSP language client already auto-track the shadow.
2060
1441
  */
2061
1442
  setLanguageClientText(uri: DocumentUri, text: string): void {
2062
- const clientFacing = this.toLanguageClientUri(uri);
2063
- this.__shadow.set(clientFacing, text);
2064
- // An explicit rebaseline supersedes whatever was in flight.
2065
- this.__pendingPushes.delete(clientFacing);
1443
+ this.languageClientShadow.setClientText(this.toLanguageClientUri(uri), text);
2066
1444
  }
2067
1445
 
2068
1446
  /** Drop the shadow baseline for a URI; the next applyEditToLanguageClient sends a full replace. */
2069
1447
  invalidateLanguageClientText(uri: DocumentUri): void {
2070
- const clientFacing = this.toLanguageClientUri(uri);
2071
- this.__shadow.invalidate(clientFacing);
2072
- this.__pendingPushes.delete(clientFacing);
1448
+ this.languageClientShadow.invalidateClientText(this.toLanguageClientUri(uri));
2073
1449
  }
2074
1450
 
2075
1451
  protected consumePendingContent(uri: DocumentUri): string | undefined {
2076
- const record = this.__documents.get(this.documentKey(uri));
2077
- const content = record?.pendingContent;
2078
- if (record) {
2079
- record.pendingContent = undefined;
2080
- }
1452
+ const key = this.documentKey(uri);
1453
+ const content = this.__pendingContent.get(key);
1454
+ this.__pendingContent.delete(key);
2081
1455
  return content;
2082
1456
  }
2083
1457