From 507adf9965b9ff5b8cd0dd2b41f7e68fb6815699 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:39:56 +0000 Subject: [PATCH 1/9] Initial plan From 8fb4937f9a0c1bda482f051cfe93f2dacc356316 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:01:55 +0000 Subject: [PATCH 2/9] Fix parse cache ref resurrection race Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- internal/project/project.go | 4 +-- internal/project/refcountcache.go | 46 ++++++++++++++++++++++---- internal/project/refcountcache_test.go | 27 +++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/internal/project/project.go b/internal/project/project.go index 39c77aa78df..77349ddd5c4 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -374,11 +374,11 @@ func (p *Project) CreateProgram() CreateProgramResult { // and it is the only file whose refcount is already accounted for. if file != dirtyFile { // UpdateProgram acquired the changed file only, so we need to ref everything else - p.host.builder.parseCache.Ref(NewParseCacheKey(file.ParseOptions(), file.Hash, file.ScriptKind)) + p.host.builder.parseCache.RefValue(NewParseCacheKey(file.ParseOptions(), file.Hash, file.ScriptKind), file) } } for _, file := range newProgram.DuplicateSourceFiles() { - p.host.builder.parseCache.Ref(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind)) + p.host.builder.parseCache.TryRef(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind)) } } else if dirtyFile != nil { // UpdateProgram always acquires the dirty file before deciding whether it can diff --git a/internal/project/refcountcache.go b/internal/project/refcountcache.go index 909b5aa4b1b..921bfc61ea1 100644 --- a/internal/project/refcountcache.go +++ b/internal/project/refcountcache.go @@ -1,6 +1,7 @@ package project import ( + "runtime" "sync" "github.com/microsoft/typescript-go/internal/collections" @@ -60,20 +61,53 @@ func (c *RefCountCache[K, V, AcquireArgs]) Has(identity K) bool { // Ref increments the reference count for an existing entry. // Panics if the entry does not exist. func (c *RefCountCache[K, V, AcquireArgs]) Ref(identity K) { + if c.TryRef(identity) { + return + } + panic("cache entry not found") +} + +// TryRef increments the reference count for an existing entry. +// Returns false if the entry does not exist. +func (c *RefCountCache[K, V, AcquireArgs]) TryRef(identity K) bool { entry, ok := c.entries.Load(identity) if !ok { - panic("cache entry not found") + return false + } + return c.tryRefEntry(entry) +} + +// RefValue increments the reference count for an entry, restoring it with value +// if it was deleted before the ref could be taken. +func (c *RefCountCache[K, V, AcquireArgs]) RefValue(identity K, value V) { + for { + entry, ok := c.entries.Load(identity) + if !ok { + entry = &refCountCacheEntry[V]{ + value: value, + refCount: 1, + } + existing, loaded := c.entries.LoadOrStore(identity, entry) + if !loaded { + return + } + entry = existing + } + if c.tryRefEntry(entry) { + return + } + runtime.Gosched() } +} + +func (c *RefCountCache[K, V, AcquireArgs]) tryRefEntry(entry *refCountCacheEntry[V]) bool { entry.mu.Lock() defer entry.mu.Unlock() if entry.refCount <= 0 && !c.Options.DisableDeletion { - // Entry was deleted while we were acquiring the lock - newEntry, _ := c.loadOrStoreNewLockedEntry(identity) - defer newEntry.mu.Unlock() - newEntry.value = entry.value - return + return false } entry.refCount++ + return true } // Deref decrements the reference count for an entry. diff --git a/internal/project/refcountcache_test.go b/internal/project/refcountcache_test.go index 439588cd3e8..ae662a0108e 100644 --- a/internal/project/refcountcache_test.go +++ b/internal/project/refcountcache_test.go @@ -13,6 +13,33 @@ import ( "gotest.tools/v3/assert" ) +func TestRefCountCacheRefValueRestoresDeletedEntry(t *testing.T) { + t.Parallel() + + cache := NewRefCountCache(RefCountCacheOptions{}, func(_ string, value string) string { + return value + }) + + assert.Equal(t, cache.Acquire("key", "parsed"), "parsed") + cache.Deref("key") + assert.Assert(t, !cache.TryRef("key")) + + cache.RefValue("key", "restored") + entry, ok := cache.entries.Load("key") + assert.Assert(t, ok) + assert.Equal(t, entry.value, "restored") + assert.Equal(t, entry.refCount, 1) + + cache.RefValue("key", "ignored") + assert.Equal(t, entry.value, "restored") + assert.Equal(t, entry.refCount, 2) + + cache.Deref("key") + cache.Deref("key") + _, ok = cache.entries.Load("key") + assert.Assert(t, !ok) +} + func TestRefCountingCaches(t *testing.T) { t.Parallel() From 0e11f0b4297afd5f2f9021e0f95646b92c97c645 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:07:32 +0000 Subject: [PATCH 3/9] Address parse cache review feedback Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- internal/project/refcountcache.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/project/refcountcache.go b/internal/project/refcountcache.go index 921bfc61ea1..c634a2a7380 100644 --- a/internal/project/refcountcache.go +++ b/internal/project/refcountcache.go @@ -1,7 +1,6 @@ package project import ( - "runtime" "sync" "github.com/microsoft/typescript-go/internal/collections" @@ -96,7 +95,6 @@ func (c *RefCountCache[K, V, AcquireArgs]) RefValue(identity K, value V) { if c.tryRefEntry(entry) { return } - runtime.Gosched() } } From 089f1bd399ff7dee21642d7bb62f4fcaa30c0dce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:11:55 +0000 Subject: [PATCH 4/9] Address validation feedback on cache test Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- internal/project/refcountcache.go | 3 +++ internal/project/refcountcache_test.go | 16 ++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/project/refcountcache.go b/internal/project/refcountcache.go index c634a2a7380..14d11f29bbc 100644 --- a/internal/project/refcountcache.go +++ b/internal/project/refcountcache.go @@ -95,6 +95,9 @@ func (c *RefCountCache[K, V, AcquireArgs]) RefValue(identity K, value V) { if c.tryRefEntry(entry) { return } + // The deleted entry has already been removed from entries while we held its + // lock, so the next iteration will either store a replacement or race with + // another goroutine that did. } } diff --git a/internal/project/refcountcache_test.go b/internal/project/refcountcache_test.go index ae662a0108e..c1d0e2e1d99 100644 --- a/internal/project/refcountcache_test.go +++ b/internal/project/refcountcache_test.go @@ -25,19 +25,19 @@ func TestRefCountCacheRefValueRestoresDeletedEntry(t *testing.T) { assert.Assert(t, !cache.TryRef("key")) cache.RefValue("key", "restored") - entry, ok := cache.entries.Load("key") - assert.Assert(t, ok) - assert.Equal(t, entry.value, "restored") - assert.Equal(t, entry.refCount, 1) + assert.Equal(t, cache.Acquire("key", "ignored"), "restored") + cache.Deref("key") + cache.Deref("key") + assert.Assert(t, !cache.Has("key")) + cache.RefValue("key", "restored") cache.RefValue("key", "ignored") - assert.Equal(t, entry.value, "restored") - assert.Equal(t, entry.refCount, 2) + assert.Equal(t, cache.Acquire("key", "ignored"), "restored") cache.Deref("key") cache.Deref("key") - _, ok = cache.entries.Load("key") - assert.Assert(t, !ok) + cache.Deref("key") + assert.Assert(t, !cache.Has("key")) } func TestRefCountingCaches(t *testing.T) { From f8fef35a8c0925ada71f833d866f8df2c1cb6513 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:16:27 +0000 Subject: [PATCH 5/9] Refine parse cache validation comments Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- internal/project/refcountcache.go | 3 ++- internal/project/refcountcache_test.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/project/refcountcache.go b/internal/project/refcountcache.go index 14d11f29bbc..2abcf7d70d8 100644 --- a/internal/project/refcountcache.go +++ b/internal/project/refcountcache.go @@ -97,7 +97,8 @@ func (c *RefCountCache[K, V, AcquireArgs]) RefValue(identity K, value V) { } // The deleted entry has already been removed from entries while we held its // lock, so the next iteration will either store a replacement or race with - // another goroutine that did. + // another goroutine that did. If another goroutine wins, we'll ref its entry + // on the next iteration. } } diff --git a/internal/project/refcountcache_test.go b/internal/project/refcountcache_test.go index c1d0e2e1d99..c8efb83f8c5 100644 --- a/internal/project/refcountcache_test.go +++ b/internal/project/refcountcache_test.go @@ -17,10 +17,10 @@ func TestRefCountCacheRefValueRestoresDeletedEntry(t *testing.T) { t.Parallel() cache := NewRefCountCache(RefCountCacheOptions{}, func(_ string, value string) string { - return value + return "parsed:" + value }) - assert.Equal(t, cache.Acquire("key", "parsed"), "parsed") + assert.Equal(t, cache.Acquire("key", "original"), "parsed:original") cache.Deref("key") assert.Assert(t, !cache.TryRef("key")) From cb399d790a25b0cd5967ebe4633591b24c33b929 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:20:49 +0000 Subject: [PATCH 6/9] Clarify cache ref race handling Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- internal/project/project.go | 2 ++ internal/project/refcountcache.go | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/project/project.go b/internal/project/project.go index 77349ddd5c4..53a5a4faa6f 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -378,6 +378,8 @@ func (p *Project) CreateProgram() CreateProgramResult { } } for _, file := range newProgram.DuplicateSourceFiles() { + // Duplicate entries only balance extra acquires from the old program. + // If one was already released, we don't have a SourceFile value to restore. p.host.builder.parseCache.TryRef(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind)) } } else if dirtyFile != nil { diff --git a/internal/project/refcountcache.go b/internal/project/refcountcache.go index 2abcf7d70d8..4d75160592b 100644 --- a/internal/project/refcountcache.go +++ b/internal/project/refcountcache.go @@ -77,7 +77,8 @@ func (c *RefCountCache[K, V, AcquireArgs]) TryRef(identity K) bool { } // RefValue increments the reference count for an entry, restoring it with value -// if it was deleted before the ref could be taken. +// if it was deleted before the ref could be taken. If another goroutine restores +// the entry first, this refs that entry and ignores value. func (c *RefCountCache[K, V, AcquireArgs]) RefValue(identity K, value V) { for { entry, ok := c.entries.Load(identity) From 529c437813b87802e2eeff319db8fa41bcef92ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:25:18 +0000 Subject: [PATCH 7/9] Clarify parse cache duplicate refs Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- internal/project/project.go | 5 +++-- internal/project/refcountcache.go | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/project/project.go b/internal/project/project.go index 53a5a4faa6f..49917b028d5 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -378,8 +378,9 @@ func (p *Project) CreateProgram() CreateProgramResult { } } for _, file := range newProgram.DuplicateSourceFiles() { - // Duplicate entries only balance extra acquires from the old program. - // If one was already released, we don't have a SourceFile value to restore. + // Duplicate entries come from the old program's bookkeeping and only + // balance entries that were already acquired; unlike reused source files, + // they don't carry a SourceFile value that could restore a released entry. p.host.builder.parseCache.TryRef(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind)) } } else if dirtyFile != nil { diff --git a/internal/project/refcountcache.go b/internal/project/refcountcache.go index 4d75160592b..e2d151da65a 100644 --- a/internal/project/refcountcache.go +++ b/internal/project/refcountcache.go @@ -96,10 +96,10 @@ func (c *RefCountCache[K, V, AcquireArgs]) RefValue(identity K, value V) { if c.tryRefEntry(entry) { return } - // The deleted entry has already been removed from entries while we held its - // lock, so the next iteration will either store a replacement or race with - // another goroutine that did. If another goroutine wins, we'll ref its entry - // on the next iteration. + // The deleted entry was already removed from entries before we could ref it, + // so the next iteration will either store a replacement or race with another + // goroutine that did. If another goroutine wins, we'll ref its entry on the + // next iteration. } } From 577e001efb14284ce818cce80b068c8aa42709c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:29:46 +0000 Subject: [PATCH 8/9] Make optional cache refs explicit Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- internal/project/project.go | 2 +- internal/project/refcountcache.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/project/project.go b/internal/project/project.go index 49917b028d5..32b570cafa9 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -381,7 +381,7 @@ func (p *Project) CreateProgram() CreateProgramResult { // Duplicate entries come from the old program's bookkeeping and only // balance entries that were already acquired; unlike reused source files, // they don't carry a SourceFile value that could restore a released entry. - p.host.builder.parseCache.TryRef(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind)) + p.host.builder.parseCache.RefIfPresent(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind)) } } else if dirtyFile != nil { // UpdateProgram always acquires the dirty file before deciding whether it can diff --git a/internal/project/refcountcache.go b/internal/project/refcountcache.go index e2d151da65a..4915a533cc9 100644 --- a/internal/project/refcountcache.go +++ b/internal/project/refcountcache.go @@ -76,6 +76,12 @@ func (c *RefCountCache[K, V, AcquireArgs]) TryRef(identity K) bool { return c.tryRefEntry(entry) } +// RefIfPresent increments the reference count for an existing entry and does +// nothing if the entry does not exist. +func (c *RefCountCache[K, V, AcquireArgs]) RefIfPresent(identity K) { + c.TryRef(identity) +} + // RefValue increments the reference count for an entry, restoring it with value // if it was deleted before the ref could be taken. If another goroutine restores // the entry first, this refs that entry and ignores value. From fb66ad27180c437bca8233734ad01f9dfe3f08d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:33:10 +0000 Subject: [PATCH 9/9] Finalize cache ref validation feedback Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- internal/project/refcountcache.go | 5 ++--- internal/project/refcountcache_test.go | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/project/refcountcache.go b/internal/project/refcountcache.go index 4915a533cc9..b00c1710b06 100644 --- a/internal/project/refcountcache.go +++ b/internal/project/refcountcache.go @@ -103,9 +103,8 @@ func (c *RefCountCache[K, V, AcquireArgs]) RefValue(identity K, value V) { return } // The deleted entry was already removed from entries before we could ref it, - // so the next iteration will either store a replacement or race with another - // goroutine that did. If another goroutine wins, we'll ref its entry on the - // next iteration. + // so continue until this goroutine stores a replacement or another goroutine + // does. If another goroutine wins, we'll ref its entry on the next iteration. } } diff --git a/internal/project/refcountcache_test.go b/internal/project/refcountcache_test.go index c8efb83f8c5..819151fd17c 100644 --- a/internal/project/refcountcache_test.go +++ b/internal/project/refcountcache_test.go @@ -25,6 +25,8 @@ func TestRefCountCacheRefValueRestoresDeletedEntry(t *testing.T) { assert.Assert(t, !cache.TryRef("key")) cache.RefValue("key", "restored") + assert.Assert(t, cache.TryRef("key")) + cache.Deref("key") assert.Equal(t, cache.Acquire("key", "ignored"), "restored") cache.Deref("key") cache.Deref("key")