zdd rev 19: generation sockets keep the flag-off file name
Gate F crash-looped a tenant whose framework reads its host from the file name of --socket: app.3.sock parsed as the host "app.3" (amadan #272). Implements the rev 19 amendment's rules 1-5. 1. Paths: nested <run dir>/g<n>/app.sock, flat /run/carlos/g/<n>/<host>.sock. Control stays <socket>.control; the canonical link still targets the generation socket by absolute path. 2. Launch parity: tests pin, for both layouts, that a generation launch's argv and agent-owned env equal the flag-off launch's apart from the socket's directory and CARLOS_CONTROL_SOCKET. 3. Directory lifecycle: clearOwnGenSocket (flat) and prepareAt (nested) make g<n> 0750 setgid before the stale-file removal; genTerminated and handlelessGenEmpty remove the generation's own directory once the proof holds and it is empty. Only a directory named for g.N that holds g.Socket is a candidate, so siblings, the run dir and a canonical generation are never touched. 4. Length bound: a flat host whose generation control socket would exceed 107 bytes at the largest generation number (hosts over 59 bytes) reads foreign to generations (genLayout / GenLayout / swapLayout): canonical socket, no GenPaths, overlap runs as a hold. Classify and pathsFor are unchanged, so flag-off spawns and the layout pass still see it as flat. 5. zddprobe exits 1 before listening when CARLOS_HOST is empty or the --socket base name is neither app.sock nor $CARLOS_HOST.sock.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 files changed,
+753
−102
cmd/zddprobe/main.go+26 −0cmd/zddprobe/main_test.go+69 −4internal/activator/backend_exec.go+23 −13internal/activator/genbackend.go+39 −5internal/activator/generations.go+85 −18internal/activator/generations_backend_test.go+18 −14internal/activator/generations_test.go+69 −24internal/activator/gensocket_rev19_test.go+360 −0internal/activator/opset.go+18 −3internal/activator/prepare.go+16 −3internal/activator/swap.go+5 −5internal/activator/swaphold.go+23 −11internal/instanceexec/exec_test.go+2 −2
CI failed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 337.094s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.042s
ok github.com/carlosframework/platform/cmd/moveprobe 93.252s
ok github.com/carlosframework/platform/cmd/zddprobe 0.489s
ok github.com/carlosframework/platform/internal/activator 233.534s
ok github.com/carlosframework/platform/internal/agentsock 0.015s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.291s
ok github.com/carlosframework/platform/internal/apperr 0.081s
ok github.com/carlosframework/platform/internal/applog 0.154s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.703s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.693s
ok github.com/carlosframework/platform/internal/changelog 0.298s
ok github.com/carlosframework/platform/internal/clamd 0.066s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 363.518s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.937s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.008s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.004s
ok github.com/carlosframework/platform/internal/domains 2.112s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 25.892s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.007s
ok github.com/carlosframework/platform/internal/fleetapi 0.120s
ok github.com/carlosframework/platform/internal/gate 1.654s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.017s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/host 97.515s
ok github.com/carlosframework/platform/internal/hostactors 0.577s
ok github.com/carlosframework/platform/internal/hostbox 2.190s
ok github.com/carlosframework/platform/internal/hostclaim 0.515s
ok github.com/carlosframework/platform/internal/hostevidence 0.015s
ok github.com/carlosframework/platform/internal/hostexec 4.910s
ok github.com/carlosframework/platform/internal/hostfence 0.035s
ok github.com/carlosframework/platform/internal/hostfiles 10.524s
ok github.com/carlosframework/platform/internal/hostfloor 0.015s
ok github.com/carlosframework/platform/internal/hostguard 1.007s
ok github.com/carlosframework/platform/internal/hostops 1.425s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.015s
ok github.com/carlosframework/platform/internal/hostquarantine 0.015s
ok github.com/carlosframework/platform/internal/hostrelocate 0.437s
ok github.com/carlosframework/platform/internal/hostresolve 0.140s
ok github.com/carlosframework/platform/internal/hostwrite 0.015s
ok github.com/carlosframework/platform/internal/infraview 0.015s
ok github.com/carlosframework/platform/internal/instancectl 1.813s
ok github.com/carlosframework/platform/internal/instanceexec 2.732s
ok github.com/carlosframework/platform/internal/instancefs 0.019s
ok github.com/carlosframework/platform/internal/instancekey 0.017s
ok github.com/carlosframework/platform/internal/instanceupdates 0.030s
ok github.com/carlosframework/platform/internal/layout 1.404s
ok github.com/carlosframework/platform/internal/lease 9.451s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.013s
ok github.com/carlosframework/platform/internal/mail 0.163s
ok github.com/carlosframework/platform/internal/maintenance 0.080s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.598s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.067s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 20.999s
ok github.com/carlosframework/platform/internal/moves/archive 0.756s
ok github.com/carlosframework/platform/internal/objstore 0.173s
ok github.com/carlosframework/platform/internal/ops 0.167s
ok github.com/carlosframework/platform/internal/park 0.203s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
2026/10/13 22:42:35 provision: listing apps/bab/blog/instances/: list refused — its records are unseen this pass
2026/10/13 22:42:35 provision: listing apps/bab/: listdirs refused — its records are unseen this pass
2026/10/13 22:42:35 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:35 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:35 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:35 provision: listing apps/bac/tito/instances/: list refused — its records are unseen this pass
2026/10/13 22:42:35 provision: listing apps/bac/tito/instances/: list refused — its records are unseen this pass
2026/10/13 22:42:35 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:35 provision: remove retired instance route shop.example: provision: claim admission unavailable
2026/10/13 22:42:35 provision: remove retired instance route shop.example: provision: claim admission unavailable
2026/10/13 22:42:35 provision: remove retired sidecar route worker.sidecar.internal: provision: claim admission unavailable
2026/10/13 22:42:35 provision: remove retired sidecar route worker.sidecar.internal: provision: claim admission unavailable
2026/10/13 22:42:35 provision: claim install acme.go.tito.io: provision: claim admission unavailable
2026/10/13 22:42:35 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:35 provision: claim install acme.go.tito.io: registry: claim row ownership changed or unknown
2026/10/13 22:42:35 provision: claim install bot.eleven.bac.sidecar.internal: provision: claim admission unavailable
2026/10/13 22:42:35 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["run"])
2026/10/13 22:42:35 provision: claim install bot.eleven.bac.sidecar.internal: registry: claim row ownership changed or unknown
2026/10/13 22:42:35 provision: refusing to mint route acme.go.tito.io (app "tito" acct "bac"): this box declares CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic
2026/10/13 22:42:35 provision: acme.go.tito.io: not minting this pass: the box's layout intent could not be read: no sealed config
2026/10/13 22:42:35 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:35 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:35 provision: REFUSING to mint sidecar bot.eleven.bac.sidecar.internal: bot.eleven.bac.sidecar.internal-sidecar.env is staged on this box — the legacy file co-spawn and a native sidecar row are two mechanisms for one host; remove the file to migrate this host onto sidecar records
2026/10/13 22:42:35 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["run"])
2026/10/13 22:42:35 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["run"])
2026/10/13 22:42:35 provision: refusing to mint route acme.go.tito.io (app "tito" acct "bac"): this box declares CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic
2026/10/13 22:42:35 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:35 provision: REFUSING to mint sidecar bot.eleven.bac.sidecar.internal: bot.eleven.bac.sidecar.internal-sidecar.env is staged on this box — the legacy file co-spawn and a native sidecar row are two mechanisms for one host; remove the file to migrate this host onto sidecar records
2026/10/13 22:42:35 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["run"])
2026/10/13 22:42:35 provision: acme.go.tito.io exists but is not the route this record describes (app "other"/"tito") — leaving it alone
2026/10/13 22:42:35 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:35 provision: claim install acme.go.tito.io: registry: claim row ownership changed or unknown
2026/10/13 22:42:35 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:35 provision: claim install acme.go.tito.io: registry: claim row ownership changed or unknown
2026/10/13 22:42:35 provision: bot.eleven.bac.sidecar.internal exists but is not a sidecar row this pass owns (kind "sidecar" provisioned false app "eleven" acct "bac") — leaving it alone
2026/10/13 22:42:35 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["run"])
2026/10/13 22:42:35 provision: claim install bot.eleven.bac.sidecar.internal: registry: claim row ownership changed or unknown
2026/10/13 22:42:35 provision: acme.go.tito.io exists but is not the route this record describes (app "other"/"tito") — leaving it alone
2026/10/13 22:42:36 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: adopted existing route acme.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:42:36 provision: bot.eleven.bac.sidecar.internal exists but is not a sidecar row this pass owns (kind "sidecar" provisioned false app "eleven" acct "bac") — leaving it alone
2026/10/13 22:42:36 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:36 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: skipping record apps/bac/tito/instances/acme.go.tito.io.json: malformed: provision: invalid maintenance projection or protected fields
2026/10/13 22:42:36 provision: skipping record apps/bac/tito/instances/new.go.tito.io.json: malformed: provision: invalid maintenance projection or protected fields
2026/10/13 22:42:36 provision: not removing acme.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:42:36 provision: skipping record apps/bac/tito/instances/acme.go.tito.io.json: malformed: provision: invalid maintenance projection or protected fields
2026/10/13 22:42:36 provision: skipping record apps/bac/tito/instances/new.go.tito.io.json: malformed: provision: invalid maintenance projection or protected fields
2026/10/13 22:42:36 provision: minted instance route ra.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route re.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rf.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rh.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route ri.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rj.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rk.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rl.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rb.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rc.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rd.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rg.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rb.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rc.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route rd.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route ra.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: skipping record apps/bac/tito/instances/rb.go.tito.io.json: read failed: read refused
2026/10/13 22:42:36 provision: skipping record apps/bac/tito/instances/rb.go.tito.io.json: read failed: read refused
2026/10/13 22:42:36 provision: minted instance route re.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route x.appe.example (app "appe" acct "bac")
2026/10/13 22:42:36 provision: minted instance route x.appf.example (app "appf" acct "bac")
2026/10/13 22:42:36 provision: minted instance route x.appg.example (app "appg" acct "bac")
2026/10/13 22:42:36 provision: minted instance route x.apph.example (app "apph" acct "bac")
2026/10/13 22:42:36 provision: minted instance route x.appi.example (app "appi" acct "bac")
2026/10/13 22:42:36 provision: minted instance route x.appc.example (app "appc" acct "bac")
2026/10/13 22:42:36 provision: minted instance route x.appd.example (app "appd" acct "bac")
2026/10/13 22:42:36 provision: minted instance route x.appj.example (app "appj" acct "bac")
2026/10/13 22:42:36 provision: minted instance route x.appa.example (app "appa" acct "bac")
2026/10/13 22:42:36 provision: minted instance route x.appb.example (app "appb" acct "bac")
2026/10/13 22:42:36 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:42:36 provision: minted instance route r0000.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0070.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0077.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0090.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0093.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0094.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0001.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0075.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0049.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0050.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0078.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0088.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0121.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0126.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0003.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0035.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0055.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0069.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0110.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0127.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0014.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0037.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0095.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0025.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0057.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0064.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0085.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0123.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0011.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0012.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0013.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0033.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0063.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0074.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0034.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0044.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0091.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0028.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0108.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0113.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0120.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0016.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0042.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0087.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0092.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0098.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0114.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0129.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0052.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0102.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0026.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0061.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0105.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0122.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0007.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0024.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0043.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0060.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0062.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0080.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0010.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0022.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0100.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0101.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0036.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0086.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0117.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0048.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0072.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0106.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0009.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0038.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0068.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0081.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0084.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0004.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0045.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0079.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0118.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0124.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0002.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0119.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0006.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0056.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0058.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0089.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0020.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0082.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0104.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0111.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0017.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0019.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0051.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0023.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0046.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0083.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0031.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0039.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0066.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0116.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0041.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0099.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0125.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0015.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0065.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0096.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0128.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0008.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0067.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0071.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0076.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0103.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0005.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0053.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0054.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0097.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0115.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0027.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0040.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0073.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0112.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0059.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0018.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0032.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0107.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0109.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0021.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0029.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0030.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:36 provision: minted instance route r0047.go.tito.io (app "tito" acct "bac")
--- FAIL: TestPlan14RootReadCensus (9.70s)
plan14_root_reads_test.go:664: root read census: 24 discrepancies
changed root read internal/activator/backend_exec.go:ExecBackend.startGenFrom; review class, task, sites and digest
changed root read internal/activator/genbackend.go:ExecBackend.GenPaths; review class, task, sites and digest
changed root read internal/activator/genbackend.go:ExecBackend.StartStaged; review class, task, sites and digest
changed root read internal/activator/opset.go:Service.genTerminated; review class, task, sites and digest
changed root read internal/activator/opset.go:Service.handlelessGenEmpty; review class, task, sites and digest
changed root read internal/activator/prepare.go:ExecBackend.prepareAt; review class, task, sites and digest
changed root read internal/activator/swap.go:Service.swapOverlap; review class, task, sites and digest
changed root read internal/activator/swaphold.go:Service.foreignRow; review class, task, sites and digest
changed root read internal/activator/swaphold.go:Service.holdAdmitLocked; review class, task, sites and digest
changed root source envelope cmd/zddprobe
changed root source envelope internal/activator
unclassified root read internal/activator/genbackend.go:ExecBackend.GenLayout registry:Host@103,whole:Route@78
unclassified root read internal/activator/genbackend.go:SplitBackend.GenLayout file:Exec@116,registry:Backing@65,whole:Route@165,whole:Route@194
unclassified root read internal/activator/generations.go:removeGenDir file:ReadDir@254,registry:Host@470,whole:Route@78
unclassified root read internal/activator/swaphold.go:Service.swapLayout whole:Route@129,whole:Route@203
changed legacy declaration internal/activator/backend_exec.go:ExecBackend.startGenFrom
changed legacy declaration internal/activator/genbackend.go:ExecBackend.GenPaths
changed legacy declaration internal/activator/genbackend.go:ExecBackend.StartStaged
changed legacy declaration internal/activator/opset.go:Service.genTerminated
changed legacy declaration internal/activator/opset.go:Service.handlelessGenEmpty
changed legacy declaration internal/activator/prepare.go:ExecBackend.prepareAt
changed legacy declaration internal/activator/swap.go:Service.swapOverlap
changed legacy declaration internal/activator/swaphold.go:Service.foreignRow
changed legacy declaration internal/activator/swaphold.go:Service.holdAdmitLocked
2026/10/13 22:42:46 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: refusing to mint route acme.go.tito.io (app "tito" acct "bac"): this box declares CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic
2026/10/13 22:42:46 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: minted instance route console.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: minted service route ha.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: minted static route site.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: minted service route svc.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: minted instance route whole.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: minted instance route shared.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: converged acme.go.tito.io channel stable -> edge
2026/10/13 22:42:46 provision: converged acme.go.tito.io channel edge -> canary/rehearsal
2026/10/13 22:42:46 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: skipping record apps/bac/tito/instances/acme.go.tito.io.json: empty channel
2026/10/13 22:42:46 provision: not removing acme.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:42:46 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: converged acme.go.tito.io environment "" -> "staging"
2026/10/13 22:42:46 provision: converged acme.go.tito.io environment "staging" -> ""
2026/10/13 22:42:46 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:46 provision: converged acme.go.tito.io steering "" -> "latency"
2026/10/13 22:42:46 provision: converged acme.go.tito.io steering "latency" -> ""
2026/10/13 22:42:46 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: converged acme.go.tito.io hibernate false -> true
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: converged acme.go.tito.io hibernate true -> false (replication "daily" cleared: only a hibernating route's rung is read)
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: converged acme.go.tito.io status-named false -> true
2026/10/13 22:42:47 provision: converged acme.go.tito.io status-named true -> false
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted service route ha.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: converged ha.go.tito.io upstreams addr "" -> "", addrs "127.0.0.1:7001,127.0.0.1:7002" -> "127.0.0.1:7001,127.0.0.1:7003" (probe "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}" -> "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}")
2026/10/13 22:42:47 provision: converged ha.go.tito.io upstreams addr "" -> "", addrs "127.0.0.1:7001,127.0.0.1:7003" -> "127.0.0.1:7003,127.0.0.1:7001" (probe "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}" -> "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}")
2026/10/13 22:42:47 provision: converged ha.go.tito.io upstreams addr "" -> "", addrs "127.0.0.1:7003,127.0.0.1:7001" -> "127.0.0.1:7003,127.0.0.1:7001" (probe "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}" -> "{\"path\":\"/healthz\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}")
2026/10/13 22:42:47 provision: minted service route whole.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: converged whole.go.tito.io upstreams addr "127.0.0.1:9443" -> "", addrs "" -> "127.0.0.1:9443,127.0.0.1:9444" (probe "" -> "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}")
2026/10/13 22:42:47 provision: converged whole.go.tito.io upstreams addr "" -> "", addrs "127.0.0.1:9443,127.0.0.1:9444" -> "" (probe "{\"path\":\"/\",\"status\":200,\"interval_ms\":2000,\"fail_after\":3,\"rise_after\":2}" -> "")
2026/10/13 22:42:47 provision: whole.go.tito.io: record describes a different route SHAPE than the row (kind "instance"/"service" unit ""/"" socket "/run/carlos/whole.go.tito.io.sock"/"" db "/data/whole.go.tito.io.db"/"") — not converged in place; `carlos remove whole.go.tito.io` purges this route's capability grants (registry.Delete's contract, not a bug) before the next pass re-mints it, so treat that as a last resort, not a routine fix
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted service route whole.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: acme.go.tito.io exists but is not the route this record describes (app "other"/"tito") — leaving it alone
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: removed retired instance route acme.go.tito.io (record gone)
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: skipping accounts/bac/account.json: malformed account manifest: invalid character 'o' in literal null (expecting 'u')
2026/10/13 22:42:47 provision: removed retired instance route acme.go.tito.io (record gone)
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: manifest bac/tito: read failed: provision: manifest bac/tito: invalid character 'o' in literal null (expecting 'u')
2026/10/13 22:42:47 provision: skipping record apps/bac/tito/instances/acme.go.tito.io.json: disagrees with its own path (host="wrong-host.go.tito.io" account="bac" app="tito")
2026/10/13 22:42:47 provision: skipping record apps/bac/tito/instances/acme$evil.go.tito.io.json: host "acme$evil.go.tito.io" is not a hostname
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: skipping record apps/bac/tito/instances/acme$evil.go.tito.io.json: host "acme$evil.go.tito.io" is not a hostname
2026/10/13 22:42:47 provision: not removing acme$evil.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:42:47 provision: skipping record apps/bac/tito/instances/bad-service.go.tito.io.json: invalid shape: kind service with no addr: the platform only proxies to a service, so the record must name the upstream it proxies to
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: skipping record apps/bac/tito/instances/bad-service.go.tito.io.json: invalid shape: kind service with no addr: the platform only proxies to a service, so the record must name the upstream it proxies to
2026/10/13 22:42:47 provision: not removing bad-service.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:42:47 provision: skipping record apps/bac/tito/instances/no-channel.go.tito.io.json: empty channel
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: skipping record apps/bac/tito/instances/no-channel.go.tito.io.json: empty channel
2026/10/13 22:42:47 provision: not removing no-channel.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: skipping record apps/bac/tito/instances/acme.go.tito.io.json: malformed: provision: invalid maintenance projection or protected fields
2026/10/13 22:42:47 provision: not removing acme.go.tito.io: its record exists but could not be read cleanly this pass
2026/10/13 22:42:47 provision: removed retired instance route ghost.go.tito.io (record gone)
2026/10/13 22:42:47 provision: duplicate host dup.go.tito.io: keeping apps/bac/tito/instances/dup.go.tito.io.json, skipping apps/bad/tito/instances/dup.go.tito.io.json
2026/10/13 22:42:47 provision: minted instance route dup.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:42:47 provision: removed retired instance route acme.go.tito.io (app in the trash)
2026/10/13 22:42:47 provision: minted static route site.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:42:47 provision: removed retired static route site.go.tito.io (app in the trash)
2026/10/13 22:42:47 provision: trash marker for bac/tito unreadable: release: malformed trash marker for bac/tito: invalid character 'n' looking for beginning of object key string — not minting for this app
2026/10/13 22:42:47 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:42:47 provision: removed retired instance route acme.go.tito.io (app in the trash)
2026/10/13 22:42:47 provision: bac/tito is in the trash — not minting instance routes for it
2026/10/13 22:42:47 provision: trash marker for bac/tito unreadable: release: malformed trash marker for bac/tito: invalid character 'n' looking for beginning of object key string — not minting for this app
2026/10/13 22:42:47 provision: adopted existing route acme.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:42:47 provision: converged acme.go.tito.io channel stable -> edge
2026/10/13 22:42:47 provision: adopted existing route acme.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:42:47 provision: converged acme.go.tito.io channel canary/rehearsal -> edge
2026/10/13 22:42:47 provision: converged acme.go.tito.io environment "staging" -> ""
2026/10/13 22:42:47 provision: acme.go.tito.io exists but is not the route this record describes (app "other"/"tito") — leaving it alone
2026/10/13 22:42:47 provision: acme.go.tito.io exists but is not the route this record describes (backing "unit" (box-scoped, not expressible in a record)) — leaving it alone
2026/10/13 22:42:47 provision: acme.go.tito.io exists but is not the route this record describes (tunnel "pi-fleet" (box-scoped, not expressible in a record)) — leaving it alone
2026/10/13 22:42:47 provision: acme.go.tito.io exists but is not the route this record describes (caps "port:25" (box-scoped, not expressible in a record)) — leaving it alone
2026/10/13 22:42:47 provision: acme.go.tito.io exists but is not the route this record describes (platform (owned by the domains reconcile pass)) — leaving it alone
2026/10/13 22:42:47 provision: acme.go.tito.io exists but is not the route this record describes (canary (owned by the domains reconcile pass)) — leaving it alone
2026/10/13 22:42:47 provision: acme.go.tito.io exists but is not the route this record describes (db "/data/other.db"/"/data/acme.go.tito.io.db") — leaving it alone
2026/10/13 22:42:47 provision: adopted existing route acme.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:42:47 provision: removed retired instance route acme.go.tito.io (record gone)
2026/10/13 22:42:47 provision: dry-run: would converge owned.go.tito.io channel stable -> edge
2026/10/13 22:42:47 provision: dry-run: would converge owned.go.tito.io environment "" -> "staging"
2026/10/13 22:42:47 provision: dry-run: would converge owned.go.tito.io hibernate false -> true
2026/10/13 22:42:47 provision: dry-run: would converge owned.go.tito.io seed
2026/10/13 22:42:47 provision: dry-run: would mint instance route acme.go.tito.io (app "tito" acct "bac" channel "stable")
2026/10/13 22:42:47 provision: dry-run: would adopt existing route adopt.go.tito.io (app "tito" acct "bac") — this record would own its channel and environment
2026/10/13 22:42:47 provision: dry-run: would converge adopt.go.tito.io channel stable -> edge
2026/10/13 22:42:47 provision: dry-run: would converge adopt.go.tito.io environment "" -> "staging"
2026/10/13 22:42:47 provision: dry-run: would remove retired instance route gone.go.tito.io (record gone)
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: adopted existing route adopt.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:42:47 provision: converged adopt.go.tito.io channel stable -> edge
2026/10/13 22:42:47 provision: converged adopt.go.tito.io environment "" -> "staging"
2026/10/13 22:42:47 provision: converged owned.go.tito.io channel stable -> edge
2026/10/13 22:42:47 provision: converged owned.go.tito.io environment "" -> "staging"
2026/10/13 22:42:47 provision: converged owned.go.tito.io hibernate false -> true
2026/10/13 22:42:47 provision: removed retired instance route gone.go.tito.io (record gone)
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: adopted existing route story.carlosframework.com (app "story" acct "bad") — this record now owns its channel and environment
2026/10/13 22:42:47 provision: story.carlosframework.com exists but is not the route this record describes (kind "instance"/"static") — leaving it alone
2026/10/13 22:42:47 provision: minted static route site.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted service route pubs.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted service route pub.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted service route priv.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route unit.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route exec.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted service route loop.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted service route named.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted static route site.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted static route pisite.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted static route site.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: removed retired static route site.go.tito.io (record gone)
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme-uk.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: converged acme.go.tito.io replicas "ie,uk" -> "uk"
2026/10/13 22:42:47 provision: converged acme.go.tito.io replicas "uk" -> ""
2026/10/13 22:42:47 provision: minted instance route acme-uk.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: converged acme-uk.go.tito.io replica mark true -> false
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route router.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: acme.go.tito.io: row is on the nested layout (db "/data/instances/acme.go.tito.io/acme.go.tito.io.db" socket "/run/carlos/instances/acme.go.tito.io/app.sock"); the record's shared paths are not drift
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route router.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route router.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: minted instance route router.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:47 provision: router.go.tito.io: not minting this pass: the box's layout intent could not be read: store down
2026/10/13 22:42:47 provision: acme.go.tito.io: not minting this pass: the box's layout intent could not be read: store down
2026/10/13 22:42:47 provision: adopted existing route acme.go.tito.io (app "tito" acct "bac") — this record now owns its channel and environment
2026/10/13 22:42:47 provision: converged acme.go.tito.io channel stable -> edge
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io held landing by mv_acme (app "tito" acct "bac")
2026/10/13 22:42:47 provision: not removing acme.go.tito.io (record gone): participant of a running operation
2026/10/13 22:42:47 provision: removed retired instance route acme.go.tito.io (record gone)
2026/10/13 22:42:47 provision: minted instance route acme.go.tito.io held landing by mv_acme (app "tito" acct "bac")
2026/10/13 22:42:47 provision: refusing to mint held route acme.go.tito.io (app "tito" acct "bac"): this box declares CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic
2026/10/13 22:42:47 provision: held acme.go.tito.io landing by mv_acme (was ready)
2026/10/13 22:42:47 provision: acme.go.tito.io: held restoring by rb_other, not by mv_acme; leaving it
2026/10/13 22:42:48 provision: acme.go.tito.io: the row is not this pass's (app "other" acct ""); not holding it for mv_acme
2026/10/13 22:42:48 provision: acme.go.tito.io exists but is not the route this record describes (app "other"/"tito") — leaving it alone
2026/10/13 22:42:48 provision: dry-run: would mint instance route acme.go.tito.io held landing by mv_acme (app "tito" acct "bac" channel "stable")
2026/10/13 22:42:48 provision: acme.go.tito.io: not held landing by mv_acme: the row's hold changed under this pass; the next pass judges it afresh
2026/10/13 22:42:48 provision: held acme.go.tito.io landing by mv_acme (was ready)
2026/10/13 22:42:48 provision: removed retired sidecar route gone.eleven.bac.sidecar.internal (record gone)
2026/10/13 22:42:48 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:48 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:48 provision: converged sidecar bot.eleven.bac.sidecar.internal args ["sidecar","run"] -> ["worker","run"]
2026/10/13 22:42:48 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:48 provision: converged sidecar bot.eleven.bac.sidecar.internal ceilings 300/3600 -> 60/120
2026/10/13 22:42:48 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:48 provision: converged sidecar bot.eleven.bac.sidecar.internal channel stable -> canary/eleven
2026/10/13 22:42:48 provision: converged sidecar bot.eleven.bac.sidecar.internal environment "other" -> "relay"
2026/10/13 22:42:48 provision: converged sidecar bot.eleven.bac.sidecar.internal hibernate true -> false
2026/10/13 22:42:48 provision: skipping sidecar record apps/bac/eleven/sidecars/bot.json: read failed: failStore: reads disabled under apps/bac/eleven/sidecars/
2026/10/13 22:42:48 provision: skipping sidecar record apps/bac/eleven/sidecars/bot.json: malformed: invalid character 'n' looking for beginning of object key string
2026/10/13 22:42:48 provision: not removing sidecar bot.eleven.bac.sidecar.internal: its record exists but could not be read cleanly this pass
2026/10/13 22:42:48 provision: skipping sidecar record apps/bac/eleven/sidecars/bot.json: empty channel
2026/10/13 22:42:48 provision: skipping sidecar record apps/bac/eleven/sidecars/held.json: empty channel
2026/10/13 22:42:48 provision: not removing sidecar held.eleven.bac.sidecar.internal: its record exists but could not be read cleanly this pass
2026/10/13 22:42:48 provision: removed retired sidecar route old.eleven.bac.sidecar.internal (record gone)
2026/10/13 22:42:48 provision: bac/eleven has sidecar records but no sidecars manifest — not minting
2026/10/13 22:42:48 provision: bac/eleven is in the trash — not minting sidecar routes for it
2026/10/13 22:42:48 provision: sidecar bot.eleven.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:42:48 provision: sidecar hub.relay.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:42:48 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:48 provision: minted sidecar route hub.relay.bac.sidecar.internal (app "relay" acct "bac" args ["relay"])
2026/10/13 22:42:48 provision: sidecar bot.eleven.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:42:48 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:48 provision: sidecar bot.eleven.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:42:48 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:48 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:48 provision: sidecar bot.eleven.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:42:48 provision: removed retired sidecar route bot.eleven.bac.sidecar.internal (record gone)
2026/10/13 22:42:48 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:48 provision: sidecar bot.eleven.bac.sidecar.internal is not placeable here: it asks for "no-inbound" placement and this box accepts inbound work (CARLOS_INBOUND="")
2026/10/13 22:42:48 provision: skipping sidecar record apps/bac/relay/sidecars/hub.json: read failed: failStore: reads disabled under apps/bac/relay/sidecars/
2026/10/13 22:42:48 provision: REFUSING to mint sidecar bot.eleven.bac.sidecar.internal: bot.eleven.bac.sidecar.internal-sidecar.env is staged on this box — the legacy file co-spawn and a native sidecar row are two mechanisms for one host; remove the file to migrate this host onto sidecar records
2026/10/13 22:42:48 provision: minted sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" args ["sidecar","run"])
2026/10/13 22:42:48 provision: dry-run: would mint sidecar route bot.eleven.bac.sidecar.internal (app "eleven" acct "bac" channel "stable" args ["sidecar","run"])
2026/10/13 22:42:48 provision: dry-run: would remove retired sidecar route gone.eleven.bac.sidecar.internal (record gone)
2026/10/13 22:42:48 provision: bot.eleven.bac.sidecar.internal exists but is not a sidecar row this pass owns (kind "sidecar" provisioned false app "eleven" acct "bac") — leaving it alone
2026/10/13 22:42:48 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:48 provision: converged acme.go.tito.io update settings "{\"updates\":\"manual\",\"channel\":\"stable\"}" -> ""
2026/10/13 22:42:48 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:48 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:48 provision: manifest bac/tito: read failed: provision: manifest bac/tito: unexpected end of JSON input
2026/10/13 22:42:48 provision: manifest bac/tito: read failed: provision: manifest bac/tito: unexpected end of JSON input
2026/10/13 22:42:48 provision: acme.go.tito.io: not minted this pass: the app's update policy could not be read
2026/10/13 22:42:48 provision: minted instance route acme.go.tito.io (app "tito" acct "bac")
2026/10/13 22:42:48 provision: manifest bac/tito: read failed: provision: manifest bac/tito: unexpected end of JSON input
2026/10/13 22:42:48 provision: acme.go.tito.io: not minted this pass: the app's update policy could not be read
2026/10/13 22:42:48 provision: minted instance route acme.go.tito.io held landing by mv_acme (app "tito" acct "bac")
FAIL
FAIL github.com/carlosframework/platform/internal/provision 13.021s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.064s
ok github.com/carlosframework/platform/internal/relay 7.237s
ok github.com/carlosframework/platform/internal/release 20.173s
ok github.com/carlosframework/platform/internal/rollout 3.740s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.322s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.473s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.185s
ok github.com/carlosframework/platform/internal/status 0.041s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.208s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.577s
FAIL
make: *** [Makefile:18: test] Error 1
amadan: step gate failed: exit status 2
amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/cmd/zddprobe/main.go b/cmd/zddprobe/main.go| index 9d692bc01..66d7f10a7 100644 |
| --- a/cmd/zddprobe/main.go |
| +++ b/cmd/zddprobe/main.go |
| @@ -9,6 +9,15 @@ |
| // `<binary> -socket <sock>`. The probe takes both. It keeps no data, so --db |
| // is accepted and ignored. |
| // |
| +// Before it listens, the probe checks what a real app may rely on (zero- |
| +// downtime spec rev 19, rule 5), and exits 1 with a line on stderr if |
| +// either is wrong: CARLOS_HOST must be set, and the file name of --socket |
| +// must be the flag-off one, app.sock (nested) or $CARLOS_HOST.sock (flat). |
| +// A generation launch that named its socket anything else crash-looped a |
| +// tenant whose framework reads its host from that name (amadan #272); the |
| +// probe ignored the name and passed. A vet boot of the probe needs |
| +// CARLOS_HOST in its environment too. |
| +// |
| // version is set at build time with -ldflags "-X main.version=...". |
| // |
| // With ZDDPROBE_TRACE=1 the probe writes one line, "serving", to stdout as a |
| @@ -26,6 +35,7 @@ import ( |
| "net/http" |
| "os" |
| "os/signal" |
| + "path/filepath" |
| "syscall" |
| "time" |
| ) |
| @@ -41,6 +51,19 @@ func main() { |
| } |
| } |
| +// checkLaunch is rule 5's check of the launch: host (CARLOS_HOST) is set, |
| +// and sock's file name is app.sock or <host>.sock, the names a flag-off |
| +// launch gives. |
| +func checkLaunch(sock, host string) error { |
| + if host == "" { |
| + return errors.New("zddprobe: CARLOS_HOST is empty; the platform sets it on every instance launch") |
| + } |
| + if base := filepath.Base(sock); base != "app.sock" && base != host+".sock" { |
| + return fmt.Errorf("zddprobe: --socket file name is %q; want app.sock or %s.sock, the flag-off names", base, host) |
| + } |
| + return nil |
| +} |
| + |
| func run(args []string, stop <-chan os.Signal) error { |
| fs := flag.NewFlagSet("zddprobe", flag.ContinueOnError) |
| fs.SetOutput(os.Stderr) |
| @@ -52,6 +75,9 @@ func run(args []string, stop <-chan os.Signal) error { |
| if *sock == "" { |
| return errors.New("zddprobe: --socket is required") |
| } |
| + if err := checkLaunch(*sock, os.Getenv("CARLOS_HOST")); err != nil { |
| + return err |
| + } |
| _ = os.Remove(*sock) |
| l, err := net.Listen("unix", *sock) |
| if err != nil { |
diff --git a/cmd/zddprobe/main_test.go b/cmd/zddprobe/main_test.go| index 42a002879..1a9db42ae 100644 |
| --- a/cmd/zddprobe/main_test.go |
| +++ b/cmd/zddprobe/main_test.go |
| @@ -3,6 +3,7 @@ package main |
| import ( |
| "bufio" |
| "context" |
| + "errors" |
| "io" |
| "net" |
| "net/http" |
| @@ -80,7 +81,7 @@ func getOverUnix(t *testing.T, sock, path string) string { |
| func TestProbeServesWithThePlatformsRunArgv(t *testing.T) { |
| dir := t.TempDir() |
| sock, db := filepath.Join(dir, "app.sock"), filepath.Join(dir, "app.db") |
| - cmd, stdout := launch(t, []string{"ZDDPROBE_TRACE=1"}, "--socket", sock, "--db", db) // backend_exec.go:2335 |
| + cmd, stdout := launch(t, []string{"ZDDPROBE_TRACE=1", "CARLOS_HOST=shop.example.com"}, "--socket", sock, "--db", db) // backend_exec.go spawnUnitFrom |
| if body := getOverUnix(t, sock, "/healthz"); !strings.HasPrefix(body, "ok ") { |
| t.Fatalf("body %q", body) |
| } |
| @@ -122,7 +123,7 @@ func TestProbeServesWithThePlatformsRunArgv(t *testing.T) { |
| func TestProbeServesWithVetsArgv(t *testing.T) { |
| sock := filepath.Join(t.TempDir(), "app.sock") |
| - launch(t, nil, "-socket", sock) // internal/vet/vet.go:135 |
| + launch(t, []string{"CARLOS_HOST=shop.example.com"}, "-socket", sock) // internal/vet/vet.go bootHealthz |
| if body := getOverUnix(t, sock, "/"); !strings.HasPrefix(body, "ok ") { |
| t.Fatalf("body %q", body) |
| } |
| @@ -130,7 +131,7 @@ func TestProbeServesWithVetsArgv(t *testing.T) { |
| func TestProbeIsSilentWithoutTrace(t *testing.T) { |
| sock := filepath.Join(t.TempDir(), "app.sock") |
| - cmd, stdout := launch(t, []string{"ZDDPROBE_TRACE="}, "--socket", sock) |
| + cmd, stdout := launch(t, []string{"ZDDPROBE_TRACE=", "CARLOS_HOST=shop.example.com"}, "--socket", sock) |
| getOverUnix(t, sock, "/") |
| if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { |
| t.Fatal(err) |
| @@ -145,8 +146,72 @@ func TestProbeIsSilentWithoutTrace(t *testing.T) { |
| } |
| func TestProbeRequiresSocket(t *testing.T) { |
| - cmd, _ := launch(t, nil, "--db", "x.db") |
| + cmd, _ := launch(t, []string{"CARLOS_HOST=shop.example.com"}, "--db", "x.db") |
| if err := cmd.Wait(); err == nil { |
| t.Fatal("probe without --socket exited 0") |
| } |
| } |
| + |
| +// The flat flag-off name, <host>.sock, is served like app.sock. |
| +func TestProbeServesOnTheFlatHostSocketName(t *testing.T) { |
| + sock := filepath.Join(t.TempDir(), "shop.example.com.sock") |
| + launch(t, []string{"CARLOS_HOST=shop.example.com"}, "--socket", sock, "--db", "x.db") |
| + if body := getOverUnix(t, sock, "/"); !strings.HasPrefix(body, "ok ") { |
| + t.Fatalf("body %q", body) |
| + } |
| +} |
| + |
| +// Rev 19, rule 5: a launch whose socket file name is not a flag-off one, or |
| +// that has no CARLOS_HOST, exits 1 with a line on stderr, before it |
| +// listens, so a probe on such a launch fails its health check as the |
| +// tenant did. |
| +func TestProbeRefusesALaunchARealAppMightRefuse(t *testing.T) { |
| + for _, tc := range []struct { |
| + name, host, file, want string |
| + }{ |
| + {"generation file name", "shop.example.com", "app.3.sock", `--socket file name is "app.3.sock"`}, |
| + {"hashed file name", "shop.example.com", "0123456789ab.3.sock", "want app.sock or shop.example.com.sock"}, |
| + {"another host's name", "shop.example.com", "other.example.com.sock", `"other.example.com.sock"`}, |
| + {"no CARLOS_HOST, nested name", "", "app.sock", "CARLOS_HOST is empty"}, |
| + {"no CARLOS_HOST, flat name", "", "shop.example.com.sock", "CARLOS_HOST is empty"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + sock := filepath.Join(t.TempDir(), tc.file) |
| + cmd := exec.Command(os.Args[0], "--socket", sock, "--db", "x.db") |
| + cmd.Env = append(os.Environ(), "ZDDPROBE_AS_MAIN=1", "CARLOS_HOST="+tc.host) |
| + var stderr strings.Builder |
| + cmd.Stderr = &stderr |
| + err := cmd.Run() |
| + var exit *exec.ExitError |
| + if !errors.As(err, &exit) || exit.ExitCode() != 1 { |
| + t.Fatalf("exit = %v, want status 1", err) |
| + } |
| + if !strings.Contains(stderr.String(), tc.want) { |
| + t.Fatalf("stderr %q, want it to say %q", stderr.String(), tc.want) |
| + } |
| + if _, err := os.Lstat(sock); !os.IsNotExist(err) { |
| + t.Fatalf("the probe listened before refusing (lstat %s: %v)", sock, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestCheckLaunch(t *testing.T) { |
| + for _, tc := range []struct { |
| + sock, host string |
| + ok bool |
| + }{ |
| + {"/run/carlos/instances/shop.example.com/g3/app.sock", "shop.example.com", true}, |
| + {"/run/carlos/instances/shop.example.com/app.sock", "shop.example.com", true}, |
| + {"/run/carlos/g/3/shop.example.com.sock", "shop.example.com", true}, |
| + {"/run/carlos/shop.example.com.sock", "shop.example.com", true}, |
| + {"/run/carlos/instances/shop.example.com/app.3.sock", "shop.example.com", false}, |
| + {"/run/carlos/g/0123456789ab.3.sock", "shop.example.com", false}, |
| + {"/run/carlos/shop.example.com.sock", "", false}, |
| + {"/run/carlos/.sock", "", false}, |
| + } { |
| + if err := checkLaunch(tc.sock, tc.host); (err == nil) != tc.ok { |
| + t.Errorf("checkLaunch(%q, %q) = %v, want ok %v", tc.sock, tc.host, err, tc.ok) |
| + } |
| + } |
| +} |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 0578016a5..dabbbae19 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -1766,15 +1766,19 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| // StartGen is Start for generation n (zero-downtime spec §7.1). perGen false |
| // is Start exactly: the canonical socket, the canonical control socket, the |
| // same argv. perGen true binds the generation's own socket: GenSocketPath |
| -// for a flat row, app.<n>.sock in the instance run dir for a nested one. A |
| -// foreign row keeps its canonical socket either way: it is outside the two |
| -// layouts the spec gives generation paths to, and keeping it is the |
| -// conservative reading (plan 1, "Spec questions"). |
| +// for a flat row, g<n>/app.sock in the instance run dir for a nested one |
| +// (rev 19, rule 1). Only the directory differs from the flag-off launch: |
| +// the file name, the rest of the argv and the agent-owned environment are |
| +// the same (rule 2). A foreign row keeps its canonical socket either way: |
| +// it is outside the two layouts the spec gives generation paths to, and |
| +// keeping it is the conservative reading (plan 1, "Spec questions"). So |
| +// does a flat row whose generation path would not fit a unix socket path |
| +// (genLayout, rule 4). |
| // |
| -// The stale-file rule (spec §7.1): before the app binds, the file at THIS |
| -// generation's own path is removed (left there by an earlier agent process, |
| -// whose counter started at 1 like this one's). A sibling generation's path |
| -// is never touched. |
| +// The stale-file rule (spec §7.1): before the app binds, the generation's |
| +// directory is made and the file at THIS generation's own path is removed |
| +// (left there by an earlier agent process, whose counter started at 1 like |
| +// this one's). A sibling generation's path is never touched. |
| func (b *ExecBackend) StartGen(ctx context.Context, rt registry.Route, n uint64, perGen bool) (Proc, Gen, error) { |
| return b.startGenFrom(ctx, rt, n, perGen, nil, cgroup.Limit{}) |
| } |
| @@ -1820,11 +1824,15 @@ func (b *ExecBackend) startGenFrom(ctx context.Context, rt registry.Route, n uin |
| p := b.pathsFor(rt) |
| sock := rt.Socket |
| if perGen { |
| - switch p.layout { |
| + switch genLayout(p.layout, rt.Host) { |
| case LayoutFlat: |
| sock = b.flatGenSocket(rt.Host, n) |
| case LayoutNested: |
| sock = nestedGenSocketIn(p.runDir, n) |
| + case LayoutForeign: |
| + if p.layout == LayoutFlat { |
| + log.Printf("activator: %s: a generation socket path would exceed %d bytes; generation %d keeps the canonical socket", rt.Host, maxUnixPath, n) |
| + } |
| } |
| } |
| var ( |
| @@ -1834,7 +1842,8 @@ func (b *ExecBackend) startGenFrom(ctx context.Context, rt registry.Route, n uin |
| ) |
| switch p.layout { |
| case LayoutNested: |
| - // prepareAt removes the stale file at sock for the nested layout. |
| + // prepareAt makes sock's generation directory and removes the |
| + // stale file at sock for the nested layout. |
| proc, ver, err = b.startUnitAt(ctx, rt, sock, n, unitExe, tier) |
| case LayoutFenced, LayoutConflict: |
| return nil, Gen{}, fmt.Errorf("%s: refusing to wake: %s", rt.Host, p.reason) |
| @@ -1873,9 +1882,10 @@ func (b *ExecBackend) flatGenSocket(host string, n uint64) string { |
| return GenSocketPath(host, n) |
| } |
| -// clearOwnGenSocket makes the flat generation directory and removes a stale |
| -// file at sock, and only at sock. The directory inherits /run/carlos's |
| -// setgid carlos-sockets group, as the nested run dirs do. |
| +// clearOwnGenSocket makes the flat generation's own directory, |
| +// /run/carlos/g/<n>, and removes a stale file at sock, and only at sock (rev |
| +// 19, rule 3). The directory is 0750 and inherits /run/carlos's setgid |
| +// carlos-sockets group, as the nested run dirs do. |
| func clearOwnGenSocket(sock string) error { |
| if err := os.MkdirAll(filepath.Dir(sock), 0o750|os.ModeSetgid); err != nil { |
| return err |
diff --git a/internal/activator/genbackend.go b/internal/activator/genbackend.go| index 240327d29..06fcc0b21 100644 |
| --- a/internal/activator/genbackend.go |
| +++ b/internal/activator/genbackend.go |
| @@ -178,13 +178,15 @@ func (a *Service) SetLiveExecGate(gate func(rt registry.Route, build string) err |
| // child execs g.StagedPath directly. A nested unit's executable mount names |
| // it, which is Task 9's leg; until then a nested row is refused. A foreign |
| // row keeps its canonical socket (plan 1): it gets one generation at a time |
| -// (a cold, pinned or hold start), and Swap never overlaps it. |
| +// (a cold, pinned or hold start), and Swap never overlaps it. A flat row |
| +// whose generation path would not fit a unix socket path is handled the |
| +// same way (genLayout, rev 19 rule 4). |
| func (b *ExecBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error) { |
| if g.Tier == (cgroup.Limit{}) { |
| g.Tier = b.GenTier(rt, g.Build) |
| } |
| p := b.pathsFor(rt) |
| - switch p.layout { |
| + switch genLayout(p.layout, rt.Host) { |
| case LayoutFlat: |
| sock := b.flatGenSocket(rt.Host, g.N) |
| if err := clearOwnGenSocket(sock); err != nil { |
| @@ -452,18 +454,50 @@ func (b *ExecBackend) writeFence(host string, gen uint64) schedule.WriteFence { |
| return func(write func()) bool { return b.ScheduleFence(host, gen, write) } |
| } |
| +// genLayouter is a backend whose rows can read differently to generations |
| +// than to Classify (genLayout, rev 19 rule 4). The swap's layout decisions |
| +// ask it before Classifier (Service.swapLayout). |
| +type genLayouter interface { |
| + GenLayout(rt registry.Route) Layout |
| +} |
| + |
| +var ( |
| + _ genLayouter = (*ExecBackend)(nil) |
| + _ genLayouter = (*SplitBackend)(nil) |
| +) |
| + |
| +// GenLayout is Layout as generations see it: a flat row whose generation |
| +// path would not fit a unix socket path reads foreign (genLayout). |
| +func (b *ExecBackend) GenLayout(rt registry.Route) Layout { |
| + l, _ := b.Layout(rt) |
| + return genLayout(l, rt.Host) |
| +} |
| + |
| +// GenLayout forwards to the exec half for exec rows. A unit row reads as |
| +// its Layout does. |
| +func (b *SplitBackend) GenLayout(rt registry.Route) Layout { |
| + if rt.Backing != registry.BackingUnit { |
| + if g, ok := b.Exec.(genLayouter); ok { |
| + return g.GenLayout(rt) |
| + } |
| + } |
| + l, _ := b.Layout(rt) |
| + return l |
| +} |
| + |
| // genPather is a backend that can name a generation's sockets. |
| type genPather interface { |
| GenPaths(rt registry.Route, n uint64) (Gen, bool) |
| } |
| // GenPaths is the generation socket pair a spawn of generation n of rt |
| -// binds, as plan 1 lays them out. It reports false for a row with no |
| -// generation path (foreign, fenced, conflict). |
| +// binds, as rev 19 lays them out. It reports false for a row with no |
| +// generation path (foreign, fenced, conflict, and a flat row whose |
| +// generation path would not fit a unix socket path, genLayout). |
| func (b *ExecBackend) GenPaths(rt registry.Route, n uint64) (Gen, bool) { |
| p := b.pathsFor(rt) |
| var sock string |
| - switch p.layout { |
| + switch genLayout(p.layout, rt.Host) { |
| case LayoutFlat: |
| sock = b.flatGenSocket(rt.Host, n) |
| case LayoutNested: |
diff --git a/internal/activator/generations.go b/internal/activator/generations.go| index 6d499315c..fce7a7fce 100644 |
| --- a/internal/activator/generations.go |
| +++ b/internal/activator/generations.go |
| @@ -1,13 +1,16 @@ |
| // 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. |
| +// (docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md §7.1, |
| +// as amended by 2026-10-13-zero-downtime-deploys-rev19-amendment.md): the |
| +// process-wide counter, the generation socket paths, the Gen handle, and the |
| +// dormant switch that keeps all of it off by default. |
| package activator |
| import ( |
| "context" |
| - "crypto/sha256" |
| - "encoding/hex" |
| + "errors" |
| + "log" |
| + "math" |
| + "os" |
| "path/filepath" |
| "strconv" |
| "sync/atomic" |
| @@ -50,7 +53,7 @@ func nextGen() uint64 { return genCounter.Add(1) } |
| // staged path. |
| type Gen struct { |
| N uint64 // process-wide, never reused (spec §7.1) |
| - Socket string // generation app socket (bounded path, spec §7.1) |
| + Socket string // generation app socket (spec §7.1, rev 19: the flag-off file name in a directory of its own) |
| Control string // Socket + ".control" |
| Build string // version name the generation runs |
| StagedPath string // immutable staged executable it exec'd (spec §7.5 step 3) |
| @@ -67,13 +70,21 @@ type Gen struct { |
| Worker bool |
| } |
| -// GenSocketDir is where flat generation sockets live: /run/carlos/g. |
| +// GenSocketDir is the root of the flat generation directories: |
| +// /run/carlos/g. Generation n's directory is /run/carlos/g/<n>. |
| 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. |
| +// maxUnixPath is the longest path a unix socket address holds. |
| +const maxUnixPath = provision.MaxUnixPath |
| + |
| +// GenSocketPath is flat generation n's socket (zero-downtime spec §7.1 as |
| +// amended by rev 19, rule 1): /run/carlos/g/<n>/<host>.sock. The file name |
| +// is the flag-off one, because an app may work out its host from the file |
| +// name of its --socket argument, and a name the flag-off launch never gives |
| +// it can make it refuse to start (amadan #272, Gate F). Each generation gets |
| +// its own directory instead. The hostname is in the path, so its length is |
| +// bounded by the host's: flatGenFits decides whether a host has generation |
| +// paths at all (rule 4). |
| func GenSocketPath(host string, n uint64) string { |
| return genSocketPathIn(GenSocketDir, host, n) |
| } |
| @@ -81,21 +92,77 @@ func GenSocketPath(host string, n uint64) string { |
| // 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") |
| + return filepath.Join(dir, strconv.FormatUint(n, 10), host+".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"). |
| +// flatGenFits reports whether host's flat generation sockets fit a unix |
| +// socket address (provision.MaxUnixPath, 107 bytes) for every generation |
| +// number (rev 19, rule 4): the control socket, the longer of the pair, at |
| +// the longest number a generation can have. That admits hosts of up to 59 |
| +// bytes; the flag-off control socket already caps a flat host at 82. It |
| +// depends on the host alone, so a row never changes treatment as |
| +// the process-wide counter grows, and an attempt admitted as an overlap |
| +// never meets a generation that cannot bind. A host that does not fit is |
| +// handled as a foreign row is (genLayout): it keeps its canonical socket. |
| +// |
| +// It is measured against GenSocketDir even under a test's RunDirOverride, |
| +// so a test sees the decision a box makes. |
| +func flatGenFits(host string) bool { |
| + return len(GenSocketPath(host, math.MaxUint64)+".control") <= maxUnixPath |
| +} |
| + |
| +// genLayout is layout l of host's row as generations see it (rev 19, rule |
| +// 4): a flat row whose generation sockets would not fit a unix socket path |
| +// (flatGenFits) reads foreign, so it keeps its canonical socket and an |
| +// overlap on it runs as a hold, exactly as for a foreign row. Every other |
| +// layout is l. Classify and pathsFor are unchanged: the flag-off spawn, the |
| +// layout pass and every path but the generation socket's still read the row |
| +// as flat. |
| +func genLayout(l Layout, host string) Layout { |
| + if l == LayoutFlat && !flatGenFits(host) { |
| + return LayoutForeign |
| + } |
| + return l |
| +} |
| + |
| +// NestedGenSocket is the nested layout's generation socket (rev 19, rule |
| +// 1): /run/carlos/instances/<InstanceID>/g<n>/app.sock, the flag-off file |
| +// name in a directory of the generation's own. The identifier is bounded at |
| +// 40 bytes, so this is at most 93 bytes (101 with ".control") whatever n is. |
| 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") |
| + return filepath.Join(runDir, "g"+strconv.FormatUint(n, 10), "app.sock") |
| +} |
| + |
| +// removeGenDir removes generation g's own socket directory once g's |
| +// termination is proven, if it is empty (rev 19, rule 3). It touches only a |
| +// directory named for g.N (flat: <n>, nested: g<n>) that holds g.Socket, so |
| +// a sibling's directory, the run dir and GenSocketDir itself are never |
| +// touched. A generation on its row's canonical socket (generations off, a |
| +// foreign row, a unit tenant) has no directory of its own and is skipped. A |
| +// directory that still holds a file (an app that left its socket behind) |
| +// stays: it is harmless, and the stale-file rule clears that file if the |
| +// number is ever used again. |
| +func removeGenDir(rt registry.Route, g Gen) { |
| + if g.N == 0 || !linksCanonical(rt, g) { |
| + return |
| + } |
| + dir := filepath.Dir(g.Socket) |
| + n := strconv.FormatUint(g.N, 10) |
| + if base := filepath.Base(dir); base != n && base != "g"+n { |
| + return |
| + } |
| + entries, err := os.ReadDir(dir) |
| + if err != nil || len(entries) != 0 { |
| + return |
| + } |
| + if err := os.Remove(dir); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("activator: %s: remove generation %d's socket directory: %v", rt.Host, g.N, err) |
| + } |
| } |
| // canonicalGen is the generation handle of a spawn that bound the route's |
diff --git a/internal/activator/generations_backend_test.go b/internal/activator/generations_backend_test.go| index d1cec5b28..a4dacf0e4 100644 |
| --- a/internal/activator/generations_backend_test.go |
| +++ b/internal/activator/generations_backend_test.go |
| @@ -105,14 +105,14 @@ func TestStartGenWithGenerationsOffBindsTheCanonicalSocket(t *testing.T) { |
| } |
| } |
| -// Flag on, flat: a 120-byte host binds the bounded generation socket and |
| -// its control socket, and only the generation's OWN stale file is removed. |
| -// The canonical path for this host would not even fit a socket address. |
| +// Flag on, flat: the host binds its generation socket, g/<n>/<host>.sock |
| +// (rev 19), and its control socket beside it, and only the generation's OWN |
| +// stale file is removed. |
| func TestStartGenFlatBindsItsOwnGenerationSocket(t *testing.T) { |
| requireUnixSockets(t) |
| b := testExecBackend(t) |
| b.RunDirOverride = shortRunDir(t) |
| - host := strings.Repeat("b", 113) + ".bab.io" // 120 bytes |
| + host := "shop.example.com" |
| stageServingLive(t, b, host) |
| rt := flatGenRoute(b, host) |
| if l, _ := b.Layout(rt); l != LayoutFlat { |
| @@ -120,10 +120,13 @@ func TestStartGenFlatBindsItsOwnGenerationSocket(t *testing.T) { |
| } |
| own := genSocketPathIn(filepath.Join(b.RunDirOverride, "g"), host, 9) |
| sibling := genSocketPathIn(filepath.Join(b.RunDirOverride, "g"), host, 8) |
| - if err := os.MkdirAll(filepath.Dir(own), 0o750); err != nil { |
| - t.Fatal(err) |
| + if want := filepath.Join(b.RunDirOverride, "g", "9", host+".sock"); own != want { |
| + t.Fatalf("own generation path %s, want %s", own, want) |
| } |
| for _, p := range []string{own, sibling} { |
| + if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| if err := os.WriteFile(p, []byte("stale"), 0o600); err != nil { |
| t.Fatal(err) |
| } |
| @@ -152,19 +155,20 @@ func TestStartGenFlatBindsItsOwnGenerationSocket(t *testing.T) { |
| } |
| } |
| -// Flag on, nested: the launch file names app.<n>.sock, the control env |
| -// follows it, and prepare removes only that generation's stale file. |
| -func TestStartGenNestedUsesAppNSock(t *testing.T) { |
| +// Flag on, nested: the launch file names g<n>/app.sock (rev 19), the |
| +// control env follows it, and prepare removes only that generation's stale |
| +// file. |
| +func TestStartGenNestedUsesItsOwnDirectory(t *testing.T) { |
| ctl := &fakeControl{inv: "inv1"} |
| unit := &fakeUnit{} |
| b, rt := nestedBackend(t, ctl, unit) |
| runDir := filepath.Dir(rt.Socket) |
| - own := filepath.Join(runDir, "app.9.sock") |
| - sibling := filepath.Join(runDir, "app.8.sock") |
| - if err := os.MkdirAll(runDir, 0o750); err != nil { |
| - t.Fatal(err) |
| - } |
| + own := filepath.Join(runDir, "g9", "app.sock") |
| + sibling := filepath.Join(runDir, "g8", "app.sock") |
| for _, p := range []string{rt.Socket, sibling, own} { |
| + if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| if err := os.WriteFile(p, []byte("stale"), 0o600); err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/activator/generations_test.go b/internal/activator/generations_test.go| index bb26d138d..36cf00518 100644 |
| --- a/internal/activator/generations_test.go |
| +++ b/internal/activator/generations_test.go |
| @@ -2,6 +2,7 @@ package activator |
| import ( |
| "math" |
| + "path/filepath" |
| "strings" |
| "testing" |
| @@ -20,48 +21,92 @@ func withGenerations(t *testing.T, on bool) { |
| 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) { |
| +// Rev 19, rule 4: a nested generation's socket pair fits a unix socket |
| +// address for any host and any generation number, because the instance |
| +// identifier is bounded. A flat one fits for every number exactly when |
| +// flatGenFits says so, and a flat host that does not fit has no generation |
| +// path at all. |
| +func TestGenerationSocketsFitSockaddrUn(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), |
| + "shop.example.com", |
| + strings.Repeat("a", 59-len(".example.com")) + ".example.com", // 59 bytes: the longest that fits |
| + strings.Repeat("b", 60-len(".example.com")) + ".example.com", // 60 bytes |
| + strings.Repeat("c", 113) + ".bab.io", // 120 bytes |
| + strings.Repeat("d", 253), |
| } { |
| + fits := flatGenFits(host) |
| + if want := len(host) <= 59; fits != want { |
| + t.Errorf("flatGenFits(%d-byte host) = %v, want %v", len(host), fits, want) |
| + } |
| 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"} { |
| + paths := []string{nested, nested + ".control"} |
| + if fits { |
| + flat := GenSocketPath(host, n) |
| + paths = append(paths, flat, flat+".control") |
| + } |
| + for _, p := range paths { |
| if len(p) > provision.MaxUnixPath { |
| t.Errorf("%q is %d bytes, over the %d a unix socket address holds", p, len(p), provision.MaxUnixPath) |
| } |
| } |
| } |
| + if !fits && len(GenSocketPath(host, math.MaxUint64)+".control") <= provision.MaxUnixPath { |
| + t.Errorf("%d-byte host refused, yet its longest generation control socket fits", len(host)) |
| + } |
| } |
| } |
| +// Rev 19, rule 1: a generation socket's file name is the flag-off one, in a |
| +// directory of the generation's own. |
| 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) |
| + host := "shop.example.com" |
| + if got, want := GenSocketPath(host, 7), "/run/carlos/g/7/shop.example.com.sock"; got != want { |
| + t.Fatalf("GenSocketPath = %q, want %q", got, want) |
| } |
| - 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 got, want := filepath.Base(GenSocketPath(host, 7)), filepath.Base(provision.DefaultSocket(host)); got != want { |
| + t.Fatalf("flat generation file name %q, want the flag-off %q", got, want) |
| } |
| - if strings.Contains(got, "bill") { |
| - t.Fatalf("GenSocketPath %q embeds the hostname; the bound depends on it never doing so", got) |
| + if got, want := NestedGenSocket(host, 7), "/run/carlos/instances/"+provision.InstanceID(host)+"/g7/app.sock"; got != want { |
| + t.Fatalf("NestedGenSocket = %q, want %q", got, want) |
| } |
| - 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 := filepath.Base(NestedGenSocket(host, 7)), filepath.Base(provision.NestedSocket(host)); got != want { |
| + t.Fatalf("nested generation file name %q, want the flag-off %q", got, want) |
| } |
| - 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) |
| + for _, pair := range [][2]string{ |
| + {GenSocketPath(host, 7), GenSocketPath(host, 8)}, |
| + {GenSocketPath(host, 7), GenSocketPath("other.example.com", 7)}, |
| + {NestedGenSocket(host, 7), NestedGenSocket(host, 8)}, |
| + {NestedGenSocket(host, 7), provision.NestedSocket(host)}, |
| + } { |
| + if pair[0] == pair[1] { |
| + t.Fatalf("generation paths collide: %q", pair[0]) |
| + } |
| + } |
| + if got := filepath.Dir(NestedGenSocket(host, 7)); filepath.Dir(got) != provision.InstanceRunDir(host) { |
| + t.Fatalf("nested generation directory %q is not directly in the run dir %q", got, provision.InstanceRunDir(host)) |
| + } |
| +} |
| + |
| +// Rev 19, rule 4: genLayout reads only an over-long flat row as foreign. |
| +func TestGenLayoutReadsAnOverLongFlatRowAsForeign(t *testing.T) { |
| + short, long := "shop.example.com", strings.Repeat("a", 60-len(".example.com"))+".example.com" |
| + for _, tc := range []struct { |
| + l Layout |
| + host string |
| + want Layout |
| + }{ |
| + {LayoutFlat, short, LayoutFlat}, |
| + {LayoutFlat, long, LayoutForeign}, |
| + {LayoutNested, long, LayoutNested}, |
| + {LayoutForeign, short, LayoutForeign}, |
| + {LayoutFenced, long, LayoutFenced}, |
| + {LayoutConflict, long, LayoutConflict}, |
| + } { |
| + if got := genLayout(tc.l, tc.host); got != tc.want { |
| + t.Errorf("genLayout(%s, %d-byte host) = %s, want %s", tc.l, len(tc.host), got, tc.want) |
| + } |
| } |
| } |
diff --git a/internal/activator/gensocket_rev19_test.go b/internal/activator/gensocket_rev19_test.go| new file mode 100644 |
| index 000000000..030768a0c |
| --- /dev/null |
| +++ b/internal/activator/gensocket_rev19_test.go |
| @@ -0,0 +1,360 @@ |
| +package activator |
| + |
| +// Tests for the zero-downtime-deploys rev 19 amendment |
| +// (docs/superpowers/specs/2026-10-13-zero-downtime-deploys-rev19-amendment.md): |
| +// a generation's socket keeps the flag-off file name in a directory of its |
| +// own, a generation launch matches a flag-off launch, the directory goes |
| +// once the generation is proven gone, and a flat host too long for a |
| +// generation path keeps its canonical socket as a foreign row does. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "maps" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// goneProc is a process whose leader has exited. |
| +type goneProc struct{} |
| + |
| +func (goneProc) Stop(context.Context) error { return nil } |
| +func (goneProc) Exited() bool { return true } |
| + |
| +// envMap is an environment list ("K=V") as a map. |
| +func envMap(t *testing.T, env []string) map[string]string { |
| + t.Helper() |
| + m := make(map[string]string, len(env)) |
| + for _, kv := range env { |
| + k, v, ok := strings.Cut(kv, "=") |
| + if !ok { |
| + continue // a multi-line value's continuation in `env` output |
| + } |
| + m[k] = v |
| + } |
| + return m |
| +} |
| + |
| +// sameLaunch checks rule 2 for one layout: the two argvs differ only in |
| +// --socket's directory, and the two environments only in |
| +// CARLOS_CONTROL_SOCKET, which names each launch's own socket + ".control". |
| +func sameLaunch(t *testing.T, offArgv, genArgv []string, offEnv, genEnv map[string]string, offSock, genSock string) { |
| + t.Helper() |
| + i := slices.Index(offArgv, "--socket") |
| + j := slices.Index(genArgv, "--socket") |
| + if i < 0 || j != i || i+1 >= len(offArgv) || len(genArgv) != len(offArgv) { |
| + t.Fatalf("argv shapes differ: flag off %v, generation %v", offArgv, genArgv) |
| + } |
| + if offArgv[i+1] != offSock || genArgv[i+1] != genSock { |
| + t.Fatalf("--socket: flag off %q (want %q), generation %q (want %q)", offArgv[i+1], offSock, genArgv[i+1], genSock) |
| + } |
| + if offSock == genSock { |
| + t.Fatalf("the generation launch bound the canonical socket %s", offSock) |
| + } |
| + if a, b := filepath.Base(offArgv[i+1]), filepath.Base(genArgv[i+1]); a != b { |
| + t.Fatalf("--socket file name: flag off %q, generation %q; rule 2 wants the same", a, b) |
| + } |
| + rest := func(argv []string) []string { return slices.Delete(slices.Clone(argv), i+1, i+2) } |
| + if !slices.Equal(rest(offArgv), rest(genArgv)) { |
| + t.Fatalf("argv differs beyond --socket: flag off %v, generation %v", offArgv, genArgv) |
| + } |
| + if offEnv["CARLOS_CONTROL_SOCKET"] != offSock+".control" || genEnv["CARLOS_CONTROL_SOCKET"] != genSock+".control" { |
| + t.Fatalf("CARLOS_CONTROL_SOCKET: flag off %q, generation %q", offEnv["CARLOS_CONTROL_SOCKET"], genEnv["CARLOS_CONTROL_SOCKET"]) |
| + } |
| + off, gen := maps.Clone(offEnv), maps.Clone(genEnv) |
| + delete(off, "CARLOS_CONTROL_SOCKET") |
| + delete(gen, "CARLOS_CONTROL_SOCKET") |
| + if !maps.Equal(off, gen) { |
| + for k := range off { |
| + if gen[k] != off[k] { |
| + t.Errorf("env %s: flag off %q, generation %q", k, off[k], gen[k]) |
| + } |
| + } |
| + for k := range gen { |
| + if _, ok := off[k]; !ok { |
| + t.Errorf("env %s: only the generation launch has it (%q)", k, gen[k]) |
| + } |
| + } |
| + t.FailNow() |
| + } |
| + if off["CARLOS_HOST"] == "" { |
| + t.Fatal("neither launch carries CARLOS_HOST") |
| + } |
| +} |
| + |
| +// Rule 2, flat: a generation's exec has the flag-off argv and agent-owned |
| +// environment apart from the socket's directory and the control path. |
| +func TestFlatGenerationLaunchMatchesTheFlagOffLaunch(t *testing.T) { |
| + requireUnixSockets(t) |
| + b := testExecBackend(t) |
| + b.RunDirOverride = shortRunDir(t) |
| + host := "shop.example.com" |
| + argvPath, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") |
| + rt := flatGenRoute(b, host) |
| + launch := func(n uint64, perGen bool) ([]string, map[string]string, string) { |
| + t.Helper() |
| + _ = os.Remove(argvPath) |
| + _ = os.Remove(envPath) |
| + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) |
| + defer cancel() |
| + var ( |
| + proc Proc |
| + g Gen |
| + ) |
| + done := make(chan struct{}) |
| + go func() { |
| + proc, g, _ = b.StartGen(ctx, rt, n, perGen) // the recorder never binds; a health error is expected |
| + close(done) |
| + }() |
| + argv := strings.Split(strings.TrimSpace(waitForFile(t, argvPath, 3*time.Second)), "\n") |
| + env := envMap(t, strings.Split(strings.TrimSpace(waitForFile(t, envPath, 3*time.Second)), "\n")) |
| + <-done |
| + if proc != nil { |
| + _ = proc.Stop(context.Background()) |
| + } |
| + return argv, env, g.Socket |
| + } |
| + offArgv, offEnv, offSock := launch(5, false) |
| + genArgv, genEnv, genSock := launch(6, true) |
| + if want := filepath.Join(b.RunDirOverride, "g", "6", host+".sock"); genSock != want { |
| + t.Fatalf("generation socket %s, want %s", genSock, want) |
| + } |
| + sameLaunch(t, offArgv, genArgv, offEnv, genEnv, rt.Socket, genSock) |
| + if offSock != rt.Socket { |
| + t.Fatalf("flag-off handle names %s, want the canonical %s", offSock, rt.Socket) |
| + } |
| + fi, err := os.Stat(filepath.Dir(genSock)) |
| + if err != nil || !fi.IsDir() || fi.Mode().Perm() != 0o750 { |
| + t.Fatalf("generation directory %s: %v, %v; want a 0750 directory", filepath.Dir(genSock), fi, err) |
| + } |
| +} |
| + |
| +// Rule 2, nested: the generation's launch file has the flag-off argv and |
| +// agent-owned environment apart from the socket's directory and the control |
| +// path, and prepare made the generation's own directory before the start. |
| +func TestNestedGenerationLaunchMatchesTheFlagOffLaunch(t *testing.T) { |
| + ctl := &fakeControl{} |
| + unit := &fakeUnit{} |
| + b, rt := nestedBackend(t, ctl, unit) |
| + launchFile := filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), "launch.run.json") |
| + var ( |
| + serveAt string |
| + dirAtStart bool |
| + stop func() |
| + ) |
| + ctl.onStart = func() { |
| + fi, err := os.Stat(filepath.Dir(serveAt)) |
| + dirAtStart = err == nil && fi.IsDir() |
| + stop = serveOK(t, serveAt) |
| + } |
| + launch := func(n uint64, perGen bool, sock, inv string) instancectl.Launch { |
| + t.Helper() |
| + serveAt, dirAtStart = sock, false |
| + ctl.mu.Lock() |
| + ctl.inv = inv |
| + ctl.mu.Unlock() |
| + unit.set("active", inv, true, true) |
| + p, g, err := b.StartGen(context.Background(), rt, n, perGen) |
| + if err != nil { |
| + t.Fatalf("StartGen(%d, %v): %v", n, perGen, err) |
| + } |
| + if g.Socket != sock { |
| + t.Fatalf("handle socket %s, want %s", g.Socket, sock) |
| + } |
| + if !dirAtStart { |
| + t.Fatalf("%s did not exist when root was asked to start the unit", filepath.Dir(sock)) |
| + } |
| + raw, err := os.ReadFile(launchFile) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var l instancectl.Launch |
| + if err := json.Unmarshal(raw, &l); err != nil { |
| + t.Fatal(err) |
| + } |
| + unit.set("inactive", inv, false, false) |
| + waitFor(t, "the unit to exit", func() bool { return p.Exited() }) |
| + ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| + defer cancel() |
| + _ = p.Stop(ctx) |
| + if stop != nil { |
| + stop() |
| + stop = nil |
| + } |
| + return l |
| + } |
| + genSock := filepath.Join(filepath.Dir(rt.Socket), "g9", "app.sock") |
| + off := launch(8, false, rt.Socket, "inv1") |
| + gen := launch(9, true, genSock, "inv2") |
| + sameLaunch(t, off.Argv, gen.Argv, envMap(t, off.Env), envMap(t, gen.Env), rt.Socket, genSock) |
| +} |
| + |
| +// Rule 4: a flat host whose generation path would not fit a unix socket |
| +// address is handled as a foreign row is: no generation path, a generation |
| +// on its canonical socket, and an overlap that runs as a hold. |
| +func TestOverLongFlatHostKeepsItsCanonicalSocket(t *testing.T) { |
| + requireUnixSockets(t) |
| + b := testExecBackend(t) |
| + b.RunDirOverride = shortRunDir(t) |
| + host := strings.Repeat("a", 60-len(".example.com")) + ".example.com" // 60 bytes |
| + stageServingLive(t, b, host) |
| + rt := flatGenRoute(b, host) |
| + if l, _ := b.Layout(rt); l != LayoutFlat { |
| + t.Fatalf("fixture classifies %q, want flat", l) |
| + } |
| + if l := b.GenLayout(rt); l != LayoutForeign { |
| + t.Fatalf("GenLayout = %s, want foreign", l) |
| + } |
| + split := &SplitBackend{Exec: b} |
| + if l := split.GenLayout(rt); l != LayoutForeign { |
| + t.Fatalf("SplitBackend.GenLayout = %s, want foreign", l) |
| + } |
| + if g, ok := b.GenPaths(rt, 9); ok { |
| + t.Fatalf("GenPaths = %+v, true; want no generation path", g) |
| + } |
| + a := &Service{backend: b, entries: map[string]*actEntry{host: {route: rt}}} |
| + if m := a.EffectiveMode(host, ModeOverlap); m != ModeHold { |
| + t.Fatalf("EffectiveMode(overlap) = %s, want hold, as for a foreign row", m) |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + proc, g, err := b.StartGen(ctx, rt, 9, true) |
| + if err != nil { |
| + t.Fatalf("StartGen: %v", err) |
| + } |
| + defer proc.Stop(context.Background()) |
| + if g.Socket != rt.Socket || g.Control != rt.Socket+".control" { |
| + t.Fatalf("handle = %+v, want the canonical socket", g) |
| + } |
| + if !isSocket(rt.Socket) { |
| + t.Fatalf("%s is not a bound socket", rt.Socket) |
| + } |
| + if _, err := os.Stat(filepath.Join(b.RunDirOverride, "g")); !os.IsNotExist(err) { |
| + t.Fatalf("a generation directory was made for a host with no generation path (stat: %v)", err) |
| + } |
| + |
| + // The same host one byte shorter has generation paths. |
| + short := host[1:] |
| + if l := b.GenLayout(flatGenRoute(b, short)); l != LayoutFlat { |
| + t.Fatalf("GenLayout(%d-byte host) = %s, want flat", len(short), l) |
| + } |
| + if _, ok := b.GenPaths(flatGenRoute(b, short), 9); !ok { |
| + t.Fatalf("GenPaths(%d-byte host) has no generation path", len(short)) |
| + } |
| +} |
| + |
| +// Rule 3: once a generation's termination is proven, its own empty |
| +// directory goes. A directory its app left a socket in stays, a sibling's |
| +// directory is never touched (empty or not), and a generation on the |
| +// canonical socket touches nothing. |
| +func TestRetiredGenerationDirectoryIsRemovedAndASiblingsIsNot(t *testing.T) { |
| + root := t.TempDir() |
| + flat := registry.Route{Host: "shop.example.com", Socket: filepath.Join(root, "shop.example.com.sock")} |
| + nestedRun := filepath.Join(root, "instances", "shop.example.com") |
| + nested := registry.Route{Host: "shop.example.com", Socket: filepath.Join(nestedRun, "app.sock")} |
| + mk := func(dir string, files ...string) { |
| + t.Helper() |
| + if err := os.MkdirAll(dir, 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, f := range files { |
| + if err := os.WriteFile(filepath.Join(dir, f), nil, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + exists := func(p string) bool { _, err := os.Lstat(p); return err == nil } |
| + a := &Service{} |
| + |
| + for _, tc := range []struct { |
| + name string |
| + rt registry.Route |
| + own string // generation 9's directory |
| + siblings []string |
| + }{ |
| + {"flat", flat, filepath.Join(root, "g", "9"), []string{filepath.Join(root, "g", "8"), filepath.Join(root, "g", "10")}}, |
| + {"nested", nested, filepath.Join(nestedRun, "g9"), []string{filepath.Join(nestedRun, "g8"), filepath.Join(nestedRun, "g10")}}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + sock := filepath.Join(tc.own, filepath.Base(tc.rt.Socket)) |
| + g := Gen{N: 9, Socket: sock, Control: sock + ".control"} |
| + mk(tc.own) |
| + mk(tc.siblings[0], filepath.Base(tc.rt.Socket)) // a running sibling's socket |
| + mk(tc.siblings[1]) // an empty sibling |
| + mk(filepath.Dir(tc.rt.Socket), filepath.Base(tc.rt.Socket)) |
| + |
| + if a.genTerminated(tc.rt, g, &maintenanceProc{}) { |
| + t.Fatal("a running generation read as terminated") |
| + } |
| + if !exists(tc.own) { |
| + t.Fatal("the directory went before the termination proof") |
| + } |
| + |
| + mk(tc.own, filepath.Base(tc.rt.Socket)) // the app left its socket behind |
| + if !a.genTerminated(tc.rt, g, goneProc{}) { |
| + t.Fatal("an exited generation read as running") |
| + } |
| + if !exists(sock) { |
| + t.Fatal("the generation's own leftover socket was removed; only an empty directory goes") |
| + } |
| + |
| + if err := os.Remove(sock); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !a.genTerminated(tc.rt, g, goneProc{}) { |
| + t.Fatal("an exited generation read as running") |
| + } |
| + if exists(tc.own) { |
| + t.Fatalf("the retired generation's empty directory %s is still there", tc.own) |
| + } |
| + for _, s := range tc.siblings { |
| + if !exists(s) { |
| + t.Fatalf("a sibling's directory %s was removed", s) |
| + } |
| + } |
| + if !exists(tc.rt.Socket) { |
| + t.Fatal("the canonical socket was touched") |
| + } |
| + |
| + // A handleless generation proven gone is cleaned the same way. |
| + mk(tc.own) |
| + if !a.handlelessGenEmpty(tc.rt, g) { |
| + t.Fatal("a handleless generation with no child read as running") |
| + } |
| + if exists(tc.own) { |
| + t.Fatalf("the handleless generation's empty directory %s is still there", tc.own) |
| + } |
| + |
| + // A generation on the canonical socket has no directory of its |
| + // own: the run dir it sits in is never a candidate. |
| + canon := Gen{N: 9, Socket: tc.rt.Socket, Control: tc.rt.Socket + ".control"} |
| + if err := os.Remove(tc.rt.Socket); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, s := range tc.siblings { |
| + if err := os.RemoveAll(s); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if tc.name == "flat" { |
| + if err := os.Remove(filepath.Join(root, "g")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if !a.genTerminated(tc.rt, canon, goneProc{}) { |
| + t.Fatal("an exited canonical generation read as running") |
| + } |
| + if !exists(filepath.Dir(tc.rt.Socket)) { |
| + t.Fatal("a canonical generation's run dir was removed") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/activator/opset.go b/internal/activator/opset.go| index d9ca3b9fe..1de7efb35 100644 |
| --- a/internal/activator/opset.go |
| +++ b/internal/activator/opset.go |
| @@ -214,12 +214,17 @@ func (a *Service) stopMember(e *actEntry, m *genMember) bool { |
| // process handle (a member that never spawned, a failed wake whose start |
| // returned none): its own child, proven empty through KillGen. A number of |
| // 0 names no child, so it is trivially proven for a member (nothing was |
| -// ever asked to start under it). |
| +// ever asked to start under it). Once proven, the generation's own socket |
| +// directory goes if it is empty (removeGenDir, rev 19 rule 3). |
| func (a *Service) handlelessGenEmpty(rt registry.Route, g Gen) bool { |
| if g.N == 0 { |
| return true |
| } |
| - return a.genChildEmpty(rt, &genMember{gen: g}) |
| + if !a.genChildEmpty(rt, &genMember{gen: g}) { |
| + return false |
| + } |
| + removeGenDir(rt, g) |
| + return true |
| } |
| // genTerminated is the ONE termination proof for a generation (spec §7.3, |
| @@ -228,6 +233,12 @@ func (a *Service) handlelessGenEmpty(rt registry.Route, g Gen) bool { |
| // set asks it: a member's stop, the reaper, the current generation's stop, |
| // a pending stop's finish, a failed wake and its retained retry. A nil |
| // process never ran. |
| +// |
| +// Once it holds for a process that ran, the generation's own socket |
| +// directory goes if it is empty (removeGenDir, rev 19 rule 3). Its socket |
| +// is gone by then unless the app left it behind, and the agent's control |
| +// listener closed with the stop. A nil process proves nothing about a |
| +// directory, so it is left; a leftover is harmless (rule 3). |
| func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool { |
| if p == nil { |
| if g.Worker { |
| @@ -240,7 +251,11 @@ func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool { |
| if !p.Exited() { |
| return false |
| } |
| - return a.genChildEmpty(rt, &genMember{gen: g, proc: p}) |
| + if !a.genChildEmpty(rt, &genMember{gen: g, proc: p}) { |
| + return false |
| + } |
| + removeGenDir(rt, g) |
| + return true |
| } |
| // genChildEmpty proves m's generation child empty, after its leader has |
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go| index 7fb9b6f40..644ac20e0 100644 |
| --- a/internal/activator/prepare.go |
| +++ b/internal/activator/prepare.go |
| @@ -38,9 +38,11 @@ func (b *ExecBackend) prepare(rt registry.Route, role instancectl.Role) (instanc |
| } |
| // prepareAt is prepare for a run role whose app socket is sock: rt.Socket, |
| -// or a generation socket (StartGen, zero-downtime spec §7.1). Only the |
| -// file at sock is removed, never a sibling generation's, and the control |
| -// listener binds sock + ".control". The seed and sidecar roles ignore sock. |
| +// or a generation socket (StartGen, zero-downtime spec §7.1). A generation |
| +// socket's own directory, g<n> in the run dir, is made first (rev 19, rule |
| +// 3). Only the file at sock is removed, never a sibling generation's, and |
| +// the control listener binds sock + ".control". The seed and sidecar roles |
| +// ignore sock. |
| // gen is the generation unit being prepared (0 for the original templates): |
| // it keys the log socket and the exit records cleared (zero-downtime spec |
| // §7.6). |
| @@ -107,6 +109,17 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s |
| // no member-facing diagnosis at all, unlike the flat path's #129 |
| // handling, which at least turns the bind failure into a named |
| // error (memberSafeWakeReason). |
| + // |
| + // A generation's socket lives in a directory of its own (rev 19, |
| + // rule 1), made here with the run dir's mode. It inherits the run |
| + // dir's setgid carlos-sockets group, and it sits inside the run dir |
| + // the generation unit binds, so the app (uid carlos, as this agent |
| + // is) can bind its socket there. |
| + if dir := filepath.Dir(sock); dir != p.runDir { |
| + if err := os.MkdirAll(dir, 0o750|os.ModeSetgid); err != nil { |
| + return p, nil, nil, nil, err |
| + } |
| + } |
| _ = os.Remove(sock) |
| ctl = b.listenControl(rt, sock+".control", p.envFile, gen) |
| } |
diff --git a/internal/activator/swap.go b/internal/activator/swap.go| index 6834fd490..321ca794f 100644 |
| --- a/internal/activator/swap.go |
| +++ b/internal/activator/swap.go |
| @@ -456,11 +456,11 @@ func (a *Service) swapOverlap(ctx context.Context, gb SwapBackend, at Attempt, n |
| return errSwapKind |
| } |
| // A foreign row keeps its canonical socket (plan 1), so two |
| - // generations of it cannot coexist. It may still hold-swap. |
| - if c, ok := a.backend.(Classifier); ok { |
| - if l, _ := c.Layout(e.route); l != LayoutFlat && l != LayoutNested { |
| - return errSwapLayout |
| - } |
| + // generations of it cannot coexist. It may still hold-swap. So |
| + // can a flat row whose generation path would not fit (rev 19 |
| + // rule 4), which swapLayout reads as foreign. |
| + if l, ok := a.swapLayout(e.route); ok && l != LayoutFlat && l != LayoutNested { |
| + return errSwapLayout |
| } |
| // A parent still marked raised with no member is a restore that |
| // failed and awaits the sweep's retry (final review F11): an overlap |
diff --git a/internal/activator/swaphold.go b/internal/activator/swaphold.go| index 2acde9891..abe3f19dd 100644 |
| --- a/internal/activator/swaphold.go |
| +++ b/internal/activator/swaphold.go |
| @@ -214,10 +214,8 @@ func (a *Service) holdAdmitLocked(e *actEntry, at Attempt, newest func(string) u |
| } |
| // The layouts that can run a staged generation: flat, foreign on its |
| // canonical socket, and nested (root mounts the staged build, Task 9). |
| - if c, ok := a.backend.(Classifier); ok { |
| - if l, _ := c.Layout(e.route); l != LayoutFlat && l != LayoutForeign && l != LayoutNested { |
| - return errSwapLayout |
| - } |
| + if l, ok := a.swapLayout(e.route); ok && l != LayoutFlat && l != LayoutForeign && l != LayoutNested { |
| + return errSwapLayout |
| } |
| if len(e.ops.members) > 0 { |
| return errSwapBusy |
| @@ -479,12 +477,10 @@ func (a *Service) startPinnedAt(ctx context.Context, rt registry.Route, pin Gen, |
| } |
| // foreignRow reports whether host's row has the foreign layout (a |
| -// hand-set socket path), which keeps its canonical socket. |
| +// hand-set socket path), which keeps its canonical socket. A flat row whose |
| +// generation path would not fit a unix socket path reads foreign here too |
| +// (swapLayout, rev 19 rule 4). |
| func (a *Service) foreignRow(host string) bool { |
| - c, ok := a.backend.(Classifier) |
| - if !ok { |
| - return false |
| - } |
| a.mu.Lock() |
| e := a.entries[host] |
| var rt registry.Route |
| @@ -495,6 +491,22 @@ func (a *Service) foreignRow(host string) bool { |
| if e == nil { |
| return false |
| } |
| - l, _ := c.Layout(rt) |
| - return l == LayoutForeign |
| + l, ok := a.swapLayout(rt) |
| + return ok && l == LayoutForeign |
| +} |
| + |
| +// swapLayout is rt's layout for the swap's decisions: the backend's |
| +// GenLayout where it has one (a flat row whose generation path would not |
| +// fit reads foreign, rev 19 rule 4), else its Classifier layout. ok is false |
| +// for a backend that can say neither, whose rows are never refused on |
| +// layout here. |
| +func (a *Service) swapLayout(rt registry.Route) (Layout, bool) { |
| + if g, ok := a.backend.(genLayouter); ok { |
| + return g.GenLayout(rt), true |
| + } |
| + if c, ok := a.backend.(Classifier); ok { |
| + l, _ := c.Layout(rt) |
| + return l, true |
| + } |
| + return "", false |
| } |
diff --git a/internal/instanceexec/exec_test.go b/internal/instanceexec/exec_test.go| index fc1165991..3d94b51db 100644 |
| --- a/internal/instanceexec/exec_test.go |
| +++ b/internal/instanceexec/exec_test.go |
| @@ -163,7 +163,7 @@ func TestExecLaunchSplitsAGenerationInstance(t *testing.T) { |
| dir := filepath.Join(data, "instances", host) |
| // The legacy launch file is a decoy: a generation must never read it. |
| writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{Schema: 1, Host: host, Argv: []string{"/opt/carlos/live/" + host, "legacy"}}) |
| - raw, err := json.Marshal(instancectl.Launch{Schema: 1, Host: host, Argv: []string{"/opt/carlos/live/" + host, "--socket", "/run/carlos/instances/" + host + "/app.7.sock"}}) |
| + raw, err := json.Marshal(instancectl.Launch{Schema: 1, Host: host, Argv: []string{"/opt/carlos/live/" + host, "--socket", "/run/carlos/instances/" + host + "/g7/app.sock"}}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -174,7 +174,7 @@ func TestExecLaunchSplitsAGenerationInstance(t *testing.T) { |
| if err := execLaunch(data, instancectl.RoleRun, instancectl.GenInstance(host, 7), c.exec); err != nil { |
| t.Fatalf("execLaunch: %v", err) |
| } |
| - if c.calls != 1 || len(c.argv) != 3 || c.argv[2] != "/run/carlos/instances/"+host+"/app.7.sock" { |
| + if c.calls != 1 || len(c.argv) != 3 || c.argv[2] != "/run/carlos/instances/"+host+"/g7/app.sock" { |
| t.Errorf("exec = %+v", c) |
| } |
| } |