fix: detect mounts via /proc/self/mountinfo so stale FUSE works
Update PKGBUILD version / update-pkgver (push) Successful in 5s
Update PKGBUILD version / update-pkgver (push) Successful in 5s
mountinfo.Mounted lstats the path. When the sshfs link dies, every stat on the mountpoint returns EIO, so -l filtered the dead mount out, mount failed in MkdirAll, and -u failed before fusermount. Switch detection to mountinfo.GetMounts (no stat) and add a fusermount -uz fallback so a stale mount can actually be torn down.
This commit is contained in:
@@ -1,5 +1,45 @@
|
|||||||
# Decisions
|
# Decisions
|
||||||
|
|
||||||
|
## 2026-05-04 – Detect mounts via `/proc/self/mountinfo` instead of `stat`
|
||||||
|
- **Who**: Dom, ClaudeCode
|
||||||
|
- **Why**: `mountinfo.Mounted(path)` from `github.com/moby/sys/mountinfo` works
|
||||||
|
by `lstat`-ing the path. When a sshfs connection dies, the FUSE endpoint
|
||||||
|
stays in `/proc/mounts` but every `stat` on the mountpoint returns EIO.
|
||||||
|
All three sshfsc paths broke as a result:
|
||||||
|
- `-l` silently filtered the dead mount out (`Mounted` returned an error,
|
||||||
|
code skipped on `merr != nil`)
|
||||||
|
- mount path failed with `mkdir … file exists` because `MkdirAll` stats
|
||||||
|
before creating
|
||||||
|
- `-u` failed with `lstat … input/output error` before reaching
|
||||||
|
`fusermount`
|
||||||
|
Net effect: a stale mount was a dead end — couldn't be listed, couldn't be
|
||||||
|
unmounted, blocked re-mount.
|
||||||
|
- **Tradeoffs**:
|
||||||
|
- Switched mount detection to `mountinfo.GetMounts(SingleEntryFilter(path))`,
|
||||||
|
which parses `/proc/self/mountinfo` and never touches the path. Robust to
|
||||||
|
EIO on the mount target.
|
||||||
|
- `list_mounts` now uses `PrefixFilter(base)` and trims the base prefix.
|
||||||
|
Direct children only — nested mounts under an alias dir would be skipped
|
||||||
|
on purpose; sshfsc only manages one level.
|
||||||
|
- `verify_mount_dir` skips `MkdirAll` when the path is already a mountpoint.
|
||||||
|
Re-running `sshfsc <alias>` against a stale mount no longer errors on
|
||||||
|
mkdir; main's existing `is_mounted_at` check then prints "Already mounted"
|
||||||
|
and returns. The user can `sshfsc -u <alias>` to recover.
|
||||||
|
- `unmount_sshfs` falls back to `fusermount -uz` (lazy) when `-u` fails.
|
||||||
|
Lazy unmount detaches the FS from the tree immediately and lets dangling
|
||||||
|
refs settle later — exactly what stale FUSE needs. Plain `-u` first
|
||||||
|
keeps the clean path noise-free.
|
||||||
|
- **How**:
|
||||||
|
- New helper `is_mounted_at(path)`; replaces `mountinfo.Mounted` at every
|
||||||
|
call site (`main`, `verify_mount_dir`, `unmount_sshfs`).
|
||||||
|
- `list_mounts` rewritten around `PrefixFilter`. Trims `base + "/"` and
|
||||||
|
skips entries containing further separators.
|
||||||
|
- `run_fusermount(flag, mount)` extracted to keep `-u` and `-uz` calls
|
||||||
|
tidy. `unmount_sshfs` retries with `-uz` on first-call error.
|
||||||
|
- New tests: `TestIsMountedAtFalseOnPlainDir`,
|
||||||
|
`TestIsMountedAtFalseOnMissingPath`. Existing tests stay green —
|
||||||
|
`mountinfo.GetMounts` returns empty under the test tmpdirs.
|
||||||
|
|
||||||
## 2026-05-04 – Add `-l` (list) and `-u` (unmount) flags
|
## 2026-05-04 – Add `-l` (list) and `-u` (unmount) flags
|
||||||
- **Who**: Dom, ClaudeCode
|
- **Who**: Dom, ClaudeCode
|
||||||
- **Why**: Mounts under `$XDG_RUNTIME_DIR/sshfs/<alias>` had to be listed and
|
- **Why**: Mounts under `$XDG_RUNTIME_DIR/sshfs/<alias>` had to be listed and
|
||||||
|
|||||||
@@ -167,9 +167,9 @@ func main() {
|
|||||||
fmt.Println("Mount: ", mount)
|
fmt.Println("Mount: ", mount)
|
||||||
}
|
}
|
||||||
|
|
||||||
chkmount, chkmount_err := mountinfo.Mounted(mount)
|
chkmount, chkmount_err := is_mounted_at(mount)
|
||||||
if chkmount_err != nil {
|
if chkmount_err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "mountinfo.Mounted() failed with %s\n", chkmount_err)
|
fmt.Fprintf(os.Stderr, "is_mounted_at() failed with %s\n", chkmount_err)
|
||||||
os.Exit(5)
|
os.Exit(5)
|
||||||
}
|
}
|
||||||
if !chkmount {
|
if !chkmount {
|
||||||
@@ -258,12 +258,28 @@ func verify_mount_dir(name string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
// If the path is already a mountpoint (possibly stale), skip MkdirAll —
|
||||||
|
// it would stat the target, which fails with EIO on stale FUSE mounts.
|
||||||
|
if ok, _ := is_mounted_at(mount); ok {
|
||||||
|
return mount, nil
|
||||||
|
}
|
||||||
if err := os.MkdirAll(mount, 0700); err != nil {
|
if err := os.MkdirAll(mount, 0700); err != nil {
|
||||||
return "", fmt.Errorf("create mount dir %q: %w", mount, err)
|
return "", fmt.Errorf("create mount dir %q: %w", mount, err)
|
||||||
}
|
}
|
||||||
return mount, nil
|
return mount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// is_mounted_at reports whether path is a mountpoint per /proc/self/mountinfo.
|
||||||
|
// Unlike mountinfo.Mounted, this does not stat the path, so stale FUSE mounts
|
||||||
|
// (whose targets return EIO on stat) are still detected.
|
||||||
|
func is_mounted_at(path string) (bool, error) {
|
||||||
|
mounts, err := mountinfo.GetMounts(mountinfo.SingleEntryFilter(path))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return len(mounts) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func list_mounts(out io.Writer) error {
|
func list_mounts(out io.Writer) error {
|
||||||
base, err := mount_base(false)
|
base, err := mount_base(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -272,24 +288,28 @@ func list_mounts(out io.Writer) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(base)
|
mounts, err := mountinfo.GetMounts(mountinfo.PrefixFilter(base))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read base %q: %w", base, err)
|
return fmt.Errorf("read mountinfo: %w", err)
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
prefix := base + string(os.PathSeparator)
|
||||||
if !entry.IsDir() {
|
for _, m := range mounts {
|
||||||
|
rel := strings.TrimPrefix(m.Mountpoint, prefix)
|
||||||
|
if rel == "" || strings.Contains(rel, string(os.PathSeparator)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path := filepath.Join(base, entry.Name())
|
fmt.Fprintln(out, rel)
|
||||||
ok, merr := mountinfo.Mounted(path)
|
|
||||||
if merr != nil || !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Fprintln(out, entry.Name())
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func run_fusermount(flag, mount string) error {
|
||||||
|
cmd := exec.Command("fusermount", flag, mount)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
func unmount_sshfs(alias string) error {
|
func unmount_sshfs(alias string) error {
|
||||||
if err := validate_ssh_field("alias", alias, rxHostUser); err != nil {
|
if err := validate_ssh_field("alias", alias, rxHostUser); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -305,18 +325,19 @@ func unmount_sshfs(alias string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ok, err := mountinfo.Mounted(mount)
|
ok, err := is_mounted_at(mount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("mountinfo.Mounted(%q): %w", mount, err)
|
return fmt.Errorf("check mount %q: %w", mount, err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("not mounted: %s", alias)
|
return fmt.Errorf("not mounted: %s", alias)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("fusermount", "-u", mount)
|
if err := run_fusermount("-u", mount); err != nil {
|
||||||
cmd.Stdout = os.Stdout
|
// stale FUSE mounts often need lazy unmount; -u fails with
|
||||||
cmd.Stderr = os.Stderr
|
// "Transport endpoint is not connected" on those.
|
||||||
if err := cmd.Run(); err != nil {
|
if err2 := run_fusermount("-uz", mount); err2 != nil {
|
||||||
return fmt.Errorf("fusermount -u %q: %w", mount, err)
|
return fmt.Errorf("fusermount -u %q: %w (lazy retry: %v)", mount, err, err2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := os.Remove(mount); err != nil {
|
if err := os.Remove(mount); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: remove empty mount dir %q: %v\n", mount, err)
|
fmt.Fprintf(os.Stderr, "warning: remove empty mount dir %q: %v\n", mount, err)
|
||||||
|
|||||||
@@ -200,3 +200,25 @@ func TestUnmountNotMountedExistingDir(t *testing.T) {
|
|||||||
t.Fatalf("want not-mounted error, got %v", err)
|
t.Fatalf("want not-mounted error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsMountedAtFalseOnPlainDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ok, err := is_mounted_at(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("plain tempdir should not be a mountpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsMountedAtFalseOnMissingPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ok, err := is_mounted_at(filepath.Join(dir, "nope"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error (no stat should happen): %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("missing path should not be a mountpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user