microsoft / microsoft/TypeScript

API returns a stale SourceFile after the latest snapshot is disposed

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

@andrewbranch 已经在做这个了。

开始于 2026年8月19日。

Needs Investigation
主要语言
Go
星标
111k
派生
14.3k
平均合并
2 天 4 小时
30 天内合并 PR
132

描述

Summary

When an API client queries a source file, disposes the latest snapshot, edits the open file, and calls updateSnapshot again, the new snapshot can return the cached SourceFile from the disposed snapshot.

In VS Code this presents as follows: the first edit is reflected in the first API snapshot, but after that snapshot is disposed, a snapshot created for the second edit still contains the text from the first edit.

Observed with @typescript/native version 7.1.0-dev.20260818.1 using an API created through API.fromLSPConnection.

The caller follows this pattern:

const snapshot = await api.updateSnapshot({ openFiles: [{ uri }] });
try {
    const project = await snapshot.getDefaultProjectForFile({ uri });
    const sourceFile = await project?.program.getSourceFile({ uri });
    // sourceFile.text can contain the previous edit here
} finally {
    await snapshot.dispose();
}

Steps to reproduce

  1. Start the native preview language server and create an API with API.fromLSPConnection.
  2. Edit an open TypeScript file.
  3. Call updateSnapshot, fetch its SourceFile (populating the client-side source-file cache), and dispose the snapshot.
  4. Edit the same file again and let the LSP didChange notification complete.
  5. Call updateSnapshot again and fetch the same SourceFile.

A direct API regression test can use the equivalent lifecycle:

const firstSnapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" });
const firstSourceFile = await firstSnapshot
    .getProject("/tsconfig.json")!
    .program.getSourceFile("/src/foo.ts");
assert.ok(firstSourceFile);
await firstSnapshot.dispose();

fs.writeFile!("/src/foo.ts", `export const foo = "changed";`);
const secondSnapshot = await api.updateSnapshot({
    fileChanges: { changed: ["/src/foo.ts"] },
});
const secondSourceFile = await secondSnapshot
    .getProject("/tsconfig.json")!
    .program.getSourceFile("/src/foo.ts");

assert.equal(secondSourceFile?.text, `export const foo = "changed";`);

Expected behavior

The second snapshot returns a SourceFile containing the second edit.

Actual behavior

The second snapshot can return the cached SourceFile containing the first edit.

Likely cause

The server and JS client appear to have different lifetime assumptions for the latest disposed snapshot:

  • Session.releaseSnapshot deletes the latest snapshot from s.snapshots when its API refcount reaches zero, while s.latestSnapshot still points to that handle.
  • On the next handleUpdateSnapshot, prevSD := s.snapshots[s.latestSnapshot] is therefore nil, so the response has no SnapshotChanges diff.
  • The JS API intentionally retains cache references for a disposed latest snapshot. On the next update it calls SourceFileCache.retainForSnapshot before releasing that snapshot's cache references.
  • With data.changes === undefined, the cache treats every previously fetched source file as unchanged and retains it for the new snapshot. getSourceFile then returns it through getRetained without asking the server for the new content.

Relevant code paths:

  • internal/api/session.go: releaseSnapshot and handleUpdateSnapshot
  • _packages/native-preview/src/api/async/api.ts: API.updateSnapshot
  • _packages/native-preview/src/api/sourceFileCache.ts: retainForSnapshot and getRetained

Either the server needs to retain a diff base independently of the client-visible snapshot lifetime, or the client must not carry cache entries forward when its previous latest snapshot was disposed and no reliable diff is available.

贡献指南

打开贡献指南

从这里开始

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

评估

这个 Issue 还没有评估数据。

把新 issue 发到你的邮箱

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