microsoft / microsoft/TypeScript

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

未关闭
#63,887 2 条评论 1 个 reaction 已指派 2 人 在 GitHub 查看

@RyanCavanaugh 已经在做这个了。

开始于 2026年8月20日。

  • #63933 来自 @copilot-swe-agent —— 未关闭
Needs Investigation
主要语言
Go
星标
111k
派生
14.3k
平均合并
2 天 4 小时
30 天内合并 PR
132

描述

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

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.

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

评估

这个 Issue 还没有评估数据。

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。