carlos / platform Public

activator: write a cold wake's build-state record at its exec (#272)

A cold wake recorded its build at step 2b, before the seed and the spawn.
The backend's own refusals came after that: the replica-key gate, and the
cgroup's (a killed cgroup kept, a populated legacy parent, a limit not
applied, a placement that failed). A refused launch ran nothing but left a
build state naming its build, and could complete an install request that
never ran. Owed before O8.

The record is now pending on the wake's context and written at the first
exec: the flat seed's and spawn's cgroup before-start hook (after every
cgroup refusal), a worker's, and, nested, right before root is asked to run
the seed or the run unit. This follows the design's rule that the record
comes after the caller's gates, immediately before the exec, rather than
undoing a write. A record of another build supersedes the pending one, and
a build whose plan was replaced before it ran is no longer recorded.

RunExecGate lets a backend outside the package honour the seam; the host
test fake now does, as ExecBackend does.

Test: TestARefusedLaunchLeavesNoBuildStateRecord (replica seed, replica
spawn, seed beside a populated legacy parent; fails on the old code), with
a control that a seed that ran is recorded once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 69cad3e298c81ba69f1f4da83b6867b8d35a1e31 parent 2683f16
8 files changed, +379 −68
  • internal/activator/activator.go +8 −3
  • internal/activator/backend_exec.go +7 −1
  • internal/activator/beforeexec.go +112 −32
  • internal/activator/beforeexec_test.go +52 −7
  • internal/activator/genbackend.go +33 −12
  • internal/activator/wakeidentity_test.go +24 −12
  • internal/activator/wakerecord_refused_test.go +137 −0
  • internal/host/buildstate_planned_test.go +6 −1
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 236a41ab7..61fd883bf 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -2278,8 +2278,11 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
}
}
if ib != nil {
- var idErr error
- id, w.pend, idErr = a.settleWakeIdentity(ctx, ib, e, rt, held, heldID)
+ var (
+ idErr error
+ wctx context.Context
+ )
+ id, w.pend, wctx, idErr = a.settleWakeIdentity(ctx, ib, e, rt, held, heldID)
if idErr != nil {
w.stopOutcome.leaseReleased = a.giveBack(ctx, host, lease)
return w, fmt.Errorf("start %s: %w", host, idErr)
@@ -2289,7 +2292,9 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
// R-EXEC-NOVERSION-2): today's live-link wake, nothing recorded.
ib = nil
} else {
- ctx = withRecordedBuild(ctx, id.Version)
+ // The record is pending on wctx, written at the first exec
+ // (amadan #272).
+ ctx = wctx
}
}
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index ddaa90770..9ab17047e 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -1037,7 +1037,7 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route, exe stri
// wedged seed would hold the lease until the wake's own 5m budget expired.
cmd.WaitDelay = seedDrainGrace
return cmd
- }, nil)
+ }, pendingRecordHook(ctx)) // the wake's record, at the exec (amadan #272)
spawned := err == nil
if spawned {
err = cmd.Wait()
@@ -1487,6 +1487,12 @@ func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route, exe *inst
// unchanged for the unit path.
_, statErr := os.Stat(dbPath)
seedOwnsDB := seedCreatedTheDB(statErr)
+ // The wake's pending record, once every refusal on this side has passed
+ // (amadan #272), and before the obligation, so a record that fails
+ // leaves nothing to discharge.
+ if err := runPendingRecord(ctx); err != nil {
+ return err
+ }
// The obligation is written ahead, as seedChild's is, before root is
// asked to run anything; it names the seed unit as the proof to ask.
if err := recordSeedObligation(host, dbPath, seedObligation{ownsDB: seedOwnsDB, layout: LayoutNested}); err != nil {
diff --git a/internal/activator/beforeexec.go b/internal/activator/beforeexec.go
index 81a7366b5..965376e57 100644
--- a/internal/activator/beforeexec.go
+++ b/internal/activator/beforeexec.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
+ "sync"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -28,10 +29,13 @@ var errBuildStateUnwritten = errors.New("the build state could not be written, s
// those carry startWorker's exec-time hook on a worker spawn's context, and
// that hook is one of recordBuild's callers.
//
-// A wake that already recorded this build (withRecordedBuild) is not
-// written twice. A build with no name (an executable outside the version
-// store) is refused while the hook is set: no build state could name it
-// (ruling R-EXEC-NOVERSION).
+// A cold wake's own record is pending until its first exec
+// (settleWakeIdentity, amadan #272): a call for that build writes nothing
+// here, and the exec seam writes it (runPendingRecord), after every refusal
+// the backend makes. A call for another build writes now and supersedes
+// it, so the exec seam never writes the wake's build over this one. A build
+// with no name (an executable outside the version store) is refused while
+// the hook is set: no build state could name it (ruling R-EXEC-NOVERSION).
//
// With host claims off the lease is the hibernate lease, so a context that
// names no location gets that one (Config.Bucket, HibernatePrefix), and the
@@ -42,7 +46,7 @@ func (a *Service) recordBuild(ctx context.Context, host, build string) error {
if a.BeforeExec == nil {
return nil
}
- if build != "" && recordedBuild(ctx) == build {
+ if p := pendingRecordFrom(ctx); p != nil && p.defers(build) {
return nil
}
if build == "" {
@@ -96,8 +100,9 @@ func (a *Service) claimedBuild(host string, s ClaimStart) error {
}
// settleWakeIdentity is doWake's step 2b (settings spec §4.2, Astra I1
-// #5): the one file this wake runs, its seed included, chosen and recorded
-// under the wake's lease after the restore and before anything execs.
+// #5): the one file this wake runs, its seed included, chosen under the
+// wake's lease after the restore and before anything execs. Its record is
+// made pending on the context it returns, and written at the first exec.
//
// - A planned wake runs its plan's boot build, and a pinned wake (a hold
// swap's) its pin. Step 0.8 published both before the lease.
@@ -131,9 +136,21 @@ func (a *Service) claimedBuild(host string, s ClaimStart) error {
// identity with no file comes back, nothing is published or recorded,
// and the caller takes the live-link calls in every layout.
//
-// Then the record (recordBuild). Any error means nothing was seeded or
-// spawned; the caller releases the lease.
-func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e *actEntry, rt registry.Route, held string, heldID *release.StagedIdentity) (release.StagedIdentity, pendingGeneration, error) {
+// The record is not written here (amadan #272). The context returned
+// carries it pending (withPendingRecord), and the seed's or the spawn's
+// exec seam writes it (runPendingRecord): after the backend's own refusals
+// (the replica-key gate, and the cgroup's: a killed cgroup kept, a
+// populated legacy parent, a limit not applied, a placement that failed),
+// immediately before the process execs. A launch refused before its exec
+// leaves no record naming a build that never ran, and completes no request
+// (settings spec §4.2: the record is what completes a request, at
+// execution). A recovery plan's boot (the good build in place of the
+// installed one the failure book blocks) is recorded with the planner's
+// selection (ruling R-PLANNED-RECOVERY), so the agent's revalidation admits
+// it before root's consumed file names the installed build failed. With no
+// hook nothing is pending, as nothing is recorded. Any error means nothing
+// was seeded or spawned; the caller releases the lease.
+func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e *actEntry, rt registry.Route, held string, heldID *release.StagedIdentity) (release.StagedIdentity, pendingGeneration, context.Context, error) {
a.mu.Lock()
pin, plan := e.pin, e.plan
a.mu.Unlock()
@@ -152,24 +169,24 @@ func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e
default:
var err error
if id, err = ib.LiveIdentity(ctx, rt); err != nil {
- return release.StagedIdentity{}, 0, err
+ return release.StagedIdentity{}, 0, ctx, err
}
captured = true
}
if !heldIdentityOK(held, id, plan, rt) {
log.Printf("activator: %s not woken: it is held on %s, and this wake would run %q (the live link moved after the check); the next wake checks again", rt.Host, held, id.Version)
- return release.StagedIdentity{}, 0, fmt.Errorf("%w: %s would run %q, wants %s", errStaleLink, rt.Host, id.Version, held)
+ return release.StagedIdentity{}, 0, ctx, fmt.Errorf("%w: %s would run %q, wants %s", errStaleLink, rt.Host, id.Version, held)
}
if captured {
if id.Version == "" {
- return release.StagedIdentity{}, 0, a.recordBuild(ctx, rt.Host, "")
+ return release.StagedIdentity{}, 0, ctx, a.recordBuild(ctx, rt.Host, "")
}
// A declined box's exec-time gate, asked about the file this wake
// will run, before it is published or recorded (askLiveExecGate):
// the spawn below runs it by path, past the backend's live-link
// check. Its refusal comes back as it is, as the backend's would.
if err := a.askLiveExecGate(rt, id.Version); err != nil {
- return release.StagedIdentity{}, 0, err
+ return release.StagedIdentity{}, 0, ctx, err
}
pend = a.addPendingGeneration(rt.Host, id)
if err := a.persistGenerations(); err != nil {
@@ -177,35 +194,98 @@ func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e
if e.worker {
// A worker's wake reads as its exec-time refusal always has
// (startWorker's hook, final review F9).
- return id, pend, fmt.Errorf("%w: %v", ErrBuildNotPublished, err)
+ return id, pend, ctx, fmt.Errorf("%w: %v", ErrBuildNotPublished, err)
}
- return id, pend, errStagedBuildNotPublished
+ return id, pend, ctx, errStagedBuildNotPublished
}
}
- // A recovery plan's boot (the good build in place of the installed one
- // the failure book blocks) is recorded with the planner's selection
- // (ruling R-PLANNED-RECOVERY), so the agent's revalidation admits it
- // before root's consumed file names the installed build failed.
+ if a.BeforeExec == nil {
+ return id, pend, ctx, nil
+ }
rctx := ctx
if plan.planned() {
rctx = withRecoverySelection(ctx, plan, rt, id.Version)
}
- if err := a.recordBuild(rctx, rt.Host, id.Version); err != nil {
- return id, pend, err
+ build := id.Version
+ return id, pend, withPendingRecord(ctx, build, func() error { return a.recordBuild(rctx, rt.Host, build) }), nil
+}
+
+// pendingRecord is one wake's record of build, written at most once, at
+// the first exec that asks for it (runPendingRecord).
+type pendingRecord struct {
+ build string
+ write func() error
+
+ mu sync.Mutex
+ // done is set once the write ran; err is its answer, which every later
+ // exec of the wake gets too.
+ done bool
+ err error
+ // superseded is set once a record of another build was written in this
+ // wake (recordBuild): the pending one would name the wrong build.
+ superseded bool
+}
+
+type pendingWakeKey struct{}
+
+// withPendingRecord returns ctx carrying build's pending record, written by
+// write.
+func withPendingRecord(ctx context.Context, build string, write func() error) context.Context {
+ return context.WithValue(ctx, pendingWakeKey{}, &pendingRecord{build: build, write: write})
+}
+
+func pendingRecordFrom(ctx context.Context) *pendingRecord {
+ p, _ := ctx.Value(pendingWakeKey{}).(*pendingRecord)
+ return p
+}
+
+// defers reports whether a record of build is the pending one's to write
+// (recordBuild then writes nothing). A record of another build supersedes
+// it, and once it is superseded nothing defers to it.
+func (p *pendingRecord) defers(build string) bool {
+ if build == "" {
+ return false
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if build != p.build {
+ p.superseded = true
+ return false
}
- return id, pend, nil
+ return !p.superseded
}
-type recordedBuildKey struct{}
+// run writes the record once. A superseded record writes nothing.
+func (p *pendingRecord) run() error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.superseded {
+ return nil
+ }
+ if !p.done {
+ p.err, p.done = p.write(), true
+ }
+ return p.err
+}
-// withRecordedBuild marks ctx as carrying a wake that has already recorded
-// build (doWake's step 2b records once, before its seed and its spawn).
-func withRecordedBuild(ctx context.Context, build string) context.Context {
- return context.WithValue(ctx, recordedBuildKey{}, build)
+// runPendingRecord writes ctx's pending wake record, if it carries one that
+// is still due. Every exec seam a cold wake reaches asks it immediately
+// before its exec: the flat seed's and spawn's cgroup before-start hook,
+// a worker's, and, nested, right before root is asked to run the seed or
+// the run unit. An error refuses that exec, with nothing run.
+func runPendingRecord(ctx context.Context) error {
+ if p := pendingRecordFrom(ctx); p != nil {
+ return p.run()
+ }
+ return nil
}
-// recordedBuild is the build ctx's wake already recorded, or "".
-func recordedBuild(ctx context.Context) string {
- s, _ := ctx.Value(recordedBuildKey{}).(string)
- return s
+// pendingRecordHook is runPendingRecord as a cgroup before-start hook for
+// a spawn that has no other: nil when ctx carries no pending record, so
+// that spawn is exactly as it was.
+func pendingRecordHook(ctx context.Context) func() error {
+ if pendingRecordFrom(ctx) == nil {
+ return nil
+ }
+ return func() error { return runPendingRecord(ctx) }
}
diff --git a/internal/activator/beforeexec_test.go b/internal/activator/beforeexec_test.go
index 239bf27c9..7ed397f31 100644
--- a/internal/activator/beforeexec_test.go
+++ b/internal/activator/beforeexec_test.go
@@ -9,6 +9,8 @@ import (
"sync"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/registry"
)
// execLog is a BeforeExec hook that records host@build, the backend's spawn
@@ -102,22 +104,65 @@ func TestTheHookIsToldWhereTheLeaseIs(t *testing.T) {
}
}
-// A wake that already recorded this build (Task 5b) is not written twice;
-// another build is.
+// A wake's own record (Task 5b) is pending until its first exec (amadan
+// #272): a call for that build writes nothing, the exec seam writes it
+// once, and another build is written at once. A record of another build
+// supersedes the pending one, which the exec seam then never writes.
func TestARecordedBuildIsNotWrittenTwice(t *testing.T) {
+ rt := registry.Route{Host: "one.example.com"}
a := &Service{cfg: Config{Bucket: "b"}}
var l execLog
a.BeforeExec = l.hook
- ctx := withRecordedBuild(context.Background(), "v1")
- if err := a.recordBuild(ctx, "one.example.com", "v1"); err != nil {
+ ctx := wakeRecord(a, rt.Host, "v1")
+ if err := a.recordBuild(ctx, rt.Host, "v1"); err != nil {
t.Fatal(err)
}
- if err := a.recordBuild(ctx, "one.example.com", "v2"); err != nil {
+ if calls, _, _ := l.seen(); len(calls) != 0 {
+ t.Fatalf("calls = %v before any exec, want none", calls)
+ }
+ for range 2 {
+ if err := runPendingRecord(ctx); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := a.recordBuild(ctx, rt.Host, "v2"); err != nil {
t.Fatal(err)
}
- if calls, _, _ := l.seen(); len(calls) != 1 || calls[0] != "one.example.com@v2" {
- t.Fatalf("calls = %v, want only the fallback's v2", calls)
+ if calls, _, _ := l.seen(); !slices.Equal(calls, []string{"one.example.com@v1", "one.example.com@v2"}) {
+ t.Fatalf("calls = %v, want v1 once at the exec, then the fallback's v2", calls)
}
+
+ var s execLog
+ a.BeforeExec = s.hook
+ ctx = wakeRecord(a, rt.Host, "v1")
+ if err := a.recordBuild(ctx, rt.Host, "v2"); err != nil {
+ t.Fatal(err)
+ }
+ if err := runPendingRecord(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if calls, _, _ := s.seen(); !slices.Equal(calls, []string{"one.example.com@v2"}) {
+ t.Fatalf("calls = %v, want only v2: the superseded v1 is never written", calls)
+ }
+
+ // A write that fails refuses every exec of the wake, and is not retried.
+ f := execLog{fail: failAll}
+ a.BeforeExec = f.hook
+ ctx = wakeRecord(a, rt.Host, "v1")
+ for range 2 {
+ if err := runPendingRecord(ctx); !errors.Is(err, errBuildStateUnwritten) {
+ t.Fatalf("err = %v, want errBuildStateUnwritten", err)
+ }
+ }
+ if calls, _, _ := f.seen(); len(calls) != 1 {
+ t.Fatalf("calls = %v, want one attempt", calls)
+ }
+}
+
+// wakeRecord is the context settleWakeIdentity returns for a wake of host
+// that settled build: its record pending.
+func wakeRecord(a *Service, host, build string) context.Context {
+ return withPendingRecord(context.Background(), build, func() error { return a.recordBuild(context.Background(), host, build) })
}
// Ruling R-WAKEWORDS: a member never sees the internal text.
diff --git a/internal/activator/genbackend.go b/internal/activator/genbackend.go
index 06fcc0b21..9ac8eaac7 100644
--- a/internal/activator/genbackend.go
+++ b/internal/activator/genbackend.go
@@ -84,11 +84,28 @@ func withExecGate(ctx context.Context, gate func() error) context.Context {
return context.WithValue(ctx, execGateKey{}, gate)
}
-// runExecGate asks ctx's exec gate, if it carries one. A spawn with a gate
-// also honours its context there: one that has ended starts nothing. With
-// no gate (every live-link spawn, the flag off) it is a no-op, so those
-// spawns are exactly as they were.
+// runExecGate asks ctx's exec gate, if it carries one, then writes ctx's
+// pending wake record, if it carries one (runPendingRecord, amadan #272):
+// the gate is the last refusal before the exec, and the record is written
+// only once nothing is left to refuse it. A spawn with a gate also honours
+// its context there: one that has ended starts nothing. With neither (every
+// live-link spawn, the flag off) it is a no-op, so those spawns are exactly
+// as they were.
func runExecGate(ctx context.Context) error {
+ if err := askExecGate(ctx); err != nil {
+ return err
+ }
+ return runPendingRecord(ctx)
+}
+
+// RunExecGate is runExecGate for a Backend implemented outside this
+// package: such a backend asks it immediately before its exec, as
+// ExecBackend does, so the caller's gate and the wake's pending record
+// (amadan #272) are honoured there.
+func RunExecGate(ctx context.Context) error { return runExecGate(ctx) }
+
+// askExecGate is runExecGate's gate alone.
+func askExecGate(ctx context.Context) error {
gate, ok := ctx.Value(execGateKey{}).(func() error)
if !ok || gate == nil {
return nil
@@ -103,31 +120,35 @@ func runExecGate(ctx context.Context) error {
// worker's) before-start hook for the cgroup
// manager (final review F1): launchHook (the test seam), then ctx's exec
// gate, asked immediately before each start attempt, after the cgroup's
-// preparation. Nil when ctx carries no gate and no test seam is set, so a
-// live-link spawn hands the manager no hook and starts exactly as it did.
+// preparation, and last ctx's pending wake record (runPendingRecord). Nil
+// when ctx carries no gate and no pending record and no test seam is set, so
+// a live-link spawn hands the manager no hook and starts exactly as it did.
func (b *ExecBackend) launchGate(ctx context.Context, host string) func() error {
return b.launchGateWith(ctx, host, nil)
}
// launchGateWith is launchGate with live, a live-link spawn's LiveExecGate
// check for the build it resolved (Task 9), asked after ctx's exec gate at
-// the same point. A nil live is launchGate exactly, so a spawn on a box with
-// no LiveExecGate hands the manager the hook it always did.
+// the same point, and before the pending record. A nil live is launchGate
+// exactly, so a spawn on a box with no LiveExecGate hands the manager the
+// hook it always did.
func (b *ExecBackend) launchGateWith(ctx context.Context, host string, live func() error) func() error {
- if _, ok := ctx.Value(execGateKey{}).(func() error); !ok && b.launchHook == nil && live == nil {
+ if _, ok := ctx.Value(execGateKey{}).(func() error); !ok && b.launchHook == nil && live == nil && pendingRecordFrom(ctx) == nil {
return nil
}
return func() error {
if b.launchHook != nil {
b.launchHook(host)
}
- if err := runExecGate(ctx); err != nil {
+ if err := askExecGate(ctx); err != nil {
return err
}
if live != nil {
- return live()
+ if err := live(); err != nil {
+ return err
+ }
}
- return nil
+ return runPendingRecord(ctx)
}
}
diff --git a/internal/activator/wakeidentity_test.go b/internal/activator/wakeidentity_test.go
index b0affa772..063f82b90 100644
--- a/internal/activator/wakeidentity_test.go
+++ b/internal/activator/wakeidentity_test.go
@@ -125,7 +125,12 @@ func (b *fakeBackend) LiveIdentity(_ context.Context, rt registry.Route) (releas
return fakeIdentity(b.ident.live), nil
}
-func (b *fakeBackend) StartGenExe(_ context.Context, rt registry.Route, n uint64, perGen bool, id release.StagedIdentity) (Proc, Gen, error) {
+func (b *fakeBackend) StartGenExe(ctx context.Context, rt registry.Route, n uint64, perGen bool, id release.StagedIdentity) (Proc, Gen, error) {
+ // The exec seam, as the real backend's: the exec gate and the wake's
+ // pending record (amadan #272), before anything starts.
+ if err := runExecGate(ctx); err != nil {
+ return nil, Gen{}, err
+ }
b.mu.Lock()
defer b.mu.Unlock()
b.starts++
@@ -147,7 +152,10 @@ func (b *fakeBackend) StartGenExe(_ context.Context, rt registry.Route, n uint64
return proc, g, nil
}
-func (b *fakeBackend) SeedExe(_ context.Context, rt registry.Route, id release.StagedIdentity) error {
+func (b *fakeBackend) SeedExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) error {
+ if err := runPendingRecord(ctx); err != nil {
+ return err
+ }
b.mu.Lock()
defer b.mu.Unlock()
b.seeds++
@@ -399,8 +407,9 @@ func TestAReplanBeforeTheSeedSeedsAndBootsOneBuild(t *testing.T) {
}
// D6: a replan that only the exec's own selection gate makes (after step
-// 2b recorded the first plan's boot) is recorded again by startPinnedAt
-// before it execs.
+// 2b settled the first plan's boot) is recorded by startPinnedAt before it
+// execs. The first plan's boot never ran, so its pending record is never
+// written (amadan #272).
func TestAReplanAfterTheRecordRecordsTheNewBuild(t *testing.T) {
dir := t.TempDir()
stale, next := stagedID(t, dir, "v2"), stagedID(t, dir, "v3")
@@ -415,8 +424,8 @@ func TestAReplanAfterTheRecordRecordsTheNewBuild(t *testing.T) {
}
done()
calls, at, _ := l.seen()
- if !slices.Equal(calls, []string{"one.example.com@v2", "one.example.com@v3"}) || at[1] != 0 {
- t.Fatalf("records %v at spawn counts %v, want v2 at the wake, then v3 before the replanned exec", calls, at)
+ if !slices.Equal(calls, []string{"one.example.com@v3"}) || at[0] != 0 {
+ t.Fatalf("records %v at spawn counts %v, want only v3, before the replanned exec", calls, at)
}
if gens := b.started(); len(gens) != 1 || gens[0].Build != "v3" {
t.Fatalf("spawned %+v, want the replanned v3", gens)
@@ -612,8 +621,10 @@ func TestARefusingHookStartsNothing(t *testing.T) {
if err := sa.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); !errors.Is(err, errBuildStateUnwritten) {
t.Fatalf("WakeWorker = %v, want errBuildStateUnwritten", err)
}
- if n := len(sb.calls()); n != 0 || sb.workerStarts() != 0 {
- t.Fatalf("%d staged worker spawns, %d children after a refused record", n, sb.workerStarts())
+ // The record is written at the worker's exec (amadan #272), so the
+ // spawn is entered and refused there: no child.
+ if sb.workerStarts() != 0 {
+ t.Fatalf("%d children after a refused record", sb.workerStarts())
}
next := stagedID(t, t.TempDir(), "v3")
@@ -925,7 +936,8 @@ func TestARefusedSidecarRecordStartsNoSidecar(t *testing.T) {
}
// At the wake: a replanned build is recorded again by its sidecar (the
- // wake's context names the first plan's), and that record is refused.
+ // wake's context names the first plan's, which never ran and so is never
+ // written), and that record is refused.
dir := t.TempDir()
stale, next := stagedID(t, dir, "v2"), stagedID(t, dir, "v3")
pa, pb, _ := planActivator(t, WakePlan{})
@@ -933,15 +945,15 @@ func TestARefusedSidecarRecordStartsNoSidecar(t *testing.T) {
pb.sidecarStaged = true
pa.Planner = laterReplan(stale, next)
var n atomic.Int32
- pl := &execLog{fail: func(string) bool { return n.Add(1) == 3 }}
+ pl := &execLog{fail: func(string) bool { return n.Add(1) == 2 }}
pa.BeforeExec = pl.hook
done, err = pa.beginRequest(context.Background(), identityRoute(t, host))
if err != nil {
t.Fatalf("the wake failed over its sidecar's record: %v", err)
}
done()
- if calls, _, _ := pl.seen(); !slices.Equal(calls, []string{host + "@v2", host + "@v3", host + "@v3"}) {
- t.Fatalf("records %v, want the wake's, the replanned spawn's and the sidecar's", calls)
+ if calls, _, _ := pl.seen(); !slices.Equal(calls, []string{host + "@v3", host + "@v3"}) {
+ t.Fatalf("records %v, want the replanned spawn's and the sidecar's", calls)
}
if sc := pb.sidecarsOn(); len(sc) != 0 || pb.sidecarStartCount() != 0 {
t.Fatalf("sidecars %+v (%d live): a sidecar ran unrecorded", sc, pb.sidecarStartCount())
diff --git a/internal/activator/wakerecord_refused_test.go b/internal/activator/wakerecord_refused_test.go
new file mode 100644
index 000000000..224175449
--- /dev/null
+++ b/internal/activator/wakerecord_refused_test.go
@@ -0,0 +1,137 @@
+//go:build linux
+
+package activator
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Amadan #272, owed before O8: a cold wake's build-state record names the
+// build about to run against the instance's database, and completes an
+// install request (settings spec §4.2). It used to be written at step 2b,
+// before the backend's own refusals: the replica-key gate and the cgroup's
+// (a populated legacy parent, a killed cgroup kept, a limit not applied).
+// A launch refused there ran nothing, yet left a record naming its build.
+// The record is now written at the exec, so a refused launch leaves none.
+
+// execIdentityBackend is fakeBackend for the lease and the restore, with a
+// real ExecBackend for the identity, the seed and the spawn: the exec seams
+// and the refusals under test are the production ones.
+type execIdentityBackend struct {
+ *fakeBackend
+ eb *ExecBackend
+}
+
+func (b *execIdentityBackend) identityFor(registry.Route) identityBackend { return b.eb }
+
+// stageScript makes body host's live build v1, as the adopt pass lays it
+// out (<opt>/versions/v1/app, linked from <opt>/live/<host>).
+func stageScript(t *testing.T, eb *ExecBackend, host, body string) {
+ t.Helper()
+ stageLive(t, eb, host, "v1")
+ if err := os.WriteFile(filepath.Join(eb.OptDir, "versions", "v1", "app"), []byte(body), 0o755); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// refusedWake runs one cold wake of rt over eb with a recording BeforeExec,
+// and returns the wake's error and the records written.
+func refusedWake(t *testing.T, eb *ExecBackend, rt registry.Route) (error, []string) {
+ t.Helper()
+ fb := &fakeBackend{restoreFresh: true}
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, &execIdentityBackend{fakeBackend: fb, eb: eb}, reg)
+ l := &execLog{}
+ a.BeforeExec = l.hook
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+ done, err := a.beginRequest(ctx, rt)
+ if err == nil {
+ done()
+ }
+ calls, _, _ := l.seen()
+ return err, calls
+}
+
+func TestARefusedLaunchLeavesNoBuildStateRecord(t *testing.T) {
+ const host = "shop.example.com"
+ route := func(eb *ExecBackend) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", Hibernate: true, App: "app",
+ Socket: noControlSocket(t), DBPath: filepath.Join(eb.DataDir, host+".db")}
+ }
+
+ t.Run("replica seed refused by the replica-key gate", func(t *testing.T) {
+ eb := testExecBackend(t)
+ stageScript(t, eb, host, "#!/bin/sh\nexit 0\n")
+ rt := route(eb)
+ rt.Replica, rt.Seed = true, `["seed"]`
+ err, calls := refusedWake(t, eb, rt)
+ if !errors.Is(err, ErrReplicaKey) {
+ t.Fatalf("wake = %v, want the replica-key gate's refusal", err)
+ }
+ if len(calls) != 0 {
+ t.Fatalf("records %v after a refused seed, want none", calls)
+ }
+ })
+
+ t.Run("replica spawn refused by the replica-key gate", func(t *testing.T) {
+ eb := testExecBackend(t)
+ stageScript(t, eb, host, "#!/bin/sh\nexit 0\n")
+ rt := route(eb)
+ rt.Replica = true
+ err, calls := refusedWake(t, eb, rt)
+ if !errors.Is(err, ErrReplicaKey) {
+ t.Fatalf("wake = %v, want the replica-key gate's refusal", err)
+ }
+ if len(calls) != 0 {
+ t.Fatalf("records %v after a refused spawn, want none", calls)
+ }
+ })
+
+ t.Run("seed refused beside a populated legacy parent", func(t *testing.T) {
+ eb, parent := delegatedBackend(t, host)
+ eb.GenContainment = true
+ if err := os.WriteFile(filepath.Join(parent, "cgroup.procs"), []byte("4242\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ stageScript(t, eb, host, "#!/bin/sh\nexit 0\n")
+ rt := route(eb)
+ rt.Seed = `["seed"]`
+ err, calls := refusedWake(t, eb, rt)
+ if !errors.Is(err, cgroup.ErrLegacyParentPopulated) {
+ t.Fatalf("wake = %v, want the cgroup's refusal", err)
+ }
+ if len(calls) != 0 {
+ t.Fatalf("records %v after a refused seed, want none", calls)
+ }
+ })
+
+ // The control: a seed that runs (and fails) did execute its build
+ // against the database, so it is recorded, once, before it ran.
+ t.Run("a seed that ran is recorded", func(t *testing.T) {
+ eb := testExecBackend(t)
+ stageScript(t, eb, host, "#!/bin/sh\nexit 1\n")
+ rt := route(eb)
+ rt.Seed = `["seed"]`
+ err, calls := refusedWake(t, eb, rt)
+ if err == nil {
+ t.Fatal("a failing seed woke the instance")
+ }
+ if !slices.Equal(calls, []string{host + "@v1"}) {
+ t.Fatalf("records %v, want v1 once", calls)
+ }
+ })
+}
diff --git a/internal/host/buildstate_planned_test.go b/internal/host/buildstate_planned_test.go
index 38eec7b52..13a29801c 100644
--- a/internal/host/buildstate_planned_test.go
+++ b/internal/host/buildstate_planned_test.go
@@ -80,7 +80,12 @@ func (b *plannedBackend) StartWorker(context.Context, registry.Route, map[string
return nil, "", errors.New("no worker")
}
-func (b *plannedBackend) StartStaged(_ context.Context, _ registry.Route, g activator.Gen) (activator.Gen, activator.Proc, error) {
+func (b *plannedBackend) StartStaged(ctx context.Context, _ registry.Route, g activator.Gen) (activator.Gen, activator.Proc, error) {
+ // The exec seam, as ExecBackend's: the caller's gate and the wake's
+ // pending record (amadan #272), before anything starts.
+ if err := activator.RunExecGate(ctx); err != nil {
+ return g, nil, err
+ }
b.mu.Lock()
defer b.mu.Unlock()
b.started = append(b.started, g.Build)