carlos / platform Public

release+host: RestartHibernating — the adopt pass reaches WARM exec children

Review found the exec-bounce branch dead in production. release.Pass splits
routes on the Hibernate FLAG and hands every flagged route restart=nil; exec
instances are minted Hibernate:true (provision/reconcile.go), so the
backing-dispatched callback was never consulted for them, warm or asleep. Net
effect on exec instances: zero.

The flag is not the question the restart decision wants answered. Hibernate
means "this route MAY sleep", not "it is asleep right now" — a busy exec child
spends every serving minute awake, and kept serving the binary its live
symlink no longer named until it happened to go idle.

RestartHibernating is the opt-in: hibernate-flagged routes get the restart
callback too. "An adopt pass never wakes a sleeping host" is preserved, moved
from withholding the callback to the callback's own semantics — Bounce returns
false without starting anything when the host is not awake, and try-restart is
a no-op on a stopped unit. Only a callback with those properties may be passed.
adoptLoop and canaryAdoptLoop set it (they run inside the process that owns the
children); RunAdopt does NOT, so root behavior is byte-identical. Sleepers are
still adopted last and still poison a bad build.

Also: makeRestartRoute's exec branch no longer swallows the unstamped
unit-tenant state (issue #14, adopt_advice.go) — a bounce that cycled nothing
falls through to try-restart, as it always did. The callback only fires on an
actual version change, so a genuinely sleeping exec child costs one no-op
try-restart per version, not a line per tick.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e676e72db9ebacf364f94c3597a3183a1ace3361 parent 4fb01ce
4 files changed, +347 −34
  • internal/host/agent.go +37 −7
  • internal/host/agent_test.go +104 −16
  • internal/release/adopt.go +76 −11
  • internal/release/adopt_test.go +130 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 5eedb0a43..31927673a 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -283,8 +283,18 @@ func adoptLoop(reg *registry.Registry, svc *activator.Service) {
// OnStageBlocked, by contrast, is exactly right here: it fires only
// when this sandboxed process cannot write the version store, and all
// it does is ask the root pass to do the part this one cannot.
+ //
+ // RestartHibernating, by contrast again, belongs HERE and not in
+ // RunAdopt: this process owns the exec children, so it is the only one
+ // whose restart callback can reach a warm hibernator at all. Without
+ // it the pass would split on the Hibernate FLAG and hand every exec
+ // instance the pure repoint — the flag says "may sleep", and a busy
+ // instance would go on serving the binary its symlink no longer names
+ // until it happened to go idle. Safe because makeRestartRoute cannot
+ // start a process on either of its paths; see release.RestartHibernating.
if err := release.Pass(ctx, reg, store, pub, optDir, restart,
- release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir()))); err != nil {
+ release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir())),
+ release.RestartHibernating(restart)); err != nil {
log.Printf("agent: adopt pass: %v", err)
}
}
@@ -703,8 +713,15 @@ func canaryAdoptLoop(reg *registry.Registry, svc *activator.Service) {
// the one step of that pickup this process cannot perform. Throttled
// inside stageRequester — this tick is 2-3 seconds, the request is at
// most one a minute.
+ //
+ // RestartHibernating IS wired here too, and for the same reason as on
+ // adoptLoop: this process owns the exec children. It matters more on
+ // this loop than on any other — a canary is promoted precisely to be
+ // picked up in seconds, and the routes following canary/* are the warm
+ // ones somebody is watching.
if err := release.PassFilter(ctx, reg, store, pub, optDir, restart, isCanaryRoute,
- release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir()))); err != nil {
+ release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir())),
+ release.RestartHibernating(restart)); err != nil {
log.Printf("agent: canary adopt pass: %v", err)
}
}
@@ -1237,10 +1254,23 @@ func statusTick(ctx context.Context, s release.Store, reg *registry.Registry, p
// cycled the process.
//
// Hosts the registry cannot answer for take the unit path: try-restart of a
-// nonexistent unit is a logged no-op, whereas a bounce of a process we might
-// not own is not. A nil bounce (no activator armed on this box) makes the
-// exec path a no-op rather than a fallthrough to systemctl — there is no
-// carlos-app@ unit behind an exec route to try-restart.
+// nonexistent unit is a no-op, whereas a bounce of a process we might not own
+// is not.
+//
+// A bounce that cycled NOTHING falls through to try-restart rather than
+// returning: an exec-backed route with an ACTIVE carlos-app@<host> unit is a
+// real state — an unstamped unit tenant, the one adopt_advice.go warns about
+// (issue #14) — and try-restart is what has always cycled it. The same
+// fallthrough covers a box with no activator armed at all. When the route
+// really is a sleeping exec child the cost is one try-restart of a stopped or
+// absent unit, which starts nothing and at worst logs once: this callback
+// only fires when the version actually moves (adoptHost early-returns on
+// cur == ver), so it is one line per version change per host, never per tick.
+//
+// Neither path can WAKE anything, which is what lets release.Pass hand this
+// callback hibernating routes (RestartHibernating): Bounce returns false
+// without starting anything when the host is not awake, and try-restart is
+// defined as a no-op on a unit that is not running.
//
// Errors are logged, never returned: a failed restart must not poison the
// adopted version for the other hosts in the pass (release/adopt.go's rule).
@@ -1253,8 +1283,8 @@ func makeRestartRoute(reg *registry.Registry, tryRestart func(host string), boun
if err == nil && rt != nil && rt.Backing != registry.BackingUnit {
if bounce != nil && bounce(host) {
log.Printf("adopt: bounced exec instance %s onto its new version", host)
+ return nil
}
- return nil
}
tryRestart(host)
return nil
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go
index 1a11066e6..6253653b9 100644
--- a/internal/host/agent_test.go
+++ b/internal/host/agent_test.go
@@ -543,24 +543,112 @@ func TestMakeRestartRouteUnknownHostFallsBackToUnit(t *testing.T) {
}
}
-// TestMakeRestartRouteExecWithNoActivatorIsANoOp: an exec-backed route on a
-// box with no activator armed must NOT fall through to systemctl — there is
-// no carlos-app@ unit behind an exec route, so try-restart would only log a
-// failure every pass. Nothing running, nothing to restart; and a nil bounce
-// must not panic.
-func TestMakeRestartRouteExecWithNoActivatorIsANoOp(t *testing.T) {
+// TestMakeRestartRouteExecFallsThroughWhenNothingBounced covers the state
+// issue #14 is about (adopt_advice.go's warning): a route claims exec backing
+// but a carlos-app@<host> unit is ACTIVE — an unstamped unit tenant. The
+// bounce finds nothing of its own to cycle and returns false, and try-restart
+// must still run, exactly as it did before the dispatch split. Same for a box
+// with no activator at all (nil bounce), which must not panic either.
+//
+// The cost when the route really is a sleeping exec child is one try-restart
+// of a stopped or absent unit — a no-op, at most one log line per version
+// change per host, since the callback only fires when the version moves.
+func TestMakeRestartRouteExecFallsThroughWhenNothingBounced(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ bounce func(string) bool
+ }{
+ {"no activator armed", nil},
+ {"activator armed but nothing awake", func(string) bool { return false }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ reg := restartTestRegistry(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "exec.test", Kind: "instance", App: "a", Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert exec route: %v", err)
+ }
+ var restarted []string
+ restart := makeRestartRoute(reg, func(h string) { restarted = append(restarted, h) }, tc.bounce)
+ if err := restart("exec.test"); err != nil {
+ t.Fatalf("restart exec.test: %v", err)
+ }
+ if len(restarted) != 1 || restarted[0] != "exec.test" {
+ t.Fatalf("a bounce that cycled nothing must fall through to try-restart: %v", restarted)
+ }
+ })
+ }
+}
+
+// TestAdoptPassBouncesAWarmHibernatingInstance is the end-to-end this task
+// exists for, through the real release.Pass with the real closure: an exec
+// instance minted the way provision does it (Hibernate: true, Backing "") is
+// warm when a new version lands, and it BOUNCES onto that version instead of
+// serving the old binary until it happens to hibernate.
+//
+// Before the RestartHibernating option this was dead code in production:
+// release.Pass split on the Hibernate FLAG and handed every flagged route
+// restart=nil, so the dispatch never ran for the routes it was written for.
+func TestAdoptPassBouncesAWarmHibernatingInstance(t *testing.T) {
+ dir := t.TempDir()
+ optDir := t.TempDir()
+ s := release.NewDirStore(dir)
+ ctx := context.Background()
+ plat := runtime.GOOS + "-" + runtime.GOARCH
+
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
reg := restartTestRegistry(t)
- if err := reg.Upsert(registry.Route{
- Host: "exec.test", Kind: "instance", App: "a", Status: "ready",
- }); err != nil {
- t.Fatalf("upsert exec route: %v", err)
+ // warm.test is awake; sleepy.test is not. Both are exec-backed hibernators
+ // — the shape provision/reconcile.go mints.
+ for _, h := range []string{"warm.test", "sleepy.test"} {
+ if err := reg.Upsert(registry.Route{
+ Host: h, Kind: "instance", App: "warmapp", Channel: "canary/dev",
+ Status: "ready", Hibernate: true,
+ }); err != nil {
+ t.Fatalf("upsert %s: %v", h, err)
+ }
}
- var restarted []string
- restart := makeRestartRoute(reg, func(h string) { restarted = append(restarted, h) }, nil)
- if err := restart("exec.test"); err != nil {
- t.Fatalf("restart exec.test: %v", err)
+ m := release.Manifest{
+ App: "warmapp", Version: "v1", Kind: "binary",
+ Artifacts: map[string]release.Artifact{plat: {}},
}
- if len(restarted) != 0 {
- t.Fatalf("exec route must not take the systemctl path: %v", restarted)
+ if err := release.Ship(ctx, s, "bab", m, map[string][]byte{plat: []byte("build one")}); err != nil {
+ t.Fatalf("Ship: %v", err)
+ }
+ if err := release.Promote(ctx, s, priv, "bab", "warmapp", "canary/dev", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+
+ var tried, bounced []string
+ // The activator's answer: only warm.test has a running child. This is
+ // exactly Service.Bounce's contract — false when the host is not awake, and
+ // it never starts anything.
+ restart := makeRestartRoute(reg,
+ func(h string) { tried = append(tried, h) },
+ func(h string) bool {
+ bounced = append(bounced, h)
+ return h == "warm.test"
+ })
+
+ if err := release.Pass(ctx, reg, s, pub, optDir, restart, release.RestartHibernating(restart)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+
+ // The warm instance was cycled onto the new binary.
+ if len(bounced) != 2 {
+ t.Fatalf("both hibernators must be offered to the callback, got %v", bounced)
+ }
+ if len(tried) != 1 || tried[0] != "sleepy.test" {
+ t.Fatalf("only the host with nothing awake falls through to try-restart: %v", tried)
+ }
+ // And both adopted: the sleeper gets the repoint and execs the new binary
+ // at its natural next wake — it is never started by the pass.
+ for _, h := range []string{"warm.test", "sleepy.test"} {
+ if v, err := release.LiveVersion(optDir, h); err != nil || v != "v1" {
+ t.Fatalf("%s live = %q, %v; want v1", h, v, err)
+ }
}
}
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 3cac99d93..49993e1d4 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -51,6 +51,12 @@ type passConfig struct {
// and every existing test — means the pass reports such a failure exactly
// as it always has and nothing else happens.
stageBlocked func(host, ver string, err error)
+
+ // restartHibernating is the callback hibernate-flagged routes get
+ // (RestartHibernating). nil — the default, and RunAdopt — keeps the
+ // original split: sleepers are repointed with restart=nil and no callback
+ // is consulted for them at all.
+ restartHibernating func(host string) error
}
// NagExpiredCanaries opts a Pass/PassFilter call into spec §5's nag: one
@@ -148,6 +154,42 @@ func OnStageBlocked(fn func(host, ver string, err error)) PassOption {
return func(c *passConfig) { c.stageBlocked = fn }
}
+// RestartHibernating extends restart to hibernate-flagged routes, which
+// otherwise get the pure repoint (restart=nil) and are never offered to the
+// callback at all.
+//
+// It exists because the flag is not the question the restart decision
+// actually wants answered. Hibernate=true means "this route MAY sleep", not
+// "it is asleep right now" — an exec instance is minted hibernating
+// (provision/reconcile.go) and then spends every serving minute awake. With
+// the flag alone deciding, a busy exec child kept serving the binary its
+// live symlink no longer names until it happened to go idle. Handing the
+// callback the sleepers lets it answer the real question at the moment it
+// matters.
+//
+// This does NOT weaken "an adopt pass never wakes a sleeping host" — it moves
+// the guarantee from withholding the callback to the callback's own
+// semantics, and only a callback with those semantics may be passed here.
+// The agent's makeRestartRoute qualifies on both halves: activator Bounce
+// returns false without starting anything when the host is not awake, and
+// `systemctl try-restart` is defined as a no-op on a unit that is not
+// running. Nothing on either path can start a process. A callback that could
+// start one belongs on the awake loop only.
+//
+// DO NOT pass this from RunAdopt (carlos-adopt.service). That process is not
+// the activator's parent: the exec children belong to the agent, so its only
+// available restart is systemctl, which cannot reach them — offering it
+// sleepers would buy nothing and cost a try-restart per sleeping host per
+// version change. Set by the agent's in-process adoptLoop/canaryAdoptLoop
+// alone, the two loops that run inside the process owning the children.
+//
+// Sleepers are still adopted AFTER every awake host and still poison a bad
+// build the same way, both unchanged: the option decides who the callback
+// sees, never the order or the health gate.
+func RestartHibernating(restart func(host string) error) PassOption {
+ return func(c *passConfig) { c.restartHibernating = restart }
+}
+
// stageBlocked reports whether err is the version store refusing a write to
// THIS uid, rather than any of the many other ways staging fails. errors.Is
// unwraps the *os.PathError that mkdir/openat return.
@@ -166,15 +208,23 @@ func stageBlocked(err error) bool {
// fleet unchecked. Only foundation failures (registry unreadable, bake clock
// unwritable) fail the pass.
//
-// restart cycles an awake host's process onto the new symlink target (the
+// restart cycles a host's process onto the new symlink target (the
// activator's proc converge); nil means symlink-only for every host. Hosts
-// whose route has Hibernate=true always get symlink-only and are NEVER
+// whose route has Hibernate=true get symlink-only by default and are NEVER
// woken: an hourly wake would defeat hibernation, and the next wake execs
// the new binary anyway since the spawn resolves live/<host> at exec time.
// The trade (from Eleven): no health check for a sleeper — a bad build
// surfaces at wake, recovered by Rollback, which repoints back. Hibernating
// hosts are processed AFTER every awake host, so an awake host discovers a
// boot-broken build (and poisons it) before any sleeper is moved onto it.
+//
+// RestartHibernating overrides only the FIRST half of that: it gives
+// hibernate-flagged hosts a restart callback too, because the flag means "may
+// sleep", not "is asleep" — a warm exec child must not go on serving a binary
+// its live symlink no longer names. It never makes the pass wake anything;
+// the option only accepts a callback that cannot start a process. See its doc
+// comment. Everything else above holds unchanged: sleepers still go last,
+// still poison a bad build, still have no health gate of their own.
func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir string, restart func(host string) error, opts ...PassOption) error {
return PassFilter(ctx, reg, s, pub, optDir, restart, nil, opts...)
}
@@ -420,10 +470,18 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
}
for _, rt := range hibernating {
ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}]
- // restart=nil: hibernating hosts get the pure repoint — never woken.
- // The capability converge still runs for them (a drop-in is unit
- // text, not a wake); see adoptHost.
- adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, nil, now, cfg, bakeIgnore(rt))
+ // cfg.restartHibernating is nil by default (and for RunAdopt): the
+ // pure repoint, never woken. The capability converge still runs for
+ // them (a drop-in is unit text, not a wake); see adoptHost.
+ //
+ // When it IS set — the agent's in-process loops — the callback is
+ // consulted for sleepers too, and the "never woken" guarantee moves
+ // into the callback: RestartHibernating only accepts one that cannot
+ // start a process (Bounce is false-when-not-awake, try-restart is a
+ // no-op on a stopped unit). The flag says a route MAY sleep, not that
+ // it is asleep now, and a warm exec child must not keep serving a
+ // binary its symlink no longer names.
+ adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, cfg.restartHibernating, now, cfg, bakeIgnore(rt))
}
return nil
}
@@ -495,8 +553,12 @@ func RouteAccount(rt registry.Route) string {
// adoptHost converges one host onto its channel head ptr (nil = the group's
// pointer didn't resolve; skip quietly, the group phase already logged).
-// restart==nil is the symlink-only path (hibernating hosts, or a caller with
-// no proc manager): repoint and record, never wake, never health-gate.
+// restart==nil is the symlink-only path (a caller with no proc manager, or a
+// hibernating host on a pass that did not set RestartHibernating): repoint
+// and record, never wake, never health-gate. A hibernating host WITH that
+// option set arrives here with a non-nil callback and takes the ordinary
+// path — safe because the option only accepts a callback that cannot start a
+// process, so "never woken" survives the callback being consulted.
// cfg is the call's PassOptions, already applied: cfg.nag nil (the ordinary
// case) means the canary-TTL nag below never fires, never even builds its
// string, and cfg.capsOps nil means the capability converge never runs at
@@ -587,9 +649,12 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
// the previous version's unit shape silently. Here a manifest this pass
// could not obtain REFUSES the version instead (capabilityPlan).
//
- // A hibernating route reaches this with restart == nil and still
- // converges: a drop-in is unit text, not a wake, and nothing below
- // starts it.
+ // A hibernating route converges here too: a drop-in is unit text, not a
+ // wake, and nothing below starts it. (It reaches this with restart == nil
+ // on an ordinary pass, or with the RestartHibernating callback on the
+ // agent's loops — which cannot start anything either. In practice this
+ // branch is root-only regardless: cfg.capsOps is set by RunAdopt alone,
+ // and RunAdopt does not set RestartHibernating.)
var (
prev caps.Snapshot
plan caps.Plan
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index b16f12a04..66e08cae4 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -78,6 +78,136 @@ func TestPassRepointsAwakeAndHibernating(t *testing.T) {
}
}
+// TestPassRestartHibernatingOptIn pins both sides of the RestartHibernating
+// door. Default (RunAdopt's shape, no option): hibernate-flagged routes get
+// the pure repoint and the callback never sees them — byte-identical to the
+// behavior TestPassRepointsAwakeAndHibernating above pins. Opted in (the
+// agent's in-process loops): the callback is consulted for sleepers too, so a
+// WARM exec child — minted Hibernate:true, but running right now — can be
+// cycled onto the binary this pass just pointed it at.
+//
+// The pass itself still starts nothing either way: all the option changes is
+// whether the callback is asked. Not waking a sleeper is the CALLBACK's
+// promise (Bounce returns false when nothing is awake; try-restart is a no-op
+// on a stopped unit), which is why handing it a sleeping host is safe.
+func TestPassRestartHibernatingOptIn(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ optIn bool
+ want []string
+ reason string
+ }{
+ {"default: sleepers are not offered to the callback", false,
+ []string{"awake.example.com"},
+ "the root adopt pass has no children of its own to cycle"},
+ {"opted in: sleepers are offered too", true,
+ []string{"awake.example.com", "sleepy.example.com"},
+ "a warm exec child must bounce onto its new binary"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addRoute(t, reg, "awake.example.com", "myapp", "canary/x", false)
+ addRoute(t, reg, "sleepy.example.com", "myapp", "canary/x", true)
+
+ var restarted []string
+ restart := func(host string) error { restarted = append(restarted, host); return nil }
+ var opts []PassOption
+ if tc.optIn {
+ opts = append(opts, RestartHibernating(restart))
+ }
+ if err := Pass(ctx, reg, s, pub, opt, restart, opts...); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if strings.Join(restarted, ",") != strings.Join(tc.want, ",") {
+ t.Fatalf("restarted = %v, want %v (%s)", restarted, tc.want, tc.reason)
+ }
+ // Either way the repoint happened for both — the option changes who
+ // is offered to the callback, never which hosts adopt.
+ for _, host := range []string{"awake.example.com", "sleepy.example.com"} {
+ if v, _ := LiveVersion(opt, host); v != "v1" {
+ t.Fatalf("%s live = %q, want v1", host, v)
+ }
+ }
+ })
+ }
+}
+
+// TestPassRestartHibernatingKeepsSleepersLast pins the ordering the option
+// must not disturb: awake hosts are still adopted BEFORE any sleeper, so an
+// awake host can discover a boot-broken build and poison it before a
+// hibernating host is moved onto it. Handing sleepers a callback must not
+// promote them into the first loop.
+func TestPassRestartHibernatingKeepsSleepersLast(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ // Named so a plain registry ordering (alphabetical by host) would put the
+ // sleeper first — only the awake/hibernating split can produce the order
+ // this test wants.
+ addRoute(t, reg, "aaa-sleepy.example.com", "myapp", "canary/x", true)
+ addRoute(t, reg, "zzz-awake.example.com", "myapp", "canary/x", false)
+
+ var order []string
+ restart := func(host string) error { order = append(order, host); return nil }
+ if err := Pass(ctx, reg, s, pub, opt, restart, RestartHibernating(restart)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ want := []string{"zzz-awake.example.com", "aaa-sleepy.example.com"}
+ if strings.Join(order, ",") != strings.Join(want, ",") {
+ t.Fatalf("restart order = %v, want %v (awake hosts must still go first)", order, want)
+ }
+}
+
+// TestPassRestartHibernatingPoisonsLikeAnyOtherHost: a sleeper's failed
+// restart takes the same rollback the awake path takes — the symlink goes
+// back and the version is poisoned for the rest of the pass. Nothing about
+// hibernation makes a broken build safe to leave pointed at.
+func TestPassRestartHibernatingPoisonsLikeAnyOtherHost(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one"))
+ shipRelease(t, s, "bab", "myapp", "v2", adoptPlatform, []byte("build two"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote v1: %v", err)
+ }
+ addRoute(t, reg, "sleepy.example.com", "myapp", "canary/x", true)
+ ok := func(string) error { return nil }
+ if err := Pass(ctx, reg, s, pub, opt, ok, RestartHibernating(ok)); err != nil {
+ t.Fatalf("Pass v1: %v", err)
+ }
+
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v2"); err != nil {
+ t.Fatalf("Promote v2: %v", err)
+ }
+ boom := func(string) error { return errors.New("bounce failed") }
+ if err := Pass(ctx, reg, s, pub, opt, boom, RestartHibernating(boom)); err != nil {
+ t.Fatalf("Pass v2: %v", err)
+ }
+ if v, _ := LiveVersion(opt, "sleepy.example.com"); v != "v1" {
+ t.Fatalf("live = %q, want the symlink rolled back to v1", v)
+ }
+}
+
// TestPassBakeDirMovesTheClock proves the BakeDir option's whole point
// (issue #13): the bake clock lands under the given dir, not optDir's
// legacy /opt-dir location.