From bd645c9260f95bf6e8c5fd6a6d5da1cb401d1717 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:24:58 -0500 Subject: [PATCH 1/5] test: add workspace didChange document-replacement --- server/src/test/workspace.test.ts | 141 ++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 server/src/test/workspace.test.ts diff --git a/server/src/test/workspace.test.ts b/server/src/test/workspace.test.ts new file mode 100644 index 0000000..2795872 --- /dev/null +++ b/server/src/test/workspace.test.ts @@ -0,0 +1,141 @@ +import 'reflect-metadata'; +import '../extensions/stringExtensions'; + +import { describe, it } from 'mocha'; +import * as assert from 'assert'; +import { container } from 'tsyringe'; +import { CancellationTokenSource } from 'vscode-languageserver'; + +import { Workspace } from '../project/workspace'; +import { ScopeItemCapability, ScopeType } from '../capabilities/capabilities'; +import { ILanguageServer } from '../injection/interface'; + +function createMockConnection() { + const disposable = { dispose: () => undefined }; + const on = () => disposable; + + return { + onCodeAction: on, + onCompletion: on, + onCompletionResolve: on, + onDefinition: on, + onDidChangeConfiguration: on, + onDidChangeWatchedFiles: on, + onDidCloseTextDocument: on, + onDocumentFormatting: on, + onDocumentSymbol: on, + onHover: on, + onInitialized: on, + onRenameRequest: on, + onFoldingRanges: on, + onRequest: on, + onDidOpenTextDocument: on, + onDidChangeTextDocument: on, + onDidSaveTextDocument: on, + onWillSaveTextDocument: on, + onWillSaveTextDocumentWaitUntil: on, + + sendDiagnostics: () => undefined, + sendNotification: () => undefined, + + workspace: { + getConfiguration: async () => ({ + maxDocumentLines: 50000, + maxNumberOfProblems: 100, + doWarnOptionExplicitMissing: true, + environment: { os: 'test', version: 'test' }, + logLevel: { outputChannel: 'debug' } + }), + onDidChangeWorkspaceFolders: on + }, + languages: { + diagnostics: { + refresh: () => undefined + } + }, + client: { + register: () => disposable + } + } as any; +} + +function createMockServer(): ILanguageServer { + return { + configuration: { + params: { + capabilities: { + workspace: { + configuration: false, + workspaceFolders: false + } + }, + workspaceFolders: [] + } + } as any, + clientConfiguration: Promise.resolve({ + maxDocumentLines: 50000, + maxNumberOfProblems: 100, + doWarnOptionExplicitMissing: true, + environment: { os: 'test', version: 'test' }, + logLevel: { outputChannel: 'debug' } + }) + }; +} + +describe('Workspace document replacement race', () => { + it('returns the replacement document while waiting for busy parse to clear', async () => { + container.clearInstances(); + + const connection = createMockConnection(); + const server = createMockServer(); + + container.registerInstance('_Connection', connection); + container.registerInstance('ILanguageServer', server); + + const workspace = new Workspace(connection, server); + + const events = (workspace as any).events; + const projectDocuments = (workspace as any).projectDocuments as Map; + + const uri = 'file:///c:/tmp/replaced.bas'; + + const oldBusyDocument = { + textDocument: { version: 1 }, + isBusy: true + } as any; + + const replacementDocument = { + textDocument: { version: 2 }, + isBusy: false + } as any; + + projectDocuments.set(uri, oldBusyDocument); + + const tokenSource = new CancellationTokenSource(); + const waitPromise = (events as any).getParsedProjectDocument(uri, 0, tokenSource.token); + + const replacementTimer = setTimeout(() => { + projectDocuments.set(uri, replacementDocument); + }, 20); + + try { + const result = await Promise.race([ + waitPromise, + new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 300)) + ]); + + assert.notStrictEqual(result, 'timeout', 'Expected to resolve with replacement document, but request timed out'); + assert.strictEqual(result, replacementDocument, 'Expected to return the replacement tracked document instance'); + } finally { + clearTimeout(replacementTimer); + tokenSource.cancel(); + + // Ensure no pending waiter keeps the test process alive when + // assertions fail (e.g., when the replacement-refresh fix is absent). + await Promise.race([ + waitPromise.catch(() => undefined), + new Promise(resolve => setTimeout(resolve, 100)) + ]); + } + }); +}); From b4d8f385e079085b94ed7519c5935f8c3ca311cc Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:27:22 -0500 Subject: [PATCH 2/5] fix: update document handling to avoid stale waits --- server/src/project/workspace.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/src/project/workspace.ts b/server/src/project/workspace.ts index a587b93..b16ec19 100644 --- a/server/src/project/workspace.ts +++ b/server/src/project/workspace.ts @@ -300,6 +300,12 @@ class WorkspaceEvents { while (document.isBusy) { if (cancelled) return undefined; await sleep(5); + // A didChange can replace the tracked document instance while an older + // request is still waiting; re-read the latest instance to avoid stale waits + const latestDocument = this.projectDocuments.get(uri); + if (latestDocument) { + document = latestDocument; + } } return document; From cfe7916db9778acf45fecfea48a126350c4dcc08 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:53:36 -0500 Subject: [PATCH 3/5] fix: ensure matching version is returned + minor refact and docs --- server/src/project/workspace.ts | 12 +++++++++++- server/src/test/workspace.test.ts | 1 - 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/server/src/project/workspace.ts b/server/src/project/workspace.ts index b16ec19..a8464e1 100644 --- a/server/src/project/workspace.ts +++ b/server/src/project/workspace.ts @@ -273,7 +273,13 @@ class WorkspaceEvents { this.initialiseDocumentsEvents(); this.documents.listen(connection); } - +/** + * Returns a project document that matches the requested version, waiting for parsing to complete if necessary. + * @param uri The URI of the document. + * @param version The version of the document to match. If 0, will return the latest version of the document after parsing is complete. + * @param token A cancellation token to cancel the operation. + * @returns A promise that resolves to the matching project document or undefined if not found or cancelled. + */ private async getParsedProjectDocument(uri: string, version: number, token: CancellationToken): Promise { // Handle token cancellation. if (token.isCancellationRequested) return undefined; @@ -304,6 +310,10 @@ class WorkspaceEvents { // request is still waiting; re-read the latest instance to avoid stale waits const latestDocument = this.projectDocuments.get(uri); if (latestDocument) { + // For versioned requests, ensure we don't return a different version. + if (version > 0 && latestDocument.textDocument.version !== version) { + return; + } document = latestDocument; } } diff --git a/server/src/test/workspace.test.ts b/server/src/test/workspace.test.ts index 2795872..c1a433e 100644 --- a/server/src/test/workspace.test.ts +++ b/server/src/test/workspace.test.ts @@ -7,7 +7,6 @@ import { container } from 'tsyringe'; import { CancellationTokenSource } from 'vscode-languageserver'; import { Workspace } from '../project/workspace'; -import { ScopeItemCapability, ScopeType } from '../capabilities/capabilities'; import { ILanguageServer } from '../injection/interface'; function createMockConnection() { From 601e339f158d8c70a400ca0a1e44c16b928741fe Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:04:16 -0500 Subject: [PATCH 4/5] style: fix formatting --- server/src/project/workspace.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/server/src/project/workspace.ts b/server/src/project/workspace.ts index a8464e1..87ab01c 100644 --- a/server/src/project/workspace.ts +++ b/server/src/project/workspace.ts @@ -273,13 +273,13 @@ class WorkspaceEvents { this.initialiseDocumentsEvents(); this.documents.listen(connection); } -/** - * Returns a project document that matches the requested version, waiting for parsing to complete if necessary. - * @param uri The URI of the document. - * @param version The version of the document to match. If 0, will return the latest version of the document after parsing is complete. - * @param token A cancellation token to cancel the operation. - * @returns A promise that resolves to the matching project document or undefined if not found or cancelled. - */ + /** + * Returns a project document that matches the requested version, waiting for parsing to complete if necessary. + * @param uri The URI of the document. + * @param version The version of the document to match. If 0, will return the latest version of the document after parsing is complete. + * @param token A cancellation token to cancel the operation. + * @returns A promise that resolves to the matching project document or undefined if not found or cancelled. + */ private async getParsedProjectDocument(uri: string, version: number, token: CancellationToken): Promise { // Handle token cancellation. if (token.isCancellationRequested) return undefined; From 7c9caa48ac69e616b3c306bc587c85345aedfe75 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:04:39 -0500 Subject: [PATCH 5/5] fix: adjust timeout durations for document replacement race test --- server/src/test/workspace.test.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/server/src/test/workspace.test.ts b/server/src/test/workspace.test.ts index c1a433e..4b43d73 100644 --- a/server/src/test/workspace.test.ts +++ b/server/src/test/workspace.test.ts @@ -112,28 +112,24 @@ describe('Workspace document replacement race', () => { const tokenSource = new CancellationTokenSource(); const waitPromise = (events as any).getParsedProjectDocument(uri, 0, tokenSource.token); - - const replacementTimer = setTimeout(() => { - projectDocuments.set(uri, replacementDocument); - }, 20); + projectDocuments.set(uri, replacementDocument); try { const result = await Promise.race([ waitPromise, - new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 300)) + new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 1000)) ]); assert.notStrictEqual(result, 'timeout', 'Expected to resolve with replacement document, but request timed out'); assert.strictEqual(result, replacementDocument, 'Expected to return the replacement tracked document instance'); } finally { - clearTimeout(replacementTimer); tokenSource.cancel(); // Ensure no pending waiter keeps the test process alive when // assertions fail (e.g., when the replacement-refresh fix is absent). await Promise.race([ waitPromise.catch(() => undefined), - new Promise(resolve => setTimeout(resolve, 100)) + new Promise(resolve => setTimeout(resolve, 300)) ]); } });