carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 19c07b2bd715f0a7e745d1e83e75357240066607 parent e7e9308
13 files changed, +753 −102
  • cmd/zddprobe/main.go +26 −0
  • cmd/zddprobe/main_test.go +69 −4
  • internal/activator/backend_exec.go +23 −13
  • internal/activator/genbackend.go +39 −5
  • internal/activator/generations.go +85 −18
  • internal/activator/generations_backend_test.go +18 −14
  • internal/activator/generations_test.go +69 −24
  • internal/activator/gensocket_rev19_test.go +360 −0
  • internal/activator/opset.go +18 −3
  • internal/activator/prepare.go +16 −3
  • internal/activator/swap.go +5 −5
  • internal/activator/swaphold.go +23 −11
  • internal/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)
}
}