+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+import collections
import errno
import os
import subprocess
import time
-def unmount(path, timeout=10):
+
+MountInfo = collections.namedtuple(
+ 'MountInfo', ['is_fuse', 'major', 'minor', 'mnttype', 'path'])
+
+
+def mountinfo():
+ mi = []
+ with open('/proc/self/mountinfo') as f:
+ for m in f.readlines():
+ mntid, pmntid, dev, root, path, extra = m.split(" ", 5)
+ mnttype = extra.split(" - ")[1].split(" ", 1)[0]
+ major, minor = dev.split(":")
+ mi.append(MountInfo(
+ is_fuse=(mnttype == "fuse" or mnttype.startswith("fuse.")),
+ major=major,
+ minor=minor,
+ mnttype=mnttype,
+ path=path,
+ ))
+ return mi
+
+
+def paths_to_unmount(path, mnttype):
+ paths = []
+ for m in mountinfo():
+ if m.path == path or m.path.startswith(path+"/"):
+ paths.append(m.path)
+ if not (m.is_fuse and (mnttype is None or
+ mnttype == m.mnttype)):
+ raise Exception(
+ "cannot unmount {}: mount type is {}".format(
+ path, m.mnttype))
+ return paths
+
+
+def safer_realpath(path, loop=True):
+ """Similar to os.path.realpath(), but avoids calling lstat().
+
+ Leaves some symlinks unresolved."""
+ if path == '/':
+ return path, True
+ elif not path.startswith('/'):
+ path = os.path.abspath(path)
+ while True:
+ path = path.rstrip('/')
+ dirname, basename = os.path.split(path)
+ try:
+ path, resolved = safer_realpath(os.path.join(dirname, os.readlink(path)), loop=False)
+ except OSError as e:
+ # Path is not a symlink (EINVAL), or is unreadable, or
+ # doesn't exist. If the error was EINVAL and dirname can
+ # be resolved, we will have eliminated all symlinks and it
+ # will be safe to call normpath().
+ dirname, resolved = safer_realpath(dirname, loop=loop)
+ path = os.path.join(dirname, basename)
+ if resolved and e.errno == errno.EINVAL:
+ return os.path.normpath(path), True
+ else:
+ return path, False
+ except RuntimeError:
+ if not loop:
+ # Unwind to the point where we first started following
+ # symlinks.
+ raise
+ # Resolving the whole path landed in a symlink cycle, but
+ # we might still be able to resolve dirname.
+ dirname, _ = safer_realpath(dirname, loop=loop)
+ return os.path.join(dirname, basename), False
+
+
+def unmount(path, subtype=None, timeout=10, recursive=False):
"""Unmount the fuse mount at path.
Unmounting is done by writing 1 to the "abort" control file in
fuse mount at all. Raises an exception if it cannot be unmounted.
"""
- path = os.path.realpath(path)
+ path, _ = safer_realpath(path)
+
+ if subtype is None:
+ mnttype = None
+ elif subtype == '':
+ mnttype = 'fuse'
+ else:
+ mnttype = 'fuse.' + subtype
+
+ if recursive:
+ paths = paths_to_unmount(path, mnttype)
+ if not paths:
+ # We might not have found any mounts merely because path
+ # contains symlinks, so we should resolve them and try
+ # again. We didn't do this from the outset because
+ # realpath() can hang (see explanation below).
+ paths = paths_to_unmount(os.path.realpath(path), mnttype)
+ for path in sorted(paths, key=len, reverse=True):
+ unmount(path, timeout=timeout, recursive=False)
+ return len(paths) > 0
was_mounted = False
- t0 = time.time()
- delay = 0
- while True:
- if timeout and t0 + timeout < time.time():
- raise Exception("timed out")
+ attempted = False
+ if timeout is None:
+ deadline = None
+ else:
+ deadline = time.time() + timeout
+ while True:
mounted = False
- with open('/proc/self/mountinfo') as mi:
- for m in mi.readlines():
- mntid, pmntid, dev, root, mnt, extra = m.split(" ", 5)
- mnttype = extra.split(" - ")[1].split(" ")[0]
- if not (mnttype == "fuse" or mnttype.startswith("fuse.")):
- continue
+ for m in mountinfo():
+ if m.is_fuse and (mnttype is None or mnttype == m.mnttype):
try:
- if os.path.realpath(mnt) == path:
+ if m.path == path:
was_mounted = True
mounted = True
break
except OSError:
continue
- if not mounted:
+ if not was_mounted and path != os.path.realpath(path):
+ # If the specified path contains symlinks, it won't appear
+ # verbatim in mountinfo.
+ #
+ # It might seem like we should have called realpath() from
+ # the outset. But we can't: realpath() hangs (in lstat())
+ # if we call it on an unresponsive mount point, and this
+ # is an important and common scenario.
+ #
+ # By waiting until now to try realpath(), we avoid this
+ # problem in the most common cases, which are: (1) the
+ # specified path has no symlinks and is a mount point, in
+ # which case was_mounted==True and we can proceed without
+ # calling realpath(); and (2) the specified path is not a
+ # mount point (e.g., it was already unmounted by someone
+ # else, or it's a typo), and realpath() can determine that
+ # without hitting any other unresponsive mounts.
+ path = os.path.realpath(path)
+ continue
+ elif not mounted:
return was_mounted
- major, minor = dev.split(":")
+ if attempted:
+ delay = 1
+ if deadline:
+ delay = min(delay, deadline - time.time())
+ if delay <= 0:
+ raise Exception("timed out")
+ time.sleep(delay)
+
try:
- with open('/sys/fs/fuse/connections/'+str(minor)+'/abort', 'w') as f:
+ with open('/sys/fs/fuse/connections/{}/abort'.format(m.minor),
+ 'w') as f:
f.write("1")
- except OSError as e:
+ except IOError as e:
if e.errno != errno.ENOENT:
raise
+
+ attempted = True
try:
subprocess.check_call(["fusermount", "-u", "-z", path])
except subprocess.CalledProcessError:
pass
-
- time.sleep(delay)
- if delay == 0:
- delay = 1