D48: a restore downloads first — every restore path resolves and pulls every image of the definition that will run before the stop, under the image-work lock held until the restore returns; a failed pull refuses with the app untouched (a deleted version and a busy registry read differently), one operator line. Red-proved. Commit only.
gates / gates (push) Successful in 1m26s

This commit is contained in:
2026-10-25 09:38:28 +01:00
parent b680db85cb
commit 2d71270858
16 changed files with 404 additions and 3 deletions
@@ -52,6 +52,16 @@ func BeginImageWork(args []string) (end func()) {
return imageWorkMu.RUnlock
}
// HoldImageWork holds the image-work lock SHARED until the returned func runs, whatever the verb (D48, 2026-10-25): a
// restore resolves and pulls its images BEFORE it stops the app and starts them minutes later, so the clean-up must
// not run anywhere in between. A compose command inside the hold takes the lock shared again (BeginImageWork); that
// nests safely because the ONLY exclusive taker is TryImageCleanup's TryLock, which never waits — so no writer can be
// queued between the two shared holds. Pinned by TestD48_HoldImageWorkBlocksCleanupAndNests.
func HoldImageWork() (end func()) {
imageWorkMu.RLock()
return imageWorkMu.RUnlock
}
// TryImageCleanup takes the image-work lock exclusively if no image work runs now. ok=false: something is
// pulling — do not clean up now.
func TryImageCleanup() (end func(), ok bool) {
@@ -0,0 +1,45 @@
package dockerexec
import (
"os"
"strings"
"testing"
"time"
)
// D48 — a restore holds the image-work lock from its pre-pull until its last start. While it is held the clean-up
// cannot run, and a compose up inside the hold does not block.
func TestD48_HoldImageWorkBlocksCleanupAndNests(t *testing.T) {
end := HoldImageWork()
if _, ok := TryImageCleanup(); ok {
t.Fatal("the image clean-up must not run while a restore holds its images")
}
done := make(chan struct{})
go func() {
defer close(done)
BeginImageWork([]string{"compose", "up", "-d"})()
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("a compose up inside the restore's hold must not block")
}
end()
unlock, ok := TryImageCleanup()
if !ok {
t.Fatal("after the hold the clean-up runs again")
}
unlock()
}
// The nesting is safe only while the one exclusive taker never WAITS (a waiting writer would block a nested RLock).
// Pin it: the package takes the lock exclusively nowhere but TryLock.
func TestD48_TheOnlyExclusiveTakerNeverWaits(t *testing.T) {
b, err := os.ReadFile("imagework.go")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "imageWorkMu.Lock()") {
t.Fatal("a blocking exclusive Lock() on imageWorkMu would deadlock a restore's nested shared hold (HoldImageWork)")
}
}