Merge remote-tracking branch 'origin/main' into zdd-gen-socket-names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+1794
−0
internal/host/startup_largebox_linux_test.go+1794 −0
diff --git a/internal/host/startup_largebox_linux_test.go b/internal/host/startup_largebox_linux_test.go| new file mode 100644 |
| index 000000000..58410ee4a |
| --- /dev/null |
| +++ b/internal/host/startup_largebox_linux_test.go |
| @@ -0,0 +1,1794 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +// The large-box startup, end to end (amadan #281, ask 2). On 2026-10-13 a |
| +// box with 142 instances failed the 20 s update gate twice: the updater |
| +// restarts carlos-instance-control just before the new edge, and the |
| +// agent's startup sweep waited on that cold control service before the |
| +// edge listened. The hotfix (6dfbff1) moved the sweep, layout recovery, |
| +// hold restoration, Start and the key pass off the listen path, behind a |
| +// startup latch that instance admission waits on. |
| +// |
| +// TestStartupServesWhileSweepAndRecoveryWait proves the latch with the |
| +// steps stubbed. This test runs them for real on a box larger than the |
| +// one that failed: |
| +// |
| +// - the agent is a child process (this test binary, re-executed), so |
| +// what is timed is a process start up to the update gate's own probe; |
| +// - instance control is the real instancectl.Server over a unix socket, |
| +// made cold by its systemctl seam: every call waits until the test |
| +// warms it, then each of the box's 300 leftover units takes a moment |
| +// to stop, as a restarted control service does; |
| +// - the sweep is the real client call, layout recovery and hold |
| +// restoration are the real pass over real journals, hold markers and |
| +// files in a temp data dir, Start is the real activator's, and the |
| +// edge is edge.RunWithActivatorReady in its production (TLS) mode; |
| +// - every kind of admission the latch gates arrives during startup: an |
| +// instance request, a sidecar-marked request, a worker wake (from |
| +// runAgent's boot pass and from a direct call), and a scheduled |
| +// delivery through the real schedule runner and deliverer; |
| +// - the activator's backend is a fake that serves an app on the row's |
| +// socket and starts workers that report ready. It and the key pass's |
| +// writer are the only fakes in the agent, and neither is a startup |
| +// step. |
| +// |
| +// The child's startup is runAgent's legacy-box sequence: the same |
| +// activatorStart, with runAgent's Sweep and Recover closures. runAgent |
| +// itself cannot run in a test (its control socket path is a constant, |
| +// it builds AWS clients, and it log.Fatals), so the child assembles the |
| +// same pieces in the same order, and TestRunAgentStartupWiring reads |
| +// runAgent's source to fail if the two drift apart. Sharing one |
| +// orchestration function between them is a follow-up (#281). Two box |
| +// facts differ from production and are set on the pass: its socket |
| +// directory is the fixture's, not /run/carlos, and it has no |
| +// carlos-sockets group. |
| +// |
| +// The child reports what it does as timestamped lines in an events file; |
| +// the test reads them to order "a request reached the activator", "the |
| +// latch opened" and "the backend acquired a host" across the two |
| +// processes. |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/tls" |
| + "crypto/x509" |
| + "crypto/x509/pkix" |
| + "encoding/json" |
| + "encoding/pem" |
| + "errors" |
| + "fmt" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "go/types" |
| + "io" |
| + "log" |
| + "math/big" |
| + "net" |
| + "net/http" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "runtime" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "syscall" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/instancefs" |
| + "github.com/carlosframework/platform/internal/layout" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/schedule" |
| + "github.com/carlosframework/platform/internal/version" |
| +) |
| + |
| +const ( |
| + // largeBoxAgentEnv names the child's config file. Set, the test binary |
| + // is the agent and nothing else. |
| + largeBoxAgentEnv = "CARLOS_TEST_LARGEBOX_AGENT" |
| + |
| + largeBoxRoutes = 300 // instance routes; the box that failed had 142 |
| + largeBoxJournals = 8 // hosts 0..7: a layout move was in flight at the restart |
| + largeBoxHolds = 4 // hosts 8..11: held down after a refused move |
| + largeBoxWakeHost = 12 // an ordinary sleeping instance, woken by a request |
| + largeBoxDueHost = 13 // a sleeping instance with a scheduled delivery due |
| + largeBoxSidecarHost = 14 // a sleeping instance its sidecar calls |
| + largeBoxWorker = "worker-000.example.com" |
| + largeBoxDuePath = "/jobs/tick" |
| + |
| + // largeBoxListenBound is how soon the gate's probe must be answered |
| + // after the agent process starts. The gate allows 20 s; the edge's own |
| + // startup on this box takes a fraction of a second. |
| + largeBoxListenBound = 3 * time.Second |
| + // largeBoxCold is how long instance control stays cold in the scenario |
| + // where it warms: longer than the listen bound, so the gate is answered |
| + // while the sweep is provably still waiting. |
| + largeBoxCold = 4 * time.Second |
| + // largeBoxUnitStop is one leftover unit's stop once control is warm. |
| + largeBoxUnitStop = 5 * time.Millisecond |
| + // largeBoxLatchBound is the activator's wakeWaitBound, which the |
| + // startup latch shares: a wake held longer is answered 503. |
| + largeBoxLatchBound = 30 * time.Second |
| + // largeBoxJoinBound is runAgent's join on the startup work after the |
| + // edge returns, and largeBoxDrain the edge's drain of in-flight |
| + // requests before it returns. |
| + largeBoxJoinBound = 5 * time.Second |
| + largeBoxDrain = 10 * time.Second |
| + // largeBoxBindAttempts is how often the agent is started on fresh ports |
| + // when one it was handed is taken before it binds (see startAgent). |
| + largeBoxBindAttempts = 3 |
| +) |
| + |
| +func largeBoxHost(i int) string { return fmt.Sprintf("app-%03d.example.com", i) } |
| + |
| +// largeBoxConfig is what the child agent is told. |
| +type largeBoxConfig struct { |
| + DataDir, SocketDir, StoreDir, AcmeDir string |
| + Control, Events, HTTP, HTTPS string |
| +} |
| + |
| +func TestStartupLargeBoxEndToEnd(t *testing.T) { |
| + if path := os.Getenv(largeBoxAgentEnv); path != "" { |
| + os.Exit(runLargeBoxAgent(path)) |
| + } |
| + t.Run("cold instance control", func(t *testing.T) { |
| + t.Parallel() |
| + testLargeBoxColdControl(t) |
| + }) |
| + t.Run("startup overruns the latch", func(t *testing.T) { |
| + t.Parallel() |
| + testLargeBoxOverrun(t) |
| + }) |
| + t.Run("shutdown mid-startup", func(t *testing.T) { |
| + t.Parallel() |
| + testLargeBoxShutdownMidStartup(t) |
| + }) |
| +} |
| + |
| +// testLargeBoxColdControl is the 2026-10-13 shape: control restarted just |
| +// before the agent, and slow to answer. The edge must answer the gate's |
| +// probe while the sweep waits, hold every admission at the latch until |
| +// sweep, recovery and hold restoration are done, and then serve each host |
| +// only where it is safe to. |
| +func testLargeBoxColdControl(t *testing.T) { |
| + b := newLargeBox(t) |
| + a := b.startAgent(t) |
| + t.Logf("gate probe answered %s after the agent started, %d routes, instance control cold", a.listen, largeBoxRoutes) |
| + if _, ok := a.event("ready"); ok { |
| + t.Fatal("the startup latch opened while instance control was still cold") |
| + } |
| + |
| + wake, held, moved, due, sidecar := largeBoxHost(largeBoxWakeHost), largeBoxHost(largeBoxJournals), largeBoxHost(0), largeBoxHost(largeBoxDueHost), largeBoxHost(largeBoxSidecarHost) |
| + flatMoved := filepath.Join(b.socketDir, moved+".sock") |
| + wakeAns, heldAns, movedAns := b.fire(wake, false), b.fire(held, false), b.fire(moved, false) |
| + sidecarAns := b.fire(sidecar, true) |
| + enterWake := a.waitEvent(t, 10*time.Second, "enter", wake) |
| + a.waitEvent(t, 10*time.Second, "enter", held) |
| + enterSidecar := a.waitEvent(t, 10*time.Second, "enter", sidecar) |
| + enterMoved := a.waitEvent(t, 10*time.Second, "enter", moved) |
| + if enterMoved.arg(1) != flatMoved { |
| + t.Fatalf("the edge routed %s to %q before recovery, want its pre-move socket %q", moved, enterMoved.arg(1), flatMoved) |
| + } |
| + // The agent's own admissions: a direct worker wake and the scheduled |
| + // tick, both started before the edge listened. |
| + workerAsked := a.waitEvent(t, 10*time.Second, "worker-direct-begin") |
| + ticked := a.waitEvent(t, 10*time.Second, "tick-done") |
| + // Every admission is now waiting, and startup is not done. |
| + time.Sleep(200 * time.Millisecond) |
| + for host, ch := range map[string]chan largeBoxAnswer{wake: wakeAns, held: heldAns, moved: movedAns, sidecar: sidecarAns} { |
| + select { |
| + case got := <-ch: |
| + t.Fatalf("%s answered before startup finished: %d %q %v", host, got.status, got.body, got.err) |
| + default: |
| + } |
| + } |
| + for _, kind := range []string{"acquire", "start", "worker-start", "served", "worker-direct", "worker-boot", "activator-start"} { |
| + if evs := a.events(kind); len(evs) != 0 { |
| + t.Fatalf("%s while instance control was cold: %v", kind, evs) |
| + } |
| + } |
| + |
| + // Instance control warms: the sweep stops the 300 leftovers, then |
| + // recovery, holds, Start, and the latch. |
| + if d := time.Until(a.began.Add(largeBoxCold)); d > 0 { |
| + time.Sleep(d) |
| + } |
| + close(b.control.warm) |
| + ready := a.waitEvent(t, 30*time.Second, "ready") |
| + t.Logf("startup latch opened %s after the agent started (sweep stopped %d units)", ready.at.Sub(a.began), b.control.stops.Load()) |
| + if got := b.control.stops.Load(); got != largeBoxRoutes { |
| + t.Errorf("the sweep stopped %d units, want %d", got, largeBoxRoutes) |
| + } |
| + for what, at := range map[string]time.Time{"wake": enterWake.at, "sidecar request": enterSidecar.at, "worker wake": workerAsked.at, "scheduled tick": ticked.at} { |
| + if !at.Before(ready.at) { |
| + t.Fatalf("the %s did not arrive before the latch opened; the test proves nothing about its latch", what) |
| + } |
| + } |
| + |
| + // Start ran only once the held hosts were fenced again and every |
| + // journal was complete. |
| + st := a.waitEvent(t, time.Second, "activator-start") |
| + if st.arg(0) != "held="+strconv.Itoa(largeBoxHolds) || st.arg(1) != "fenced="+strconv.Itoa(largeBoxHolds) || st.arg(2) != "journals=0" || st.at.After(ready.at) { |
| + t.Fatalf("at Start: %v", st) |
| + } |
| + |
| + // The wake was held, then served. |
| + got := b.answer(t, wakeAns) |
| + if got.status != http.StatusOK || got.body != "host="+wake+" socket="+filepath.Join(b.socketDir, wake+".sock") { |
| + t.Fatalf("held wake after startup: %d %q %v", got.status, got.body, got.err) |
| + } |
| + a.assertAfter(t, ready, "acquire", wake) |
| + |
| + // The sidecar's call was held too, then answered as the activator |
| + // answers a sidecar whose instance sleeps: it does not wake it. |
| + got = b.answer(t, sidecarAns) |
| + if got.status != http.StatusServiceUnavailable || got.body != "instance is asleep; sidecar requests do not wake it\n" || got.at.Before(ready.at) { |
| + t.Fatalf("sidecar request held through startup: %d %q %v at %s, latch at %s", got.status, got.body, got.err, got.at, ready.at) |
| + } |
| + |
| + // The worker: both wakes waited, then coalesced on one child. |
| + a.assertAfter(t, ready, "worker-direct", largeBoxWorker, "true") |
| + a.assertAfter(t, ready, "worker-boot", largeBoxWorker, "true") |
| + a.assertAfter(t, ready, "worker-start", largeBoxWorker) |
| + if n := len(a.events("worker-start")); n != 1 { |
| + t.Errorf("worker started %d times, want once", n) |
| + } |
| + |
| + // The scheduled delivery waited, then woke its instance and ran. |
| + a.assertAfter(t, ready, "served", due, largeBoxDuePath) |
| + a.assertAfter(t, ready, "acquire", due) |
| + |
| + // The held host was not started: its restored hold answers maintenance. |
| + got = b.answer(t, heldAns) |
| + if got.status != http.StatusServiceUnavailable || got.body != "instance failed to wake\n" || got.header.Get("Cache-Control") != "no-store" { |
| + t.Fatalf("held host after startup: %d %q %v %v", got.status, got.body, got.header, got.err) |
| + } |
| + |
| + // The moved host was not served at the socket recovery moved it from: |
| + // the request answers as a wake in progress, and its retry is routed |
| + // afresh to where recovery put it. |
| + got = b.answer(t, movedAns) |
| + if got.status != http.StatusServiceUnavailable || got.body != "instance is waking; retry shortly\n" || got.header.Get("Retry-After") != "5" { |
| + t.Fatalf("host whose recovery moved it, held through startup: %d %q %v", got.status, got.body, got.err) |
| + } |
| + nested, err := layout.PlanFor(b.dataDir, b.socketDir, moved, layout.ModeNested) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got = b.get(moved, false) |
| + if got.status != http.StatusOK || got.body != "host="+moved+" socket="+nested.To.Socket { |
| + t.Fatalf("retry of the moved host: %d %q %v", got.status, got.body, got.err) |
| + } |
| + got = b.get(held, false) |
| + if got.status != http.StatusServiceUnavailable || got.body != "instance failed to wake\n" { |
| + t.Fatalf("retry of the held host: %d %q %v", got.status, got.body, got.err) |
| + } |
| + |
| + // The background key pass ran after Start, and its slot refused the |
| + // held hosts. |
| + a.waitEvent(t, 30*time.Second, "startup-done") |
| + keys := a.events("key") |
| + if len(keys) != largeBoxRoutes-largeBoxHolds { |
| + t.Errorf("key pass wrote %d rows, want %d", len(keys), largeBoxRoutes-largeBoxHolds) |
| + } |
| + for _, e := range keys { |
| + if e.at.Before(ready.at) { |
| + t.Errorf("key pass reached %s before Start", e.arg(0)) |
| + } |
| + } |
| + |
| + b.assertNoWrongService(t, a) |
| + b.assertRecovered(t) |
| + |
| + a.signal(t, syscall.SIGTERM) |
| + a.waitExit(t, largeBoxJoinBound+3*time.Second) |
| + a.assertCleanShutdown(t) |
| +} |
| + |
| +// testLargeBoxOverrun: instance control never warms, so startup never |
| +// finishes. A wake must be answered with the wake-timeout 503 at the |
| +// latch's bound: not an error, not a hang, and not a wake. |
| +func testLargeBoxOverrun(t *testing.T) { |
| + b := newLargeBox(t) |
| + a := b.startAgent(t) |
| + t.Logf("gate probe answered %s after the agent started, instance control never warms", a.listen) |
| + |
| + wake := largeBoxHost(largeBoxWakeHost) |
| + began := time.Now() |
| + got := b.get(wake, false) |
| + took := time.Since(began) |
| + t.Logf("wake held %s by a startup that does not finish", took) |
| + if got.err != nil { |
| + t.Fatalf("wake during an overrunning startup: %v", got.err) |
| + } |
| + if got.status != http.StatusServiceUnavailable || got.body != "instance is waking; retry shortly\n" || got.header.Get("Retry-After") != "5" { |
| + t.Fatalf("wake during an overrunning startup: %d %q %v", got.status, got.body, got.header) |
| + } |
| + if took < largeBoxLatchBound-time.Second || took > largeBoxLatchBound+5*time.Second { |
| + t.Fatalf("wake answered after %s, want the latch bound %s", took, largeBoxLatchBound) |
| + } |
| + a.assertNeverStarted(t) |
| + b.assertNoWrongService(t, a) |
| + |
| + // Shutting down with the sweep still waiting: nothing in flight, so the |
| + // edge returns at once and runAgent's join bounds the rest. |
| + at := time.Now() |
| + a.signal(t, syscall.SIGTERM) |
| + a.waitExit(t, largeBoxJoinBound+3*time.Second) |
| + t.Logf("agent exited %s after SIGTERM with the sweep still waiting", time.Since(at)) |
| + a.assertNeverStarted(t) |
| + a.assertCleanShutdown(t) |
| +} |
| + |
| +// testLargeBoxShutdownMidStartup: SIGTERM with startup still waiting on |
| +// the sweep and a wake held at the latch. The held request is answered |
| +// 503 (never proxied), the background work is joined within runAgent's |
| +// bound, and nothing of startup outlives it. |
| +func testLargeBoxShutdownMidStartup(t *testing.T) { |
| + b := newLargeBox(t) |
| + a := b.startAgent(t) |
| + |
| + wake := largeBoxHost(largeBoxWakeHost) |
| + ans := b.fire(wake, false) |
| + a.waitEvent(t, 10*time.Second, "enter", wake) |
| + |
| + at := time.Now() |
| + a.signal(t, syscall.SIGTERM) |
| + // The edge drains in-flight requests for up to largeBoxDrain before it |
| + // returns, and the held wake is in flight until the activator shuts |
| + // down after the join. |
| + a.waitExit(t, largeBoxDrain+largeBoxJoinBound+3*time.Second) |
| + t.Logf("agent exited %s after SIGTERM with a wake held at the latch", time.Since(at)) |
| + got := b.answer(t, ans) |
| + if got.err != nil || got.status != http.StatusServiceUnavailable { |
| + t.Fatalf("wake held through shutdown: %d %q %v", got.status, got.body, got.err) |
| + } |
| + a.assertNeverStarted(t) |
| + b.assertNoWrongService(t, a) |
| + a.assertCleanShutdown(t) |
| +} |
| + |
| +// --- runAgent's wiring ------------------------------------------------------------ |
| + |
| +// TestRunAgentStartupWiring reads the startup's source and fails if it |
| +// drifts from what runLargeBoxAgent rebuilds, or if anything on runAgent's |
| +// path to the edge waits for startup to finish. Calls are matched on the |
| +// callee's own name (a selector's Sel), and order means execution order on |
| +// one goroutine: a step inside a go or defer statement, or inside a |
| +// function literal that is not invoked on the spot, does not count as |
| +// taken. Where the hotfix does mean a step to run in the background, the |
| +// one goroutine it runs on is found structurally and its order checked |
| +// inside it. |
| +func TestRunAgentStartupWiring(t *testing.T) { |
| + fset := token.NewFileSet() |
| + decls := map[string]*ast.FuncDecl{} // runAgent, startActivator, recoverLayoutAtStart, activatorStart.Run |
| + files, err := filepath.Glob("*.go") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range files { |
| + if strings.HasSuffix(name, "_test.go") { |
| + continue |
| + } |
| + f, err := parser.ParseFile(fset, name, nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, d := range f.Decls { |
| + fd, ok := d.(*ast.FuncDecl) |
| + if !ok { |
| + continue |
| + } |
| + key := fd.Name.Name |
| + if fd.Recv != nil && len(fd.Recv.List) == 1 { |
| + key = types.ExprString(fd.Recv.List[0].Type) + "." + key |
| + } |
| + switch key { |
| + case "runAgent", "startActivator", "recoverLayoutAtStart", "activatorStart.Run": |
| + decls[key] = fd |
| + } |
| + } |
| + } |
| + for _, key := range []string{"runAgent", "startActivator", "recoverLayoutAtStart", "activatorStart.Run"} { |
| + if decls[key] == nil { |
| + t.Fatalf("%s not found in package host", key) |
| + } |
| + } |
| + str := types.ExprString |
| + |
| + // runAgent: the latch is set, the start sequence is handed the startup |
| + // context, the edge runs, then cancel, join and Shutdown, all on |
| + // runAgent's own goroutine. |
| + runAgent := decls["runAgent"].Body |
| + sync := syncCalls(runAgent) |
| + var run, edgeCall *ast.CallExpr |
| + var lit *ast.CompositeLit |
| + for _, c := range sync { |
| + if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Run" { |
| + if cl, ok := sel.X.(*ast.CompositeLit); ok && str(cl.Type) == "activatorStart" { |
| + run, lit = c, cl |
| + } |
| + } |
| + if calleeName(c) == "RunWithActivatorReady" { |
| + edgeCall = c |
| + } |
| + } |
| + if run == nil || edgeCall == nil { |
| + t.Fatalf("runAgent: activatorStart{...}.Run called synchronously: %v; edge.RunWithActivatorReady called synchronously: %v", run != nil, edgeCall != nil) |
| + } |
| + if len(run.Args) != 1 || str(run.Args[0]) != "startupCtx" { |
| + t.Errorf("activatorStart.Run is given %v, want startupCtx", run.Args) |
| + } |
| + var latch token.Pos |
| + ast.Inspect(runAgent, func(n ast.Node) bool { |
| + if a, ok := n.(*ast.AssignStmt); ok && len(a.Lhs) == 1 && len(a.Rhs) == 1 && selName(a.Lhs[0]) == "Startup" && str(a.Rhs[0]) == "started" { |
| + latch = a.Pos() |
| + } |
| + return true |
| + }) |
| + if latch == token.NoPos || latch > run.Pos() { |
| + t.Error("runAgent: svc.Startup = started is not set before activatorStart.Run") |
| + } |
| + after := func(name string, arg string) token.Pos { |
| + for _, c := range sync { |
| + if c.Pos() > edgeCall.Pos() && calleeName(c) == name && (arg == "" || (len(c.Args) > 0 && str(c.Args[len(c.Args)-1]) == arg)) { |
| + return c.Pos() |
| + } |
| + } |
| + return token.NoPos |
| + } |
| + prev, prevName := edgeCall.Pos(), "edge.RunWithActivatorReady" |
| + for _, step := range []struct{ name, arg string }{{"cancelStartup", ""}, {"joinAgentStartup", "startupDone"}, {"Shutdown", ""}} { |
| + p := after(step.name, step.arg) |
| + if p == token.NoPos || p < prev { |
| + t.Errorf("runAgent: %s is not called synchronously after %s", step.name, prevName) |
| + continue |
| + } |
| + prev, prevName = p, step.name |
| + } |
| + |
| + // The activatorStart fields the child copies, and the steps inside |
| + // them. Run calls each field on its background goroutine, so inside a |
| + // field each step must be synchronous. |
| + fields := map[string]ast.Expr{} |
| + for _, el := range lit.Elts { |
| + if kv, ok := el.(*ast.KeyValueExpr); ok { |
| + fields[str(kv.Key)] = kv.Value |
| + } |
| + } |
| + for field, want := range map[string]string{ |
| + "Start": "Start", "Publish": "Store", "Slot": "PrepareScheduledToken", |
| + "Routes": "List", "Route": "Get", "Claims": "Enabled", |
| + } { |
| + if got := selName(fields[field]); got != want { |
| + t.Errorf("activatorStart.%s is %s, want a %s method value", field, exprOrNil(fields[field]), want) |
| + } |
| + } |
| + funcField := func(name string) ast.Node { |
| + fl, _ := fields[name].(*ast.FuncLit) |
| + if fl == nil { |
| + t.Errorf("activatorStart.%s is not a func literal", name) |
| + return nil |
| + } |
| + return fl.Body |
| + } |
| + if body := funcField("Recover"); body != nil { |
| + requireSteps(t, "activatorStart.Recover", body, "newLayoutPass", "startupRecoveryIntent", "recoverLayoutAtStart") |
| + } |
| + if body := funcField("Sweep"); body != nil { |
| + requireSteps(t, "activatorStart.Sweep", body, "Sweep") |
| + } |
| + if body := funcField("Ready"); body != nil { |
| + closes := false |
| + for _, c := range syncCalls(body) { |
| + closes = closes || (calleeName(c) == "close" && len(c.Args) == 1 && str(c.Args[0]) == "started") |
| + } |
| + if !closes { |
| + t.Error("activatorStart.Ready does not close started synchronously") |
| + } |
| + } |
| + |
| + // activatorStart.Run: on a legacy box, one goroutine runs the start |
| + // sequence and then the key pass, in that order; start itself is |
| + // Start, then Ready. |
| + runBody := decls["activatorStart.Run"].Body |
| + var background []*ast.FuncLit |
| + ast.Inspect(runBody, func(n ast.Node) bool { |
| + if g, ok := n.(*ast.GoStmt); ok { |
| + if fl, ok := g.Call.Fun.(*ast.FuncLit); ok && hasCall(syncCalls(fl.Body), "startActivator") { |
| + background = append(background, fl) |
| + } |
| + } |
| + return true |
| + }) |
| + if len(background) != 1 { |
| + t.Errorf("activatorStart.Run: %d goroutines run startActivator, want the one background start", len(background)) |
| + } else { |
| + requireSteps(t, "activatorStart.Run's background goroutine", background[0].Body, "startActivator", "backfillInstanceKeys") |
| + } |
| + var startBody ast.Node |
| + ast.Inspect(runBody, func(n ast.Node) bool { |
| + if a, ok := n.(*ast.AssignStmt); ok && len(a.Lhs) == 1 && len(a.Rhs) == 1 && str(a.Lhs[0]) == "start" { |
| + if fl, ok := a.Rhs[0].(*ast.FuncLit); ok { |
| + startBody = fl.Body |
| + } |
| + } |
| + return true |
| + }) |
| + if startBody == nil { |
| + t.Error("activatorStart.Run: no start closure") |
| + } else { |
| + requireSteps(t, "activatorStart.Run's start", startBody, "Start", "Ready") |
| + } |
| + |
| + // startActivator: its sweep, recoverLayout and start parameters, called |
| + // in that order, synchronously (matched by position, not name). |
| + sa := decls["startActivator"] |
| + var params []string |
| + for _, f := range sa.Type.Params.List { |
| + for _, n := range f.Names { |
| + params = append(params, n.Name) |
| + } |
| + } |
| + if len(params) != 4 { |
| + t.Errorf("startActivator takes %v, want (ctx, sweep, recoverLayout, start)", params) |
| + } else { |
| + requireSteps(t, "startActivator", sa.Body, params[1], params[2], params[3]) |
| + } |
| + |
| + // recoverLayoutAtStart: journal recovery, then hold restoration. |
| + requireSteps(t, "recoverLayoutAtStart", decls["recoverLayoutAtStart"].Body, "Recover", "RestoreHolds") |
| + |
| + // The worker boot pass waits on the latch or the startup context, then |
| + // lists and wakes. |
| + var bootPass *ast.FuncLit |
| + ast.Inspect(runAgent, func(n ast.Node) bool { |
| + if g, ok := n.(*ast.GoStmt); ok { |
| + if fl, ok := g.Call.Fun.(*ast.FuncLit); ok && hasCall(syncCalls(fl.Body), "runWorkerBootPass") { |
| + bootPass = fl |
| + } |
| + } |
| + return true |
| + }) |
| + if bootPass == nil { |
| + t.Error("runAgent: no goroutine runs runWorkerBootPass") |
| + } else if !receivesFrom(bootPass.Body, "started") || !hasCall(syncCalls(bootPass.Body), "Done") { |
| + t.Error("runAgent: the worker boot pass does not wait on started and startupCtx.Done") |
| + } |
| + |
| + // Nothing before the edge waits for startup. On runAgent's own |
| + // goroutine (closures invoked on the spot included): no receive at all, |
| + // no join, and a closure that waits on startup is only called directly |
| + // on a claim box, where Run has already started the activator before |
| + // it returns. |
| + waiters := map[string]bool{} |
| + ast.Inspect(runAgent, func(n ast.Node) bool { |
| + if a, ok := n.(*ast.AssignStmt); ok && len(a.Lhs) == 1 && len(a.Rhs) == 1 { |
| + if fl, ok := a.Rhs[0].(*ast.FuncLit); ok && (receivesFrom(fl.Body, "started") || receivesFrom(fl.Body, "startupDone") || hasCall(syncCalls(fl.Body), "joinAgentStartup")) { |
| + waiters[str(a.Lhs[0])] = true |
| + } |
| + } |
| + return true |
| + }) |
| + var walk func(n ast.Node, claimBranch bool) |
| + walk = func(n ast.Node, claimBranch bool) { |
| + ast.Inspect(n, func(n ast.Node) bool { |
| + if n == nil || n.Pos() >= edgeCall.Pos() { |
| + return false |
| + } |
| + switch n := n.(type) { |
| + case *ast.FuncLit, *ast.GoStmt, *ast.DeferStmt: |
| + return false |
| + case *ast.IfStmt: |
| + if n.Init != nil { |
| + walk(n.Init, claimBranch) |
| + } |
| + walk(n.Cond, claimBranch) |
| + walk(n.Body, claimBranch || selName(n.Cond) == "Enabled") |
| + if n.Else != nil { |
| + walk(n.Else, claimBranch) |
| + } |
| + return false |
| + case *ast.UnaryExpr: |
| + if n.Op == token.ARROW { |
| + t.Errorf("runAgent waits on %s at %s, before the edge listens", str(n.X), fset.Position(n.Pos())) |
| + } |
| + case *ast.CallExpr: |
| + name := calleeName(n) |
| + if name == "joinAgentStartup" { |
| + t.Errorf("runAgent joins startup at %s, before the edge listens", fset.Position(n.Pos())) |
| + } |
| + if waiters[str(n.Fun)] && !claimBranch { |
| + t.Errorf("runAgent calls %s, which waits on startup, at %s, before the edge listens", str(n.Fun), fset.Position(n.Pos())) |
| + } |
| + if fl, ok := n.Fun.(*ast.FuncLit); ok { // invoked on the spot: its body runs here |
| + walk(fl.Body, claimBranch) |
| + for _, a := range n.Args { |
| + walk(a, claimBranch) |
| + } |
| + return false |
| + } |
| + } |
| + return true |
| + }) |
| + } |
| + walk(runAgent, false) |
| +} |
| + |
| +// syncCalls lists, in source order, the calls body makes on its own |
| +// goroutine: none inside a go or defer statement, and none inside a |
| +// function literal unless the literal is invoked right there. |
| +func syncCalls(body ast.Node) []*ast.CallExpr { |
| + var out []*ast.CallExpr |
| + ast.Inspect(body, func(n ast.Node) bool { |
| + switch n := n.(type) { |
| + case *ast.GoStmt, *ast.DeferStmt, *ast.FuncLit: |
| + return false |
| + case *ast.CallExpr: |
| + out = append(out, n) |
| + if fl, ok := n.Fun.(*ast.FuncLit); ok { |
| + for _, a := range n.Args { |
| + out = append(out, syncCalls(a)...) |
| + } |
| + out = append(out, syncCalls(fl.Body)...) |
| + return false |
| + } |
| + } |
| + return true |
| + }) |
| + return out |
| +} |
| + |
| +// requireSteps fails unless body calls each step synchronously, in order, |
| +// and calls none of them anywhere else (in a go or defer statement, or a |
| +// literal that is not invoked on the spot). |
| +func requireSteps(t *testing.T, where string, body ast.Node, steps ...string) { |
| + t.Helper() |
| + sync := syncCalls(body) |
| + inSync := map[*ast.CallExpr]bool{} |
| + for _, c := range sync { |
| + inSync[c] = true |
| + } |
| + ast.Inspect(body, func(n ast.Node) bool { |
| + if c, ok := n.(*ast.CallExpr); ok && !inSync[c] { |
| + for _, s := range steps { |
| + if calleeName(c) == s { |
| + t.Errorf("%s: %s is called off its goroutine (go, defer, or a literal not invoked there)", where, s) |
| + } |
| + } |
| + } |
| + return true |
| + }) |
| + i := 0 |
| + for _, c := range sync { |
| + if i < len(steps) && calleeName(c) == steps[i] { |
| + i++ |
| + } |
| + } |
| + if i < len(steps) { |
| + t.Errorf("%s: want %v called in that order on its goroutine; %s is missing or out of order", where, steps, steps[i]) |
| + } |
| +} |
| + |
| +// calleeName is the name a call is matched on: the function's own name, |
| +// whatever the receiver or package it is reached through. |
| +func calleeName(c *ast.CallExpr) string { |
| + switch f := c.Fun.(type) { |
| + case *ast.Ident: |
| + return f.Name |
| + case *ast.SelectorExpr: |
| + return f.Sel.Name |
| + } |
| + return "" |
| +} |
| + |
| +func selName(e ast.Expr) string { |
| + switch e := e.(type) { |
| + case *ast.SelectorExpr: |
| + return e.Sel.Name |
| + case *ast.Ident: |
| + return e.Name |
| + } |
| + return "" |
| +} |
| + |
| +func hasCall(calls []*ast.CallExpr, name string) bool { |
| + for _, c := range calls { |
| + if calleeName(c) == name { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// receivesFrom reports whether body receives from the channel named ch |
| +// (in a select case or a bare receive). |
| +func receivesFrom(body ast.Node, ch string) bool { |
| + found := false |
| + ast.Inspect(body, func(n ast.Node) bool { |
| + if u, ok := n.(*ast.UnaryExpr); ok && u.Op == token.ARROW && types.ExprString(u.X) == ch { |
| + found = true |
| + } |
| + return !found |
| + }) |
| + return found |
| +} |
| + |
| +func exprOrNil(e ast.Expr) string { |
| + if e == nil { |
| + return "unset" |
| + } |
| + return types.ExprString(e) |
| +} |
| + |
| +// --- the box ------------------------------------------------------------------ |
| + |
| +type largeBox struct { |
| + dataDir, socketDir, storeDir, acmeDir string |
| + reg *registry.Registry |
| + control *largeBoxControl |
| + httpAddr, httpsAddr string |
| + client *http.Client |
| +} |
| + |
| +// largeBoxSocketDir makes the box's run directory. It must be short: a |
| +// nested instance's socket is <dir>/instances/<instance id>/app.sock, the |
| +// activator binds a ".control" socket beside it, and the whole path must |
| +// fit a unix socket address (provision.MaxUnixPath). The default temp dir |
| +// is tried first, then a directory of the test's own directly under /tmp; |
| +// when neither is short enough the test is skipped, not failed, since |
| +// nothing about the code under test is wrong. |
| +func largeBoxSocketDir(t *testing.T) string { |
| + t.Helper() |
| + fits := func(dir string) bool { |
| + return len(filepath.Join(dir, "instances", provision.InstanceID(largeBoxHost(0)), "app.sock.control")) <= provision.MaxUnixPath |
| + } |
| + var tried []string |
| + for _, base := range []string{os.TempDir(), "/tmp"} { |
| + dir, err := os.MkdirTemp(base, "lb") |
| + if err != nil { |
| + tried = append(tried, base+": "+err.Error()) |
| + continue |
| + } |
| + if fits(dir) { |
| + t.Cleanup(func() { os.RemoveAll(dir) }) |
| + return dir |
| + } |
| + os.RemoveAll(dir) |
| + tried = append(tried, dir+": too long") |
| + } |
| + t.Skipf("no directory short enough for a nested instance's control socket (%d bytes at most): %s", provision.MaxUnixPath, strings.Join(tried, "; ")) |
| + return "" |
| +} |
| + |
| +// newLargeBox builds the box the agent restarts on: largeBoxRoutes |
| +// sleeping instances on flat paths, largeBoxJournals of them with a layout |
| +// move cut short at three different points, largeBoxHolds held down after |
| +// a refused move (the reason, a second name for the database, persists), |
| +// a scheduled delivery due on one instance, an always-on worker the boot |
| +// pass starts, a nested layout intent in the deployment store, a cached |
| +// certificate for every host the test asks for, and a cold instance |
| +// control. |
| +func newLargeBox(t *testing.T) *largeBox { |
| + t.Helper() |
| + sd := largeBoxSocketDir(t) |
| + b := &largeBox{dataDir: t.TempDir(), storeDir: t.TempDir(), acmeDir: t.TempDir(), socketDir: sd} |
| + |
| + var err error |
| + b.reg, err = registry.Open(filepath.Join(b.dataDir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { b.reg.Close() }) |
| + var hosts []string |
| + for i := 0; i < largeBoxRoutes; i++ { |
| + h := largeBoxHost(i) |
| + hosts = append(hosts, h) |
| + if err := b.reg.Upsert(registry.Route{ |
| + Host: h, Kind: "instance", Account: "acct", App: "shop", Status: "ready", Hibernate: true, |
| + Socket: filepath.Join(sd, h+".sock"), DBPath: provision.DefaultDBPath(b.dataDir, h), |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := b.reg.Upsert(registry.Route{ |
| + Host: largeBoxWorker, Kind: "sidecar", Account: "acct", App: "shop", Status: "ready", Args: `["serve"]`, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + due := time.Now().Unix() - 5 |
| + if err := b.reg.UpsertTimer(registry.Timer{ |
| + Host: largeBoxHost(largeBoxDueHost), Name: "tick", Kind: schedule.KindSchedule, Spec: "every 6h0m0s", |
| + Path: largeBoxDuePath, NextAt: due, DueAt: due, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + for i := 0; i < largeBoxJournals; i++ { |
| + crash := "after-journal" |
| + switch { |
| + case i >= 6: |
| + crash = "before-remove" |
| + case i >= 4: |
| + crash = "mid-files" |
| + } |
| + b.crashedMove(t, largeBoxHost(i), crash) |
| + } |
| + for i := largeBoxJournals; i < largeBoxJournals+largeBoxHolds; i++ { |
| + b.heldMove(t, largeBoxHost(i), i) |
| + } |
| + appendIntent(t, release.NewDirStore(b.storeDir), release.LayoutModeNested, "") |
| + |
| + served := []string{largeBoxHost(largeBoxWakeHost), largeBoxHost(largeBoxSidecarHost)} |
| + for i := 0; i < largeBoxJournals+largeBoxHolds; i++ { |
| + served = append(served, largeBoxHost(i)) |
| + } |
| + pool := writeLargeBoxCerts(t, b.acmeDir, served) |
| + |
| + b.control = startLargeBoxControl(t, filepath.Join(sd, "control.sock"), hosts) |
| + b.client = &http.Client{ |
| + Timeout: largeBoxLatchBound + 15*time.Second, |
| + Transport: &http.Transport{ |
| + Proxy: nil, |
| + DisableKeepAlives: true, |
| + TLSClientConfig: &tls.Config{RootCAs: pool}, |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "tcp", b.httpsAddr) |
| + }, |
| + }, |
| + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, |
| + } |
| + return b |
| +} |
| + |
| +func (b *largeBox) seedFlat(t *testing.T, h string) { |
| + t.Helper() |
| + db := provision.DefaultDBPath(b.dataDir, h) |
| + for path, body := range map[string]string{db: "db " + h, db + "-wal": "wal", filepath.Join(b.dataDir, h+".env"): "CARLOS_ADMIN_TOKEN=t\n"} { |
| + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +// crashedMove leaves h as an agent killed mid-migration to nested leaves |
| +// it: the instance directories made and the journal written, then |
| +// nothing moved (after-journal), the database moved (mid-files), or |
| +// everything moved and the row flipped with only the journal's removal |
| +// left (before-remove). |
| +func (b *largeBox) crashedMove(t *testing.T, h, crash string) { |
| + t.Helper() |
| + b.seedFlat(t, h) |
| + if err := layout.EnsureDirs(b.dataDir, b.socketDir, h, -1); err != nil { |
| + t.Fatal(err) |
| + } |
| + plan, err := layout.PlanFor(b.dataDir, b.socketDir, h, layout.ModeNested) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + present := make([]bool, len(plan.Artifacts)) |
| + for i, a := range plan.Artifacts { |
| + if a.Name == "socket" { |
| + continue |
| + } |
| + _, err := os.Lstat(a.Src) |
| + present[i] = err == nil |
| + } |
| + j := plan.Journal(present) |
| + if err := layout.WriteJournal(b.dataDir, j); err != nil { |
| + t.Fatal(err) |
| + } |
| + move := func(a layout.Artifact) { |
| + if err := os.Rename(a.Src, a.Dst); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + switch crash { |
| + case "mid-files": |
| + move(plan.Artifacts[0]) |
| + case "before-remove": |
| + for i, a := range plan.Artifacts { |
| + if present[i] { |
| + move(a) |
| + } |
| + } |
| + if err := b.reg.SetInstancePaths(h, plan.From.DB, plan.From.Socket, plan.To.DB, plan.To.Socket); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := layout.UpdatePhase(b.dataDir, j, layout.PhaseDone); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +// heldMove leaves h held down after a refused move to nested: its hold |
| +// marker, and the reason, which persists across the restart (a second |
| +// name for its database, which a flat tenant can plant). |
| +func (b *largeBox) heldMove(t *testing.T, h string, i int) { |
| + t.Helper() |
| + b.seedFlat(t, h) |
| + if err := os.Link(provision.DefaultDBPath(b.dataDir, h), filepath.Join(b.dataDir, ".x"+strconv.Itoa(i))); err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := filepath.Join(b.dataDir, "layout") |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + marker, _ := json.Marshal(map[string]any{"host": h, "reason": "db has 2 names", "since": time.Now().UTC()}) |
| + if err := os.WriteFile(filepath.Join(dir, h+".hold"), marker, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// assertRecovered checks the files: every journal completed and removed, |
| +// each journalled host's row and database at the nested layout, and every |
| +// hold marker kept. |
| +func (b *largeBox) assertRecovered(t *testing.T) { |
| + t.Helper() |
| + if hosts, err := layout.JournalHosts(b.dataDir); err != nil || len(hosts) != 0 { |
| + t.Errorf("journals after startup: %v %v", hosts, err) |
| + } |
| + for i := 0; i < largeBoxJournals; i++ { |
| + h := largeBoxHost(i) |
| + plan, err := layout.PlanFor(b.dataDir, b.socketDir, h, layout.ModeNested) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := b.reg.Get(h) |
| + if err != nil || rt == nil || rt.DBPath != plan.To.DB || rt.Socket != plan.To.Socket { |
| + t.Errorf("%s row after recovery: %+v %v", h, rt, err) |
| + } |
| + if body, err := os.ReadFile(plan.Artifacts[0].Dst); err != nil || string(body) != "db "+h { |
| + t.Errorf("%s database after recovery: %q %v", h, body, err) |
| + } |
| + } |
| + held, err := layout.HoldMarkers(b.dataDir) |
| + if err != nil || len(held) != largeBoxHolds { |
| + t.Errorf("hold markers after startup: %v %v", held, err) |
| + } |
| +} |
| + |
| +// assertNoWrongService reads the backend's record: no wake admitted before |
| +// the latch opened, no held host started, and no journalled host started |
| +// at its pre-recovery socket. |
| +func (b *largeBox) assertNoWrongService(t *testing.T, a *largeBoxAgent) { |
| + t.Helper() |
| + if v := a.events("violation"); len(v) != 0 { |
| + t.Errorf("backend violations: %v", v) |
| + } |
| + for _, e := range append(a.events("acquire"), a.events("start")...) { |
| + for i := largeBoxJournals; i < largeBoxJournals+largeBoxHolds; i++ { |
| + if e.arg(0) == largeBoxHost(i) { |
| + t.Errorf("held host %s reached the backend: %v", e.arg(0), e) |
| + } |
| + } |
| + } |
| + for _, e := range a.events("start") { |
| + for i := 0; i < largeBoxJournals; i++ { |
| + if h := largeBoxHost(i); e.arg(0) == h && e.arg(1) == filepath.Join(b.socketDir, h+".sock") { |
| + t.Errorf("%s started at its pre-recovery socket", h) |
| + } |
| + } |
| + } |
| +} |
| + |
| +type largeBoxAnswer struct { |
| + status int |
| + body string |
| + header http.Header |
| + err error |
| + at time.Time // when the answer was read |
| +} |
| + |
| +// get asks the edge for host's root over TLS. sidecar marks the request |
| +// as the host's sidecar's (activator.SidecarHeader). |
| +func (b *largeBox) get(host string, sidecar bool) largeBoxAnswer { |
| + req, err := http.NewRequest(http.MethodGet, "https://"+host+"/", nil) |
| + if err != nil { |
| + return largeBoxAnswer{err: err, at: time.Now()} |
| + } |
| + if sidecar { |
| + req.Header.Set(activator.SidecarHeader, "1") |
| + } |
| + resp, err := b.client.Do(req) |
| + if err != nil { |
| + return largeBoxAnswer{err: err, at: time.Now()} |
| + } |
| + defer resp.Body.Close() |
| + body, err := io.ReadAll(resp.Body) |
| + return largeBoxAnswer{status: resp.StatusCode, body: string(body), header: resp.Header, err: err, at: time.Now()} |
| +} |
| + |
| +func (b *largeBox) fire(host string, sidecar bool) chan largeBoxAnswer { |
| + ch := make(chan largeBoxAnswer, 1) |
| + go func() { ch <- b.get(host, sidecar) }() |
| + return ch |
| +} |
| + |
| +func (b *largeBox) answer(t *testing.T, ch chan largeBoxAnswer) largeBoxAnswer { |
| + t.Helper() |
| + select { |
| + case got := <-ch: |
| + return got |
| + case <-time.After(10 * time.Second): |
| + t.Fatal("request did not finish") |
| + return largeBoxAnswer{} |
| + } |
| +} |
| + |
| +func freeLoopbackAddr(t *testing.T) string { |
| + t.Helper() |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer ln.Close() |
| + return ln.Addr().String() |
| +} |
| + |
| +// writeLargeBoxCerts puts one self-signed certificate for hosts into the |
| +// edge's autocert directory cache, under each host's name, so the edge in |
| +// production mode serves them TLS without ordering anything. |
| +func writeLargeBoxCerts(t *testing.T, dir string, hosts []string) *x509.CertPool { |
| + t.Helper() |
| + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tmpl := &x509.Certificate{ |
| + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "large box"}, DNSNames: hosts, |
| + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), |
| + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, |
| + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true, |
| + } |
| + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + keyDER, err := x509.MarshalECPrivateKey(key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pemBytes := append(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...) |
| + for _, h := range hosts { |
| + if err := os.WriteFile(filepath.Join(dir, h), pemBytes, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + cert, err := x509.ParseCertificate(der) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pool := x509.NewCertPool() |
| + pool.AddCert(cert) |
| + return pool |
| +} |
| + |
| +// --- instance control --------------------------------------------------------- |
| + |
| +// largeBoxControl is the real instance-control server with a cold systemd |
| +// behind its Systemctl seam: every call waits for warm, then the box's |
| +// leftover units are listed active and each stop takes largeBoxUnitStop. |
| +type largeBoxControl struct { |
| + warm chan struct{} |
| + units string |
| + stops atomic.Int32 |
| +} |
| + |
| +func startLargeBoxControl(t *testing.T, path string, hosts []string) *largeBoxControl { |
| + t.Helper() |
| + c := &largeBoxControl{warm: make(chan struct{})} |
| + var units strings.Builder |
| + for _, h := range hosts { |
| + fmt.Fprintf(&units, "%s loaded active running carlos instance\n", instancectl.UnitName(instancectl.RoleRun, h)) |
| + } |
| + c.units = units.String() |
| + srv := &instancectl.Server{ |
| + Systemctl: c.systemctl, |
| + PeerOK: func(net.Conn) error { return nil }, |
| + CgroupRoot: t.TempDir(), |
| + Build: version.Build, |
| + Logf: func(string, ...any) {}, |
| + } |
| + ln, err := net.Listen("unix", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + done := make(chan struct{}) |
| + go func() { defer close(done); _ = srv.Serve(ctx, ln) }() |
| + t.Cleanup(func() { cancel(); <-done }) |
| + return c |
| +} |
| + |
| +func (c *largeBoxControl) systemctl(ctx context.Context, args ...string) (string, error) { |
| + select { |
| + case <-c.warm: |
| + case <-ctx.Done(): |
| + return "", ctx.Err() |
| + } |
| + switch args[0] { |
| + case "list-units": |
| + return c.units, nil |
| + case "list-jobs": |
| + return "", nil |
| + case "stop": |
| + select { |
| + case <-time.After(largeBoxUnitStop): |
| + case <-ctx.Done(): |
| + return "", ctx.Err() |
| + } |
| + c.stops.Add(1) |
| + return "", nil |
| + case "show": |
| + return "inactive\n", nil |
| + } |
| + return "", fmt.Errorf("systemctl %v: not part of a sweep", args) |
| +} |
| + |
| +// --- the agent process ---------------------------------------------------------- |
| + |
| +type largeBoxAgent struct { |
| + cmd *exec.Cmd |
| + began time.Time |
| + listen time.Duration // process start to the gate probe's first answer |
| + eventsPath string |
| + log string |
| + exited chan struct{} // closed once the process has been waited for |
| + exitErr error // Wait's answer, read after exited closes |
| + b *largeBox |
| +} |
| + |
| +// startAgent starts the agent and returns it once the gate's probe is |
| +// answered and its TLS listener accepts. The edge's -http and -https take |
| +// an address, and :0 would bind, but the edge never says which port it |
| +// got, so the test picks free ports and hands them over. A port taken in |
| +// between makes the edge's listener fail and the agent exit; only that is |
| +// retried on fresh ports (largeBoxRetryable). A deadline missed is the |
| +// failure this test exists to catch, and is never retried. |
| +func (b *largeBox) startAgent(t *testing.T) *largeBoxAgent { |
| + t.Helper() |
| + for attempt := 1; ; attempt++ { |
| + b.httpAddr, b.httpsAddr = freeLoopbackAddr(t), freeLoopbackAddr(t) |
| + a := b.launchAgent(t) |
| + outcome, err := a.awaitListening() |
| + if outcome == largeBoxUp { |
| + outcome, err = a.awaitTLS() |
| + } |
| + if outcome == largeBoxUp { |
| + return a |
| + } |
| + agentLog, _ := os.ReadFile(a.log) |
| + if largeBoxRetryable(outcome, string(agentLog), b.httpAddr, b.httpsAddr) && attempt < largeBoxBindAttempts { |
| + t.Logf("attempt %d: %v; a port the test handed the agent was taken before it bound it, starting again on fresh ports", attempt, err) |
| + continue |
| + } |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// largeBoxOutcome is how waiting for the agent to come up ended. |
| +type largeBoxOutcome int |
| + |
| +const ( |
| + largeBoxUp largeBoxOutcome = iota |
| + largeBoxLate // a deadline passed: a failure, never retried |
| + largeBoxExited // the agent exited before it was up |
| +) |
| + |
| +// largeBoxRetryable reports whether a start may be retried on fresh |
| +// ports: only when the agent exited, and its log says the edge could not |
| +// bind one of the addresses the test handed it because it was in use. |
| +// Edge listener failures are logged as "edge: http listener <addr>: ..." |
| +// (edge.RunWithActivatorReady). |
| +func largeBoxRetryable(o largeBoxOutcome, agentLog string, addrs ...string) bool { |
| + if o != largeBoxExited { |
| + return false |
| + } |
| + for _, line := range strings.Split(agentLog, "\n") { |
| + if !strings.Contains(line, "address already in use") { |
| + continue |
| + } |
| + for _, addr := range addrs { |
| + if strings.Contains(line, " listener "+addr+": ") { |
| + return true |
| + } |
| + } |
| + } |
| + return false |
| +} |
| + |
| +func TestLargeBoxRetriesOnlyItsOwnBindFailure(t *testing.T) { |
| + const plain, tls = "127.0.0.1:40001", "127.0.0.1:40002" |
| + bind := "edge: https listener " + tls + ": listen tcp " + tls + ": bind: address already in use\n" |
| + for _, c := range []struct { |
| + name string |
| + o largeBoxOutcome |
| + log string |
| + want bool |
| + }{ |
| + {"own bind failure", largeBoxExited, "edge: serving 300 route(s)\n" + bind, true}, |
| + {"deadline missed, bind failure logged too", largeBoxLate, bind, false}, |
| + {"exited for another reason", largeBoxExited, "edge: tunnels: invalid\n", false}, |
| + {"someone else's address", largeBoxExited, "edge: http listener 127.0.0.1:1: listen tcp 127.0.0.1:1: bind: address already in use\n", false}, |
| + {"in use, but not a listener line", largeBoxExited, "agent: something: address already in use\n", false}, |
| + } { |
| + if got := largeBoxRetryable(c.o, c.log, plain, tls); got != c.want { |
| + t.Errorf("%s: retry = %v, want %v", c.name, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func (b *largeBox) launchAgent(t *testing.T) *largeBoxAgent { |
| + t.Helper() |
| + cfgDir := t.TempDir() |
| + cfg := largeBoxConfig{ |
| + DataDir: b.dataDir, SocketDir: b.socketDir, StoreDir: b.storeDir, AcmeDir: b.acmeDir, |
| + Control: filepath.Join(b.socketDir, "control.sock"), Events: filepath.Join(cfgDir, "events"), |
| + HTTP: b.httpAddr, HTTPS: b.httpsAddr, |
| + } |
| + raw, err := json.Marshal(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cfgPath := filepath.Join(cfgDir, "agent.json") |
| + if err := os.WriteFile(cfgPath, raw, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + exe, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a := &largeBoxAgent{eventsPath: cfg.Events, log: filepath.Join(cfgDir, "agent.log"), exited: make(chan struct{}), b: b} |
| + logFile, err := os.Create(a.log) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { logFile.Close() }) |
| + a.cmd = exec.Command(exe, "-test.run=^TestStartupLargeBoxEndToEnd$", "-test.count=1") |
| + // A clean environment: nothing of the developer's or the runner's |
| + // (a deployment bucket, AWS credentials, CARLOS_*) reaches the agent. |
| + a.cmd.Env = []string{"HOME=" + b.dataDir, "TMPDIR=" + os.TempDir(), "CARLOS_DATA_DIR=" + b.dataDir, largeBoxAgentEnv + "=" + cfgPath} |
| + a.cmd.Stdout, a.cmd.Stderr = logFile, logFile |
| + a.began = time.Now() |
| + if err := a.cmd.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + go func() { |
| + a.exitErr = a.cmd.Wait() |
| + close(a.exited) |
| + }() |
| + t.Cleanup(func() { |
| + select { |
| + case <-a.exited: |
| + default: |
| + _ = a.cmd.Process.Kill() |
| + <-a.exited |
| + } |
| + if t.Failed() { |
| + if out, err := os.ReadFile(a.log); err == nil { |
| + if len(out) > 16<<10 { |
| + out = out[len(out)-16<<10:] |
| + } |
| + t.Logf("agent log (tail):\n%s", out) |
| + } |
| + } |
| + }) |
| + return a |
| +} |
| + |
| +// awaitListening probes as the update gate does (systemapply.go |
| +// productionSysOps.probeEdge): GET / on the edge's plain-HTTP listener, |
| +// three-second client timeout, no redirect followed, and any HTTP answer |
| +// passes. It records how long after the process started the first answer |
| +// came; an answer past largeBoxListenBound, or none by then, is late. |
| +func (a *largeBoxAgent) awaitListening() (largeBoxOutcome, error) { |
| + probe := &http.Client{ |
| + Timeout: 3 * time.Second, |
| + Transport: &http.Transport{Proxy: nil, DisableKeepAlives: true}, |
| + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, |
| + } |
| + deadline := a.began.Add(largeBoxListenBound) |
| + for { |
| + resp, err := probe.Get("http://" + a.b.httpAddr + "/") |
| + if err == nil { |
| + resp.Body.Close() |
| + a.listen = time.Since(a.began) |
| + if a.listen > largeBoxListenBound { |
| + return largeBoxLate, fmt.Errorf("gate probe answered %s after the agent started, over %s", a.listen, largeBoxListenBound) |
| + } |
| + return largeBoxUp, nil |
| + } |
| + if time.Now().After(deadline) { |
| + return largeBoxLate, fmt.Errorf("gate probe unanswered %s after the agent started: %v", time.Since(a.began), err) |
| + } |
| + select { |
| + case <-a.exited: |
| + return largeBoxExited, fmt.Errorf("agent exited before listening: %v", a.exitErr) |
| + default: |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| +} |
| + |
| +// awaitTLS waits, boundedly, for the edge's TLS listener: the edge binds |
| +// it in its own goroutine, beside the plain-HTTP one the gate probes, so |
| +// it can come up a moment later. Not part of the gate's measurement. |
| +func (a *largeBoxAgent) awaitTLS() (largeBoxOutcome, error) { |
| + deadline := time.Now().Add(largeBoxListenBound) |
| + for { |
| + c, err := net.DialTimeout("tcp", a.b.httpsAddr, time.Second) |
| + if err == nil { |
| + c.Close() |
| + } |
| + select { |
| + case <-a.exited: |
| + return largeBoxExited, fmt.Errorf("agent exited before its TLS listener was up: %v", a.exitErr) |
| + default: |
| + } |
| + if err == nil { |
| + return largeBoxUp, nil |
| + } |
| + if time.Now().After(deadline) { |
| + return largeBoxLate, fmt.Errorf("TLS listener not up %s after the gate probe: %v", largeBoxListenBound, err) |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| +} |
| + |
| +func (a *largeBoxAgent) signal(t *testing.T, sig os.Signal) { |
| + t.Helper() |
| + if err := a.cmd.Process.Signal(sig); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (a *largeBoxAgent) waitExit(t *testing.T, bound time.Duration) { |
| + t.Helper() |
| + select { |
| + case <-a.exited: |
| + if a.exitErr != nil { |
| + t.Fatalf("agent exit: %v", a.exitErr) |
| + } |
| + case <-time.After(bound): |
| + t.Fatalf("agent did not exit within %s of the signal", bound) |
| + } |
| +} |
| + |
| +// assertNeverStarted: startup never finished, so the activator never |
| +// started and nothing reached the backend or ran. |
| +func (a *largeBoxAgent) assertNeverStarted(t *testing.T) { |
| + t.Helper() |
| + for _, kind := range []string{"ready", "activator-start", "acquire", "start", "worker-start", "served"} { |
| + if evs := a.events(kind); len(evs) != 0 { |
| + t.Errorf("%s with startup unfinished: %v", kind, evs) |
| + } |
| + } |
| +} |
| + |
| +// assertAfter waits briefly for the event kind with args and fails unless |
| +// it came after ref. |
| +func (a *largeBoxAgent) assertAfter(t *testing.T, ref largeBoxEvent, kind string, args ...string) { |
| + t.Helper() |
| + e := a.waitEvent(t, 10*time.Second, kind, args...) |
| + if e.at.Before(ref.at) { |
| + t.Errorf("%s %v came %s before %s", kind, args, ref.at.Sub(e.at), ref.kind) |
| + } |
| +} |
| + |
| +// assertCleanShutdown reads the agent's own account of its shutdown: the |
| +// startup work joined within runAgent's bound, and no goroutine of the |
| +// startup sequence, the activator or the fake instances left after it. |
| +func (a *largeBoxAgent) assertCleanShutdown(t *testing.T) { |
| + t.Helper() |
| + j, ok := a.event("joined") |
| + if !ok { |
| + t.Fatal("agent did not report its startup join") |
| + } |
| + took, err := time.ParseDuration(j.arg(1)) |
| + if j.arg(0) != "true" || err != nil || took > largeBoxJoinBound { |
| + t.Fatalf("startup join: %v", j) |
| + } |
| + t.Logf("startup work joined %s after the edge returned", took) |
| + if _, ok := a.event("leak-free"); !ok { |
| + t.Fatal("goroutines of the startup sequence outlived shutdown; see the agent log") |
| + } |
| +} |
| + |
| +type largeBoxEvent struct { |
| + at time.Time |
| + kind string |
| + args []string |
| +} |
| + |
| +func (e largeBoxEvent) arg(i int) string { |
| + if i < len(e.args) { |
| + return e.args[i] |
| + } |
| + return "" |
| +} |
| + |
| +func (a *largeBoxAgent) all() []largeBoxEvent { |
| + f, err := os.Open(a.eventsPath) |
| + if err != nil { |
| + return nil |
| + } |
| + defer f.Close() |
| + var out []largeBoxEvent |
| + sc := bufio.NewScanner(f) |
| + for sc.Scan() { |
| + fields := strings.Fields(sc.Text()) |
| + if len(fields) < 2 { |
| + continue |
| + } |
| + ns, err := strconv.ParseInt(fields[0], 10, 64) |
| + if err != nil { |
| + continue |
| + } |
| + out = append(out, largeBoxEvent{at: time.Unix(0, ns), kind: fields[1], args: fields[2:]}) |
| + } |
| + return out |
| +} |
| + |
| +func (a *largeBoxAgent) events(kind string) []largeBoxEvent { |
| + var out []largeBoxEvent |
| + for _, e := range a.all() { |
| + if e.kind == kind { |
| + out = append(out, e) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func (a *largeBoxAgent) event(kind string, args ...string) (largeBoxEvent, bool) { |
| + for _, e := range a.events(kind) { |
| + match := true |
| + for i, want := range args { |
| + if e.arg(i) != want { |
| + match = false |
| + } |
| + } |
| + if match { |
| + return e, true |
| + } |
| + } |
| + return largeBoxEvent{}, false |
| +} |
| + |
| +func (a *largeBoxAgent) waitEvent(t *testing.T, bound time.Duration, kind string, args ...string) largeBoxEvent { |
| + t.Helper() |
| + deadline := time.Now().Add(bound) |
| + for { |
| + if e, ok := a.event(kind, args...); ok { |
| + return e |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatalf("agent did not report %s %v within %s", kind, args, bound) |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| +} |
| + |
| +// --- inside the agent process --------------------------------------------------- |
| + |
| +type largeBoxEvents struct { |
| + mu sync.Mutex |
| + f *os.File |
| +} |
| + |
| +func (e *largeBoxEvents) log(kind string, args ...any) { |
| + var b strings.Builder |
| + fmt.Fprintf(&b, "%d %s", time.Now().UnixNano(), kind) |
| + for _, a := range args { |
| + fmt.Fprintf(&b, " %v", a) |
| + } |
| + b.WriteByte('\n') |
| + e.mu.Lock() |
| + defer e.mu.Unlock() |
| + _, _ = e.f.WriteString(b.String()) |
| +} |
| + |
| +// runLargeBoxAgent is the agent: runAgent's legacy-box startup over the |
| +// fixture, then the edge, then runAgent's shutdown. It returns the exit |
| +// code. |
| +func runLargeBoxAgent(cfgPath string) int { |
| + raw, err := os.ReadFile(cfgPath) |
| + if err != nil { |
| + log.Printf("largebox agent: %v", err) |
| + return 2 |
| + } |
| + var cfg largeBoxConfig |
| + if err := json.Unmarshal(raw, &cfg); err != nil { |
| + log.Printf("largebox agent: %v", err) |
| + return 2 |
| + } |
| + f, err := os.OpenFile(cfg.Events, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) |
| + if err != nil { |
| + log.Printf("largebox agent: %v", err) |
| + return 2 |
| + } |
| + defer f.Close() |
| + ev := &largeBoxEvents{f: f} |
| + |
| + agentCtx, cancelAgent := context.WithCancel(context.Background()) |
| + defer cancelAgent() |
| + startupCtx, cancelStartup := context.WithCancel(agentCtx) |
| + defer cancelStartup() |
| + |
| + dd := cfg.DataDir |
| + regPath := filepath.Join(dd, "registry.db") |
| + reg, err := registry.Open(regPath) |
| + if err != nil { |
| + log.Printf("largebox agent: %v", err) |
| + return 2 |
| + } |
| + defer reg.Close() |
| + |
| + control := instancectl.NewClient(cfg.Control) |
| + nestedOK := nestedGate(func() error { return instancefs.SelfCheck(dd) }, instancefs.MountNoSymfollow, control.Ready) |
| + started := make(chan struct{}) |
| + backend := &largeBoxBackend{ev: ev, started: started} |
| + svc := activator.New(activator.Config{Bucket: "unused", Idle: time.Hour}, backend, reg) |
| + backend.svc = svc |
| + svc.Routes = reg |
| + svc.Startup = started |
| + openStore := func(context.Context) (release.Store, error) { return release.NewDirStore(cfg.StoreDir), nil } |
| + |
| + startupDone := activatorStart{ |
| + Ready: func() { |
| + ev.log("ready") // before the close, so no acquire can be stamped earlier |
| + close(started) |
| + }, |
| + Sweep: func(ctx context.Context) error { |
| + sw, err := control.Sweep(ctx) |
| + if err != nil { |
| + log.Printf("agent: instance sweep: %v (nested wakes refuse until the channel answers)", err) |
| + } else if len(sw.Units) > 0 { |
| + log.Printf("agent: swept %d instance unit(s) left from a previous agent", len(sw.Units)) |
| + } |
| + ev.log("swept", len(sw.Units), err == nil) |
| + return err |
| + }, |
| + Recover: func(ctx context.Context, swept bool) *layout.Pass { |
| + pass := newLayoutPass(reg, svc, dd, nestedOK, swept) |
| + pass.SocketDir, pass.SocketsGID = cfg.SocketDir, -1 // the fixture's run dir; no carlos-sockets group |
| + intent := startupRecoveryIntent(ctx, dd, openStore, "largebox") |
| + recoverLayoutAtStart(ctx, pass, intent) |
| + ev.log("recovered", intent) |
| + return pass |
| + }, |
| + Routes: reg.List, |
| + Route: reg.Get, |
| + Keys: largeBoxKeys{ev}, |
| + Slot: svc.PrepareScheduledToken, |
| + Publish: armedLayoutPass.Store, |
| + // The checkpoint: when Start runs, every hold marker's host must |
| + // already be fenced again and every journal complete. |
| + Start: func() { |
| + held, _ := layout.HoldMarkers(dd) |
| + fenced := 0 |
| + for _, h := range held { |
| + if svc.FenceGeneration(h) != 0 { |
| + fenced++ |
| + } |
| + } |
| + journals, _ := layout.JournalHosts(dd) |
| + ev.log("activator-start", "held="+strconv.Itoa(len(held)), "fenced="+strconv.Itoa(fenced), "journals="+strconv.Itoa(len(journals))) |
| + svc.Start() |
| + }, |
| + }.Run(startupCtx) |
| + go func() { |
| + <-startupDone |
| + ev.log("startup-done") |
| + }() |
| + // runAgent's worker boot pass, which waits on the same latch. |
| + go func() { |
| + select { |
| + case <-started: |
| + case <-startupCtx.Done(): |
| + return |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return |
| + } |
| + runWorkerBootPass(routes, func(rt registry.Route) error { |
| + err := svc.WakeWorker(rt) |
| + ev.log("worker-boot", rt.Host, err == nil) |
| + return err |
| + }) |
| + }() |
| + |
| + // Admissions from inside the agent that do not pass runAgent's own |
| + // gates, so they meet the activator's latch on their own: a worker |
| + // wake as an operator command or the sidecar-wake leg makes it, and |
| + // one scheduler tick through the real runner and deliverer with a |
| + // delivery already due. |
| + if rt, err := reg.Get(largeBoxWorker); err == nil && rt != nil { |
| + go func() { |
| + ev.log("worker-direct-begin", rt.Host) |
| + err := svc.WakeWorker(*rt) |
| + ev.log("worker-direct", rt.Host, err == nil) |
| + }() |
| + } |
| + runner := &schedule.Runner{ |
| + Reg: reg, Deliver: &activatorDeliverer{svc: svc}, Token: scheduleToken(dd, svc, reg), |
| + Now: time.Now, Timeout: largeBoxLatchBound + 15*time.Second, Box: "largebox", Build: "test", |
| + } |
| + runner.Tick(agentCtx) // delivers in the background, and returns |
| + ev.log("tick-done") |
| + |
| + edge.RunWithActivatorReady([]string{"-registry", regPath, "-acme-cache", cfg.AcmeDir, "-http", cfg.HTTP, "-https", cfg.HTTPS}, |
| + largeBoxActivator{Service: svc, ev: ev}, edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes), |
| + func(e *edge.Edge) { |
| + reload := func() { _ = e.Reload() } |
| + layoutReload.Store(&reload) |
| + }) |
| + |
| + // runAgent's shutdown, in its order. |
| + cancelStartup() |
| + joinCtx, joinCancel := context.WithTimeout(context.Background(), largeBoxJoinBound) |
| + began := time.Now() |
| + joined := joinAgentStartup(joinCtx, startupDone) |
| + joinCancel() |
| + ev.log("joined", joined, time.Since(began)) |
| + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| + svc.Shutdown(ctx) |
| + cancel() |
| + cancelAgent() |
| + |
| + deadline := time.Now().Add(2 * time.Second) |
| + for { |
| + left := largeBoxStartupGoroutines() |
| + if len(left) == 0 { |
| + ev.log("leak-free") |
| + return 0 |
| + } |
| + if time.Now().After(deadline) { |
| + log.Printf("largebox agent: %d goroutine(s) outlived shutdown:\n\n%s", len(left), strings.Join(left, "\n\n")) |
| + ev.log("leak", len(left)) |
| + return 0 |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| +} |
| + |
| +// largeBoxStartupGoroutines lists every goroutine but this one that runs, |
| +// or was started by, the startup sequence, the layout pass, the control |
| +// client, the activator, the scheduler, or this agent's own fakes, and |
| +// every server connection still open (a request held at the latch is |
| +// answered only once the activator shuts down, after the edge has stopped |
| +// waiting for it). The edge's process-lifetime loops (its registry watch, |
| +// its SIGHUP reader) are not startup's and are left alone. |
| +func largeBoxStartupGoroutines() []string { |
| + buf := make([]byte, 8<<20) |
| + n := runtime.Stack(buf, true) |
| + blocks := strings.Split(string(buf[:n]), "\n\n") |
| + markers := []string{ |
| + "host.activatorStart", "host.startActivator", "host.backfillInstanceKeys", "host.keyBackfillInSlot", |
| + "host.recoverLayoutAtStart", "host.startupRecoveryIntent", "host.runWorkerBootPass", |
| + "host.runLargeBoxAgent", "host.(*largeBoxBackend)", "host.largeBoxActivator", "host.(*activatorDeliverer)", |
| + "layout.(*Pass)", "instancectl.(*Client)", "activator.(*Service)", "schedule.(*Runner)", |
| + "net/http.(*conn).serve", |
| + } |
| + var left []string |
| + for _, g := range blocks[1:] { // [0] is the calling goroutine |
| + for _, m := range markers { |
| + if strings.Contains(g, m) { |
| + left = append(left, g) |
| + break |
| + } |
| + } |
| + } |
| + return left |
| +} |
| + |
| +// largeBoxActivator is the activator as the edge sees it, reporting each |
| +// instance request as it reaches it. |
| +type largeBoxActivator struct { |
| + *activator.Service |
| + ev *largeBoxEvents |
| +} |
| + |
| +func (a largeBoxActivator) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + if rt.Kind == "instance" { |
| + a.ev.log("enter", rt.Host, rt.Socket) |
| + } |
| + return a.Service.ServeHTTP(w, r, rt, proxy) |
| +} |
| + |
| +// largeBoxBackend is activator.Backend with no S3 and no processes: a wake |
| +// serves an app on the row's socket that names the host and the socket it |
| +// was started at, and a worker reports ready at once, as a child does |
| +// over the agent socket. It records every acquire, start and request |
| +// served, and any acquire the startup latch should have prevented. |
| +type largeBoxBackend struct { |
| + ev *largeBoxEvents |
| + started <-chan struct{} |
| + svc *activator.Service |
| +} |
| + |
| +func (b *largeBoxBackend) Acquire(_ context.Context, rt registry.Route) (activator.Lease, error) { |
| + select { |
| + case <-b.started: |
| + default: |
| + b.ev.log("violation", "acquire-before-ready", rt.Host) |
| + } |
| + b.ev.log("acquire", rt.Host) |
| + return nopLease{}, nil |
| +} |
| + |
| +func (b *largeBoxBackend) Restore(context.Context, registry.Route) (bool, error) { return false, nil } |
| +func (b *largeBoxBackend) Seed(context.Context, registry.Route) error { return nil } |
| + |
| +func (b *largeBoxBackend) Start(_ context.Context, rt registry.Route) (activator.Proc, string, error) { |
| + b.ev.log("start", rt.Host, rt.Socket) |
| + if err := os.MkdirAll(filepath.Dir(rt.Socket), 0o750); err != nil { |
| + return nil, "", err |
| + } |
| + ln, err := net.Listen("unix", rt.Socket) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + body := "host=" + rt.Host + " socket=" + rt.Socket |
| + p := &largeBoxProc{done: make(chan struct{})} |
| + p.srv = &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b.ev.log("served", rt.Host, r.URL.Path) |
| + io.WriteString(w, body) |
| + })} |
| + go func() { defer close(p.done); _ = p.srv.Serve(ln) }() |
| + return p, "", nil |
| +} |
| + |
| +func (b *largeBoxBackend) NextDue(context.Context, registry.Route) (int64, error) { return 0, nil } |
| +func (b *largeBoxBackend) HasSidecar(registry.Route) bool { return false } |
| +func (b *largeBoxBackend) StartSidecar(context.Context, registry.Route) (activator.Proc, error) { |
| + return nil, errors.New("largeBoxBackend: no co-spawned sidecars on this box") |
| +} |
| + |
| +func (b *largeBoxBackend) StartWorker(_ context.Context, rt registry.Route, _ map[string]string) (activator.Proc, string, error) { |
| + b.ev.log("worker-start", rt.Host) |
| + go b.svc.NoteWorkerReady(rt.Host, "") |
| + return &largeBoxWorkerProc{}, "", nil |
| +} |
| + |
| +type largeBoxProc struct { |
| + srv *http.Server |
| + done chan struct{} |
| + exited atomic.Bool |
| +} |
| + |
| +func (p *largeBoxProc) Stop(context.Context) error { |
| + _ = p.srv.Close() |
| + <-p.done |
| + p.exited.Store(true) |
| + return nil |
| +} |
| + |
| +func (p *largeBoxProc) Exited() bool { return p.exited.Load() } |
| + |
| +type largeBoxWorkerProc struct{ exited atomic.Bool } |
| + |
| +func (p *largeBoxWorkerProc) Stop(context.Context) error { p.exited.Store(true); return nil } |
| +func (p *largeBoxWorkerProc) Exited() bool { return p.exited.Load() } |
| + |
| +// largeBoxKeys is the exec backend's key writer as the key pass sees it: |
| +// every row it is handed already has its key. |
| +type largeBoxKeys struct{ ev *largeBoxEvents } |
| + |
| +func (k largeBoxKeys) BackfillInstanceKey(_ context.Context, rt registry.Route) (activator.InstanceKeyOutcome, error) { |
| + k.ev.log("key", rt.Host) |
| + return activator.InstanceKeyPresent, nil |
| +} |