package activator import ( "context" "fmt" "path/filepath" "slices" "sync" "testing" "time" "github.com/carlosframework/platform/internal/registry" ) // genFakeBackend is fakeBackend with the generation capability. A spawn // with perGen "binds" /gen/.sock (nothing is bound: the fake proc stands // in for the process) and NextDueVia records the socket it was asked to // dial, so a test can see which generation a probe reached. type genFakeBackend struct { *fakeBackend gmu sync.Mutex perGens []bool dueSocks []string } func (b *genFakeBackend) StartGen(ctx context.Context, rt registry.Route, n uint64, perGen bool) (Proc, Gen, error) { b.gmu.Lock() b.perGens = append(b.perGens, perGen) b.gmu.Unlock() proc, ver, err := b.fakeBackend.Start(ctx, rt) if err != nil { return proc, Gen{}, err } sock := rt.Socket if perGen { sock = fmt.Sprintf("/gen/%d.sock", n) } return proc, Gen{N: n, Socket: sock, Control: sock + ".control", Build: ver}, nil } func (b *genFakeBackend) NextDueVia(ctx context.Context, rt registry.Route, socket string) (int64, error) { b.gmu.Lock() b.dueSocks = append(b.dueSocks, socket) b.gmu.Unlock() return b.fakeBackend.NextDue(ctx, rt) } func (b *genFakeBackend) seen() (perGens []bool, dueSocks []string) { b.gmu.Lock() defer b.gmu.Unlock() return slices.Clone(b.perGens), slices.Clone(b.dueSocks) } // genActivator is testActivator over genFakeBackend, with the switch set // for the test's duration BEFORE New reads it. func genActivator(t *testing.T, on bool) (*Service, *genFakeBackend) { t.Helper() withGenerations(t, on) gb := &genFakeBackend{fakeBackend: &fakeBackend{execVerFn: func() string { return "v7" }}} reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } return New(Config{Bucket: "test-bucket", Idle: time.Hour}, gb, reg), gb } // genTestRoute is testRoute with its canonical socket in the test's own // temp dir. With generations on the activator links a route's canonical // path to its current generation (Task 4), and testRoute's shared // /tmp/nope.sock must never become a real link. func genTestRoute(t *testing.T, host string) registry.Route { t.Helper() rt := testRoute(host) rt.Socket = filepath.Join(t.TempDir(), "c.sock") return rt } // stopAndWait bounces host's awake instance and waits for the stop to finish. func stopAndWait(t *testing.T, a *Service, host string) { t.Helper() if !a.Bounce(host) { t.Fatalf("Bounce(%s) started no stop", host) } waitFor(t, host+" to stop", func() bool { return !a.Owns(host) }) } func TestAppSocketNamesTheCurrentGeneration(t *testing.T) { a, gb := genActivator(t, true) rt := genTestRoute(t, "cur.example.com") if _, ok := a.AppSocket(rt.Host); ok { t.Fatal("AppSocket answered for an instance that never woke") } arrangeAwake(t, a, rt) g, ok := a.ServingGen(rt.Host) if !ok || g.N == 0 { t.Fatalf("ServingGen = %+v, %v; want the awake generation", g, ok) } sock, ok := a.AppSocket(rt.Host) if !ok || sock != fmt.Sprintf("/gen/%d.sock", g.N) || g.Socket != sock || g.Build != "v7" { t.Fatalf("AppSocket = %q (%v), ServingGen = %+v; want /gen/%d.sock and build v7", sock, ok, g, g.N) } if n, ok := a.CacheGen(rt.Host); !ok || n != g.N { t.Fatalf("CacheGen = %d, %v; want %d", n, ok, g.N) } if perGens, _ := gb.seen(); !slices.Equal(perGens, []bool{true}) { t.Fatalf("StartGen perGen = %v, want [true]", perGens) } } // Flag off: one generation per entry, on the canonical socket. func TestAppSocketIsTheCanonicalSocketWithGenerationsOff(t *testing.T) { a, gb := genActivator(t, false) rt := genTestRoute(t, "off.example.com") arrangeAwake(t, a, rt) if sock, ok := a.AppSocket(rt.Host); !ok || sock != rt.Socket { t.Fatalf("AppSocket = %q, %v; want the canonical %q", sock, ok, rt.Socket) } if perGens, _ := gb.seen(); !slices.Equal(perGens, []bool{false}) { t.Fatalf("StartGen perGen = %v, want [false]", perGens) } } // A backend without the capability (UnitBackend, older doubles) still gets a // numbered, canonical handle. func TestPlainBackendGetsTheCanonicalHandle(t *testing.T) { withGenerations(t, true) a, _, _, _ := testActivator(t, time.Hour) rt := genTestRoute(t, "plain.example.com") arrangeAwake(t, a, rt) g, ok := a.ServingGen(rt.Host) if !ok || g.Socket != rt.Socket || g.N == 0 { t.Fatalf("ServingGen = %+v, %v; want the canonical socket and a number", g, ok) } } // Spec ยง12 "Generation identity": a stop, then a crash, then a wake never // reuses a generation number. CacheGen keeps the last number while the // instance sleeps. func TestGenerationNumbersNeverRepeatAcrossStopCrashAndWake(t *testing.T) { a, gb := genActivator(t, true) rt := genTestRoute(t, "ident.example.com") arrangeAwake(t, a, rt) g1, _ := a.ServingGen(rt.Host) stopAndWait(t, a, rt.Host) if _, ok := a.AppSocket(rt.Host); ok { t.Fatal("AppSocket answered for a sleeping instance") } if n, ok := a.CacheGen(rt.Host); !ok || n != g1.N { t.Fatalf("CacheGen while asleep = %d, %v; want the last current generation %d", n, ok, g1.N) } arrangeAwake(t, a, rt) g2, _ := a.ServingGen(rt.Host) if g2.N <= g1.N { t.Fatalf("wake after a stop took generation %d after %d", g2.N, g1.N) } gb.fakeBackend.mu.Lock() crashed := gb.fakeBackend.procs[len(gb.fakeBackend.procs)-1] gb.fakeBackend.mu.Unlock() crashed.exit() arrangeAwake(t, a, rt) // finds the dead child, stops it, wakes a fresh one g3, _ := a.ServingGen(rt.Host) if g3.N <= g2.N { t.Fatalf("wake after a crash took generation %d after %d", g3.N, g2.N) } if n, _ := a.CacheGen(rt.Host); n != g3.N { t.Fatalf("CacheGen = %d, want %d", n, g3.N) } } // The sleep-time next-due probe dials AppSocket: the generation socket with // the switch on, the canonical one with it off. func TestNextDueAtStopDialsTheCurrentGeneration(t *testing.T) { for _, on := range []bool{true, false} { t.Run(fmt.Sprintf("generations=%v", on), func(t *testing.T) { a, gb := genActivator(t, on) rt := genTestRoute(t, "due.example.com") arrangeAwake(t, a, rt) g, _ := a.ServingGen(rt.Host) stopAndWait(t, a, rt.Host) want := rt.Socket if on { want = fmt.Sprintf("/gen/%d.sock", g.N) } if _, dueSocks := gb.seen(); !slices.Equal(dueSocks, []string{want}) { t.Fatalf("next-due probe dialled %v, want [%s]", dueSocks, want) } }) } } // A retire hook hears about a generation that stopped being current, with // the switch on, and never with it off (the canonical path is shared by every // generation then). func TestGenRetiredFiresAtStopOnlyWithGenerationsOn(t *testing.T) { for _, on := range []bool{true, false} { t.Run(fmt.Sprintf("generations=%v", on), func(t *testing.T) { a, _ := genActivator(t, on) var mu sync.Mutex var got []string a.OnGenRetired(func(host, socket string) { mu.Lock() got = append(got, host+" "+socket) mu.Unlock() }) rt := genTestRoute(t, "ret.example.com") arrangeAwake(t, a, rt) g, _ := a.ServingGen(rt.Host) stopAndWait(t, a, rt.Host) mu.Lock() defer mu.Unlock() var want []string if on { want = []string{rt.Host + " " + g.Socket} } if !slices.Equal(got, want) { t.Fatalf("retire hooks = %v, want %v", got, want) } }) } } // A worker binds nothing, so it has no app socket for traffic. func TestAppSocketIsFalseForAWorker(t *testing.T) { a, _ := genActivator(t, true) rt := testWorkerRoute("bot.x.internal") if err := a.WakeWorker(rt); err != nil { t.Fatalf("WakeWorker: %v", err) } waitFor(t, "the worker to wake", func() bool { return a.Owns(rt.Host) }) if sock, ok := a.AppSocket(rt.Host); ok { t.Fatalf("AppSocket(worker) = %q, want false", sock) } }