carlos / platform Public

zdd plan 1 task 1: generation counter, Gen and bounded generation socket paths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev bd5bcb50bfacf18eda51aada0dda2f61b71b64a7 parent 4f26f15
2 files changed, +195 −0
  • internal/activator/generations.go +100 −0
  • internal/activator/generations_test.go +95 −0
diff --git a/internal/activator/generations.go b/internal/activator/generations.go
new file mode 100644
index 000000000..15c83b055
--- /dev/null
+++ b/internal/activator/generations.go
@@ -0,0 +1,100 @@
+// generations.go is the generation identity of zero-downtime deploys
+// (docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md §7.1):
+// the process-wide counter, the bounded generation socket paths, the Gen
+// handle, and the dormant switch that keeps all of it off by default.
+package activator
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "path/filepath"
+ "strconv"
+ "sync/atomic"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// generationsOn is the dormant switch (spec §15). Off, every spawn binds the
+// route's canonical socket exactly as before, and nothing outside this
+// package changes behaviour. Plan 5 turns it on from CARLOS_SWAP=overlap by
+// calling SetGenerations(true) in internal/host's runAgent BEFORE
+// activator.New: a Service reads the switch once, at construction, so one
+// process never mixes the two socket schemes.
+var generationsOn atomic.Bool
+
+// SetGenerations sets the dormant switch for Services built after the call.
+func SetGenerations(on bool) { generationsOn.Store(on) }
+
+// GenerationsEnabled reports the switch as it stands now.
+func GenerationsEnabled() bool { return generationsOn.Load() }
+
+// genCounter is the ONE generation counter for the whole process (spec
+// §7.1). It is package-level, never per Service and never per entry, and it
+// is never reset: an entry deleted at a stop and recreated at the next wake
+// cannot reuse a number an earlier entry had, so a cached (route, n) pair in
+// the edge (same process) can never match a later generation.
+var genCounter atomic.Uint64
+
+// nextGen takes the next generation number. The first is 1; 0 means "no
+// generation" everywhere a Gen is optional.
+func nextGen() uint64 { return genCounter.Add(1) }
+
+// Gen is one spawn of one instance (spec §3 "Generation"). It is the
+// generation HANDLE (spec §7.1): returned by the spawn and held by whoever
+// started it. Health checks, settle observation and retirement use the
+// handle, never AppSocket. Plan 1 fills N, Socket, Control and Build;
+// StagedPath, Digest and Demand stay empty until plans 3 and 4 exec by
+// staged path.
+type Gen struct {
+ N uint64 // process-wide, never reused (spec §7.1)
+ Socket string // generation app socket (bounded path, spec §7.1)
+ Control string // Socket + ".control"
+ Build string // version name the generation runs
+ StagedPath string // immutable staged executable it exec'd (spec §7.5 step 3)
+ Digest string // sha256 of StagedPath at exec, hex
+ Demand string // the release's own `swap` demand verified for this artifact: "hold" or "" (spec §4.2, §8.5)
+}
+
+// GenSocketDir is where flat generation sockets live: /run/carlos/g.
+const GenSocketDir = provision.SocketDir + "/g"
+
+// GenSocketPath is the bounded flat generation path (spec §7.1):
+// /run/carlos/g/<first 12 hex of sha256(host)>.<n>.sock. It is at most 52
+// bytes (60 with ".control") whatever the hostname, because the hostname
+// never appears in it.
+func GenSocketPath(host string, n uint64) string {
+ return genSocketPathIn(GenSocketDir, host, n)
+}
+
+// genSocketPathIn is GenSocketPath under another directory (a test's
+// RunDirOverride).
+func genSocketPathIn(dir, host string, n uint64) string {
+ sum := sha256.Sum256([]byte(host))
+ return filepath.Join(dir, hex.EncodeToString(sum[:])[:12]+"."+strconv.FormatUint(n, 10)+".sock")
+}
+
+// NestedGenSocket is the nested layout's generation socket:
+// /run/carlos/instances/<InstanceID>/app.<n>.sock (spec §7.1). The
+// identifier is bounded at 40 bytes, so this is at most 92 bytes (100 with
+// ".control").
+func NestedGenSocket(host string, n uint64) string {
+ return nestedGenSocketIn(provision.InstanceRunDir(host), n)
+}
+
+// nestedGenSocketIn is NestedGenSocket under an already-resolved run dir.
+func nestedGenSocketIn(runDir string, n uint64) string {
+ return filepath.Join(runDir, "app."+strconv.FormatUint(n, 10)+".sock")
+}
+
+// canonicalGen is the generation handle of a spawn that bound the route's
+// canonical socket: every spawn with the switch off, a backend with no
+// generation support (unit-backed tenants, test doubles), and a foreign
+// layout row.
+func canonicalGen(rt registry.Route, n uint64, build string) Gen {
+ g := Gen{N: n, Socket: rt.Socket, Build: build}
+ if rt.Socket != "" {
+ g.Control = rt.Socket + ".control"
+ }
+ return g
+}
diff --git a/internal/activator/generations_test.go b/internal/activator/generations_test.go
new file mode 100644
index 000000000..bb26d138d
--- /dev/null
+++ b/internal/activator/generations_test.go
@@ -0,0 +1,95 @@
+package activator
+
+import (
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// withGenerations sets the dormant switch for one test and restores it after.
+// Every test that builds a Service with the switch on goes through here: the
+// switch is package state, so a test that forgot to restore it would turn
+// generations on for every test after it.
+func withGenerations(t *testing.T, on bool) {
+ t.Helper()
+ prev := GenerationsEnabled()
+ SetGenerations(on)
+ t.Cleanup(func() { SetGenerations(prev) })
+}
+
+// The cross-plan review item this plan owns: a 120-byte hostname gets
+// generation and control sockets under the 108-byte sockaddr_un limit, in
+// both layouts, at the largest generation number a uint64 can hold.
+func TestGenerationSocketsFitSockaddrUnForLongHosts(t *testing.T) {
+ for _, host := range []string{
+ "h",
+ strings.Repeat("a", 110) + ".bab.io", // 117 bytes
+ strings.Repeat("b", 113) + ".bab.io", // 120 bytes
+ strings.Repeat("c", 253),
+ } {
+ for _, n := range []uint64{1, 42, math.MaxUint64} {
+ flat := GenSocketPath(host, n)
+ if got := len(flat + ".control"); got > 60 {
+ t.Errorf("GenSocketPath(%d-byte host, %d)+.control = %d bytes, want at most 60 (spec §7.1)", len(host), n, got)
+ }
+ nested := NestedGenSocket(host, n)
+ for _, p := range []string{flat, flat + ".control", nested, nested + ".control"} {
+ if len(p) > provision.MaxUnixPath {
+ t.Errorf("%q is %d bytes, over the %d a unix socket address holds", p, len(p), provision.MaxUnixPath)
+ }
+ }
+ }
+ }
+}
+
+func TestGenSocketPathShape(t *testing.T) {
+ got := GenSocketPath("bill.go.tito.io", 7)
+ if !strings.HasPrefix(got, "/run/carlos/g/") || !strings.HasSuffix(got, ".7.sock") {
+ t.Fatalf("GenSocketPath = %q, want /run/carlos/g/<h12>.7.sock", got)
+ }
+ h12 := strings.TrimSuffix(strings.TrimPrefix(got, "/run/carlos/g/"), ".7.sock")
+ if len(h12) != 12 || strings.Trim(h12, "0123456789abcdef") != "" {
+ t.Fatalf("hash part %q is not 12 lower-case hex digits", h12)
+ }
+ if strings.Contains(got, "bill") {
+ t.Fatalf("GenSocketPath %q embeds the hostname; the bound depends on it never doing so", got)
+ }
+ if GenSocketPath("bill.go.tito.io", 8) == got || GenSocketPath("other.go.tito.io", 7) == got {
+ t.Fatal("generation paths collide across generations or hosts")
+ }
+ if got, want := NestedGenSocket("bill.go.tito.io", 7), "/run/carlos/instances/bill.go.tito.io/app.7.sock"; got != want {
+ t.Fatalf("NestedGenSocket = %q, want %q", got, want)
+ }
+}
+
+func TestGenCounterIsProcessWideAndNeverRepeats(t *testing.T) {
+ seen := map[uint64]bool{}
+ var last uint64
+ for i := 0; i < 1000; i++ {
+ n := nextGen()
+ if n == 0 || seen[n] || n <= last {
+ t.Fatalf("nextGen returned %d after %d (seen=%v); want strictly increasing, never 0", n, last, seen[n])
+ }
+ seen[n], last = true, n
+ }
+}
+
+func TestCanonicalGenKeepsTheRouteSocket(t *testing.T) {
+ rt := registry.Route{Host: "h.example.com", Socket: "/run/carlos/h.example.com.sock"}
+ g := canonicalGen(rt, 3, "v1")
+ if g.N != 3 || g.Socket != rt.Socket || g.Control != rt.Socket+".control" || g.Build != "v1" {
+ t.Fatalf("canonicalGen = %+v", g)
+ }
+ if g := canonicalGen(registry.Route{Host: "w"}, 4, ""); g.Control != "" {
+ t.Fatalf("a socketless route got control %q", g.Control)
+ }
+}
+
+func TestGenerationsSwitchDefaultsOff(t *testing.T) {
+ if GenerationsEnabled() {
+ t.Fatal("generations are on by default; CARLOS_SWAP unset must leave them off (spec §15)")
+ }
+}