package scheduler import ( "context" "errors" "io" "testing" "time" "github.com/mostlygeek/llama-swap/internal/config" "github.com/mostlygeek/llama-swap/internal/logmon" "github.com/mostlygeek/llama-swap/internal/process" "github.com/mostlygeek/llama-swap/internal/shared" ) // Serial methods all run on the router's single run-loop goroutine, so these // tests drive them directly and synchronously, reusing fakeEffects and the // req/reqCh helpers from fifo_test.go. A load completes via OnSwapDone and a // served request finishes via OnServeDone — the events the run loop delivers. // newSerial builds a Serial with the production defaults: aging at one point // per minute, swap affinity at +10. func newSerial(eff Effects) *Serial { return NewSerial("test", logmon.NewWriter(io.Discard), config.SerialConfig{}, eff) } // newSerialCfg builds a Serial with explicit scoring settings. Passing 0 for // either term disables it. func newSerialCfg(eff Effects, agingDivisor, swapAffinityBonus int) *Serial { return NewSerial("test", logmon.NewWriter(io.Discard), config.SerialConfig{ AgingDivisor: &agingDivisor, SwapAffinityBonus: &swapAffinityBonus, }, eff) } // fakeClock replaces a Serial's clock with one the test advances by hand, so // aging is exercised without sleeping. type fakeClock struct{ t time.Time } func newFakeClock(s *Serial) *fakeClock { c := &fakeClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} s.now = func() time.Time { return c.t } return c } func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) } // reqP is a HandlerReq carrying an explicit caller priority. func reqP(model string, priority int) HandlerReq { return HandlerReq{Model: model, Priority: priority} } // lastStart returns the most recent StartSwap record. func lastStart(t *testing.T, eff *fakeEffects) startRec { t.Helper() if len(eff.starts) == 0 { t.Fatal("no StartSwap recorded") } return eff.starts[len(eff.starts)-1] } func sameSet(a, b []string) bool { if len(a) != len(b) { return false } m := map[string]int{} for _, x := range a { m[x]++ } for _, x := range b { m[x]-- } for _, v := range m { if v != 0 { return false } } return true } // servedOrder returns the model IDs of every successful serve grant in order. func servedOrder(eff *fakeEffects) []string { var out []string for _, g := range eff.grants { if g.err == nil && g.serve { out = append(out, g.model) } } return out } func TestSerial_FastPath_AlreadyLoaded(t *testing.T) { eff := newFakeEffects() eff.states["a"] = process.StateReady s := newSerial(eff) s.OnRequest(req("a")) if got := len(eff.starts); got != 0 { t.Errorf("StartSwap calls=%d want 0 (already loaded, no swap)", got) } if got := eff.served("a"); got != 1 { t.Errorf("served(a)=%d want 1", got) } } func TestSerial_ColdStart_LoadsThenServes(t *testing.T) { eff := newFakeEffects() eff.states["a"] = process.StateStopped s := newSerial(eff) s.OnRequest(req("a")) if got := eff.startsFor("a"); got != 1 { t.Fatalf("StartSwap(a)=%d want 1", got) } if got := eff.served("a"); got != 0 { t.Errorf("served(a)=%d want 0 before load completes", got) } eff.states["a"] = process.StateReady s.OnSwapDone(SwapDone{ModelID: "a"}) if got := eff.served("a"); got != 1 { t.Errorf("served(a)=%d want 1 after load", got) } } func TestSerial_UnknownModel(t *testing.T) { eff := newFakeEffects() // no states => unknown s := newSerial(eff) s.OnRequest(req("ghost")) if len(eff.starts) != 0 { t.Errorf("StartSwap calls=%d want 0", len(eff.starts)) } if eff.errored("ghost") != 1 { t.Fatalf("errored(ghost)=%d want 1", eff.errored("ghost")) } if !errors.Is(eff.grants[0].err, ErrModelNotFound) { t.Errorf("err=%v want ErrModelNotFound", eff.grants[0].err) } } func TestSerial_EvictsEveryOtherModel(t *testing.T) { eff := newFakeEffects() eff.states["x"] = process.StateReady // already running eff.states["y"] = process.StateReady // also running (e.g. left over) eff.states["a"] = process.StateStopped s := newSerial(eff) s.OnRequest(req("a")) st := lastStart(t, eff) if st.model != "a" { t.Fatalf("loading %s want a", st.model) } if !sameSet(st.evict, []string{"x", "y"}) { t.Errorf("evict=%v want [x y] (serial evicts ALL other models)", st.evict) } } // TestSerial_OneJobAtATime verifies a second request waits while the first is // serving, and only starts after the first finishes. func TestSerial_OneJobAtATime(t *testing.T) { eff := newFakeEffects() eff.states["a"] = process.StateReady eff.states["b"] = process.StateStopped s := newSerial(eff) s.OnRequest(req("a")) // served immediately s.OnRequest(req("b")) // must wait — a is serving if got := eff.startsFor("b"); got != 0 { t.Fatalf("StartSwap(b)=%d want 0 while a is serving", got) } if got := eff.served("a"); got != 1 { t.Fatalf("served(a)=%d want 1", got) } // a finishes -> b may now load (evicting a). s.OnServeDone(ServeDoneEvent{ModelID: "a"}) if got := eff.startsFor("b"); got != 1 { t.Fatalf("StartSwap(b)=%d want 1 after a finished", got) } if st := lastStart(t, eff); !sameSet(st.evict, []string{"a"}) { t.Errorf("b evict=%v want [a]", st.evict) } } // TestSerial_SameModelConsecutive_NoReload verifies back-to-back requests for the // already-loaded model run without a reload, one after another. func TestSerial_SameModelConsecutive_NoReload(t *testing.T) { eff := newFakeEffects() eff.states["a"] = process.StateStopped s := newSerial(eff) s.OnRequest(req("a")) // cold load s.OnRequest(req("a")) // queued behind the first eff.states["a"] = process.StateReady s.OnSwapDone(SwapDone{ModelID: "a"}) // first serves if got := eff.served("a"); got != 1 { t.Fatalf("served(a)=%d want 1 (one at a time)", got) } s.OnServeDone(ServeDoneEvent{ModelID: "a"}) // first done -> second serves if got := eff.served("a"); got != 2 { t.Fatalf("served(a)=%d want 2", got) } if got := eff.startsFor("a"); got != 1 { t.Errorf("StartSwap(a)=%d want 1 (second request must not reload)", got) } } // TestSerial_StrictArrivalOrder covers the scoring function's degenerate case: // with equal priorities and swap affinity disabled, qwen36, qwen35, sdxl, // qwen36 execute in EXACTLY that order with evictions between each model // switch, including reloading qwen36 at the end even though it ran first. // Aging cannot reorder them either — they all arrive at the same instant, so // they age at the same rate. func TestSerial_StrictArrivalOrder(t *testing.T) { eff := newFakeEffects() for _, m := range []string{"qwen36", "qwen35", "sdxl"} { eff.states[m] = process.StateStopped } s := newSerialCfg(eff, config.DefaultAgingDivisor, 0) for _, m := range []string{"qwen36", "qwen35", "sdxl", "qwen36"} { s.OnRequest(req(m)) } // Only the first job starts loading; the rest wait their turn. if len(eff.starts) != 1 || eff.starts[0].model != "qwen36" { t.Fatalf("starts=%+v want only [qwen36] loading first", eff.starts) } // step completes the current model's load+serve and returns control to the // scheduler, which must start the next queued model. step := func(model string, wantEvict []string) { t.Helper() st := lastStart(t, eff) if st.model != model { t.Fatalf("loading %q want %q", st.model, model) } if !sameSet(st.evict, wantEvict) { t.Fatalf("loading %q evict=%v want %v", model, st.evict, wantEvict) } // Simulate the eviction + load actually happening. for _, e := range st.evict { eff.states[e] = process.StateStopped } eff.states[model] = process.StateReady s.OnSwapDone(SwapDone{ModelID: model}) s.OnServeDone(ServeDoneEvent{ModelID: model}) } step("qwen36", nil) // cold load, nothing else running step("qwen35", []string{"qwen36"}) // evict qwen36 step("sdxl", []string{"qwen35"}) // evict qwen35 step("qwen36", []string{"sdxl"}) // RELOAD qwen36, evict sdxl want := []string{"qwen36", "qwen35", "sdxl", "qwen36"} if got := servedOrder(eff); !sameOrder(got, want) { t.Fatalf("serve order=%v want %v", got, want) } } func sameOrder(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // stepModel completes the current load+serve for model and returns control to // the scheduler so it dispatches the next queued request. func stepModel(t *testing.T, s *Serial, eff *fakeEffects, model string) { t.Helper() if len(eff.starts) > 0 { if st := eff.starts[len(eff.starts)-1]; st.model == model { for _, e := range st.evict { eff.states[e] = process.StateStopped } eff.states[model] = process.StateReady s.OnSwapDone(SwapDone{ModelID: model}) } } s.OnServeDone(ServeDoneEvent{ModelID: model}) } // TestSerial_HigherPriorityDispatchesFirst is the point of the whole exercise: // a batch job already queued must yield to an interactive request that arrives // later, because dispatch order is by score, not arrival. func TestSerial_HigherPriorityDispatchesFirst(t *testing.T) { eff := newFakeEffects() for _, m := range []string{"running", "batch", "interactive"} { eff.states[m] = process.StateStopped } s := newSerial(eff) s.OnRequest(reqP("running", shared.PriorityNormal)) // dispatches immediately s.OnRequest(reqP("batch", shared.PriorityBatch)) // queued first... s.OnRequest(reqP("interactive", shared.PriorityInteractive)) // ...but this jumps it stepModel(t, s, eff, "running") if got := eff.startsFor("interactive"); got != 1 { t.Fatalf("StartSwap(interactive)=%d want 1 (must overtake the queued batch job)", got) } if got := eff.startsFor("batch"); got != 0 { t.Fatalf("StartSwap(batch)=%d want 0 (still waiting behind interactive)", got) } stepModel(t, s, eff, "interactive") if got := eff.startsFor("batch"); got != 1 { t.Fatalf("StartSwap(batch)=%d want 1 once nothing outranks it", got) } } // TestSerial_TierOffsetBreaksTieWithinBand verifies the composition rule the // design depends on: a small per-caller offset orders requests inside a band // and never promotes one across a band. A "max member" batch job at -98 beats // other batch work but still loses to every normal request at 0. func TestSerial_TierOffsetBreaksTieWithinBand(t *testing.T) { eff := newFakeEffects() for _, m := range []string{"running", "free", "max", "normal"} { eff.states[m] = process.StateStopped } s := newSerialCfg(eff, 0, 0) // isolate the priority term s.OnRequest(reqP("running", 0)) s.OnRequest(reqP("free", shared.PriorityBatch)) // -100 s.OnRequest(reqP("max", shared.PriorityBatch+2)) // -98, max tier s.OnRequest(reqP("normal", shared.PriorityNormal)) // 0 stepModel(t, s, eff, "running") if got := eff.startsFor("normal"); got != 1 { t.Fatalf("StartSwap(normal)=%d want 1 (a tier bonus must not cross a band)", got) } stepModel(t, s, eff, "normal") if got := eff.startsFor("max"); got != 1 { t.Fatalf("StartSwap(max)=%d want 1 (max tier outranks free within the batch band)", got) } stepModel(t, s, eff, "max") if got := eff.startsFor("free"); got != 1 { t.Fatalf("StartSwap(free)=%d want 1 (last)", got) } } // TestSerial_AgingPreventsStarvation verifies the one term allowed to cross // bands: a batch job that has waited long enough eventually beats an // interactive request that arrived just now. func TestSerial_AgingPreventsStarvation(t *testing.T) { eff := newFakeEffects() for _, m := range []string{"running", "batch", "interactive"} { eff.states[m] = process.StateStopped } s := newSerialCfg(eff, 60, 0) // one point per minute, no affinity clock := newFakeClock(s) s.OnRequest(reqP("running", shared.PriorityNormal)) s.OnRequest(reqP("batch", shared.PriorityBatch)) // -100 // The batch job waits long enough to gain 201 points: -100 + 201 = 101, // just past a fresh interactive request at +100. clock.advance(201 * time.Minute) s.OnRequest(reqP("interactive", shared.PriorityInteractive)) stepModel(t, s, eff, "running") if got := eff.startsFor("batch"); got != 1 { t.Fatalf("StartSwap(batch)=%d want 1 (aging must eventually beat a fresh interactive request)", got) } if got := eff.startsFor("interactive"); got != 0 { t.Fatalf("StartSwap(interactive)=%d want 0 (outranked by the aged batch job)", got) } } // TestSerial_AgingCannotCrossBandTooEarly is the other half of aging: a batch // job that has waited only a little still loses to interactive traffic. func TestSerial_AgingCannotCrossBandTooEarly(t *testing.T) { eff := newFakeEffects() for _, m := range []string{"running", "batch", "interactive"} { eff.states[m] = process.StateStopped } s := newSerialCfg(eff, 60, 0) clock := newFakeClock(s) s.OnRequest(reqP("running", shared.PriorityNormal)) s.OnRequest(reqP("batch", shared.PriorityBatch)) clock.advance(30 * time.Minute) // -100 + 30 = -70 s.OnRequest(reqP("interactive", shared.PriorityInteractive)) stepModel(t, s, eff, "running") if got := eff.startsFor("interactive"); got != 1 { t.Fatalf("StartSwap(interactive)=%d want 1 (30 minutes is not enough aging)", got) } } // TestSerial_SwapAffinity_PrefersResidentModel verifies the bounded bonus keeps // the model cache from thrashing: with equal priorities, a queued request for // the already-loaded model runs before earlier requests that would each force a // cold load. func TestSerial_SwapAffinity_PrefersResidentModel(t *testing.T) { eff := newFakeEffects() for _, m := range []string{"qwen36", "qwen35", "sdxl"} { eff.states[m] = process.StateStopped } s := newSerial(eff) // affinity +10 by default for _, m := range []string{"qwen36", "qwen35", "sdxl", "qwen36"} { s.OnRequest(req(m)) } stepModel(t, s, eff, "qwen36") // qwen36 now resident and ready if got := eff.served("qwen36"); got != 2 { t.Fatalf("served(qwen36)=%d want 2 (the trailing qwen36 should skip the swap queue)", got) } if len(eff.starts) != 1 { t.Fatalf("starts=%+v want only the initial qwen36 load (no reload)", eff.starts) } stepModel(t, s, eff, "qwen36") if got := eff.startsFor("qwen35"); got != 1 { t.Fatalf("StartSwap(qwen35)=%d want 1 once no resident request remains", got) } } // TestSerial_SwapAffinity_CannotCrossBand verifies the bonus is bounded: an // interactive request that needs a cold load still beats a batch job for the // already-resident model. func TestSerial_SwapAffinity_CannotCrossBand(t *testing.T) { eff := newFakeEffects() eff.states["resident"] = process.StateReady eff.states["cold"] = process.StateStopped s := newSerialCfg(eff, 0, 99) // the largest bonus config permits s.OnRequest(reqP("resident", shared.PriorityNormal)) // dispatches immediately s.OnRequest(reqP("resident", shared.PriorityBatch)) // queued, would get +99 s.OnRequest(reqP("cold", shared.PriorityInteractive)) s.OnServeDone(ServeDoneEvent{ModelID: "resident"}) if got := eff.startsFor("cold"); got != 1 { t.Fatalf("StartSwap(cold)=%d want 1 (-100+99 must still lose to +100)", got) } } // TestSerial_DispatchSetsMetadata verifies the dispatch decision is recorded on // the request context, which is what puts it in the activity log. func TestSerial_DispatchSetsMetadata(t *testing.T) { eff := newFakeEffects() eff.states["hold"] = process.StateReady eff.states["a"] = process.StateStopped s := newSerialCfg(eff, 60, 0) clock := newFakeClock(s) s.OnRequest(req("hold")) // serves immediately, occupying the slot ctx := shared.SetContext(context.Background(), shared.ReqContextData{ModelID: "a", Metadata: make(map[string]string)}) s.OnRequest(HandlerReq{Model: "a", Priority: shared.PriorityBatch, Ctx: ctx}) clock.advance(2 * time.Minute) // -100 + 2 aging points s.OnServeDone(ServeDoneEvent{ModelID: "hold"}) // a is dispatched (and annotated) here; finish its load so it is granted. eff.states["hold"] = process.StateStopped eff.states["a"] = process.StateReady s.OnSwapDone(SwapDone{ModelID: "a"}) data, ok := shared.ReadContext(eff.lastServeReq.Ctx) if !ok { t.Fatal("context data missing from granted request") } want := map[string]string{ "serial_priority": "-100", "serial_band": shared.BandBatch, "serial_score": "-98", "serial_queue_wait_ms": "120000", } for k, v := range want { if got := data.Metadata[k]; got != v { t.Errorf("%s = %q, want %q", k, got, v) } } } // TestSerial_QueueStats checks the numbers the /metrics endpoint reports: // per-band depth and wait while queued, dispatch counts after the fact, and the // reorder counters that say whether the scoring terms changed anything. func TestSerial_QueueStats(t *testing.T) { eff := newFakeEffects() for _, m := range []string{"running", "batch", "interactive"} { eff.states[m] = process.StateStopped } s := newSerialCfg(eff, 60, 0) clock := newFakeClock(s) s.OnRequest(reqP("running", shared.PriorityNormal)) // dispatched, not queued s.OnRequest(reqP("batch", shared.PriorityBatch)) clock.advance(5 * time.Minute) s.OnRequest(reqP("interactive", shared.PriorityInteractive)) stats := s.QueueStats() if got := stats.Bands[shared.BandBatch].Depth; got != 1 { t.Errorf("batch depth=%d want 1", got) } if got := stats.Bands[shared.BandBatch].OldestWait; got != 5*time.Minute { t.Errorf("batch oldest wait=%s want 5m", got) } if got := stats.Bands[shared.BandInteractive].Depth; got != 1 { t.Errorf("interactive depth=%d want 1", got) } if got := stats.Bands[shared.BandNormal].Depth; got != 0 { t.Errorf("normal depth=%d want 0 (it dispatched immediately)", got) } if got := stats.Bands[shared.BandNormal].Dispatched; got != 1 { t.Errorf("normal dispatched=%d want 1", got) } // Dispatching interactive over the older batch job is priority doing its // job, not aging: with only 5 minutes of aging the winner is unchanged. stepModel(t, s, eff, "running") stats = s.QueueStats() if got := stats.Bands[shared.BandInteractive].Dispatched; got != 1 { t.Errorf("interactive dispatched=%d want 1", got) } if stats.AgingReorders != 0 { t.Errorf("aging reorders=%d want 0", stats.AgingReorders) } if stats.AffinityReorders != 0 { t.Errorf("affinity reorders=%d want 0 (affinity disabled)", stats.AffinityReorders) } } // TestSerial_QueueStats_CountsReorders verifies each scoring term is credited // when it actually changes the dispatch decision. func TestSerial_QueueStats_CountsReorders(t *testing.T) { t.Run("aging", func(t *testing.T) { eff := newFakeEffects() for _, m := range []string{"running", "old", "new"} { eff.states[m] = process.StateStopped } s := newSerialCfg(eff, 60, 0) clock := newFakeClock(s) s.OnRequest(reqP("running", 0)) s.OnRequest(reqP("old", -10)) clock.advance(30 * time.Minute) // old: -10 + 30 = 20 s.OnRequest(reqP("new", 0)) // new: 0 stepModel(t, s, eff, "running") if got := eff.startsFor("old"); got != 1 { t.Fatalf("StartSwap(old)=%d want 1 (aging promoted it)", got) } if got := s.QueueStats().AgingReorders; got != 1 { t.Errorf("aging reorders=%d want 1", got) } }) t.Run("swap affinity", func(t *testing.T) { eff := newFakeEffects() eff.states["resident"] = process.StateReady eff.states["cold"] = process.StateStopped s := newSerialCfg(eff, 0, 10) s.OnRequest(req("resident")) // dispatches immediately s.OnRequest(reqP("cold", 5)) // higher priority, but needs a swap s.OnRequest(reqP("resident", 0)) // +10 affinity beats it s.OnServeDone(ServeDoneEvent{ModelID: "resident"}) if got := eff.served("resident"); got != 2 { t.Fatalf("served(resident)=%d want 2 (affinity outranked the cold request)", got) } if got := s.QueueStats().AffinityReorders; got != 1 { t.Errorf("affinity reorders=%d want 1", got) } }) } func TestSerial_SwapError_FailsCallerAndAdvances(t *testing.T) { eff := newFakeEffects() eff.states["a"] = process.StateStopped eff.states["b"] = process.StateStopped s := newSerial(eff) s.OnRequest(req("a")) s.OnRequest(req("b")) // queued behind a // a's load fails: its caller is errored and b proceeds. s.OnSwapDone(SwapDone{ModelID: "a", Err: errors.New("boom")}) if eff.errored("a") != 1 { t.Fatalf("errored(a)=%d want 1", eff.errored("a")) } if got := eff.startsFor("b"); got != 1 { t.Fatalf("StartSwap(b)=%d want 1 after a's load failed", got) } } // TestSerial_GrantServeFalse_Advances verifies that when the active request's // caller has disconnected by serve time, the queue advances to the next request. func TestSerial_GrantServeFalse_Advances(t *testing.T) { eff := newFakeEffects() eff.states["a"] = process.StateStopped eff.states["b"] = process.StateStopped eff.serveResult["a"] = false // a's caller is gone by grant time s := newSerial(eff) s.OnRequest(req("a")) s.OnRequest(req("b")) // queued eff.states["a"] = process.StateReady s.OnSwapDone(SwapDone{ModelID: "a"}) // grant fails -> advance to b if got := eff.served("a"); got != 0 { t.Errorf("served(a)=%d want 0 (caller gone)", got) } if got := eff.startsFor("b"); got != 1 { t.Fatalf("StartSwap(b)=%d want 1 (advanced after gone caller)", got) } } func TestSerial_OnCancel_QueuedRequest(t *testing.T) { eff := newFakeEffects() eff.states["a"] = process.StateStopped eff.states["b"] = process.StateStopped s := newSerial(eff) s.OnRequest(reqCh("a")) // starts loading a cancelled := reqCh("b") s.OnRequest(cancelled) // queued behind a if len(s.queued) != 1 { t.Fatalf("queued=%d want 1", len(s.queued)) } s.OnCancel(cancelled) if len(s.queued) != 0 { t.Fatalf("queued=%d want 0 after cancel", len(s.queued)) } // a completes; b is gone, so nothing starts for it. eff.states["a"] = process.StateReady s.OnSwapDone(SwapDone{ModelID: "a"}) s.OnServeDone(ServeDoneEvent{ModelID: "a"}) if got := eff.startsFor("b"); got != 0 { t.Errorf("StartSwap(b)=%d want 0 (cancelled before its turn)", got) } } func TestSerial_OnShutdown_FailsQueuedAndActiveLoad(t *testing.T) { eff := newFakeEffects() eff.states["a"] = process.StateStopped eff.states["b"] = process.StateStopped eff.states["c"] = process.StateStopped s := newSerial(eff) s.OnRequest(req("a")) // active (loading) s.OnRequest(req("b")) // queued s.OnRequest(req("c")) // queued s.OnShutdown(errors.New("shutting down")) if got := eff.errored(""); got != 3 { t.Errorf("error grants=%d want 3 (active load + 2 queued)", got) } if len(s.queued) != 0 { t.Errorf("queued=%d want 0 after shutdown", len(s.queued)) } } // TestSerial_OnUnload_WhileServing verifies that unloading the model that is // actively serving does not strand the queue: OnUnload stops the process but // leaves the active request to end via OnServeDone, which then advances. func TestSerial_OnUnload_WhileServing(t *testing.T) { eff := newFakeEffects() eff.states["a"] = process.StateReady eff.states["b"] = process.StateStopped s := newSerial(eff) s.OnRequest(req("a")) // served immediately (a ready) s.OnRequest(req("b")) // queued behind a if got := eff.served("a"); got != 1 { t.Fatalf("served(a)=%d want 1", got) } // Unload a while it is serving: the process is stopped, but the queue must // not advance yet — the active serve is still outstanding. s.OnUnload([]string{"a"}, time.Second) if len(eff.stops) != 1 || !sameSet(eff.stops[0].ids, []string{"a"}) { t.Errorf("StopProcesses=%+v want one call stopping [a]", eff.stops) } if got := eff.startsFor("b"); got != 0 { t.Fatalf("StartSwap(b)=%d want 0 before the serving request ends", got) } // The killed handler returns -> OnServeDone advances to b. eff.states["a"] = process.StateStopped s.OnServeDone(ServeDoneEvent{ModelID: "a"}) if got := eff.startsFor("b"); got != 1 { t.Fatalf("StartSwap(b)=%d want 1 after the serving request ended", got) } } func TestSerial_OnUnload_DropsQueuedAndStops(t *testing.T) { eff := newFakeEffects() eff.states["a"] = process.StateStopped eff.states["b"] = process.StateStopped s := newSerial(eff) s.OnRequest(req("a")) // active (loading a) s.OnRequest(req("b")) // queued // Unload a: its active load is failed and a is stopped. s.OnUnload([]string{"a"}, time.Second) if eff.errored("a") != 1 { t.Errorf("errored(a)=%d want 1 (active load failed)", eff.errored("a")) } if len(eff.stops) != 1 || !sameSet(eff.stops[0].ids, []string{"a"}) { t.Errorf("StopProcesses=%+v want one call stopping [a]", eff.stops) } // b was queued and not unloaded; with a's load cancelled it now starts. if got := eff.startsFor("b"); got != 1 { t.Errorf("StartSwap(b)=%d want 1 after unload advanced the queue", got) } }