activator, schedule: strict spawn and timer-control adapters (claim Plan 6 Task 5)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+248
−0
internal/activator/claimspawn.go+31 −0internal/activator/claimspawn_kernel_linux_test.go+84 −0internal/activator/claimspawn_test.go+40 −0internal/schedule/claimtimers.go+63 −0internal/schedule/claimtimers_test.go+30 −0
CI passed — run details
diff --git a/internal/activator/claimspawn.go b/internal/activator/claimspawn.go| new file mode 100644 |
| index 000000000..75a5eb498 |
| --- /dev/null |
| +++ b/internal/activator/claimspawn.go |
| @@ -0,0 +1,31 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "os/exec" |
| + |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| +) |
| + |
| +func spawnClaimed(ctx context.Context, enabled bool, manager *cgroup.Manager, host string, limit cgroup.Limit, selected hostwrite.Work, identity hostwrite.Identity, build func() *exec.Cmd) (*exec.Cmd, *cgroup.Prepared, error) { |
| + if !enabled { |
| + cmd, err := manager.Start(host, limit, build) |
| + return cmd, nil, err |
| + } |
| + if selected == nil || build == nil { |
| + return nil, nil, hostwrite.ErrUnavailable |
| + } |
| + var cmd *exec.Cmd |
| + var prepared *cgroup.Prepared |
| + err := selected.With(ctx, identity, func() error { |
| + var err error |
| + prepared, err = manager.PrepareStrict(host, limit) |
| + if err != nil { |
| + return err |
| + } |
| + cmd = build() |
| + return prepared.Start(cmd) |
| + }) |
| + return cmd, prepared, err |
| +} |
diff --git a/internal/activator/claimspawn_kernel_linux_test.go b/internal/activator/claimspawn_kernel_linux_test.go| new file mode 100644 |
| index 000000000..5cb96322d |
| --- /dev/null |
| +++ b/internal/activator/claimspawn_kernel_linux_test.go |
| @@ -0,0 +1,84 @@ |
| +//go:build linux |
| + |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| +) |
| + |
| +func TestClaimSpawnKernelChild(t *testing.T) { |
| + if os.Getenv("CARLOS_CLAIM_SPAWN_CHILD") != "1" { |
| + t.Skip("synthetic contained child only") |
| + } |
| + time.Sleep(time.Minute) |
| +} |
| + |
| +func TestClaimSpawnCleanupRespawn(t *testing.T) { |
| + if os.Getenv("CARLOS_CLAIM_SPAWN_KERNEL") != "1" { |
| + t.Skip("requires a disposable delegated user service") |
| + } |
| + manager := cgroup.Setup(t.Logf) |
| + if manager == nil { |
| + t.Fatal("missing delegated cgroup") |
| + } |
| + const host = "claimspawn.example" |
| + calls := 0 |
| + dir := t.TempDir() |
| + build := func() *exec.Cmd { |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestClaimSpawnKernelChild$", "-test.count=1") |
| + cmd.Env = []string{"CARLOS_CLAIM_SPAWN_CHILD=1"} |
| + cmd.Dir = dir |
| + return cmd |
| + } |
| + for attempt := range 2 { |
| + cmd, prepared, err := spawnClaimed(t.Context(), true, manager, host, cgroup.TierDefault, claimSpawnWork{false, &calls}, hostwrite.Identity{}, build) |
| + if err != nil || cmd == nil || prepared == nil { |
| + t.Fatalf("spawn %d: cmd=%v prepared=%v err=%v", attempt, cmd, prepared, err) |
| + } |
| + // The unit's KillMode covers fatal test exits. Normal cleanup below |
| + // must release the original reservation without removing the group. |
| + if err := prepared.Close(); !errors.Is(err, cgroup.ErrStrictRefused) { |
| + t.Fatalf("populated close: %v", err) |
| + } |
| + cleanup, err := manager.OpenCleanup(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) |
| + err = cleanup.KillAndEmpty(ctx) |
| + cancel() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var exitErr *exec.ExitError |
| + if err := cmd.Wait(); err != nil && !errors.As(err, &exitErr) { |
| + t.Fatal(err) |
| + } |
| + if cmd.ProcessState == nil { |
| + t.Fatal("child was not reaped") |
| + } |
| + if err := cleanup.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if other, err := manager.PrepareStrict(host, cgroup.TierDefault); other != nil || !errors.Is(err, cgroup.ErrStrictState) { |
| + t.Fatalf("cleanup close released original reservation: %v", err) |
| + } |
| + if populated, err := prepared.Populated(); err != nil || populated { |
| + t.Fatalf("pinned group not proven empty: %v %v", populated, err) |
| + } |
| + if err := prepared.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if calls != 2 { |
| + t.Fatal(calls) |
| + } |
| +} |
diff --git a/internal/activator/claimspawn_test.go b/internal/activator/claimspawn_test.go| new file mode 100644 |
| index 000000000..427255522 |
| --- /dev/null |
| +++ b/internal/activator/claimspawn_test.go |
| @@ -0,0 +1,40 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os/exec" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| +) |
| + |
| +type claimSpawnWork struct { |
| + deny bool |
| + calls *int |
| +} |
| + |
| +func (w claimSpawnWork) With(_ context.Context, _ hostwrite.Identity, fn func() error) error { |
| + *w.calls++ |
| + if w.deny { |
| + return hostwrite.ErrUnavailable |
| + } |
| + return fn() |
| +} |
| +func TestClaimSpawnNeverFallsBack(t *testing.T) { |
| + calls, builds := 0, 0 |
| + build := func() *exec.Cmd { builds++; return exec.Command("must-not-execute") } |
| + for _, deny := range []bool{true, false} { |
| + cmd, prepared, err := spawnClaimed(context.Background(), true, nil, "shop.example", cgroup.TierDefault, claimSpawnWork{deny, &calls}, hostwrite.Identity{}, build) |
| + if err == nil || cmd != nil || prepared != nil { |
| + t.Fatal(cmd, prepared, err) |
| + } |
| + if deny && !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatal(err) |
| + } |
| + } |
| + if builds != 0 || calls != 2 { |
| + t.Fatal(builds, calls) |
| + } |
| +} |
diff --git a/internal/schedule/claimtimers.go b/internal/schedule/claimtimers.go| new file mode 100644 |
| index 000000000..ff2b208d2 |
| --- /dev/null |
| +++ b/internal/schedule/claimtimers.go |
| @@ -0,0 +1,63 @@ |
| +package schedule |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +var errTimerHost = errors.New("schedule: selected timer host differs") |
| + |
| +type SelectedTimers struct { |
| + Store TimerStore |
| + Work hostwrite.Work |
| + Context context.Context |
| + Host string |
| +} |
| + |
| +func (s SelectedTimers) write(host string, fn func() error) error { |
| + if s.Store == nil || s.Work == nil || s.Context == nil { |
| + return hostwrite.ErrUnavailable |
| + } |
| + if host != s.Host { |
| + return errTimerHost |
| + } |
| + return s.Work.With(s.Context, hostwrite.Identity{}, fn) |
| +} |
| +func (s SelectedTimers) UpsertTimer(t registry.Timer) error { |
| + return s.write(t.Host, func() error { return s.Store.UpsertTimer(t) }) |
| +} |
| +func (s SelectedTimers) DeleteTimer(host, name string) error { |
| + return s.write(host, func() error { return s.Store.DeleteTimer(host, name) }) |
| +} |
| +func (s SelectedTimers) GetTimer(host, name string) (*registry.Timer, error) { |
| + if s.Store == nil { |
| + return nil, hostwrite.ErrUnavailable |
| + } |
| + if host != s.Host { |
| + return nil, errTimerHost |
| + } |
| + return s.Store.GetTimer(host, name) |
| +} |
| +func (s SelectedTimers) TimersFor(host string) ([]registry.Timer, error) { |
| + if s.Store == nil { |
| + return nil, hostwrite.ErrUnavailable |
| + } |
| + if host != s.Host { |
| + return nil, errTimerHost |
| + } |
| + return s.Store.TimersFor(host) |
| +} |
| +func (s SelectedTimers) CountTimers(host, kind string) (int, error) { |
| + if s.Store == nil { |
| + return 0, hostwrite.ErrUnavailable |
| + } |
| + if host != s.Host { |
| + return 0, errTimerHost |
| + } |
| + return s.Store.CountTimers(host, kind) |
| +} |
| + |
| +var _ TimerStore = SelectedTimers{} |
diff --git a/internal/schedule/claimtimers_test.go b/internal/schedule/claimtimers_test.go| new file mode 100644 |
| index 000000000..2fcd705ef |
| --- /dev/null |
| +++ b/internal/schedule/claimtimers_test.go |
| @@ -0,0 +1,30 @@ |
| +package schedule |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +type timerDeny struct{} |
| + |
| +func (timerDeny) With(context.Context, hostwrite.Identity, func() error) error { |
| + return hostwrite.ErrUnavailable |
| +} |
| +func TestSelectedTimerRejectsRetiredProducer(t *testing.T) { |
| + // A nil underlying pointer would panic if a denied write reached the registry. |
| + var reg *registry.Registry |
| + store := SelectedTimers{Store: reg, Work: timerDeny{}, Context: context.Background(), Host: "shop.example"} |
| + if err := store.UpsertTimer(registry.Timer{Host: "shop.example"}); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatal(err) |
| + } |
| + if err := store.DeleteTimer("shop.example", "one"); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatal(err) |
| + } |
| + if err := store.DeleteTimer("other.example", "one"); !errors.Is(err, errTimerHost) { |
| + t.Fatal(err) |
| + } |
| +} |