Skip to content

Conversation

@weswigham
Copy link
Member

And, so we can test for these races more simply, enable the incremental mode program construction+emit codepath for compiler tests with @incremental: true specified.

There are other GetCheckerForFile calls in internal/execute/incremental that probably need to be swapped to GetCheckerForFileExclusive, as using basically any checker functions in a concurrent context without a lock on it could lead to a data race (thanks to lazy caching everywhere inside the checker), but the single call I changed here is definitely the root cause of the crash in the issue. I could just go swap them all, but we don't have regression tests for those sites to prove that's actually needed. The LS has request-scoped checker associations and assumes one thread per request, so is unlikely to need the per-checker lock functionality (though it is here, since I made it part of the pool interface), unless the number of in-flight requests exceeds the maximum number of available checkers (which can only happen if there's a disconnect between request workgroup max paralleism and max checkers afaik).

Fixes #1470

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR fixes a race condition in incremental compilation mode where concurrent access to the same checker for alias following operations could cause data races. The fix introduces thread-safe exclusive checker access via locks and enables incremental mode testing through compiler tests with @incremental: true.

  • Adds GetCheckerForFileExclusive methods to checker pool interfaces, which lock a mutex per checker to prevent concurrent access
  • Updates affectedfileshandler.go to use exclusive checker access for alias following operations
  • Extends the ProgramLike interface with additional methods needed for incremental program compatibility
  • Enables incremental mode in test infrastructure when @incremental: true is specified

Reviewed Changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
testdata/tests/cases/compiler/incrementalConcurrentSafeAliasFollowing.ts New regression test with multiple files and export aliases to reproduce the race condition
testdata/baselines/reference/compiler/incrementalConcurrentSafeAliasFollowing.* Expected baseline outputs for the new test
internal/project/checkerpool.go Adds per-checker mutex locks and GetCheckerForFileExclusive implementation for the project-level checker pool
internal/compiler/checkerpool.go Adds per-checker mutex locks and GetCheckerForFileExclusive implementation for the compiler-level checker pool
internal/compiler/program.go Exposes GetCheckerForFileExclusive and extends ProgramLike interface
internal/execute/incremental/affectedfileshandler.go Uses exclusive checker access to prevent race condition during alias following
internal/execute/incremental/program.go Implements additional ProgramLike interface methods for compatibility
internal/testutil/harnessutil/harnessutil.go Enables incremental program construction in tests, changes Program field to ProgramLike
internal/testutil/tsbaseline/*.go Updates to work with ProgramLike interface instead of concrete Program type
internal/testrunner/compiler_runner.go Adds type switching logic to extract underlying Program from ProgramLike

Comment on lines +91 to +94
done()
p.mu.Lock()
defer p.mu.Unlock()
p.locks[idx].Unlock()
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

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

The order of operations in the cleanup function is incorrect and could cause a deadlock or race condition. The done() callback should be called after unlocking p.locks[idx], not before. Additionally, acquiring p.mu after calling done() creates a potential for race conditions since the checker may be reassigned before the lock is released. The correct sequence should be: unlock the per-checker mutex, then call done() to release the checker back to the pool.

Suggested change
done()
p.mu.Lock()
defer p.mu.Unlock()
p.locks[idx].Unlock()
p.mu.Lock()
defer p.mu.Unlock()
p.locks[idx].Unlock()
done()

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

I think the suggestion is correct, since locks should be released in first-in-last-out order


func (p *checkerPool) GetCheckerForFileExclusive(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func()) {
c, done := p.GetCheckerForFile(ctx, file)
idx := slices.Index(p.checkers, c)
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

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

Missing error handling for when slices.Index returns -1 (checker not found). If the checker is not in the slice, this will cause a panic when accessing p.locks[idx]. Although this should never happen in normal operation, defensive programming suggests adding a check or assertion.

Suggested change
idx := slices.Index(p.checkers, c)
idx := slices.Index(p.checkers, c)
if idx == -1 {
panic("checker not found in checker pool")
}

Copilot uses AI. Check for mistakes.

//// [a.tsbuildinfo]
{
"version": "TEST",
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

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

Error: Unexpected token

Copilot uses AI. Check for mistakes.
switch desugared := c.result.Program.(type) {
case *compiler.Program:
{
p = desugared
Copy link
Member

Choose a reason for hiding this comment

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

Can ProgramLike have a method that avoids us having to do this repeatedly?

// GetCheckerForFileExclusive is the same as GetCheckerForFile but also locks a mutex associated with the checker.
// Call `done` to free the lock.
func (p *CheckerPool) GetCheckerForFileExclusive(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func()) {
c, done := p.GetCheckerForFile(ctx, file)
Copy link
Member

Choose a reason for hiding this comment

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

This implementation of checker pool can just leave this method unimplemented if it's only going to be used by incremental build. I have a cleanup/simplification of CheckerPool on the backburner so minimizing the surface area would be good.

Copy link
Member

@sheetalkamat sheetalkamat Nov 11, 2025

Choose a reason for hiding this comment

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

Multi project far spins off threads to work on different projects .. so we need to do something different like request id + project to ensure checkers are separate ? #1991 or use this method to get correctly locked checker

Copy link
Member

Choose a reason for hiding this comment

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

Is there not a separate checker pool per project in that case?

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.

fatal error: concurrent map read and map write

4 participants