diff --git a/view/html.go b/view/html.go index 3f8737febd6f886d5b019477038bc819d23455e9..09ac5ea42e3f7265eaf5ef43b23385ba7eba20d3 100644 --- a/view/html.go +++ b/view/html.go @@ -9,6 +9,7 @@ import ( "os" "regexp" "strings" + "sync/atomic" "time" "charm.land/lipgloss/v2" @@ -282,9 +283,7 @@ var nextImageID uint32 = 1000 // allocImageID returns a unique Kitty image ID. func allocImageID() uint32 { - id := nextImageID - nextImageID++ - return id + return atomic.AddUint32(&nextImageID, 1) } func fetchRemoteBase64(url string) string { diff --git a/view/html_test.go b/view/html_test.go index 795e705baeba9e5e27aeff46c2d939f4e0b26d5e..625971b4e569f2a7be079796c109349a4377620d 100644 --- a/view/html_test.go +++ b/view/html_test.go @@ -5,6 +5,8 @@ import ( "os" "regexp" "strings" + "sync" + "sync/atomic" "testing" "charm.land/lipgloss/v2" @@ -761,3 +763,44 @@ func TestRemoteImageCache_EvictsOldestWhenFull(t *testing.T) { } } } + +func TestAllocImageID_NoRace(t *testing.T) { + // Reset the counter so IDs start from a known value. + atomic.StoreUint32(&nextImageID, 1000) + + const goroutines = 100 + const idsPerGoroutine = 100 + + results := make(chan uint32, goroutines*idsPerGoroutine) + + var wg sync.WaitGroup + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + for range idsPerGoroutine { + results <- allocImageID() + } + }() + } + + // Close channel once all writers are done. + go func() { + wg.Wait() + close(results) + }() + + // Collect all IDs and verify uniqueness. + seen := make(map[uint32]bool, goroutines*idsPerGoroutine) + for id := range results { + if seen[id] { + t.Fatalf("duplicate image ID allocated: %d (race condition detected)", id) + } + seen[id] = true + } + + expected := uint32(goroutines * idsPerGoroutine) + if uint32(len(seen)) != expected { + t.Errorf("expected %d unique IDs, got %d", expected, len(seen)) + } +}