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.
This commit is contained in:
James Coleman 2026-10-16 13:42:44 -05:00
parent ac0ea56f98
commit be8f6ecf55

112
main.go
View file

@ -8,13 +8,23 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
"time"
"golang.org/x/term" "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. // RaidMount holds mount point details parsed from the raid table.
type RaidMount struct { type RaidMount struct {
Source string Source string
@ -34,18 +44,48 @@ type App struct {
var app *App var app *App
// isMounted checks /proc/mounts for a target mountpoint to determine if it is mounted. // unescapeMountField decodes the octal escapes the kernel writes into
// Symlinks in the target path are resolved so the comparison matches the real // /proc/mounts for characters that would otherwise break field splitting:
// paths recorded by the kernel. // space, tab, newline, and backslash. Without this a mountpoint containing a
func isMounted(target string) bool { // 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) cleanTarget := filepath.Clean(target)
candidates := map[string]bool{cleanTarget: true}
if resolved, err := filepath.EvalSymlinks(cleanTarget); err == nil { if resolved, err := filepath.EvalSymlinks(cleanTarget); err == nil {
cleanTarget = resolved candidates[resolved] = true
} }
file, err := os.Open("/proc/mounts") file, err := os.Open("/proc/mounts")
if err != nil { if err != nil {
log.Fatal(err) return false, fmt.Errorf("read mount table: %w", err)
} }
defer file.Close() defer file.Close()
@ -55,11 +95,31 @@ func isMounted(target string) bool {
if len(args) < 3 { if len(args) < 3 {
continue continue
} }
if filepath.Clean(args[1]) == cleanTarget { if candidates[filepath.Clean(unescapeMountField(args[1]))] {
return true 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. // 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 // 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. // string is treated as bindfs native flags with no -o options.
func mountBindfs(mount RaidMount) error { 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") fmt.Println(mount.Target, "is already mounted")
return nil return nil
} }
@ -124,7 +188,13 @@ func mountBindfs(mount RaidMount) error {
return fmt.Errorf("bindfs %s: %w", mount.Target, err) 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 fmt.Errorf("unable to mount: %s", mount.Target)
} }
return nil return nil
@ -198,7 +268,14 @@ func mountDrive(mount RaidMount, encryptionPassword string) error {
} }
// If we're already mounted on this mountpoint, skip. // 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") fmt.Println(mount.Target, "is already mounted")
return nil return nil
} }
@ -222,12 +299,17 @@ func mountDrive(mount RaidMount, encryptionPassword string) error {
return fmt.Errorf("mount %s: %w", mount.Target, err) return fmt.Errorf("mount %s: %w", mount.Target, err)
} }
// Verify that it actually mounted. // Verify that it actually mounted. FUSE-backed filesystems such as mergerfs
if !isMounted(mount.Target) { // 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 { if mount.Encrypted && openedLUKS {
closeLUKS(mount.CryptName) closeLUKS(mount.CryptName)
} }
return fmt.Errorf("unable to mount: %s", mount.Target) return err
} }
return nil return nil
} }