Skip to content

Skip stale overlay paths in markProjectsAffectedByConfigChanges - #4790

Open
blixt wants to merge 1 commit into
microsoft:mainfrom
flitsinc:fix-stale-overlay-config-affected-files
Open

Skip stale overlay paths in markProjectsAffectedByConfigChanges#4790
blixt wants to merge 1 commit into
microsoft:mainfrom
flitsinc:fix-stale-overlay-config-affected-files

Conversation

@blixt

@blixt blixt commented Jul 29, 2026

Copy link
Copy Markdown

Problem

We embed internal/project in a long-running server and see recurring fatal crashes with this stack (currently several per day across our fleet):

panic: runtime error: invalid memory address or nil pointer dereference
github.com/microsoft/typescript-go/internal/project.(*ProjectCollectionBuilder).markProjectsAffectedByConfigChanges
	internal/project/projectcollectionbuilder.go:710
github.com/microsoft/typescript-go/internal/project.(*ProjectCollectionBuilder).DidChangeFiles
github.com/microsoft/typescript-go/internal/project.(*Snapshot).Clone
github.com/microsoft/typescript-go/internal/project.(*Session).updateSnapshot

The crash site is:

for path := range configChangeResult.affectedFiles {
	fileName := b.fs.overlays[path].FileName()
	...
}

affectedFiles is keyed by the config file registry's configFileNames cache, and the unchecked map lookup assumes every cached path is still overlay-backed. When a stale entry survives for a file that is no longer open, the lookup returns a nil *Overlay and FileName() panics.

Root cause

The two states advance at different times:

  • The session-level overlay map is committed eagerly by flushChangesLockedoverlayFS.processChanges, when pending changes are flushed.
  • The registry bookkeeping for a close (configFileRegistryBuilder.didCloseFile, which deletes the configFileNames entry) only happens later, when the snapshot clone processes that flush's FileChangeSummary.

If a snapshot update is interrupted between those two steps, the Closed event is consumed forever while the registry entry survives. The most realistic interruption is a panic during the snapshot update that gets recovered at the request boundary — lsp.Server.recover does exactly this and keeps the session alive (as does any embedding host with per-request recovery).

After that, the very next tsconfig.json create/change/delete in an ancestor directory of the stale entry — or any excessive-watch-event invalidateCache pass, which reports every cached path — puts the stale path into affectedFiles and crashes. Worse, the panic fires inside Session.updateSnapshot while snapshotMu is held, so a host that recovers it is left with a permanently wedged session (every subsequent request blocks on the leaked lock).

Fix

Skip affectedFiles paths that no longer have an overlay. This is safe and self-healing rather than signal-hiding:

  • Both producers of affectedFiles (invalidateCache and the created/deleted-config handling in DidChangeFiles) have already removed the stale configFileNames entry by the time the consumer runs, so skipping leaves no stale state behind — the registry converges back to a consistent state on the first config event instead of crashing.
  • A file that is no longer open has no default project to recompute; ensureConfiguredProjectAndAncestorsForFile on it would operate on a closed file.
  • Open files in the same batch are still recomputed, and affectedProjects marking is untouched, so config-change invalidation behavior is preserved.

Test

TestMarkProjectsAffectedByConfigChangesStaleOverlay is a white-box regression test that opens two files under one tsconfig, severs a snapshot update between the overlay-map commit and the registry bookkeeping using the session's own flush machinery (queue a Close, call flushChangesLocked, never clone — the exact state a recovered mid-update panic leaves), then changes the tsconfig.

  • Before the fix it panics with exactly the production stack above (also reproduced on a ~2-week-old commit of main).
  • After the fix it passes and asserts the affected project is still reloaded (target change takes effect), the open file's config lookup is recomputed, and the stale entry is gone.

go test ./internal/project/... and -race on the new test pass.

Disclosure

This patch was authored with AI assistance (Claude Code); I have read and understood the change and will respond to review feedback myself.

The config file registry caches config file lookups (configFileNames) for
open files and removes an entry when the file's Closed event is processed
during a snapshot clone. The session-level overlay map, however, is
updated eagerly by flushChangesLocked before the clone runs. If a
snapshot update is interrupted between those two steps - e.g. by a panic
recovered at the request boundary (Server.recover), which leaves the
session alive - the overlay is gone but the registry entry survives.

The next config file create/change/delete in an ancestor directory of the
stale entry (or an excessive-watch-event cache invalidation) then reports
the stale path in changeFileResult.affectedFiles, and
markProjectsAffectedByConfigChanges crashes dereferencing the missing
overlay - inside Session.updateSnapshot while snapshotMu is held, so a
host that recovers the panic is left with a permanently wedged session.

Skip affectedFiles paths that no longer have an overlay: the producers of
affectedFiles have already dropped the stale cache entry, and a file that
is no longer open has no default project to recompute, so the registry
self-heals. Open files in the same batch are still recomputed and
affected projects are still marked dirty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 15:29

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand Claude's addiction with adding entirely new test files.

There's a perfectly good test file already

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very sorry this made it to your attention, the intent was actually for me to draft it and give it a human touch (the agent took me too literally when I said "so we can create a PR later"). Let me know if I should delete it and resend it when it's actually ready.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we already have a test file for this code, projectcollectiobuilder_test.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Prevents stale closed-file registry entries from crashing snapshot updates during config changes.

Changes:

  • Skips affected paths without overlays.
  • Adds a regression test reproducing the interrupted-update state.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
internal/project/projectcollectionbuilder.go Guards stale overlay lookups.
internal/project/projectcollectionbuilder_staleoverlay_test.go Tests config reload behavior with a stale overlay path.

if logger != nil {
logger.Logf("Skipping default project recomputation for %s: no overlay (file is no longer open)", path)
}
continue
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.

3 participants