diff --git a/modules/mounts/cmd/mounts/apply.go b/modules/mounts/cmd/mounts/apply.go index 97cb80da..678efadc 100644 --- a/modules/mounts/cmd/mounts/apply.go +++ b/modules/mounts/cmd/mounts/apply.go @@ -593,7 +593,7 @@ func (a *Applier) judge(p Pair, states map[string]map[string]string, mounted map } mnt, auto := states[p.MountUnit], states[p.AutoUnit] cur, isMounted := mounted[p.Mountpoint] - isMounted = isMounted && cur.Type != "autofs" + isMounted = isMounted && !cur.IsAutomount() switch { case !there && p.Occasional: return StateAbsent, "not present: " + tried.Error() diff --git a/modules/mounts/cmd/mounts/data.go b/modules/mounts/cmd/mounts/data.go index 4550ed81..803a1a84 100644 --- a/modules/mounts/cmd/mounts/data.go +++ b/modules/mounts/cmd/mounts/data.go @@ -158,7 +158,7 @@ func kindOf(what, fstype string) string { // there, or nothing when it is, or nothing is mounted there but an automount. func (m *Machine) notOwnSource(kind, what, point string, mounted map[string]Mounted) error { mt, there := mounted[point] - if !there || mt.Type == "autofs" { + if !there || mt.IsAutomount() { return nil } if kind != "" && m.sourceIs(kind, what, mt) { @@ -172,7 +172,7 @@ func (m *Machine) notOwnSource(kind, what, point string, mounted map[string]Moun func (m *Machine) mountpointFree(p Pair, own map[string]bool, mounted map[string]Mounted) error { ours := own[p.MountUnit] && own[p.AutoUnit] if mt, there := mounted[p.Mountpoint]; there { - if mt.Type == "autofs" { + if mt.IsAutomount() { if ours { return nil } diff --git a/modules/mounts/cmd/mounts/mounts.go b/modules/mounts/cmd/mounts/mounts.go index 46569135..94bbd1ab 100644 --- a/modules/mounts/cmd/mounts/mounts.go +++ b/modules/mounts/cmd/mounts/mounts.go @@ -135,6 +135,14 @@ type Mounted struct { Options string `json:"options,omitempty"` } +// IsAutomount says whether this line is only the autofs mount an armed automount leaves at its point. +// Nothing is mounted there yet: the share is mounted over it at first use, and that line then wins in +// ParseMountinfo. Every judgement of "mounted" asks this one question, so List, the look and the checks +// before a mount cannot disagree (novox/hq issue 437). +func (mt Mounted) IsAutomount() bool { + return mt.Type == "autofs" +} + // ParseMountinfo reads /proc/self/mountinfo: per line the mount point (field 5), and after the " - " // separator the type, the source and the superblock options. The last mount at a point wins, as it // is the one a path resolves to. @@ -400,7 +408,7 @@ func (mt *Mount) say() string { if mt.MountUnit.Result != "" && mt.MountUnit.Result != "success" { state += ", " + mt.MountUnit.Result } - case mt.MountedNow != nil: + case mt.MountedNow != nil && !mt.MountedNow.IsAutomount(): state = "mounted" case mt.Automount != nil && mt.Automount.Active == "active": state = "not mounted, waits for its first use (automount " + mt.Automount.Sub + ")" diff --git a/modules/mounts/cmd/mounts/mounts_test.go b/modules/mounts/cmd/mounts/mounts_test.go index 52f9858e..538472d4 100644 --- a/modules/mounts/cmd/mounts/mounts_test.go +++ b/modules/mounts/cmd/mounts/mounts_test.go @@ -258,6 +258,35 @@ func TestListReadsTheRootMountsUnit(t *testing.T) { } } +// The desktop's recalbox share as it stood on 2026-10-11 (novox/hq issue 437): its automount armed +// (active, waiting), its mount unit inactive, and in mountinfo only the autofs line the armed automount +// leaves at the point, with the superblock options the desktop showed. Nothing is mounted there, so it +// must be said as waiting for its first use, never as mounted. /mnt/backup in TestList keeps the other +// half: an nfs4 line over the autofs line is mounted. +func TestListSaysAnArmedAutomountIsNotMounted(t *testing.T) { + f := newFake(t) + f.file("/etc/fstab", "//192.0.2.176/Share /mnt/recalbox cifs username=root,password=s3cret,iocharset=utf8,sec=ntlmssp 0 0\n") + f.file("/proc/self/mountinfo", "22 1 259:3 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p3 rw\n"+ + "40 22 0:50 / /mnt/recalbox rw,relatime shared:30 - autofs systemd-1 rw,fd=116,pgrp=1,timeout=600,minproto=5,maxproto=5,direct,pipe_ino=68285996\n") + f.answers["systemctl list-units --type=mount,automount --all --no-legend --plain --no-pager"] = desktopListUnits + names := strings.Join(ParseListUnits(desktopListUnits), " ") + f.answers["systemctl show -p "+ShowProperties+" --no-pager -- "+names] = + "Id=mnt-recalbox.automount\nWhere=/mnt/recalbox\nResult=success\nActiveState=active\nSubState=waiting\n" + + "FragmentPath=/etc/systemd/system/mnt-recalbox.automount\nUnitFileState=enabled\n\n" + + "Id=mnt-recalbox.mount\nWhere=/mnt/recalbox\nWhat=//192.0.2.176/Share\nType=cifs\n" + + "Options=credentials=/var/lib/mounts/smb-recalbox.credentials,iocharset=utf8,_netdev,nofail,uid=1000,gid=1000\n" + + "Result=success\nActiveState=inactive\nSubState=dead\nFragmentPath=/etc/systemd/system/mnt-recalbox.mount\n" + + "TimeoutUSec=10s\nUnitFileState=enabled-runtime\n" + r := f.machine().List(false, "recalbox") + if len(r.Said) != 1 { + t.Fatalf("said %q, want one line for /mnt/recalbox", r.Said) + } + want := "/mnt/recalbox (cifs from //192.0.2.176/Share): not mounted, waits for its first use (automount waiting); from /etc/fstab line 1, no nofail" + if r.Said[0] != want { + t.Errorf("said %q,\nwant %q", r.Said[0], want) + } +} + func TestListNarrowed(t *testing.T) { m := listFake(t).machine() if r := m.List(true, ""); len(r.Mounts) != 3 {