Summary
The first textDocument/diagnostic (pull) request against a file, in a fresh tsgo --lsp session, can return {"kind":"full","items":[]} — no diagnostics — even when the file has a real, unambiguous error, and even after the request completes normally and program construction has finished. It is not a timing issue: waiting 2s, 8s, and 10s+ before issuing the pull request all reproduce it identically, and retrying the pull request itself doesn't help either. Querying a different file first, then the original file, reliably makes the original file's diagnostics correct.
This reproduced with two unrelated diagnostic codes (a missing-module error, TS2307, and a plain type mismatch, TS2322), so it doesn't look specific to one kind of check.
Minimal repro
Two files, no external dependencies:
helper.ts
export function greet(name: string): string {
return `Hello, ${name}`;
}
broken.ts
import { greet } from "./helper";
export const message = greet("world");
const bad: string = 123; // real error: TS2322
tsconfig.json
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noEmit": true }, "include": ["*.ts"] }
Standalone Node script (no dependencies beyond node:child_process/node:fs) — byte-accurate Content-Length framing, opens broken.ts as the only file in the session, waits, then pulls diagnostics:
import { spawn } from 'node:child_process';
import fs from 'node:fs';
const TSGO = process.argv[2]; // path to tsgo binary
const DIR = process.argv[3]; // path to the fixture directory above
const FILE = `${DIR}/broken.ts`;
const child = spawn(TSGO, ['--lsp', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] });
child.stderr.on('data', () => {});
let buf = Buffer.alloc(0);
const listeners = new Set();
child.stdout.on('data', (c) => {
buf = Buffer.concat([buf, c]);
while (true) {
const he = buf.indexOf('\r\n\r\n'); if (he === -1) break;
const m = /Content-Length: (\d+)/i.exec(buf.subarray(0, he).toString('ascii'));
if (!m) { buf = buf.subarray(he + 4); continue; }
const len = parseInt(m[1], 10), bs = he + 4;
if (buf.length < bs + len) break;
const body = buf.subarray(bs, bs + len); buf = buf.subarray(bs + len);
let msg; try { msg = JSON.parse(body.toString('utf8')); } catch { continue; }
if (msg.method && msg.id !== undefined && !msg.result && !msg.error) {
const rb = JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: null });
child.stdin.write(`Content-Length: ${Buffer.byteLength(rb, 'utf8')}\r\n\r\n${rb}`);
}
for (const l of [...listeners]) l(msg);
}
});
function send(method, params) { const b = JSON.stringify({ jsonrpc: '2.0', method, params }); child.stdin.write(`Content-Length: ${Buffer.byteLength(b, 'utf8')}\r\n\r\n${b}`); }
let seq = 0;
function sendReq(method, params) { const id = ++seq; const b = JSON.stringify({ jsonrpc: '2.0', id, method, params }); child.stdin.write(`Content-Length: ${Buffer.byteLength(b, 'utf8')}\r\n\r\n${b}`); return id; }
function waitFor(pred, ms = 15000) { return new Promise((res, rej) => { const t = setTimeout(() => rej(new Error('timeout')), ms); const h = (m) => { if (pred(m)) { clearTimeout(t); listeners.delete(h); res(m); } }; listeners.add(h); }); }
const toUri = (p) => `file://${p}`;
(async () => {
const initId = sendReq('initialize', { processId: process.pid, rootUri: toUri(DIR), capabilities: { textDocument: { publishDiagnostics: {}, diagnostic: {} } } });
await waitFor((m) => m.id === initId);
send('initialized', {});
send('textDocument/didOpen', { textDocument: { uri: toUri(FILE), languageId: 'typescript', version: 1, text: fs.readFileSync(FILE, 'utf8') } });
await new Promise((r) => setTimeout(r, 10000)); // 10s+ wait — does not help
const id = sendReq('textDocument/diagnostic', { textDocument: { uri: toUri(FILE) } });
const resp = await waitFor((m) => m.id === id);
console.log('broken.ts, first file in session:', JSON.stringify(resp.result));
// Expected: one item, TS2322. Actual: { kind: "full", items: [] }
child.kill('SIGKILL');
process.exit(0);
})();
What's been ruled out
| Hypothesis |
Result |
| Simple timing race, needs a short wait |
No — reproduces identically at 2s, and does not resolve at 10s+ |
| Needs a retry of the pull request |
No — 3 retries with backoff (0/500/1500ms) still returns empty |
| Needs any second file opened first |
No — opening an unrelated file (same project) first does not resolve it, tested against a large real-world project |
| Needs a diagnostic pull for any other file first |
No — pulling diagnostics for an unrelated file first, then pulling the target, still returns empty, tested against the same large real-world project |
| Needs a related file (same project) opened and queried first |
Yes in every case tested — but see the open question below |
Open question — project scale, or flakiness?
Against a large real-world project (~11,000 files in the reachable graph), an unrelated same-project file did not unblock the target file — only a specific working sequence did. Against the minimal two-file fixture above, an unrelated sibling file (importing the same helper.ts but with no direct relationship to broken.ts) reliably did unblock it. Same failure class, opposite behavior depending on project size. I haven't run enough repetitions to rule out flakiness as the actual explanation rather than a real scale-dependent rule — flagging this honestly rather than asserting a clean mechanism.
Environment
microsoft/typescript-go built from source, main, commit from 2026-08-12
- macOS, arm64
tsgo --lsp --stdio
Possibly related
#63859 describes root-file-order affecting genuine conditional-type inference results in the batch compiler, which a maintainer noted is an accepted/intentional class of behavior for TS7's type ordering. This looks like a different mechanism — the errors here are unambiguous (a missing module, a plain type mismatch), and the issue is specifically that the LSP pull-diagnostics response for the first-queried file is empty rather than the underlying type-checking result differing — but flagging it in case they're related at some lower level.
Summary
The first
textDocument/diagnostic(pull) request against a file, in a freshtsgo --lspsession, can return{"kind":"full","items":[]}— no diagnostics — even when the file has a real, unambiguous error, and even after the request completes normally and program construction has finished. It is not a timing issue: waiting 2s, 8s, and 10s+ before issuing the pull request all reproduce it identically, and retrying the pull request itself doesn't help either. Querying a different file first, then the original file, reliably makes the original file's diagnostics correct.This reproduced with two unrelated diagnostic codes (a missing-module error, TS2307, and a plain type mismatch, TS2322), so it doesn't look specific to one kind of check.
Minimal repro
Two files, no external dependencies:
helper.tsbroken.tstsconfig.json{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noEmit": true }, "include": ["*.ts"] }Standalone Node script (no dependencies beyond
node:child_process/node:fs) — byte-accurate Content-Length framing, opensbroken.tsas the only file in the session, waits, then pulls diagnostics:What's been ruled out
Open question — project scale, or flakiness?
Against a large real-world project (~11,000 files in the reachable graph), an unrelated same-project file did not unblock the target file — only a specific working sequence did. Against the minimal two-file fixture above, an unrelated sibling file (importing the same
helper.tsbut with no direct relationship tobroken.ts) reliably did unblock it. Same failure class, opposite behavior depending on project size. I haven't run enough repetitions to rule out flakiness as the actual explanation rather than a real scale-dependent rule — flagging this honestly rather than asserting a clean mechanism.Environment
microsoft/typescript-gobuilt from source,main, commit from 2026-08-12tsgo --lsp --stdioPossibly related
#63859 describes root-file-order affecting genuine conditional-type inference results in the batch compiler, which a maintainer noted is an accepted/intentional class of behavior for TS7's type ordering. This looks like a different mechanism — the errors here are unambiguous (a missing module, a plain type mismatch), and the issue is specifically that the LSP pull-diagnostics response for the first-queried file is empty rather than the underlying type-checking result differing — but flagging it in case they're related at some lower level.