| index 2a5db49b0..36fb7eebe 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -20,6 +20,7 @@ import ( |
| "net/http/httputil" |
| "os" |
| "path/filepath" |
| + "slices" |
| "strings" |
| "sync" |
| "sync/atomic" |
| @@ -1911,28 +1912,79 @@ func TestExecBackendHasSidecar(t *testing.T) { |
| } |
| } |
| |
| -// Start passes a --control-socket flag and starts a purge listener bound to |
| -// it whenever ExecBackend.Cache is set — the wiring that lets a spawned |
| -// instance invalidate its own cached responses right after a write. |
| +// stageRecordingLive stages host's live binary as a shell script that dumps |
| +// its argv and its CARLOS_CONTROL_SOCKET to two files and exits — the only way |
| +// to see what the child was ACTUALLY handed, argv and environment both. |
| +// Returns the two paths. |
| +func stageRecordingLive(t *testing.T, b *ExecBackend, host, ver string) (argvPath, envPath string) { |
| + t.Helper() |
| + dir := t.TempDir() |
| + argvPath = filepath.Join(dir, "argv") |
| + envPath = filepath.Join(dir, "env") |
| + script := "#!/bin/sh\n" + |
| + "printf '%s\\n' \"$@\" > '" + argvPath + "'\n" + |
| + "printf '%s\\n' \"${CARLOS_CONTROL_SOCKET-<unset>}\" > '" + envPath + "'\n" |
| + |
| + vdir := filepath.Join(b.OptDir, "versions", ver) |
| + if err := os.MkdirAll(vdir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + bin := filepath.Join(vdir, "app") |
| + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.Remove(b.liveLink(host)) |
| + if err := os.Symlink(bin, b.liveLink(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + return argvPath, envPath |
| +} |
| + |
| +// waitForFile polls until path exists (the child writes it concurrently with |
| +// Start's health wait) and returns its contents. |
| +func waitForFile(t *testing.T, path string, within time.Duration) string { |
| + t.Helper() |
| + deadline := time.Now().Add(within) |
| + for time.Now().Before(deadline) { |
| + if b, err := os.ReadFile(path); err == nil { |
| + return string(b) |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + t.Fatalf("%s never appeared within %s", path, within) |
| + return "" |
| +} |
| + |
| +// Start hands the spawned instance its control socket through the ENVIRONMENT |
| +// (CARLOS_CONTROL_SOCKET), never as a CLI flag, and starts a purge listener |
| +// bound to it whenever ExecBackend.Cache is set. |
| +// |
| +// The argv assertion is the regression guard: a --control-socket flag makes |
| +// every app binary that predates this feature exit 2 at startup (Go's flag |
| +// package with ExitOnError, which is what app binaries use), i.e. a |
| +// fleet-wide outage the moment the agent upgrades. An unknown environment |
| +// variable is simply ignored by such a binary. |
| // |
| // Deviation from the task brief's literal test: the brief asserted the |
| // control socket file still exists AFTER Start returns, even on the |
| -// health-wait error stageLive's fake binary (`#!/bin/sh\n`, never binds |
| -// rt.Socket) is expected to produce here. That's not actually true of the |
| -// implementation the same brief specifies — Start's pre-existing |
| -// health-wait-failure path calls p.Stop() before returning, and Step 3's |
| -// own childProc.Stop change closes the purge listener there, which (per |
| -// Go's net.Listener.Close on a unix socket the listener itself created) |
| -// unlinks the socket file. So by the time Start returns with an error, the |
| -// file is already gone — confirmed empirically, not a guess. This test |
| -// instead polls for the socket WHILE Start's health-wait is still running |
| -// (i.e. before its internal Stop cleanup fires), which is the invariant |
| -// that's actually guaranteed and is what "Start wires up the purge |
| -// listener" means in practice. |
| -func TestExecBackendStartPassesControlSocketFlagAndStartsPurgeListener(t *testing.T) { |
| +// health-wait error the fake binary (which never binds rt.Socket) is expected |
| +// to produce here. That's not actually true of the implementation the same |
| +// brief specifies — Start's pre-existing health-wait-failure path calls |
| +// p.Stop() before returning, and Step 3's own childProc.Stop change closes |
| +// the purge listener there, which (per Go's net.Listener.Close on a unix |
| +// socket the listener itself created) unlinks the socket file. So by the time |
| +// Start returns with an error, the file is already gone — confirmed |
| +// empirically, not a guess. This test instead polls for the socket WHILE |
| +// Start's health-wait is still running (i.e. before its internal Stop cleanup |
| +// fires), which is the invariant that's actually guaranteed and is what |
| +// "Start wires up the purge listener" means in practice. |
| +func TestExecBackendStartPassesControlSocketInEnvAndStartsPurgeListener(t *testing.T) { |
| b := testExecBackend(t) |
| b.Cache = edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes) |
| - stageLive(t, b, "control-socket.example.com", "v1") |
| + argvPath, envPath := stageRecordingLive(t, b, "control-socket.example.com", "v1") |
| |
| rt := registry.Route{ |
| Host: "control-socket.example.com", |
| @@ -1960,21 +2012,33 @@ func TestExecBackendStartPassesControlSocketFlagAndStartsPurgeListener(t *testin |
| } |
| time.Sleep(10 * time.Millisecond) |
| } |
| + |
| + gotArgv := strings.Fields(waitForFile(t, argvPath, time.Second)) |
| + gotEnv := strings.TrimSpace(waitForFile(t, envPath, time.Second)) |
| + |
| <-done |
| if proc != nil { |
| defer proc.Stop(context.Background()) |
| } |
| |
| + wantArgv := []string{"--socket", rt.Socket, "--db", filepath.Join(b.DataDir, rt.Host+".db")} |
| + if !slices.Equal(gotArgv, wantArgv) { |
| + t.Fatalf("child argv = %v, want %v — a flag the app binary doesn't parse makes it exit 2 at startup", gotArgv, wantArgv) |
| + } |
| + if gotEnv != wantControlSocket { |
| + t.Fatalf("child CARLOS_CONTROL_SOCKET = %q, want %q", gotEnv, wantControlSocket) |
| + } |
| if !found { |
| t.Fatalf("expected control socket %s to exist while Start's health-wait ran", wantControlSocket) |
| } |
| } |
| |
| // A nil ExecBackend.Cache disables the purge listener entirely — no control |
| -// socket, no error. |
| +// socket, no CARLOS_CONTROL_SOCKET in the child's environment (there is |
| +// nothing for it to talk to), no error. |
| func TestExecBackendStartWithNilCacheSkipsPurgeListener(t *testing.T) { |
| b := testExecBackend(t) // b.Cache left nil |
| - stageLive(t, b, "no-cache.example.com", "v1") |
| + _, envPath := stageRecordingLive(t, b, "no-cache.example.com", "v1") |
| |
| rt := registry.Route{ |
| Host: "no-cache.example.com", |
| @@ -1992,4 +2056,46 @@ func TestExecBackendStartWithNilCacheSkipsPurgeListener(t *testing.T) { |
| if _, err := os.Stat(rt.Socket + ".control"); err == nil { |
| t.Fatal("expected no control socket when ExecBackend.Cache is nil") |
| } |
| + if got := strings.TrimSpace(waitForFile(t, envPath, time.Second)); got != "<unset>" { |
| + t.Fatalf("child CARLOS_CONTROL_SOCKET = %q, want it unset when there is no cache", got) |
| + } |
| +} |
| + |
| +// Spawning a fresh process for a host is a deploy boundary: the code behind |
| +// the host just changed, so every response cached from the old process is |
| +// stale. Start drops them all before the new process serves anything — |
| +// without this, a redeploy keeps serving old HTML until the TTL expires. |
| +func TestExecBackendStartPurgesHostCacheOnSpawn(t *testing.T) { |
| + b := testExecBackend(t) |
| + cache := edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes) |
| + b.Cache = cache |
| + stageLive(t, b, "deploy.example.com", "v1") |
| + |
| + stale := edge.CacheKey{Host: "deploy.example.com", Method: "GET", Path: "/event"} |
| + otherHost := edge.CacheKey{Host: "bystander.example.com", Method: "GET", Path: "/event"} |
| + for _, k := range []edge.CacheKey{stale, otherHost} { |
| + cache.Put(k, &edge.CachedResponse{ |
| + StatusCode: 200, Header: http.Header{}, Body: []byte("old html"), |
| + StoredAt: time.Now(), TTL: time.Minute, |
| + }) |
| + } |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + rt := registry.Route{ |
| + Host: "deploy.example.com", |
| + Kind: "instance", |
| + Socket: filepath.Join(t.TempDir(), "instance.sock"), |
| + } |
| + proc, _, _ := b.Start(ctx, rt) // health-wait failure is expected; the spawn still happened |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + |
| + if _, ok := cache.Get(stale); ok { |
| + t.Fatal("spawning a fresh process for a host must drop that host's cached responses") |
| + } |
| + if _, ok := cache.Get(otherHost); !ok { |
| + t.Fatal("another host's cached responses must survive a spawn") |
| + } |
| } |