R-351: the restore compares where the backup says the data lived; second press cannot start a second run
gates / gates (push) Successful in 10s
gates / gates (push) Successful in 10s
Part 3 (not droppable) and the engine half of Part 2. No version bump yet - one bump and
one bake at the end of the session.
PART 3a - a second press really did start a second run. Established with a test BEFORE any
change: both offboxReconstituteHandler and offboxPlaceHandler answered "...elindult" and
overwrote the first restore's op/stack. Cause: every restore handler gated on
backupMgr.IsRunning() - the CONCURRENCY flag, which the restore goroutine acquires AFTER the
handler returns (offbox_reconstitute.go:180, offbox_restore.go:393). Seven sites. The wizard
had read the correct flag since v0.154.0 and said so in a comment; the handlers never moved.
New Server.restoreOpBlocked() reads BOTH flags - the display flag covers the whole off-box
restore, the concurrency flag is the only one the nightly backup holds - and the refusal now
names the running app and a route.
PART 3b - the page DOES refresh; the defect was the RESULT. backups_shared.html gated the
terminal result on a page-local sawRunning flag, so a restore that finished before the page
was opened, or inside one 3s poll, was shown to nobody. The 2026-08-21 OpenGist restore took
8.666s and no screen ever said it completed - the answer existed only in docker logs.
RestoreOpStatus.LastRecent now carries the server's verdict. The 10-minute window moved to
internal/backup as RestoreResultWindow and internal/web's constant is an alias: one
expression, two surfaces. Also removed the wizard's self-contradiction, which said the state
refreshes automatically AND that you must refresh the page.
PART 2 (engine) - every recovery unit manifest has carried drive and namespace_root since
schema 1, and NO non-test code read either back. The reconstitution opened the manifest and
took only the coherence stamp, then resolved its destination from the live app. A restore
into a different destination succeeded silently under a green message. New
backup/offbox_placement.go: CheckPlacement (pure, total), PlacementMismatchMessage,
recordedPlacementFromScratch. Compared before the safety dump and before the first byte.
A mismatch is NAMED and refused; ackPlacementChange lets the customer proceed deliberately -
a separate field from confirm=1, because one click must not carry two decisions. An UNKNOWN
recording is never a mismatch: refusing on an absence would strand every pre-field unit.
The not-installed refusal (R-253) now names the drive the backup recorded.
RED-PROOFS, each mutation asserted applied and reverted to 0:
B both guards removed (count asserted 2) -> the restore WAS seen starting with no drive
attached: no error, full 3.00s run, wrote into /tmp/mutant-destination
C Mismatch forced false -> the silent divergent restore returned
E Known() forced true -> the fabricated empty prefill appeared
D Mismatch forced true -> 8 ordinary reconstitute tests broke, proving reachability both ways
Note on D: the existing fixtures write a schema-1 manifest with NO drive, so they are
scenario-E shaped. The matching case is covered in the scenario table, not by them.
Gates 11/11 OK. Suite 28 packages ok. Hungarian verified as hex, no BOM, no mojibake sentinels.
NOT in this commit, still open: Part 2's scenario-A prefill UI, Part 1's deploy-page
visibility line, Part 1's specification document, Part 4's measurement.
This commit is contained in:
@@ -113,7 +113,12 @@ const (
|
||||
// Without a bound the last result would light that phase forever — landing on the page a week later
|
||||
// would claim you had just finished a restore. Same reasoning as escrowCeremonyGraceWindow; shorter,
|
||||
// because this answers "what just happened", not "are we still waiting".
|
||||
const restoreResultWindow = 10 * time.Minute
|
||||
//
|
||||
// R-351: this is now an ALIAS, not a second value. The list page's banner needs the same bound, and
|
||||
// the payload carries the verdict (RestoreOpStatus.LastRecent), so the window is defined once in
|
||||
// internal/backup beside the status it bounds. Keeping a separate literal here is how the two
|
||||
// surfaces would drift.
|
||||
const restoreResultWindow = backup.RestoreResultWindow
|
||||
|
||||
// hasRecentRestoreResult reports whether THIS app has a just-finished restore to show. Pure (the
|
||||
// clock is a parameter) so the boundary and the wrong-app case are table-testable.
|
||||
@@ -182,6 +187,43 @@ func restoreOpInFlight(st backup.RestoreOpStatus) bool {
|
||||
return st.Running
|
||||
}
|
||||
|
||||
// restoreOpBlocked reports whether a NEW restore must be refused right now, and returns the
|
||||
// Hungarian refusal to show. It reads BOTH flags, deliberately:
|
||||
//
|
||||
// - `RestoreStatus().Running` — the DISPLAY flag, set synchronously by `BeginRestoreOp` in the
|
||||
// handler. It is the only one that is true for the WHOLE duration of an off-box restore, which
|
||||
// is what makes it the right flag to refuse on.
|
||||
// - `IsRunning()` — the CONCURRENCY flag. The nightly backup run holds this one and never calls
|
||||
// `BeginRestoreOp`, so dropping it would open a hole the old guard did close. Kept, not replaced.
|
||||
//
|
||||
// R-351, the measured defect: every restore handler read ONLY `IsRunning()`, which the restore
|
||||
// goroutine acquires AFTER the handler has already returned (offbox_reconstitute.go:180,
|
||||
// offbox_restore.go:393). A second press inside that window started a second run and was told
|
||||
// „…elindult". Pinned by TestRestoreHandlers_SecondPressDoesNotStartASecondRun, which asserts the
|
||||
// CONSEQUENCE — that the first restore's identity survives the second press — rather than which
|
||||
// flag was read.
|
||||
//
|
||||
// The refusal names a reason AND a route: the page it redirects to is the wizard, which carries the
|
||||
// live status banner, so „ezen az oldalon" is a true instruction and not a gesture.
|
||||
func (s *Server) restoreOpBlocked() (string, bool) {
|
||||
if s.backupMgr == nil {
|
||||
return "", false
|
||||
}
|
||||
if st := s.backupMgr.RestoreStatus(); restoreOpInFlight(st) {
|
||||
subject := "Egy visszaállítási művelet"
|
||||
if st.Stack != "" {
|
||||
subject = "Egy visszaállítási művelet (" + st.Stack + ")"
|
||||
}
|
||||
return subject + " már fut, ezért most nem indítható újabb. Az állapotát ezen az oldalon " +
|
||||
"követheted; amint befejeződik, újra indíthatsz visszaállítást.", true
|
||||
}
|
||||
if s.backupMgr.IsRunning() {
|
||||
return "Egy mentési művelet már fut, ezért most nem indítható visszaállítás. Az állapotát " +
|
||||
"ezen az oldalon követheted; amint befejeződik, újra indíthatsz visszaállítást.", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// backupsRestoreWizardHandler renders GET /backups/restore/app?name=<app> — the single entry the
|
||||
// list page now offers per app.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user