Heals stale FUSE mount references in consumer container mount namespaces after decypharr recycles
  • Go 97.6%
  • Dockerfile 2.4%
Find a file
Cinna e3fd8dc16f fix: pin main goroutine to M0 so reconcile reads stay in host mount ns
Injection goroutines LockOSThread + never unlock, but Go's runtime
doesn't reap the tainted thread synchronously. If the main goroutine
gets scheduled onto a tainted thread before it's destroyed, reads
of /proc/self/mountinfo return the consumer's ns view (which doesn't
contain the host's FUSE path), causing false "not mounted" reports
even though the actual host mount is fine.

Fix: LockOSThread at main() startup pins the reconcile loop to the
initial thread (M0), which is never mutated. Injection goroutines
still spawn and die on other threads; the tainted-thread leak still
happens but no longer affects the main loop's view.

Verified: full cycle (decypharr recycle → 7 stale ns detected → heal)
now stays "all consumers healthy" indefinitely after the heal.
2026-07-08 16:10:26 -04:00
deploy fix: pin main goroutine to M0 so reconcile reads stay in host mount ns 2026-07-08 16:10:26 -04:00
.gitignore Initial: mount-refresher — heals stale FUSE mount refs post decypharr recycle 2026-07-08 16:03:58 -04:00
Dockerfile Initial: mount-refresher — heals stale FUSE mount refs post decypharr recycle 2026-07-08 16:03:58 -04:00
go.mod Initial: mount-refresher — heals stale FUSE mount refs post decypharr recycle 2026-07-08 16:03:58 -04:00
go.sum Initial: mount-refresher — heals stale FUSE mount refs post decypharr recycle 2026-07-08 16:03:58 -04:00
main.go fix: pin main goroutine to M0 so reconcile reads stay in host mount ns 2026-07-08 16:10:26 -04:00
README.md Initial: mount-refresher — heals stale FUSE mount refs post decypharr recycle 2026-07-08 16:03:58 -04:00

mount-refresher

Heals stale FUSE mount references in consumer container mount namespaces after the FUSE producer (decypharr) recycles.

Problem

The FUSE mount at /var/lib/decypharr-dfs on the host lives in a kernel peer group created at mount time. When decypharr's container dies, the peer group is destroyed. Consumer containers (sonarr, radarr, jellyfin, …) with HostToContainer (rslave) mounts to that path retain a dangling slave reference to the dead group. A new decypharr FUSE mount forms a fresh peer group — but consumer mounts can't join it, because propagation relationships are established only at mount creation time.

Result: after a decypharr recycle, every consumer sees Socket not connected on /mnt/decypharr and stays broken until pod restart.

Approach

Uses open_tree(2) + move_mount(2) (Linux 5.2+) to:

  1. Clone the fresh FUSE mount subtree from the host's mount namespace
  2. Enter each stale consumer's mount namespace via setns(2)
  3. Lazy-detach the stale mount (MNT_DETACH)
  4. Attach the cloned mount at the target path

Result: each consumer's /mnt/decypharr points at the fresh FUSE with no process restart, no pod restart, no init-hook rerun.

Runs as a DaemonSet on the node where decypharr lives (Arc node in our case). Detects stale mounts by scanning /host/proc/*/mountinfo for fuse.decypharr entries whose major:minor differs from the host's current mount.

Gotchas

  • Go multithreading breaks setns(CLONE_NEWNS): the kernel refuses because fs_struct is shared across goroutines' OS threads. We unshare(CLONE_FS) on the calling thread first to satisfy the check, then tag the thread as tainted (never UnlockOSThread) so the Go runtime discards it after the goroutine exits.
  • Requires privileged: true + hostPID: true: open_tree needs CAP_SYS_ADMIN; ns fds need hostPID to see other pods' processes.

Deployment

DaemonSet on the Arc node. See deploy/daemonset.yaml.