microsoft / microsoft/TypeScript

textDocument/diagnostic on the first-opened file in a session can silently omit real errors

Open
#63,887 2 comments 1 reaction 2 assignees Claimed by @RyanCavanaugh View on GitHub
Needs Investigation
Dominant language
Go
Stars
111k
Forks
14.3k
Avg merge
2d 4h
Merged PRs (30d)
132

Description

## 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`
```ts
export function greet(name: string): string {
return `Hello, ${name}`;
}
```

`broken.ts`
```ts
import { greet } from "./helper";
export const message = greet("world");
const bad: string = 123; // real error: TS2322
```

`tsconfig.json`
```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:

```js
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

microsoft/TypeScript#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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.