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
+10
View File
@@ -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)
}
}