eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// Unit tests for the activator state machine (activator.go) with a fake
4// backend — wake coalescing, idle→sleep, lease-lost stand-down, contention
5// 503s. No AWS, no processes: the live end-to-end test (the wake/restore/
6// sleep money path against real S3 + a real spawned instance) is
7// TestActivatorLiveHibernation in activator_live_test.go.
8
9import (
10 "context"
11 "errors"
12 "fmt"
13 "io"
14 "net"
15 "net/http"
16 "net/http/httptest"
17 "os"
18 "path/filepath"
19 "sync"
20 "sync/atomic"
21 "testing"
22 "time"
23
24 "lchat/internal/bot"
25)
26
27// --- fakes -------------------------------------------------------------------
28
29type fakeRep struct {
30 mu sync.Mutex
31 err error
32 closed bool
33}
34
35func (r *fakeRep) Err() error {
36 r.mu.Lock()
37 defer r.mu.Unlock()
38 return r.err
39}
40func (r *fakeRep) setErr(err error) {
41 r.mu.Lock()
42 defer r.mu.Unlock()
43 r.err = err
44}
45func (r *fakeRep) Close(context.Context) error {
46 r.mu.Lock()
47 defer r.mu.Unlock()
48 r.closed = true
49 return nil
50}
51func (r *fakeRep) isClosed() bool {
52 r.mu.Lock()
53 defer r.mu.Unlock()
54 return r.closed
55}
56
57type fakeProc struct {
58 mu sync.Mutex
59 stopped bool
60 dead bool
61 // stopGate, when non-nil, blocks stop() until closed — holds an entry in
62 // the stopping state so a test can poke at it.
63 stopGate chan struct{}
64 // label + rec feed the backend's order log ("instance" / "sidecar"), so
65 // the co-spawn/co-stop tests can assert interleavings.
66 label string
67 rec *fakeBackend
68}
69
70func (p *fakeProc) stop(context.Context) error {
71 p.mu.Lock()
72 gate := p.stopGate
73 p.mu.Unlock()
74 if gate != nil {
75 <-gate
76 }
77 p.mu.Lock()
78 p.stopped = true
79 p.mu.Unlock()
80 if p.rec != nil {
81 p.rec.record("stop:" + p.label)
82 }
83 return nil
84}
85func (p *fakeProc) exited() bool {
86 p.mu.Lock()
87 defer p.mu.Unlock()
88 return p.dead
89}
90func (p *fakeProc) isStopped() bool {
91 p.mu.Lock()
92 defer p.mu.Unlock()
93 return p.stopped
94}
95
96type fakeLease struct {
97 rep *fakeRep
98 b *fakeBackend // records the sync interval the wake threads through
99}
100
101func (l *fakeLease) replicate(_ context.Context, _ string, syncInterval time.Duration) (actRep, error) {
102 if l.b != nil {
103 l.b.mu.Lock()
104 l.b.syncIntervals = append(l.b.syncIntervals, syncInterval)
105 l.b.mu.Unlock()
106 }
107 return l.rep, nil
108}
109func (l *fakeLease) release(context.Context) error { return nil }
110
111type fakeBackend struct {
112 mu sync.Mutex
113 acquires int
114 restores int
115 starts int
116 acquireErr error
117 startGate chan struct{} // when non-nil, start() blocks until closed
118 reps []*fakeRep
119 procs []*fakeProc
120 // syncIntervals records, per wake, the replication-class tick doWake
121 // threads into replicate() — the ladder's seam assertion.
122 syncIntervals []time.Duration
123 dueVal int64 // what the instance reports at the sleep-time next-due probe
124 dueErr error // when non-nil, the probe fails (dead socket, missing token, …)
125
126 // execVerFn models the live/<host> target the child execs at spawn — start()
127 // returns it as the spawnVersion, exactly as s3Backend reads the symlink just
128 // before Start(). Nil → "" (unknown), which disables convergence, so tests
129 // that don't care are unaffected. Point it at the same source as the sweep's
130 // liveVer seam to model a symlink faithfully (both read the current target).
131 execVerFn func() string
132
133 // Bot sidecar co-spawn. sidecarStaged is hasSidecar's answer (false by
134 // default, so every pre-co-spawn test doubles as the "host without a
135 // sidecar spawns none" case); sidecarErr injects spawn failures.
136 sidecarStaged bool
137 sidecarErr error
138 sidecarStarts int
139 sidecars []*fakeProc
140 // order interleaves backend calls and proc stops (start:instance,
141 // start:sidecar, nextDue, stop:sidecar, stop:instance) for the ordering
142 // assertions.
143 order []string
144}
145
146func (b *fakeBackend) record(ev string) {
147 b.mu.Lock()
148 b.order = append(b.order, ev)
149 b.mu.Unlock()
150}
151
152func (b *fakeBackend) events() []string {
153 b.mu.Lock()
154 defer b.mu.Unlock()
155 return append([]string(nil), b.order...)
156}
157
158func (b *fakeBackend) acquire(context.Context, string) (actLease, error) {
159 b.mu.Lock()
160 b.acquires++
161 err := b.acquireErr
162 rep := &fakeRep{}
163 b.reps = append(b.reps, rep)
164 b.mu.Unlock()
165 if err != nil {
166 return nil, err
167 }
168 return &fakeLease{rep: rep, b: b}, nil
169}
170
171func (b *fakeBackend) restore(context.Context, string, string) error {
172 b.mu.Lock()
173 defer b.mu.Unlock()
174 b.restores++
175 return nil
176}
177
178func (b *fakeBackend) start(context.Context, Route) (actProc, string, error) {
179 b.mu.Lock()
180 b.starts++
181 b.order = append(b.order, "start:instance")
182 gate := b.startGate
183 fn := b.execVerFn
184 proc := &fakeProc{label: "instance", rec: b}
185 b.procs = append(b.procs, proc)
186 b.mu.Unlock()
187 if gate != nil {
188 <-gate
189 }
190 ver := ""
191 if fn != nil {
192 ver = fn()
193 }
194 return proc, ver, nil
195}
196
197func (b *fakeBackend) nextDue(context.Context, Route) (int64, error) {
198 b.mu.Lock()
199 defer b.mu.Unlock()
200 b.order = append(b.order, "nextDue")
201 if b.dueErr != nil {
202 return 0, b.dueErr
203 }
204 return b.dueVal, nil
205}
206
207func (b *fakeBackend) hasSidecar(string) bool {
208 b.mu.Lock()
209 defer b.mu.Unlock()
210 return b.sidecarStaged
211}
212
213func (b *fakeBackend) startSidecar(context.Context, Route) (actProc, error) {
214 b.mu.Lock()
215 defer b.mu.Unlock()
216 b.sidecarStarts++
217 b.order = append(b.order, "start:sidecar")
218 if b.sidecarErr != nil {
219 return nil, b.sidecarErr
220 }
221 p := &fakeProc{label: "sidecar", rec: b}
222 b.sidecars = append(b.sidecars, p)
223 return p, nil
224}
225
226func (b *fakeBackend) sidecarStartCount() int {
227 b.mu.Lock()
228 defer b.mu.Unlock()
229 return b.sidecarStarts
230}
231
232func (b *fakeBackend) lastSidecar() *fakeProc {
233 b.mu.Lock()
234 defer b.mu.Unlock()
235 return b.sidecars[len(b.sidecars)-1]
236}
237
238func (b *fakeBackend) setSidecarErr(err error) {
239 b.mu.Lock()
240 defer b.mu.Unlock()
241 b.sidecarErr = err
242}
243
244func (b *fakeBackend) setDue(v int64) {
245 b.mu.Lock()
246 defer b.mu.Unlock()
247 b.dueVal = v
248}
249
250func (b *fakeBackend) counts() (acquires, restores, starts int) {
251 b.mu.Lock()
252 defer b.mu.Unlock()
253 return b.acquires, b.restores, b.starts
254}
255
256func (b *fakeBackend) lastProc() *fakeProc {
257 b.mu.Lock()
258 defer b.mu.Unlock()
259 return b.procs[len(b.procs)-1]
260}
261
262func (b *fakeBackend) lastRep() *fakeRep {
263 b.mu.Lock()
264 defer b.mu.Unlock()
265 return b.reps[len(b.reps)-1]
266}
267
268// fakeClock is the activator's `now` seam.
269type fakeClock struct {
270 mu sync.Mutex
271 t time.Time
272}
273
274func (c *fakeClock) now() time.Time {
275 c.mu.Lock()
276 defer c.mu.Unlock()
277 return c.t
278}
279func (c *fakeClock) advance(d time.Duration) {
280 c.mu.Lock()
281 defer c.mu.Unlock()
282 c.t = c.t.Add(d)
283}
284
285// testActivator wires the fakes to a REAL (temp-dir) registry as the due store,
286// so the due-time tests exercise the actual routes.next_due SQL, not a fake of it.
287func testActivator(t *testing.T, idle time.Duration) (*activatorSvc, *fakeBackend, *fakeClock, *Registry) {
288 t.Helper()
289 b := &fakeBackend{}
290 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
291 reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
292 if err != nil {
293 t.Fatalf("open registry: %v", err)
294 }
295 a := newActivator(hibernateConfig{Bucket: "test-bucket", Idle: idle}, b, reg)
296 a.now = clk.now
297 return a, b, clk, reg
298}
299
300func testRoute(host string) Route {
301 // Hibernate is set because these routes model activator-managed hosts —
302 // the per-host gate (dueInstances, the router's upstream flag) only acts
303 // on flagged instances.
304 return Route{Host: host, Kind: "instance", Socket: "/tmp/nope.sock",
305 DBPath: filepath.Join("/tmp", host+".db"), Hibernate: true}
306}
307
308// (waitFor — poll until a condition holds — is shared from bot_run_test.go;
309// the stop path runs on its own goroutine, so tests observe its completion
310// rather than assuming it.)
311
312func (a *activatorSvc) entryCount() int {
313 a.mu.Lock()
314 defer a.mu.Unlock()
315 return len(a.entries)
316}
317
318// --- tests ---------------------------------------------------------------------
319
320// Many concurrent requests during a wake coalesce into exactly one wake: one
321// lease claim, one restore, one spawn.
322func TestActivatorWakeCoalesces(t *testing.T) {
323 a, b, _, _ := testActivator(t, time.Minute)
324 b.startGate = make(chan struct{})
325
326 const n = 12
327 var ok atomic.Int32
328 var wg sync.WaitGroup
329 for i := 0; i < n; i++ {
330 wg.Add(1)
331 go func() {
332 defer wg.Done()
333 release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
334 if err != nil {
335 t.Errorf("beginRequest: %v", err)
336 return
337 }
338 ok.Add(1)
339 release()
340 }()
341 }
342 // Let the requests pile up on the gated wake, then open it.
343 waitFor(t, "the wake to begin", func() bool { _, _, s := b.counts(); return s == 1 })
344 time.Sleep(50 * time.Millisecond)
345 close(b.startGate)
346 wg.Wait()
347
348 if got := ok.Load(); got != n {
349 t.Fatalf("%d/%d requests succeeded", got, n)
350 }
351 acquires, restores, starts := b.counts()
352 if acquires != 1 || restores != 1 || starts != 1 {
353 t.Fatalf("wake did not coalesce: acquires=%d restores=%d starts=%d (want 1 each)", acquires, restores, starts)
354 }
355}
356
357// The wake threads the route's replication class into replicate() as the
358// litestream sync interval — the per-plan durability ladder's seam. Unset
359// (every pre-ladder route) must thread 0, i.e. litestream's default: a deploy
360// of the ladder changes no host's durability by itself.
361func TestActivatorWakeThreadsReplicationClass(t *testing.T) {
362 a, b, _, _ := testActivator(t, time.Minute)
363
364 for i, tc := range []struct {
365 class string
366 want time.Duration
367 }{
368 {"", 0},
369 {replicationStream, 0},
370 {replicationBatched, 5 * time.Minute},
371 {replicationDaily, 24 * time.Hour},
372 } {
373 rt := testRoute(fmt.Sprintf("ladder%d.example.com", i))
374 rt.Replication = tc.class
375 release, err := a.beginRequest(context.Background(), rt)
376 if err != nil {
377 t.Fatalf("beginRequest(%q): %v", tc.class, err)
378 }
379 release()
380 b.mu.Lock()
381 got := b.syncIntervals[len(b.syncIntervals)-1]
382 b.mu.Unlock()
383 if got != tc.want {
384 t.Errorf("class %q threaded sync interval %v, want %v", tc.class, got, tc.want)
385 }
386 }
387}
388
389// After the idle window with nothing in flight, the sweeper hibernates the
390// instance: process stopped, replication closed (final checkpoint + lease
391// release), entry forgotten; the next request wakes it afresh.
392func TestActivatorIdleSleep(t *testing.T) {
393 a, b, clk, _ := testActivator(t, 10*time.Second)
394
395 release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
396 if err != nil {
397 t.Fatalf("beginRequest: %v", err)
398 }
399 release()
400
401 // Not yet idle: nothing happens.
402 clk.advance(9 * time.Second)
403 a.sweepOnce()
404 if a.entryCount() != 1 {
405 t.Fatal("instance hibernated before its idle window elapsed")
406 }
407
408 clk.advance(2 * time.Second)
409 a.sweepOnce()
410 waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 })
411 if !b.lastProc().isStopped() {
412 t.Fatal("idle instance's process was not stopped")
413 }
414 if !b.lastRep().isClosed() {
415 t.Fatal("idle instance's replication was not closed (no final checkpoint/lease release)")
416 }
417
418 // A new request wakes it again — a second full wake.
419 release, err = a.beginRequest(context.Background(), testRoute("one.example.com"))
420 if err != nil {
421 t.Fatalf("beginRequest after sleep: %v", err)
422 }
423 release()
424 if _, _, starts := b.counts(); starts != 2 {
425 t.Fatalf("starts=%d after sleep+rewake, want 2", starts)
426 }
427}
428
429// An in-flight request (an open WebSocket, in production) holds the instance
430// awake past the idle window; only after it finishes does the idle clock run.
431func TestActivatorInflightKeepsAwake(t *testing.T) {
432 a, b, clk, _ := testActivator(t, 10*time.Second)
433
434 release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
435 if err != nil {
436 t.Fatalf("beginRequest: %v", err)
437 }
438
439 clk.advance(time.Hour)
440 a.sweepOnce()
441 if a.entryCount() != 1 || b.lastProc().isStopped() {
442 t.Fatal("instance with an in-flight request was hibernated")
443 }
444
445 release() // stamps lastActive = now
446 a.sweepOnce()
447 if a.entryCount() != 1 {
448 t.Fatal("instance hibernated immediately after its last request finished")
449 }
450 clk.advance(11 * time.Second)
451 a.sweepOnce()
452 waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 })
453}
454
455// A ship repoints live/<host> under an awake child. A running process never
456// re-reads its symlink, so idle-hibernate is the only thing that converges an
457// instance — and an always-awake space (every bot-sidecar host) never idles.
458// The sweep closes that gap: it notices the drift and, after a grace window and
459// rate-limited to one per tick, gracefully cycles the child so its next wake
460// execs the new binary. See docs/awake-instance-convergence.md.
461func TestActivatorConvergesOnSymlinkRepoint(t *testing.T) {
462 a, b, clk, _ := testActivator(t, time.Hour) // idle far away — isolate the convergence path
463 var ver atomic.Value // the live/<host> symlink target; swapped to model a ship
464 ver.Store("v1")
465 // Model one symlink: the child execs whatever it points at now (execVerFn,
466 // frozen into spawnVersion at wake), and the sweep reads the current target
467 // (liveVer). A "ship" swaps ver; the running child keeps its frozen version.
468 a.liveVer = func(string) (string, error) { return ver.Load().(string), nil }
469 b.execVerFn = func() string { return ver.Load().(string) }
470
471 release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
472 if err != nil {
473 t.Fatalf("beginRequest: %v", err)
474 }
475 release()
476
477 // Symlink unchanged: never cycles, however long it stays awake.
478 clk.advance(3 * convergeGrace)
479 a.sweepOnce()
480 if a.entryCount() != 1 {
481 t.Fatal("cycled an instance whose symlink never moved")
482 }
483
484 // Ship: repoint the symlink. The first sweep after only starts the grace
485 // clock — it must not cycle yet, so a move-then-rollback never drops a live
486 // connection.
487 ver.Store("v2")
488 a.sweepOnce()
489 if a.entryCount() != 1 {
490 t.Fatal("cycled inside the grace window — a transient repoint would drop a live connection")
491 }
492
493 // Past the grace: gracefully cycle (stop → final checkpoint → lease release →
494 // asleep). The reconnecting client/sidecar would wake it onto v2.
495 clk.advance(convergeGrace + time.Second)
496 a.sweepOnce()
497 waitFor(t, "convergence cycle", func() bool { return a.entryCount() == 0 })
498 if !b.lastProc().isStopped() {
499 t.Fatal("stale instance's process was not stopped")
500 }
501 if !b.lastRep().isClosed() {
502 t.Fatal("convergence cycle skipped the final checkpoint / lease release")
503 }
504
505 // Re-wake against the now-current symlink: spawnVersion tracks v2, so it
506 // never cycles again — no thrash.
507 release, err = a.beginRequest(context.Background(), testRoute("one.example.com"))
508 if err != nil {
509 t.Fatalf("re-wake after convergence: %v", err)
510 }
511 release()
512 clk.advance(3 * convergeGrace)
513 a.sweepOnce()
514 if a.entryCount() != 1 {
515 t.Fatal("cycled again after already converging onto the current build")
516 }
517}
518
519// Two awake children go stale at once (a fleet-wide ship). One sweep cycles at
520// most one — a staggered convergence, never a simultaneous blip of every space.
521func TestActivatorConvergenceRateLimitsPerSweep(t *testing.T) {
522 a, b, clk, _ := testActivator(t, time.Hour)
523 var ver atomic.Value
524 ver.Store("v1")
525 a.liveVer = func(string) (string, error) { return ver.Load().(string), nil }
526 b.execVerFn = func() string { return ver.Load().(string) }
527
528 for _, h := range []string{"one.example.com", "two.example.com"} {
529 release, err := a.beginRequest(context.Background(), testRoute(h))
530 if err != nil {
531 t.Fatalf("beginRequest %s: %v", h, err)
532 }
533 release()
534 }
535 if a.entryCount() != 2 {
536 t.Fatalf("want 2 awake instances, got %d", a.entryCount())
537 }
538
539 ver.Store("v2") // ship under both
540 a.sweepOnce() // starts the grace clock for both
541 clk.advance(convergeGrace + time.Second)
542
543 a.sweepOnce() // both are past grace, but only one may cycle this tick
544 waitFor(t, "first convergence cycle", func() bool { return a.entryCount() == 1 })
545 // A moment later, the sweep takes the second.
546 a.sweepOnce()
547 waitFor(t, "second convergence cycle", func() bool { return a.entryCount() == 0 })
548}
549
550// The feature's whole premise: converge a space that NEVER reaches inflight==0
551// (an open WebSocket, or a bot sidecar's persistent connection), which
552// idle-hibernate can never touch. Unlike idle-hibernate, convergence fires with
553// a request in flight — the graceful stop drains it exactly as a restart would.
554func TestActivatorConvergesWithRequestInFlight(t *testing.T) {
555 a, b, clk, _ := testActivator(t, time.Hour)
556 var ver atomic.Value
557 ver.Store("v1")
558 a.liveVer = func(string) (string, error) { return ver.Load().(string), nil }
559 b.execVerFn = func() string { return ver.Load().(string) }
560
561 // Hold the request open for the whole test — inflight stays 1, so the idle
562 // path (which needs inflight==0) can never converge this host.
563 release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
564 if err != nil {
565 t.Fatalf("beginRequest: %v", err)
566 }
567 defer release()
568
569 ver.Store("v2") // ship under the busy space
570 a.sweepOnce() // grace starts; must not cycle a busy space mid-grace
571 if a.entryCount() != 1 {
572 t.Fatal("cycled inside the grace window")
573 }
574 clk.advance(convergeGrace + time.Second)
575 a.sweepOnce()
576 // Cycles despite the in-flight request — idle-hibernate never would.
577 waitFor(t, "busy-space convergence", func() bool { return a.entryCount() == 0 })
578 if !b.lastProc().isStopped() {
579 t.Fatal("busy stale instance's process was not stopped")
580 }
581}
582
583// convergeDecision's state machine, exhaustively: the grace clock starts on
584// first drift, survives a rollback correctly, and a read error never clears
585// accrued grace (it means "unknown", not "converged").
586func TestConvergeDecision(t *testing.T) {
587 base := time.Unix(1_700_000_000, 0)
588 set := base.Add(-40 * time.Second) // a grace clock that started 40s ago
589 errBoom := errors.New("readlink boom")
590 cases := []struct {
591 name string
592 cur string
593 readErr error
594 spawnVer string
595 staleSince time.Time
596 wantCycle bool
597 wantStale time.Time
598 }{
599 {"matches — clears grace", "v1", nil, "v1", set, false, time.Time{}},
600 {"empty cur — no data, clears", "", nil, "v1", set, false, time.Time{}},
601 {"empty spawnVer — unknown, clears", "v2", nil, "", set, false, time.Time{}},
602 {"first drift — starts grace, no cycle", "v2", nil, "v1", time.Time{}, false, base},
603 {"drift within grace — holds, no cycle", "v2", nil, "v1", base.Add(-30 * time.Second), false, base.Add(-30 * time.Second)},
604 {"drift past grace — cycles", "v2", nil, "v1", base.Add(-convergeGrace - time.Second), true, base.Add(-convergeGrace - time.Second)},
605 {"read error — preserves accrued grace", "", errBoom, "v1", set, false, set},
606 {"read error before any drift — stays zero", "", errBoom, "v1", time.Time{}, false, time.Time{}},
607 {"rollback to spawn — clears grace", "v1", nil, "v1", base.Add(-convergeGrace - time.Second), false, time.Time{}},
608 }
609 for _, c := range cases {
610 t.Run(c.name, func(t *testing.T) {
611 cycle, next := convergeDecision(c.cur, c.readErr, c.spawnVer, c.staleSince, base, convergeGrace)
612 if cycle != c.wantCycle {
613 t.Errorf("cycle = %v, want %v", cycle, c.wantCycle)
614 }
615 if !next.Equal(c.wantStale) {
616 t.Errorf("nextStale = %v, want %v", next, c.wantStale)
617 }
618 })
619 }
620}
621
622// A lost lease (failed renewal) stands the instance down immediately — no
623// waiting for idle: someone else may own the S3 prefix now.
624func TestActivatorLeaseLostStandsDown(t *testing.T) {
625 a, b, _, _ := testActivator(t, time.Hour)
626
627 release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
628 if err != nil {
629 t.Fatalf("beginRequest: %v", err)
630 }
631 release()
632
633 b.lastRep().setErr(errors.New("lease renewal failed"))
634 a.sweepOnce() // idle window is an hour away; the lost lease alone must trigger the stop
635 waitFor(t, "stand-down", func() bool { return a.entryCount() == 0 })
636 if !b.lastProc().isStopped() {
637 t.Fatal("lease-lost instance's process was not stopped")
638 }
639 if !b.lastRep().isClosed() {
640 t.Fatal("lease-lost instance's replication was not closed")
641 }
642}
643
644// A dead child (crashed instance) is recycled by the next request: the stale
645// entry is stopped and a fresh wake happens in the same beginRequest call.
646func TestActivatorDeadChildRewakesOnRequest(t *testing.T) {
647 a, b, _, _ := testActivator(t, time.Hour)
648
649 release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
650 if err != nil {
651 t.Fatalf("beginRequest: %v", err)
652 }
653 release()
654
655 p := b.lastProc()
656 p.mu.Lock()
657 p.dead = true
658 p.mu.Unlock()
659
660 release, err = a.beginRequest(context.Background(), testRoute("one.example.com"))
661 if err != nil {
662 t.Fatalf("beginRequest after crash: %v", err)
663 }
664 release()
665 if _, _, starts := b.counts(); starts != 2 {
666 t.Fatalf("starts=%d after crash+request, want 2 (recycle + rewake)", starts)
667 }
668}
669
670// A request that arrives mid-stop waits for the stop, then wakes fresh.
671func TestActivatorRequestDuringStopRewakes(t *testing.T) {
672 a, b, clk, _ := testActivator(t, 10*time.Second)
673
674 release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
675 if err != nil {
676 t.Fatalf("beginRequest: %v", err)
677 }
678 release()
679
680 // Hold the stop open so the entry sits in stopping.
681 gate := make(chan struct{})
682 p := b.lastProc()
683 p.mu.Lock()
684 p.stopGate = gate
685 p.mu.Unlock()
686
687 clk.advance(time.Minute)
688 a.sweepOnce()
689
690 got := make(chan error, 1)
691 go func() {
692 release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
693 if err == nil {
694 release()
695 }
696 got <- err
697 }()
698 time.Sleep(50 * time.Millisecond) // the request should now be parked on e.stopped
699 close(gate)
700 if err := <-got; err != nil {
701 t.Fatalf("request during stop: %v", err)
702 }
703 if _, _, starts := b.counts(); starts != 2 {
704 t.Fatalf("starts=%d, want 2 (sleep then rewake)", starts)
705 }
706}
707
708// A wake that exceeds the bound turns queued requests away with errWakeTimeout
709// (503 at the router) rather than piling connections up behind it.
710func TestActivatorWakeTimeout(t *testing.T) {
711 a, b, _, _ := testActivator(t, time.Minute)
712 old := wakeWaitBound
713 wakeWaitBound = 100 * time.Millisecond
714 t.Cleanup(func() { wakeWaitBound = old })
715
716 gate := make(chan struct{})
717 b.startGate = gate
718 t.Cleanup(func() { close(gate) }) // let the stuck wake finish at test end
719
720 _, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
721 if !errors.Is(err, errWakeTimeout) {
722 t.Fatalf("got %v, want errWakeTimeout", err)
723 }
724}
725
726// Lease contention surfaces through the router as 503 + Retry-After, and a
727// failed wake leaves no entry behind (the next request retries the claim).
728func TestActivatorLeaseContention503(t *testing.T) {
729 a, b, _, _ := testActivator(t, time.Minute)
730 b.acquireErr = &errLeaseHeld{holder: "old-incarnation", retryAfter: 7 * time.Second}
731
732 reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
733 if err != nil {
734 t.Fatalf("open registry: %v", err)
735 }
736 if err := reg.upsert(Route{Host: "held.example.com", Kind: "instance", Socket: "/tmp/held.sock", Hibernate: true}); err != nil {
737 t.Fatalf("upsert: %v", err)
738 }
739 rt, err := newRouter(reg, a)
740 if err != nil {
741 t.Fatalf("new router: %v", err)
742 }
743 front := httptest.NewServer(rt)
744 t.Cleanup(front.Close)
745
746 req, _ := http.NewRequest("GET", front.URL+"/", nil)
747 req.Host = "held.example.com"
748 resp, err := http.DefaultClient.Do(req)
749 if err != nil {
750 t.Fatalf("request: %v", err)
751 }
752 defer resp.Body.Close()
753 if resp.StatusCode != http.StatusServiceUnavailable {
754 t.Fatalf("status %d, want 503", resp.StatusCode)
755 }
756 if ra := resp.Header.Get("Retry-After"); ra != "8" { // 7s truncated + 1
757 t.Fatalf("Retry-After %q, want 8", ra)
758 }
759 if a.entryCount() != 0 {
760 t.Fatal("failed wake left an entry behind")
761 }
762}
763
764// Per-host gating (#550 Part 4 v2 opt-in): with the activator CONFIGURED, only
765// a hibernate-flagged instance goes through the wake path; an unflagged one is
766// proxied straight, exactly as if there were no activator at all — the
767// deploy-safety property that lets the bucket env ship fleet-wide before any
768// host is flagged.
769func TestRouterPerHostHibernateGating(t *testing.T) {
770 a, b, _, reg := testActivator(t, time.Minute)
771
772 // Two live backends playing the instances. The flagged host's listener
773 // stands in for the child a wake would have spawned (the fake backend's
774 // start() spawns nothing).
775 bind := func(name string) string {
776 sock := tmpSock(t)
777 ln, err := net.Listen("unix", sock)
778 if err != nil {
779 t.Fatalf("listen unix: %v", err)
780 }
781 srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
782 io.WriteString(w, name)
783 })}
784 go srv.Serve(ln)
785 t.Cleanup(func() { srv.Close() })
786 return sock
787 }
788 if err := reg.upsert(Route{Host: "hib.example.com", Kind: "instance", Socket: bind("hib"), Hibernate: true}); err != nil {
789 t.Fatal(err)
790 }
791 if err := reg.upsert(Route{Host: "plain.example.com", Kind: "instance", Socket: bind("plain")}); err != nil {
792 t.Fatal(err)
793 }
794
795 rt, err := newRouter(reg, a)
796 if err != nil {
797 t.Fatalf("new router: %v", err)
798 }
799 front := httptest.NewServer(rt)
800 t.Cleanup(front.Close)
801
802 // The unflagged host: proxied straight, the activator never touched.
803 if body, code := proxyGet(t, front.URL, "plain.example.com"); code != 200 || body != "plain" {
804 t.Fatalf("unflagged host: got %d %q, want 200 plain", code, body)
805 }
806 if acquires, _, starts := b.counts(); acquires != 0 || starts != 0 {
807 t.Fatalf("unflagged instance touched the activator (acquires=%d starts=%d)", acquires, starts)
808 }
809
810 // The flagged host: served through a full wake — lease, restore, spawn.
811 if body, code := proxyGet(t, front.URL, "hib.example.com"); code != 200 || body != "hib" {
812 t.Fatalf("flagged host: got %d %q, want 200 hib", code, body)
813 }
814 if acquires, restores, starts := b.counts(); acquires != 1 || restores != 1 || starts != 1 {
815 t.Fatalf("flagged host skipped the wake path: acquires=%d restores=%d starts=%d (want 1 each)", acquires, restores, starts)
816 }
817}
818
819// A reload that finds an awake host no longer hibernate-flagged retires its
820// child — stopped, final-checkpointed (replication closed), entry forgotten —
821// the router-process half of `lchat hibernate --off`. And once the flag is
822// off, a recorded next_due no longer wakes it: the due index only acts on
823// hibernating hosts, so a systemd-managed instance can never be spawned
824// against its own unit.
825func TestActivatorReloadRetiresUnflaggedHost(t *testing.T) {
826 a, b, clk, reg := testActivator(t, time.Hour)
827 route := testRoute("flip.example.com")
828 if err := reg.upsert(route); err != nil {
829 t.Fatal(err)
830 }
831 rt, err := newRouter(reg, a)
832 if err != nil {
833 t.Fatalf("new router: %v", err)
834 }
835
836 release, err := a.beginRequest(context.Background(), route)
837 if err != nil {
838 t.Fatalf("beginRequest: %v", err)
839 }
840 release()
841 // The retire's sleep re-records next_due AFTER the flag is cleared; an
842 // already-past value must stay inert under the flag filter.
843 b.setDue(clk.now().Add(-5 * time.Second).Unix())
844
845 if err := reg.setHibernate("flip.example.com", false); err != nil {
846 t.Fatal(err)
847 }
848 if err := rt.reload(); err != nil {
849 t.Fatalf("reload: %v", err)
850 }
851 waitFor(t, "retire", func() bool { return a.entryCount() == 0 })
852 if !b.lastProc().isStopped() {
853 t.Fatal("retired child was not stopped")
854 }
855 if !b.lastRep().isClosed() {
856 t.Fatal("retired child's replication was not closed (no final checkpoint/lease release)")
857 }
858
859 // The due sweeper must not resurrect a host that's back under systemd.
860 a.sweepOnce()
861 if _, _, starts := b.counts(); starts != 1 {
862 t.Fatalf("starts=%d after retire, want 1 (no due wake for a systemd-managed host)", starts)
863 }
864}
865
866// A retire that lands mid-wake can't stop the child yet — it finishes the job
867// once the wake resolves, so a flip-off during the small wake window still
868// releases cleanly.
869func TestActivatorRetireDuringWake(t *testing.T) {
870 a, b, clk, reg := testActivator(t, time.Hour)
871 route := testRoute("mid.example.com")
872 if err := reg.upsert(route); err != nil {
873 t.Fatal(err)
874 }
875 rt, err := newRouter(reg, a)
876 if err != nil {
877 t.Fatalf("new router: %v", err)
878 }
879
880 // Start a request-less (due-time) wake and hold it in the waking state.
881 b.startGate = make(chan struct{})
882 if err := reg.setNextDue("mid.example.com", clk.now().Add(-time.Second).Unix()); err != nil {
883 t.Fatal(err)
884 }
885 a.sweepOnce()
886 waitFor(t, "the wake to begin", func() bool { _, _, s := b.counts(); return s == 1 })
887
888 // Flip off mid-wake: the reload's retire must defer, not deadlock.
889 if err := reg.setHibernate("mid.example.com", false); err != nil {
890 t.Fatal(err)
891 }
892 if err := rt.reload(); err != nil {
893 t.Fatalf("reload: %v", err)
894 }
895 close(b.startGate)
896
897 waitFor(t, "post-wake retire", func() bool { return a.entryCount() == 0 })
898 if !b.lastProc().isStopped() {
899 t.Fatal("child woken mid-retire was not stopped")
900 }
901 if !b.lastRep().isClosed() {
902 t.Fatal("child woken mid-retire left replication open (lease never released)")
903 }
904}
905
906// With hibernation on, sibling services (kind "service") are untouched: the
907// router proxies them directly, no wake, no activity tracking.
908func TestActivatorServiceRoutesBypass(t *testing.T) {
909 sock := tmpSock(t)
910 ln, err := net.Listen("unix", sock)
911 if err != nil {
912 t.Fatalf("listen unix: %v", err)
913 }
914 backend := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
915 io.WriteString(w, "service ok")
916 })}
917 go backend.Serve(ln)
918 t.Cleanup(func() { backend.Close() })
919
920 a, b, _, _ := testActivator(t, time.Minute)
921 reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
922 if err != nil {
923 t.Fatalf("open registry: %v", err)
924 }
925 if err := reg.upsert(Route{Host: "svc.example.com", Kind: "service", Socket: sock}); err != nil {
926 t.Fatalf("upsert: %v", err)
927 }
928 rt, err := newRouter(reg, a)
929 if err != nil {
930 t.Fatalf("new router: %v", err)
931 }
932 front := httptest.NewServer(rt)
933 t.Cleanup(front.Close)
934
935 req, _ := http.NewRequest("GET", front.URL+"/", nil)
936 req.Host = "svc.example.com"
937 resp, err := http.DefaultClient.Do(req)
938 if err != nil {
939 t.Fatalf("request: %v", err)
940 }
941 body, _ := io.ReadAll(resp.Body)
942 resp.Body.Close()
943 if resp.StatusCode != 200 || string(body) != "service ok" {
944 t.Fatalf("got %d %q, want 200 service ok", resp.StatusCode, body)
945 }
946 if acquires, _, _ := b.counts(); acquires != 0 {
947 t.Fatal("a service route touched the activator")
948 }
949}
950
951// shutdown checkpoints and stops every awake child before the router exits.
952func TestActivatorShutdownStopsChildren(t *testing.T) {
953 a, b, _, _ := testActivator(t, time.Hour)
954 for _, h := range []string{"one.example.com", "two.example.com"} {
955 release, err := a.beginRequest(context.Background(), testRoute(h))
956 if err != nil {
957 t.Fatalf("beginRequest %s: %v", h, err)
958 }
959 release()
960 }
961
962 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
963 defer cancel()
964 a.shutdown(ctx)
965
966 if a.entryCount() != 0 {
967 t.Fatal("shutdown left entries behind")
968 }
969 b.mu.Lock()
970 defer b.mu.Unlock()
971 for i, p := range b.procs {
972 if !p.stopped {
973 t.Fatalf("proc %d not stopped on shutdown", i)
974 }
975 }
976 for i, r := range b.reps {
977 if !r.closed {
978 t.Fatalf("replication %d not closed on shutdown", i)
979 }
980 }
981}
982
983// --- due-time index --------------------------------------------------------------
984
985// entryAwake reports whether host has a live entry in the awake state — how the
986// due tests observe an async due-time wake resolving.
987func (a *activatorSvc) entryAwake(host string) bool {
988 a.mu.Lock()
989 defer a.mu.Unlock()
990 e := a.entries[host]
991 return e != nil && e.state == actAwake
992}
993
994// The full self-maintaining cycle: sleep records the instance's next due time
995// in the registry; the sweeper wakes the host (no request) when it arrives; the
996// following sleep re-records — and once nothing is scheduled, no more due wakes.
997func TestActivatorDueTimeWakeCycle(t *testing.T) {
998 a, b, clk, reg := testActivator(t, 10*time.Second)
999 route := testRoute("due.example.com")
1000 if err := reg.upsert(route); err != nil {
1001 t.Fatalf("upsert: %v", err)
1002 }
1003
1004 // Wake by request; the instance has a delivery scheduled 30s out.
1005 dueAt := clk.now().Add(30 * time.Second).Unix()
1006 b.setDue(dueAt)
1007 release, err := a.beginRequest(context.Background(), route)
1008 if err != nil {
1009 t.Fatalf("beginRequest: %v", err)
1010 }
1011 release()
1012
1013 // Idle out: the sleep probes the instance and records its due time.
1014 clk.advance(11 * time.Second)
1015 a.sweepOnce()
1016 waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 })
1017 if rt, err := reg.get(route.Host); err != nil || rt == nil || rt.NextDue != dueAt {
1018 t.Fatalf("registry next_due = %+v, %v; want %d", rt, err, dueAt)
1019 }
1020
1021 // Before the due time: sweeps leave it asleep.
1022 a.sweepOnce()
1023 if _, _, starts := b.counts(); starts != 1 {
1024 t.Fatalf("woke before its due time (starts=%d)", starts)
1025 }
1026
1027 // At the due time: the sweeper wakes it with no client request.
1028 clk.advance(20 * time.Second) // now = wake+31s ≥ due
1029 a.sweepOnce()
1030 waitFor(t, "due-time wake", func() bool { return a.entryAwake(route.Host) })
1031 if _, _, starts := b.counts(); starts != 2 {
1032 t.Fatalf("starts=%d after due wake, want 2", starts)
1033 }
1034
1035 // The woken instance stays up for the normal idle window, then sleeps and
1036 // re-records — nothing scheduled now, so the index clears and stays quiet.
1037 b.setDue(0)
1038 clk.advance(11 * time.Second)
1039 a.sweepOnce()
1040 waitFor(t, "re-hibernation", func() bool { return a.entryCount() == 0 })
1041 if rt, _ := reg.get(route.Host); rt.NextDue != 0 {
1042 t.Fatalf("next_due = %d after empty-schedule sleep, want 0", rt.NextDue)
1043 }
1044 clk.advance(time.Hour)
1045 a.sweepOnce()
1046 if _, _, starts := b.counts(); starts != 2 {
1047 t.Fatalf("starts=%d with nothing scheduled, want 2 (no phantom due wake)", starts)
1048 }
1049}
1050
1051// A due time already in the past at sleep time (a delivery that raced the stop)
1052// is recorded as-is: the sweeper wakes the host on its next tick — once — and
1053// there is no ping-pong, because a woken instance can't sleep again until a
1054// full idle window passes.
1055func TestActivatorDuePastWakesOnceNoPingPong(t *testing.T) {
1056 a, b, clk, reg := testActivator(t, 10*time.Second)
1057 route := testRoute("late.example.com")
1058 if err := reg.upsert(route); err != nil {
1059 t.Fatalf("upsert: %v", err)
1060 }
1061 b.setDue(clk.now().Add(-5 * time.Second).Unix()) // overdue before it even sleeps
1062
1063 release, err := a.beginRequest(context.Background(), route)
1064 if err != nil {
1065 t.Fatalf("beginRequest: %v", err)
1066 }
1067 release()
1068 clk.advance(11 * time.Second)
1069 a.sweepOnce()
1070 waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 })
1071
1072 // Next tick: woken for the overdue delivery.
1073 a.sweepOnce()
1074 waitFor(t, "overdue wake", func() bool { return a.entryAwake(route.Host) })
1075
1076 // No thrash: with the wake fresh (clock unmoved), repeated sweeps neither
1077 // re-sleep it nor double-wake it, even though next_due is still in the past.
1078 for i := 0; i < 5; i++ {
1079 a.sweepOnce()
1080 }
1081 if a.entryCount() != 1 || !a.entryAwake(route.Host) {
1082 t.Fatal("due-woken instance did not stay awake through its idle window")
1083 }
1084 if _, _, starts := b.counts(); starts != 2 {
1085 t.Fatalf("starts=%d, want 2 (request wake + one due wake)", starts)
1086 }
1087}
1088
1089// A failed next-due probe (dead socket, missing admin token, …) records 0 and
1090// the sleep proceeds — the instance degrades to "sleeps until a request",
1091// never a crash and never a stuck stop.
1092func TestActivatorDueProbeFailureRecordsZero(t *testing.T) {
1093 a, b, clk, reg := testActivator(t, 10*time.Second)
1094 route := testRoute("deaf.example.com")
1095 if err := reg.upsert(route); err != nil {
1096 t.Fatalf("upsert: %v", err)
1097 }
1098 b.dueErr = errors.New("dial unix: no such file")
1099
1100 release, err := a.beginRequest(context.Background(), route)
1101 if err != nil {
1102 t.Fatalf("beginRequest: %v", err)
1103 }
1104 release()
1105 clk.advance(11 * time.Second)
1106 a.sweepOnce()
1107 waitFor(t, "hibernation despite probe failure", func() bool { return a.entryCount() == 0 })
1108
1109 if rt, _ := reg.get(route.Host); rt.NextDue != 0 {
1110 t.Fatalf("next_due = %d after failed probe, want 0", rt.NextDue)
1111 }
1112 clk.advance(time.Hour)
1113 a.sweepOnce()
1114 if _, _, starts := b.counts(); starts != 1 {
1115 t.Fatalf("starts=%d, want 1 (a failed probe must not invent a wake)", starts)
1116 }
1117}
1118
1119// The index lives in the registry, so a due time recorded before a router
1120// restart still fires: a brand-new activator over the same registry — no
1121// in-memory state carried over — wakes the host when its time arrives.
1122func TestActivatorDueSurvivesRestart(t *testing.T) {
1123 a1, b1, clk, reg := testActivator(t, 10*time.Second)
1124 route := testRoute("reborn.example.com")
1125 if err := reg.upsert(route); err != nil {
1126 t.Fatalf("upsert: %v", err)
1127 }
1128 b1.setDue(clk.now().Add(30 * time.Second).Unix())
1129
1130 release, err := a1.beginRequest(context.Background(), route)
1131 if err != nil {
1132 t.Fatalf("beginRequest: %v", err)
1133 }
1134 release()
1135 clk.advance(11 * time.Second)
1136 a1.sweepOnce()
1137 waitFor(t, "hibernation", func() bool { return a1.entryCount() == 0 })
1138
1139 // "Restart": a fresh activator, same registry.
1140 b2 := &fakeBackend{}
1141 a2 := newActivator(hibernateConfig{Bucket: "test-bucket", Idle: 10 * time.Second}, b2, reg)
1142 a2.now = clk.now
1143
1144 clk.advance(25 * time.Second) // past the due time
1145 a2.sweepOnce()
1146 waitFor(t, "post-restart due wake", func() bool { return a2.entryAwake(route.Host) })
1147 if _, _, starts := b2.counts(); starts != 1 {
1148 t.Fatalf("starts=%d on the new activator, want 1", starts)
1149 }
1150}
1151
1152// --- bot sidecar co-spawn ----------------------------------------------------
1153
1154// indexOf: position of ev in the backend's order log (-1 = absent).
1155func indexOf(events []string, ev string) int {
1156 for i, e := range events {
1157 if e == ev {
1158 return i
1159 }
1160 }
1161 return -1
1162}
1163
1164// A host with a sidecar staged gets it co-spawned on wake, strictly AFTER the
1165// instance process is up — the sidecar dials the instance, so spawning it
1166// first would just burn its reconnect backoff. (Every other test in this file
1167// runs with sidecarStaged=false and asserts nothing sidecar-ish happens, which
1168// is the "host without a sidecar behaves exactly as today" half.)
1169func TestActivatorCoSpawnsSidecarAfterInstance(t *testing.T) {
1170 a, b, _, _ := testActivator(t, time.Minute)
1171 b.sidecarStaged = true
1172
1173 release, err := a.beginRequest(context.Background(), testRoute("bots.example.com"))
1174 if err != nil {
1175 t.Fatalf("beginRequest: %v", err)
1176 }
1177 release()
1178
1179 if got := b.sidecarStartCount(); got != 1 {
1180 t.Fatalf("sidecar starts = %d, want 1", got)
1181 }
1182 ev := b.events()
1183 if i, s := indexOf(ev, "start:instance"), indexOf(ev, "start:sidecar"); i == -1 || s == -1 || s < i {
1184 t.Fatalf("sidecar did not start strictly after the instance: %v", ev)
1185 }
1186}
1187
1188// A sidecar that fails to spawn must not fail the wake — members > bots: the
1189// space serves without its bots, and the sweeper keeps retrying with backoff
1190// until a spawn sticks.
1191func TestActivatorSidecarFailureDoesNotFailWake(t *testing.T) {
1192 a, b, clk, _ := testActivator(t, time.Hour)
1193 b.sidecarStaged = true
1194 b.setSidecarErr(errors.New("exec: live symlink gone"))
1195 route := testRoute("botless.example.com")
1196
1197 release, err := a.beginRequest(context.Background(), route)
1198 if err != nil {
1199 t.Fatalf("beginRequest with a failing sidecar: %v", err)
1200 }
1201 release()
1202 if !a.entryAwake(route.Host) {
1203 t.Fatal("instance not awake after a sidecar-only failure")
1204 }
1205 if got := b.sidecarStartCount(); got != 1 {
1206 t.Fatalf("sidecar attempts = %d, want 1 (the wake's)", got)
1207 }
1208
1209 // The sweeper retries: attempt 2 immediately (first retry), then gated by
1210 // the doubling backoff; once the fault clears, a retry lands the sidecar.
1211 a.sweepOnce()
1212 if got := b.sidecarStartCount(); got != 2 {
1213 t.Fatalf("sidecar attempts after first sweep = %d, want 2", got)
1214 }
1215 a.sweepOnce() // still inside the 2s gate — no attempt
1216 if got := b.sidecarStartCount(); got != 2 {
1217 t.Fatalf("backoff gate ignored: attempts = %d, want 2", got)
1218 }
1219 b.setSidecarErr(nil)
1220 clk.advance(3 * time.Second)
1221 a.sweepOnce()
1222 if got := b.sidecarStartCount(); got != 3 {
1223 t.Fatalf("sidecar attempts after fault cleared = %d, want 3", got)
1224 }
1225 if b.lastSidecar().isStopped() || b.lastSidecar().exited() {
1226 t.Fatal("recovered sidecar isn't running")
1227 }
1228}
1229
1230// Co-stop ordering at sleep: the sidecar is stopped FIRST — before the
1231// next-due probe (so bots can't shift the schedule being recorded) and before
1232// the instance's own stop (so the sidecar never outlives its instance).
1233func TestActivatorCoStopOrdering(t *testing.T) {
1234 a, b, clk, _ := testActivator(t, 10*time.Second)
1235 b.sidecarStaged = true
1236
1237 release, err := a.beginRequest(context.Background(), testRoute("bots.example.com"))
1238 if err != nil {
1239 t.Fatalf("beginRequest: %v", err)
1240 }
1241 release()
1242
1243 clk.advance(11 * time.Second)
1244 a.sweepOnce()
1245 waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 })
1246
1247 if !b.lastSidecar().isStopped() {
1248 t.Fatal("sidecar not stopped at sleep")
1249 }
1250 ev := b.events()
1251 sc, due, inst := indexOf(ev, "stop:sidecar"), indexOf(ev, "nextDue"), indexOf(ev, "stop:instance")
1252 if sc == -1 || due == -1 || inst == -1 || !(sc < due && due < inst) {
1253 t.Fatalf("co-stop order wrong: %v (want stop:sidecar < nextDue < stop:instance)", ev)
1254 }
1255}
1256
1257// Orphan prevention: whatever takes the instance down — here a crashed child,
1258// but a lost lease and a retire ride the identical stop path — takes the
1259// sidecar with it. An orphaned sidecar would reconnect-loop against the router
1260// forever (503s at best; a wake source if the marker rule ever broke).
1261func TestActivatorInstanceDeathStopsSidecar(t *testing.T) {
1262 a, b, _, _ := testActivator(t, time.Hour)
1263 b.sidecarStaged = true
1264
1265 release, err := a.beginRequest(context.Background(), testRoute("bots.example.com"))
1266 if err != nil {
1267 t.Fatalf("beginRequest: %v", err)
1268 }
1269 release()
1270
1271 p := b.lastProc()
1272 p.mu.Lock()
1273 p.dead = true
1274 p.mu.Unlock()
1275 a.sweepOnce()
1276 waitFor(t, "stand-down", func() bool { return a.entryCount() == 0 })
1277 if !b.lastSidecar().isStopped() {
1278 t.Fatal("instance died but its sidecar was left running (orphan)")
1279 }
1280}
1281
1282// Sidecar death while the instance stays awake: restarted with doubling,
1283// capped backoff — Restart=always semantics, sweep-driven — and a run that
1284// held for sidecarHealthyRun earns a fresh backoff.
1285func TestActivatorSidecarRestartBackoff(t *testing.T) {
1286 a, b, clk, _ := testActivator(t, time.Hour)
1287 b.sidecarStaged = true
1288
1289 release, err := a.beginRequest(context.Background(), testRoute("bots.example.com"))
1290 if err != nil {
1291 t.Fatalf("beginRequest: %v", err)
1292 }
1293 release()
1294 kill := func() {
1295 sc := b.lastSidecar()
1296 sc.mu.Lock()
1297 sc.dead = true
1298 sc.mu.Unlock()
1299 }
1300
1301 // T0: first crash — restarted on the next sweep (attempt 2), gate 2s.
1302 kill()
1303 a.sweepOnce()
1304 if got := b.sidecarStartCount(); got != 2 {
1305 t.Fatalf("first restart: attempts = %d, want 2", got)
1306 }
1307 // Crash-loop: still inside the 2s gate — no restart.
1308 kill()
1309 a.sweepOnce()
1310 if got := b.sidecarStartCount(); got != 2 {
1311 t.Fatalf("gate ignored: attempts = %d, want 2", got)
1312 }
1313 // T3: past the 2s gate — attempt 3, gate doubles to 4s.
1314 clk.advance(3 * time.Second)
1315 a.sweepOnce()
1316 if got := b.sidecarStartCount(); got != 3 {
1317 t.Fatalf("second restart: attempts = %d, want 3", got)
1318 }
1319 // T6: 3s later is inside the 4s gate — the doubling is real.
1320 kill()
1321 clk.advance(3 * time.Second)
1322 a.sweepOnce()
1323 if got := b.sidecarStartCount(); got != 3 {
1324 t.Fatalf("doubled gate ignored: attempts = %d, want 3", got)
1325 }
1326 // T8: past it — attempt 4 (gate now 8s).
1327 clk.advance(2 * time.Second)
1328 a.sweepOnce()
1329 if got := b.sidecarStartCount(); got != 4 {
1330 t.Fatalf("third restart: attempts = %d, want 4", got)
1331 }
1332 // A healthy run (≥ sidecarHealthyRun) resets the ladder: the next crash
1333 // restarts immediately and its gate is back to the 2s base…
1334 clk.advance(2 * time.Minute)
1335 kill()
1336 a.sweepOnce()
1337 if got := b.sidecarStartCount(); got != 5 {
1338 t.Fatalf("post-healthy restart: attempts = %d, want 5", got)
1339 }
1340 // …which a 3s advance clears (16s — the unreset ladder — would not).
1341 kill()
1342 clk.advance(3 * time.Second)
1343 a.sweepOnce()
1344 if got := b.sidecarStartCount(); got != 6 {
1345 t.Fatalf("backoff not reset after a healthy run: attempts = %d, want 6", got)
1346 }
1347}
1348
1349// The activity question (#550 Part 4 v2): the co-spawned sidecar dials its
1350// instance's public URL through this very router (the box pins its hosted
1351// names to loopback), so without the marker its keepalives would be member
1352// activity and the pair could never sleep. Full front-door check: marked
1353// requests are served while awake — with the marker stripped before the
1354// backend — but stamp nothing (the host hibernates on schedule beneath them),
1355// and once asleep they get 503 and wake nothing. An outsider spoofing the
1356// marker gets exactly that same treatment: it can't wake the host and can't
1357// suppress anyone else's stamps (stamps are per-request), so the marker is
1358// not a DoS lever in either direction.
1359func TestActivatorSidecarRequestsInvisibleToActivity(t *testing.T) {
1360 a, b, clk, reg := testActivator(t, 10*time.Second)
1361
1362 // A live backend listener stands in for the child a wake would have
1363 // spawned (the fake backend's start() spawns nothing), recording whether
1364 // the sidecar marker ever leaks through the proxy.
1365 sock := tmpSock(t)
1366 ln, err := net.Listen("unix", sock)
1367 if err != nil {
1368 t.Fatalf("listen unix: %v", err)
1369 }
1370 var leaked atomic.Bool
1371 srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1372 if r.Header.Get(bot.SidecarHeader) != "" {
1373 leaked.Store(true)
1374 }
1375 io.WriteString(w, "ok")
1376 })}
1377 go srv.Serve(ln)
1378 t.Cleanup(func() { srv.Close() })
1379
1380 const host = "bots.example.com"
1381 if err := reg.upsert(Route{Host: host, Kind: "instance", Socket: sock, Hibernate: true}); err != nil {
1382 t.Fatal(err)
1383 }
1384 rt, err := newRouter(reg, a)
1385 if err != nil {
1386 t.Fatalf("new router: %v", err)
1387 }
1388 front := httptest.NewServer(rt)
1389 t.Cleanup(front.Close)
1390
1391 markedGet := func() int {
1392 req, _ := http.NewRequest("GET", front.URL+"/", nil)
1393 req.Host = host
1394 req.Header.Set(bot.SidecarHeader, "1")
1395 resp, err := http.DefaultClient.Do(req)
1396 if err != nil {
1397 t.Fatalf("marked request: %v", err)
1398 }
1399 defer resp.Body.Close()
1400 io.Copy(io.Discard, resp.Body)
1401 return resp.StatusCode
1402 }
1403
1404 // A member request wakes the host.
1405 if body, code := proxyGet(t, front.URL, host); code != 200 || body != "ok" {
1406 t.Fatalf("member wake: got %d %q", code, body)
1407 }
1408 // Marked requests are served while awake…
1409 if code := markedGet(); code != 200 {
1410 t.Fatalf("marked request while awake: %d, want 200", code)
1411 }
1412 // …but keep stamping nothing: sidecar traffic riding through the whole
1413 // idle window must not delay the sleep.
1414 clk.advance(6 * time.Second)
1415 if code := markedGet(); code != 200 {
1416 t.Fatalf("marked request mid-window: %d, want 200", code)
1417 }
1418 clk.advance(5 * time.Second) // 11s since the member request
1419 a.sweepOnce()
1420 waitFor(t, "hibernation beneath sidecar traffic", func() bool { return a.entryCount() == 0 })
1421
1422 // Asleep: marked requests are refused and wake NOTHING — this is also the
1423 // outsider-spoof case, and it's harmless.
1424 if code := markedGet(); code != 503 {
1425 t.Fatalf("marked request while asleep: %d, want 503", code)
1426 }
1427 if a.entryCount() != 0 {
1428 t.Fatal("a marked request created an entry (a wake) for a sleeping host")
1429 }
1430 if _, _, starts := b.counts(); starts != 1 {
1431 t.Fatalf("starts = %d after marked requests, want 1 (no sidecar-triggered wake)", starts)
1432 }
1433 // An unmarked request still wakes it, and the marker never reached the
1434 // backend on any request.
1435 if _, code := proxyGet(t, front.URL, host); code != 200 {
1436 t.Fatalf("member re-wake: %d, want 200", code)
1437 }
1438 if _, _, starts := b.counts(); starts != 2 {
1439 t.Fatalf("starts = %d after member re-wake, want 2", starts)
1440 }
1441 if leaked.Load() {
1442 t.Fatal("the sidecar marker leaked through the proxy to the backend")
1443 }
1444}
1445
1446func TestHibernateConfigFromEnv(t *testing.T) {
1447 t.Setenv("LCHAT_HIBERNATE_BUCKET", "")
1448 if cfg, err := hibernateConfigFromEnv(); err != nil || cfg != nil {
1449 t.Fatalf("unset bucket: got %+v, %v; want nil, nil (feature off)", cfg, err)
1450 }
1451
1452 t.Setenv("LCHAT_HIBERNATE_BUCKET", "b")
1453 t.Setenv("LCHAT_HIBERNATE_PREFIX", "/instances/")
1454 t.Setenv("LCHAT_HIBERNATE_IDLE", "90s")
1455 cfg, err := hibernateConfigFromEnv()
1456 if err != nil {
1457 t.Fatalf("config: %v", err)
1458 }
1459 if cfg.Bucket != "b" || cfg.Prefix != "instances" || cfg.Idle != 90*time.Second {
1460 t.Fatalf("got %+v", cfg)
1461 }
1462 if got := cfg.hibernatePrefix("x.example.com"); got != "instances/x.example.com" {
1463 t.Fatalf("prefix: %q", got)
1464 }
1465
1466 t.Setenv("LCHAT_HIBERNATE_IDLE", "")
1467 cfg, err = hibernateConfigFromEnv()
1468 if err != nil || cfg.Idle != 15*time.Minute {
1469 t.Fatalf("default idle: %+v, %v", cfg, err)
1470 }
1471
1472 t.Setenv("LCHAT_HIBERNATE_IDLE", "banana")
1473 if _, err := hibernateConfigFromEnv(); err == nil {
1474 t.Fatal("bad idle duration should error, not default silently")
1475 }
1476}
1477
1478// --- ensureLiveLink (the controld-mediated fix for mesh-replicated routes) ---
1479
1480// ensureLiveLinkTestEnv redirects optDir/selfPath into temp dirs, same seam
1481// controld_test.go's controlTestEnv uses, so ensureSelfStaged/pointLive run
1482// for real against throwaway paths.
1483func ensureLiveLinkTestEnv(t *testing.T) {
1484 t.Helper()
1485 oldOpt, oldSelf := optDir, selfPath
1486 t.Cleanup(func() { optDir, selfPath = oldOpt, oldSelf })
1487 optDir = t.TempDir()
1488 stub := filepath.Join(t.TempDir(), "stub")
1489 if err := os.WriteFile(stub, []byte("#!/bin/sh\n"), 0o755); err != nil {
1490 t.Fatal(err)
1491 }
1492 selfPath = func() (string, error) { return stub, nil }
1493}
1494
1495// Already-materialized: the common case for every existing box A instance —
1496// neither the direct path nor materialize is ever invoked.
1497func TestEnsureLiveLinkAlreadyMaterialized(t *testing.T) {
1498 ensureLiveLinkTestEnv(t)
1499 ver, err := ensureSelfStaged()
1500 if err != nil {
1501 t.Fatal(err)
1502 }
1503 if err := pointLive("existing.example.com", ver); err != nil {
1504 t.Fatal(err)
1505 }
1506 called := false
1507 materialize := func(context.Context, string) error { called = true; return nil }
1508 if err := ensureLiveLink(context.Background(), "existing.example.com", materialize); err != nil {
1509 t.Fatalf("ensureLiveLink: %v", err)
1510 }
1511 if called {
1512 t.Error("materialize was called for an already-materialized host")
1513 }
1514}
1515
1516// No materialize hook configured (no control token — the common case for any
1517// box that hasn't opted in): falls back to the direct calls, today's
1518// long-standing behavior.
1519func TestEnsureLiveLinkFallsBackWithoutMaterialize(t *testing.T) {
1520 ensureLiveLinkTestEnv(t)
1521 if err := ensureLiveLink(context.Background(), "fresh.example.com", nil); err != nil {
1522 t.Fatalf("ensureLiveLink: %v", err)
1523 }
1524 if _, err := os.Stat(liveLink("fresh.example.com")); err != nil {
1525 t.Fatalf("live symlink was not created: %v", err)
1526 }
1527}
1528
1529// Missing symlink + materialize configured: routed through materialize, not
1530// the direct (sandboxed-and-failing, in production) calls.
1531func TestEnsureLiveLinkUsesMaterializeWhenConfigured(t *testing.T) {
1532 ensureLiveLinkTestEnv(t)
1533 var gotHost string
1534 materialize := func(_ context.Context, host string) error {
1535 gotHost = host
1536 // The real controld handler stages + points live; a fake only needs
1537 // to prove IT was the one asked, and that its result is honored.
1538 ver, err := ensureSelfStaged()
1539 if err != nil {
1540 return err
1541 }
1542 return pointLive(host, ver)
1543 }
1544 if err := ensureLiveLink(context.Background(), "seeded.11ch.at", materialize); err != nil {
1545 t.Fatalf("ensureLiveLink: %v", err)
1546 }
1547 if gotHost != "seeded.11ch.at" {
1548 t.Fatalf("materialize called with %q, want seeded.11ch.at", gotHost)
1549 }
1550 if _, err := os.Stat(liveLink("seeded.11ch.at")); err != nil {
1551 t.Fatalf("live symlink was not created via materialize: %v", err)
1552 }
1553}
1554
1555// A materialize failure (e.g. controld unreachable) must propagate — the
1556// exact live symptom found 2026-07-13 (a 503 "instance failed to wake"),
1557// never silently swallowed into "looks fine, wakes anyway."
1558func TestEnsureLiveLinkPropagatesMaterializeError(t *testing.T) {
1559 ensureLiveLinkTestEnv(t)
1560 wantErr := fmt.Errorf("controld materialize seeded.11ch.at: HTTP 502")
1561 materialize := func(context.Context, string) error { return wantErr }
1562 err := ensureLiveLink(context.Background(), "seeded.11ch.at", materialize)
1563 if err == nil {
1564 t.Fatal("expected an error, got nil")
1565 }
1566 if _, statErr := os.Stat(liveLink("seeded.11ch.at")); statErr == nil {
1567 t.Fatal("live symlink should not exist after a failed materialize")
1568 }
1569}
1570