diff --git a/examples/order-flow/browser/src/page/monaco-lsp-adapter.ts b/examples/order-flow/browser/src/page/monaco-lsp-adapter.ts index adb8dfe3..321e49bd 100644 --- a/examples/order-flow/browser/src/page/monaco-lsp-adapter.ts +++ b/examples/order-flow/browser/src/page/monaco-lsp-adapter.ts @@ -844,7 +844,7 @@ export class MonacoLspAdapter { * exists so that if it is ever reached the failure is loud rather than a * slow desynchronisation. * - **A stale version.** The framework addresses a position-dependent - * (line-keyed) push at the version this client last declared, precisely so + * (line-keyed) push at its last known version of this buffer, precisely so * the client can refuse a push its buffer has outrun: applying a stale * range does not fail, it splices the file at the wrong lines. Refusing is * not a dead branch — `ModelService` re-pushes a full-range replace on diff --git a/packages/core/src/documents/hydranium-text-documents.ts b/packages/core/src/documents/hydranium-text-documents.ts index eafe4179..dbae7006 100644 --- a/packages/core/src/documents/hydranium-text-documents.ts +++ b/packages/core/src/documents/hydranium-text-documents.ts @@ -129,6 +129,22 @@ export interface DocumentDirtyChangedEvent { readonly dirty: boolean; } +/** + * The language client's open of a document under one URI, from its didOpen to + * its didClose. Each URI is its own editor buffer with its own version counter, + * so a file reached through a symlink and its real path has one of these each. + */ +export interface LanguageClientDocumentState { + /** The version the client last declared for this URI. */ + declaredVersion: number; + /** + * The version an applied versioned push moved this URI to, ahead of its + * echo. Apart from `declaredVersion`, whose staleness guard would drop that + * echo and strand its pending push. + */ + pushedVersion?: number; +} + /** * All per-URI client-facing tracking the manager keys by normalized URI, * collapsed into one record so a URI's full state lives in one place and the @@ -153,24 +169,26 @@ export interface DocumentTrackingRecord { readonly versionAuthors: string[]; /** * Last version id each client declared for this document (didOpen baseline, - * advanced by every accepted didChange). Client version ids are CLIENT-owned - * per LSP (Monaco numbers its own buffer) and are used ONLY for this - * per-client staleness guard — they never leak into the shared version - * sequence, which the server assigns (see {@link HydraniumTextDocuments.__versionSequences}). + * advanced by every accepted didChange), for the per-client staleness guard. + * Client version ids are CLIENT-owned per LSP (Monaco numbers its own + * buffer) — they never leak into the shared version sequence, which the + * server assigns (see {@link HydraniumTextDocuments.__versionSequences}). + * The language client's entry is the latest any of its URIs declared; the + * store checks and addresses it per URI, through + * {@link DocumentTrackingRecord.languageClientDocuments}. */ readonly clientVersions: Map; /** Content staged by integrity rules for a closed document. Consumed on next open. */ pendingContent?: string; /** - * The URI(s) the LSP textual language client opened this (canonically-keyed) - * document under. Usually one; a set because the same physical file can be - * opened under more than one URI (a symlink path and its real path). This is - * the egress address: `applyEditToLanguageClient` - * targets these, since the document is *keyed* by its canonical identity but - * Monaco holds it under the URI it opened. Lifecycle-bound — cleared with the - * record on last close. + * Each URI the LSP textual language client opened this (canonically-keyed) + * document under, with that open's state. Usually one; more when the same + * file is opened under a symlink path and its real path. These are the + * egress addresses: the document is *keyed* by its canonical identity, but + * Monaco holds it under the URI it opened. The language client holds the + * document while any entry remains. */ - languageClientUris?: Set; + languageClientDocuments?: Map; /** * The clients whose close of this document was caused by a lost connection * and that have not opened it again, each with a stopwatch started at its @@ -568,9 +586,12 @@ export class HydraniumTextDocuments exten // client knowing). Gating on the shared version drops real edits in // exactly that lag window. A client with no baseline (never opened — // a protocol anomaly) falls back to the shared-version compare, the - // conservative answer. + // conservative answer. The language client is checked per URI: each is + // its own buffer, and one URI's higher id would drop the other's edits. const record = this.trackingFor(uri); - const lastSeen = record.clientVersions.get(clientId) ?? document.version; + const languageClientDocument = + clientId === LANGUAGE_CLIENT_ID ? record.languageClientDocuments?.get(this.toLanguageClientUri(td.uri)) : undefined; + const lastSeen = languageClientDocument?.declaredVersion ?? record.clientVersions.get(clientId) ?? document.version; if (lastSeen >= td.version) { // Distinguish "already at this version" (common: an echo from the client that triggered // the update) from "incoming version older than ours" (stale race). @@ -580,6 +601,9 @@ export class HydraniumTextDocuments exten return; } record.clientVersions.set(clientId, td.version); + if (languageClientDocument) { + languageClientDocument.declaredVersion = td.version; + } // A language-client change is keyed to the buffer that client holds, // which is the synced text only while the two agree — an authored @@ -700,6 +724,16 @@ export class HydraniumTextDocuments exten cause: SessionEndCause = 'closed' ): void { const uri = this.documentKey(event.textDocument.uri); + if (clientId === LANGUAGE_CLIENT_ID) { + const clientFacing = this.toLanguageClientUri(event.textDocument.uri); + const languageClientDocuments = this.__documents.get(uri)?.languageClientDocuments; + if (languageClientDocuments?.delete(clientFacing) && languageClientDocuments.size > 0) { + // Another URI still holds the document, so only this one's buffer goes. + this.__shadow.invalidate(clientFacing); + this.__pendingPushes.delete(clientFacing); + return; + } + } if (!this.__sessions.removeOpen(uri, clientId)) { return; } @@ -714,11 +748,10 @@ export class HydraniumTextDocuments exten this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId })); if (clientId === LANGUAGE_CLIENT_ID) { - // Monaco closed the document; drop the URI it held and the shadow - // baselined under it. (If this was the last URI/client the whole record - // is deleted on release, clearing the set anyway.) + // Monaco closed the document; drop the shadow baselined under the URI + // it held. (If this was the last client the whole record is deleted on + // release.) const droppedUri = this.toLanguageClientUri(event.textDocument.uri); - this.__documents.get(uri)?.languageClientUris?.delete(droppedUri); this.__shadow.invalidate(droppedUri); this.__pendingPushes.delete(droppedUri); } @@ -974,8 +1007,8 @@ export class HydraniumTextDocuments exten if (clientId === LANGUAGE_CLIENT_ID) { const clientFacing = this.toLanguageClientUri(td.uri); const record = this.__documents.get(uri); - if (record && !record.languageClientUris?.has(clientFacing)) { - (record.languageClientUris ??= new Set()).add(clientFacing); + if (record && !record.languageClientDocuments?.has(clientFacing)) { + (record.languageClientDocuments ??= new Map()).set(clientFacing, { declaredVersion: td.version }); // This tab's own buffer, NOT the synced text: a second tab is a second // client model, read from disk, so a server-authored write already // applied to the first tab leaves it BEHIND the synced document. Keying @@ -996,7 +1029,8 @@ export class HydraniumTextDocuments exten if (clientId === LANGUAGE_CLIENT_ID) { // Remember the URI Monaco opened under (may differ from the canonical key) // so outbound applyEditToLanguageClient can address the URI it actually holds. - (record.languageClientUris ??= new Set()).add(this.toLanguageClientUri(td.uri)); + // A fresh state, since a reopened buffer numbers its versions afresh. + (record.languageClientDocuments ??= new Map()).set(this.toLanguageClientUri(td.uri), { declaredVersion: td.version }); } if (!document) { // Use integrity-staged content if available, otherwise the client-provided (disk) text. @@ -1122,7 +1156,7 @@ export class HydraniumTextDocuments exten * two URIs for one physical file (a symlink path and its real path) into a * single registration — dedup at the editor layer, not just in * `LangiumDocuments`. The URI the client opened under is preserved separately - * for egress addressing (see {@link DocumentTrackingRecord.languageClientUris}). The + * for egress addressing (see {@link DocumentTrackingRecord.languageClientDocuments}). The * policy is always bound (the framework defaults it to * `DefaultDocumentUriPolicy`, where canonical ≡ syntactic normalize). */ @@ -1485,17 +1519,26 @@ export class HydraniumTextDocuments exten async closeLanguageClientDocuments(): Promise { await this.services.workspace.WorkspaceManager.workspaceInitialized; for (const uri of this.__sessions.opensOf(LANGUAGE_CLIENT_ID)) { - // Every spelling the client opened the file under, since the close - // below drops only the canonical one's. - const clientUris = this.__documents.get(uri)?.languageClientUris; - for (const clientUri of clientUris ?? []) { - this.invalidateLanguageClientText(clientUri); - } - clientUris?.clear(); + this.untrackLanguageClientDocuments(uri); this.notifyDidCloseTextDocument({ textDocument: { uri } }); } } + /** + * Stop tracking every URI the language client holds the document `key` + * under, with its shadow and pending pushes. Call before a close by + * canonical key that means all of them: a close for one URI while others + * remain drops only that URI's and keeps the client's hold. + */ + protected untrackLanguageClientDocuments(key: CanonicalUri): void { + const languageClientDocuments = this.__documents.get(key)?.languageClientDocuments; + for (const clientUri of languageClientDocuments?.keys() ?? []) { + this.__shadow.invalidate(clientUri); + this.__pendingPushes.delete(clientUri); + } + languageClientDocuments?.clear(); + } + /** * The file behind `uri` was deleted: close every open of it except the * language client's. @@ -1525,6 +1568,7 @@ export class HydraniumTextDocuments exten */ override delete(uri: string | URI | T): void { const key = this.documentKey((typeof uri === 'object' && 'uri' in uri ? uri.uri : uri).toString()); + this.untrackLanguageClientDocuments(key); for (const clientId of this.__sessions.clientsOf(key)) { this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId); } @@ -1596,8 +1640,9 @@ export class HydraniumTextDocuments exten * and the client applying, the ranges address the wrong lines and splice the * buffer (observed as a duplicated declaration, which the integrity tier then * "repairs" into a suffixed name and persists). The edit is therefore - * addressed at the language client's LAST DECLARED VERSION rather than at - * `null` ("version intentionally unknown"), which is what lets the client + * addressed at the language client's last known version for that URI (see + * {@link languageClientVersion}) rather than at `null` ("version + * intentionally unknown"), which is what lets the client * reject a push its buffer has outrun. On rejection the shadow is invalidated, * so the caller's retry is a full-range replace — position-independent, and * safe to apply to whatever the client now holds. @@ -1617,8 +1662,8 @@ export class HydraniumTextDocuments exten // when the same file was opened under a symlink and its real path. Falls back // to the normalized URI when nothing is tracked. The shadow is keyed by each // URI, so every diff is against the right baseline. - const recorded = this.__documents.get(this.documentKey(uri))?.languageClientUris; - const targets: Iterable = recorded && recorded.size > 0 ? recorded : [this.toLanguageClientUri(uri)]; + const recorded = this.__documents.get(this.documentKey(uri))?.languageClientDocuments; + const targets: Iterable = recorded && recorded.size > 0 ? [...recorded.keys()] : [this.toLanguageClientUri(uri)]; let lastResult: ApplyWorkspaceEditResult | undefined; for (const targetUri of targets) { // Read BEFORE computeEdits, which overwrites the baseline and drops @@ -1639,7 +1684,10 @@ export class HydraniumTextDocuments exten // stale-by-one version into a refused update for no safety gain — and // it is exactly what the caller retries with after a rejection, so // gating it there would refuse the recovery too. - const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri); + const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri, targetUri); + // Captured before the await: a reopen replaces the state, so a late + // reply then writes to the old one instead of the new buffer's. + const languageClientDocument = recorded?.get(targetUri); // A full `ApplyWorkspaceEditParams`, `edit` and all — NOT a bare // `WorkspaceEdit` with a `label` beside it. `applyEdit` takes // `ApplyWorkspaceEditParams | WorkspaceEdit` and discriminates on @@ -1658,6 +1706,20 @@ export class HydraniumTextDocuments exten if (result && result.applied === false) { this.__shadow.invalidate(targetUri); this.__pendingPushes.delete(targetUri); + if (version !== UNKNOWN_CLIENT_VERSION) { + this.tracer + .with(uri) + .warn( + `Language client refused applyEdit addressed at version ${version} (it last declared version ${languageClientDocument?.declaredVersion})` + ); + } + } else if (result?.applied && version !== UNKNOWN_CLIENT_VERSION && languageClientDocument) { + // A client steps once per applied edit that changes its buffer. A + // line edit does while the shadow is right; a full replace may be a + // no-op the client drops. Left to the echo, a push sent first is + // refused; advanced after a no-op, one could pass at a version a + // keystroke reached. + languageClientDocument.pushedVersion = version + 1; } lastResult = result; } catch (err) { @@ -1670,21 +1732,27 @@ export class HydraniumTextDocuments exten } /** - * The version the LSP textual language client last declared for `uri`, for - * addressing an outgoing `workspace/applyEdit`. + * The version the LSP textual language client holds `uri` at under + * `targetUri`, for addressing an outgoing `workspace/applyEdit`. * * Client version ids are CLIENT-owned per LSP, so this is the id the client - * itself stamped on its last `didOpen` / `didChange` — never the shared - * server version, which advances on authored writes the client knows nothing - * about and would therefore reject every push. + * itself stamped on its last `didOpen` / `didChange` for `targetUri`, or the + * one an applied push moved it to ahead of the push's echo + * ({@link LanguageClientDocumentState}) — never the shared server version, + * which advances on authored writes the client knows nothing about and would + * therefore reject every push. * - * Falls back to {@link UNKNOWN_CLIENT_VERSION} when the client has never - * declared one, which is the honest answer for a document it has not opened. - * That is also the case in which there is no shadow, so the push is already a + * Falls back to {@link UNKNOWN_CLIENT_VERSION} when the client has not opened + * the document under `targetUri`, which is the honest answer. That is also + * the case in which there is no shadow, so the push is already a * position-independent full replace and has nothing to gain from a gate. */ - protected languageClientVersion(uri: DocumentUri): number { - return this.__documents.get(this.documentKey(uri))?.clientVersions.get(LANGUAGE_CLIENT_ID) ?? UNKNOWN_CLIENT_VERSION; + protected languageClientVersion(uri: DocumentUri, targetUri: LanguageClientUri = this.toLanguageClientUri(uri)): number { + const state = this.__documents.get(this.documentKey(uri))?.languageClientDocuments?.get(targetUri); + if (state === undefined) { + return UNKNOWN_CLIENT_VERSION; + } + return Math.max(state.declaredVersion, state.pushedVersion ?? state.declaredVersion); } /** diff --git a/packages/core/src/langium/model-service/model-service.ts b/packages/core/src/langium/model-service/model-service.ts index 5a54b0e1..6d895e9c 100644 --- a/packages/core/src/langium/model-service/model-service.ts +++ b/packages/core/src/langium/model-service/model-service.ts @@ -922,7 +922,7 @@ export class DefaultModelService< try { let result = await this.services.workspace.TextDocuments.applyEditToLanguageClient(uri, text, { label: this.editLabel() }); if (result?.applied === false && !this.pendingSync.has(uri)) { - // The push is addressed at the client's LAST DECLARED VERSION, so a + // The push is addressed at the client's last known version, so a // rejection normally means the client's buffer moved while the // line-keyed diff was in flight — exactly the case where applying it // would splice the file. Dropping the push there would leave the @@ -940,7 +940,7 @@ export class DefaultModelService< // `pendingSync` check skips the retry when a newer settle has already // queued — best-effort, since a settle arriving later simply pushes // after this and still wins. - uriLogger.warn(`Language client rejected applyEdit at its declared version — re-pushing a full replace`); + uriLogger.debug(`Re-pushing a full replace after the language client refused applyEdit`); result = await this.services.workspace.TextDocuments.applyEditToLanguageClient(uri, text, { label: this.editLabel() }); if (result?.applied === false) { uriLogger.warn(`Language client rejected the full-replace retry too — client content is stale`); diff --git a/packages/core/test/documents/hydranium-text-documents.test.ts b/packages/core/test/documents/hydranium-text-documents.test.ts index 26287db6..28712952 100644 --- a/packages/core/test/documents/hydranium-text-documents.test.ts +++ b/packages/core/test/documents/hydranium-text-documents.test.ts @@ -79,7 +79,11 @@ function makeLogger(): LoggerStub { return stub; } -function makeSharedServices(connection: ConnectionStub | undefined, logger?: LoggerStub): ServerSharedServices { +function makeSharedServices( + connection: ConnectionStub | undefined, + logger?: LoggerStub, + uriPolicy: unknown = new DefaultDocumentUriPolicy() +): ServerSharedServices { return { lsp: connection ? { Connection: connection } : undefined, Logger: { for: () => logger }, @@ -92,12 +96,15 @@ function makeSharedServices(connection: ConnectionStub | undefined, logger?: Log WorkspaceManager: { workspaceInitialized: Promise.resolve() }, // The store always resolves keys through the canonicalizer; the framework // default (syntactic normalize) is what production binds absent a stronger identity. - DocumentUriPolicy: new DefaultDocumentUriPolicy() + DocumentUriPolicy: uriPolicy } } as unknown as ServerSharedServices; } -function makeDocs(connection: ConnectionStub | undefined = undefined): { +function makeDocs( + connection: ConnectionStub | undefined = undefined, + uriPolicy?: unknown +): { docs: HydraniumTextDocuments; recorded: RecordedApplyEdit[]; logger: LoggerStub; @@ -112,7 +119,7 @@ function makeDocs(connection: ConnectionStub | undefined = undefined): { } }; const logger = makeLogger(); - const services = makeSharedServices(wrappedConnection, logger); + const services = makeSharedServices(wrappedConnection, logger, uriPolicy); // The framework's tracer surface is `with(uri).warn(msg)`; the LoggerStub // matches it and is returned from both `services.Logger.for(...)` and // `services.Tracer.for(...)` — the latter is what the constructor resolves. @@ -396,6 +403,340 @@ describe('HydraniumTextDocuments.applyEditToLanguageClient version gate', () => // marker would pass on a differently-shaped corruption. expect(clientText).toBe(USER_EDITED); }); + + describe('a push that follows an applied one before its echo', () => { + // A version-checking client that steps its version once per applied edit + // and holds the echoes back, as an editor does for its echo latency. + function makeEchoingClient(): { + docs: HydraniumTextDocuments; + recorded: RecordedApplyEdit[]; + logger: LoggerStub; + client: { text: string; version: number }; + deliverEchoes: () => void; + holdNextReply: () => () => void; + } { + const client = { text: 'a\nb\nc\n', version: 1 }; + const echoes: Array<{ version: number; edits: TextEdit[] }> = []; + let heldReply: Promise | undefined; + const { docs, recorded, logger } = makeDocs({ + workspace: { + applyEdit: async params => { + const change = params.edit.documentChanges![0] as { textDocument: { version: number | null }; edits: TextEdit[] }; + if (change.textDocument.version !== null && change.textDocument.version !== client.version) { + return { applied: false }; + } + const text = TextDocumentImpl.applyEdits(TextDocumentImpl.create(URI, 'plaintext', 0, client.text), change.edits); + // An edit that changes nothing is applied without a step or an echo. + if (text !== client.text) { + client.text = text; + client.version++; + echoes.push({ version: client.version, edits: change.edits }); + } + const reply = heldReply; + heldReply = undefined; + await reply; + return { applied: true }; + } + } + }); + const deliverEchoes = (): void => { + for (const echo of echoes.splice(0)) { + docs.notifyDidChangeTextDocument( + { + textDocument: { uri: URI, version: echo.version }, + contentChanges: echo.edits.map(edit => ({ range: edit.range, text: edit.newText })) + }, + LANGUAGE_CLIENT_ID + ); + } + }; + // The next push applies at once but answers only on release. + const holdNextReply = (): (() => void) => { + let release: () => void = () => undefined; + heldReply = new Promise(resolve => (release = resolve)); + return release; + }; + openInLanguageClient(docs, client.text); + return { docs, recorded, logger, client, deliverEchoes, holdNextReply }; + } + + it('is addressed at the version the applied push moved the client to', async () => { + const { docs, recorded, client } = makeEchoingClient(); + + await docs.applyEditToLanguageClient(URI, 'a\nB\nc\n'); + const second = await docs.applyEditToLanguageClient(URI, 'a\nB\nC\n'); + + expect(second).toEqual({ applied: true }); + const identifier = (recorded[1].params.edit.documentChanges![0] as { textDocument: { version: number | null } }).textDocument; + expect(identifier.version).toBe(2); + expect(client.text).toBe('a\nB\nC\n'); + }); + + it('still recognises the late echoes, and addresses the next push past them', async () => { + const { docs, recorded, logger, client, deliverEchoes } = makeEchoingClient(); + + await docs.applyEditToLanguageClient(URI, 'a\nB\nc\n'); + await docs.applyEditToLanguageClient(URI, 'a\nB\nC\n'); + deliverEchoes(); + const third = await docs.applyEditToLanguageClient(URI, 'A\nB\nC\n'); + + expect(logger.warnCalls).toEqual([]); + expect(third).toEqual({ applied: true }); + const identifier = (recorded[2].params.edit.documentChanges![0] as { textDocument: { version: number | null } }).textDocument; + expect(identifier.version).toBe(3); + expect(client.text).toBe('A\nB\nC\n'); + // A dropped echo would leave its push pending, and this keystroke's + // range would then be read against the text before that push. + deliverEchoes(); + client.version++; + docs.notifyDidChangeTextDocument( + { + textDocument: { uri: URI, version: client.version }, + contentChanges: [{ range: Range.create(3, 0, 3, 0), text: 'd\n' }] + }, + LANGUAGE_CLIENT_ID + ); + expect(docs.get(URI)?.getText()).toBe('A\nB\nC\nd\n'); + }); + + it('is addressed at the declared version after a full replace that changed nothing', async () => { + const { docs, recorded, client } = makeEchoingClient(); + // No baseline, so the push is a full replace; the client already holds its text. + docs.invalidateLanguageClientText(URI); + await docs.applyEditToLanguageClient(URI, client.text); + + const next = await docs.applyEditToLanguageClient(URI, 'a\nB\nc\n'); + + const identifier = (recorded[1].params.edit.documentChanges![0] as { textDocument: { version: number | null } }).textDocument; + expect(identifier.version).toBe(1); + expect(next).toEqual({ applied: true }); + }); + + it('is addressed at the reopened buffer, not a version pushed before the close', async () => { + const { docs, recorded, client, deliverEchoes } = makeEchoingClient(); + // Another holder keeps the tracking record alive across the editor's close. + docs.attachClient(URI, 'data-session'); + await docs.applyEditToLanguageClient(URI, 'a\nB\nc\n'); + await docs.applyEditToLanguageClient(URI, 'a\nB\nC\n'); + await docs.applyEditToLanguageClient(URI, 'A\nB\nC\n'); + deliverEchoes(); + docs.notifyDidCloseTextDocument({ textDocument: { uri: URI } }, LANGUAGE_CLIENT_ID); + + // Reopened from disk, so the buffer numbers from 1 again. Joining a held + // document makes the first push a full replace, and its echo a baseline. + client.text = 'a\nb\nc\n'; + client.version = 1; + openInLanguageClient(docs, client.text); + await docs.applyEditToLanguageClient(URI, 'A\nB\nC\n'); + deliverEchoes(); + await docs.applyEditToLanguageClient(URI, 'A\nB\nC2\n'); + + const identifier = (recorded[4].params.edit.documentChanges![0] as { textDocument: { version: number | null } }).textDocument; + expect(identifier.version).toBe(2); + expect(client.text).toBe('A\nB\nC2\n'); + }); + + it('names both versions when the client refuses a push', async () => { + const { docs, logger, client } = makeEchoingClient(); + + await docs.applyEditToLanguageClient(URI, 'a\nB\nc\n'); + // A keystroke in the editor that the server has not heard of yet. + client.version++; + const second = await docs.applyEditToLanguageClient(URI, 'a\nB\nC\n'); + + expect(second).toEqual({ applied: false }); + expect(logger.warnCalls.map(call => call.message)).toEqual([ + 'Language client refused applyEdit addressed at version 2 (it last declared version 1)' + ]); + }); + + it('is addressed at a declared version that has overtaken the pushed one', async () => { + const { docs, client, deliverEchoes } = makeEchoingClient(); + + await docs.applyEditToLanguageClient(URI, 'a\nB\nc\n'); + deliverEchoes(); + // A keystroke the server has heard, so the client declared past the push. + client.text = 'a\nB\nc\nd\n'; + client.version++; + docs.notifyDidChangeTextDocument( + { textDocument: { uri: URI, version: client.version }, contentChanges: [{ text: client.text }] }, + LANGUAGE_CLIENT_ID + ); + const second = await docs.applyEditToLanguageClient(URI, 'a\nB\nC\nd\n'); + + expect(second).toEqual({ applied: true }); + expect(client.text).toBe('a\nB\nC\nd\n'); + }); + + it('is not addressed at a version reported by a reply that lands after a reopen', async () => { + const { docs, recorded, client, deliverEchoes, holdNextReply } = makeEchoingClient(); + // Another holder keeps the tracking record alive across the editor's close. + docs.attachClient(URI, 'data-session'); + await docs.applyEditToLanguageClient(URI, 'a\nB\nc\n'); + deliverEchoes(); + const release = holdNextReply(); + const inFlight = docs.applyEditToLanguageClient(URI, 'a\nB\nC\n'); + + // The editor closes and reopens from disk before that reply arrives. + docs.notifyDidCloseTextDocument({ textDocument: { uri: URI } }, LANGUAGE_CLIENT_ID); + client.text = 'a\nB\nC\n'; + client.version = 1; + openInLanguageClient(docs, client.text); + release(); + await inFlight; + // A heard keystroke gives the store a baseline for a line-keyed push. + client.text = 'a\nB\nC\nd\n'; + client.version = 2; + docs.notifyDidChangeTextDocument( + { textDocument: { uri: URI, version: client.version }, contentChanges: [{ text: client.text }] }, + LANGUAGE_CLIENT_ID + ); + const next = await docs.applyEditToLanguageClient(URI, 'A\nB\nC\nd\n'); + + const identifier = (recorded.at(-1)!.params.edit.documentChanges![0] as { textDocument: { version: number | null } }).textDocument; + expect(identifier.version).toBe(2); + expect(next).toEqual({ applied: true }); + }); + }); + + describe('a file open under two language-client URIs', () => { + const REAL = 'file:///real/a.x'; + const LINK = 'file:///link/a.x'; + const toText = (uri: string | { toString(): string }): string => (typeof uri === 'string' ? uri : uri.toString()); + const linkAware = { + canonicalUri: (uri: string | { toString(): string }): string => (toText(uri) === LINK ? REAL : toText(uri)), + loadUri: (uri: string | { toString(): string }) => ({ toString: () => (toText(uri) === LINK ? REAL : toText(uri)) }) + }; + + // Two version-checking tabs, each its own buffer with its own counter, both + // brought to version 2 with the store holding a baseline for each. + async function makeTwoTabs(): Promise<{ + docs: HydraniumTextDocuments; + tabs: Record; + }> { + const tabs: Record = { + [LINK]: { text: 'a\nb\n', version: 1 }, + [REAL]: { text: 'a\nb\n', version: 1 } + }; + const { docs } = makeDocs( + { + workspace: { + applyEdit: async params => { + const change = params.edit.documentChanges![0] as { + textDocument: { uri: string; version: number | null }; + edits: TextEdit[]; + }; + const tab = tabs[change.textDocument.uri]; + if (change.textDocument.version !== null && change.textDocument.version !== tab.version) { + return { applied: false }; + } + tab.text = TextDocumentImpl.applyEdits(TextDocumentImpl.create(REAL, 'plaintext', 0, tab.text), change.edits); + tab.version++; + return { applied: true }; + } + } + }, + linkAware + ); + for (const uri of [LINK, REAL]) { + docs.notifyDidOpenTextDocument( + { textDocument: { uri, languageId: 'plaintext', version: 1, text: 'a\nb\n' } }, + LANGUAGE_CLIENT_ID + ); + } + await docs.applyEditToLanguageClient(REAL, 'a\nB\n'); + for (const uri of [LINK, REAL]) { + docs.notifyDidChangeTextDocument( + { textDocument: { uri, version: 2 }, contentChanges: [{ text: 'a\nB\n' }] }, + LANGUAGE_CLIENT_ID + ); + } + return { docs, tabs }; + } + + it('addresses each tab at its own version', async () => { + const { docs, tabs } = await makeTwoTabs(); + + const result = await docs.applyEditToLanguageClient(REAL, 'a\nB\nc\n'); + + expect(result).toEqual({ applied: true }); + expect(tabs[LINK].text).toBe('a\nB\nc\n'); + expect(tabs[REAL].text).toBe('a\nB\nc\n'); + }); + + it('does not splice a tab whose unheard keystroke reached the version the other tab was pushed to', async () => { + const { docs, tabs } = await makeTwoTabs(); + tabs[REAL] = { text: 'z\na\nB\n', version: 3 }; + + await docs.applyEditToLanguageClient(REAL, 'a\nX\n'); + + expect(tabs[REAL].text).toBe('z\na\nB\n'); + }); + + // A keystroke the store hears, as a full-text change at the tab's next version. + function type( + docs: HydraniumTextDocuments, + tabs: Record, + uri: string, + text: string + ): void { + tabs[uri] = { text, version: tabs[uri].version + 1 }; + docs.notifyDidChangeTextDocument( + { textDocument: { uri, version: tabs[uri].version }, contentChanges: [{ text }] }, + LANGUAGE_CLIENT_ID + ); + } + + it("applies one tab's change after the other declared a higher version", async () => { + const { docs, tabs } = await makeTwoTabs(); + type(docs, tabs, LINK, 'a\nB\nl3\n'); + type(docs, tabs, LINK, 'a\nB\nl3\nl4\n'); + + type(docs, tabs, REAL, 'r\na\nB\n'); + + expect(docs.get(REAL)?.getText()).toBe('r\na\nB\n'); + }); + + it('addresses each tab at the version that tab declared', async () => { + const { docs, tabs } = await makeTwoTabs(); + type(docs, tabs, LINK, 'a\nB\nl3\n'); + type(docs, tabs, LINK, 'a\nB\nl3\nl4\n'); + + const result = await docs.applyEditToLanguageClient(REAL, 'a\nB\nl3\nl4\nS\n'); + + expect(result).toEqual({ applied: true }); + expect(tabs[REAL].text).toBe('a\nB\nl3\nl4\nS\n'); + }); + + it('keeps the document open while the other tab still holds it', async () => { + const { docs, tabs } = await makeTwoTabs(); + + docs.notifyDidCloseTextDocument({ textDocument: { uri: LINK } }, LANGUAGE_CLIENT_ID); + type(docs, tabs, REAL, 'r\na\nB\n'); + + expect(docs.isOpenInAnyClient(REAL)).toBe(true); + expect(docs.get(REAL)?.getText()).toBe('r\na\nB\n'); + docs.notifyDidCloseTextDocument({ textDocument: { uri: REAL } }, LANGUAGE_CLIENT_ID); + expect(docs.isOpenInAnyClient(REAL)).toBe(false); + }); + + it('closes every tab when the document is deleted, so a reopen recreates it', async () => { + const { docs, tabs } = await makeTwoTabs(); + + docs.delete(REAL); + + expect(docs.get(REAL)).toBeUndefined(); + expect(docs.isOpenInAnyClient(REAL)).toBe(false); + tabs[REAL] = { text: 'b\n', version: 1 }; + docs.notifyDidOpenTextDocument( + { textDocument: { uri: REAL, languageId: 'plaintext', version: 1, text: 'b\n' } }, + LANGUAGE_CLIENT_ID + ); + expect(docs.get(REAL)?.getText()).toBe('b\n'); + type(docs, tabs, REAL, 'b\nc\n'); + expect(docs.get(REAL)?.getText()).toBe('b\nc\n'); + }); + }); }); describe('HydraniumTextDocuments.isOpenInAnyClient', () => { @@ -1694,7 +2035,7 @@ describe('HydraniumTextDocuments get() — canonical lookup (symlink divergence) class InspectableTextDocuments extends HydraniumTextDocuments { languageClientState(uri: string): { uris: string[]; shadowed: string[]; pending: string[] } { return { - uris: [...(this.__documents.get(this.documentKey(uri))?.languageClientUris ?? [])].sort(), + uris: [...(this.__documents.get(this.documentKey(uri))?.languageClientDocuments?.keys() ?? [])].sort(), shadowed: [LINK, REAL].filter(clientUri => this.__shadow.isTracked(clientUri)).sort(), pending: [...this.__pendingPushes.keys()].sort() };