Improve code quality and thread safety (#844)
- Format Go files (gofmt) for backend/windows/notify*.go - Add thread safety to Stopwatch with sync.Mutex - Add comprehensive unit tests for Stopwatch with race detection - Add godoc comments to ImageCache public APIs - Improve IPC documentation with platform-specific socket path details - Document portable mode TODO for future enhancement
This commit is contained in:
+31
-3
@@ -18,7 +18,9 @@ type CacheItem struct {
|
|||||||
lastAccessed int64
|
lastAccessed int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// A custom in-memory cache for images with the following eviction strategy:
|
// ImageCache is a thread-safe in-memory cache for images with LRU eviction.
|
||||||
|
//
|
||||||
|
// Eviction strategy:
|
||||||
// 1. If there are fewer than MinSize items in the cache, none will be evicted
|
// 1. If there are fewer than MinSize items in the cache, none will be evicted
|
||||||
// 2. If a new addition would make the cache exceed MaxSize, an item will be immediately evicted
|
// 2. If a new addition would make the cache exceed MaxSize, an item will be immediately evicted
|
||||||
// 2a. in this case, evict the LRU expired item or if none expired, the LRU item
|
// 2a. in this case, evict the LRU expired item or if none expired, the LRU item
|
||||||
@@ -39,14 +41,19 @@ type ImageCache struct {
|
|||||||
cache map[string]CacheItem
|
cache map[string]CacheItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrNotFound is returned when a requested cache item does not exist.
|
||||||
var ErrNotFound = errors.New("item not found")
|
var ErrNotFound = errors.New("item not found")
|
||||||
|
|
||||||
|
// Init initializes the cache and starts a background goroutine for periodic eviction.
|
||||||
|
// The goroutine stops when the provided context is cancelled.
|
||||||
func (i *ImageCache) Init(ctx context.Context, evictionInterval time.Duration) {
|
func (i *ImageCache) Init(ctx context.Context, evictionInterval time.Duration) {
|
||||||
i.cache = make(map[string]CacheItem)
|
i.cache = make(map[string]CacheItem)
|
||||||
go i.periodicallyEvict(ctx, evictionInterval)
|
go i.periodicallyEvict(ctx, evictionInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
// holds writer lock for O(i.MaxSize) worst case
|
// SetWithTTL stores an image in the cache with a custom time-to-live duration.
|
||||||
|
// If the cache is at MaxSize, an item will be evicted using LRU strategy.
|
||||||
|
// Thread-safe. Holds writer lock for O(MaxSize) worst case.
|
||||||
func (i *ImageCache) SetWithTTL(key string, val image.Image, ttl time.Duration) {
|
func (i *ImageCache) SetWithTTL(key string, val image.Image, ttl time.Duration) {
|
||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
defer i.mu.Unlock()
|
defer i.mu.Unlock()
|
||||||
@@ -71,10 +78,14 @@ func (i *ImageCache) SetWithTTL(key string, val image.Image, ttl time.Duration)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set stores an image in the cache with the default TTL.
|
||||||
|
// See SetWithTTL for more details.
|
||||||
func (i *ImageCache) Set(key string, val image.Image) {
|
func (i *ImageCache) Set(key string, val image.Image) {
|
||||||
i.SetWithTTL(key, val, i.DefaultTTL)
|
i.SetWithTTL(key, val, i.DefaultTTL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Has returns true if the key exists in the cache, expired or not.
|
||||||
|
// Thread-safe.
|
||||||
func (i *ImageCache) Has(key string) bool {
|
func (i *ImageCache) Has(key string) bool {
|
||||||
i.mu.RLock()
|
i.mu.RLock()
|
||||||
defer i.mu.RUnlock()
|
defer i.mu.RUnlock()
|
||||||
@@ -83,10 +94,17 @@ func (i *ImageCache) Has(key string) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get retrieves an image from the cache and updates its last accessed time.
|
||||||
|
// Returns ErrNotFound if the key doesn't exist.
|
||||||
|
// Thread-safe.
|
||||||
func (i *ImageCache) Get(key string) (image.Image, error) {
|
func (i *ImageCache) Get(key string) (image.Image, error) {
|
||||||
return i.GetResetTTL(key, false)
|
return i.GetResetTTL(key, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetResetTTL retrieves an image and optionally resets its expiration time.
|
||||||
|
// If resetTTL is true, the expiration is reset to now + original TTL.
|
||||||
|
// Returns ErrNotFound if the key doesn't exist.
|
||||||
|
// Thread-safe.
|
||||||
func (i *ImageCache) GetResetTTL(key string, resetTTL bool) (image.Image, error) {
|
func (i *ImageCache) GetResetTTL(key string, resetTTL bool) (image.Image, error) {
|
||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
defer i.mu.Unlock()
|
defer i.mu.Unlock()
|
||||||
@@ -102,7 +120,11 @@ func (i *ImageCache) GetResetTTL(key string, resetTTL bool) (image.Image, error)
|
|||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets the image if it exists and extends TTL to time.Now + ttl iff the image would expire before then
|
// GetExtendTTL retrieves an image and extends its TTL if it would expire sooner.
|
||||||
|
// The expiration time is extended to now + ttl only if the current expiration
|
||||||
|
// is earlier than that time.
|
||||||
|
// Returns ErrNotFound if the key doesn't exist.
|
||||||
|
// Thread-safe.
|
||||||
func (i *ImageCache) GetExtendTTL(key string, ttl time.Duration) (image.Image, error) {
|
func (i *ImageCache) GetExtendTTL(key string, ttl time.Duration) (image.Image, error) {
|
||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
defer i.mu.Unlock()
|
defer i.mu.Unlock()
|
||||||
@@ -118,6 +140,10 @@ func (i *ImageCache) GetExtendTTL(key string, ttl time.Duration) (image.Image, e
|
|||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWithNewTTL retrieves an image and replaces its TTL with a new value.
|
||||||
|
// The expiration time is set to now + newTtl.
|
||||||
|
// Returns ErrNotFound if the key doesn't exist.
|
||||||
|
// Thread-safe.
|
||||||
func (i *ImageCache) GetWithNewTTL(key string, newTtl time.Duration) (image.Image, error) {
|
func (i *ImageCache) GetWithNewTTL(key string, newTtl time.Duration) (image.Image, error) {
|
||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
defer i.mu.Unlock()
|
defer i.mu.Unlock()
|
||||||
@@ -132,6 +158,8 @@ func (i *ImageCache) GetWithNewTTL(key string, newTtl time.Duration) (image.Imag
|
|||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear removes all items from the cache.
|
||||||
|
// Thread-safe.
|
||||||
func (i *ImageCache) Clear() {
|
func (i *ImageCache) Clear() {
|
||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
defer i.mu.Unlock()
|
defer i.mu.Unlock()
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// socketPath is automatically initialized based on platform conventions:
|
||||||
|
// - macOS: ~/Library/Caches/supersonic/supersonic.sock (or /tmp/supersonic-{uid}.sock as fallback)
|
||||||
|
// - Linux/Unix: $XDG_RUNTIME_DIR/supersonic.sock (or /tmp/supersonic-{uid}.sock as fallback)
|
||||||
|
//
|
||||||
|
// TODO: Add support for portable mode by allowing override via environment variable
|
||||||
|
// or configuration file (e.g., SUPERSONIC_SOCKET_PATH).
|
||||||
var socketPath = "/tmp/supersonic.sock"
|
var socketPath = "/tmp/supersonic.sock"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -29,15 +35,21 @@ func init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dial establishes a connection to the IPC socket.
|
||||||
|
// Returns an error if the socket doesn't exist or connection fails.
|
||||||
func Dial() (net.Conn, error) {
|
func Dial() (net.Conn, error) {
|
||||||
// TODO - use XDG runtime dir, also handle portable mode
|
|
||||||
return net.Dial("unix", socketPath)
|
return net.Dial("unix", socketPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listen creates a Unix domain socket listener at the configured path.
|
||||||
|
// The socket file is created automatically and should be cleaned up
|
||||||
|
// with DestroyConn() when done.
|
||||||
func Listen() (net.Listener, error) {
|
func Listen() (net.Listener, error) {
|
||||||
return net.Listen("unix", socketPath)
|
return net.Listen("unix", socketPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DestroyConn removes the Unix socket file from the filesystem.
|
||||||
|
// Should be called during application shutdown.
|
||||||
func DestroyConn() error {
|
func DestroyConn() error {
|
||||||
return os.Remove(socketPath)
|
return os.Remove(socketPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stopwatch is a thread-safe timer for measuring elapsed time.
|
||||||
|
// It can be started, stopped, and reset, and supports reading
|
||||||
|
// the elapsed time while running or stopped.
|
||||||
type Stopwatch struct {
|
type Stopwatch struct {
|
||||||
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
started time.Time
|
started time.Time
|
||||||
elapsed time.Duration
|
elapsed time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start begins or resumes the stopwatch.
|
||||||
|
// If already running, this is a no-op.
|
||||||
func (s *Stopwatch) Start() {
|
func (s *Stopwatch) Start() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if s.running {
|
if s.running {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -16,7 +28,12 @@ func (s *Stopwatch) Start() {
|
|||||||
s.running = true
|
s.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop pauses the stopwatch and accumulates the elapsed time.
|
||||||
|
// If already stopped, this is a no-op.
|
||||||
func (s *Stopwatch) Stop() {
|
func (s *Stopwatch) Stop() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if !s.running {
|
if !s.running {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -24,7 +41,13 @@ func (s *Stopwatch) Stop() {
|
|||||||
s.running = false
|
s.running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Elapsed returns the total elapsed time.
|
||||||
|
// If the stopwatch is running, includes time since last Start().
|
||||||
|
// Safe to call concurrently with other methods.
|
||||||
func (s *Stopwatch) Elapsed() time.Duration {
|
func (s *Stopwatch) Elapsed() time.Duration {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
e := s.elapsed
|
e := s.elapsed
|
||||||
if s.running {
|
if s.running {
|
||||||
e += time.Since(s.started)
|
e += time.Since(s.started)
|
||||||
@@ -32,7 +55,11 @@ func (s *Stopwatch) Elapsed() time.Duration {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset stops the stopwatch and clears the elapsed time.
|
||||||
func (s *Stopwatch) Reset() {
|
func (s *Stopwatch) Reset() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
s.running = false
|
s.running = false
|
||||||
s.elapsed = time.Duration(0)
|
s.elapsed = time.Duration(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStopwatch_Basic(t *testing.T) {
|
||||||
|
sw := &Stopwatch{}
|
||||||
|
|
||||||
|
// Test initial state
|
||||||
|
if elapsed := sw.Elapsed(); elapsed != 0 {
|
||||||
|
t.Errorf("Expected initial elapsed time to be 0, got %v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test start and elapsed
|
||||||
|
sw.Start()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
elapsed := sw.Elapsed()
|
||||||
|
if elapsed < 10*time.Millisecond {
|
||||||
|
t.Errorf("Expected at least 10ms elapsed, got %v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test stop
|
||||||
|
sw.Stop()
|
||||||
|
stoppedElapsed := sw.Elapsed()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if sw.Elapsed() != stoppedElapsed {
|
||||||
|
t.Error("Elapsed time should not increase after Stop()")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test reset
|
||||||
|
sw.Reset()
|
||||||
|
if elapsed := sw.Elapsed(); elapsed != 0 {
|
||||||
|
t.Errorf("Expected elapsed time to be 0 after reset, got %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopwatch_StartStop(t *testing.T) {
|
||||||
|
sw := &Stopwatch{}
|
||||||
|
|
||||||
|
// Start, accumulate some time
|
||||||
|
sw.Start()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
sw.Stop()
|
||||||
|
firstElapsed := sw.Elapsed()
|
||||||
|
|
||||||
|
// Start again, accumulate more time
|
||||||
|
sw.Start()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
sw.Stop()
|
||||||
|
secondElapsed := sw.Elapsed()
|
||||||
|
|
||||||
|
if secondElapsed <= firstElapsed {
|
||||||
|
t.Errorf("Expected elapsed time to accumulate, first=%v second=%v", firstElapsed, secondElapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopwatch_DoubleStart(t *testing.T) {
|
||||||
|
sw := &Stopwatch{}
|
||||||
|
|
||||||
|
sw.Start()
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
firstStart := sw.Elapsed()
|
||||||
|
|
||||||
|
// Second Start() should be no-op
|
||||||
|
sw.Start()
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
secondStart := sw.Elapsed()
|
||||||
|
|
||||||
|
// Time should continue from first start
|
||||||
|
if secondStart < firstStart {
|
||||||
|
t.Error("Second Start() affected timing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopwatch_DoubleStop(t *testing.T) {
|
||||||
|
sw := &Stopwatch{}
|
||||||
|
|
||||||
|
sw.Start()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
sw.Stop()
|
||||||
|
elapsed := sw.Elapsed()
|
||||||
|
|
||||||
|
// Second Stop() should be no-op
|
||||||
|
sw.Stop()
|
||||||
|
if sw.Elapsed() != elapsed {
|
||||||
|
t.Error("Second Stop() changed elapsed time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopwatch_ConcurrentAccess(t *testing.T) {
|
||||||
|
sw := &Stopwatch{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Test concurrent Start/Stop/Elapsed calls
|
||||||
|
// This should not cause data races
|
||||||
|
const goroutines = 10
|
||||||
|
const iterations = 100
|
||||||
|
|
||||||
|
wg.Add(goroutines * 3)
|
||||||
|
|
||||||
|
// Concurrent starts
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < iterations; j++ {
|
||||||
|
sw.Start()
|
||||||
|
time.Sleep(time.Microsecond)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent stops
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < iterations; j++ {
|
||||||
|
sw.Stop()
|
||||||
|
time.Sleep(time.Microsecond)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent reads
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < iterations; j++ {
|
||||||
|
_ = sw.Elapsed()
|
||||||
|
time.Sleep(time.Microsecond)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// If we got here without data races, the test passes
|
||||||
|
// Run with: go test -race
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopwatch_Reset(t *testing.T) {
|
||||||
|
sw := &Stopwatch{}
|
||||||
|
|
||||||
|
// Reset when stopped
|
||||||
|
sw.Reset()
|
||||||
|
if elapsed := sw.Elapsed(); elapsed != 0 {
|
||||||
|
t.Errorf("Expected 0 after reset, got %v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset when running
|
||||||
|
sw.Start()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
sw.Reset()
|
||||||
|
if elapsed := sw.Elapsed(); elapsed != 0 {
|
||||||
|
t.Errorf("Expected 0 after reset while running, got %v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After reset, should be able to start again
|
||||||
|
sw.Start()
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
elapsed := sw.Elapsed()
|
||||||
|
if elapsed < 5*time.Millisecond {
|
||||||
|
t.Errorf("Expected at least 5ms after reset and start, got %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user