Skip to content
Merged
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
32 changes: 32 additions & 0 deletions pkg/coll/coll_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,3 +500,35 @@ func TestUnflattenMap(t *testing.T) {
t.Errorf("age = %v; want 30", got["age"])
}
}

// ─── HashMap nil-receiver safety ────────────────────────────────────────────

func TestHashMap_NilReceiver(t *testing.T) {
var m *coll.HashMap[string, int]

t.Run("Get", func(t *testing.T) {
if v := m.Get("key"); v != 0 {
t.Errorf("Get() on nil = %d; want 0", v)
}
})
t.Run("Size", func(t *testing.T) {
if v := m.Size(); v != 0 {
t.Errorf("Size() on nil = %d; want 0", v)
}
})
t.Run("IsEmpty", func(t *testing.T) {
if !m.IsEmpty() {
t.Error("IsEmpty() on nil = false; want true")
}
})
t.Run("ContainsKey", func(t *testing.T) {
if m.ContainsKey("key") {
t.Error("ContainsKey() on nil = true; want false")
}
})
t.Run("KeySet", func(t *testing.T) {
if ks := m.KeySet(); ks != nil {
t.Errorf("KeySet() on nil = %v; want nil", ks)
}
})
}
15 changes: 15 additions & 0 deletions pkg/coll/hashmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ func (hash *HashMap[K, V]) Put(key K, value V) {
//
// value := hashMap.Get("apple") // Retrieves the value associated with "apple".
func (hash *HashMap[K, V]) Get(key K) (value V) {
if hash == nil || hash.items == nil {
return value
}
return hash.items[key]
}

Expand Down Expand Up @@ -79,6 +82,9 @@ func (hash *HashMap[K, V]) Clear() {
//
// size := hashMap.Size() // Gets the size of the map.
func (hash *HashMap[K, V]) Size() int {
if hash == nil {
return 0
}
return len(hash.items)
}

Expand All @@ -90,6 +96,9 @@ func (hash *HashMap[K, V]) Size() int {
//
// isEmpty := hashMap.IsEmpty() // Returns true if the map is empty.
func (hash *HashMap[K, V]) IsEmpty() bool {
if hash == nil {
return true
}
return len(hash.items) == 0
}

Expand All @@ -104,6 +113,9 @@ func (hash *HashMap[K, V]) IsEmpty() bool {
//
// exists := hashMap.ContainsKey("apple") // Checks if the key "apple" exists in the map.
func (hash *HashMap[K, V]) ContainsKey(key K) bool {
if hash == nil || hash.items == nil {
return false
}
_, ok := hash.items[key]
return ok
}
Expand All @@ -116,6 +128,9 @@ func (hash *HashMap[K, V]) ContainsKey(key K) bool {
//
// keys := hashMap.KeySet() // Returns all the keys in the map.
func (hash *HashMap[K, V]) KeySet() []K {
if hash == nil {
return nil
}
keys := make([]K, hash.Size())
i := 0
for key := range hash.items {
Expand Down
11 changes: 11 additions & 0 deletions pkg/conv/conv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,14 @@ func TestAsConvError_Nil(t *testing.T) {
t.Errorf("AsConvError(nil) should return (nil, false)")
}
}

func TestToTime_FromTimeValue(t *testing.T) {
now := time.Now().Truncate(time.Second)
got, err := conv.Time(now)
if err != nil {
t.Fatalf("Time(time.Time) error = %v", err)
}
if !got.Equal(now) {
t.Errorf("Time(time.Time) = %v; want %v", got, now)
}
}
4 changes: 3 additions & 1 deletion pkg/conv/time.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,9 @@ func (c *Converter) timeFromReflect(from any) (time.Time, error) {
return c.stringToTime(value.String())
case kind == reflect.Struct:
if value.Type() == typeOfTime && value.CanInterface() {
return value.Interface().(time.Time), nil
if t, ok := value.Interface().(time.Time); ok {
return t, nil
}
}
case isKindInt(kind):
return time.Unix(value.Int(), 0), nil
Expand Down
18 changes: 18 additions & 0 deletions pkg/hashy/hash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -942,3 +942,21 @@ func TestHash_ValidateNilHasher(t *testing.T) {
t.Errorf("error %q does not contain %q", err.Error(), want)
}
}

func TestHash_TimeValue(t *testing.T) {
now := time.Now()
h1, err := hashy.Hash(now)
if err != nil {
t.Fatalf("Hash(time.Time) error = %v", err)
}
if h1 == 0 {
t.Error("Hash(time.Time) returned zero")
}
h2, err := hashy.Hash(now)
if err != nil {
t.Fatalf("Hash(time.Time) second call error = %v", err)
}
if h1 != h2 {
t.Errorf("Hash(time.Time) not deterministic: %d != %d", h1, h2)
}
}
6 changes: 5 additions & 1 deletion pkg/hashy/hasher.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ func (h *hasher) hashNumeric(value reflect.Value) (uint64, error) {
// fmt.Println(hash, err) // time.Now().Unix() nil
func (h *hasher) hashTime(value reflect.Value) (uint64, error) {
h.hash.Reset()
timeVal := value.Interface().(time.Time)
iface := value.Interface()
timeVal, ok := iface.(time.Time)
if !ok {
return 0, fmt.Errorf("hashy: expected time.Time, got %T", iface)
}
data, err := timeVal.MarshalBinary()
if err != nil {
return 0, err
Expand Down
5 changes: 4 additions & 1 deletion pkg/slogger/rotation.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,13 @@ func (lfw *LevelFileWriter) WriteLevel(level Level, p []byte) (int, error) {
rf = lfw.writers[ErrorLevel]
}
}
lfw.mu.Unlock()
if rf == nil {
lfw.mu.Unlock()
return 0, nil
}
// Hold lfw.mu until after the write completes so that a concurrent
// Close cannot invalidate rf between the lookup and the write.
defer lfw.mu.Unlock()
return rf.write(p)
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/sysx/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ func IsReadable(path string) bool {
if err != nil {
return false
}
f.Close()
defer f.Close()
return true
}
fi, err := os.Stat(path)
Expand Down Expand Up @@ -246,7 +246,7 @@ func IsWritable(path string) bool {
if err != nil {
return false
}
f.Close()
defer f.Close()
return true
}

Expand Down
6 changes: 4 additions & 2 deletions pkg/sysx/lock_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ func lockFile(f *os.File, isWrite bool) error {
uintptr(unsafe.Pointer(ol)),
)
if r1 == 0 {
if err != nil && err.Error() != "The operation completed successfully." {
// err is always a syscall.Errno after a Call; compare directly
// instead of relying on the locale-dependent Error() string.
if err != syscall.Errno(0) {
return err
}
return syscall.EINVAL
Expand All @@ -59,7 +61,7 @@ func unlockFile(f *os.File) error {
uintptr(unsafe.Pointer(ol)),
)
if r1 == 0 {
if err != nil && err.Error() != "The operation completed successfully." {
if err != syscall.Errno(0) {
return err
}
return syscall.EINVAL
Expand Down
4 changes: 2 additions & 2 deletions pkg/sysx/net.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ func IsPortAvailable(port int) bool {
if err != nil {
return false
}
ln.Close()
defer ln.Close()
return true
}

Expand Down Expand Up @@ -454,7 +454,7 @@ func CheckTCPConn(host string, port int, timeout time.Duration) error {
if err != nil {
return fmt.Errorf("sysx: TCP connection to %s failed: %w", addr, err)
}
conn.Close()
defer conn.Close()
return nil
}

Expand Down
8 changes: 7 additions & 1 deletion pkg/sysx/utilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,13 @@ func windowsOSVersion() string {
// A non-nil *sync.Mutex unique to the provided path.
func getFileMutex(path string) *sync.Mutex {
v, _ := fileMutexes.LoadOrStore(path, &sync.Mutex{})
return v.(*sync.Mutex)
mu, ok := v.(*sync.Mutex)
if !ok {
// Should never happen: fileMutexes only stores *sync.Mutex values.
mu = &sync.Mutex{}
fileMutexes.Store(path, mu)
}
return mu
}

// parseBool parses a lowercase, trimmed string as a boolean.
Expand Down
15 changes: 12 additions & 3 deletions replify.go
Original file line number Diff line number Diff line change
Expand Up @@ -3606,18 +3606,27 @@ func (w *wrapper) Respond() map[string]any {
if !w.Available() {
return nil
}

// Fast path: check cache under read lock.
w.cacheMutex.RLock()
hash := w.Hash256()

if w.cacheHash == hash && w.cachedWrap != nil {
defer w.cacheMutex.RUnlock()
return w.cachedWrap
cached := w.cachedWrap
w.cacheMutex.RUnlock()
return cached
}
w.cacheMutex.RUnlock()

// Slow path: acquire write lock and double-check before rebuilding.
w.cacheMutex.Lock()
defer w.cacheMutex.Unlock()

// Re-compute hash under write lock to avoid using a stale value.
hash = w.Hash256()
if w.cacheHash == hash && w.cachedWrap != nil {
return w.cachedWrap
}

response := w.build()
w.cachedWrap = response
w.cacheHash = hash
Expand Down
35 changes: 35 additions & 0 deletions replify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package replify_test

import (
"sync"
"testing"

"github.com/sivaosorg/replify"
)

// TestRespond_ConcurrentSafety verifies that calling Respond from multiple
// goroutines does not trigger the race detector or produce inconsistent
// results. This exercises the double-checked locking pattern in Respond().
func TestRespond_ConcurrentSafety(t *testing.T) {
t.Parallel()

w := replify.New().
WithHeader(replify.OK).
WithMessage("concurrent test").
WithBody(map[string]string{"key": "value"})

const goroutines = 50
var wg sync.WaitGroup
wg.Add(goroutines)

for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
resp := w.Respond()
if resp == nil {
t.Error("Respond() returned nil")
}
}()
}
wg.Wait()
}
Loading