Skip to content

Commit 79fe60a

Browse files
fix: release speculative parse-cache acquire using the acquired file's own key (#4285)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RyanCavanaugh <6685088+RyanCavanaugh@users.noreply.github.com>
1 parent 42eb4d4 commit 79fe60a

5 files changed

Lines changed: 65 additions & 9 deletions

File tree

internal/compiler/program.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,11 @@ func NewProgram(opts ProgramOptions) *Program {
281281
// In addition to a new program, return a boolean indicating whether the data of the old program was reused.
282282
// createCheckerPool, if non-nil, overrides the CreateCheckerPool stored in the old program's options,
283283
// ensuring each caller uses a fresh closure and avoiding data races on captured variables.
284-
func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHost, createCheckerPool func(*Program) CheckerPool) (*Program, bool) {
284+
// The returned *ast.SourceFile is the changed file as acquired through newHost; it is nil
285+
// only if the host cannot locate the file (e.g. it was deleted). Callers that manage
286+
// host-side parse caches must release this exact pointer when the old program could not be
287+
// reused, since it was acquired speculatively before that decision was made.
288+
func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHost, createCheckerPool func(*Program) CheckerPool) (*Program, *ast.SourceFile, bool) {
285289
newOpts := p.opts
286290
newOpts.Host = newHost
287291
if createCheckerPool != nil {
@@ -297,11 +301,11 @@ func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHos
297301
_, inRedirectFiles := p.redirectFilesByPath[changedFilePath]
298302
_, isRedirectTarget := p.redirectTargetsMap[changedFilePath]
299303
if inRedirectFiles || isRedirectTarget {
300-
return NewProgram(newOpts), false
304+
return NewProgram(newOpts), newFile, false
301305
}
302306

303307
if !canReplaceFileInProgram(oldFile, newFile) {
304-
return NewProgram(newOpts), false
308+
return NewProgram(newOpts), newFile, false
305309
}
306310
// TODO: reverify compiler options when config has changed?
307311
result := &Program{
@@ -322,7 +326,7 @@ func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHos
322326
result.filesByPath = maps.Clone(result.filesByPath)
323327
result.filesByPath[newFile.Path()] = newFile
324328
updateFileIncludeProcessor(result)
325-
return result, true
329+
return result, newFile, true
326330
}
327331

328332
func (p *Program) initCheckerPool() {

internal/project/logging/logger.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ func NewLogger(output io.Writer) Logger {
124124
}
125125
}
126126

127+
// NewNopLogger returns a no-op Logger that discards all log messages.
128+
// It is safe to call any method on the returned Logger.
129+
func NewNopLogger() Logger {
130+
return (*logger)(nil)
131+
}
132+
127133
func formatTime(t time.Time) string {
128134
return fmt.Sprintf("[%s]", t.Format("15:04:05.000"))
129135
}

internal/project/project.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -366,23 +366,26 @@ func (p *Project) CreateProgram() CreateProgramResult {
366366
commandLine := p.getCommandLineWithTypingsFiles()
367367

368368
if p.dirtyFilePath != "" && p.Program != nil && p.Program.CommandLine() == commandLine {
369-
newProgram, programCloned = p.Program.UpdateProgram(p.dirtyFilePath, p.host, createCheckerPool)
369+
var dirtyFile *ast.SourceFile
370+
newProgram, dirtyFile, programCloned = p.Program.UpdateProgram(p.dirtyFilePath, p.host, createCheckerPool)
370371
if programCloned {
371372
updateKind = ProgramUpdateKindCloned
372373
for _, file := range newProgram.SourceFiles() {
373-
if file.Path() != p.dirtyFilePath {
374+
// Use pointer identity: dirtyFile is the exact instance UpdateProgram acquired,
375+
// and it is the only file whose refcount is already accounted for.
376+
if file != dirtyFile {
374377
// UpdateProgram acquired the changed file only, so we need to ref everything else
375378
p.host.builder.parseCache.Ref(NewParseCacheKey(file.ParseOptions(), file.Hash, file.ScriptKind))
376379
}
377380
}
378381
for _, file := range newProgram.DuplicateSourceFiles() {
379382
p.host.builder.parseCache.Ref(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind))
380383
}
381-
} else if newFile := newProgram.GetSourceFileByPath(p.dirtyFilePath); newFile != nil {
384+
} else if dirtyFile != nil {
382385
// UpdateProgram always acquires the dirty file before deciding whether it can
383386
// reuse the old program. If it falls back to a full rebuild, release that
384387
// speculative acquire so the rebuilt program is the only remaining owner.
385-
p.host.builder.parseCache.Deref(NewParseCacheKey(newFile.ParseOptions(), newFile.Hash, newFile.ScriptKind))
388+
p.host.builder.parseCache.Deref(NewParseCacheKey(dirtyFile.ParseOptions(), dirtyFile.Hash, dirtyFile.ScriptKind))
386389
}
387390
} else {
388391
var typingsLocation string

internal/project/session.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,12 +189,16 @@ func NewSession(init *SessionInit) *Session {
189189
}
190190
extendedConfigCache := NewExtendedConfigCache()
191191

192+
sessionLogger := init.Logger
193+
if sessionLogger == nil {
194+
sessionLogger = logging.NewNopLogger()
195+
}
192196
session := &Session{
193197
backgroundCtx: init.BackgroundCtx,
194198
options: init.Options,
195199
toPath: toPath,
196200
client: init.Client,
197-
logger: init.Logger,
201+
logger: sessionLogger,
198202
npmExecutor: init.NpmExecutor,
199203
fs: overlayFS,
200204
parseCache: parseCache,

internal/project/snapshot_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,45 @@ func TestSnapshot(t *testing.T) {
171171
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, otherIndexURI)
172172
assert.NilError(t, err)
173173
})
174+
175+
t.Run("fallback rebuild with recomputed parse options is safe for later clone", func(t *testing.T) {
176+
t.Parallel()
177+
178+
testFiles := map[string]any{
179+
"/project/node_modules/pkg/index.ts": `export const pkg = 0;`,
180+
"/project/src/other.ts": `export const other = 1;`,
181+
}
182+
session := setup(testFiles)
183+
pkgURI := lsproto.DocumentUri("file:///project/node_modules/pkg/index.ts")
184+
otherURI := lsproto.DocumentUri("file:///project/src/other.ts")
185+
186+
session.DidOpenFile(context.Background(), pkgURI, 1, testFiles["/project/node_modules/pkg/index.ts"].(string), lsproto.LanguageKindTypeScript)
187+
session.DidOpenFile(context.Background(), otherURI, 1, testFiles["/project/src/other.ts"].(string), lsproto.LanguageKindTypeScript)
188+
_, err := session.GetLanguageService(context.Background(), pkgURI)
189+
assert.NilError(t, err)
190+
191+
err = session.fs.fs.WriteFile("/project/node_modules/pkg/package.json", `{ "type": "module" }`)
192+
assert.NilError(t, err)
193+
session.DidChangeFile(context.Background(), pkgURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
194+
{
195+
WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{
196+
Text: `import "./missing"; export const pkg = 1;`,
197+
},
198+
},
199+
})
200+
_, err = session.GetLanguageService(context.Background(), pkgURI)
201+
assert.NilError(t, err)
202+
203+
session.DidChangeFile(context.Background(), otherURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
204+
{
205+
WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{
206+
Text: `export const other = 2;`,
207+
},
208+
},
209+
})
210+
_, err = session.GetLanguageService(context.Background(), otherURI)
211+
assert.NilError(t, err)
212+
})
174213
}
175214

176215
func BenchmarkSnapshotCloneRefCost(b *testing.B) {

0 commit comments

Comments
 (0)