From fd61ce9c91cbc23641512317b39c7947fbbbb1d1 Mon Sep 17 00:00:00 2001 From: jackeddisciple Date: Sat, 24 Oct 2026 01:20:31 +0530 Subject: [PATCH] Don't reuse removed synthetic program IDs in the same update --- packages/typescript/test/async/api.test.ts | 30 +++++++++ packages/typescript/test/sync/api.test.ts | 30 +++++++++ tsc/internal/api/session_apistate_test.go | 37 +++++++++++ .../api/session_createprogram_test.go | 42 ++++++++++++ .../project/projectcollectionbuilder.go | 3 + tsc/internal/project/snapshot_test.go | 66 +++++++++++++++++++ 6 files changed, 208 insertions(+) diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 379f07b6b68f7..54e525883b1fc 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -868,6 +868,36 @@ describe("API", { concurrency }, () => { assert.deepEqual(await originalProgram.getSourceFileNames(), ["/src/a.ts"]); }); + test("snapshot.update removes and creates synthetic programs together", async () => { + await using api = spawnAPI({ + "/src/a.ts": `export const a = 1;`, + "/src/b.ts": `export const b = 2;`, + }); + const snapshot = await api.createSnapshot({ + createPrograms: [{ + rootFiles: ["/src/a.ts"], + compilerOptions: { noLib: true }, + }], + }); + const removedProgram = snapshot.operation.createdPrograms[0]; + + const updated = await snapshot.update({ + removePrograms: [removedProgram.id], + createPrograms: [{ + rootFiles: ["/src/b.ts"], + compilerOptions: { noLib: true }, + }], + }); + const createdProgram = updated.operation.createdPrograms[0]; + + assert.notEqual(createdProgram.id, removedProgram.id); + assert.equal(updated.getProgram(removedProgram.id), undefined); + assert.deepEqual(updated.getProjects().map(project => project.id), [createdProgram.id]); + assert.deepEqual(await createdProgram.getSourceFileNames(), ["/src/b.ts"]); + assert.deepEqual(await createdProgram.getSemanticDiagnostics("/src/b.ts"), []); + assert.deepEqual(await removedProgram.getSourceFileNames(), ["/src/a.ts"]); + }); + test("snapshot.update reconfigures module resolution providers", async () => { const root = toRootedFilePath("/src/index.ts", undefined); const providedA = toRootedFilePath("/a.d.ts", undefined); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index f048e8ede87e6..1b248c5b4f6bd 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -855,6 +855,36 @@ describe("API", { concurrency }, () => { assert.deepEqual(originalProgram.getSourceFileNames(), ["/src/a.ts"]); }); + test("snapshot.update removes and creates synthetic programs together", () => { + using api = spawnAPI({ + "/src/a.ts": `export const a = 1;`, + "/src/b.ts": `export const b = 2;`, + }); + const snapshot = api.createSnapshot({ + createPrograms: [{ + rootFiles: ["/src/a.ts"], + compilerOptions: { noLib: true }, + }], + }); + const removedProgram = snapshot.operation.createdPrograms[0]; + + const updated = snapshot.update({ + removePrograms: [removedProgram.id], + createPrograms: [{ + rootFiles: ["/src/b.ts"], + compilerOptions: { noLib: true }, + }], + }); + const createdProgram = updated.operation.createdPrograms[0]; + + assert.notEqual(createdProgram.id, removedProgram.id); + assert.equal(updated.getProgram(removedProgram.id), undefined); + assert.deepEqual(updated.getProjects().map(project => project.id), [createdProgram.id]); + assert.deepEqual(createdProgram.getSourceFileNames(), ["/src/b.ts"]); + assert.deepEqual(createdProgram.getSemanticDiagnostics("/src/b.ts"), []); + assert.deepEqual(removedProgram.getSourceFileNames(), ["/src/a.ts"]); + }); + test("snapshot.update reconfigures module resolution providers", () => { const root = toRootedFilePath("/src/index.ts", undefined); const providedA = toRootedFilePath("/a.d.ts", undefined); diff --git a/tsc/internal/api/session_apistate_test.go b/tsc/internal/api/session_apistate_test.go index 585e2aa3442fc..1d20938be7fb0 100644 --- a/tsc/internal/api/session_apistate_test.go +++ b/tsc/internal/api/session_apistate_test.go @@ -285,6 +285,43 @@ func TestGetCurrentLanguageServerSnapshotCreatesAndRemovesPrograms(t *testing.T) assert.Equal(t, len(projectSession.Snapshot().ProjectCollection.SyntheticProjects()), 0) } +func TestGetCurrentLanguageServerSnapshotRemovesAndCreatesPrograms(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{fileName: `export const value = 1;`}) + defer projectSession.Close() + + session := NewLSPSession(projectSession, nil) + _, err := session.handleGetCurrentLanguageServerSnapshot(context.Background(), &GetCurrentLanguageServerSnapshotParams{ + Changes: &LanguageServerSnapshotChanges{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }}, + }, + }) + assert.NilError(t, err) + + replaced, err := session.handleGetCurrentLanguageServerSnapshot(context.Background(), &GetCurrentLanguageServerSnapshotParams{ + Changes: &LanguageServerSnapshotChanges{ + RemovePrograms: []project.SyntheticProjectID{syntheticProjectID(1)}, + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }}, + }, + }) + assert.NilError(t, err) + assert.DeepEqual(t, *replaced.Operation.CreatedPrograms, []project.SyntheticProjectID{syntheticProjectID(2)}) + assert.Equal(t, len(projectSession.Snapshot().ProjectCollection.SyntheticProjects()), 1) + assert.Assert(t, !session.createdPrograms.Has(syntheticProjectID(1))) + assert.Assert(t, session.createdPrograms.Has(syntheticProjectID(2))) + + session.Close() + assert.Equal(t, len(projectSession.Snapshot().ProjectCollection.SyntheticProjects()), 0) +} + func TestOpenFilePreservesWindowsDriveLetterCase(t *testing.T) { t.Parallel() diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index d3fff608e7c84..7db098cfa7ac3 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -280,6 +280,48 @@ func TestUpdateSnapshotEnsuresSyntheticProgram(t *testing.T) { assert.Equal(t, ensured.Projects[0].Dirty, false) } +func TestUpdateSnapshotRemovesAndCreatesPrograms(t *testing.T) { + t.Parallel() + + const ( + fileA = "/home/projects/p/a.ts" + fileB = "/home/projects/p/b.ts" + ) + projectSession, _ := projecttestutil.Setup(map[string]any{ + fileA: "export const a = 1;", + fileB: "export const b = 1;", + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + + created, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: fileA}}, + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }}, + }) + assert.NilError(t, err) + assert.DeepEqual(t, *created.Operation.CreatedPrograms, []project.SyntheticProjectID{syntheticProjectID(1)}) + + updated, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + Snapshot: created.Snapshot, + Changes: &CreateSnapshotParams{ + RemovePrograms: []project.SyntheticProjectID{syntheticProjectID(1)}, + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: fileB}}, + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }}, + }, + }) + assert.NilError(t, err) + assert.DeepEqual(t, *updated.Operation.CreatedPrograms, []project.SyntheticProjectID{syntheticProjectID(2)}) + assert.DeepEqual(t, updated.Changes.RemovedProjects, []project.ID{syntheticProjectID(1).AsID()}) + assert.Equal(t, len(updated.Projects), 1) + assert.Equal(t, updated.Projects[0].Id, syntheticProjectID(2).AsID()) + assert.DeepEqual(t, updated.Projects[0].ParsedCommandLine.FileNames, []tspath.RootedFilePath{fileB}) +} + func TestCreateProgramReportsNonCompositeProjectReference(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 99872b4cb6eee..65639aadb2e8e 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -388,6 +388,9 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque func (b *ProjectCollectionBuilder) nextSyntheticProjectID() SyntheticProjectID { for id := 1; ; id++ { projectID := NewSyntheticProjectID(id) + if _, ok := b.base.syntheticProjects[projectID]; ok { + continue + } if _, ok := b.syntheticProjects.Load(projectID); !ok { return projectID } diff --git a/tsc/internal/project/snapshot_test.go b/tsc/internal/project/snapshot_test.go index 76a1b07d8cf7a..a1c024d653518 100644 --- a/tsc/internal/project/snapshot_test.go +++ b/tsc/internal/project/snapshot_test.go @@ -122,6 +122,72 @@ func TestSnapshot(t *testing.T) { assert.Equal(t, len(removedSnapshot.ProjectCollection.SyntheticProjects()), 1) }) + t.Run("removes and creates synthetic programs in one update", func(t *testing.T) { + t.Parallel() + session := setup(map[string]any{ + "/a.ts": "export const a = 1;", + "/b.ts": "export const b = 1;", + }) + defer session.Close() + + ctx := context.Background() + options := &core.CompilerOptions{NoLib: core.TSTrue} + createdSnapshot, err := session.CloneSnapshot( + ctx, + session.Snapshot(), + FileChangeSummary{}, + &APISnapshotRequest{CreatePrograms: []*APICreateProgramRequest{{ + RootFileNames: []tspath.RootedFilePath{"/a.ts"}, + CompilerOptions: options, + }}}, + ) + assert.NilError(t, err) + defer createdSnapshot.Deref() + firstProject := createdSnapshot.CreatedPrograms()[0] + firstProgramID, ok := firstProject.ID().Synthetic() + assert.Assert(t, ok) + + replacedSnapshot, err := session.CloneSnapshot( + ctx, + createdSnapshot, + FileChangeSummary{}, + &APISnapshotRequest{ + RemovePrograms: collections.NewSetFromItems(firstProgramID), + CreatePrograms: []*APICreateProgramRequest{{ + RootFileNames: []tspath.RootedFilePath{"/b.ts"}, + CompilerOptions: options, + }}, + }, + ) + assert.NilError(t, err) + defer replacedSnapshot.Deref() + + createdPrograms := replacedSnapshot.CreatedPrograms() + assert.Equal(t, len(createdPrograms), 1) + secondProject := createdPrograms[0] + assert.Assert(t, secondProject.ID() != firstProject.ID()) + assert.DeepEqual(t, secondProject.CommandLine.FileNames(), []tspath.RootedFilePath{"/b.ts"}) + assert.Assert(t, secondProject.GetProgram().GetSourceFile("/b.ts") != nil) + assert.Assert(t, replacedSnapshot.ProjectCollection.GetProject(firstProject.ID()) == nil) + assert.Equal(t, replacedSnapshot.ProjectCollection.GetProject(secondProject.ID()), secondProject) + assert.Equal(t, len(replacedSnapshot.ProjectCollection.SyntheticProjects()), 1) + assert.Equal(t, createdSnapshot.ProjectCollection.GetProject(firstProject.ID()), firstProject) + + reusedSnapshot, err := session.CloneSnapshot( + ctx, + replacedSnapshot, + FileChangeSummary{}, + &APISnapshotRequest{CreatePrograms: []*APICreateProgramRequest{{ + RootFileNames: []tspath.RootedFilePath{"/a.ts"}, + CompilerOptions: options, + }}}, + ) + assert.NilError(t, err) + defer reusedSnapshot.Deref() + assert.Equal(t, reusedSnapshot.CreatedPrograms()[0].ID(), firstProject.ID()) + assert.Equal(t, len(reusedSnapshot.ProjectCollection.SyntheticProjects()), 2) + }) + t.Run("failed API update is not adopted", func(t *testing.T) { t.Parallel() session := setup(map[string]any{