Skip to content

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

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

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.

Metadata

Metadata

Labels

Needs InvestigationThis issue needs a team member to investigate its status.

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions