Skip to content

Commit 9a35551

Browse files
committed
fix: make unmount handling robust for busy/disconnected mounts
1 parent 49b32aa commit 9a35551

4 files changed

Lines changed: 229 additions & 8 deletions

File tree

pkg/utils/mount/mount.go

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"syscall"
1414
"time"
1515

16+
"github.com/containerd/log"
1617
"github.com/pkg/errors"
1718

1819
"github.com/containerd/nydus-snapshotter/pkg/errdefs"
@@ -26,17 +27,61 @@ type Interface interface {
2627
type Mounter struct {
2728
}
2829

30+
// These indirections are test seams so unit tests can stub out the real
31+
// syscall / filesystem check without touching real mountpoints.
32+
var (
33+
syscallUnmount = syscall.Unmount
34+
isMountpoint = IsMountpoint
35+
)
36+
37+
// isDisconnected reports whether err indicates a broken/stale mountpoint whose
38+
// backing server (e.g. a dead nydusd behind a FUSE mount) is gone. Such a
39+
// mountpoint still needs to be unmounted, so callers must not bail out on it.
40+
func isDisconnected(err error) bool {
41+
return errors.Is(err, syscall.ENOTCONN) || errors.Is(err, syscall.ESTALE)
42+
}
43+
44+
// unmountWithFallback tries a plain unmount first and, on failure, degrades to
45+
// force then lazy detach so that busy or disconnected mountpoints are always
46+
// torn down instead of being left behind.
47+
func unmountWithFallback(target string) error {
48+
err := syscallUnmount(target, 0)
49+
if err == nil || errors.Is(err, syscall.EINVAL) {
50+
// EINVAL means the target is not a mountpoint (already unmounted).
51+
return nil
52+
}
53+
54+
// umountForce aborts in-flight requests, which is what a disconnected FUSE
55+
// mount needs; try it first.
56+
if ferr := syscallUnmount(target, umountForce); ferr == nil {
57+
log.L.Warnf("force umount %s after plain umount failed: %v", target, err)
58+
return nil
59+
}
60+
61+
// umountDetach (lazy) detaches from the namespace even while busy; last resort.
62+
if lerr := syscallUnmount(target, umountDetach); lerr != nil {
63+
return errors.Wrapf(lerr, "lazy umount %s (plain umount error: %v)", target, err)
64+
}
65+
log.L.Warnf("lazy-detached %s after plain umount failed: %v", target, err)
66+
return nil
67+
}
68+
2969
func (m *Mounter) Umount(target string) error {
30-
if mounted, err := IsMountpoint(target); err == nil {
31-
if !mounted {
32-
return errors.New("not mounted")
70+
mounted, err := isMountpoint(target)
71+
if err != nil {
72+
// A disconnected/stale mountpoint fails IsMountpoint (stat returns
73+
// ENOTCONN) but still needs unmounting; proceed instead of bailing out.
74+
if !isDisconnected(err) {
75+
return err
3376
}
34-
} else {
35-
return err
77+
mounted = true
78+
}
79+
80+
if !mounted {
81+
return nil
3682
}
3783

38-
// return syscall.Unmount(target, syscall.MNT_FORCE)
39-
return syscall.Unmount(target, 0)
84+
return unmountWithFallback(target)
4085
}
4186

4287
func NormalizePath(path string) (realPath string, err error) {
@@ -83,8 +128,11 @@ func IsMountpoint(path string) (bool, error) {
83128

84129
func WaitUntilUnmounted(path string) error {
85130
return retry.Do(func() error {
86-
mounted, err := IsMountpoint(path)
131+
mounted, err := isMountpoint(path)
87132
if err != nil {
133+
if isDisconnected(err) {
134+
return nil
135+
}
88136
return err
89137
}
90138

pkg/utils/mount/mount_darwin.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* Copyright (c) 2022. Nydus Developers. All rights reserved.
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package mount
8+
9+
import "golang.org/x/sys/unix"
10+
11+
// darwin has no lazy (MNT_DETACH) unmount; degrade the last resort to a forced
12+
// unmount. This build exists mainly for local development on macOS; nydusd
13+
// mounts are only managed at runtime on Linux.
14+
const (
15+
umountForce = unix.MNT_FORCE
16+
umountDetach = unix.MNT_FORCE
17+
)

pkg/utils/mount/mount_linux.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* Copyright (c) 2022. Nydus Developers. All rights reserved.
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package mount
8+
9+
import "golang.org/x/sys/unix"
10+
11+
const (
12+
// umountForce aborts in-flight requests (needed for disconnected FUSE mounts).
13+
umountForce = unix.MNT_FORCE
14+
// umountDetach performs a lazy unmount, detaching a busy mountpoint from the
15+
// namespace and cleaning it up once it is no longer referenced.
16+
umountDetach = unix.MNT_DETACH
17+
)

pkg/utils/mount/mount_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright (c) 2022. Nydus Developers. All rights reserved.
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package mount
8+
9+
import (
10+
"syscall"
11+
"testing"
12+
13+
"github.com/pkg/errors"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestIsDisconnected(t *testing.T) {
19+
assert.True(t, isDisconnected(syscall.ENOTCONN))
20+
assert.True(t, isDisconnected(syscall.ESTALE))
21+
// Must see through pkg/errors wrapping (IsMountpoint wraps stat errors).
22+
assert.True(t, isDisconnected(errors.Wrapf(syscall.ENOTCONN, "stat target of %s", "/foo")))
23+
assert.False(t, isDisconnected(syscall.EBUSY))
24+
assert.False(t, isDisconnected(errors.New("some other error")))
25+
assert.False(t, isDisconnected(nil))
26+
}
27+
28+
func TestUnmountWithFallback(t *testing.T) {
29+
origUnmount := syscallUnmount
30+
t.Cleanup(func() { syscallUnmount = origUnmount })
31+
32+
type call struct {
33+
flags int
34+
}
35+
36+
tests := []struct {
37+
name string
38+
errByAttempt []error // error returned for the Nth syscallUnmount call
39+
wantErr bool
40+
wantCalls []call
41+
}{
42+
{
43+
name: "plain umount succeeds",
44+
errByAttempt: []error{nil},
45+
wantCalls: []call{{0}},
46+
},
47+
{
48+
name: "EINVAL treated as already unmounted",
49+
errByAttempt: []error{syscall.EINVAL},
50+
wantCalls: []call{{0}},
51+
},
52+
{
53+
name: "EBUSY falls back to force",
54+
errByAttempt: []error{syscall.EBUSY, nil},
55+
wantCalls: []call{{0}, {umountForce}},
56+
},
57+
{
58+
name: "force fails then lazy detach succeeds",
59+
errByAttempt: []error{syscall.EBUSY, syscall.EBUSY, nil},
60+
wantCalls: []call{{0}, {umountForce}, {umountDetach}},
61+
},
62+
{
63+
name: "all attempts fail returns error",
64+
errByAttempt: []error{syscall.EBUSY, syscall.EBUSY, syscall.EBUSY},
65+
wantErr: true,
66+
wantCalls: []call{{0}, {umountForce}, {umountDetach}},
67+
},
68+
}
69+
70+
for _, tc := range tests {
71+
t.Run(tc.name, func(t *testing.T) {
72+
var gotCalls []call
73+
idx := 0
74+
syscallUnmount = func(_ string, flags int) error {
75+
gotCalls = append(gotCalls, call{flags})
76+
err := tc.errByAttempt[idx]
77+
idx++
78+
return err
79+
}
80+
81+
err := unmountWithFallback("/mnt/target")
82+
if tc.wantErr {
83+
require.Error(t, err)
84+
} else {
85+
require.NoError(t, err)
86+
}
87+
assert.Equal(t, tc.wantCalls, gotCalls)
88+
})
89+
}
90+
}
91+
92+
func TestUmount(t *testing.T) {
93+
origIsMountpoint := isMountpoint
94+
origUnmount := syscallUnmount
95+
t.Cleanup(func() {
96+
isMountpoint = origIsMountpoint
97+
syscallUnmount = origUnmount
98+
})
99+
100+
t.Run("disconnected mountpoint still gets unmounted", func(t *testing.T) {
101+
isMountpoint = func(string) (bool, error) { return false, syscall.ENOTCONN }
102+
unmounted := false
103+
syscallUnmount = func(string, int) error { unmounted = true; return nil }
104+
105+
require.NoError(t, (&Mounter{}).Umount("/mnt/target"))
106+
assert.True(t, unmounted, "a disconnected mountpoint must still be unmounted")
107+
})
108+
109+
t.Run("not mounted returns nil without unmounting", func(t *testing.T) {
110+
isMountpoint = func(string) (bool, error) { return false, nil }
111+
called := false
112+
syscallUnmount = func(string, int) error { called = true; return nil }
113+
114+
require.NoError(t, (&Mounter{}).Umount("/mnt/target"))
115+
assert.False(t, called, "must not attempt to unmount a non-mountpoint")
116+
})
117+
118+
t.Run("other IsMountpoint error is propagated", func(t *testing.T) {
119+
wantErr := errors.New("boom")
120+
isMountpoint = func(string) (bool, error) { return false, wantErr }
121+
syscallUnmount = func(string, int) error { return nil }
122+
123+
require.ErrorIs(t, (&Mounter{}).Umount("/mnt/target"), wantErr)
124+
})
125+
}
126+
127+
func TestWaitUntilUnmountedIgnoresDisconnectedMountpoint(t *testing.T) {
128+
origIsMountpoint := isMountpoint
129+
t.Cleanup(func() { isMountpoint = origIsMountpoint })
130+
131+
calls := 0
132+
isMountpoint = func(string) (bool, error) {
133+
calls++
134+
return false, syscall.ENOTCONN
135+
}
136+
137+
require.NoError(t, WaitUntilUnmounted("/mnt/target"))
138+
assert.Equal(t, 1, calls)
139+
}

0 commit comments

Comments
 (0)