R-1251 (P1): a pending put-back blocks every start — a cut restore's copy on an unbound drive waits (a restore hold), is poured back when the drive binds before any start; a failed put-back holds the app (H2); Stop during a restore still puts back (H7). Commit only, unreleased.
gates / gates (push) Successful in 1m18s

This commit is contained in:
2026-10-24 19:07:02 +02:00
parent 2449223f10
commit bb2809dc68
16 changed files with 847 additions and 24 deletions
+20 -2
View File
@@ -222,7 +222,7 @@ func (s *Server) restartStacks(names []string) {
// Slice 4 / R-379: the drive-return gate starts apps UNATTENDED, so it must honour a hold
// exactly as the customer's button does. Until v0.237.0 it did not — a held app whose drive
// blinked would have been started again. "A hold that only one path honours is not a hold."
if s.appHeld(name) {
if !s.resumePutBack(name) || s.appHeld(name) {
continue
}
if err := s.stackMgr.StartStack(name); err != nil {
@@ -346,6 +346,13 @@ func (s *Server) ReconcileDriveGates() {
}
}
s.restartStacks(stopped)
// R-1251: a put-back that waited for a drive runs when one returns (the app was held, not gate-stopped,
// so it is not in `stopped`); the guard starts it afterwards through the gated starter.
if s.backupMgr != nil {
if started := s.backupMgr.AppStopGuard().ResumeWaitingPutBacks(); len(started) > 0 {
s.logger.Printf("[WARN] [gate] drive RETURNED %s — %d interrupted restore(s) put back, then started: %v", a.Path, len(started), started)
}
}
if err := s.settings.ClearDisconnected(a.Path); err != nil {
s.logger.Printf("[WARN] [gate] clear disconnected %s: %v", a.Path, err)
}
@@ -469,7 +476,9 @@ func (s *Server) processGuestBootChange() {
}
recreate := func(bs bootStack) {
s.logger.Printf("[INFO] [gate] boot %s: live bind confirmed — recreating drive-backed app %s (state=%s) onto %s", resp.GuestBootID, bs.name, bs.state, bs.hdd)
if s.appHeld(bs.name) {
// R-1251: a cut restore whose copy waited for THIS drive is poured back first — the recreate used to start
// the app on the cut restore's data, and a later start poured the old copy over what the household wrote.
if !s.resumePutBack(bs.name) || s.appHeld(bs.name) {
return
}
_ = s.stackMgr.StopStack(bs.name)
@@ -713,6 +722,15 @@ func (s *Server) notifyDriveReturned(path string, isTarget map[string]bool) {
s.notifier.NotifyStorageReconnected(label)
}
// resumePutBack (R-1251) pours back a cut restore's copy that waited for this app's drive, BEFORE any start. false =
// the app stays held (the copy is still unreachable, or the put-back failed and the app is held for support). Nil-safe.
func (s *Server) resumePutBack(name string) bool {
if s.backupMgr == nil {
return true
}
return s.backupMgr.AppStopGuard().ResumePutBack(name)
}
// appHeld reports whether an app carries a hold (failed update or failed restore) and logs the skip.
// Nil-safe: no backup manager means no hold store, so nothing is held.
func (s *Server) appHeld(name string) bool {
@@ -0,0 +1,62 @@
package web
import (
"go/ast"
"go/parser"
"go/token"
"os"
"strings"
"testing"
)
// R-1251 (review round 2, HUNT-1 H1): the two web paths that START a drive-backed app when its drive is there — the
// boot recreate and the drive-return restart — must pour a waiting put-back back FIRST (resumePutBack), before
// appHeld and before StartStack; and the drive-return branch must run the sweep for held apps it did not stop. A
// source pin: the decision is tested in internal/backup (r1251_putback_waits_test.go); this pins the WIRING.
// COMPANION RED-PROOF (2026-10-24): drop `!s.resumePutBack(...) ||` from either path, or the sweep from the Return
// branch → this fails.
func TestR1251_DriveStartPathsPutBackBeforeTheyStart(t *testing.T) {
src, err := os.ReadFile("intermediary.go")
if err != nil {
t.Fatal(err)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "intermediary.go", src, 0)
if err != nil {
t.Fatal(err)
}
body := map[string]string{}
for _, d := range f.Decls {
if fn, ok := d.(*ast.FuncDecl); ok && fn.Body != nil {
body[fn.Name.Name] = string(src[fset.Position(fn.Body.Pos()).Offset:fset.Position(fn.Body.End()).Offset])
}
}
order := func(fn, first, then string) {
t.Helper()
b, ok := body[fn]
if !ok {
t.Fatalf("function %s not found in intermediary.go — the pin is stale", fn)
}
i, j := strings.Index(b, first), strings.Index(b, then)
if i < 0 || j < 0 || i > j {
t.Errorf("%s: %q must come before %q (found at %d, %d) — the app would start on the cut restore's data", fn, first, then, i, j)
}
}
order("restartStacks", "!s.resumePutBack(name)", "s.stackMgr.StartStack(name)")
order("restartStacks", "!s.resumePutBack(name)", "s.appHeld(name)")
// the boot recreate is a closure inside the boot-id handler; find the function that holds it
var recreateFn string
for name, b := range body {
if strings.Contains(b, "live bind confirmed — recreating drive-backed app") {
recreateFn = name
}
}
if recreateFn == "" {
t.Fatal("the boot recreate closure was not found — the pin is stale")
}
order(recreateFn, "!s.resumePutBack(bs.name)", "s.stackMgr.StartStack(bs.name)")
order("ReconcileDriveGates", "s.restartStacks(stopped)", "ResumeWaitingPutBacks()")
if !strings.Contains(body["resumePutBack"], "ResumePutBack(name)") {
t.Error("resumePutBack no longer asks the app-stop guard")
}
}