a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
388 lines
11 KiB
Go
388 lines
11 KiB
Go
// Untagged tests for the load / idle-unload concurrency core. These run
|
|
// in both the `llama` and non-`llama` builds — they never touch the
|
|
// llama.cpp libraries, exercising the gate through fake load/unload
|
|
// hooks so the refcount + TTL state machine is verified under -race
|
|
// without a real 4 GiB model.
|
|
package local
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeResource stands in for the model + contexts. load/unload just flip
|
|
// counters so the gate's lifecycle is observable.
|
|
type fakeResource struct {
|
|
mu sync.Mutex
|
|
loads int
|
|
unloads int
|
|
live bool
|
|
failNth int // when >0, the load'th call returns an error
|
|
}
|
|
|
|
func (f *fakeResource) load() error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.loads++
|
|
if f.failNth > 0 && f.loads == f.failNth {
|
|
return errors.New("boom")
|
|
}
|
|
f.live = true
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeResource) unload() {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.unloads++
|
|
f.live = false
|
|
}
|
|
|
|
func (f *fakeResource) snapshot() (loads, unloads int, live bool) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.loads, f.unloads, f.live
|
|
}
|
|
|
|
func TestIdleGate_LazyLoadOnFirstAcquire(t *testing.T) {
|
|
fr := &fakeResource{}
|
|
g := newIdleGate(fr.load, fr.unload)
|
|
|
|
if g.isLoaded() {
|
|
t.Fatal("gate should start unloaded")
|
|
}
|
|
cold, err := g.acquire()
|
|
if err != nil {
|
|
t.Fatalf("acquire: %v", err)
|
|
}
|
|
if !cold {
|
|
t.Fatal("first acquire must report a cold load")
|
|
}
|
|
if !g.isLoaded() {
|
|
t.Fatal("gate should be loaded after acquire")
|
|
}
|
|
// A second acquire while the first is still in flight must NOT reload.
|
|
cold2, err := g.acquire()
|
|
if err != nil {
|
|
t.Fatalf("acquire 2: %v", err)
|
|
}
|
|
if cold2 {
|
|
t.Fatal("second concurrent acquire must not cold-load")
|
|
}
|
|
g.release()
|
|
g.release()
|
|
|
|
loads, _, live := fr.snapshot()
|
|
if loads != 1 || !live {
|
|
t.Fatalf("want exactly one load and live resource, got loads=%d live=%v", loads, live)
|
|
}
|
|
}
|
|
|
|
func TestIdleGate_LoadErrorLeavesUnloadedAndRetries(t *testing.T) {
|
|
fr := &fakeResource{failNth: 1}
|
|
g := newIdleGate(fr.load, fr.unload)
|
|
|
|
if _, err := g.acquire(); err == nil {
|
|
t.Fatal("acquire must surface the load error")
|
|
}
|
|
if g.isLoaded() {
|
|
t.Fatal("a failed load must leave the gate unloaded")
|
|
}
|
|
// The next acquire retries the load (unlike a sync.Once, which would
|
|
// have cached the failure forever).
|
|
cold, err := g.acquire()
|
|
if err != nil {
|
|
t.Fatalf("retry acquire: %v", err)
|
|
}
|
|
if !cold {
|
|
t.Fatal("retry after a failed load must cold-load")
|
|
}
|
|
g.release()
|
|
}
|
|
|
|
func TestIdleGate_IdleUnloadAfterTTL(t *testing.T) {
|
|
fr := &fakeResource{}
|
|
g := newIdleGate(fr.load, fr.unload)
|
|
|
|
now := time.Unix(0, 0)
|
|
g.now = func() time.Time { return now }
|
|
|
|
cold, err := g.acquire()
|
|
if err != nil || !cold {
|
|
t.Fatalf("acquire: cold=%v err=%v", cold, err)
|
|
}
|
|
g.release()
|
|
|
|
ttl := 10 * time.Minute
|
|
// Not yet idle enough: no unload.
|
|
now = now.Add(5 * time.Minute)
|
|
if idle, ok := g.maybeUnload(ttl); ok {
|
|
t.Fatalf("unloaded too early after %s idle", idle)
|
|
}
|
|
if !g.isLoaded() {
|
|
t.Fatal("must still be loaded before TTL elapses")
|
|
}
|
|
// Past the TTL: unload fires and reports the idle duration.
|
|
now = now.Add(6 * time.Minute)
|
|
idle, ok := g.maybeUnload(ttl)
|
|
if !ok {
|
|
t.Fatal("expected an idle unload past the TTL")
|
|
}
|
|
if idle < ttl {
|
|
t.Fatalf("reported idle %s < ttl %s", idle, ttl)
|
|
}
|
|
if g.isLoaded() {
|
|
t.Fatal("gate must be unloaded after the idle reap")
|
|
}
|
|
_, unloads, live := fr.snapshot()
|
|
if unloads != 1 || live {
|
|
t.Fatalf("want one unload and dead resource, got unloads=%d live=%v", unloads, live)
|
|
}
|
|
}
|
|
|
|
func TestIdleGate_NeverUnloadsWhileInFlight(t *testing.T) {
|
|
fr := &fakeResource{}
|
|
g := newIdleGate(fr.load, fr.unload)
|
|
now := time.Unix(0, 0)
|
|
g.now = func() time.Time { return now }
|
|
|
|
if _, err := g.acquire(); err != nil { // held — not released
|
|
t.Fatalf("acquire: %v", err)
|
|
}
|
|
now = now.Add(time.Hour) // long past any TTL
|
|
if idle, ok := g.maybeUnload(time.Minute); ok {
|
|
t.Fatalf("unloaded while a use was in flight (idle=%s)", idle)
|
|
}
|
|
if !g.isLoaded() {
|
|
t.Fatal("in-flight use must keep the resource loaded")
|
|
}
|
|
g.release()
|
|
// release refreshes lastUse to "now"; advance again so the gate is
|
|
// idle past the TTL before the reaper runs.
|
|
now = now.Add(time.Hour)
|
|
if _, ok := g.maybeUnload(time.Minute); !ok {
|
|
t.Fatal("expected unload once the in-flight use drained")
|
|
}
|
|
}
|
|
|
|
func TestIdleGate_DisabledTTLNeverUnloads(t *testing.T) {
|
|
fr := &fakeResource{}
|
|
g := newIdleGate(fr.load, fr.unload)
|
|
now := time.Unix(0, 0)
|
|
g.now = func() time.Time { return now }
|
|
if _, err := g.acquire(); err != nil {
|
|
t.Fatalf("acquire: %v", err)
|
|
}
|
|
g.release()
|
|
now = now.Add(24 * time.Hour)
|
|
if _, ok := g.maybeUnload(0); ok {
|
|
t.Fatal("a non-positive TTL must disable idle unloading")
|
|
}
|
|
if !g.isLoaded() {
|
|
t.Fatal("resource must stay loaded when TTL is disabled")
|
|
}
|
|
}
|
|
|
|
func TestIdleGate_ReloadAfterUnload(t *testing.T) {
|
|
fr := &fakeResource{}
|
|
g := newIdleGate(fr.load, fr.unload)
|
|
now := time.Unix(0, 0)
|
|
g.now = func() time.Time { return now }
|
|
|
|
if _, err := g.acquire(); err != nil {
|
|
t.Fatalf("acquire: %v", err)
|
|
}
|
|
g.release()
|
|
now = now.Add(time.Hour)
|
|
if _, ok := g.maybeUnload(time.Minute); !ok {
|
|
t.Fatal("expected idle unload")
|
|
}
|
|
// Next acquire transparently reloads.
|
|
cold, err := g.acquire()
|
|
if err != nil {
|
|
t.Fatalf("reacquire: %v", err)
|
|
}
|
|
if !cold {
|
|
t.Fatal("acquire after an idle unload must cold-load again")
|
|
}
|
|
g.release()
|
|
loads, unloads, live := fr.snapshot()
|
|
if loads != 2 || unloads != 1 || !live {
|
|
t.Fatalf("want loads=2 unloads=1 live=true, got loads=%d unloads=%d live=%v", loads, unloads, live)
|
|
}
|
|
}
|
|
|
|
func TestIdleGate_ClosedRejectsAcquire(t *testing.T) {
|
|
fr := &fakeResource{}
|
|
g := newIdleGate(fr.load, fr.unload)
|
|
if _, err := g.acquire(); err != nil {
|
|
t.Fatalf("acquire: %v", err)
|
|
}
|
|
g.release()
|
|
g.close()
|
|
if _, err := g.acquire(); !errors.Is(err, errGateClosed) {
|
|
t.Fatalf("acquire after close must return errGateClosed, got %v", err)
|
|
}
|
|
_, unloads, live := fr.snapshot()
|
|
if unloads != 1 || live {
|
|
t.Fatalf("close must unload a loaded resource, got unloads=%d live=%v", unloads, live)
|
|
}
|
|
// close is idempotent.
|
|
g.close()
|
|
if _, unloads, _ := fr.snapshot(); unloads != 1 {
|
|
t.Fatalf("second close must not unload again, got unloads=%d", unloads)
|
|
}
|
|
}
|
|
|
|
// TestIdleGate_ConcurrentAcquireReleaseVsUnload hammers the gate from
|
|
// many goroutines while a reaper races to unload, asserting the
|
|
// resource is never unloaded with a use in flight and load/unload stay
|
|
// balanced. Run under -race this is the core safety proof.
|
|
func TestIdleGate_ConcurrentAcquireReleaseVsUnload(t *testing.T) {
|
|
fr := &fakeResource{}
|
|
g := newIdleGate(fr.load, fr.unload)
|
|
|
|
// A live-use counter the reaper checks: it must never observe an
|
|
// unload (live flip to false) while this is > 0.
|
|
var inUse int64
|
|
var violations int64
|
|
|
|
origUnload := g.unload
|
|
g.unload = func() {
|
|
if atomic.LoadInt64(&inUse) > 0 {
|
|
atomic.AddInt64(&violations, 1)
|
|
}
|
|
origUnload()
|
|
}
|
|
|
|
stop := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
|
|
// Reaper: a zero TTL here would be disabled, so use a tiny positive
|
|
// TTL and a now() that always looks "long idle" so it unloads
|
|
// aggressively whenever inFlight hits zero.
|
|
g.now = func() time.Time { return time.Unix(1<<40, 0) }
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
g.maybeUnload(time.Nanosecond)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Workers: acquire, mark in-use, release.
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < 2000; j++ {
|
|
cold, err := g.acquire()
|
|
_ = cold
|
|
if err != nil {
|
|
continue
|
|
}
|
|
atomic.AddInt64(&inUse, 1)
|
|
// tiny bit of "work" while the reaper spins
|
|
atomic.AddInt64(&inUse, -1)
|
|
g.release()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Let workers finish, then stop the reaper.
|
|
go func() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
close(stop)
|
|
}()
|
|
wg.Wait()
|
|
|
|
if v := atomic.LoadInt64(&violations); v != 0 {
|
|
t.Fatalf("resource was unloaded %d times while a use was in flight", v)
|
|
}
|
|
}
|
|
|
|
func TestIdleTicker_StopIsIdempotent(t *testing.T) {
|
|
var ticks int64
|
|
tk := startIdleTicker(time.Millisecond, func() { atomic.AddInt64(&ticks, 1) })
|
|
time.Sleep(20 * time.Millisecond)
|
|
tk.Stop()
|
|
after := atomic.LoadInt64(&ticks)
|
|
if after == 0 {
|
|
t.Fatal("ticker never fired")
|
|
}
|
|
// Second Stop must not panic or hang.
|
|
tk.Stop()
|
|
// No ticks after Stop.
|
|
time.Sleep(10 * time.Millisecond)
|
|
if atomic.LoadInt64(&ticks) != after {
|
|
t.Fatal("ticker fired after Stop")
|
|
}
|
|
// Stop on a nil ticker is a no-op.
|
|
var nilTicker *idleTicker
|
|
nilTicker.Stop()
|
|
}
|
|
|
|
func TestIdleTTLFromEnv(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
val string // GORTEX_LLM_IDLE_TTL value
|
|
set bool // whether the env var is set at all
|
|
cfg string // llm.local.idle_ttl config fallback
|
|
want time.Duration
|
|
}{
|
|
// Env-only (no config): the original behaviour is preserved.
|
|
{name: "env unset, no config", set: false, want: defaultIdleTTL},
|
|
{name: "env empty, no config", val: "", set: true, want: defaultIdleTTL},
|
|
{name: "env 5m", val: "5m", set: true, want: 5 * time.Minute},
|
|
{name: "env 30s", val: "30s", set: true, want: 30 * time.Second},
|
|
{name: "env 0 disables", val: "0", set: true, want: 0},
|
|
{name: "env off disables", val: "off", set: true, want: 0},
|
|
{name: "env none disables", val: "none", set: true, want: 0},
|
|
{name: "env garbage -> default", val: "garbage", set: true, want: defaultIdleTTL},
|
|
// Config fallback: consulted only when the env var is unset.
|
|
{name: "config 15m, env unset", set: false, cfg: "15m", want: 15 * time.Minute},
|
|
{name: "config off, env unset", set: false, cfg: "off", want: 0},
|
|
{name: "config garbage, env unset", set: false, cfg: "garbage", want: defaultIdleTTL},
|
|
{name: "config empty, env unset", set: false, cfg: "", want: defaultIdleTTL},
|
|
// Precedence: a set env var wins over any config value.
|
|
{name: "env 5m beats config 15m", val: "5m", set: true, cfg: "15m", want: 5 * time.Minute},
|
|
{name: "env off beats config 15m", val: "off", set: true, cfg: "15m", want: 0},
|
|
// An explicitly-empty env var does NOT count as set, so config wins.
|
|
{name: "env empty falls through to config", val: "", set: true, cfg: "15m", want: 15 * time.Minute},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.set {
|
|
t.Setenv("GORTEX_LLM_IDLE_TTL", tc.val)
|
|
} else {
|
|
_ = os.Unsetenv("GORTEX_LLM_IDLE_TTL")
|
|
}
|
|
if got := idleTTLFromEnv(tc.cfg); got != tc.want {
|
|
t.Errorf("idleTTLFromEnv(cfg=%q) with env(%q,set=%v) = %s, want %s",
|
|
tc.cfg, tc.val, tc.set, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTickInterval(t *testing.T) {
|
|
if got := tickInterval(10 * time.Minute); got != time.Minute {
|
|
t.Errorf("tickInterval(10m) = %s, want 1m (clamped)", got)
|
|
}
|
|
if got := tickInterval(2 * time.Second); got != time.Second {
|
|
t.Errorf("tickInterval(2s) = %s, want 1s", got)
|
|
}
|
|
if got := tickInterval(time.Millisecond); got != time.Second {
|
|
t.Errorf("tickInterval(1ms) = %s, want 1s (floor)", got)
|
|
}
|
|
}
|