Wake latency pass 2c: the agent hashes a staged build once per file identity; a cold start skips set-property when the slice has the tier (census-ruling-wake-latency-4)
Squashed from wake-latency-4 into main.
11 files changed,
+402
−22
docs/superpowers/notes/census-proposal-wake-latency-4.md+20 −0internal/instancectl/gen.go+45 −18internal/instancectl/server_gen_test.go+57 −3internal/provision/testdata/plan14-root-reads-chain.tsv+2 −0internal/provision/testdata/plan14-root-reads-wake-latency-4-new.tsv+3 −0internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv+4 −0internal/release/digestcache.go+110 −0internal/release/digestcache_linux.go+22 −0internal/release/digestcache_other.go+12 −0internal/release/digestcache_test.go+126 −0internal/release/staged.go+1 −1
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (0 s)
ok amadan.net/carlos/platform.git/launchctx 0.014s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (17 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (72 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.691s
ok github.com/carlosframework/platform/cmd/moveprobe 11.763s
ok github.com/carlosframework/platform/cmd/zddprobe 0.759s
ok github.com/carlosframework/platform/internal/activator 59.680s
ok github.com/carlosframework/platform/internal/agentsock 0.042s
ok github.com/carlosframework/platform/internal/alerting 0.010s
ok github.com/carlosframework/platform/internal/analytics 0.039s
ok github.com/carlosframework/platform/internal/appconfig 2.779s
ok github.com/carlosframework/platform/internal/apperr 0.136s
ok github.com/carlosframework/platform/internal/applog 0.277s
ok github.com/carlosframework/platform/internal/assetcache 0.143s
ok github.com/carlosframework/platform/internal/awsinv 0.015s
ok github.com/carlosframework/platform/internal/boxchoice 0.016s
ok github.com/carlosframework/platform/internal/boxid 0.720s
? 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.023s
ok github.com/carlosframework/platform/internal/caps 0.024s
ok github.com/carlosframework/platform/internal/certwarm 0.009s
ok github.com/carlosframework/platform/internal/cgroup 0.784s
ok github.com/carlosframework/platform/internal/changelog 0.476s
ok github.com/carlosframework/platform/internal/claimactivation 1.079s
ok github.com/carlosframework/platform/internal/clamd 0.064s
? github.com/carlosframework/platform/internal/cliflag [no test files]
? 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 7.789s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.027s
ok github.com/carlosframework/platform/internal/dbquery 0.016s
ok github.com/carlosframework/platform/internal/dbreads 2.178s
? github.com/carlosframework/platform/internal/dbreads/dbreadstest [no test files]
ok github.com/carlosframework/platform/internal/dbverify 0.021s
ok github.com/carlosframework/platform/internal/dnsrec 0.020s
ok github.com/carlosframework/platform/internal/docsite 0.028s
ok github.com/carlosframework/platform/internal/domains 2.661s
ok github.com/carlosframework/platform/internal/doorkey 0.013s
ok github.com/carlosframework/platform/internal/economics 0.017s
ok github.com/carlosframework/platform/internal/edge 45.925s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.009s
ok github.com/carlosframework/platform/internal/fleetapi 0.148s
ok github.com/carlosframework/platform/internal/gate 1.690s
ok github.com/carlosframework/platform/internal/genselect 0.022s
ok github.com/carlosframework/platform/internal/geo 0.029s
ok github.com/carlosframework/platform/internal/geoip 0.036s
ok github.com/carlosframework/platform/internal/historyreadback 0.206s
ok github.com/carlosframework/platform/internal/hostactors 5.922s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.006s
ok github.com/carlosframework/platform/internal/hostbox 2.795s
ok github.com/carlosframework/platform/internal/hostclaim 0.721s
ok github.com/carlosframework/platform/internal/hostenv 0.005s
ok github.com/carlosframework/platform/internal/hostevidence 0.008s
ok github.com/carlosframework/platform/internal/hostexec 5.238s
ok github.com/carlosframework/platform/internal/hostfence 0.040s
ok github.com/carlosframework/platform/internal/hostfiles 10.509s
ok github.com/carlosframework/platform/internal/hostfloor 0.020s
ok github.com/carlosframework/platform/internal/hostguard 1.052s
ok github.com/carlosframework/platform/internal/hostops 1.767s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.005s
ok github.com/carlosframework/platform/internal/hostquarantine 0.006s
ok github.com/carlosframework/platform/internal/hostrelocate 0.548s
ok github.com/carlosframework/platform/internal/hostresolve 0.196s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.004s
ok github.com/carlosframework/platform/internal/instancectl 5.241s
ok github.com/carlosframework/platform/internal/instanceexec 2.768s
ok github.com/carlosframework/platform/internal/instancefs 0.006s
ok github.com/carlosframework/platform/internal/instancekey 0.004s
ok github.com/carlosframework/platform/internal/instanceupdates 0.009s
ok github.com/carlosframework/platform/internal/layout 1.648s
ok github.com/carlosframework/platform/internal/lbhealth 0.006s
ok github.com/carlosframework/platform/internal/lease 10.181s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.443s
ok github.com/carlosframework/platform/internal/ledger 0.012s
ok github.com/carlosframework/platform/internal/mail 0.174s
ok github.com/carlosframework/platform/internal/maintenance 0.079s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.543s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.108s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 23.215s
ok github.com/carlosframework/platform/internal/moves/archive 0.871s
ok github.com/carlosframework/platform/internal/objstore 0.236s
ok github.com/carlosframework/platform/internal/ops 0.176s
ok github.com/carlosframework/platform/internal/park 0.225s
ok github.com/carlosframework/platform/internal/plans 0.008s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
ok github.com/carlosframework/platform/internal/provision 24.800s
ok github.com/carlosframework/platform/internal/publicrelease 0.012s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.608s
ok github.com/carlosframework/platform/internal/relay 7.224s
ok github.com/carlosframework/platform/internal/release 26.687s
ok github.com/carlosframework/platform/internal/rollout 3.840s
ok github.com/carlosframework/platform/internal/rolloutwords 0.006s
ok github.com/carlosframework/platform/internal/rootaws 0.009s
ok github.com/carlosframework/platform/internal/rootparticipation 0.058s
ok github.com/carlosframework/platform/internal/rootstate 0.028s
ok github.com/carlosframework/platform/internal/runs 0.594s
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.594s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/engines 2.138s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.968s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.601s
ok github.com/carlosframework/platform/internal/status 0.067s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.086s
ok github.com/carlosframework/platform/internal/svcrole 0.003s
ok github.com/carlosframework/platform/internal/telemetry 0.005s
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.003s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.005s
ok github.com/carlosframework/platform/internal/vet 1.608s
=== internal_console@0 (74 s)
ok github.com/carlosframework/platform/internal/console 72.645s
=== internal_console@1 (53 s)
ok github.com/carlosframework/platform/internal/console 51.521s
=== internal_console@2 (46 s)
ok github.com/carlosframework/platform/internal/console 43.927s
=== internal_console@3 (43 s)
ok github.com/carlosframework/platform/internal/console 41.577s
=== internal_console@4 (45 s)
ok github.com/carlosframework/platform/internal/console 43.523s
=== internal_console@5 (45 s)
ok github.com/carlosframework/platform/internal/console 43.170s
=== internal_console@6 (48 s)
ok github.com/carlosframework/platform/internal/console 45.957s
=== internal_console@7 (52 s)
ok github.com/carlosframework/platform/internal/console 50.089s
=== internal_host@0 (67 s)
ok github.com/carlosframework/platform/internal/host 65.273s
=== internal_host@1 (34 s)
ok github.com/carlosframework/platform/internal/host 32.231s
=== internal_host@2 (36 s)
ok github.com/carlosframework/platform/internal/host 33.392s
=== internal_host@3 (35 s)
ok github.com/carlosframework/platform/internal/host 32.946s
=== root@0 (70 s)
ok github.com/carlosframework/platform 68.412s
=== root@1 (49 s)
ok github.com/carlosframework/platform 47.305s
=== root@2 (60 s)
ok github.com/carlosframework/platform 58.681s
=== root@3 (58 s)
ok github.com/carlosframework/platform 56.465s
=== root@4 (62 s)
ok github.com/carlosframework/platform 60.764s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50659490 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.030s
=== RUN TestT4StorageProbeMinio
2026/10/25 07:01:48 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E1B3B31648B33A, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.027s
=== RUN TestS3StoreMinioContract
=== RUN TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.03s)
--- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
--- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.02s)
PASS
ok github.com/carlosframework/platform/internal/release 0.039s
Bucket created successfully `local/dbf-deploy`.
Bucket created successfully `local/dbf-data-ie`.
Bucket created successfully `local/dbf-data-uk`.
local/dbf-deploy versioning is enabled
Created policy `dbf-console` successfully.
Created policy `dbf-ukbox` successfully.
Added user `dbfconsole` successfully.
Added user `dbfukbox` successfully.
Attached Policies: [dbf-console]
To User: dbfconsole
Attached Policies: [dbf-ukbox]
To User: dbfukbox
=== RUN TestDBForwardMinio
2026/10/25 07:01:50 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/25 07:01:50 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/25 07:01:50 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/25 07:01:50 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/25 07:01:50 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/25 07:01:50 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/25 07:01:50 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/25 07:01:50 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/25 07:01:50 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/25 07:01:50 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/25 07:01:50 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/25 07:01:50 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/25 07:01:51 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/25 07:01:51 console: could not notify member@example.com of a CLI token issue: keymail: lookup member@example.com: keymail: GET /api/federation/lookup?addr=member%40example.com&from=console%40127.0.0.1%3A36565: could not determine target mailbox
agent.go:135: dbreads: dbr_hs6buzvz6sn54i2pi55ukbtqty: planned 1 files (720 bytes) and restored to 1 in 11ms
agent.go:135: dbreads: dbr_hs6buzvz6sn54i2pi55ukbtqty: output 5 bytes in 1 confirmed chunks in 2ms
2026/10/25 07:01:52 console: db read dbr_hs6buzvz6sn54i2pi55ukbtqty: relayed 1 chunks (102 bytes) in 1.006s, ended done
agent.go:135: dbreads: dbr_dg5ljqez4zbfivqmkfn42oerue: planned 3 files (1544 bytes) and restored to 3 in 12ms
agent.go:135: dbreads: dbr_dg5ljqez4zbfivqmkfn42oerue: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/25 07:01:53 console: db read dbr_dg5ljqez4zbfivqmkfn42oerue: relayed 1 chunks (102 bytes) in 1.005s, ended done
agent.go:135: dbreads: dbr_kiadmrmrbutibv4qvgifxr755q: planned 3 files (1544 bytes) and held through 3 in 12ms
agent.go:135: dbreads: dbr_kiadmrmrbutibv4qvgifxr755q: output 1544 bytes in 1 confirmed chunks in 1ms
2026/10/25 07:01:54 console: db read dbr_kiadmrmrbutibv4qvgifxr755q: relayed 1 chunks (1641 bytes) in 1.005s, ended done
=== RUN TestDBForwardMinio/by_disconnect
=== NAME TestDBForwardMinio
agent.go:135: dbreads: dbr_ofre5e6ci7tzkudujrn7xbztbq: planned 3 files (1544 bytes) and restored to 3 in 11ms
2026/10/25 07:01:54 console: db read dbr_ofre5e6ci7tzkudujrn7xbztbq: relayed 0 chunks (0 bytes) in 22ms, ended client gone
agent.go:135: dbreads: dbr_ofre5e6ci7tzkudujrn7xbztbq: output 5 bytes in 1 confirmed chunks in 105ms
=== RUN TestDBForwardMinio/by_timeout
=== NAME TestDBForwardMinio
agent.go:135: dbreads: dbr_msmypjuzhvpb5azr35cf4f2hyy: planned 3 files (1544 bytes) and restored to 3 in 13ms
2026/10/25 07:02:14 console: db read dbr_msmypjuzhvpb5azr35cf4f2hyy: relayed 0 chunks (0 bytes) in 19.685s, ended no-result
agent.go:135: dbreads: dbr_msmypjuzhvpb5azr35cf4f2hyy: output 5 bytes in 1 confirmed chunks in 19.654s
--- PASS: TestDBForwardMinio (23.15s)
--- PASS: TestDBForwardMinio/by_disconnect (0.23s)
--- PASS: TestDBForwardMinio/by_timeout (19.80s)
PASS
ok github.com/carlosframework/platform/internal/console 23.169s
minio contract: PASS
diff --git a/docs/superpowers/notes/census-proposal-wake-latency-4.md b/docs/superpowers/notes/census-proposal-wake-latency-4.md| new file mode 100644 |
| index 000000000..d5afb0b24 |
| --- /dev/null |
| +++ b/docs/superpowers/notes/census-proposal-wake-latency-4.md |
| @@ -0,0 +1,20 @@ |
| +# Census approval: wake-latency-4 |
| + |
| +Ruling `census-ruling-wake-latency-4`. Every change below keeps the disposition it already has, so it needs an approval, not a decision. |
| + |
| +Envelopes re-pinned: `internal/instancectl`, `internal/release`. |
| + |
| +Seal: `86570d1326baad6d866b4066b6bf65218434b201fb1f2c05ebc6e3c79f9c5a98` → `86570d1326baad6d866b4066b6bf65218434b201fb1f2c05ebc6e3c79f9c5a98`. |
| + |
| +Overlay: `internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv`, appended to `internal/provision/testdata/plan14-root-reads-chain.tsv`. |
| + |
| +## The decision: two new units, written by hand |
| + |
| +`internal/release/digestcache.go` adds two units that read files, so census-propose leaves them to a decision. Their rows are in `internal/provision/testdata/plan14-root-reads-wake-latency-4-new.tsv`, chained before the overlay above. The seal does not move. |
| + |
| +| unit | reads | proposed disposition | |
| +|---|---|---| |
| +| `FileSHA256Cached` | `file:Open`: opens the staged build `VerifyStaged` names, as `FileSHA256` does | `authority`, B3,J4, `root-input;kept=B3;scope=protected-decision-only`, the same as `FileSHA256` | |
| +| `SHA256Cached` | `file:Stat` ×2: fstats that descriptor before and after hashing it | the same | |
| + |
| +Only the agent calls these, through `release.VerifyStaged`, which is its duplicate of root's check on the nested path. Root instance control's staged-exe check is unchanged: it hashes in full before every bind into a tenant's namespace. That was Paul's ruling on 2026-10-25, after Astra's finding that a write through an already-writable mapping changes the bytes without moving a timestamp. The agent reuses a digest for an unchanged file identity (device, inode, size, mtime, ctime). A file changed in the last two seconds, or while it was being hashed, is never cached. |
diff --git a/internal/instancectl/gen.go b/internal/instancectl/gen.go| index 54b308362..154da8771 100644 |
| --- a/internal/instancectl/gen.go |
| +++ b/internal/instancectl/gen.go |
| @@ -113,24 +113,51 @@ func (s *Server) slicePopulated(host string) bool { |
| return !strings.Contains(string(b), "populated 0") |
| } |
| -// genDropInApplied reports whether systemd's view of unit is the drop-in |
| -// just written (GenDropIn). The unit's name fixes the host and generation, |
| -// and so every line of the drop-in but the tier's: the slice says systemd |
| -// read a drop-in at all, and the memory lines say it was this tier's. |
| -// NeedDaemonReload=no says no file changed since systemd loaded the unit, |
| -// though only by mtime, which is why the tier is checked as well (Astra |
| -// review: a reused, still-loaded generation and a clock that went back). |
| -// Any failure to answer is a no. |
| -func (s *Server) genDropInApplied(ctx context.Context, host, unit string, tier cgroup.Limit) (slice string, ok bool) { |
| - out, err := s.Systemctl(ctx, "show", "-p", "Slice,NeedDaemonReload,MemoryMax,MemoryHigh,MemorySwapMax", unit) |
| +// genView is startGen's one look at systemd, a single show of the unit and |
| +// its host slice. applied reports whether systemd's view of unit is the |
| +// drop-in just written (GenDropIn). The unit's name fixes the host and |
| +// generation, and so every line of the drop-in but the tier's: the slice |
| +// says systemd read a drop-in at all, and the memory lines say it was this |
| +// tier's. NeedDaemonReload=no says no file changed since systemd loaded |
| +// the unit, though only by mtime, which is why the tier is checked as well |
| +// (Astra review: a reused, still-loaded generation and a clock that went |
| +// back). tierSet reports whether the slice already carries every value |
| +// setTier would give it, so a cold start can skip that call (wake latency, |
| +// pass 2c). Any failure to answer is a no to both. |
| +func (s *Server) genView(ctx context.Context, host, unit string, tier cgroup.Limit) (slice string, applied, tierSet bool) { |
| + out, err := s.Systemctl(ctx, "show", "-p", "Slice,NeedDaemonReload,MemoryMax,MemoryHigh,MemorySwapMax,CPUWeight,CPUQuotaPerSecUSec,IOWeight", unit, SliceName(host)) |
| if err != nil { |
| - return "", false |
| + return "", false, false |
| } |
| - m := parseShow(out) |
| + blocks := strings.SplitN(strings.TrimLeft(out, "\n"), "\n\n", 2) |
| + m := parseShow(blocks[0]) |
| max := strconv.FormatInt(tier.MemoryMax, 10) |
| - return m["Slice"], m["Slice"] == SliceName(host) && m["NeedDaemonReload"] == "no" && |
| - m["MemoryMax"] == max && m["MemorySwapMax"] == max && |
| - m["MemoryHigh"] == strconv.FormatInt(cgroup.HighOf(tier.MemoryMax), 10) |
| + high := strconv.FormatInt(cgroup.HighOf(tier.MemoryMax), 10) |
| + applied = m["Slice"] == SliceName(host) && m["NeedDaemonReload"] == "no" && |
| + m["MemoryMax"] == max && m["MemorySwapMax"] == max && m["MemoryHigh"] == high |
| + if len(blocks) == 2 { |
| + sl := parseShow(blocks[1]) |
| + sh := cgroup.SharesOf(tier) |
| + tierSet = sl["MemoryMax"] == max && sl["MemorySwapMax"] == max && sl["MemoryHigh"] == high && |
| + sl["CPUWeight"] == strconv.Itoa(sh.CPUWeight) && sl["IOWeight"] == strconv.Itoa(sh.IOWeight) && |
| + sl["CPUQuotaPerSecUSec"] == cpuQuotaShown(sh.CPUQuotaPercent) |
| + } |
| + return m["Slice"], applied, tierSet |
| +} |
| + |
| +// cpuQuotaShown is CPUQuota=<pct>% as systemctl show prints its |
| +// CPUQuotaPerSecUSec (systemd 257): 500ms, 1s, 1.500000s. A format this |
| +// gets wrong only costs the set-property call it would have saved. |
| +func cpuQuotaShown(pct int) string { |
| + usec := int64(pct) * 10000 |
| + switch { |
| + case usec%1_000_000 == 0: |
| + return strconv.FormatInt(usec/1_000_000, 10) + "s" |
| + case usec < 1_000_000: |
| + return strconv.FormatInt(usec/1000, 10) + "ms" |
| + default: |
| + return strconv.FormatFloat(float64(usec)/1e6, 'f', 6, 64) + "s" |
| + } |
| } |
| // startGen is /start with a generation. The host slice is the parent (spec |
| @@ -180,20 +207,20 @@ func (s *Server) startGen(w http.ResponseWriter, r *http.Request, req request) { |
| // straight away, so the next reference reads the drop-in fresh: the |
| // common case needs no reload, which on a small box was a third of the |
| // whole wake. The check is the same either way (genDropInApplied). |
| - slice, applied := s.genDropInApplied(ctx, req.host, unit, req.tier) |
| + slice, applied, tierSet := s.genView(ctx, req.host, unit, req.tier) |
| if !applied { |
| reloadStart := time.Now() |
| if out, err := s.Systemctl(ctx, "daemon-reload"); err != nil { |
| fail(http.StatusBadGateway, "daemon-reload: "+strings.TrimSpace(out+" "+err.Error())) |
| return |
| } |
| - if slice, applied = s.genDropInApplied(ctx, req.host, unit, req.tier); !applied { |
| + if slice, applied, tierSet = s.genView(ctx, req.host, unit, req.tier); !applied { |
| fail(http.StatusBadGateway, "not starting "+unit+": its drop-in did not apply (slice "+strconv.Quote(slice)+")") |
| return |
| } |
| reload = time.Since(reloadStart).Round(time.Millisecond).String() |
| } |
| - if !s.slicePopulated(req.host) { |
| + if !s.slicePopulated(req.host) && !tierSet { |
| if err := s.setTier(ctx, req.host, req.tier); err != nil { |
| fail(http.StatusBadGateway, "set-property: "+err.Error()) |
| return |
diff --git a/internal/instancectl/server_gen_test.go b/internal/instancectl/server_gen_test.go| index 6f2f61937..577b5baf1 100644 |
| --- a/internal/instancectl/server_gen_test.go |
| +++ b/internal/instancectl/server_gen_test.go |
| @@ -19,6 +19,14 @@ const genHost = "bill-2.example.com" |
| var genUnit = GenUnitName(genHost, 7) |
| +// genShow is startGen's one look at systemd: the unit's drop-in lines and |
| +// its host slice's tier, in one call. |
| +var genShow = "show -p Slice,NeedDaemonReload,MemoryMax,MemoryHigh,MemorySwapMax,CPUWeight,CPUQuotaPerSecUSec,IOWeight " + genUnit + " " + SliceName(genHost) |
| + |
| +// genSliceAtTier is that call's second block for a slice already at the |
| +// default tier. |
| +const genSliceAtTier = "\nSlice=carlos.slice\nNeedDaemonReload=no\nMemoryMax=536870912\nMemoryHigh=483183819\nMemorySwapMax=536870912\nCPUWeight=100\nCPUQuotaPerSecUSec=1s\nIOWeight=100\n" |
| + |
| // genTierShow is systemd's answer for the default tier's memory lines. |
| const genTierShow = "MemoryMax=536870912\nMemoryHigh=483183819\nMemorySwapMax=536870912\n" |
| @@ -74,7 +82,7 @@ func TestGenStartWritesTheDropInChecksItThenStartsWithoutAReload(t *testing.T) { |
| t.Fatalf("exe app link = %q, %v", target, err) |
| } |
| want := strings.Join([]string{ |
| - "show -p Slice,NeedDaemonReload,MemoryMax,MemoryHigh,MemorySwapMax " + genUnit, |
| + genShow, |
| `set-property --runtime carlos-instances-bill\x2d2.example.com.slice MemoryMax=536870912 MemoryHigh=483183819 MemorySwapMax=536870912 CPUWeight=100 CPUQuota=100% IOWeight=100`, |
| "start " + genUnit, |
| "show -p InvocationID --value " + genUnit, |
| @@ -112,9 +120,9 @@ func TestGenStartReloadsWhenSystemdHoldsAStaleCopy(t *testing.T) { |
| t.Fatal(err) |
| } |
| want := strings.Join([]string{ |
| - "show -p Slice,NeedDaemonReload,MemoryMax,MemoryHigh,MemorySwapMax " + genUnit, |
| + genShow, |
| "daemon-reload", |
| - "show -p Slice,NeedDaemonReload,MemoryMax,MemoryHigh,MemorySwapMax " + genUnit, |
| + genShow, |
| }, "\n") |
| if got := ctl.joined(); !strings.HasPrefix(got, want+"\n") || !strings.Contains(got, "\nstart "+genUnit) { |
| t.Errorf("calls =\n%s\nwant them to begin\n%s\nand then start", got, want) |
| @@ -777,3 +785,49 @@ func TestAbandonedGenStartResetsFailedOnlyAfterAProvenKill(t *testing.T) { |
| t.Errorf("reset-failed after a failed kill:\n%s", ctl.joined()) |
| } |
| } |
| + |
| +// A cold start whose slice already carries the tier (it kept it from the |
| +// last generation: set-property --runtime lasts until a reboot) skips the |
| +// set-property: one systemctl call fewer on a wake. The same show read it. |
| +func TestGenStartSkipsTheTierWhenTheSliceAlreadyHasIt(t *testing.T) { |
| + ctl := genCtl() |
| + ctl.answers["show -p Slice,NeedDaemonReload,MemoryMax,MemoryHigh,MemorySwapMax"] = "Slice=" + SliceName(genHost) + "\nNeedDaemonReload=no\n" + genTierShow + genSliceAtTier |
| + _, sock, _ := genServer(t, ctl) |
| + if _, err := NewClient(sock).StartGen(context.Background(), genHost, 7, cgroup.TierDefault.MemoryMax); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := ctl.joined(); strings.Contains(got, "set-property") { |
| + t.Errorf("the slice already had the tier but was set again:\n%s", got) |
| + } |
| +} |
| + |
| +// Any one value off, or the slice missing from the answer, and the tier is |
| +// set as before. |
| +func TestGenStartSetsTheTierWhenTheSliceDiffers(t *testing.T) { |
| + for name, block := range map[string]string{ |
| + "memory": strings.Replace(genSliceAtTier, "MemoryMax=536870912", "MemoryMax=1073741824", 1), |
| + "cpu quota": strings.Replace(genSliceAtTier, "CPUQuotaPerSecUSec=1s", "CPUQuotaPerSecUSec=2s", 1), |
| + "io weight": strings.Replace(genSliceAtTier, "IOWeight=100", "IOWeight=[not set]", 1), |
| + "no slice block": "", |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + ctl := genCtl() |
| + ctl.answers["show -p Slice,NeedDaemonReload,MemoryMax,MemoryHigh,MemorySwapMax"] = "Slice=" + SliceName(genHost) + "\nNeedDaemonReload=no\n" + genTierShow + block |
| + _, sock, _ := genServer(t, ctl) |
| + if _, err := NewClient(sock).StartGen(context.Background(), genHost, 7, cgroup.TierDefault.MemoryMax); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := ctl.joined(); !strings.Contains(got, "set-property --runtime "+SliceName(genHost)) { |
| + t.Errorf("the slice differs (%s) but the tier was not set:\n%s", name, got) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestCPUQuotaShowsAsSystemdShowsIt(t *testing.T) { |
| + for pct, want := range map[int]string{50: "500ms", 100: "1s", 150: "1.500000s", 200: "2s", 250: "2.500000s"} { |
| + if got := cpuQuotaShown(pct); got != want { |
| + t.Errorf("cpuQuotaShown(%d) = %q, want %q (systemd 257)", pct, got, want) |
| + } |
| + } |
| +} |
diff --git a/internal/provision/testdata/plan14-root-reads-chain.tsv b/internal/provision/testdata/plan14-root-reads-chain.tsv| index 671a7bc0d..9c3817b47 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-chain.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-chain.tsv |
| @@ -10,3 +10,5 @@ plan14-root-reads-edge-asset-cache-2.tsv de15b849fcb93b76caca55be924cde41512f8c4 |
| plan14-root-reads-pro-6214-service-runs-decisions.tsv ec2d8daaec385b34b3be9c8c6430427c082ac36cd40150e1c6f520e20e3da72d |
| plan14-root-reads-pro-6214-service-runs.tsv 86570d1326baad6d866b4066b6bf65218434b201fb1f2c05ebc6e3c79f9c5a98 |
| plan14-root-reads-wake-latency-3.tsv 86570d1326baad6d866b4066b6bf65218434b201fb1f2c05ebc6e3c79f9c5a98 |
| +plan14-root-reads-wake-latency-4-new.tsv 86570d1326baad6d866b4066b6bf65218434b201fb1f2c05ebc6e3c79f9c5a98 |
| +plan14-root-reads-wake-latency-4.tsv 86570d1326baad6d866b4066b6bf65218434b201fb1f2c05ebc6e3c79f9c5a98 |
diff --git a/internal/provision/testdata/plan14-root-reads-wake-latency-4-new.tsv b/internal/provision/testdata/plan14-root-reads-wake-latency-4-new.tsv| new file mode 100644 |
| index 000000000..615db99c4 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-wake-latency-4-new.tsv |
| @@ -0,0 +1,3 @@ |
| +# Census overlay wake-latency-4-new (wake latency pass 2c), hand-written: the two new units census-propose leaves to a decision. census-ruling-wake-latency-4, asked of the census owner with the approval card (docs/superpowers/notes/census-proposal-wake-latency-4.md). |
| +internal/release/digestcache.go FileSHA256Cached authority B3,J4 root-input;kept=B3;scope=protected-decision-only;rev3=census-ruling-wake-latency-4;identity=the agent (/opt/carlos/carlos agent, user carlos), through release.VerifyStaged only;basis=FileSHA256 with SHA256Cached: opens the staged build VerifyStaged names and hashes it, or returns the digest cached for the same file identity; root instance control does not use it and hashes in full before every bind (Paul, 2026-10-25); decides only the digest VerifyStaged compares file:Open@64 3a4557e9a9aef30afd681962f77cff0586a787f95d664c426c8f2b3d70e08cb4 |
| +internal/release/digestcache.go SHA256Cached authority B3,J4 root-input;kept=B3;scope=protected-decision-only;rev3=census-ruling-wake-latency-4;identity=the agent, through FileSHA256Cached only;basis=fstats the open file before and after hashing it to key a digest by device, inode, size, mtime and ctime; a file changed in the last two seconds, or while hashed, is not cached; decides only whether VerifyStaged reuses a digest; root never calls it file:Stat@60,file:Stat@645 f965eb2656c2356e210d84946e70a1f9498aee542c21f3d2b7cb926fd4fa09d7 |
diff --git a/internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv b/internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv| new file mode 100644 |
| index 000000000..6aef32fd9 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-wake-latency-4.tsv |
| @@ -0,0 +1,4 @@ |
| +# Census overlay wake-latency-4, written by census-propose (#369). census-ruling-wake-latency-4, approved by the census owner before the merge; the approval card is docs/superpowers/notes/census-proposal-wake-latency-4.md. |
| +# 0 body, 0 sites, 2 envelope: each changed unit re-frozen at its existing disposition with its claim appended. |
| +@coverage internal/instancectl c9d70004f78997724526ab2acdc80f4bdd1a6fe4621f911440ba1c2eb547c3a0 |
| +@coverage internal/release e52b41652b566af823ceb76758edce99b7055eb6bed7df5945bf1d814e5acc73 |
diff --git a/internal/release/digestcache.go b/internal/release/digestcache.go| new file mode 100644 |
| index 000000000..b63e1bb42 |
| --- /dev/null |
| +++ b/internal/release/digestcache.go |
| @@ -0,0 +1,110 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "io" |
| + "os" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// A staged build was hashed whole on every wake, by the agent |
| +// (VerifyStaged) and again by root (instance control's staged-exe check): |
| +// 24 ms for a 9 MB binary on a dev box, so a 100 MB bundle paid a quarter |
| +// of a second a wake. The agent's check now keeps its digest per file |
| +// identity. Root's check does not, and hashes in full before every bind |
| +// into a tenant's namespace (Paul's ruling, 2026-10-25, on Astra's |
| +// finding): a write through a mapping that is already writable changes the |
| +// bytes without moving a timestamp, so no identity proves a file |
| +// unchanged, and root's check is the boundary. The agent's is a duplicate |
| +// of it on the nested path. The identity is: device, |
| +// inode, size, and the modification and change times to the nanosecond, |
| +// read from the descriptor being hashed. An ordinary write moves its |
| +// ctime, which no unprivileged caller can set back, and a replacement by |
| +// rename (how the version store installs a build) is a new inode, so such |
| +// a change is a new identity and is hashed again; a write through an |
| +// existing writable mapping may not be, which is why root does not cache. Where the platform gives no |
| +// such identity (fileIdentity is linux-only), every call hashes, as before. |
| +// |
| +// File times are coarse (a clock tick), so a file changed moments before |
| +// it is hashed could be rewritten within the same tick and keep its |
| +// identity. As git does for its index, a digest is cached only for a file |
| +// whose last change was at least digestSettle before the hash: any later |
| +// write then lands at a visibly later time. |
| +var ( |
| + digestMu sync.Mutex |
| + digestCache = map[string]string{} // identity -> sha256 hex |
| + // digestHashHook, when set (tests only), runs each time a file is |
| + // actually hashed. |
| + digestHashHook func() |
| + // digestNow is the clock the settle rule reads (tests move it). |
| + digestNow = time.Now |
| +) |
| + |
| +// digestSettle is how long a file must have been unchanged for its digest |
| +// to be cached. |
| +const digestSettle = 2 * time.Second |
| + |
| +// digestCacheMax bounds the cache: identities of replaced builds are never |
| +// asked again, so past the bound it starts over rather than grow. |
| +const digestCacheMax = 4096 |
| + |
| +func resetDigestCache() { |
| + digestMu.Lock() |
| + defer digestMu.Unlock() |
| + digestCache = map[string]string{} |
| +} |
| + |
| +// SHA256Cached is the sha256 of the open file f, from the cache when f's |
| +// identity was hashed before. It reads from f's current offset, as |
| +// io.Copy would, so callers pass a freshly opened file. |
| +func SHA256Cached(f *os.File) (string, error) { |
| + fi, err := f.Stat() |
| + if err != nil { |
| + return "", err |
| + } |
| + id, changed, ok := fileIdentity(fi) |
| + settled := ok && digestNow().Sub(changed) >= digestSettle |
| + if ok { |
| + digestMu.Lock() |
| + sum, hit := digestCache[id] |
| + digestMu.Unlock() |
| + if hit { |
| + return sum, nil |
| + } |
| + } |
| + if digestHashHook != nil { |
| + digestHashHook() |
| + } |
| + h := sha256.New() |
| + if _, err := io.Copy(h, f); err != nil { |
| + return "", err |
| + } |
| + sum := hex.EncodeToString(h.Sum(nil)) |
| + if settled { |
| + // The identity is read again after the hash: a file that changed |
| + // while it was read is not cached under either identity. |
| + if fi2, err := f.Stat(); err == nil { |
| + if id2, _, ok2 := fileIdentity(fi2); ok2 && id2 == id { |
| + digestMu.Lock() |
| + if len(digestCache) >= digestCacheMax { |
| + digestCache = map[string]string{} |
| + } |
| + digestCache[id] = sum |
| + digestMu.Unlock() |
| + } |
| + } |
| + } |
| + return sum, nil |
| +} |
| + |
| +// FileSHA256Cached is SHA256Cached on the file at path. |
| +func FileSHA256Cached(path string) (string, error) { |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer f.Close() |
| + return SHA256Cached(f) |
| +} |
diff --git a/internal/release/digestcache_linux.go b/internal/release/digestcache_linux.go| new file mode 100644 |
| index 000000000..d3bbb8362 |
| --- /dev/null |
| +++ b/internal/release/digestcache_linux.go |
| @@ -0,0 +1,22 @@ |
| +package release |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| + "syscall" |
| + "time" |
| +) |
| + |
| +// fileIdentity is f's device, inode, size, mtime and ctime (nanoseconds), |
| +// and the later of its mtime and ctime: when it last changed. |
| +func fileIdentity(fi os.FileInfo) (string, time.Time, bool) { |
| + st, ok := fi.Sys().(*syscall.Stat_t) |
| + if !ok || !fi.Mode().IsRegular() { |
| + return "", time.Time{}, false |
| + } |
| + m, c := time.Unix(st.Mtim.Sec, st.Mtim.Nsec), time.Unix(st.Ctim.Sec, st.Ctim.Nsec) |
| + if m.After(c) { |
| + c = m |
| + } |
| + return fmt.Sprintf("%d:%d:%d:%d.%d:%d.%d", st.Dev, st.Ino, st.Size, st.Mtim.Sec, st.Mtim.Nsec, st.Ctim.Sec, st.Ctim.Nsec), c, true |
| +} |
diff --git a/internal/release/digestcache_other.go b/internal/release/digestcache_other.go| new file mode 100644 |
| index 000000000..4daca3830 |
| --- /dev/null |
| +++ b/internal/release/digestcache_other.go |
| @@ -0,0 +1,12 @@ |
| +//go:build !linux |
| + |
| +package release |
| + |
| +import ( |
| + "os" |
| + "time" |
| +) |
| + |
| +// fileIdentity has no portable inode and change time here: every call |
| +// hashes (digestcache.go). |
| +func fileIdentity(os.FileInfo) (string, time.Time, bool) { return "", time.Time{}, false } |
diff --git a/internal/release/digestcache_test.go b/internal/release/digestcache_test.go| new file mode 100644 |
| index 000000000..58742b448 |
| --- /dev/null |
| +++ b/internal/release/digestcache_test.go |
| @@ -0,0 +1,126 @@ |
| +//go:build linux |
| + |
| +package release |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func sumOf(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } |
| + |
| +// settledClock moves the cache's clock a minute ahead, so the files a test |
| +// just wrote count as settled. |
| +func settledClock(t *testing.T) { |
| + t.Helper() |
| + digestNow = func() time.Time { return time.Now().Add(time.Minute) } |
| + t.Cleanup(func() { digestNow = time.Now }) |
| +} |
| + |
| +// A file changed moments before it is hashed is not cached: a rewrite in |
| +// the same clock tick could keep its identity. |
| +func TestFileSHA256CachedDoesNotCacheAFreshFile(t *testing.T) { |
| + resetDigestCache() |
| + path := filepath.Join(t.TempDir(), "app") |
| + if err := os.WriteFile(path, []byte("build one"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + hashed := 0 |
| + digestHashHook = func() { hashed++ } |
| + t.Cleanup(func() { digestHashHook = nil }) |
| + for range 2 { |
| + if _, err := FileSHA256Cached(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if hashed != 2 { |
| + t.Fatalf("hashed %d times, want every time for a file not yet settled", hashed) |
| + } |
| +} |
| + |
| +// A staged build is hashed once per file identity, not once per wake: the |
| +// second check of an unchanged file reads none of it. |
| +func TestFileSHA256CachedHashesAnUnchangedFileOnce(t *testing.T) { |
| + resetDigestCache() |
| + settledClock(t) |
| + path := filepath.Join(t.TempDir(), "app") |
| + if err := os.WriteFile(path, []byte("build one"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + hashed := 0 |
| + digestHashHook = func() { hashed++ } |
| + t.Cleanup(func() { digestHashHook = nil }) |
| + for range 3 { |
| + got, err := FileSHA256Cached(path) |
| + if err != nil || got != sumOf([]byte("build one")) { |
| + t.Fatalf("FileSHA256Cached = %q, %v", got, err) |
| + } |
| + } |
| + if hashed != 1 { |
| + t.Fatalf("hashed %d times, want once", hashed) |
| + } |
| +} |
| + |
| +// Any change to the file is a new identity: rewritten in place (its mtime |
| +// and ctime move, whatever mtime is set back to) or replaced by a rename (a |
| +// new inode). Each is hashed again, so a changed copy is always caught. |
| +func TestFileSHA256CachedSeesEveryChange(t *testing.T) { |
| + resetDigestCache() |
| + settledClock(t) |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "app") |
| + if err := os.WriteFile(path, []byte("build one"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := FileSHA256Cached(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + // In place, same size, mtime put back: ctime still moves. |
| + st, _ := os.Stat(path) |
| + time.Sleep(10 * time.Millisecond) |
| + if err := os.WriteFile(path, []byte("build two"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chtimes(path, st.ModTime(), st.ModTime()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := FileSHA256Cached(path); err != nil || got != sumOf([]byte("build two")) { |
| + t.Fatalf("after an in-place rewrite = %q, %v", got, err) |
| + } |
| + // Replaced by rename. |
| + tmp := filepath.Join(dir, "app.new") |
| + if err := os.WriteFile(tmp, []byte("build three"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := FileSHA256Cached(path); err != nil || got != sumOf([]byte("build three")) { |
| + t.Fatalf("after a rename = %q, %v", got, err) |
| + } |
| +} |
| + |
| +// VerifyStaged goes through the cache and still refuses a changed copy. |
| +func TestVerifyStagedStillRefusesAChangedCopy(t *testing.T) { |
| + resetDigestCache() |
| + settledClock(t) |
| + path := filepath.Join(t.TempDir(), "app") |
| + if err := os.WriteFile(path, []byte("build one"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + id := StagedIdentity{Version: "v1", StagedPath: path, Digest: sumOf([]byte("build one"))} |
| + if err := VerifyStaged(id); err != nil { |
| + t.Fatal(err) |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + if err := os.WriteFile(path, []byte("tampered!"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := VerifyStaged(id); err == nil { |
| + t.Fatal("a changed copy verified") |
| + } |
| +} |
diff --git a/internal/release/staged.go b/internal/release/staged.go| index 8487a0743..df1e43e28 100644 |
| --- a/internal/release/staged.go |
| +++ b/internal/release/staged.go |
| @@ -180,7 +180,7 @@ func VerifyStaged(id StagedIdentity) error { |
| if id.StagedPath == "" || id.Digest == "" { |
| return fmt.Errorf("%w: no retained path and digest for %q", ErrStagedCopyChanged, id.Version) |
| } |
| - sum, err := FileSHA256(id.StagedPath) |
| + sum, err := FileSHA256Cached(id.StagedPath) |
| if err != nil { |
| return fmt.Errorf("%w: %s: %v", ErrStagedCopyChanged, id.StagedPath, err) |
| } |