R-1253 (P2): an export that stops its app refuses while another operation (a restore) holds the app-stop marker — its Undo is never replaced. Commit only.
gates / gates (push) Successful in 1m28s

This commit is contained in:
2026-10-24 20:29:59 +02:00
parent 518a13592d
commit c9b4b82dc7
9 changed files with 178 additions and 1 deletions
@@ -138,6 +138,11 @@ type AppStopGuard struct {
holds AppStopHolds
undoReachable func(stack, dir string) bool
// liveOp (R-1253) is the operation of THIS process that holds the marker now (set by Begin, cleared by End). The
// marker has one slot; BeginExclusive refuses to replace a live one. A marker left by a crash has no live op.
liveMu sync.Mutex
liveOp string
// suppressed (R-330) is the in-memory "the alarm must not fire for these, we are holding them"
// set. It is deliberately NOT persisted: after a restart the guard no longer holds anything —
// Recover() either brings the apps back or leaves them genuinely down, and a down app must alarm.
@@ -315,6 +320,9 @@ func (g *AppStopGuard) Begin(opID string, reason AppStopReason, stackNames []str
}); err != nil {
return err
}
g.liveMu.Lock()
g.liveOp = opID
g.liveMu.Unlock()
// R-330: the marker is on disk, so the caller is now cleared to stop these apps — which is
// exactly the moment the app-down alarm must stop counting them. Marked AFTER the write, so a
// Begin that failed (and therefore stopped nothing) suppresses nothing either.
@@ -335,6 +343,9 @@ func (g *AppStopGuard) End() {
// still owes its suppressed stacks a release — otherwise an unwired-path guard would hold them
// until appStopMaxHold.
g.releaseStarted()
g.liveMu.Lock()
g.liveOp = ""
g.liveMu.Unlock()
if g.path == "" {
return
}
@@ -343,6 +354,35 @@ func (g *AppStopGuard) End() {
}
}
// ErrAppStopBusy (R-1253): another operation of this process holds the app-stop marker; replacing it would lose that
// operation's crash record (a restore's Undo among it).
var ErrAppStopBusy = errors.New("another operation holds the app-stop marker")
// BusyWith names the operation of THIS process that holds the marker now ("" = none). A marker a crash left is not
// busy: nothing in this process will End it, and the next operation may replace it (Recover already ran).
func (g *AppStopGuard) BusyWith() string {
if g == nil {
return ""
}
g.liveMu.Lock()
defer g.liveMu.Unlock()
return g.liveOp
}
// BeginExclusive is Begin that refuses while another operation of this process holds the marker (R-1253: an export
// with „stop the app first" during another app's restore replaced the restore's record and its Undo). Used by the
// exporter; the backup manager's own operations are already serialised by its running flag.
func (g *AppStopGuard) BeginExclusive(opID string, reason AppStopReason, stackNames []string) error {
if g == nil {
return nil
}
if busy := g.BusyWith(); busy != "" && busy != opID {
g.logger.Printf("[WARN] [appstop] %s refused: %s holds the app-stop marker (R-1253)", opID, busy)
return fmt.Errorf("%w: %s", ErrAppStopBusy, busy)
}
return g.Begin(opID, reason, stackNames)
}
// Recover restarts any apps left stopped by an operation that died before restarting them, then
// clears the marker. Call ONCE at startup, and — critically — call it to COMPLETION before the boot
// reconciler is launched, so an app this marker explains is not also reported as an unexplained boot