From 0a4ad462a6fcb70b8a3d8e4cbe54481a4556c7e8 Mon Sep 17 00:00:00 2001 From: gnacho Date: Tue, 20 Oct 2026 09:03:15 +0200 Subject: [PATCH] fix(selfupdate): count the current binary towards the space budget (#547) The install pre-check demanded the whole new binary plus headroom from free space alone, ignoring the space the unlink of the current binary frees before the copy. On tiny overlays (20 MB flash, ~16 MB binary) free space never reaches the new binary size, so every self-update failed with "not enough space" even though the swap itself fit. Add the current binary's apparent size to the budget: on compressing filesystems (ubifs) it overestimates the freed space, the safe direction for a pre-check. The rescue copy and the graceful ENOSPC handling on the copy itself stay unchanged. Closes #547 --- internal/modules/selfupdate.go | 29 ++++++++++++++++----- internal/modules/selfupdate_install_test.go | 26 ++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) 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.