package activator import ( "context" "database/sql" "errors" "os" "os/exec" "path/filepath" "reflect" "runtime" "strings" "sync" "sync/atomic" "testing" "time" _ "modernc.org/sqlite" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/edge" "github.com/carlosframework/platform/internal/lease" "github.com/carlosframework/platform/internal/registry" ) // TestExecBackendTierSeam: a nil Tier means the default tier, and a set // Tier is consulted with the route. The cgroup manager is nil here (no // delegation in a test), so this pins the seam's contract, not the write. func TestExecBackendTierSeam(t *testing.T) { var b ExecBackend rt := registry.Route{Host: "h.example.com"} if got := b.tier(rt); got != cgroup.TierDefault { t.Fatalf("nil Tier: %v, want TierDefault", got) } b.Tier = func(registry.Route) cgroup.Limit { return cgroup.TierChildren } if got := b.tier(rt); got != cgroup.TierChildren { t.Fatalf("set Tier: %v, want TierChildren", got) } } // TestSpawnInCgroupAppliesTheTier: the seam every spawn site uses really // writes the route's tier into the host cgroup — a regression that swapped // a site back to a bare Cgroups.Start(…, cgroup.Limit{}, …) compiles fine // and this is what catches the class. Real cgroup.SetupAt against a // temp-dir fixture; the spawn is /bin/true. func TestSpawnInCgroupAppliesTheTier(t *testing.T) { root := t.TempDir() for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} { if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil { t.Fatal(err) } } m, err := cgroup.SetupAt(root, t.Logf) if err != nil { t.Fatal(err) } b := ExecBackend{Cgroups: m, Tier: func(registry.Route) cgroup.Limit { return cgroup.TierChildren }} rt := registry.Route{Host: "h.example.com"} cmd, _, err := b.spawnInCgroup(rt, func() *exec.Cmd { return exec.Command("/bin/true") }) if err != nil { t.Fatal(err) } _ = cmd.Wait() got, err := os.ReadFile(filepath.Join(root, "carlos-exec@h.example.com", "memory.max")) if err != nil { t.Fatalf("memory.max not written: %v", err) } if string(got) != "1073741824" { t.Errorf("memory.max = %q, want the children tier", got) } } // The box's placement code reaches every exec child as CARLOS_REGION (issue // #329) — agent-owned in both directions: set from ExecBackend.Region, and // STRIPPED when the backend has none, even when a tenant's own env file // tries to supply it. Same rule and same reason as CARLOS_CONTROL_SOCKET / // CARLOS_PURGE: an app must not be told it runs somewhere the agent never // vouched for. func TestExecBackendStartChildRegion(t *testing.T) { for _, tc := range []struct { name, backendRegion, want string wantSet bool }{ {"set from the backend", "uk", "uk", true}, {"stripped when the backend has none", "", "", false}, } { t.Run(tc.name, func(t *testing.T) { b := testExecBackend(t) b.Region = tc.backendRegion host := "region-env.example.com" _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") // A tenant-supplied value that must never survive as-is. if err := os.WriteFile(filepath.Join(b.DataDir, host+".config.env"), []byte("CARLOS_REGION=forged\n"), 0o600); err != nil { t.Fatal(err) } rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "instance.sock")} ctx, cancel := recordedCtx(2*time.Second, envPath) defer cancel() proc, _, _ := b.Start(ctx, rt) // fake binary never binds the socket; health-wait error expected if proc != nil { defer proc.Stop(context.Background()) } child := map[string]string{} for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") { if k, v, ok := strings.Cut(line, "="); ok { child[k] = v } } got, ok := child["CARLOS_REGION"] if ok != tc.wantSet || got != tc.want { t.Fatalf("child CARLOS_REGION = %q (set=%v), want %q (set=%v)", got, ok, tc.want, tc.wantSet) } }) } } // Every exec child is told which host it is, in its environment, from the // row (discussion #256): an app that used to read its hostname off its // socket's file name has nothing to read nested (app.sock). The variable // is agent-owned: a tenant's own env file cannot make an app another host. func TestExecBackendStartChildHandsTheHost(t *testing.T) { b := testExecBackend(t) host := "host-env.example.com" _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") if err := os.WriteFile(filepath.Join(b.DataDir, host+".env"), []byte("CARLOS_HOST=forged.example.com\n"), 0o600); err != nil { t.Fatal(err) } rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "instance.sock")} ctx, cancel := recordedCtx(2*time.Second, envPath) defer cancel() proc, _, _ := b.Start(ctx, rt) // fake binary never binds the socket; health-wait error expected if proc != nil { defer proc.Stop(context.Background()) } child := map[string]string{} for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") { if k, v, ok := strings.Cut(line, "="); ok { child[k] = v } } if got := child["CARLOS_HOST"]; got != host { t.Fatalf("child CARLOS_HOST = %q, want %q", got, host) } } // The staging fallback (issue #331): a host whose ordinary prefix is empty // restores from a move's staged copy, promoted server-side and dropped // after a clean restore. A staged copy present beside an own lineage wins: // it is promoted over the ordinary prefix (the boomerang case below; // ruling R-STAGEDREAD-2). func TestRestoreStagingFallback(t *testing.T) { ctx := context.Background() b := testExecBackend(t) f := newFakeMirrorStore() b.Mirror = f host := "moved.example.com" rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} // restoreReplica is stubbed for the WHOLE test (the real one dials S3): // it restores — materializing a genuinely valid SQLite file, so CheckDB // passes — exactly when the fake bucket holds objects under the prefix // it was pointed at, which is the promotion's observable effect. var restoredFrom []string oldRestore := restoreReplica restoreReplica = func(_ context.Context, dbPath, bucket, prefix, region string) (bool, error) { objs, _ := f.List(context.Background(), bucket, prefix) if len(objs) == 0 { return false, nil } restoredFrom = append(restoredFrom, bucket+"/"+prefix) return true, writeMinimalSQLite(dbPath) } defer func() { restoreReplica = oldRestore }() // Nothing staged, nothing own: plain fresh boot (restored=false→fresh=true). fresh, err := b.Restore(ctx, rt) if err != nil || !fresh { t.Fatalf("empty everything: fresh=%v err=%v, want fresh boot", fresh, err) } // Staged copy present, own prefix empty: promoted then restored. f.put(b.Cfg.Bucket, "moves/"+host+"/generations/a/snapshot", "e1") _ = os.Remove(filepath.Join(b.DataDir, host+".db")) fresh, err = b.Restore(ctx, rt) if err != nil { t.Fatalf("staged restore: %v", err) } if fresh { t.Fatal("staged restore reported a fresh boot") } if len(restoredFrom) != 1 || restoredFrom[0] != b.Cfg.Bucket+"/"+b.Cfg.hibernatePrefix(host) { t.Fatalf("restored from %v, want the promoted ordinary prefix %s", restoredFrom, b.Cfg.hibernatePrefix(host)) } own, _ := f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) if len(own) != 1 { t.Fatalf("promotion left own prefix = %v", own) } staged, _ := f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") if len(staged) != 0 { t.Fatalf("staging not dropped after a clean restore: %v", staged) } // STAGING WINS (review round 1): a fresh staged copy beside a stale // own lineage is the boomerang landing — the staged bytes replace the // own prefix and are what gets restored. f.put(b.Cfg.Bucket, "moves/"+host+"/generations/b/newer-snapshot", "e9") _ = os.Remove(filepath.Join(b.DataDir, host+".db")) restoredFrom = nil if _, err := b.Restore(ctx, rt); err != nil { t.Fatal(err) } own, _ = f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) if len(own) != 1 { t.Fatalf("own prefix after boomerang promote = %v, want exactly the staged copy", own) } for k := range own { if !strings.Contains(k, "newer-snapshot") { t.Fatalf("own prefix kept the stale lineage: %v", own) } } staged, _ = f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") if len(staged) != 0 { t.Fatalf("staging not dropped after the boomerang restore: %v", staged) } // A staging-fallback ERROR fails the wake — never a fresh boot + seed // that would shadow the staged data forever. f.put(b.Cfg.Bucket, "moves/"+host+"/generations/c/snapshot", "eA") f.failList = true _ = os.Remove(filepath.Join(b.DataDir, host+".db")) if _, err := b.Restore(ctx, rt); err == nil { t.Fatal("List failure in the staging fallback did not fail the restore") } f.failList = false } // A move's copy receipts live in the SOURCE box's own bucket at // MoveReceiptsKey, and this box may be that source for an earlier move of // the same host (a move back). Landing must never take them for data: // they are not under MovesPrefix, so they are never promoted, and not // under the hibernate prefix, so they are never restored from or swept. func TestRestoreIgnoresMoveReceipts(t *testing.T) { ctx := context.Background() b := testExecBackend(t) f := newFakeMirrorStore() b.Mirror = f host := "moved.example.com" rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} receipts := MoveReceiptsKey(host) if strings.HasPrefix(receipts, MovesPrefix(host)) || strings.HasPrefix(receipts, b.Cfg.hibernatePrefix(host)+"/") || strings.HasPrefix(receipts, b.Cfg.Prefix+"/") { t.Fatalf("receipts key %q sits under a data prefix", receipts) } var restoredFrom []string oldRestore := restoreReplica restoreReplica = func(_ context.Context, dbPath, bucket, prefix, region string) (bool, error) { objs, _ := f.List(context.Background(), bucket, prefix) if len(objs) == 0 { return false, nil } restoredFrom = append(restoredFrom, bucket+"/"+prefix) return true, writeMinimalSQLite(dbPath) } defer func() { restoreReplica = oldRestore }() // Receipts alone: a fresh boot, nothing promoted, receipts untouched. f.put(b.Cfg.Bucket, receipts, "r1") fresh, err := b.Restore(ctx, rt) if err != nil || !fresh || len(restoredFrom) != 0 { t.Fatalf("receipts alone: fresh=%v err=%v restored=%v, want a fresh boot", fresh, err, restoredFrom) } // Beside a staged copy: only the staged data is promoted. f.put(b.Cfg.Bucket, "moves/"+host+"/ltx/0/a.ltx", "e1") _ = os.Remove(filepath.Join(b.DataDir, host+".db")) if _, err := b.Restore(ctx, rt); err != nil { t.Fatal(err) } own, _ := f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) if len(own) != 1 { t.Fatalf("own prefix after promotion = %v, want only the staged object", own) } if _, ok := f.buckets[b.Cfg.Bucket][receipts]; !ok { t.Fatal("landing deleted the receipts of a move it did not make") } } // fakeMirrorStore is the test double for lease.MirrorStore, bucket→key→object. // A mirror round copies and deletes side by side, so every method holds mu. type fakeMirrorStore struct { mu sync.Mutex buckets map[string]map[string]lease.MirrorObject failList bool } func newFakeMirrorStore() *fakeMirrorStore { return &fakeMirrorStore{buckets: map[string]map[string]lease.MirrorObject{}} } func (f *fakeMirrorStore) put(bucket, key, etag string) { f.mu.Lock() defer f.mu.Unlock() f.putLocked(bucket, key, etag) } func (f *fakeMirrorStore) putLocked(bucket, key, etag string) { if f.buckets[bucket] == nil { f.buckets[bucket] = map[string]lease.MirrorObject{} } f.buckets[bucket][key] = lease.MirrorObject{ETag: etag} } func (f *fakeMirrorStore) List(_ context.Context, bucket, prefix string) (map[string]lease.MirrorObject, error) { f.mu.Lock() defer f.mu.Unlock() if f.failList { return nil, context.DeadlineExceeded } out := map[string]lease.MirrorObject{} for k, v := range f.buckets[bucket] { if strings.HasPrefix(k, prefix) { out[k] = v } } return out, nil } func (f *fakeMirrorStore) Copy(_ context.Context, srcBucket, srcKey, srcETag, dstBucket, dstKey string) (string, error) { f.mu.Lock() defer f.mu.Unlock() o, ok := f.buckets[srcBucket][srcKey] if !ok || (srcETag != "" && o.ETag != srcETag) { return "", lease.ErrCopySourceChanged } if f.buckets[dstBucket] == nil { f.buckets[dstBucket] = map[string]lease.MirrorObject{} } f.buckets[dstBucket][dstKey] = o return o.ETag, nil } func (f *fakeMirrorStore) Get(_ context.Context, bucket, key string) ([]byte, error) { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.buckets[bucket][key]; !ok { return nil, lease.ErrMirrorNotFound } return []byte("{}"), nil } func (f *fakeMirrorStore) Put(_ context.Context, bucket, key string, _ []byte) error { f.mu.Lock() defer f.mu.Unlock() f.putLocked(bucket, key, "receipts") return nil } func (f *fakeMirrorStore) Delete(_ context.Context, bucket, key string) error { f.mu.Lock() defer f.mu.Unlock() delete(f.buckets[bucket], key) return nil } // writeMinimalSQLite creates a genuinely valid empty SQLite database so // lease.CheckDB passes on the stubbed restore's output. func writeMinimalSQLite(dbPath string) error { db, err := sql.Open("sqlite", dbPath) if err != nil { return err } if _, err := db.Exec("create table t(x)"); err != nil { return err } return db.Close() } func TestLaunchEnvironmentComposesTheSpawnEnvironment(t *testing.T) { b := &ExecBackend{DataDir: t.TempDir(), Region: "ie"} // Cache nil: no purge channel on this box rt := registry.Route{Host: "a.go.example.com", Socket: filepath.Join(b.DataDir, "a.sock")} if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("MINE=1\nCARLOS_ADMIN_TOKEN=t\nCARLOS_PURGE=header\nCARLOS_REGION=fake\n"), 0o600); err != nil { t.Fatal(err) } t.Setenv("CARLOS_SECRETS_KEY", "never") t.Setenv("UNRELATED", "yes") // The rendered configuration file (the path instanceEnv reads first) // renders MINE=0; the instance's own .env overrides it, so MINE is // reported as shadowed (spec §7.3 T3). if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte("MINE=0\nFROM_CONFIG=c\n"), 0o600); err != nil { t.Fatal(err) } env, shadowed, err := b.LaunchEnvironment(rt) if err != nil { t.Fatal(err) } if len(shadowed) != 1 || shadowed[0] != "MINE" || env["FROM_CONFIG"] != "c" { t.Fatalf("shadowed names %v, want [MINE]; FROM_CONFIG: want set from the config file, got set=%v", shadowed, env["FROM_CONFIG"] != "") } if env["MINE"] != "1" || env["UNRELATED"] != "yes" || env["CARLOS_SECRETS_KEY"] != "" || env["CARLOS_ADMIN_TOKEN"] != "t" { t.Fatalf("launch env: MINE matches the own file: %v; UNRELATED passed through: %v; CARLOS_SECRETS_KEY: want unset, got set=%v; CARLOS_ADMIN_TOKEN matches the own file: %v", env["MINE"] == "1", env["UNRELATED"] == "yes", env["CARLOS_SECRETS_KEY"] != "", env["CARLOS_ADMIN_TOKEN"] == "t") } // The agent-owned layer, exactly as agentOwnedVars composes it: the // tenant's region and purge values are overridden or removed, and the // host is the row's (#256). if env["CARLOS_HOST"] != rt.Host { t.Fatalf("CARLOS_HOST = %q, want the row's host %q", env["CARLOS_HOST"], rt.Host) } if env["CARLOS_REGION"] != "ie" || env["CARLOS_CONTROL_SOCKET"] != rt.Socket+".control" { t.Fatalf("agent-owned vars: CARLOS_REGION is the backend's: %v; CARLOS_CONTROL_SOCKET is the route's control path: %v", env["CARLOS_REGION"] == "ie", env["CARLOS_CONTROL_SOCKET"] == rt.Socket+".control") } if _, ok := env["CARLOS_PURGE"]; ok { t.Fatal("CARLOS_PURGE survived on a box without a cache") } b.Cache = edge.NewCache(16, 1<<20) if env, _, _ = b.LaunchEnvironment(rt); env["CARLOS_PURGE"] != "header" { t.Fatalf("CARLOS_PURGE on a box with a cache: want header, got set=%v", env["CARLOS_PURGE"] != "") } // A box with no region strips the tenant's value rather than keeping it. b.Region = "" if env, _, _ = b.LaunchEnvironment(rt); env["CARLOS_REGION"] != "" { t.Fatal("CARLOS_REGION: want unset on a box with no region, got set") } } // An empty CARLOS_INSTANCE_KEY in the instance's own file supplies no key // (instance-key plan ruling 13): the merge keeps the configured key, and // the launch report does not list the empty one as shadowing it. A // non-empty own key still wins and is still reported. func TestEmptyOwnInstanceKeyShadowsNothing(t *testing.T) { cfg := map[string]string{"CARLOS_INSTANCE_KEY": "cfg"} if got := mergeEnv(cfg, map[string]string{"CARLOS_INSTANCE_KEY": ""}); got["CARLOS_INSTANCE_KEY"] != "cfg" { t.Fatalf("mergeEnv with an empty own key: CARLOS_INSTANCE_KEY = %q, want the configured key", got["CARLOS_INSTANCE_KEY"]) } b := &ExecBackend{DataDir: t.TempDir(), Region: "ie"} rt := registry.Route{Host: "k.go.example.com", Socket: filepath.Join(b.DataDir, "k.sock")} write := func(name, body string) { t.Helper() if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+name), []byte(body), 0o600); err != nil { t.Fatal(err) } } write(".config.env", "CARLOS_INSTANCE_KEY=cfg\nMINE=0\n") write(".env", "CARLOS_INSTANCE_KEY=\nMINE=1\n") env, shadowed, err := b.LaunchEnvironment(rt) if err != nil { t.Fatal(err) } if env["CARLOS_INSTANCE_KEY"] != "cfg" || len(shadowed) != 1 || shadowed[0] != "MINE" { t.Fatalf("empty own key: CARLOS_INSTANCE_KEY is the configured key: %v; shadowed %v, want [MINE]", env["CARLOS_INSTANCE_KEY"] == "cfg", shadowed) } write(".env", "CARLOS_INSTANCE_KEY=own\nMINE=1\n") if env, shadowed, err = b.LaunchEnvironment(rt); err != nil || env["CARLOS_INSTANCE_KEY"] != "own" || strings.Join(shadowed, ",") != "CARLOS_INSTANCE_KEY,MINE" { t.Fatalf("non-empty own key: CARLOS_INSTANCE_KEY is the own key: %v; shadowed %v, want [CARLOS_INSTANCE_KEY MINE]; err %v", env["CARLOS_INSTANCE_KEY"] == "own", shadowed, err) } } // With neither file present the composition is the agent-owned layer over // the agent's own environment, no error and nothing shadowed. func TestLaunchEnvironmentNoFiles(t *testing.T) { b := &ExecBackend{DataDir: t.TempDir(), Region: "ie"} rt := registry.Route{Host: "b.go.example.com", Socket: filepath.Join(b.DataDir, "b.sock")} env, shadowed, err := b.LaunchEnvironment(rt) if err != nil || len(shadowed) != 0 || env["CARLOS_REGION"] != "ie" { t.Fatalf("CARLOS_REGION is the backend's: %v; shadowed %v; err %v", env["CARLOS_REGION"] == "ie", shadowed, err) } } // recordingStrict is a strictProc that starts the command for real and // records each call in order, so a test can see the spawn went through the // strict handle and what happened to it on exit. type recordingStrict struct { mu sync.Mutex calls []string deadline bool started chan struct{} closed chan struct{} } func newRecordingStrict() *recordingStrict { return &recordingStrict{started: make(chan struct{}), closed: make(chan struct{})} } func (r *recordingStrict) note(s string) { r.mu.Lock(); r.calls = append(r.calls, s); r.mu.Unlock() } func (r *recordingStrict) Start(cmd *exec.Cmd) error { r.note("start") defer close(r.started) return cmd.Start() } func (r *recordingStrict) KillAndEmpty(ctx context.Context) error { _, ok := ctx.Deadline() r.mu.Lock() r.deadline = ok r.mu.Unlock() r.note("kill-and-empty") return nil } func (r *recordingStrict) Close() error { r.note("close") close(r.closed) return nil } func (r *recordingStrict) seen() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.calls...) } // TestSpawnUnderAMoveIsStrictOrRefused (moves design §5.3): a host Strict // names spawns only through the strict handle. A handle that cannot be // prepared refuses the spawn with ErrStrictRequired and nothing runs; a // prepared one starts the child and, on exit, is drained (KillAndEmpty // under a deadline) and then closed. With Strict false or unset the plain // spawn runs exactly as before and the seam is never asked. func TestSpawnUnderAMoveIsStrictOrRefused(t *testing.T) { old := spawnHealthTimeout spawnHealthTimeout = 300 * time.Millisecond t.Cleanup(func() { spawnHealthTimeout = old }) host := "strict.example.com" // argvPath is the recorder's first file: once it exists and the child // has exited, the health wait the recorder never answers is over. start := func(t *testing.T, b *ExecBackend, argvPath string) error { t.Helper() rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} ctx, cancel := recordedCtx(2*time.Second, argvPath) defer cancel() proc, _, err := b.Start(ctx, rt) // the fake binary never binds: a health error is expected if proc != nil { _ = proc.Stop(context.Background()) } return err } noSpawn := func(t *testing.T, argvPath string) { t.Helper() awaitChildFile(argvPath, 200*time.Millisecond) // until no child is left to write it if _, err := os.Stat(argvPath); !os.IsNotExist(err) { t.Fatalf("the child ran: %v", err) } } t.Run("a handle that cannot be prepared refuses the spawn", func(t *testing.T) { b := testExecBackend(t) argvPath, _ := stageRecordingLive(t, b, host, "v1") b.Strict = func(h string) bool { return h == host } b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { return nil, cgroup.ErrStrictRefused } if err := start(t, b, argvPath); !errors.Is(err, ErrStrictRequired) { t.Fatalf("Start: %v, want ErrStrictRequired", err) } noSpawn(t, argvPath) }) t.Run("no delegation and no seam refuses the spawn", func(t *testing.T) { b := testExecBackend(t) // Cgroups nil argvPath, _ := stageRecordingLive(t, b, host, "v1") b.Strict = func(string) bool { return true } if err := start(t, b, argvPath); !errors.Is(err, ErrStrictRequired) { t.Fatalf("Start: %v, want ErrStrictRequired", err) } noSpawn(t, argvPath) }) t.Run("a handle whose Start fails refuses the spawn and is closed", func(t *testing.T) { b := testExecBackend(t) argvPath, _ := stageRecordingLive(t, b, host, "v1") b.Strict = func(string) bool { return true } rec := newRecordingStrict() failing := &failingStrict{recordingStrict: rec} b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { return failing, nil } if err := start(t, b, argvPath); !errors.Is(err, ErrStrictRequired) { t.Fatalf("Start: %v, want ErrStrictRequired", err) } noSpawn(t, argvPath) if got := strings.Join(rec.seen(), ","); got != "start,kill-and-empty,close" { t.Fatalf("handle calls = %s", got) } }) t.Run("a prepared handle starts the child and is drained then closed on exit", func(t *testing.T) { b := testExecBackend(t) argvPath, _ := stageRecordingLive(t, b, host, "v1") b.Strict = func(string) bool { return true } rec := newRecordingStrict() var gotHost string var gotLimit cgroup.Limit b.Tier = func(registry.Route) cgroup.Limit { return cgroup.TierChildren } b.prepareStrict = func(h string, l cgroup.Limit) (strictProc, error) { gotHost, gotLimit = h, l; return rec, nil } _ = start(t, b, argvPath) waitForFile(t, argvPath, time.Second) select { case <-rec.closed: case <-time.After(5 * time.Second): t.Fatalf("the handle was never closed: %v", rec.seen()) } if got := strings.Join(rec.seen(), ","); got != "start,kill-and-empty,close" { t.Fatalf("handle calls = %s, want start, kill-and-empty, close", got) } if !rec.deadline { t.Fatal("KillAndEmpty was called without a deadline") } if gotHost != host || gotLimit != cgroup.TierChildren { t.Fatalf("prepared %q at %v, want %q at the route's tier", gotHost, gotLimit, host) } }) for name, strict := range map[string]func(string) bool{ "Strict false": func(string) bool { return false }, "Strict unset": nil, } { t.Run(name+" runs the plain spawn", func(t *testing.T) { b := testExecBackend(t) argvPath, _ := stageRecordingLive(t, b, host, "v1") b.Strict = strict asked := false b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { asked = true return nil, errors.New("must not be asked") } _ = start(t, b, argvPath) waitForFile(t, argvPath, time.Second) if asked { t.Fatal("the strict seam was asked for a host not in a move") } }) } } // failingStrict is a recordingStrict whose Start fails without starting // anything, the way Prepared.Start refuses a populated group. type failingStrict struct{ *recordingStrict } func (f *failingStrict) Start(*exec.Cmd) error { f.note("start") return cgroup.ErrStrictRefused } // delegatedManager is a cgroup manager over a temp-dir fixture: delegation // "exists" (Root() is set), but the test process is not placed in it. func delegatedManager(t *testing.T) *cgroup.Manager { t.Helper() root := t.TempDir() for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} { if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil { t.Fatal(err) } } m, err := cgroup.SetupAt(root, t.Logf) if err != nil { t.Fatal(err) } return m } // TestNextSpawnStrict is S2's input: OK only when the host is in a move // (Strict), the box has a delegated cgroup subtree, PrepareStrict's own // runtime preflight passes (cgroup.Manager.StrictPreflight, the same // function PrepareStrict runs, so S2 cannot approve a box PrepareStrict // refuses), and no earlier strict group of the host awaits cleanup. func TestNextSpawnStrict(t *testing.T) { m := delegatedManager(t) host := "s2.example.com" yes := func(string) bool { return true } ok := func() error { return nil } for name, tc := range map[string]struct { strict func(string) bool cg *cgroup.Manager preflight func() error ok bool inWords string }{ "in a move, delegated, preflight passes": {yes, m, ok, true, ""}, "not in a move": {func(string) bool { return false }, m, ok, false, "not strict"}, "Strict unset": {nil, m, ok, false, "not strict"}, "no delegation": {yes, nil, ok, false, "delegated"}, // The real preflight, no seam: delegation exists, but this test // process is not placed in the fixture's subtree. "delegated, agent misplaced": {yes, m, nil, false, "not placed"}, "preflight says unsupported": {yes, m, func() error { return cgroup.ErrStrictUnsupported }, false, "cannot spawn strictly"}, } { t.Run(name, func(t *testing.T) { if tc.preflight == nil && runtime.GOOS != "linux" { t.Skip("the real preflight answers unsupported off Linux") } b := &ExecBackend{Strict: tc.strict, Cgroups: tc.cg, strictPreflight: tc.preflight} c := b.NextSpawnStrict(host) if c.OK != tc.ok || (!c.OK && !strings.Contains(c.Reason, tc.inWords)) || (c.OK && c.Reason != "") { t.Fatalf("NextSpawnStrict = %+v, want ok=%v naming %q", c, tc.ok, tc.inWords) } }) } } // flakyStrict is a recordingStrict whose KillAndEmpty fails its first // killFails calls and whose Close fails its first closeFails calls, the // way a drain that outlives its deadline or a group still settling does. type flakyStrict struct { *recordingStrict killFails, closeFails int } func (f *flakyStrict) KillAndEmpty(ctx context.Context) error { f.mu.Lock() fail := f.killFails > 0 if fail { f.killFails-- } f.mu.Unlock() if fail { f.note("kill-and-empty failed") return cgroup.ErrStrictDeadline } return f.recordingStrict.KillAndEmpty(ctx) } func (f *flakyStrict) Close() error { f.mu.Lock() fail := f.closeFails > 0 if fail { f.closeFails-- } f.mu.Unlock() if fail { f.note("close failed") return cgroup.ErrStrictRefused } return f.recordingStrict.Close() } // TestStrictCleanupIsRetriedAndRefusesUntilItSucceeds: a strict group whose // drain or close fails on exit keeps its handle; cleanup is retried on a // bounded schedule, and until it succeeds the host's strict spawns are // refused (and S2 says so) without preparing a new group. Once it // succeeds, strict spawns work again. A drain that succeeded is not // repeated when only the close failed. func TestStrictCleanupIsRetriedAndRefusesUntilItSucceeds(t *testing.T) { old := spawnHealthTimeout spawnHealthTimeout = 300 * time.Millisecond t.Cleanup(func() { spawnHealthTimeout = old }) host := "retry.example.com" for name, tc := range map[string]struct { killFails, closeFails int want string }{ "the drain fails once": {1, 0, "start,kill-and-empty failed,kill-and-empty,close"}, "the close fails once": {0, 1, "start,kill-and-empty,close failed,close"}, } { t.Run(name, func(t *testing.T) { oldBackoff := strictCleanupBackoff strictCleanupBackoff = []time.Duration{400 * time.Millisecond, 400 * time.Millisecond} t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) b := testExecBackend(t) b.Cgroups = delegatedManager(t) b.strictPreflight = func() error { return nil } argvPath, _ := stageRecordingLive(t, b, host, "v1") b.Strict = func(string) bool { return true } var mu sync.Mutex var handles []*flakyStrict b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { mu.Lock() defer mu.Unlock() h := &flakyStrict{recordingStrict: newRecordingStrict()} if len(handles) == 0 { h.killFails, h.closeFails = tc.killFails, tc.closeFails } handles = append(handles, h) return h, nil } prepared := func() int { mu.Lock(); defer mu.Unlock(); return len(handles) } startOnce := func() error { rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} ctx, cancel := recordedCtx(2*time.Second, argvPath) // the recorder ran and exited defer cancel() proc, _, err := b.Start(ctx, rt) if proc != nil { _ = proc.Stop(context.Background()) } return err } _ = startOnce() waitForFile(t, argvPath, time.Second) deadline := time.Now().Add(2 * time.Second) for !b.strictCleanupPending(host) { if time.Now().After(deadline) { t.Fatalf("no cleanup pending after a failed drain: %v", handles[0].seen()) } time.Sleep(5 * time.Millisecond) } if c := b.NextSpawnStrict(host); c.OK || !strings.Contains(c.Reason, "cleaned up") { t.Fatalf("S2 while cleanup is pending: %+v", c) } if err := startOnce(); !errors.Is(err, ErrStrictRequired) { t.Fatalf("spawn while cleanup is pending: %v, want ErrStrictRequired", err) } if n := prepared(); n != 1 { t.Fatalf("a new strict group was prepared while the old one awaited cleanup (%d)", n) } deadline = time.Now().Add(3 * time.Second) for b.strictCleanupPending(host) { if time.Now().After(deadline) { t.Fatalf("cleanup never succeeded on retry: %v", handles[0].seen()) } time.Sleep(5 * time.Millisecond) } if got := strings.Join(handles[0].seen(), ","); got != tc.want { t.Fatalf("first handle's calls = %s, want %s", got, tc.want) } if c := b.NextSpawnStrict(host); !c.OK { t.Fatalf("S2 after cleanup: %+v", c) } if err := os.Remove(argvPath); err != nil { t.Fatal(err) } _ = startOnce() waitForFile(t, argvPath, time.Second) if n := prepared(); n != 2 { t.Fatalf("strict groups prepared = %d, want a second one after cleanup", n) } }) } } // TestStrictCleanupGivesUpButStaysRefused: the retry schedule is bounded. // When it runs out the handle is kept and the host's strict spawns stay // refused: a group that never drained may still hold a process. func TestStrictCleanupGivesUpButStaysRefused(t *testing.T) { oldBackoff := strictCleanupBackoff strictCleanupBackoff = []time.Duration{10 * time.Millisecond, 10 * time.Millisecond} t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) host := "stuck.example.com" b := testExecBackend(t) h := &flakyStrict{recordingStrict: newRecordingStrict(), killFails: 100} b.Strict = func(string) bool { return true } b.settleStrict(host, b.ownStrictForTest(host, h)) time.Sleep(200 * time.Millisecond) if got := strings.Count(strings.Join(h.seen(), ","), "kill-and-empty failed"); got != 3 { t.Fatalf("drain attempts = %d, want the first and two retries", got) } if !b.strictCleanupPending(host) { t.Fatal("the handle was dropped after the schedule ran out") } b.Strict = func(string) bool { return true } b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { t.Fatal("a strict group was prepared for a host awaiting cleanup") return nil, nil } if _, _, err := b.spawnInCgroup(registry.Route{Host: host}, func() *exec.Cmd { return exec.Command("/bin/true") }); !errors.Is(err, ErrStrictRequired) { t.Fatalf("spawn: %v, want ErrStrictRequired", err) } } // ownStrictForTest registers sp as host's strict owner, as a strict spawn // does, so a test can drive settleStrict directly. func (b *ExecBackend) ownStrictForTest(host string, sp strictProc) *strictOwner { g := b.gate(host) g.mu.Lock() defer g.mu.Unlock() g.owner = &strictOwner{sp: sp} return g.owner } // dirStrict is a strict handle whose identity is a real directory: the // host's group directory under a temp-dir cgroup fixture. Like // cgroup.Prepared's identityOK, once that directory is removed (or // replaced) the handle can neither drain nor close again, for ever. It // models what an exit-path CleanupHost would do to a retained handle. type dirStrict struct { *recordingStrict dir string id os.FileInfo killFails, closeFails int } func newDirStrict(t *testing.T, dir string) *dirStrict { t.Helper() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } id, err := os.Stat(dir) if err != nil { t.Fatal(err) } return &dirStrict{recordingStrict: newRecordingStrict(), dir: dir, id: id} } func (d *dirStrict) identityOK() bool { now, err := os.Stat(d.dir) return err == nil && os.SameFile(now, d.id) } func (d *dirStrict) KillAndEmpty(ctx context.Context) error { if !d.identityOK() { d.note("kill refused: group gone") return cgroup.ErrStrictRefused } d.mu.Lock() fail := d.killFails > 0 if fail { d.killFails-- } d.mu.Unlock() if fail { d.note("kill-and-empty timed out") return cgroup.ErrStrictDeadline } return d.recordingStrict.KillAndEmpty(ctx) } func (d *dirStrict) Close() error { if !d.identityOK() { d.note("close refused: group gone") return cgroup.ErrStrictRefused } d.mu.Lock() fail := d.closeFails > 0 if fail { d.closeFails-- } d.mu.Unlock() if fail { d.note("close failed") return cgroup.ErrStrictRefused } return d.recordingStrict.Close() } // TestPendingStrictCleanupOwnsTheGroup is the invariant: while a host has // pending strict cleanup, nothing else may use or remove that host's // cgroup. The exit path does not remove the group directory under a // retained handle (with a handle whose identity is that directory, a // removal would strand it for ever), so a drain that timed out before the // group emptied, and a transient Close failure on an empty group, both // recover on retry; the directory goes only once the handle is closed, and // strict spawns work afterwards. Meanwhile any spawn of the host, an // ordinary one included (Strict switched off), is refused and never runs. func TestPendingStrictCleanupOwnsTheGroup(t *testing.T) { old := spawnHealthTimeout spawnHealthTimeout = 300 * time.Millisecond t.Cleanup(func() { spawnHealthTimeout = old }) host := "owned.example.com" for name, tc := range map[string]struct { killFails, closeFails int want string }{ "a drain timeout, then the group empties": {1, 0, "start,kill-and-empty timed out,kill-and-empty,close"}, "a transient close failure on an empty group": {0, 1, "start,kill-and-empty,close failed,close"}, } { t.Run(name, func(t *testing.T) { oldBackoff := strictCleanupBackoff strictCleanupBackoff = []time.Duration{500 * time.Millisecond, 500 * time.Millisecond} t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) b := testExecBackend(t) b.Cgroups = delegatedManager(t) b.strictPreflight = func() error { return nil } groupDir := filepath.Join(b.Cgroups.Root(), cgroup.ExecName(host)) argvPath, _ := stageRecordingLive(t, b, host, "v1") var strict atomic.Bool strict.Store(true) b.Strict = func(string) bool { return strict.Load() } var mu sync.Mutex var handles []*dirStrict b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { mu.Lock() defer mu.Unlock() h := newDirStrict(t, groupDir) if len(handles) == 0 { h.killFails, h.closeFails = tc.killFails, tc.closeFails } handles = append(handles, h) return h, nil } startOnce := func() error { rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} ctx, cancel := recordedCtx(2*time.Second, argvPath) // the recorder ran and exited defer cancel() proc, _, err := b.Start(ctx, rt) if proc != nil { _ = proc.Stop(context.Background()) } return err } _ = startOnce() waitForFile(t, argvPath, time.Second) deadline := time.Now().Add(2 * time.Second) for !b.strictCleanupPending(host) { if time.Now().After(deadline) { t.Fatalf("no cleanup pending: %v", handles[0].seen()) } time.Sleep(5 * time.Millisecond) } // The child has exited and its exit path has run: the group // directory is still there, owned by the retained handle. time.Sleep(50 * time.Millisecond) if _, err := os.Stat(groupDir); err != nil { t.Fatalf("the exit path removed the group under a pending handle: %v", err) } // An ordinary spawn of the host is refused while cleanup is // pending, and nothing runs. strict.Store(false) if err := os.Remove(argvPath); err != nil { t.Fatal(err) } if err := startOnce(); !errors.Is(err, ErrStrictCleanupPending) { t.Fatalf("ordinary spawn during pending cleanup: %v, want ErrStrictCleanupPending", err) } awaitChildFile(argvPath, 100*time.Millisecond) // until no child is left to write it if _, err := os.Stat(argvPath); !os.IsNotExist(err) { t.Fatalf("an ordinary child ran in a group awaiting cleanup: %v", err) } strict.Store(true) deadline = time.Now().Add(4 * time.Second) for b.strictCleanupPending(host) { if time.Now().After(deadline) { t.Fatalf("cleanup never succeeded: %v", handles[0].seen()) } time.Sleep(5 * time.Millisecond) } if got := strings.Join(handles[0].seen(), ","); got != tc.want { t.Fatalf("first handle's calls = %s, want %s", got, tc.want) } if _, err := os.Stat(groupDir); !os.IsNotExist(err) { t.Fatalf("the group directory outlived its handle's cleanup: %v", err) } _ = startOnce() waitForFile(t, argvPath, time.Second) mu.Lock() n := len(handles) mu.Unlock() if n != 2 { t.Fatalf("strict groups prepared = %d, want a second one after cleanup", n) } }) } } // TestALiveStrictGroupRefusesOrdinarySpawns: while a strict child of a // host is alive, an ordinary spawn of the same host (Strict switched off // meanwhile) is refused rather than placed in the strict group, where the // strict child's exit drain would kill it. func TestALiveStrictGroupRefusesOrdinarySpawns(t *testing.T) { host := "live.example.com" b := testExecBackend(t) var strict atomic.Bool strict.Store(true) b.Strict = func(string) bool { return strict.Load() } rec := newRecordingStrict() b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { return rec, nil } rt := registry.Route{Host: host} cmd, settle, err := b.spawnInCgroup(rt, func() *exec.Cmd { return exec.Command("sleep", "5") }) if err != nil { t.Fatalf("strict spawn: %v", err) } strict.Store(false) ran := false if _, _, err := b.spawnInCgroup(rt, func() *exec.Cmd { ran = true; return exec.Command("/bin/true") }); !errors.Is(err, ErrStrictGroupInUse) || ran { t.Fatalf("ordinary spawn beside a live strict child: %v (ran %v), want ErrStrictGroupInUse", err, ran) } _ = cmd.Process.Kill() _ = cmd.Wait() settle() if _, settle2, err := b.spawnInCgroup(rt, func() *exec.Cmd { return exec.Command("/bin/true") }); err != nil { t.Fatalf("ordinary spawn after the strict child settled: %v", err) } else { settle2() } } // hookAt returns a strictHook that calls fn at point and does nothing at // any other point. func hookAt(point string, fn func()) func(string) { return func(p string) { if p == point { fn() } } } // TestAnOlderExitCannotRemoveANewOwnersGroup: the exit path's removal and // a strict spawn's ownership are decided under the same host gate. An exit // that has checked "no owner" holds the gate through its removal, so a // strict spawn arriving meanwhile waits, then owns a group it created // itself; an exit that runs once any owner exists, live or pending, removes // nothing. A strict child whose cleanup then fails keeps its directory. func TestAnOlderExitCannotRemoveANewOwnersGroup(t *testing.T) { oldBackoff := strictCleanupBackoff strictCleanupBackoff = []time.Duration{time.Hour} t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) host := "race.example.com" b := testExecBackend(t) b.Cgroups = delegatedManager(t) groupDir := filepath.Join(b.Cgroups.Root(), cgroup.ExecName(host)) b.Strict = func(string) bool { return true } var handle *dirStrict b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { handle = newDirStrict(t, groupDir) handle.killFails = 100 return handle, nil } if err := os.MkdirAll(groupDir, 0o755); err != nil { t.Fatal(err) } // The older exit has found no owner and is about to remove the group. // A strict spawn starts right then and must wait for the gate. spawned := make(chan error, 1) var settle func() var cmd *exec.Cmd b.strictHook = hookAt("exit-cleanup-checked", func() { go func() { var err error cmd, settle, err = b.spawnInCgroup(registry.Route{Host: host}, func() *exec.Cmd { return exec.Command("/bin/true") }) spawned <- err }() select { case err := <-spawned: t.Errorf("a strict spawn took the host while an exit held its gate: %v", err) spawned <- err case <-time.After(150 * time.Millisecond): } }) b.cleanupHost(host) b.strictHook = nil if err := <-spawned; err != nil { t.Fatalf("strict spawn after the exit: %v", err) } if _, err := os.Stat(groupDir); err != nil { t.Fatalf("the new owner has no group directory: %v", err) } // Any owner, live as here, keeps the directory from an exit. b.cleanupHost(host) if _, err := os.Stat(groupDir); err != nil { t.Fatalf("an exit removed a live owner's group: %v", err) } _ = cmd.Wait() settle() // the drain fails: the owner turns pending if !b.strictCleanupPending(host) { t.Fatal("cleanup is not pending after a failed drain") } b.cleanupHost(host) if _, err := os.Stat(groupDir); err != nil { t.Fatalf("an exit removed a pending owner's group: %v", err) } if !handle.identityOK() { t.Fatal("the retained handle lost its group") } } // TestAPendingTransitionRacesARefusal: a spawn refused while the owner is // between its failed cleanup attempt and turning pending sees a live owner // (ErrStrictGroupInUse); once pending, ErrStrictCleanupPending. pending is // only ever read and written under the host gate, so -race stays quiet // however the two interleave. func TestAPendingTransitionRacesARefusal(t *testing.T) { oldBackoff := strictCleanupBackoff strictCleanupBackoff = []time.Duration{time.Hour} t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) host := "transition.example.com" b := testExecBackend(t) b.Strict = func(string) bool { return false } o := b.ownStrictForTest(host, &flakyStrict{recordingStrict: newRecordingStrict(), killFails: 100}) spawn := func() error { _, _, err := b.spawnInCgroup(registry.Route{Host: host}, func() *exec.Cmd { return exec.Command("/bin/true") }) return err } var during error b.strictHook = hookAt("cleanup-failed", func() { during = spawn() }) b.settleStrict(host, o) b.strictHook = nil if !errors.Is(during, ErrStrictGroupInUse) { t.Fatalf("refusal before the owner turned pending: %v, want ErrStrictGroupInUse", during) } if err := spawn(); !errors.Is(err, ErrStrictCleanupPending) { t.Fatalf("refusal after: %v, want ErrStrictCleanupPending", err) } // The same transition against concurrent refusals, for -race. host2 := "transition2.example.com" o2 := b.ownStrictForTest(host2, &flakyStrict{recordingStrict: newRecordingStrict(), killFails: 100}) var wg sync.WaitGroup stop := make(chan struct{}) var looping sync.WaitGroup for i := 0; i < 8; i++ { wg.Add(1) looping.Add(1) go func() { defer wg.Done() first := true for { _, _, err := b.spawnInCgroup(registry.Route{Host: host2}, func() *exec.Cmd { return exec.Command("/bin/true") }) if !errors.Is(err, ErrStrictGroupInUse) && !errors.Is(err, ErrStrictCleanupPending) { t.Errorf("spawn beside an owner: %v", err) } _ = b.NextSpawnStrict(host2) if first { looping.Done() first = false } select { case <-stop: return default: } } }() } looping.Wait() b.settleStrict(host2, o2) // turns pending while the refusals run time.Sleep(20 * time.Millisecond) close(stop) wg.Wait() } // TestAStalledStartBlocksOnlyItsHost: host A's ordinary start stalls inside // Cgroups.Start, holding A's gate. Host B's ordinary and strict spawns and // its NextSpawnStrict proceed; the global map lock is never held across a // start. func TestAStalledStartBlocksOnlyItsHost(t *testing.T) { b := testExecBackend(t) b.Cgroups = delegatedManager(t) b.strictPreflight = func() error { return nil } b.Strict = func(h string) bool { return h == "b-strict.example.com" } b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { return newRecordingStrict(), nil } release := make(chan struct{}) entered := make(chan struct{}) var once sync.Once doneA := make(chan error, 1) go func() { cmd, settle, err := b.spawnInCgroup(registry.Route{Host: "a.example.com"}, func() *exec.Cmd { once.Do(func() { close(entered); <-release }) return exec.Command("/bin/true") }) if err == nil { _ = cmd.Wait() settle() } doneA <- err }() <-entered within := func(what string, fn func()) { t.Helper() done := make(chan struct{}) go func() { fn(); close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatalf("%s was blocked by host A's stalled start", what) } } within("host B's ordinary spawn", func() { cmd, settle, err := b.spawnInCgroup(registry.Route{Host: "b.example.com"}, func() *exec.Cmd { return exec.Command("/bin/true") }) if err != nil { t.Errorf("host B: %v", err) return } _ = cmd.Wait() settle() }) within("host B's strict spawn", func() { cmd, settle, err := b.spawnInCgroup(registry.Route{Host: "b-strict.example.com"}, func() *exec.Cmd { return exec.Command("/bin/true") }) if err != nil { t.Errorf("host B strict: %v", err) return } _ = cmd.Wait() settle() }) within("host B's NextSpawnStrict", func() { if c := b.NextSpawnStrict("b-strict.example.com"); !c.OK { t.Errorf("S2 for host B: %+v", c) } }) within("host B's exit cleanup", func() { b.cleanupHost("b.example.com") }) close(release) if err := <-doneA; err != nil { t.Fatalf("host A: %v", err) } } // TestSwitchedOffSpawnsTakeNoNewLock: with Strict nil, a spawn and its exit // path are main's: no host gate is created and the global map lock is // never taken (held by the test throughout, a spawn that wanted it would // hang). func TestSwitchedOffSpawnsTakeNoNewLock(t *testing.T) { old := spawnHealthTimeout spawnHealthTimeout = 300 * time.Millisecond t.Cleanup(func() { spawnHealthTimeout = old }) b := testExecBackend(t) b.Cgroups = delegatedManager(t) host := "off.example.com" argvPath, _ := stageRecordingLive(t, b, host, "v1") b.strictMu.Lock() defer b.strictMu.Unlock() done := make(chan struct{}) go func() { defer close(done) rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() proc, _, _ := b.Start(ctx, rt) if proc != nil { _ = proc.Stop(context.Background()) } }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("a switched-off spawn waited on the strict map lock") } waitForFile(t, argvPath, time.Second) // The exit path's call, again, with the lock held. cleaned := make(chan struct{}) go func() { b.cleanupHost(host); close(cleaned) }() select { case <-cleaned: case <-time.After(2 * time.Second): t.Fatal("a switched-off exit cleanup waited on the strict map lock") } if b.strictGates != nil { t.Fatalf("a switched-off spawn created host gates: %v", b.strictGates) } } // Under Phase 1 the staging-wins promotion is gone (spec §5.3, M15): a // staging tree beside an empty own prefix is not promoted, the restore is a // fresh boot, and the tree is left exactly as found. Off, the legacy // promotion runs as TestRestoreStagingFallback pins. func TestRestoreDoesNotPromoteStagingUnderPhase1(t *testing.T) { ctx := context.Background() b := testExecBackend(t) f := newFakeMirrorStore() b.Mirror = f b.Phase1 = true host := "moved.example.com" rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} oldRestore := restoreReplica restoreReplica = func(_ context.Context, dbPath, bucket, prefix, region string) (bool, error) { objs, _ := f.List(context.Background(), bucket, prefix) if len(objs) == 0 { return false, nil } return true, writeMinimalSQLite(dbPath) } defer func() { restoreReplica = oldRestore }() // The legacy shape and the Phase 1 shape (moves///...). f.put(b.Cfg.Bucket, "moves/"+host+"/generations/a/snapshot", "e1") f.put(b.Cfg.Bucket, "moves/"+host+"/mv_abcdefghijklmnopqrstuvwxyz/g-cur/0000/0000000000000001-0000000000000002.ltx", "e2") before, _ := f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") wantStaging := map[string]lease.MirrorObject{ "moves/" + host + "/generations/a/snapshot": {ETag: "e1"}, "moves/" + host + "/mv_abcdefghijklmnopqrstuvwxyz/g-cur/0000/0000000000000001-0000000000000002.ltx": {ETag: "e2"}, } if !reflect.DeepEqual(before, wantStaging) { t.Fatalf("staging fixture: %v, want %v", before, wantStaging) } fresh, err := b.Restore(ctx, rt) if err != nil || !fresh { t.Fatalf("Phase 1 restore with only staging: fresh=%v err=%v, want a fresh boot", fresh, err) } if own, _ := f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)); len(own) != 0 { t.Fatalf("staging was promoted into the own prefix: %v", own) } after, _ := f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") if !reflect.DeepEqual(after, before) { t.Fatalf("staging changed: before %v after %v", before, after) } // An own lineage restores as ever, and still leaves staging alone. f.put(b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)+"/snapshot", "own") _ = os.Remove(filepath.Join(b.DataDir, host+".db")) fresh, err = b.Restore(ctx, rt) if err != nil || fresh { t.Fatalf("own lineage: fresh=%v err=%v, want a restore", fresh, err) } if after, _ = f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/"); !reflect.DeepEqual(after, before) { t.Fatalf("staging touched by an own-lineage restore: %v", after) } }