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
gates / gates (push) Successful in 1m28s
This commit is contained in:
@@ -120,6 +120,9 @@ type appStopGuard interface {
|
||||
// same reason Begin is: the guard owns the "we are holding this app" fact, and a second owner
|
||||
// would drift from it.
|
||||
ReleaseFailed(stacks ...string)
|
||||
// BusyWith (R-1253) names another operation that holds the marker now ("" = none): an export that stops its app
|
||||
// refuses then, because the marker has one slot and a restore's crash record (its Undo) would be replaced.
|
||||
BusyWith() string
|
||||
}
|
||||
|
||||
// NewExporter creates a new export/import engine.
|
||||
@@ -183,6 +186,13 @@ func (e *Exporter) StartExport(req ExportRequest) error {
|
||||
e.debugf("StartExport rejected: stack %q is not deployed", req.StackName)
|
||||
return fmt.Errorf("stack %q is not deployed", req.StackName)
|
||||
}
|
||||
if req.StopApp && e.stopGuard != nil {
|
||||
if busy := e.stopGuard.BusyWith(); busy != "" {
|
||||
e.mu.Unlock()
|
||||
e.logger.Printf("[WARN] Export of %s refused: %s holds the app-stop marker — an export that stops the app would replace its crash record (R-1253)", req.StackName, busy)
|
||||
return util.MsgError("err.appexport.another_operation_running")
|
||||
}
|
||||
}
|
||||
|
||||
e.debugf("StartExport: stack=%q dest=%q password=%v stopApp=%v",
|
||||
req.StackName, req.DestDrive, req.Password != "", req.StopApp)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package appexport
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// busyGuard is the app-stop seam with another operation holding the marker.
|
||||
type busyGuard struct{ begins []string }
|
||||
|
||||
func (g *busyGuard) Begin(opID string, _ []string) error {
|
||||
g.begins = append(g.begins, opID)
|
||||
return nil
|
||||
}
|
||||
func (g *busyGuard) End() {}
|
||||
func (g *busyGuard) ReleaseFailed(...string) {}
|
||||
func (g *busyGuard) BusyWith() string { return "unit-restore:bookstack" }
|
||||
|
||||
// R-1253: an export that stops its app is refused BEFORE anything starts while another operation holds the marker;
|
||||
// an export that does not stop its app writes no marker and still runs. RED-PROOF (2026-10-24): with the BusyWith check
|
||||
// removed from StartExport, the stop-first export starts (StartExport returns nil) and this fails.
|
||||
func TestR1253_AStopFirstExportWaitsForTheOtherOperation(t *testing.T) {
|
||||
stackDir := filepath.Join(t.TempDir(), "immich")
|
||||
if err := os.MkdirAll(stackDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prov := &rtProvider{stackDir: stackDir, stacksDir: filepath.Dir(stackDir), deployed: true, running: true}
|
||||
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
|
||||
g := &busyGuard{}
|
||||
e.SetStopGuard(g)
|
||||
err := e.StartExport(ExportRequest{StackName: "immich", DestDrive: t.TempDir(), StopApp: true})
|
||||
if err == nil {
|
||||
t.Fatal("a stop-first export must be refused while a restore holds the app-stop marker")
|
||||
}
|
||||
if k, ok := err.(interface{ Key() string }); !ok || k.Key() != "err.appexport.another_operation_running" {
|
||||
t.Fatalf("the refusal must carry its household sentence key, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "visszaállítás") {
|
||||
t.Fatalf("the Hungarian sentence must name the cause: %q", err.Error())
|
||||
}
|
||||
if len(g.begins) != 0 {
|
||||
t.Fatalf("nothing may be marked or stopped, begins=%v", g.begins)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1250,6 +1250,7 @@
|
||||
"err.after_start.failed": "Saving did not work. Try again.",
|
||||
"err.backup.restore_drive_gone": "The drive “%s” cannot be reached, so the restore could not start. Connect the drive again, then start the restore again.",
|
||||
"err.stacks.remove_keep_data_while_support": "While support is working on this app, its data cannot be deleted — you can remove the app and keep its data.",
|
||||
"err.appexport.another_operation_running": "Another operation (for example a restore) is holding an app stopped now, so an export that stops the app cannot start. Try again when that operation has finished.",
|
||||
"err.appexport.a_mentes_nem_tartalmaz_alkalmazasadatot_0": "the backup holds no app data (0 data folders, 0 volumes for an app that stores data)",
|
||||
"err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a": "the contents of data folder %q are missing from the package",
|
||||
"err.appexport.a_z_kotet_adata_hianyzik_a": "the data of volume %q is missing from the package",
|
||||
|
||||
@@ -1245,6 +1245,7 @@
|
||||
"err.after_start.failed": "A mentés nem sikerült. Próbáld meg újra.",
|
||||
"err.backup.restore_drive_gone": "A(z) „%s” meghajtó nem érhető el, ezért a visszaállítás nem tudott elindulni. Csatlakoztasd újra a meghajtót, majd indítsd el újra a visszaállítást.",
|
||||
"err.stacks.remove_keep_data_while_support": "Amíg az ügyfélszolgálat foglalkozik az alkalmazással, az adatai nem törölhetők — az alkalmazást az adatai megtartásával eltávolíthatod.",
|
||||
"err.appexport.another_operation_running": "Egy másik művelet (például egy visszaállítás) éppen leállítva tart egy alkalmazást, ezért most nem indítható olyan exportálás, amely leállítja az alkalmazást. Próbáld újra, amikor az a művelet befejeződött.",
|
||||
"err.appexport.a_mentes_nem_tartalmaz_alkalmazasadatot_0": "a mentés nem tartalmaz alkalmazásadatot (0 adatkönyvtár, 0 kötet egy adattárolós alkalmazásnál)",
|
||||
"err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a": "a(z) %q adatkönyvtár tartalma hiányzik a csomagból",
|
||||
"err.appexport.a_z_kotet_adata_hianyzik_a": "a(z) %q kötet adata hiányzik a csomagból",
|
||||
|
||||
Reference in New Issue
Block a user