claim Plan 14 Part 1 fix round 5: no census means main's restart effects, always
Merge ruling 6: with no root census every restart, rollback, start and reset-failed effect is main's exact code (restored from origin/main); the named-unit binding only feeds history, proofs and diagnostics, and drives effects only once a census exists (Part 3). Startup journal recovery is gated the same way. Removes the round 3-4 compensations. A 90-case main-equivalence table compares effects, pointers and process state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+503
−89
internal/host/legacyrestart.go+26 −9internal/host/plan14_fix2_live_linux_test.go+24 −7internal/host/plan14_fix3_live_linux_test.go+3 −4internal/host/plan14_fix5_live_linux_test.go+281 −0internal/host/plan14_fix5_main_linux_test.go+97 −0internal/host/releaseinit_linux_test.go+4 −1internal/host/rootrestart.go+24 −68internal/host/rootrollback_resume.go+10 −0internal/provision/plan14_legacy_identity_test.go+20 −0internal/provision/plan14_root_reads_test.go+3 −0internal/provision/testdata/plan14-root-reads-fix5.tsv+11 −0
CI passed — 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 331.113s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.917s
ok github.com/carlosframework/platform/cmd/moveprobe 92.180s
ok github.com/carlosframework/platform/internal/activator 227.663s
ok github.com/carlosframework/platform/internal/agentsock 0.017s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.017s
ok github.com/carlosframework/platform/internal/appconfig 2.352s
ok github.com/carlosframework/platform/internal/apperr 0.073s
ok github.com/carlosframework/platform/internal/applog 0.126s
ok github.com/carlosframework/platform/internal/awsinv 0.005s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.785s
? 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.004s
ok github.com/carlosframework/platform/internal/cgroup 0.199s
ok github.com/carlosframework/platform/internal/changelog 0.314s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 363.173s
? 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 6.502s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.004s
ok github.com/carlosframework/platform/internal/docsite 0.004s
ok github.com/carlosframework/platform/internal/domains 2.151s
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 26.120s
? 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.129s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.016s
ok github.com/carlosframework/platform/internal/geoip 0.031s
ok github.com/carlosframework/platform/internal/host 93.874s
ok github.com/carlosframework/platform/internal/hostactors 0.333s
ok github.com/carlosframework/platform/internal/hostbox 2.499s
ok github.com/carlosframework/platform/internal/hostclaim 0.523s
ok github.com/carlosframework/platform/internal/hostevidence 0.014s
ok github.com/carlosframework/platform/internal/hostexec 4.872s
ok github.com/carlosframework/platform/internal/hostfence 0.028s
ok github.com/carlosframework/platform/internal/hostfiles 10.522s
ok github.com/carlosframework/platform/internal/hostfloor 0.017s
ok github.com/carlosframework/platform/internal/hostguard 0.968s
ok github.com/carlosframework/platform/internal/hostops 1.446s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.013s
ok github.com/carlosframework/platform/internal/hostquarantine 0.013s
ok github.com/carlosframework/platform/internal/hostrelocate 0.453s
ok github.com/carlosframework/platform/internal/hostresolve 0.164s
ok github.com/carlosframework/platform/internal/hostwrite 0.015s
ok github.com/carlosframework/platform/internal/infraview 0.014s
ok github.com/carlosframework/platform/internal/instancectl 1.725s
ok github.com/carlosframework/platform/internal/instanceexec 2.696s
ok github.com/carlosframework/platform/internal/instancefs 0.021s
ok github.com/carlosframework/platform/internal/instancekey 0.016s
ok github.com/carlosframework/platform/internal/instanceupdates 0.021s
ok github.com/carlosframework/platform/internal/layout 1.444s
ok github.com/carlosframework/platform/internal/lease 9.435s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.008s
ok github.com/carlosframework/platform/internal/ledger 0.013s
ok github.com/carlosframework/platform/internal/mail 0.162s
ok github.com/carlosframework/platform/internal/maintenance 0.077s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.544s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.075s
ok github.com/carlosframework/platform/internal/metrics 0.007s
ok github.com/carlosframework/platform/internal/moves 21.140s
ok github.com/carlosframework/platform/internal/moves/archive 0.766s
ok github.com/carlosframework/platform/internal/objstore 0.172s
ok github.com/carlosframework/platform/internal/ops 0.165s
ok github.com/carlosframework/platform/internal/park 0.210s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
ok github.com/carlosframework/platform/internal/provision 13.070s
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.054s
ok github.com/carlosframework/platform/internal/relay 7.235s
ok github.com/carlosframework/platform/internal/release 19.436s
ok github.com/carlosframework/platform/internal/rollout 3.705s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.006s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.004s
ok github.com/carlosframework/platform/internal/safefile 0.003s
ok github.com/carlosframework/platform/internal/schedule 2.295s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.418s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.590s
ok github.com/carlosframework/platform/internal/status 0.042s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.202s
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.550s
=== 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
--- 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.021s
=== RUN TestT4StorageProbeMinio
2026/10/13 16:52:28 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE24F7BF764B18, 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.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/internal/host/legacyrestart.go b/internal/host/legacyrestart.go| index 55092d1b3..dcbcc3afe 100644 |
| --- a/internal/host/legacyrestart.go |
| +++ b/internal/host/legacyrestart.go |
| @@ -2,12 +2,12 @@ package host |
| import ( |
| "errors" |
| + "fmt" |
| "log" |
| "os" |
| "path/filepath" |
| "time" |
| - "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/rootparticipation" |
| @@ -17,12 +17,13 @@ var errRestartBinding = errors.Join(errors.New("named-unit binding changed"), os |
| // A retained census keeps the strict path even after the flag is disabled. |
| // An unreadable census is never evidence that this is a legacy box. |
| +// The claims flag alone cannot select bound effects without a census. |
| func legacyRootRestart() (bool, error) { |
| l, required, err := rootparticipation.OpenForUpdate() |
| if l.State != nil { |
| l.State.Close() |
| } |
| - return !required && !hostwrite.Enabled(os.Getenv) && err == nil, err |
| + return !required && err == nil, err |
| } |
| func (c rootRestartChecks) allowLegacy() (bool, error) { |
| @@ -44,11 +45,19 @@ func restartEffectFailed(err error) bool { |
| } |
| // This is main's stamped-unit path, including its fallback on Show or |
| -// ExecStart mismatch. Binding is optional until a root census exists. |
| +// ExecStart mismatch. Binding cannot drive effects without a root census. |
| func legacyCheckedUnitRestart(sys unitSystem, optDir string, getenv func(string) string, rt registry.Route, settled func(registry.Route) error, c rootRestartChecks) error { |
| before, err := sys.Show(rt.Unit) |
| link := release.LiveLink(optDir, rt.Host) |
| - if err != nil || before.ExecPath != link || before.ActiveState != "active" { |
| + if err != nil { |
| + log.Printf("adopt: %s: cannot read %s (%v); restart not checked", rt.Host, rt.Unit, err) |
| + return sys.TryRestart(rt.Unit) |
| + } |
| + if before.ExecPath != link { |
| + log.Printf("adopt: binding: %s runs %s, but the unit stamp is on %s; restart not checked", rt.Unit, before.ExecPath, rt.Host) |
| + return sys.TryRestart(rt.Unit) |
| + } |
| + if before.ActiveState != "active" { |
| return sys.TryRestart(rt.Unit) |
| } |
| want, err := filepath.EvalSymlinks(link) |
| @@ -60,11 +69,15 @@ func legacyCheckedUnitRestart(sys unitSystem, optDir string, getenv func(string) |
| } |
| err = settleUnit(sys, rt.Unit, before.InvocationID, legacySettleOptions(optDir, getenv, rt, want, c)) |
| if errors.Is(err, errUnitStopped) { |
| + log.Printf("adopt: %s: %s was stopped during settle; not judged, not restarted", rt.Host, rt.Unit) |
| return nil |
| } |
| - if err == nil && settled != nil { |
| - if e := settled(rt); e != nil { |
| - log.Print(e) |
| + if err == nil { |
| + log.Printf("adopt: %s: %s settled on %s", rt.Host, rt.Unit, filepath.Dir(want)) |
| + if settled != nil { |
| + if e := settled(rt); e != nil { |
| + log.Print(e) |
| + } |
| } |
| } |
| return err |
| @@ -73,7 +86,7 @@ func legacyCheckedUnitRestart(sys unitSystem, optDir string, getenv func(string) |
| func legacyRollbackRestart(sys unitSystem, optDir string, getenv func(string) string, rt registry.Route, c rootRestartChecks) error { |
| before, _ := sys.Show(rt.Unit) |
| if err := sys.ResetFailed(rt.Unit); err != nil { |
| - log.Print(err) |
| + log.Printf("adopt: %s: reset-failed %s: %v (continuing)", rt.Host, rt.Unit, err) |
| } |
| if err := sys.Restart(rt.Unit); err != nil { |
| return err |
| @@ -84,9 +97,13 @@ func legacyRollbackRestart(sys unitSystem, optDir string, getenv func(string) st |
| } |
| err = settleUnit(sys, rt.Unit, before.InvocationID, legacySettleOptions(optDir, getenv, rt, want, c)) |
| if errors.Is(err, errUnitStopped) { |
| + log.Printf("adopt: %s: %s was stopped during the rollback's settle; not judged, not restarted", rt.Host, rt.Unit) |
| return nil |
| } |
| - return err |
| + if err != nil { |
| + return fmt.Errorf("rollback did not come up either: %w", err) |
| + } |
| + return nil |
| } |
| func legacySettleOptions(optDir string, getenv func(string) string, rt registry.Route, want string, c rootRestartChecks) settleOpts { |
diff --git a/internal/host/plan14_fix2_live_linux_test.go b/internal/host/plan14_fix2_live_linux_test.go| index f58a79c8c..2ab363b71 100644 |
| --- a/internal/host/plan14_fix2_live_linux_test.go |
| +++ b/internal/host/plan14_fix2_live_linux_test.go |
| @@ -20,7 +20,9 @@ func TestPlan14Fix2LiveSafety(t *testing.T) { |
| } |
| for _, scenario := range []string{"console update with empty history", "two consecutive updates", "incident recipe", "failed forward fix", "crash rollback", "successful restart crash before settle"} { |
| t.Run(scenario, func(t *testing.T) { |
| - e, rt, u, c, _ := plan14AdoptionFixture(t) |
| + e, rt, base, c, _ := plan14AdoptionFixture(t) |
| + u := &r2Units{plan14RestartUnits: base} |
| + c.legacy, c.legacyReady = legacyRootRestart, c.ready |
| if err := e.store.Delete(t.Context(), provision.RecordKey(rt.Account, rt.App, rt.Host)); err != nil { |
| t.Fatal(err) |
| } |
| @@ -113,17 +115,32 @@ func TestPlan14Fix2LiveSafety(t *testing.T) { |
| return release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, restart, release.WithRestartFailurePropagation(), release.WithRollbackRestart(rollback)) |
| } |
| err = run() |
| - if scenario == "crash rollback" { |
| - if err == nil { |
| - t.Fatal("failed update succeeded") |
| - } |
| - } else if err != nil { |
| + if err != nil { |
| t.Fatal(err) |
| } |
| want := "v2" |
| - if scenario == "crash rollback" || scenario == "incident recipe" { |
| + if scenario == "incident recipe" { |
| want = "v1" |
| } |
| + if scenario == "crash rollback" || scenario == "incident recipe" || scenario == "failed forward fix" { |
| + // Main cannot settle a guard ExecStart against the live-link |
| + // stamp. A stopped unit needs the operator's reset and start. |
| + if u.props.MainPID != 0 { |
| + t.Fatal("main path woke a stopped guard unit", u.effects) |
| + } |
| + if live, err := release.LiveVersion(u.opt, rt.Host); err != nil || live != want { |
| + t.Fatal("main pointer result", live, err) |
| + } |
| + if scenario == "crash rollback" { |
| + return |
| + } |
| + if err := u.ResetFailed(rt.Unit); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := u.Start(rt.Unit); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| if u.props.MainPID <= 0 || u.exe != release.VersionPath(u.opt, "", want, "console") { |
| t.Fatalf("not serving %s: %+v", want, u) |
| } |
diff --git a/internal/host/plan14_fix3_live_linux_test.go b/internal/host/plan14_fix3_live_linux_test.go| index 76a9049f8..c7b3861dc 100644 |
| --- a/internal/host/plan14_fix3_live_linux_test.go |
| +++ b/internal/host/plan14_fix3_live_linux_test.go |
| @@ -246,7 +246,6 @@ func TestR2UnguardedIncidentRecipe(t *testing.T) { |
| if _, err := f.source.forHost(t.Context(), f.rt.Host); err != nil { |
| t.Fatal("PID-zero binding rejected", err) |
| } |
| - f.c.legacy = nil |
| } |
| f.promote(t, "v1") |
| if err := f.pass(t); err != nil { |
| @@ -277,15 +276,15 @@ func TestR2GuardedAutoRestartFixForward(t *testing.T) { |
| for _, state := range []unitProps{{ActiveState: "activating", SubState: "auto-restart"}, {ActiveState: "activating", SubState: "start"}} { |
| t.Run(state.SubState, func(t *testing.T) { |
| f := r2Fixture(t, "guard") |
| - f.c.legacy = nil |
| f.u.props.ActiveState, f.u.props.SubState, f.u.props.MainPID = state.ActiveState, state.SubState, 0 |
| if err := f.pass(t); err != nil { |
| t.Fatal(err) |
| } |
| f.wantLive(t, "v2") |
| f.wantRunning(t, "v2") |
| - if len(f.u.effects) != 2 || f.u.effects[0] != "reset console.service" { |
| - t.Fatal(f.u.effects) |
| + // Without a census, main try-restarts the activating unit once. |
| + if len(f.u.effects) != 1 || len(f.u.tries) != 1 || f.u.tries[0] != "console.service" { |
| + t.Fatal(f.u.effects, f.u.tries) |
| } |
| }) |
| } |
diff --git a/internal/host/plan14_fix5_live_linux_test.go b/internal/host/plan14_fix5_live_linux_test.go| new file mode 100644 |
| index 000000000..0676ce7be |
| --- /dev/null |
| +++ b/internal/host/plan14_fix5_live_linux_test.go |
| @@ -0,0 +1,281 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +// Each unit has separate process state. In particular, restarting the local |
| +// binding must never look like a restart of a different box-local stamp. |
| +type fix5Units struct { |
| + units map[string]*r2Units |
| + events []string |
| + resetError, tryError bool |
| +} |
| + |
| +func (u *fix5Units) Show(name string) (unitProps, error) { return u.units[name].Show(name) } |
| +func (u *fix5Units) Exe(pid int) (string, error) { |
| + for _, unit := range u.units { |
| + if unit.props.MainPID == pid { |
| + return unit.Exe(pid) |
| + } |
| + } |
| + return "", os.ErrNotExist |
| +} |
| +func (u *fix5Units) StartTime(pid int) (string, error) { |
| + for _, unit := range u.units { |
| + if unit.props.MainPID == pid { |
| + return unit.StartTime(pid) |
| + } |
| + } |
| + return "", os.ErrNotExist |
| +} |
| +func (u *fix5Units) JournalTail(string, int) string { return "" } |
| +func (u *fix5Units) TryRestart(name string) error { |
| + u.events = append(u.events, "try-restart "+name) |
| + if u.tryError { |
| + u.tryError = false |
| + return errors.New("try-restart: bus timeout") |
| + } |
| + return u.units[name].TryRestart(name) |
| +} |
| +func (u *fix5Units) Restart(name string) error { |
| + u.events = append(u.events, "restart "+name) |
| + return u.units[name].Restart(name) |
| +} |
| +func (u *fix5Units) ResetFailed(name string) error { |
| + u.events = append(u.events, "reset-failed "+name) |
| + if u.resetError { |
| + return errors.New("reset-failed: bus timeout") |
| + } |
| + return u.units[name].ResetFailed(name) |
| +} |
| +func (u *fix5Units) Start(name string) error { |
| + u.events = append(u.events, "start "+name) |
| + return u.units[name].Start(name) |
| +} |
| + |
| +type fix5Result struct { |
| + Events []string |
| + Live, Previous string |
| + Route registry.Route |
| + Processes map[string]string |
| +} |
| + |
| +func fix5Run(t *testing.T, kind, history string, main bool) []fix5Result { |
| + t.Helper() |
| + sourceKind := kind |
| + if kind == "record" { |
| + sourceKind = "none" |
| + } |
| + if kind == "different stamp" { |
| + sourceKind = "list" |
| + } |
| + f := r2Fixture(t, sourceKind) |
| + |
| + if kind == "record" { |
| + f.rt.Account = "bab" |
| + plan14PutRecord(t, f.e.store, provision.Record{Schema: 1, Host: f.rt.Host, Account: "bab", App: f.rt.App, Kind: f.rt.Kind, Unit: f.rt.Unit, Addr: f.rt.Addr, Channel: f.rt.Channel, Health: &provision.Health{Path: "/ready"}}) |
| + } |
| + u := &fix5Units{units: map[string]*r2Units{f.rt.Unit: f.u}} |
| + target := f.u |
| + if kind == "different stamp" { |
| + copyUnit := *f.u.plan14RestartUnits |
| + copyUnit.props.Name, copyUnit.props.MainPID = "stamped.service", 700 |
| + target = &r2Units{plan14RestartUnits: ©Unit} |
| + f.rt.Unit = copyUnit.props.Name |
| + u.units[f.rt.Unit] = target |
| + } |
| + if err := f.e.root.reg.Upsert(f.rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.source.sys = u |
| + if kind != "none" && !(kind == "record" && os.Getenv("CARLOS_HOST_CLAIM") == "1") { |
| + if _, err := f.source.forHost(t.Context(), f.rt.Host); err != nil { |
| + t.Fatal("fixture must bind", err) |
| + } |
| + } |
| + incident := strings.HasPrefix(history, "incident") || history == "TestR4ForwardResetFailedError" |
| + if incident { |
| + if err := release.StageArtifact(f.u.opt, "", "v2", f.rt.App, []byte("new build")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.PointLive(f.u.opt, f.rt.Host, "", "v2", f.rt.App); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.e.root.reg.SetVersions(f.rt.Host, "v2", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + target.exe = release.VersionPath(f.u.opt, "", "v2", f.rt.App) |
| + target.props.ActiveState, target.props.SubState, target.props.MainPID = "failed", "failed", 0 |
| + f.promote(t, "v1") |
| + } |
| + switch history { |
| + case "crash", "failed-then-reset-failed before rollback": |
| + target.crashNew = true |
| + case "auto-restart": |
| + target.props.ActiveState, target.props.SubState, target.props.MainPID = "activating", "auto-restart", 0 |
| + case "inactive": |
| + target.props.ActiveState, target.props.SubState, target.props.MainPID = "inactive", "dead", 0 |
| + case "incident reset before": |
| + if err := u.ResetFailed(f.rt.Unit); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "TestR4ForwardResetFailedError": |
| + u.resetError = true |
| + case "TestR4ForwardTryRestartError": |
| + u.tryError = true |
| + } |
| + getenv := func(string) string { return "5s" } |
| + observer := f.e.root.obs |
| + observer.resolve, observer.sys = f.c.resolve, u |
| + observer.procExe = func(pid int) string { exe, _ := u.Exe(pid); return exe } |
| + pr := f.e.root |
| + pr.obs = observer |
| + restart := rootRestartRouteWith(f.e.root.reg, u, f.u.opt, getenv, observer.recordSettled(f.e.bake), f.c) |
| + rollback := rollbackUnitRestart(f.e.root.reg, u, f.u.opt, getenv, f.c) |
| + startChecks := f.c |
| + startChecks.legacyStart = u.Start |
| + start := rollbackUnitRestart(f.e.root.reg, u, f.u.opt, getenv, startChecks) |
| + if main { |
| + restart = makeRestartRoute(f.e.root.reg, func(host string) { _ = u.TryRestart(activator.UnitName(host)) }, fix5MainCheckedRestart(u, f.u.opt, getenv, nil, f.c), nil) |
| + rollback = fix5MainRollbackRestart(f.e.root.reg, u, f.u.opt, getenv, f.c) |
| + start = makeStartRoute(f.e.root.reg, u.Start) |
| + } else { |
| + // Binding may observe health and retain a running build, but cannot enter |
| + // the strict effect machinery or touch its durable restart journal. |
| + f.c.attempts = func() (rootstate.Store, error) { |
| + t.Fatal("no-census pass opened restart journal") |
| + return nil, os.ErrPermission |
| + } |
| + f.c.dispatch = func(context.Context, registry.Route) (bool, error) { |
| + t.Fatal("no-census pass dispatched binding") |
| + return false, os.ErrPermission |
| + } |
| + restart = rootRestartRouteWith(f.e.root.reg, u, f.u.opt, getenv, observer.recordSettled(f.e.bake), f.c) |
| + rollback = rollbackUnitRestart(f.e.root.reg, u, f.u.opt, getenv, f.c) |
| + startChecks = f.c |
| + startChecks.legacyStart = u.Start |
| + start = rollbackUnitRestart(f.e.root.reg, u, f.u.opt, getenv, startChecks) |
| + } |
| + originalRollback := rollback |
| + rollback = func(host string) error { |
| + if history == "failed-then-reset-failed before rollback" { |
| + if err := u.ResetFailed(f.rt.Unit); err != nil { |
| + return err |
| + } |
| + } |
| + return originalRollback(host) |
| + } |
| + snapshot := func() fix5Result { |
| + t.Helper() |
| + live, err := release.LiveVersion(f.u.opt, f.rt.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + previous, err := filepath.EvalSymlinks(filepath.Join(f.u.opt, "previous", f.rt.Host)) |
| + if err != nil && !os.IsNotExist(err) { |
| + t.Fatal(err) |
| + } |
| + rt, err := f.e.root.reg.Get(f.rt.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rt.CreatedAt = 0 |
| + processes := map[string]string{} |
| + for name, unit := range u.units { |
| + processes[name] = strings.TrimPrefix(unit.exe, f.u.opt) + "|" + unit.props.ActiveState + "|" + unit.props.SubState + "|" + unit.props.InvocationID |
| + } |
| + return fix5Result{append([]string(nil), u.events...), live, strings.TrimPrefix(previous, f.u.opt), *rt, processes} |
| + } |
| + pass := func() { |
| + t.Helper() |
| + run := func() error { |
| + opts := []release.PassOption{release.WithStartUnit(start), release.WithRollbackRestart(rollback)} |
| + if !main { |
| + opts = append(opts, release.WithRunningVersion(f.c.runningVersion), release.WithRestartAttemptSettled(f.c.finish)) |
| + } |
| + return release.Pass(t.Context(), f.e.root.reg, f.e.store, f.pub, f.u.opt, restart, opts...) |
| + } |
| + var err error |
| + if main { |
| + err = run() |
| + } else { |
| + err = pr.around(t.Context(), run) |
| + } |
| + if err != nil { |
| + t.Fatal("pass status differs from main", err) |
| + } |
| + } |
| + pass() |
| + results := []fix5Result{snapshot()} |
| + if incident { |
| + u.resetError = false |
| + if history != "incident reset before" { |
| + if err := u.ResetFailed(f.rt.Unit); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := u.Start(f.rt.Unit); err != nil { |
| + t.Fatal(err) |
| + } |
| + results = append(results, snapshot()) |
| + } |
| + // A second pass detects a synthetic refusal that would reject the fix. |
| + pass() |
| + return append(results, snapshot()) |
| +} |
| + |
| +func TestPlan14Fix5MainEquivalence(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, flag := range []string{"", "1"} { |
| + t.Run(map[string]string{"": "claims-off", "1": "claims-on"}[flag], func(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", flag) |
| + for _, kind := range []string{"none", "record", "list", "guard", "different stamp"} { |
| + for _, history := range []string{"healthy", "crash", "failed-then-reset-failed before rollback", "auto-restart", "inactive", "incident reset before", "incident reset after", "TestR4ForwardResetFailedError", "TestR4ForwardTryRestartError"} { |
| + t.Run(kind+"/"+history, func(t *testing.T) { |
| + want := fix5Run(t, kind, history, true) |
| + got := fix5Run(t, kind, history, false) |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("no-census effects/pointers differ from main:\ngot %+v\nwant %+v", got, want) |
| + } |
| + }) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14Fix5NoCensusSkipsJournalRecovery(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, flag := range []string{"", "1"} { |
| + t.Setenv("CARLOS_HOST_CLAIM", flag) |
| + f := r2Fixture(t, "list") |
| + f.c.attempts = func() (rootstate.Store, error) { |
| + t.Fatal("read retained journal without a census") |
| + return nil, os.ErrPermission |
| + } |
| + if err := recoverRootRestarts(t.Context(), f.u, f.u.opt, nil, f.c, hostwrite.Config{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(f.u.effects) != 0 { |
| + t.Fatal(f.u.effects) |
| + } |
| + } |
| +} |
diff --git a/internal/host/plan14_fix5_main_linux_test.go b/internal/host/plan14_fix5_main_linux_test.go| new file mode 100644 |
| index 000000000..3ffea14fe |
| --- /dev/null |
| +++ b/internal/host/plan14_fix5_main_linux_test.go |
| @@ -0,0 +1,97 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "log" |
| + "path/filepath" |
| + "time" |
| +) |
| + |
| +// Frozen from origin/main 34a4328f, internal/host/agent.go. Only names, |
| +// clock and readiness injection differ, so the oracle cannot drift with |
| +// the production legacy helpers. |
| +func fix5MainCheckedRestart(sys unitSystem, optDir string, getenv func(string) string, settled func(rt registry.Route), c rootRestartChecks) func(rt registry.Route) error { |
| + return func(rt registry.Route) error { |
| + before, err := sys.Show(rt.Unit) |
| + if err != nil { |
| + log.Printf("adopt: %s: cannot read %s (%v); restart not checked", rt.Host, rt.Unit, err) |
| + return sys.TryRestart(rt.Unit) |
| + } |
| + link := filepath.Join(optDir, "live", rt.Host) |
| + if before.ExecPath != link { |
| + log.Printf("adopt: binding: %s runs %s, but the unit stamp is on %s; restart not checked", rt.Unit, before.ExecPath, rt.Host) |
| + return sys.TryRestart(rt.Unit) |
| + } |
| + if before.ActiveState != "active" { |
| + return sys.TryRestart(rt.Unit) // stopped by an operator: stays a no-op |
| + } |
| + want, err := filepath.EvalSymlinks(link) |
| + if err != nil { |
| + return sys.TryRestart(rt.Unit) |
| + } |
| + if err := sys.TryRestart(rt.Unit); err != nil { |
| + return err |
| + } |
| + timeout := unitSettleTimeout(getenv) |
| + err = settleUnit(sys, rt.Unit, before.InvocationID, settleOpts{ |
| + Timeout: timeout, Poll: 500 * time.Millisecond, Hold: 5 * time.Second, |
| + WantDir: filepath.Dir(want), Ready: c.legacyReady(rt), |
| + Now: c.now, Sleep: c.sleep}) |
| + if errors.Is(err, errUnitStopped) { |
| + // An operator stopped it between the read above and now. Not a |
| + // verdict on the build, and a rollback restart would undo their |
| + // stop: same as a unit that was stopped before the restart. |
| + log.Printf("adopt: %s: %s was stopped during settle; not judged, not restarted", rt.Host, rt.Unit) |
| + return nil |
| + } |
| + if err == nil { |
| + log.Printf("adopt: %s: %s settled on %s", rt.Host, rt.Unit, filepath.Dir(want)) |
| + // D3 (proofobserve.go): a build that passed the settle check is a |
| + // build this box has run well. |
| + if settled != nil { |
| + settled(rt) |
| + } |
| + } |
| + return err |
| + } |
| +} |
| + |
| +// rollbackUnitRestart is D1's rollback restart (release.WithRollbackRestart): |
| +// the live link already names the restored build; replace whatever is |
| +// running (a deadline failure can leave the rejected build up) and check |
| +// the restored build comes up too. |
| +func fix5MainRollbackRestart(reg *registry.Registry, sys unitSystem, optDir string, getenv func(string) string, c rootRestartChecks) func(host string) error { |
| + return func(host string) error { |
| + rt, err := reg.Get(host) |
| + if err != nil || rt == nil || rt.Unit == "" { |
| + return nil |
| + } |
| + before, _ := sys.Show(rt.Unit) |
| + if err := sys.ResetFailed(rt.Unit); err != nil { |
| + log.Printf("adopt: %s: reset-failed %s: %v (continuing)", host, rt.Unit, err) |
| + } |
| + if err := sys.Restart(rt.Unit); err != nil { |
| + return err |
| + } |
| + want, err := filepath.EvalSymlinks(filepath.Join(optDir, "live", host)) |
| + if err != nil { |
| + return err |
| + } |
| + err = settleUnit(sys, rt.Unit, before.InvocationID, settleOpts{ |
| + Timeout: unitSettleTimeout(getenv), Poll: 500 * time.Millisecond, Hold: 5 * time.Second, |
| + WantDir: filepath.Dir(want), Ready: c.legacyReady(*rt), |
| + Now: c.now, Sleep: c.sleep}) |
| + if errors.Is(err, errUnitStopped) { |
| + // The restored build is what the link names; a stop an operator |
| + // made while it settled is theirs to keep, not a failed recovery. |
| + log.Printf("adopt: %s: %s was stopped during the rollback's settle; not judged, not restarted", host, rt.Unit) |
| + return nil |
| + } |
| + if err != nil { |
| + return fmt.Errorf("rollback did not come up either: %w", err) |
| + } |
| + return nil |
| + } |
| +} |
diff --git a/internal/host/releaseinit_linux_test.go b/internal/host/releaseinit_linux_test.go| index f9c94da44..0ae5ffdca 100644 |
| --- a/internal/host/releaseinit_linux_test.go |
| +++ b/internal/host/releaseinit_linux_test.go |
| @@ -74,7 +74,10 @@ func TestPlan14ProductionReleaseInitialization(t *testing.T) { |
| if got := release.ReadHealthy(release.RootHealthyDir, "", "console"); len(got) != 1 || got[0].SHA256 != b.SHA256 { |
| t.Fatal(got) |
| } |
| - for path, mode := range map[string]os.FileMode{release.RootHealthyDir: 0755, filepath.Join(release.RootHealthyDir, "healthy"): 0755, filepath.Join(release.RootHealthyDir, "private"): 0700, rootRestartState: 0700} { |
| + if _, err := os.Stat(rootRestartState); !os.IsNotExist(err) { |
| + t.Fatal("no-census initialization created restart state", err) |
| + } |
| + for path, mode := range map[string]os.FileMode{release.RootHealthyDir: 0755, filepath.Join(release.RootHealthyDir, "healthy"): 0755} { |
| info, err := os.Stat(path) |
| if err != nil || info.Mode().Perm() != mode { |
| t.Fatalf("%s mode: %v %v", path, info, err) |
diff --git a/internal/host/rootrestart.go b/internal/host/rootrestart.go| index 1f5ff06c6..eb87e6355 100644 |
| --- a/internal/host/rootrestart.go |
| +++ b/internal/host/rootrestart.go |
| @@ -26,8 +26,6 @@ type rootRestartChecks struct { |
| legacyLog *sync.Once |
| legacyReady func(registry.Route) func() error |
| legacyStart func(string) error |
| - legacyRoute *registry.Route |
| - effectStarted *bool |
| targetDigest string |
| targetFile func(string) (*os.File, error) |
| dispatch func(context.Context, registry.Route) (bool, error) |
| @@ -117,6 +115,16 @@ func (c rootRestartChecks) runningVersion(ctx context.Context, rt registry.Route |
| return "", nil |
| } |
| b, err := c.resolve(ctx, rt) |
| + if err == nil && b.Route.Unit != rt.Unit { |
| + // Without a census, retain only the unit main will restart. |
| + legacy, checkErr := c.allowLegacy() |
| + if checkErr != nil { |
| + return "", checkErr |
| + } |
| + if legacy { |
| + return "", nil |
| + } |
| + } |
| if err != nil { |
| legacy, checkErr := c.allowLegacy() |
| if checkErr != nil { |
| @@ -145,7 +153,8 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin |
| return err |
| } |
| if legacy { |
| - c.legacyRoute = &candidate |
| + c.noteLegacy() |
| + return legacyCheckedUnitRestart(sys, optDir, getenv, candidate, settled, c) |
| } |
| if c.attempts == nil { |
| return os.ErrPermission |
| @@ -241,12 +250,7 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin |
| } |
| err = restartBoundUnit(sys, optDir, getenv, binding, false, settled, c) |
| if err == nil { |
| - settleErr := settleRollbackAttempt(s, attempt) |
| - if legacy && settleErr != nil { |
| - log.Print(settleErr) |
| - return nil |
| - } |
| - return settleErr |
| + return settleRollbackAttempt(s, attempt) |
| } |
| if errors.Is(err, errUnitStopped) { |
| return errors.Join(err, settleRollbackAttempt(s, attempt)) |
| @@ -291,9 +295,6 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, |
| effect = func(fn func() error) error { return fn() } |
| } |
| if c.verifyOnly { |
| - if c.effectStarted != nil { |
| - *c.effectStarted = true |
| - } |
| before.InvocationID = "" |
| } else if err := effect(func() error { |
| if rollback { |
| @@ -302,13 +303,7 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, |
| return errRestartBinding |
| } |
| if err := sys.ResetFailed(rt.Unit); err != nil { |
| - if c.legacyRoute == nil { |
| - return err |
| - } |
| - log.Print(err) |
| - } |
| - if c.effectStarted != nil { |
| - *c.effectStarted = true |
| + return err |
| } |
| if err := sys.Restart(rt.Unit); err != nil { |
| return err |
| @@ -327,9 +322,6 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, |
| if err != nil || current.Source != binding.Source || current.Version != binding.Version || current.Digest != binding.Digest || current.PID != binding.PID || current.Invocation != binding.Invocation { |
| return errRestartBinding |
| } |
| - if c.effectStarted != nil { |
| - *c.effectStarted = true |
| - } |
| if pointerBoundUnit(before) { |
| if err := sys.ResetFailed(rt.Unit); err != nil { |
| return err |
| @@ -344,17 +336,6 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, |
| }); err != nil { |
| return err |
| } |
| - // Without a census, optional binding checks cannot change main's verdict |
| - // after the effect. Settle this invocation once, keeping D1's crash result. |
| - if c.legacyRoute != nil { |
| - err := settleUnit(sys, rt.Unit, before.InvocationID, legacySettleOptions(optDir, getenv, *c.legacyRoute, want, c)) |
| - if err == nil && settled != nil { |
| - if e := effect(func() error { return settled(binding.Route) }); e != nil { |
| - log.Print(e) |
| - } |
| - } |
| - return err |
| - } |
| var observed proofBinding |
| var bindingErr error |
| checkedReady := func() error { |
| @@ -425,29 +406,19 @@ func rollbackUnitRestart(reg *registry.Registry, sys unitSystem, optDir string, |
| } |
| return legacyRollbackRestart(sys, optDir, getenv, *rt, c) |
| } |
| - effectStarted := false |
| - c.effectStarted = &effectStarted |
| legacy, err := c.allowLegacy() |
| if err != nil { |
| return err |
| } |
| if legacy { |
| + c.noteLegacy() |
| if routeErr != nil || rt == nil { |
| return nil |
| } |
| - c.legacyRoute = rt |
| - defer func() { |
| - if errors.Is(result, errUnitStopped) { |
| - result = nil |
| - } else if result != nil && !effectStarted && !restartEffectFailed(result) { |
| - c.noteLegacy() |
| - if c.legacyStart != nil { |
| - result = c.legacyStart(rt.Unit) |
| - } else { |
| - result = legacyRollbackRestart(sys, optDir, getenv, *rt, c) |
| - } |
| - } |
| - }() |
| + if c.legacyStart != nil { |
| + return c.legacyStart(rt.Unit) |
| + } |
| + return legacyRollbackRestart(sys, optDir, getenv, *rt, c) |
| } |
| if c.attempts == nil || c.rollback == nil { |
| return os.ErrPermission |
| @@ -465,14 +436,7 @@ func rollbackUnitRestart(reg *registry.Registry, sys unitSystem, optDir string, |
| attempt, binding, err := loadRollback(s, host, c.now()) |
| if attempt.ID != "" { |
| defer func() { |
| - settleErr := settleRollbackAttempt(s, attempt) |
| - if legacy && effectStarted { |
| - if settleErr != nil { |
| - log.Print(settleErr) |
| - } |
| - return |
| - } |
| - result = errors.Join(result, settleErr) |
| + result = errors.Join(result, settleRollbackAttempt(s, attempt)) |
| }() |
| } |
| if err != nil { |
| @@ -558,20 +522,16 @@ func rootRestartRouteWith(reg *registry.Registry, sys unitSystem, optDir string, |
| if err != nil { |
| return err |
| } |
| + if legacy { |
| + checks.noteLegacy() |
| + return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks) |
| + } |
| if checks.dispatch != nil { |
| need, err := checks.dispatch(context.Background(), *rt) |
| if err != nil { |
| - if legacy { |
| - checks.noteLegacy() |
| - return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks) |
| - } |
| return err |
| } |
| if !need { |
| - if legacy { |
| - checks.noteLegacy() |
| - return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks) |
| - } |
| return restartVerifiedOrdinaryUnit(sys, optDir, host, checks.targetFile) |
| } |
| } |
| @@ -579,10 +539,6 @@ func rootRestartRouteWith(reg *registry.Registry, sys unitSystem, optDir string, |
| if errors.Is(err, errUnitStopped) { |
| return nil |
| } |
| - if legacy && err != nil && !restartEffectFailed(err) { |
| - checks.noteLegacy() |
| - return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks) |
| - } |
| return err |
| } |
| } |
diff --git a/internal/host/rootrollback_resume.go b/internal/host/rootrollback_resume.go| index 41a61bcb2..920b37fdf 100644 |
| --- a/internal/host/rootrollback_resume.go |
| +++ b/internal/host/rootrollback_resume.go |
| @@ -14,6 +14,16 @@ import ( |
| ) |
| func recoverRootRestarts(ctx context.Context, sys unitSystem, optDir string, getenv func(string) string, checks rootRestartChecks, writes hostwrite.Config) error { |
| + legacy, err := checks.allowLegacy() |
| + if err != nil { |
| + return err |
| + } |
| + if legacy { |
| + checks.noteLegacy() |
| + // D3 still records root health without a census. Preparing its |
| + // public parent must not create or recover bound restart state. |
| + return rootstate.EnsurePublicDir(release.RootHealthyDir) |
| + } |
| if checks.attempts == nil || checks.prepareRollback == nil { |
| return os.ErrPermission |
| } |
diff --git a/internal/provision/plan14_legacy_identity_test.go b/internal/provision/plan14_legacy_identity_test.go| index d0d7caa36..146f54083 100644 |
| --- a/internal/provision/plan14_legacy_identity_test.go |
| +++ b/internal/provision/plan14_legacy_identity_test.go |
| @@ -171,3 +171,23 @@ func TestPlan14Fix4OverlayPreservesLegacySeal(t *testing.T) { |
| t.Fatal("unexpected legacy seal", seal) |
| } |
| } |
| + |
| +func TestPlan14Fix5OverlayPreservesLegacySeal(t *testing.T) { |
| + root := censusRoot(t) |
| + inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}} |
| + for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv", "plan14-root-reads-merge3.tsv", "plan14-root-reads-fix4.tsv"} { |
| + if err := loadRootReadInventory(root+"/internal/provision/testdata/"+name, &inv, i > 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + seal := rootReadLegacySeal(inv) |
| + if err := loadRootReadInventory(root+"/internal/provision/testdata/plan14-root-reads-fix5.tsv", &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + if seal != "65dba959f80cc47e9fd99be145ba8b24bb3b37ffaf77b47d1b9cc3e01da14b47" { |
| + t.Fatal("unexpected legacy seal", seal) |
| + } |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 42b03aa9d..43d6dc9a1 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -634,6 +634,9 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-fix4.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-fix5.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/testdata/plan14-root-reads-fix5.tsv b/internal/provision/testdata/plan14-root-reads-fix5.tsv| new file mode 100644 |
| index 000000000..b1399475e |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-fix5.tsv |
| @@ -0,0 +1,11 @@ |
| +# Plan 14 fix5: merge ruling 6 makes main effects unconditional without a census; earlier overlays and the legacy freeze are unchanged. |
| +internal/host/legacyrestart.go legacyCheckedUnitRestart authority B3 rev3=merge-ruling-6;basis=Restore main stamped-unit restart and D1 settle, including diagnostics; only clock, readiness and health-hook adapters differ. file:EvalSymlinks@675,registry:Host@1044,registry:Host@1139,registry:Host@241,registry:Host@335,registry:Host@543,registry:Unit@1053,registry:Unit@1148,registry:Unit@198,registry:Unit@344,registry:Unit@382,registry:Unit@517,registry:Unit@576,registry:Unit@649,registry:Unit@744,registry:Unit@782,registry:Unit@844,whole:Route@1218,whole:Route@910 999d8be82a85a24691baaa2d4ffadd0fc6ba073bcc1eee57c40f2167708c370e |
| +internal/host/legacyrestart.go legacyRollbackRestart authority B3 rev3=merge-ruling-6;basis=Restore main reset-failed, restart and D1 settle even after an operator reset; retain main error wrapping and diagnostics. file:EvalSymlinks@378,registry:Host@276,registry:Host@425,registry:Host@706,registry:Unit@157,registry:Unit@193,registry:Unit@285,registry:Unit@325,registry:Unit@491,registry:Unit@715,whole:Route@557 f55b81780b4da5fb5f08fd18079919514edc606bd18e2189e69cf78f1aea8202 |
| +internal/host/legacyrestart.go legacyRootRestart authority B3,G rev3=merge-ruling-6;basis=Census presence alone selects bound effects; the claims flag without a census keeps main effects. 5fd48683abd82664baa77c2ddc7528f92f557b04586225d64b4a6e77147df931 |
| +internal/host/rootrestart.go checkedUnitRestart authority B3 rev3=merge-ruling-6;basis=Return main effects before opening any bound restart state when no census exists; strict journal and identity checks remain census-only. file:EvalSymlinks@1247,file:EvalSymlinks@2024,registry:Account@1593,registry:Account@2392,registry:App@1625,registry:App@2424,registry:Host@1294,registry:Host@2080,registry:Host@2967,registry:Host@3234,registry:Host@3906,registry:Host@703,registry:Unit@3788,registry:Unit@904,whole:Route@1601,whole:Route@1633,whole:Route@2400,whole:Route@2432,whole:Route@3796,whole:Route@479,whole:Route@831,whole:Route@912 1eadec9e355427edc4f8211c2106e855b7d95a0aeae5b71f7961cc19fee0a33e |
| +internal/host/rootrestart.go restartBoundUnit authority B3 rev3=merge-ruling-6;basis=Remove dead no-census settle and effect-tracking exceptions; retain the census-backed strict effect path. file:EvalSymlinks@635,registry:Account@2355,registry:Account@3854,registry:App@2391,registry:App@3894,registry:Host@1618,registry:Host@2257,registry:Host@2851,registry:Host@3593,registry:Host@682,registry:Unit@1104,registry:Unit@1172,registry:Unit@1927,registry:Unit@1995,registry:Unit@2037,registry:Unit@3200,registry:Unit@395,whole:Route@198,whole:Route@2361,whole:Route@2397,whole:Route@317,whole:Route@3862,whole:Route@3902,whole:Route@4113 75130e52351fe6272887aafece15446eeef0760c2e02f9a51cc1dd4e2a6a1455 |
| +internal/host/rootrestart.go rollbackUnitRestart authority B3 rev3=merge-ruling-6;basis=Select main rollback or start before bound journal discovery on no-census boxes; preserve ordinary and Hibernate guards. file:Read@1568,registry:Hibernate@393,registry:Host@1860,registry:Unit@1962,registry:Unit@2263,registry:Unit@351,registry:Unit@446,registry:Unit@538,registry:Unit@829,whole:Route@1868,whole:Route@1970,whole:Route@2271,whole:Route@337,whole:Route@433,whole:Route@606,whole:Route@743,whole:Route@897 569a52f96e0206a496b7718a5103fdb8d6e379a81f9c143e2df5c15ca024fb6b |
| +internal/host/rootrestart.go rootRestartChecks.runningVersion authority B3 rev3=merge-ruling-6;basis=Without a census, ignore another unit local binding for retention so the stamp determines the rollback target; keep resolved-path retention. registry:Host@151,registry:Unit@104,registry:Unit@121,registry:Unit@230,registry:Unit@246,whole:Route@208,whole:Route@232 9618c96657140602f59ade8b967f7fe4729d84d915c7fa33e0a276dd82377a40 |
| +internal/host/rootrestart.go rootRestartRouteWith authority B3 rev3=merge-ruling-6;basis=Select main stamped-unit effects before binding dispatch without a census; ordinary routes still bypass census and record reads. registry:Unit@354,registry:Unit@420,whole:Route@341,whole:Route@515,whole:Route@708,whole:Route@818,whole:Route@978 fad63ffb31320e0154e3a6c76ed0b887812bfefae607d698cd2e6c9386787546 |
| +internal/host/rootrollback_resume.go recoverRootRestarts authority B3 rev3=merge-ruling-6;basis=Prepare the shared D3 health parent but skip bound journal recovery without a census, including with the claims flag set; retained-census recovery stays strict. file:Read@819 5b33733153e36a48058ee3245f0710debe8b9ccffc0bb9c40c0b2d5b8de3599d |
| +@coverage internal/host a2fe6f145509946acb6953012b232f894ece8d79063f254193b13b28a2c97214 |