diff --git a/internal/modules/selfupdate.go b/internal/modules/selfupdate.go index 93ccb4b..ab850c9 100644 --- a/internal/modules/selfupdate.go +++ b/internal/modules/selfupdate.go @@ -369,11 +369,26 @@ func installSelfUpdate(tmpPath, currentBin string) error { } dir := filepath.Dir(currentBin) - if !spaceFor(dir, uint64(info.Size())) { + + // The current binary is unlinked below before the new copy lands, so + // its space counts towards the budget; without it the pre-check + // demands the whole new binary from free space alone and every update + // on a tiny overlay fails even though the swap itself would fit (#547). + // Apparent size is used on purpose: on compressing filesystems (ubifs) + // it overestimates what the unlink actually frees, which is the safe + // direction for a pre-check (a false "not enough" only fails the + // update; a false "enough" defers ENOSPC to the copy, which is already + // handled gracefully). + var freed uint64 + if cur, err := os.Stat(currentBin); err == nil && cur.Size() > 0 { + freed = uint64(cur.Size()) + } + + if !spaceFor(dir, uint64(info.Size()), freed) { // Best effort: drop the package cache to make room on tiny // overlays. No-op where the directory does not exist. os.RemoveAll("/etc/apk/cache") - if !spaceFor(dir, uint64(info.Size())) { + if !spaceFor(dir, uint64(info.Size()), freed) { return fmt.Errorf("not enough space on %s for the new binary (need ~%d bytes)", dir, info.Size()) } } @@ -390,14 +405,16 @@ func installSelfUpdate(tmpPath, currentBin string) error { } // spaceFor reports whether dir has at least needed bytes available plus a -// small headroom. Unknown filesystems (statfs failure) are treated as -// having room so the update is never blocked by an exotic mount. -func spaceFor(dir string, needed uint64) bool { +// small headroom, counting freed bytes that the install is about to +// release (the current binary being replaced). Unknown filesystems (statfs +// failure) are treated as having room so the update is never blocked by an +// exotic mount. +func spaceFor(dir string, needed, freed uint64) bool { var st syscall.Statfs_t if err := syscall.Statfs(dir, &st); err != nil || st.Bsize <= 0 { return true } - avail := uint64(st.Bavail) * uint64(st.Bsize) + avail := uint64(st.Bavail)*uint64(st.Bsize) + freed return avail >= needed+(1<<20) } diff --git a/internal/modules/selfupdate_install_test.go b/internal/modules/selfupdate_install_test.go index 078b6eb..ae9ef28 100644 --- a/internal/modules/selfupdate_install_test.go +++ b/internal/modules/selfupdate_install_test.go @@ -3,6 +3,7 @@ package modules import ( "os" "path/filepath" + "syscall" "testing" ) @@ -55,6 +56,31 @@ func TestInstallSelfUpdateReplacesBinary(t *testing.T) { } } +// The space pre-check must count the current binary's size towards the +// budget, because the install unlinks it before copying the new one (#547): +// on a tiny overlay the free space alone is always smaller than the new +// binary, and demanding it up front makes every self-update fail. +func TestSpaceForCountsFreedBytes(t *testing.T) { + dir := t.TempDir() + var st syscall.Statfs_t + if err := syscall.Statfs(dir, &st); err != nil || st.Bsize <= 0 { + t.Skip("statfs not available") + } + avail := uint64(st.Bavail) * uint64(st.Bsize) + + need := avail + 10<<20 + if spaceFor(dir, need, 0) { + t.Fatalf("spaceFor(need=%d, freed=0) = true, want false", need) + } + if !spaceFor(dir, need, 20<<20) { + t.Fatalf("spaceFor(need=%d, freed=%d) = false, want true", need, 20<<20) + } + // The 1MiB headroom is still required on top of avail+freed. + if spaceFor(dir, avail+20<<20, 20<<20) { + t.Fatal("spaceFor without the 1MiB headroom = true, want false") + } +} + // A missing current binary (e.g. a previous partial install) must not // block the install: the rescue copy fails, is logged, and the update // continues.