Repository navigation
Request for a visible document can reach the server before its didOpen when hidden documents are open at start #1868
Description
Summary
When a LanguageClient starts while open but hidden documents match its document selector, a request for a visible document sent right after start() resolves can reach the server before that document's textDocument/didOpen. A server that validates the document then rejects the request (here with "Document is not open"). No middleware is involved; the client uses default options.
Reproduced with vscode-languageclient 10.1.2 on VS Code 1.139.1 and 1.91.1 (macOS arm64). The code involved is the same in 10.1.1 and on main as of 35409e7.
Reproduction
Five files. node run.js downloads VS Code stable, opens a workspace with 30 hidden documents and one visible one, and 10 times: creates a client, awaits start(), requests symbols for the visible document, and stops the client. The server logs every message it receives and rejects documentSymbol for a document it has no didOpen for.
package.json
{
"name": "lc-open-order-repro",
"version": "0.0.0",
"private": true,
"engines": { "vscode": "^1.91.0" },
"main": "./extension.js",
"activationEvents": [],
"dependencies": { "vscode-languageclient": "10.1.2" },
"devDependencies": { "@vscode/test-electron": "3.1.0" }
}extension.js
exports.activate = () => {};test/index.js
const path = require('path');
const vscode = require('vscode');
const { LanguageClient, TransportKind } = require('vscode-languageclient/node');
exports.run = async () => {
const workspace = process.env.REPRO_WORKSPACE;
// Documents that are loaded but not shown in an editor, and one visible one.
for (let i = 0; i < Number(process.env.REPRO_HIDDEN); i++) {
await vscode.workspace.openTextDocument(path.join(workspace, `hidden-${i}.txt`));
}
const visible = await vscode.workspace.openTextDocument(path.join(workspace, 'visible.txt'));
await vscode.window.showTextDocument(visible);
const server = { module: path.join(__dirname, '..', 'server.js'), transport: TransportKind.stdio };
for (let attempt = 0; attempt < Number(process.env.REPRO_ATTEMPTS); attempt++) {
// No middleware and default options.
const client = new LanguageClient('repro', 'Repro', { run: server, debug: server }, {
documentSelector: [{ scheme: 'file', language: 'plaintext' }],
});
await client.start();
// Ask for the visible document's symbols as soon as the client is running.
await vscode.commands.executeCommand('vscode.executeDocumentSymbolProvider', visible.uri);
await client.stop();
}
};server.js
// Minimal LSP server over stdio: records the order of incoming messages and
// rejects requests for documents it has not received a didOpen for.
const fs = require('fs');
const log = process.env.REPRO_LOG;
const open = new Set();
let buffer = Buffer.alloc(0);
function send(message) {
const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }));
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
process.stdout.write(body);
}
function handle(message) {
const uri = message.params?.textDocument?.uri ?? '';
fs.appendFileSync(log, `${message.method} ${uri.split('/').pop()}\n`);
switch (message.method) {
case 'initialize':
return send({ id: message.id, result: { capabilities: {
textDocumentSync: { openClose: true, change: 2 },
documentSymbolProvider: true,
} } });
case 'textDocument/didOpen': open.add(uri); return;
case 'textDocument/didClose': open.delete(uri); return;
case 'textDocument/documentSymbol':
if (!open.has(uri)) {
fs.appendFileSync(log, ` -> rejected: ${uri.split('/').pop()} is not open\n`);
return send({ id: message.id, error: { code: -32602, message: 'Document is not open' } });
}
return send({ id: message.id, result: [] });
case 'shutdown': return send({ id: message.id, result: null });
case 'exit': process.exit(0);
}
}
process.stdin.on('data', chunk => {
buffer = Buffer.concat([buffer, chunk]);
for (;;) {
const header = buffer.indexOf('\r\n\r\n');
if (header < 0) return;
const length = Number(/Content-Length: (\d+)/i.exec(buffer.subarray(0, header).toString())[1]);
if (buffer.length < header + 4 + length) return;
handle(JSON.parse(buffer.subarray(header + 4, header + 4 + length).toString()));
buffer = buffer.subarray(header + 4 + length);
}
});run.js
const fs = require('fs');
const os = require('os');
const path = require('path');
const { runTests } = require('@vscode/test-electron');
(async () => {
const log = path.join(__dirname, 'messages.log');
fs.rmSync(log, { force: true });
const workspace = path.join(__dirname, 'ws');
const hidden = Number(process.env.REPRO_HIDDEN ?? 30);
fs.rmSync(workspace, { recursive: true, force: true });
fs.mkdirSync(workspace);
for (let i = 0; i < hidden; i++) fs.writeFileSync(path.join(workspace, `hidden-${i}.txt`), 'hidden\n');
fs.writeFileSync(path.join(workspace, 'visible.txt'), 'visible\n');
const userData = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-repro-'));
await runTests({
vscodeExecutablePath: process.env.VSCODE_EXECUTABLE_PATH,
extensionDevelopmentPath: __dirname,
extensionTestsPath: path.join(__dirname, 'test', 'index.js'),
launchArgs: [workspace, '--disable-extensions', '--user-data-dir', userData],
extensionTestsEnv: { REPRO_LOG: log, REPRO_WORKSPACE: workspace, REPRO_HIDDEN: String(hidden), REPRO_ATTEMPTS: process.env.REPRO_ATTEMPTS ?? '10' },
});
const messages = fs.readFileSync(log, 'utf8');
process.stdout.write(messages);
const starts = messages.split('\n').filter(line => line.startsWith('initialize ')).length;
const rejected = messages.split('\n').filter(line => line.includes('-> rejected')).length;
console.log(`${rejected} of ${starts} starts sent documentSymbol before didOpen`);
})().catch(error => { console.error(error); process.exit(1); });Run with npm install && node run.js. REPRO_HIDDEN sets the number of hidden documents, and REPRO_ATTEMPTS the number of starts.
Results
On VS Code 1.139.1:
| Hidden documents | Starts where the request arrived before didOpen |
|---|---|
| 30 | 10 of 10, then 9 of 10 on a rerun |
| 100 | 10 of 10 |
| 0 | 0 of 10 |
It is a race, so the counts vary between runs; one earlier run of 3 starts gave 0 of 3.
The messages the server received during the first two starts with 30 hidden documents (runs of hidden opens condensed):
initialize
initialized
(13 x didOpen hidden-*)
textDocument/documentSymbol visible.txt
-> rejected: visible.txt is not open
(17 x didOpen hidden-*)
textDocument/didOpen visible.txt
shutdown
exit
initialize
initialized
(27 x didOpen hidden-*)
textDocument/documentSymbol visible.txt
-> rejected: visible.txt is not open
(3 x didOpen hidden-*)
textDocument/didOpen visible.txt
shutdown
exit
With 0 hidden documents every start gives the expected order:
initialize
initialized
textDocument/didOpen visible.txt
textDocument/documentSymbol visible.txt
shutdown
exit
On VS Code 1.91.1 we have also seen the visible document's didOpen arrive after shutdown.
Cause
From client/src/common/textSynchronization.ts and client/src/common/client.ts:
DidOpenTextDocumentFeature.register()iteratesworkspace.textDocuments. A hidden document goes into_pendingOpenNotifications. For a visible document it callsthis._client.sendNotification(...)without awaiting it, and records the document in_syncedDocumentsstraight away.BaseLanguageClient.sendNotification()awaits$start()and thenthis._didOpenTextDocumentFeature.sendPendingOpenNotifications(...)before writing its own message.sendPendingOpenNotifications()copies and clears the pending map, then awaitssuper.callback()for each hidden document in turn. So the visible document'sdidOpenis written only after every hidden one.BaseLanguageClient.sendRequest()also awaits$start()andsendPendingOpenNotifications(). But step 2 has already cleared the map, so this await returns at once, and the request is written while step 2 is still sending hidden opens.
The visible document is already in _syncedDocuments, so nothing else holds back a request for it. The more hidden documents are open, the wider the window. didChange also goes through sendNotification(), so it should be able to overtake the didOpen in the same way. The repro above only shows the request case, but in our extension an edit made right after a restart was lost until the next edit.
This looks different from #1717 / #1718 (pull diagnostics requested for a document whose open was still pending, fixed in 10.0.0-next.20) and from #1859 (lifecycle notifications parked across a crash restart). Here the request is for a visible document, and there is no middleware.
Expected
A request or didChange for a document is not sent until that document's didOpen has been written. One direction: a sendPendingOpenNotifications() call that finds a flush already in progress waits for it, and the visible documents' didOpen notifications are part of that ordered flush instead of being written after it.
Workaround
Middleware that records the didOpen promise per URI, and runs didChange, didSave, didClose and the provider calls for that URI after it:
const opening = new Map<string, Promise<void>>();
const afterOpen = <T>(document: vscode.TextDocument, send: () => T): T => {
const pending = opening.get(document.uri.toString());
return pending ? pending.then(send) as T : send();
};
// in clientOptions.middleware:
didOpen: (document, send) => {
const uri = document.uri.toString();
const sent = send(document);
const settled = sent.then(() => {}, () => {});
opening.set(uri, settled);
void settled.then(() => { if (opening.get(uri) === settled) opening.delete(uri); });
return sent;
},
didChange: (event, send) => afterOpen(event.document, () => send(event)),
provideDocumentSymbols: (document, token, send) => afterOpen(document, () => send(document, token)),
// ...and the same for the other features in use
Activity
dbaeumer commentedon Sep 28, 2026
qq: did you enable delay open notifications (e.g. this setting:
vscode-languageserver-node/client/src/common/textSynchronization.ts
Line 45 in 4cd3a5a
Fix document opening order for hidden documents at startup (#1871)
rvben commentedon Sep 28, 2026
Thanks Dirk Bäumer (@dbaeumer) !
dbaeumer commentedon Sep 29, 2026
Reopening since there is more work to do for the delay case
Fix open order of visible documents when delaying open notifications (#…