Skip to content

fix(lsp): hold tsserver during editor refreshes and fail fast on a stalled assertion - #264

Merged
mikn merged 1 commit into
mainfrom
mikn/lsp-generated-deletion
Oct 7, 2026
Merged

mikn merged 1 commit into
mainfrom
mikn/lsp-generated-deletion

Conversation

@mikn

@mikn mikn commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

//tests/integration:lsp_test intermittently runs into its 3600 s timeout in CI. It fails three times out of three on some branches; on main it passes or fails depending on cache state.

During its editor session, a generated member is deleted and refresh_generated runs. tsserver then keeps offering the deleted member, e.g. awaiting assertion: removedMembers.removed: completion membership. generated_editor_test.mjs retries that check every 250 ms until the test deadline, with logging off once the message repeats, so a precise assertion failure became a silent one-hour timeout.

Root cause

It only happens with a warm disk cache, which CI's shared integration cache provides.

  • Bazel's replacement gap. On a cache hit, Bazel replaces each changed output by deleting it and writing the cached copy about a millisecond later. A cold build runs the action in between, so the path stays missing for tens of milliseconds.
  • tsserver's watcher. tsserver (TypeScript 5.9.3; the same code is in 6.0.3) handles a delete event by checking whether the path still exists. If that check falls inside the millisecond gap, it switches to a missing-file poller. The poller's baseline stat already sees the new file, so it never reports "created". The poller ignores "changed" events, so the file is never watched again for the session.
  • Evidence. tsserver's verbose log shows first.d.ts deleted, then Changing watcher to MissingFileSystemEntryWatcher, and no "created" event ever follows. In the next phase the file is replaced on disk with no event at all. Sending reloadProjects makes the stuck check pass immediately.
  • Which session hangs is random. The plugin and no-plugin sessions each win or lose the race independently, so the stuck assertion varies between runs.

What changes

  • tests/lsp/generated_editor_test.mjs pauses the running tsserver processes (SIGSTOP) for the duration of each refresh and resumes them (SIGCONT, in a finally). tsserver then handles each replacement only once it is complete: its existence check finds the new file and it watches the new inode.
  • A check that keeps failing with the same assertion for 30 s now throws that assertion instead of waiting for the test deadline. Normal settling takes a few watcher polls; the 250 ms retry is unchanged.
  • docs/getting-started/ide-setup.md documents the editor-side limitation: after a refresh served from a cache, tsserver's own watcher can keep a stale generated declaration until the TS server restarts.

The ruleset's refresh is unchanged. Rewriting or touching the file cannot help once the watcher has died: a dead watcher ignores "changed", and "created" only fires if a poll sees the path missing. Disabling the disk cache for the test rebuilt about 1,350 actions per pass and still hit the timeout once.

Verification

All on the remote runner. A "warm pass" reruns the test with a shared RULES_TS_IT_SCRATCH, so its nested Bazel sees a warm disk cache.

Variant Cold pass Warm passes
main (before) passes hang, TIMEOUT at 200 s and 280 s
fail-fast only passes 2 pass; the 3rd fails in 68 s with the assertion instead of hanging
this change passes (196 s; 190 s) 5 of 5 pass (55–61 s each), in two separate runs

//tests/lsp/... passes 11 of 11, and //tests/integration:lsp_test passes with its normal settings.

Risks

  • The test no longer covers tsserver observing a refresh while it is in progress, which is exactly where the TypeScript watcher bug lives. Editors that rely on tsserver's built-in watcher keep the limitation documented above.
  • The watcher bug belongs upstream in TypeScript's missing-file watcher: the poller's baseline is taken after the existence check, and its "changed" events are ignored.

🤖 Generated with Claude Code

…alled assertion

On a warm disk cache, Bazel deletes a changed output and writes its cached
replacement within about a millisecond. When tsserver handles the deletion in
that window, its fs-event watcher finds the path missing and falls back to a
poller whose first stat already sees the new file. That poller reports only
creations, so tsserver keeps the stale declaration for the rest of the session,
and the editor test retried it until the enclosing test deadline.

The editor test now stops both tsserver processes while a refresh runs, so
they handle each replacement only after it is complete. A check that fails
with the same assertion for 30 s now throws instead of waiting for the
deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikn
mikn merged commit d117cbc into main Oct 7, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant