From be8f6ecf5591bbbb822781645b09454bdb2dd81e Mon Sep 17 00:00:00 2001 From: James Coleman Date: Fri, 16 Oct 2026 13:42:44 -0500 Subject: [PATCH] fix(mount): wait for helper mounts to appear before verifying - bindfs and other FUSE helpers return before their mount is visible in /proc/mounts, so the single post-mount check raced them and aborted with "unable to mount" even though the mount succeeded, leaving configured services unstarted. - Post-mount verification now polls the mount table for up to 2 seconds before declaring failure. - Mount table entries are matched against both the symlink-resolved and literal target, so a target whose path cannot be resolved still matches a live mount instead of being mounted a second time. - Octal escapes in /proc/mounts are decoded, so mountpoints containing spaces are recognized as mounted. - Failure to read /proc/mounts is returned as an error and closes any LUKS volume opened for that entry, instead of exiting the process mid-run from a mount goroutine. --- main.go | 118 +++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 100 insertions(+), 18 deletions(-) diff --git a/main.go b/main.go index 87558aa..bbfe9f8 100644 --- a/main.go +++ b/main.go @@ -8,19 +8,29 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" "sync" "syscall" + "time" "golang.org/x/term" ) +// Mount helpers that background themselves can take a moment for their mount to +// become visible in the mount table, so verification polls instead of sampling +// once. +const ( + mountSettleTimeout = 2 * time.Second + mountSettlePoll = 20 * time.Millisecond +) + // RaidMount holds mount point details parsed from the raid table. type RaidMount struct { Source string - Target string - FSType string - Flags string + Target string + FSType string + Flags string CryptName string Encrypted bool Parallel bool @@ -34,18 +44,48 @@ type App struct { var app *App -// isMounted checks /proc/mounts for a target mountpoint to determine if it is mounted. -// Symlinks in the target path are resolved so the comparison matches the real -// paths recorded by the kernel. -func isMounted(target string) bool { +// unescapeMountField decodes the octal escapes the kernel writes into +// /proc/mounts for characters that would otherwise break field splitting: +// space, tab, newline, and backslash. Without this a mountpoint containing a +// space never matches and is reported as unmounted. +func unescapeMountField(field string) string { + if !strings.Contains(field, `\`) { + return field + } + + var b strings.Builder + for i := 0; i < len(field); i++ { + // Escapes are always a backslash followed by exactly three octal digits. + if field[i] == '\\' && i+3 < len(field) { + if v, err := strconv.ParseUint(field[i+1:i+4], 8, 8); err == nil { + b.WriteByte(byte(v)) + i += 3 + continue + } + } + b.WriteByte(field[i]) + } + return b.String() +} + +// isMounted checks /proc/mounts for a target mountpoint to determine if it is +// mounted. A read failure is returned rather than treated as "not mounted", +// since the two are otherwise indistinguishable and the caller would go on to +// stack a second mount on the same target. +func isMounted(target string) (bool, error) { + // Compare against both the resolved and unresolved forms. The kernel records + // fully resolved paths, but EvalSymlinks fails when any component is + // temporarily unreachable, and falling back to only the unresolved path + // would silently report a live mount as missing. cleanTarget := filepath.Clean(target) + candidates := map[string]bool{cleanTarget: true} if resolved, err := filepath.EvalSymlinks(cleanTarget); err == nil { - cleanTarget = resolved + candidates[resolved] = true } file, err := os.Open("/proc/mounts") if err != nil { - log.Fatal(err) + return false, fmt.Errorf("read mount table: %w", err) } defer file.Close() @@ -55,11 +95,31 @@ func isMounted(target string) bool { if len(args) < 3 { continue } - if filepath.Clean(args[1]) == cleanTarget { - return true + if candidates[filepath.Clean(unescapeMountField(args[1]))] { + return true, nil } } - return false + // A truncated scan looks identical to a target that is simply absent. + if err := scanner.Err(); err != nil { + return false, fmt.Errorf("read mount table: %w", err) + } + return false, nil +} + +// waitMounted polls the mount table for target, giving a helper that has +// already exited a brief window for its mount to become visible. +func waitMounted(target string) (bool, error) { + deadline := time.Now().Add(mountSettleTimeout) + for { + mounted, err := isMounted(target) + if err != nil || mounted { + return mounted, err + } + if time.Now().After(deadline) { + return false, nil + } + time.Sleep(mountSettlePoll) + } } // closeLUKS attempts to close a LUKS volume by name, logging any failure. @@ -83,7 +143,11 @@ func closeLUKS(cryptName string) { // Either side of the "|" may be empty. If no "|" is present the entire Flags // string is treated as bindfs native flags with no -o options. func mountBindfs(mount RaidMount) error { - if isMounted(mount.Target) { + mounted, err := isMounted(mount.Target) + if err != nil { + return err + } + if mounted { fmt.Println(mount.Target, "is already mounted") return nil } @@ -124,7 +188,13 @@ func mountBindfs(mount RaidMount) error { return fmt.Errorf("bindfs %s: %w", mount.Target, err) } - if !isMounted(mount.Target) { + // bindfs backgrounds itself once the mount is established, so poll rather + // than sampling the mount table a single time. + mounted, err = waitMounted(mount.Target) + if err != nil { + return err + } + if !mounted { return fmt.Errorf("unable to mount: %s", mount.Target) } return nil @@ -198,7 +268,14 @@ func mountDrive(mount RaidMount, encryptionPassword string) error { } // If we're already mounted on this mountpoint, skip. - if isMounted(mount.Target) { + mounted, err := isMounted(mount.Target) + if err != nil { + if mount.Encrypted && openedLUKS { + closeLUKS(mount.CryptName) + } + return err + } + if mounted { fmt.Println(mount.Target, "is already mounted") return nil } @@ -222,12 +299,17 @@ func mountDrive(mount RaidMount, encryptionPassword string) error { return fmt.Errorf("mount %s: %w", mount.Target, err) } - // Verify that it actually mounted. - if !isMounted(mount.Target) { + // Verify that it actually mounted. FUSE-backed filesystems such as mergerfs + // background themselves, so allow the same settle window used for bindfs. + mounted, err = waitMounted(mount.Target) + if err == nil && !mounted { + err = fmt.Errorf("unable to mount: %s", mount.Target) + } + if err != nil { if mount.Encrypted && openedLUKS { closeLUKS(mount.CryptName) } - return fmt.Errorf("unable to mount: %s", mount.Target) + return err } return nil }