Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions tsc/internal/api/session_apistate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,66 @@ func TestGetCurrentLanguageServerSnapshotCloseAndReopenProject(t *testing.T) {
assert.Equal(t, session.openProjects.Len(), 0)
}

// Regression for #65071: closeProjects then openFiles used to nil-deref in
// acquireConfigForFile when dirty.SyncMap.LoadOrStore returned a tombstone as nil.
func TestCloseProjectsThenOpenFilesDoesNotPanic(t *testing.T) {
t.Parallel()

const (
configFileName = "/home/projects/p/tsconfig.json"
fileName = "/home/projects/p/a.ts"
)
init, _ := projecttestutil.GetSessionInitOptions(map[string]any{
configFileName: `{ "compilerOptions": { "strict": true }, "files": ["a.ts"] }`,
fileName: `export const a = 1;`,
}, nil, &projecttestutil.TypingsInstallerOptions{})
session := NewStandaloneSession(init, nil)
defer session.Close()
ctx := context.Background()

config := DocumentIdentifier{FileName: configFileName}
file := DocumentIdentifier{FileName: fileName}

snap, err := session.handleCreateSnapshot(ctx, &CreateSnapshotParams{
OpenProjects: []DocumentIdentifier{config},
OpenFiles: []DocumentIdentifier{file},
})
assert.NilError(t, err)

// Same update: closeProjects + openFiles (previously panicked).
_, err = session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{
Snapshot: snap.Snapshot,
Changes: &CreateSnapshotParams{
CloseProjects: []DocumentIdentifier{config},
OpenFiles: []DocumentIdentifier{file},
},
})
assert.NilError(t, err)

// Separate updates: closeProjects, then openFiles.
snap2, err := session.handleCreateSnapshot(ctx, &CreateSnapshotParams{
OpenProjects: []DocumentIdentifier{config},
OpenFiles: []DocumentIdentifier{file},
})
assert.NilError(t, err)
closed, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{
Snapshot: snap2.Snapshot,
Changes: &CreateSnapshotParams{
CloseProjects: []DocumentIdentifier{config},
},
})
assert.NilError(t, err)
reopened, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{
Snapshot: closed.Snapshot,
Changes: &CreateSnapshotParams{
OpenFiles: []DocumentIdentifier{file},
},
})
assert.NilError(t, err)
assert.Assert(t, reopened.Operation.OpenedFiles != nil)
assert.Equal(t, len(*reopened.Operation.OpenedFiles), 1)
}

func TestGetCurrentLanguageServerSnapshotCloseAndReopenFile(t *testing.T) {
t.Parallel()

Expand Down
14 changes: 12 additions & 2 deletions tsc/internal/project/dirty/syncmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,14 @@ func (m *SyncMap[K, V]) LoadOrStore(key K, value V) (*SyncMapEntry[K, V], bool)
dirty.mu.Lock()
defer dirty.mu.Unlock()
if dirty.delete {
return nil, false
// Resurrect a tombstone instead of returning nil. Callers such as
// acquireConfigForFile / findOrCreateProject treat LoadOrStore as
// "get or create" and immediately dereference the entry; a nil
// return after Delete panics (#65071, #65054, #64911).
dirty.value = value
dirty.delete = false
dirty.dirty = true
return dirty, false
}
return dirty, true
}
Expand All @@ -253,7 +260,10 @@ func (m *SyncMap[K, V]) LoadOrStore(key K, value V) (*SyncMapEntry[K, V], bool)
entry.mu.Lock()
defer entry.mu.Unlock()
if entry.delete {
return nil, false
entry.value = value
entry.delete = false
entry.dirty = true
return entry, false
}
}
return entry, loaded
Expand Down
57 changes: 57 additions & 0 deletions tsc/internal/project/dirty/syncmap_tombstone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package dirty

import (
"testing"

"gotest.tools/v3/assert"
)

// LoadOrStore after Delete must resurrect a tombstone with the new value
// instead of returning (nil, false). Callers that dereference the entry
// otherwise panic — #65071, #65054, #64911.
func TestLoadOrStoreAfterDeleteTombstone(t *testing.T) {
t.Parallel()

t.Run("base key deleted then LoadOrStore", func(t *testing.T) {
t.Parallel()
base := map[string]*testValue{
"key1": {data: "original"},
}
m := NewSyncMap(base)
m.Delete("key1")

entry, loaded := m.LoadOrStore("key1", &testValue{data: "new"})
assert.Assert(t, entry != nil, "LoadOrStore must not return nil entry after tombstone")
assert.Equal(t, false, loaded, "key was deleted so store should win")
assert.Equal(t, "new", entry.Value().data)
})

t.Run("dirty-only key deleted then LoadOrStore", func(t *testing.T) {
t.Parallel()
m := NewSyncMap(map[string]*testValue{})
e, loaded := m.LoadOrStore("k", &testValue{data: "first"})
assert.Equal(t, false, loaded)
assert.Assert(t, e != nil)
e.Delete()

entry, loaded2 := m.LoadOrStore("k", &testValue{data: "second"})
assert.Assert(t, entry != nil, "LoadOrStore must not return nil after dirty tombstone")
assert.Equal(t, false, loaded2)
assert.Equal(t, "second", entry.Value().data)
})

t.Run("Load after Delete then LoadOrStore resurrects", func(t *testing.T) {
t.Parallel()
base := map[string]*testValue{"a": {data: "base"}}
m := NewSyncMap(base)
m.Delete("a")
_, ok := m.Load("a")
assert.Equal(t, false, ok)

entry, _ := m.LoadOrStore("a", &testValue{data: "resurrected"})
assert.Assert(t, entry != nil)
got, ok2 := m.Load("a")
assert.Equal(t, true, ok2)
assert.Equal(t, "resurrected", got.Value().data)
})
}