activator: a declined box pins the live link once for nested starts and seeds
With LiveExecGate set, the nested run and sidecar starts and the flat and nested seeds resolve and hash live/<host> once (declinedLivePin), ask the gate about that version, and run that identity: by path (flat seed) or named to root (StartExe, StartGenExe, StartSidecarExe, SeedExe). A repoint after the read changes nothing; a link outside the version store is refused with errBuildStateUnwritten and asks root for nothing. Seeds pin before the database stat, so a refusal never removes a database. Closes the Task 9 residual (ruling 3) on Control.Start.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+336
−35
internal/activator/backend_exec.go+98 −35internal/activator/identity_test.go+238 −0
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 1537dfd13..e011c9281 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -884,7 +884,9 @@ func removeSeedDB(dbPath string) { |
| // seedChild is Seed's flat body. exe is the file the seed execs: the live |
| // link (Seed), which is materialized first if it is missing, or a staged |
| // path SeedExe verified, which is exec'd by path whatever the link names. |
| -// tier is that file's own; zero is the live tier (b.tier). |
| +// With LiveExecGate set the live link is pinned instead (declinedLivePin) |
| +// and the file it named is exec'd. tier is that file's own; zero is the |
| +// live tier (b.tier). |
| func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route, exe string, tier cgroup.Limit) error { |
| host := rt.Host |
| paths := b.pathsFor(rt) |
| @@ -896,7 +898,17 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route, exe stri |
| return nil // defensive: doWake only calls us for a non-empty seed |
| } |
| if exe == b.liveLink(host) { |
| - if err := b.ensureLiveLink(ctx, host); err != nil { |
| + if b.LiveExecGate != nil { |
| + // A declined box seeds the build its gate passed, by path, at |
| + // that build's tier (declinedLivePin), never the link. Before the |
| + // database stat below: a refusal runs nothing and so can never |
| + // count as a failed seed whose database is removed. |
| + id, err := b.declinedLivePin(ctx, rt) |
| + if err != nil { |
| + return err |
| + } |
| + exe, tier = id.StagedPath, b.GenTier(rt, id.Version) |
| + } else if err := b.ensureLiveLink(ctx, host); err != nil { |
| return err |
| } |
| } |
| @@ -1075,12 +1087,25 @@ func (c *capBuffer) String() string { |
| // wake recorded (SeedExe): root validates it and binds the seed unit's app |
| // mount to it, so the live link need not exist and a repoint of it |
| // changes nothing. A control channel that cannot name it refuses the seed. |
| -// tier is that build's own (ruling R-TIER); zero is the live tier. |
| +// With LiveExecGate set a nil exe pins the live link (declinedLivePin) and |
| +// seeds that file the same way. tier is that build's own (ruling R-TIER); |
| +// zero is the live tier. |
| func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route, exe *instancectl.StagedExe, tier cgroup.Limit) error { |
| host := rt.Host |
| if err := b.nestedGate(rt); err != nil { |
| return err |
| } |
| + if exe == nil && b.LiveExecGate != nil { |
| + // A declined box names the build its gate passed to root, at that |
| + // build's tier (declinedLivePin), as SeedExe does. Before anything |
| + // is prepared and before the database stat below, so a refusal asks |
| + // root for nothing and removes nothing. |
| + id, err := b.declinedLivePin(ctx, rt) |
| + if err != nil { |
| + return err |
| + } |
| + exe, tier = stagedExeOf(id), b.GenTier(rt, id.Version) |
| + } |
| if tier == (cgroup.Limit{}) { |
| tier = b.tier(rt) |
| } |
| @@ -1321,6 +1346,37 @@ func (b *ExecBackend) LiveIdentity(ctx context.Context, rt registry.Route) (rele |
| return id, nil |
| } |
| +// declinedLivePin is a live-link start's identity on a box with |
| +// LiveExecGate set (a declined box that honours failure records or hold-ran |
| +// markers): live/<host> materialized and resolved ONCE, hashed |
| +// (LiveIdentity), and the gate asked about that version. The caller runs |
| +// exactly that file: a flat start execs it by path, and a nested one names |
| +// it to root (StartExe, StartGenExe, StartSidecarExe, SeedExe), which |
| +// validates it and binds it in place of the link. So the build the gate |
| +// passed is the build that runs, and root repointing the link after this |
| +// read changes nothing. |
| +// |
| +// A link outside the version store names no build the gate could judge or |
| +// root could validate (instancectl.validateStagedExe), and is refused in |
| +// recordBuild's words, as the activator already refuses every exec row's |
| +// unversioned capture while a build state is kept (ruling |
| +// R-EXEC-NOVERSION). Nothing has been prepared, launched or asked of root |
| +// when it returns an error. |
| +func (b *ExecBackend) declinedLivePin(ctx context.Context, rt registry.Route) (release.StagedIdentity, error) { |
| + id, err := b.LiveIdentity(ctx, rt) |
| + if err != nil { |
| + return release.StagedIdentity{}, err |
| + } |
| + if id.Version == "" { |
| + log.Printf("activator: %s not started: the executable its live link names (%s) is outside the version store, so this box cannot check it against its holds; move the instance onto a promoted build", rt.Host, id.StagedPath) |
| + return release.StagedIdentity{}, fmt.Errorf("%s: %w: the executable is outside the version store", rt.Host, errBuildStateUnwritten) |
| + } |
| + if err := b.LiveExecGate(rt, id.Version); err != nil { |
| + return release.StagedIdentity{}, err |
| + } |
| + return id, nil |
| +} |
| + |
| // Start dispatches a wake by layout (design §8.1): a nested row starts as a |
| // unit through the root instance-control channel, a fenced or conflicting |
| // row refuses outright, and a flat or foreign row runs today's exec child. |
| @@ -1856,7 +1912,9 @@ func (b *ExecBackend) normalizeSocketMode(rt registry.Route, sock string, proc P |
| // 3): root validates the path and mounts it in place of the live link, so |
| // whatever happens to the live link meanwhile changes nothing, and the |
| // caller owns the health deadline and the termination. tier is that |
| -// artifact's own tier; zero is the live tier (b.tier). |
| +// artifact's own tier; zero is the live tier (b.tier). With LiveExecGate |
| +// set a nil exe pins the live link (declinedLivePin) and starts that file as |
| +// a staged one, at its own tier. |
| // |
| // n is the generation this spawn is (StartGen's n; 0 from Start). Under |
| // GenContainment it selects the carlos-instance-gen@<id>:<n> unit and its |
| @@ -1870,7 +1928,20 @@ func (b *ExecBackend) spawnUnitFrom(ctx context.Context, rt registry.Route, sock |
| if err != nil { |
| return nil, "", err |
| } |
| - if exe == nil { |
| + pinned := "" |
| + if exe == nil && b.LiveExecGate != nil { |
| + // A declined box that honours failure records or hold-ran markers |
| + // (Task 9) pins the live link once and names that file to root at |
| + // its own tier (declinedLivePin), so root binds the build the gate |
| + // passed and a repoint after this read changes nothing. Before |
| + // anything is prepared: a refusal binds no listener and writes no |
| + // launch file. |
| + id, err := b.declinedLivePin(ctx, rt) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + exe, tier, pinned = stagedExeOf(id), b.GenTier(rt, id.Version), id.Version |
| + } else if exe == nil { |
| // A staged exe is mounted in place of the live link, which then |
| // need not exist (as spawnChildFrom does for a flat host). |
| if err := b.ensureLiveLink(ctx, rt.Host); err != nil { |
| @@ -1895,8 +1966,12 @@ func (b *ExecBackend) spawnUnitFrom(ctx context.Context, rt registry.Route, sock |
| } |
| // Read the symlink immediately before the start, same fail-safe |
| // direction as startChild: a concurrent repoint at worst records the |
| - // older version (one extra convergence cycle), never the newer. |
| - spawnVer, _ := b.LiveVersion(rt.Host) |
| + // older version (one extra convergence cycle), never the newer. A |
| + // pinned start names the build it pinned, which is what root binds. |
| + spawnVer := pinned |
| + if spawnVer == "" { |
| + spawnVer, _ = b.LiveVersion(rt.Host) |
| + } |
| // The caller's exec gate (final review F1), right before root is asked |
| // to start the unit: the config read, the listeners and the launch file |
| // above all take time. A refusal starts nothing; the generation's own |
| @@ -1907,20 +1982,6 @@ func (b *ExecBackend) spawnUnitFrom(ctx context.Context, rt registry.Route, sock |
| b.launchHook(rt.Host) |
| } |
| err = runExecGate(ctx) |
| - if err == nil && exe == nil && b.LiveExecGate != nil { |
| - // A declined box that honours failure records or hold-ran markers |
| - // (Task 9) asks about the build the live link names now, the one |
| - // spawnVer read immediately above: the plan read the link before the |
| - // lease and the restore, and root may have repointed it at a held |
| - // build since. |
| - // |
| - // Accepted residual (Task 9 fix round 1, ruling 3; ledgered): the |
| - // gate checks the version read above, before root's trampoline |
| - // execs the link, and a repoint landing in between escapes it (the |
| - // flat spawn execs the path it resolved instead). Closing it needs |
| - // an expected identity on Control.Start, owed on #272. |
| - err = b.LiveExecGate(rt, spawnVer) |
| - } |
| if err != nil { |
| if gen != 0 { |
| _ = os.Remove(filepath.Join(p.dataDir, instancectl.GenLaunchFileName(gen))) |
| @@ -2531,13 +2592,27 @@ func (b *ExecBackend) startSidecarUnit(ctx context.Context, rt registry.Route) ( |
| } |
| // startSidecarUnitFrom is startSidecarUnit with the executable and the tier |
| -// named. A nil exe is the live link (today's start). A staged exe is the |
| -// serving generation's captured build (zero-downtime spec §7.5), which root |
| +// named. A nil exe is the live link (today's start), pinned instead on a box |
| +// with LiveExecGate set (declinedLivePin). A staged exe is the serving |
| +// generation's captured build (zero-downtime spec §7.5), which root |
| // validates before it lays out the mount, and tier that generation's own. |
| func (b *ExecBackend) startSidecarUnitFrom(ctx context.Context, rt registry.Route, exe *instancectl.StagedExe, tier cgroup.Limit) (Proc, error) { |
| if err := b.nestedGate(rt); err != nil { |
| return nil, err |
| } |
| + if exe == nil && b.LiveExecGate != nil { |
| + // A declined box that honours failure records or hold-ran markers |
| + // (Task 9 fix round 1, ruling 1) pins the live link once and names |
| + // that file to root at its own tier (declinedLivePin), as the nested |
| + // instance start does (spawnUnitFrom): root binds the build the gate |
| + // passed, never the link. Before the launch file is written, so a |
| + // refusal leaves nothing behind. |
| + id, err := b.declinedLivePin(ctx, rt) |
| + if err != nil { |
| + return nil, err |
| + } |
| + exe, tier = stagedExeOf(id), b.GenTier(rt, id.Version) |
| + } |
| p, _, logs, fileEnv, err := b.prepare(rt, instancectl.RoleSidecar) |
| if err != nil { |
| return nil, err |
| @@ -2562,18 +2637,6 @@ func (b *ExecBackend) startSidecarUnitFrom(ctx context.Context, rt registry.Rout |
| } |
| inv, err = gc.StartSidecarExe(ctx, rt.Host, tier.MemoryMax, *exe) |
| } else { |
| - if b.LiveExecGate != nil { |
| - // A declined box that honours failure records or hold-ran |
| - // markers (Task 9 fix round 1, ruling 1): the build the live link |
| - // names, asked right before root is asked to start the sidecar, |
| - // as the nested instance start asks it (spawnUnitFrom), with the |
| - // same residual: root's trampoline execs the link itself. |
| - ver, _ := b.LiveVersion(rt.Host) |
| - if gerr := b.LiveExecGate(rt, ver); gerr != nil { |
| - logs.Close() |
| - return nil, gerr |
| - } |
| - } |
| inv, err = b.Control.Start(ctx, rt.Host, instancectl.RoleSidecar, b.tier(rt).MemoryMax) |
| } |
| if err != nil { |
diff --git a/internal/activator/identity_test.go b/internal/activator/identity_test.go| index a6689998e..71aa69e4c 100644 |
| --- a/internal/activator/identity_test.go |
| +++ b/internal/activator/identity_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "net/http/httptest" |
| "os" |
| "path/filepath" |
| + "slices" |
| "strings" |
| "testing" |
| "time" |
| @@ -434,6 +435,243 @@ func TestNestedSeedExeNamesTheRecordedFile(t *testing.T) { |
| } |
| } |
| +// repointingGate is g's gate followed, on every answer, by root's repoint of |
| +// host's live link at to: the window between a declined start's gate and |
| +// the moment what it starts is bound or exec'd. |
| +func repointingGate(t *testing.T, b *ExecBackend, host, to string, g *liveGate) func(registry.Route, string) error { |
| + t.Helper() |
| + return func(rt registry.Route, build string) error { |
| + err := g.gate(rt, build) |
| + repointLiveLink(t, b, host, to) |
| + return err |
| + } |
| +} |
| + |
| +// declinedPinFixture is a nested backend whose root channel names |
| +// executables, with v1 and v2 staged and the live link at v1. It returns |
| +// v1's identity and v2's path. |
| +func declinedPinFixture(t *testing.T, ctl *fakeControl, unit *fakeUnit) (*ExecBackend, registry.Route, instancectl.StagedExe, string) { |
| + t.Helper() |
| + b, rt := nestedBackend(t, ctl, unit) |
| + b.Control = identityControl{ctl} |
| + v1 := stageVersion(t, b, rt.Host, "v1", "exit 0\n") |
| + v2 := stageVersion(t, b, rt.Host, "v2", "exit 0\n") |
| + repointLiveLink(t, b, rt.Host, v1) |
| + sum, err := release.FileSHA256(v1) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b, rt, instancectl.StagedExe{Path: v1, Version: "v1", Digest: sum}, v2 |
| +} |
| + |
| +// Task 5 (readiness 2, recon A §1): a declined box's nested live-link start |
| +// pins the link once. The gate is asked about the build the link names, and |
| +// root is asked to bind exactly that file (StartExe at generation 0, |
| +// StartGenExe at generation n), so root's repoint of the link at v2 after |
| +// the gate answered still runs v1. Never a plain Start or StartGen, which |
| +// would bind whatever the link names when root lays the unit out. |
| +func TestADeclinedNestedLiveLinkStartRunsThePinnedFile(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + gens bool |
| + want func(host string, x instancectl.StagedExe) string |
| + }{ |
| + {"generation 0", false, func(h string, x instancectl.StagedExe) string { return exeLine("startexe", h, "run", x) }}, |
| + {"generation n", true, func(h string, x instancectl.StagedExe) string { return exeLine("startgenexe", h, "6", x) }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + ctl := &fakeControl{inv: "inv1"} |
| + unit := &fakeUnit{} |
| + b, rt, x, v2 := declinedPinFixture(t, ctl, unit) |
| + b.GenContainment = tc.gens |
| + g := &liveGate{refuse: map[string]bool{"v2": true}} |
| + b.LiveExecGate = repointingGate(t, b, rt.Host, v2, g) |
| + sock := rt.Socket |
| + if tc.gens { |
| + sock = nestedGenSocketIn(b.pathsFor(rt).runDir, 6) |
| + } |
| + var stopServing func() |
| + ctl.onStart = func() { stopServing = serveOK(t, sock) } |
| + defer func() { |
| + if stopServing != nil { |
| + stopServing() |
| + } |
| + }() |
| + unit.set("active", "inv1", true, true) |
| + var ( |
| + p Proc |
| + ver string |
| + err error |
| + ) |
| + if tc.gens { |
| + var gen Gen |
| + p, gen, err = b.StartGen(context.Background(), rt, 6, true) |
| + ver = gen.Build |
| + } else { |
| + p, ver, err = b.Start(context.Background(), rt) |
| + } |
| + if err != nil { |
| + t.Fatalf("start: %v", err) |
| + } |
| + if !strings.Contains(ctl.joined(), tc.want(rt.Host, x)) { |
| + t.Fatalf("root was asked:\n%s\nwant %q", ctl.joined(), tc.want(rt.Host, x)) |
| + } |
| + noPlainVerbs(t, ctl) |
| + if got := g.builds(); !slices.Equal(got, []string{"v1"}) { |
| + t.Fatalf("gate asked %v, want [v1]", got) |
| + } |
| + if ver != "v1" { |
| + t.Fatalf("version %q, want the pinned v1", ver) |
| + } |
| + unit.set("inactive", "inv1", false, false) |
| + waitFor(t, "the unit to exit", p.Exited) |
| + }) |
| + } |
| +} |
| + |
| +// The same for the legacy sidecar's nested live-link start: root is asked |
| +// to bind the pinned v1 (StartSidecarExe), never a plain sidecar start. |
| +func TestADeclinedNestedSidecarStartRunsThePinnedFile(t *testing.T) { |
| + ctl := &fakeControl{inv: "inv1"} |
| + unit := &fakeUnit{} |
| + b, rt, x, v2 := declinedPinFixture(t, ctl, unit) |
| + g := &liveGate{refuse: map[string]bool{"v2": true}} |
| + b.LiveExecGate = repointingGate(t, b, rt.Host, v2, g) |
| + unit.set("active", "inv1", true, true) |
| + p, err := b.StartSidecar(context.Background(), rt) |
| + if err != nil { |
| + t.Fatalf("StartSidecar: %v", err) |
| + } |
| + if want := exeLine("startsidecarexe", rt.Host, "sidecar", x); !strings.Contains(ctl.joined(), want) { |
| + t.Fatalf("root was asked:\n%s\nwant %q", ctl.joined(), want) |
| + } |
| + noPlainVerbs(t, ctl) |
| + if got := g.builds(); !slices.Equal(got, []string{"v1"}) { |
| + t.Fatalf("gate asked %v, want [v1]", got) |
| + } |
| + unit.set("inactive", "inv1", false, false) |
| + waitFor(t, "the sidecar unit to exit", p.Exited) |
| +} |
| + |
| +// A declined box's live link outside the version store names no build the |
| +// gate could judge or root could validate: every nested live-link start |
| +// (run, sidecar, seed) is refused in recordBuild's words, the gate is asked |
| +// nothing, and root is asked for nothing. |
| +func TestADeclinedLinkOutsideTheStoreAsksRootForNothing(t *testing.T) { |
| + ctl := &fakeControl{inv: "inv1", seed: instancectl.SeedResponse{InvocationID: "s1", Result: "success", Status: "0", Code: "exited"}} |
| + b, rt := nestedBackend(t, ctl, &fakeUnit{}) |
| + b.Control = identityControl{ctl} |
| + rt.Seed = `["seed"]` |
| + hand := filepath.Join(t.TempDir(), "app") |
| + if err := os.WriteFile(hand, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + repointLiveLink(t, b, rt.Host, hand) |
| + g := &liveGate{} |
| + b.LiveExecGate = g.gate |
| + if _, _, err := b.Start(context.Background(), rt); !errors.Is(err, errBuildStateUnwritten) { |
| + t.Fatalf("Start = %v, want errBuildStateUnwritten", err) |
| + } |
| + if _, err := b.StartSidecar(context.Background(), rt); !errors.Is(err, errBuildStateUnwritten) { |
| + t.Fatalf("StartSidecar = %v, want errBuildStateUnwritten", err) |
| + } |
| + if err := b.Seed(context.Background(), rt); !errors.Is(err, errBuildStateUnwritten) { |
| + t.Fatalf("Seed = %v, want errBuildStateUnwritten", err) |
| + } |
| + if got := ctl.joined(); got != "" { |
| + t.Fatalf("root was asked:\n%s", got) |
| + } |
| + if got := g.builds(); len(got) != 0 { |
| + t.Fatalf("gate asked %v about a link that names no build", got) |
| + } |
| +} |
| + |
| +// Task 5 (recon A §2): a declined box's flat seed on the live link pins the |
| +// link once and execs the gated file by path, so root's repoint at v2 after |
| +// the gate answered still seeds with v1. A held build seeds nothing: no |
| +// process runs and no database is created. |
| +func TestADeclinedFlatSeedRunsThePinnedFile(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "seedpin.example.com" |
| + marker := filepath.Join(t.TempDir(), "argv0") |
| + // $0 into marker, then the database the seed was handed (--db, $3). |
| + body := "printf '%s' \"$0\" > '" + marker + "'\n: > \"$3\"\n" |
| + v1 := stageVersion(t, b, host, "v1", body) |
| + v2 := stageVersion(t, b, host, "v2", body) |
| + repointLiveLink(t, b, host, v1) |
| + rt := registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`} |
| + db := b.pathsFor(rt).dbPath |
| + g := &liveGate{refuse: map[string]bool{"v2": true}} |
| + b.LiveExecGate = repointingGate(t, b, host, v2, g) |
| + if err := b.Seed(context.Background(), rt); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + if got := waitForFile(t, marker, time.Second); got != v1 { |
| + t.Fatalf("seeded with %q, want the pinned %q", got, v1) |
| + } |
| + if got := g.builds(); !slices.Equal(got, []string{"v1"}) { |
| + t.Fatalf("gate asked %v, want [v1]", got) |
| + } |
| + if _, err := os.Stat(db); err != nil { |
| + t.Fatalf("the seed's database: %v", err) |
| + } |
| + |
| + // The link names held v2 now. A fresh instance seeds nothing. |
| + os.Remove(marker) |
| + removeSeedDB(db) |
| + held := &liveGate{refuse: map[string]bool{"v2": true}} |
| + b.LiveExecGate = held.gate |
| + if err := b.Seed(context.Background(), rt); !errors.Is(err, errLiveHeld) { |
| + t.Fatalf("Seed onto held v2 = %v, want the gate's refusal", err) |
| + } |
| + time.Sleep(100 * time.Millisecond) |
| + if _, err := os.Stat(marker); !os.IsNotExist(err) { |
| + t.Fatal("held v2 ran as the seed") |
| + } |
| + if _, err := os.Stat(db); !os.IsNotExist(err) { |
| + t.Fatalf("a refused seed left a database: %v", err) |
| + } |
| + if got := held.builds(); !slices.Equal(got, []string{"v2"}) { |
| + t.Fatalf("gate asked %v, want [v2]", got) |
| + } |
| +} |
| + |
| +// The nested seed: root is asked to run the seed unit on the pinned v1 |
| +// (SeedExe), never a plain Seed of the link. A held build asks root for |
| +// nothing. |
| +func TestADeclinedNestedSeedRunsThePinnedFile(t *testing.T) { |
| + ctl := &fakeControl{seed: instancectl.SeedResponse{InvocationID: "s1", Result: "success", Status: "0", Code: "exited"}} |
| + b, rt, x, v2 := declinedPinFixture(t, ctl, &fakeUnit{}) |
| + rt.Seed = `["seed"]` |
| + g := &liveGate{refuse: map[string]bool{"v2": true}} |
| + b.LiveExecGate = repointingGate(t, b, rt.Host, v2, g) |
| + if err := b.Seed(context.Background(), rt); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + if want := exeLine("seedexe", rt.Host, "seed", x); !strings.Contains(ctl.joined(), want) { |
| + t.Fatalf("root was asked:\n%s\nwant %q", ctl.joined(), want) |
| + } |
| + noPlainVerbs(t, ctl) |
| + if got := g.builds(); !slices.Equal(got, []string{"v1"}) { |
| + t.Fatalf("gate asked %v, want [v1]", got) |
| + } |
| + |
| + ctl.mu.Lock() |
| + ctl.calls = nil |
| + ctl.mu.Unlock() |
| + held := &liveGate{refuse: map[string]bool{"v2": true}} |
| + b.LiveExecGate = held.gate |
| + if err := b.Seed(context.Background(), rt); !errors.Is(err, errLiveHeld) { |
| + t.Fatalf("Seed onto held v2 = %v, want the gate's refusal", err) |
| + } |
| + if got := ctl.joined(); got != "" { |
| + t.Fatalf("a held seed reached root:\n%s", got) |
| + } |
| + if got := held.builds(); !slices.Equal(got, []string{"v2"}) { |
| + t.Fatalf("gate asked %v, want [v2]", got) |
| + } |
| +} |
| + |
| // A control channel that cannot name an executable for the original |
| // template refuses a generation-0 start that names one. It never drops the |
| // file and binds the live link instead. |