carlos / platform Public

zdd-5 final review: ask the exec gate at the backend's launch point (F1)

Every staged instance spawn (a planned wake's B and its fallback, a
hold's pinned build, an overlap successor) carries its composed gate
into StartStaged on the context (withExecGate). ExecBackend asks it
(runExecGate) after all of its preparation: flat, after the token, the
config read and the control socket's bind, right before the process
starts; nested, after the launch file, right before root's start call.
A refusal starts nothing, unbinds the listeners and removes a nested
generation's launch file, returns no process (so no execution
evidence), and classifies as the gate's own refusal: abandonment. A
spawn with a gate also refuses once its context has ended. Spawns with
no gate on their context (every live-link spawn, the flag off) are
unchanged. The earlier gate calls stay.

SplitBackend forwards the context unchanged; the test backends ask the
gate at their launch point too. Tests run the real flat and nested
preparation for all four callers with the refusal landed inside
StartStaged, plus merge-triage T9's short-window refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 607da3e848e5f0628e5d13b584b720bd9c9445fe parent 1328fe6
9 files changed, +480 −6
  • internal/activator/backend_exec.go +35 −0
  • internal/activator/exec_gate_test.go +373 −0
  • internal/activator/genbackend.go +42 −0
  • internal/activator/genbackend_test.go +4 −1
  • internal/activator/swap.go +4 −2
  • internal/activator/swap_fixture_test.go +5 −0
  • internal/activator/swap_sim_test.go +5 −0
  • internal/activator/swaphold.go +6 −2
  • internal/activator/wakeplan_test.go +6 −1
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 230caca69..21848458d 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -216,6 +216,11 @@ type ExecBackend struct {
// strictHook, when set, is called at named points of the strict
// cleanup path. Test-only: it makes interleavings deterministic.
strictHook func(point string)
+ // launchHook, when set, is called by a staged spawn once its
+ // preparation is done, immediately before its exec gate (runExecGate).
+ // Test-only: it lands a lease loss, a fence or a supersession after
+ // the preparation and before the launch (final review F1).
+ launchHook func(host string)
}
// hostGate serialises everything that touches one host's group once Strict
@@ -1440,6 +1445,21 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc
if exe == "" {
spawnVer, _ = b.LiveVersion(host)
}
+ // The caller's exec gate (final review F1), after every step above: the
+ // token, the config read and the control socket's bind all take time,
+ // and the lease, a fence or the attempt can change meanwhile. A refusal
+ // starts nothing and unbinds the listener, as a failed spawn does; no
+ // child cgroup has been placed yet. With no gate on ctx (every live-link
+ // spawn) this does nothing.
+ if b.launchHook != nil {
+ b.launchHook(host)
+ }
+ if err := runExecGate(ctx); err != nil {
+ if ctl != nil {
+ ctl.Close()
+ }
+ return nil, "", err
+ }
// Spawned through the cgroup manager (nil-tolerant: no delegation means
// a plain start, exactly as before) so the child lands in
// carlos-exec@<host> and the sampler can account it. build may run
@@ -1627,6 +1647,21 @@ func (b *ExecBackend) spawnUnitFrom(ctx context.Context, rt registry.Route, sock
// direction as startChild: a concurrent repoint at worst records the
// older version (one extra convergence cycle), never the newer.
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
+ // launch file (it carries the instance's environment) goes, and the
+ // listeners close, as after root's own refusal (ErrStagedExe below).
+ // With no gate on ctx (every live-link spawn) this does nothing.
+ if b.launchHook != nil {
+ b.launchHook(rt.Host)
+ }
+ if err := runExecGate(ctx); err != nil {
+ if gen != 0 {
+ _ = os.Remove(filepath.Join(p.dataDir, instancectl.GenLaunchFileName(gen)))
+ }
+ return fail(err)
+ }
inv, err := b.startRole(ctx, rt, instancectl.RoleRun, gen, exe, tier)
if errors.Is(err, instancectl.ErrStagedExe) {
// Root refused the staged build before it laid out or started
diff --git a/internal/activator/exec_gate_test.go b/internal/activator/exec_gate_test.go
new file mode 100644
index 000000000..4d0ebb0c3
--- /dev/null
+++ b/internal/activator/exec_gate_test.go
@@ -0,0 +1,373 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// launchBackend is the swap fixture's fakeSwapBackend (lease, restore,
+// raise, switch) with StartStaged on a REAL ExecBackend, flat or nested, so
+// a staged spawn runs the production preparation: the admin token, the
+// config read, the control socket's bind and, nested, the launch file and
+// the root start call. The activator's row is mapped onto the real
+// backend's row (rt) of the chosen layout. Its KillGen stops the
+// generation's handle, as execStagedBackend's does.
+type launchBackend struct {
+ *fakeSwapBackend
+ eb *ExecBackend
+ rt registry.Route
+ ctl *exeControl // nested only: what root was asked to start
+
+ lmu sync.Mutex
+ entered []uint64 // generation numbers that reached the real backend
+ procs map[uint64]Proc
+ builds []string // the builds the real backend returned a process for
+}
+
+func (b *launchBackend) StartStaged(ctx context.Context, _ registry.Route, g Gen) (Gen, Proc, error) {
+ b.lmu.Lock()
+ b.entered = append(b.entered, g.N)
+ b.lmu.Unlock()
+ got, p, err := b.eb.StartStaged(ctx, b.rt, g)
+ if p != nil {
+ b.lmu.Lock()
+ b.procs[g.N] = p
+ b.builds = append(b.builds, g.Build)
+ b.lmu.Unlock()
+ }
+ return got, p, err
+}
+
+func (b *launchBackend) KillGen(ctx context.Context, _ registry.Route, n uint64) error {
+ b.lmu.Lock()
+ p := b.procs[n]
+ b.lmu.Unlock()
+ if p == nil {
+ return nil
+ }
+ _ = p.Stop(ctx)
+ if !p.Exited() {
+ return ErrStillRunning
+ }
+ return nil
+}
+
+func (b *launchBackend) gens() []uint64 {
+ b.lmu.Lock()
+ defer b.lmu.Unlock()
+ return slices.Clone(b.entered)
+}
+
+func (b *launchBackend) stopAll() {
+ b.lmu.Lock()
+ procs := make([]Proc, 0, len(b.procs))
+ for _, p := range b.procs {
+ procs = append(procs, p)
+ }
+ b.lmu.Unlock()
+ for _, p := range procs {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ _ = p.Stop(ctx)
+ cancel()
+ }
+}
+
+// launched is the evidence that a build's process was started: the real
+// backend returned a process for it; flat, the marker its script writes the
+// moment it runs; nested, root was asked to start the unit with it.
+func (b *launchBackend) launched(t *testing.T, id release.StagedIdentity, marker string) bool {
+ t.Helper()
+ b.lmu.Lock()
+ started := slices.Contains(b.builds, id.Version)
+ b.lmu.Unlock()
+ if started {
+ return true
+ }
+ if b.ctl != nil {
+ b.ctl.mu.Lock()
+ defer b.ctl.mu.Unlock()
+ for _, x := range b.ctl.got {
+ if x.Path == id.StagedPath {
+ return true
+ }
+ }
+ return false
+ }
+ _, err := os.Stat(marker)
+ return err == nil
+}
+
+// prepGone checks that what generation n's preparation made is gone after
+// a refusal at the launch: its control socket, and nested, its launch file.
+func (b *launchBackend) prepGone(t *testing.T, n uint64) {
+ t.Helper()
+ var sock string
+ p := b.eb.pathsFor(b.rt)
+ switch p.layout {
+ case LayoutNested:
+ sock = nestedGenSocketIn(p.runDir, n)
+ launch := filepath.Join(p.dataDir, instancectl.GenLaunchFileName(n))
+ if _, err := os.Stat(launch); !os.IsNotExist(err) {
+ t.Fatalf("generation %d's launch file %s after a refusal at the launch: %v, want it removed", n, launch, err)
+ }
+ default:
+ sock = b.eb.flatGenSocket(b.rt.Host, n)
+ }
+ if _, err := os.Stat(sock + ".control"); !os.IsNotExist(err) {
+ t.Fatalf("generation %d's control socket after a refusal at the launch: %v, want it unbound", n, err)
+ }
+}
+
+// newLaunchBackend builds the real backend for layout and wraps fb.
+func newLaunchBackend(t *testing.T, layout string, fb *fakeSwapBackend) *launchBackend {
+ t.Helper()
+ b := &launchBackend{fakeSwapBackend: fb, procs: map[uint64]Proc{}}
+ switch layout {
+ case "flat":
+ eb := testExecBackend(t)
+ eb.RunDirOverride = t.TempDir()
+ host := "launch.example.com"
+ stageLive(t, eb, host, "v0")
+ b.eb, b.rt = eb, registry.Route{Host: host, Kind: "instance", Hibernate: true,
+ DBPath: filepath.Join(eb.DataDir, host+".db"), Socket: filepath.Join(eb.RunDirOverride, host+".sock")}
+ case "nested":
+ eb, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ eb.GenContainment = true
+ b.ctl = &exeControl{}
+ eb.Control = b.ctl
+ b.eb, b.rt = eb, rt
+ default:
+ t.Fatalf("layout %q", layout)
+ }
+ if got, _ := b.eb.Layout(b.rt); (layout == "flat") != (got == LayoutFlat) || (layout == "nested") != (got == LayoutNested) {
+ t.Fatalf("fixture layout %v, want %s", got, layout)
+ }
+ t.Cleanup(b.stopAll)
+ return b
+}
+
+// markerScript is a staged build that writes marker the moment it runs.
+func markerScript(t *testing.T, dir, ver, marker string) release.StagedIdentity {
+ t.Helper()
+ return stagedScript(t, dir, ver, "#!/bin/sh\necho ran > '"+marker+"'\nexec sleep 30\n")
+}
+
+// onCall runs f on the call numbered want (counting from 1) and on no
+// other.
+func onCall(want int32, f func()) func(string) {
+ var n atomic.Int32
+ return func(string) {
+ if n.Add(1) == want {
+ f()
+ }
+ }
+}
+
+// Final review F1: every staged instance spawn's composed gate is asked at
+// the backend's final launch point, AFTER the backend's own preparation:
+// flat, after the token, the config read and the control socket's bind,
+// right before the process starts; nested, after the launch file, right
+// before root is asked to start the unit. A lease lost, a fence set or the
+// attempt superseded during that preparation starts nothing, removes what
+// the preparation made, records no execution evidence and is abandonment,
+// never a failure of the build. The four callers: a planned wake's B, its
+// fallback, a hold's pinned build and an overlap successor, on both
+// layouts. Before the fix the gate ran only before StartStaged, and each of
+// these launched.
+func TestTheExecGateIsAskedAtTheBackendsLaunchPoint(t *testing.T) {
+ for _, layout := range []string{"flat", "nested"} {
+ t.Run(layout+"/planned B, lease lost", func(t *testing.T) {
+ b := newLaunchBackend(t, layout, &fakeSwapBackend{fakeBackend: &fakeBackend{}})
+ dir := t.TempDir()
+ mark := filepath.Join(t.TempDir(), "v2.ran")
+ next := markerScript(t, dir, "v2", mark)
+ good := markerScript(t, dir, "v1", filepath.Join(t.TempDir(), "v1.ran"))
+ a, evs := launchActivator(t, b, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
+ b.eb.launchHook = onCall(1, func() { b.lastRep().setErr(errors.New("lease renewal failed")) })
+ _, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if !errors.Is(err, errSwapLeaseLost) || classifyStep(nil, err) == stepFailed {
+ t.Fatalf("wake = %v, want the lost lease, abandonment", err)
+ }
+ gens := b.gens()
+ if len(gens) != 1 {
+ t.Fatalf("the backend was entered for generations %v, want B's alone (the refusal is inside it)", gens)
+ }
+ if b.launched(t, next, mark) {
+ t.Fatal("B was launched after the lease was lost during its preparation")
+ }
+ b.prepGone(t, gens[0])
+ time.Sleep(50 * time.Millisecond)
+ if k := evs.kinds(); len(k) != 0 {
+ t.Fatalf("events %v: a refusal at the launch was recorded", k)
+ }
+ })
+ t.Run(layout+"/planned fallback, fenced", func(t *testing.T) {
+ defer func(k time.Duration) { firstWakeKillBound = k }(firstWakeKillBound)
+ firstWakeKillBound = 500 * time.Millisecond
+ b := newLaunchBackend(t, layout, &fakeSwapBackend{fakeBackend: &fakeBackend{}})
+ dir := t.TempDir()
+ markB, markGood := filepath.Join(t.TempDir(), "v2.ran"), filepath.Join(t.TempDir(), "v1.ran")
+ next, good := markerScript(t, dir, "v2", markB), markerScript(t, dir, "v1", markGood)
+ a, evs := launchActivator(t, b, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
+ const host = "one.example.com"
+ a.genHealth = func(context.Context, Gen) error { return errors.New("GET / answered 500") }
+ b.eb.launchHook = onCall(2, func() {
+ a.mu.Lock()
+ a.fences = map[string]uint64{host: 1}
+ a.mu.Unlock()
+ })
+ if _, err := a.beginRequest(context.Background(), testRoute(host)); err == nil {
+ t.Fatal("served after the fallback was refused")
+ }
+ if gens := b.gens(); len(gens) != 2 {
+ t.Fatalf("the backend was entered for generations %v, want B's and the fallback's", gens)
+ }
+ waitFor(t, "B's launch evidence", func() bool { return b.launched(t, next, markB) })
+ time.Sleep(100 * time.Millisecond)
+ if b.launched(t, good, markGood) {
+ t.Fatal("the good build was launched on a host fenced during its preparation")
+ }
+ b.prepGone(t, b.gens()[1])
+ // B ran and failed: that is recorded. The refused fallback is not
+ // a fallback that served.
+ evs.wait(t, GenFailed)
+ if k := evs.kinds(); slices.Contains(k, GenFellBack) {
+ t.Fatalf("events %v: a fallback refused at its launch was reported as served", k)
+ }
+ })
+ t.Run(layout+"/hold's pinned build, lease lost", func(t *testing.T) {
+ f := newSwapFixture(t)
+ b := newLaunchBackend(t, layout, f.b)
+ f.a.backend = b
+ sink := &lockedSink{}
+ f.a.Logs = sink
+ mark := filepath.Join(t.TempDir(), "v2.ran")
+ id := markerScript(t, t.TempDir(), "v2", mark)
+ at := f.attempt(ModeHold)
+ at.StagedPath, at.Digest = id.StagedPath, id.Digest
+ b.eb.launchHook = onCall(1, func() { f.b.lastRep().setErr(errors.New("lease renewal failed")) })
+ res := f.a.Swap(context.Background(), at, f.newest)
+ if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapLeaseLost.Error()) {
+ t.Fatalf("Swap = %+v, want abandoned: the lost lease", res)
+ }
+ gens := b.gens()
+ if len(gens) != 1 {
+ t.Fatalf("the backend was entered for generations %v, want the pin's alone", gens)
+ }
+ if b.launched(t, id, mark) {
+ t.Fatal("the hold's build was launched after the lease was lost during its preparation")
+ }
+ b.prepGone(t, gens[0])
+ if got := sink.errors(); len(got) != 0 {
+ t.Fatalf("member error events %q for an abandoned hold", got)
+ }
+ })
+ t.Run(layout+"/overlap successor, superseded", func(t *testing.T) {
+ f := newSwapFixture(t)
+ b := newLaunchBackend(t, layout, f.b)
+ f.a.backend = b
+ mark := filepath.Join(t.TempDir(), "v2.ran")
+ id := markerScript(t, t.TempDir(), "v2", mark)
+ at := f.attempt(ModeOverlap)
+ at.StagedPath, at.Digest = id.StagedPath, id.Digest
+ b.eb.launchHook = onCall(1, func() { f.rev.Add(1) })
+ res := f.a.Swap(context.Background(), at, f.newest)
+ if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) {
+ t.Fatalf("Swap = %+v, want abandoned: superseded", res)
+ }
+ gens := b.gens()
+ if len(gens) != 1 {
+ t.Fatalf("the backend was entered for generations %v, want the successor's alone", gens)
+ }
+ if b.launched(t, id, mark) {
+ t.Fatal("the successor was launched after its attempt was superseded during its preparation")
+ }
+ b.prepGone(t, gens[0])
+ if g, members := f.a.genStateForTest(f.host); g.N != f.incumbent.N || len(members) != 0 {
+ t.Fatalf("current %+v, members %v: want the incumbent serving and an empty operation set", g, members)
+ }
+ })
+ }
+}
+
+// launchActivator is planActivator over a launchBackend.
+func launchActivator(t *testing.T, b *launchBackend, plan WakePlan) (*Service, *genLog) {
+ t.Helper()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, b, reg)
+ a.gens = true
+ evs := &genLog{}
+ a.OnGen = evs.add
+ a.Planner = func(context.Context, registry.Route) WakePlan { return plan }
+ a.genHealth = func(context.Context, Gen) error { return nil }
+ t.Cleanup(func() { a.Shutdown(context.Background()) })
+ return a, evs
+}
+
+// Merge triage T9: on a first wake whose health window is short (under the
+// verdict floor, so B would not be tried and the good build would boot in
+// its place), a refusal by B's own gate is abandonment: nothing boots, B is
+// not reported untried, and nothing is recorded. verdictFloorGate asks the
+// gate first, so a build held since the plan (or a lost lease, a fence) is
+// never mistaken for a lack of time. Here B's selection check passes at
+// checkPlan and refuses at B's exec. With the floor asked first, the good
+// build booted untried and the wake reported GenHealthy.
+func TestAGateRefusalOnAShortWindowAbandonsRatherThanGoingUntried(t *testing.T) {
+ shrinkVerdictFloor(t, time.Hour) // every window is under the floor
+ dir := t.TempDir()
+ good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
+ a, b, evs := planActivator(t, WakePlan{})
+ var asked atomic.Int32
+ a.Planner = func(context.Context, registry.Route) WakePlan {
+ return WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good,
+ Check: func(build string) error {
+ if build == "v2" && asked.Add(1) >= 2 {
+ return errors.New("v2 failed on a sibling")
+ }
+ return nil
+ }}
+ }
+ _, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if !errors.Is(err, errPlanStale) || errors.Is(err, errNoTimeForVerdict) {
+ t.Fatalf("wake = %v, want the gate's refusal (errPlanStale), never a lack of time", err)
+ }
+ if got := classifyStep(nil, err); got != stepStale {
+ t.Fatalf("classifyStep = %v, want stepStale: abandonment", got)
+ }
+ if gens := b.started(); len(gens) != 0 {
+ t.Fatalf("spawned %+v: the good build booted untried after B's gate refused", gens)
+ }
+ time.Sleep(50 * time.Millisecond)
+ if k := evs.kinds(); len(k) != 0 {
+ t.Fatalf("events %v: an abandoned short-window wake reported something (an untried B reads as GenHealthy)", k)
+ }
+}
+
+// The exec gate rides on the context through SplitBackend to its exec half,
+// which asks it at its launch (genbackend.go's contract): a refusal there
+// reaches the caller unchanged.
+func TestSplitBackendCarriesTheExecGate(t *testing.T) {
+ exec := &recordingGenExec{fakeBackend: &fakeBackend{}}
+ s := &SplitBackend{Exec: exec, Unit: &UnitBackend{}}
+ refused := errors.New("refused at the launch")
+ ctx := withExecGate(context.Background(), func() error { return refused })
+ _, p, err := s.StartStaged(ctx, registry.Route{Host: "e.example.com"}, Gen{N: 7, StagedPath: "/opt/carlos/versions/v2/app"})
+ if !errors.Is(err, refused) || p != nil || exec.started != 0 {
+ t.Fatalf("StartStaged = %v, %v (started %d): want the gate's refusal and nothing started", p, err, exec.started)
+ }
+}
diff --git a/internal/activator/genbackend.go b/internal/activator/genbackend.go
index a883bc340..7eb496cd1 100644
--- a/internal/activator/genbackend.go
+++ b/internal/activator/genbackend.go
@@ -31,6 +31,13 @@ type SwapBackend interface {
// filled in, and never waits for health: the caller checks health
// through that handle under its own deadline, and stops (or kills) a
// process that fails. A nil Proc means nothing ran.
+ //
+ // ctx may carry the caller's exec gate (withExecGate; final review F1).
+ // An implementation asks it (runExecGate) at its final launch point,
+ // after all of its preparation and immediately before the process or
+ // unit is started, and when it refuses starts nothing, cleans up what
+ // its preparation made, and returns the gate's error as it is with a
+ // nil Proc.
StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error)
// GenTier is build's own tier, from its staged manifest (spec §7.3).
// The swap captures it into Gen.Tier at admission.
@@ -52,6 +59,41 @@ type SwapBackend interface {
GenSwitched(rt registry.Route, g Gen, p Proc) error
}
+// execGateKey carries a staged instance spawn's exec gate on its context
+// (withExecGate).
+type execGateKey struct{}
+
+// withExecGate returns ctx carrying gate, the caller's composed ownership
+// and attempt checks for a staged instance spawn (final review F1): a
+// planned wake's B or its fallback, a hold's pinned build, an overlap
+// successor. The backend asks it at the very end of its preparation, right
+// before the exec (flat) or the root start call (nested), so a lease lost,
+// a fence set or an attempt superseded while the backend read config,
+// bound sockets or wrote launch files execs nothing. It rides on the
+// context, as withBeforeExec does for workers, so StartStaged's signature
+// and every forwarding backend (SplitBackend) stay as they are.
+func withExecGate(ctx context.Context, gate func() error) context.Context {
+ if gate == nil {
+ return ctx
+ }
+ 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.
+func runExecGate(ctx context.Context) error {
+ gate, ok := ctx.Value(execGateKey{}).(func() error)
+ if !ok || gate == nil {
+ return nil
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return gate()
+}
+
// StartStaged is plan 1's StartGen with perGen true, except for the
// executable, the tier and the health wait, which is the caller's. A flat
// child execs g.StagedPath directly. A nested unit's executable mount names
diff --git a/internal/activator/genbackend_test.go b/internal/activator/genbackend_test.go
index 0601823fd..bf20e32d9 100644
--- a/internal/activator/genbackend_test.go
+++ b/internal/activator/genbackend_test.go
@@ -58,7 +58,10 @@ type recordingGenExec struct {
started int
}
-func (r *recordingGenExec) StartStaged(_ context.Context, _ registry.Route, g Gen) (Gen, Proc, error) {
+func (r *recordingGenExec) StartStaged(ctx context.Context, _ registry.Route, g Gen) (Gen, Proc, error) {
+ if err := runExecGate(ctx); err != nil {
+ return g, nil, err
+ }
r.started++
return g, &fakeProc{}, nil
}
diff --git a/internal/activator/swap.go b/internal/activator/swap.go
index 2afb1d11d..83b9f1fac 100644
--- a/internal/activator/swap.go
+++ b/internal/activator/swap.go
@@ -298,7 +298,9 @@ func (a *Service) genHealthy(ctx context.Context, g Gen) error {
//
// gate is the attempt gate (memberCurrentLocked, taking a.mu itself). It
// runs immediately before every exec: claims off, right before
-// StartStaged. Claims on, it belongs after the runtime's Read and Prepare
+// StartStaged, and again inside it at the backend's final launch point,
+// after the backend's preparation (withExecGate; final review F1). Claims
+// on, it belongs after the runtime's Read and Prepare
// and again inside the selected work at the exec, as the spawn's before
// hook (plan 3 Task 10).
//
@@ -314,7 +316,7 @@ func (a *Service) spawnSuccessor(ctx context.Context, gb SwapBackend, e *actEntr
if err := gate(); err != nil {
return g, nil, err
}
- return gb.StartStaged(ctx, rt, g)
+ return gb.StartStaged(withExecGate(ctx, gate), rt, g)
}
if a.claimSpawn != nil {
return a.claimSpawn(ctx, e, rt, work, g)
diff --git a/internal/activator/swap_fixture_test.go b/internal/activator/swap_fixture_test.go
index e8beea1e7..a13ad344a 100644
--- a/internal/activator/swap_fixture_test.go
+++ b/internal/activator/swap_fixture_test.go
@@ -91,6 +91,11 @@ func (g *fakeSwapBackend) StartStaged(ctx context.Context, _ registry.Route, gen
if err != nil {
return gen, nil, err
}
+ // The caller's exec gate at the launch point (final review F1), after
+ // the preparation genGate stands for, as the real backend asks it.
+ if err := runExecGate(ctx); err != nil {
+ return gen, nil, err
+ }
g.gmu.Lock()
hold := g.genHold
g.gmu.Unlock()
diff --git a/internal/activator/swap_sim_test.go b/internal/activator/swap_sim_test.go
index 55add7e38..4362f8540 100644
--- a/internal/activator/swap_sim_test.go
+++ b/internal/activator/swap_sim_test.go
@@ -600,6 +600,11 @@ func (b *simBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen)
if err := ctx.Err(); err != nil {
return g, nil, err
}
+ // The caller's exec gate at the launch point, after the preparation
+ // the jitter stands for (final review F1).
+ if err := runExecGate(ctx); err != nil {
+ return g, nil, err
+ }
g.Socket = filepath.Join(b.dir, fmt.Sprintf("%s.%d.sock", rt.Host, g.N))
g.Control = g.Socket + ".control"
p, err := b.startOutcome(rt.Host, g.Build, g.N)
diff --git a/internal/activator/swaphold.go b/internal/activator/swaphold.go
index d29880ea4..2e6591912 100644
--- a/internal/activator/swaphold.go
+++ b/internal/activator/swaphold.go
@@ -336,7 +336,11 @@ func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) (
// startPinnedAt is startPinned with gate, when set, asked immediately
// before StartStaged: a refusal there returns no process, with nothing of
-// the build run.
+// the build run. The same gate rides into StartStaged on its context
+// (withExecGate; final review F1) and is asked again at the backend's final
+// launch point, after the backend's own preparation (the config read, the
+// sockets, a nested row's launch file). A refusal there is the same: no
+// process, so no execution evidence (startPinnedChecked, spawnPlanned).
//
// healthBy, when set, caps the health check (zero-downtime spec §8.2): a
// first wake's B is checked until the earlier of healthBy (the waiters'
@@ -361,7 +365,7 @@ func (a *Service) startPinnedAt(ctx context.Context, rt registry.Route, pin Gen,
return nil, "", pin, err
}
}
- g, proc, err := gb.StartStaged(ctx, rt, pin)
+ g, proc, err := gb.StartStaged(withExecGate(ctx, gate), rt, pin)
if err != nil {
return proc, "", pin, err
}
diff --git a/internal/activator/wakeplan_test.go b/internal/activator/wakeplan_test.go
index 4ca6c2739..4a96ffb25 100644
--- a/internal/activator/wakeplan_test.go
+++ b/internal/activator/wakeplan_test.go
@@ -44,13 +44,18 @@ func newPlanGenBackend() *planGenBackend {
return &planGenBackend{fakeBackend: &fakeBackend{}, procs: map[uint64]*fakeProc{}}
}
-func (b *planGenBackend) StartStaged(_ context.Context, _ registry.Route, g Gen) (Gen, Proc, error) {
+func (b *planGenBackend) StartStaged(ctx context.Context, _ registry.Route, g Gen) (Gen, Proc, error) {
b.gmu.Lock()
gate := b.gate
b.gmu.Unlock()
if gate != nil {
<-gate
}
+ // The caller's exec gate at the launch point (final review F1), as the
+ // real backend asks it after its preparation.
+ if err := runExecGate(ctx); err != nil {
+ return g, nil, err
+ }
b.gmu.Lock()
defer b.gmu.Unlock()
g.Socket = "/run/carlos/g/test." + strconv.FormatUint(g.N, 10) + ".sock"