microsoft / microsoft/TypeScript

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

Abierto
#63,887 2 comentarios 1 reacción 2 asignados Ver en GitHub

@RyanCavanaugh ya está trabajando en esto.

Desde el 20/8/2026.

  • #63933 de @copilot-swe-agent — abierto
Needs Investigation
Lenguaje dominante
Go
Estrellas
111k
Forks
14.3k
Merge medio
2 d 4 h
PR fusionados (30 d)
132

Descripción

## 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.

Guía de contribución

Abrir la guía de contribución

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.