From ea5adae0b2232ad09cceb00d062fa15e5f44ca2e Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:30:55 +0100 Subject: [PATCH 1/2] Read the mounts once when emptying a target for a clean install _cs_clean_target_dir asked mountpoint about every directory below the target, one process each. On a node_modules tree that took minutes: 277 s for 20,000 directories, against 1.5 s now. The mounts below the target are read from /proc/self/mountinfo once and pruned by path, which also catches a bind mount from the same filesystem and a mounted file. Alpine shares the same code now; busybox mountpoint missed same-filesystem bind mounts there. --- lib/API.txt | 1 + lib/alpine.func | 45 ++++++++++++++++++++++++----- lib/forge.func | 42 ++++++++++++++++++++++----- tests/alpine-clean-install.sh | 32 +++++++++----------- tests/forge-branch-clean-install.sh | 16 +++++----- tests/forge-clean-install.sh | 42 ++++++++++++++++++++++++++- 6 files changed, 136 insertions(+), 42 deletions(-) diff --git a/lib/API.txt b/lib/API.txt index 1181a97e..6a74de52 100644 --- a/lib/API.txt +++ b/lib/API.txt @@ -4,6 +4,7 @@ _configure_pg_cron_preload _cs_apt_install_optional _cs_clean_target_dir _cs_drop_pm_shim +_cs_mounts_under _cs_repair_dpkg _cs_write_cargo_net _cs_write_cargo_profile diff --git a/lib/alpine.func b/lib/alpine.func index e37a3fdc..a08576ac 100644 --- a/lib/alpine.func +++ b/lib/alpine.func @@ -353,14 +353,27 @@ check_for_gh_release() { } # Empty a target directory before a clean install, leaving mount points and -# CLEAN_INSTALL_KEEP paths alone. Same function as in lib/forge.func, which says -# why it is written this way, except for how it spots a mount: busybox mountpoint -# compares device numbers, so it misses a bind mount from the same filesystem. +# CLEAN_INSTALL_KEEP paths alone. Same functions as in lib/forge.func, which +# says why they are written this way. +_cs_mounts_under() { + local dir="${1:?directory}" info="${3:-/proc/self/mountinfo}" mnt _ + local -n _cs_mounts="${2:?array name}" + _cs_mounts=() + [[ -r "$info" ]] || return 1 + while read -r _ _ _ _ mnt _; do + printf -v mnt '%b' "$mnt" + [[ "$mnt" == "$dir"/* ]] && _cs_mounts+=("$mnt") + done <"$info" + return 0 +} + _cs_clean_target_dir() { local dir="${1:?directory}" list entry stash="" [[ -d "$dir" ]] || return 0 dir="$(realpath "$dir")" # mountinfo lists resolved paths + # Moved aside rather than pruned from find: a kept path can be nested, and + # removing its parent would take it along. mv also keeps a symlink a symlink. local -a keep=() [[ -n "${CLEAN_INSTALL_KEEP:-}" ]] && read -ra keep <<<"$CLEAN_INSTALL_KEEP" if ((${#keep[@]})); then @@ -373,14 +386,32 @@ _cs_clean_target_dir() { done fi - if ! command -v mountpoint >/dev/null 2>&1; then + # The mounts are read once: asking mountpoint about every directory took + # minutes on a node_modules tree. + local -a mounts=() prune=() + if ! _cs_mounts_under "$dir" mounts; then + msg_error "Cannot read /proc/self/mountinfo, so ${dir} is not emptied" + return 1 + fi + if ((${#mounts[@]} == 0)); then find "${dir:?}" -mindepth 1 -maxdepth 1 -print0 | xargs -0 rm -rf -- else + # -path takes a pattern, so a literal \ * ? [ in a mount path is escaped. + local bs='\' + for entry in "${mounts[@]}"; do + entry="${entry//"$bs"/"$bs$bs"}" + entry="${entry//"*"/"$bs*"}" + entry="${entry//"?"/"$bs?"}" + entry="${entry//"["/"${bs}["}" + prune+=(-path "$entry" -prune -o) + done list="$(mktemp)" || return 1 - local -a skip_mounts=(\( -type d \( -exec mountpoint -q {} \; -o -exec grep -qF " {} " /proc/self/mountinfo \; \) -prune \)) - find "${dir:?}" -mindepth 1 "${skip_mounts[@]}" -o ! -type d -print0 >"$list" + # Not rm -r on the list: it would hold the parents of a nested mount, and + # rm -r on one would empty the mount. Files first, then directories deepest + # first. rmdir fails on purpose on a parent that still holds a mount. + find "${dir:?}" -mindepth 1 "${prune[@]}" ! -type d -print0 >"$list" xargs -0 rm -f -- <"$list" - find "${dir:?}" -mindepth 1 "${skip_mounts[@]}" -o -type d -print0 >"$list" + find "${dir:?}" -mindepth 1 "${prune[@]}" -type d -print0 >"$list" sort -rz "$list" | xargs -0 rmdir -- 2>/dev/null || true rm -f "$list" fi diff --git a/lib/forge.func b/lib/forge.func index 5cb09959..25bb99ba 100644 --- a/lib/forge.func +++ b/lib/forge.func @@ -504,6 +504,21 @@ _download_source_tarball() { return 250 } +# Fills the array named with the mount points strictly below , read +# from mountinfo (resolved paths, -style escapes decoded). Returns 1 when +# mountinfo cannot be read. +_cs_mounts_under() { + local dir="${1:?directory}" info="${3:-/proc/self/mountinfo}" mnt _ + local -n _cs_mounts="${2:?array name}" + _cs_mounts=() + [[ -r "$info" ]] || return 1 + while read -r _ _ _ _ mnt _; do + printf -v mnt '%b' "$mnt" + [[ "$mnt" == "$dir"/* ]] && _cs_mounts+=("$mnt") + done <"$info" + return 0 +} + # Empty a target directory before a clean install, without touching anything # mounted into it. The list is written out in full before anything is removed: # piping find straight into `xargs rm -rf` let rm delete parents while find was @@ -513,7 +528,7 @@ _download_source_tarball() { _cs_clean_target_dir() { local dir="${1:?directory}" list entry stash="" [[ -d "$dir" ]] || return 0 - dir="${dir%/}" + dir="$(realpath "$dir")" # mountinfo lists resolved paths # Moved aside rather than pruned from find: a kept path can be nested, and # removing its parent would take it along. mv also keeps a symlink a symlink. @@ -529,19 +544,32 @@ _cs_clean_target_dir() { done fi - # mountpoint lives in util-linux, which a minimal Alpine may not have. Without - # it the -exec below would fail once per directory, and there is nothing to - # protect anyway -- so drop the top-level entries and let rm recurse. - if ! command -v mountpoint >/dev/null 2>&1; then + # The mounts are read once: asking mountpoint about every directory took + # minutes on a node_modules tree. + local -a mounts=() prune=() + if ! _cs_mounts_under "$dir" mounts; then + msg_error "Cannot read /proc/self/mountinfo, so ${dir} is not emptied" + return 1 + fi + if ((${#mounts[@]} == 0)); then find "${dir:?}" -mindepth 1 -maxdepth 1 -print0 | xargs -0 rm -rf -- else + # -path takes a pattern, so a literal \ * ? [ in a mount path is escaped. + local bs='\' + for entry in "${mounts[@]}"; do + entry="${entry//"$bs"/"$bs$bs"}" + entry="${entry//"*"/"$bs*"}" + entry="${entry//"?"/"$bs?"}" + entry="${entry//"["/"${bs}["}" + prune+=(-path "$entry" -prune -o) + done list="$(mktemp)" || return 1 # Not rm -r on the list: it would hold the parents of a nested mount, and # rm -r on one would empty the mount. Files first, then directories deepest # first. rmdir fails on purpose on a parent that still holds a mount. - find "${dir:?}" -mindepth 1 \( -type d -exec mountpoint -q {} \; -prune \) -o ! -type d -print0 >"$list" + find "${dir:?}" -mindepth 1 "${prune[@]}" ! -type d -print0 >"$list" xargs -0 rm -f -- <"$list" - find "${dir:?}" -mindepth 1 \( -type d -exec mountpoint -q {} \; -prune \) -o -type d -print0 >"$list" + find "${dir:?}" -mindepth 1 "${prune[@]}" -type d -print0 >"$list" sort -rz "$list" | xargs -0 rmdir -- 2>/dev/null || true rm -f "$list" fi diff --git a/tests/alpine-clean-install.sh b/tests/alpine-clean-install.sh index ad02e442..32f253eb 100644 --- a/tests/alpine-clean-install.sh +++ b/tests/alpine-clean-install.sh @@ -36,13 +36,13 @@ download_with_progress() { cp "$TEST_DIR/$archive" "$2"; } export HOME="$TEST_DIR/home" mkdir -p "$HOME" "$TEST_DIR/bin" -# Any directory called mnt counts as a mount point, so no root is needed. -cat >"$TEST_DIR/bin/mountpoint" <<'EOF' -#!/usr/bin/env bash -[[ "${2##*/}" == "mnt" ]] -EOF -chmod +x "$TEST_DIR/bin/mountpoint" -export PATH="$TEST_DIR/bin:$PATH" +# Every path called mnt* counts as a mount, so no root is needed: the real +# parser reads a mountinfo written from them. +eval "real$(declare -f _cs_mounts_under)" +_cs_mounts_under() { + find "$TEST_DIR" -name 'mnt*' | sed 's/ /\\040/g; s|^|36 25 0:32 / |; s|$| rw - tmpfs tmpfs rw|' >"$TEST_DIR/mountinfo" + real_cs_mounts_under "$1" "$2" "$TEST_DIR/mountinfo" +} mkdir -p "$TEST_DIR/src/app-1.0" "$TEST_DIR/flat" "$TEST_DIR/hollow/app-1.0" echo new >"$TEST_DIR/src/app-1.0/app" @@ -92,19 +92,13 @@ for mode in tarball prebuild; do check "$mode: nested mount survives" "0 ./app ./data/mnt/file " "$(CLEAN_INSTALL=1 deploy "$mode" good.tar.gz)" done -# busybox mountpoint misses a bind mount from the same filesystem. mountinfo has it. -mkdir -p "$TEST_DIR/bin-bind" -printf '#!/usr/bin/env bash\nexit 1\n' >"$TEST_DIR/bin-bind/mountpoint" -cat >"$TEST_DIR/bin-bind/grep" <<'EOF' -#!/usr/bin/env bash -[[ "$2" == *"/data/bind " && "$3" == "/proc/self/mountinfo" ]] -EOF -chmod +x "$TEST_DIR/bin-bind/mountpoint" "$TEST_DIR/bin-bind/grep" +# busybox mountpoint compares device numbers and missed a bind mount from the +# same filesystem; mountinfo lists it. setup -mkdir -p "$target/data/bind" -echo mounted >"$target/data/bind/file" -PATH="$TEST_DIR/bin-bind:$PATH" _cs_clean_target_dir "$target" -check "bind mount from the same filesystem survives" "./data/bind/file " "$(listing)" +mkdir -p "$target/data/mnt bind" +echo mounted >"$target/data/mnt bind/file" +_cs_clean_target_dir "$target" +check "bind mount survives" "./data/mnt bind/file " "$(listing)" setup mv "$target" "$TEST_DIR/opt/real" diff --git a/tests/forge-branch-clean-install.sh b/tests/forge-branch-clean-install.sh index c0c5a89e..17b85942 100644 --- a/tests/forge-branch-clean-install.sh +++ b/tests/forge-branch-clean-install.sh @@ -2,7 +2,7 @@ set -euo pipefail CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -eval "$(awk '/^(_cs_clean_target_dir|fetch_and_deploy_gh_branch)\(\) \{/,/^}/' "$CORE_DIR/lib/forge.func")" +eval "$(awk '/^(_cs_mounts_under|_cs_clean_target_dir|fetch_and_deploy_gh_branch)\(\) \{/,/^}/' "$CORE_DIR/lib/forge.func")" TEST_DIR="$(mktemp -d)" trap 'rm -rf -- "$TEST_DIR"' EXIT @@ -34,13 +34,13 @@ export GIT_CONFIG_VALUE_0="https://github.com/" export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com -# Any directory called mnt counts as a mount point, so no root is needed. -cat >"$TEST_DIR/bin/mountpoint" <<'EOF' -#!/usr/bin/env bash -[[ "${2##*/}" == "mnt" ]] -EOF -chmod +x "$TEST_DIR/bin/mountpoint" -export PATH="$TEST_DIR/bin:$PATH" +# Every path called mnt* counts as a mount, so no root is needed: the real +# parser reads a mountinfo written from them. +eval "real$(declare -f _cs_mounts_under)" +_cs_mounts_under() { + find "$TEST_DIR" -name 'mnt*' | sed 's/ /\\040/g; s|^|36 25 0:32 / |; s|$| rw - tmpfs tmpfs rw|' >"$TEST_DIR/mountinfo" + real_cs_mounts_under "$1" "$2" "$TEST_DIR/mountinfo" +} git init -q -b main "$TEST_DIR/remote/owner/app" commit() { diff --git a/tests/forge-clean-install.sh b/tests/forge-clean-install.sh index 8401974c..8dcb300b 100644 --- a/tests/forge-clean-install.sh +++ b/tests/forge-clean-install.sh @@ -2,7 +2,7 @@ set -euo pipefail CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -eval "$(awk '/^(_cs_clean_target_dir|_deploy_source_tarball|_deploy_unpacked_archive|_forge_workdir|_forge_place|_forge_disk_full)\(\) \{/,/^}/' "$CORE_DIR/lib/forge.func")" +eval "$(awk '/^(_cs_mounts_under|_cs_clean_target_dir|_deploy_source_tarball|_deploy_unpacked_archive|_forge_workdir|_forge_place|_forge_disk_full)\(\) \{/,/^}/' "$CORE_DIR/lib/forge.func")" TEST_DIR="$(mktemp -d)" trap 'rm -rf -- "$TEST_DIR"' EXIT @@ -116,6 +116,46 @@ rc=0 FAKE_AVAIL=900000 _forge_disk_full "$TEST_DIR/dst" || rc=$? check "a failure with space left is not blamed on the disk" 0 "$rc" +# mountinfo: only what is mounted strictly below the target, escapes decoded. +cat >"$TEST_DIR/mountinfo" <<'EOF' +22 1 0:20 / / rw - ext4 /dev/root rw +30 22 0:25 / /opt/app rw - zfs tank/app rw +31 30 0:26 / /opt/app/media\040library rw - nfs srv:/media rw +32 30 0:27 /cfg.yml /opt/app/config.yml rw - ext4 /dev/sdb rw +33 22 0:28 / /opt/app2 rw - tmpfs tmpfs rw +EOF +mounts_of() { + local -a found=() + _cs_mounts_under "$1" found "$TEST_DIR/mountinfo" + printf '%s|' "${found[@]}" +} +check "mountinfo: mounts below the target, decoded" "/opt/app/media library|/opt/app/config.yml|" "$(mounts_of /opt/app)" +rc=0 +_cs_mounts_under /opt/app found "$TEST_DIR/no-mountinfo" || rc=$? +check "mountinfo: unreadable is an error" 1 "$rc" + +# Every path called mnt* counts as a mount, so no root is needed: the real +# parser reads a mountinfo written from them. +eval "real$(declare -f _cs_mounts_under)" +_cs_mounts_under() { + find "$TEST_DIR" -name 'mnt*' | sed 's/ /\\040/g; s|^|36 25 0:32 / |; s|$| rw - tmpfs tmpfs rw|' >"$TEST_DIR/mountinfo" + real_cs_mounts_under "$1" "$2" "$TEST_DIR/mountinfo" +} +# node_modules has tens of thousands of directories; mountpoint ran once for each. +mkdir -p "$TEST_DIR/bin-count" +printf '#!/usr/bin/env bash\necho x >>"%s"\nexit 1\n' "$TEST_DIR/mountpoint.calls" >"$TEST_DIR/bin-count/mountpoint" +chmod +x "$TEST_DIR/bin-count/mountpoint" +rm -rf "$TEST_DIR/target" +for i in $(seq 40); do mkdir -p "$TEST_DIR/target/node_modules/p$i/lib" && echo x >"$TEST_DIR/target/node_modules/p$i/lib/index.js"; done +mkdir -p "$TEST_DIR/target/data/mnt media" "$TEST_DIR/target/mnt[x]" "$TEST_DIR/target/a*b" +echo mounted >"$TEST_DIR/target/data/mnt media/file" +echo mounted >"$TEST_DIR/target/mnt[x]/file" +echo mounted >"$TEST_DIR/target/mnt.conf" +echo gone >"$TEST_DIR/target/a*b/file" +PATH="$TEST_DIR/bin-count:$PATH" _cs_clean_target_dir "$TEST_DIR/target" +check "mounts: a mounted directory and a mounted file survive, the rest goes" "./data/mnt media/file ./mnt.conf ./mnt[x]/file " "$(listing)" +check "mounts: no process per directory" 0 "$(cat "$TEST_DIR/mountpoint.calls" 2>/dev/null | wc -l | tr -d ' ')" + # The tarball mode used to return 0 whatever cp did. setup mkdir -p "$TEST_DIR/pkg/app-1.0" && echo v1 >"$TEST_DIR/pkg/app-1.0/app" From 237c6f683d6abd4be5c011fc1b28d9280ec4fd39 Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:41:16 +0100 Subject: [PATCH 2/2] Trim the comments to what a reader would get wrong --- lib/alpine.func | 11 ++--------- lib/forge.func | 6 +----- tests/alpine-clean-install.sh | 4 ---- tests/forge-branch-clean-install.sh | 2 -- tests/forge-clean-install.sh | 4 ---- 5 files changed, 3 insertions(+), 24 deletions(-) diff --git a/lib/alpine.func b/lib/alpine.func index a08576ac..4ef57b0b 100644 --- a/lib/alpine.func +++ b/lib/alpine.func @@ -353,8 +353,7 @@ check_for_gh_release() { } # Empty a target directory before a clean install, leaving mount points and -# CLEAN_INSTALL_KEEP paths alone. Same functions as in lib/forge.func, which -# says why they are written this way. +# CLEAN_INSTALL_KEEP paths alone, as in lib/forge.func. _cs_mounts_under() { local dir="${1:?directory}" info="${3:-/proc/self/mountinfo}" mnt _ local -n _cs_mounts="${2:?array name}" @@ -372,8 +371,6 @@ _cs_clean_target_dir() { [[ -d "$dir" ]] || return 0 dir="$(realpath "$dir")" # mountinfo lists resolved paths - # Moved aside rather than pruned from find: a kept path can be nested, and - # removing its parent would take it along. mv also keeps a symlink a symlink. local -a keep=() [[ -n "${CLEAN_INSTALL_KEEP:-}" ]] && read -ra keep <<<"$CLEAN_INSTALL_KEEP" if ((${#keep[@]})); then @@ -386,8 +383,6 @@ _cs_clean_target_dir() { done fi - # The mounts are read once: asking mountpoint about every directory took - # minutes on a node_modules tree. local -a mounts=() prune=() if ! _cs_mounts_under "$dir" mounts; then msg_error "Cannot read /proc/self/mountinfo, so ${dir} is not emptied" @@ -406,9 +401,7 @@ _cs_clean_target_dir() { prune+=(-path "$entry" -prune -o) done list="$(mktemp)" || return 1 - # Not rm -r on the list: it would hold the parents of a nested mount, and - # rm -r on one would empty the mount. Files first, then directories deepest - # first. rmdir fails on purpose on a parent that still holds a mount. + # Files first, then directories deepest first; rmdir fails on purpose on a parent that holds a mount. find "${dir:?}" -mindepth 1 "${prune[@]}" ! -type d -print0 >"$list" xargs -0 rm -f -- <"$list" find "${dir:?}" -mindepth 1 "${prune[@]}" -type d -print0 >"$list" diff --git a/lib/forge.func b/lib/forge.func index 25bb99ba..c7718798 100644 --- a/lib/forge.func +++ b/lib/forge.func @@ -504,9 +504,7 @@ _download_source_tarball() { return 250 } -# Fills the array named with the mount points strictly below , read -# from mountinfo (resolved paths, -style escapes decoded). Returns 1 when -# mountinfo cannot be read. +# Fills the array named with the mount points strictly below ; returns 1 when mountinfo is unreadable. _cs_mounts_under() { local dir="${1:?directory}" info="${3:-/proc/self/mountinfo}" mnt _ local -n _cs_mounts="${2:?array name}" @@ -544,8 +542,6 @@ _cs_clean_target_dir() { done fi - # The mounts are read once: asking mountpoint about every directory took - # minutes on a node_modules tree. local -a mounts=() prune=() if ! _cs_mounts_under "$dir" mounts; then msg_error "Cannot read /proc/self/mountinfo, so ${dir} is not emptied" diff --git a/tests/alpine-clean-install.sh b/tests/alpine-clean-install.sh index 32f253eb..2fce3d24 100644 --- a/tests/alpine-clean-install.sh +++ b/tests/alpine-clean-install.sh @@ -36,8 +36,6 @@ download_with_progress() { cp "$TEST_DIR/$archive" "$2"; } export HOME="$TEST_DIR/home" mkdir -p "$HOME" "$TEST_DIR/bin" -# Every path called mnt* counts as a mount, so no root is needed: the real -# parser reads a mountinfo written from them. eval "real$(declare -f _cs_mounts_under)" _cs_mounts_under() { find "$TEST_DIR" -name 'mnt*' | sed 's/ /\\040/g; s|^|36 25 0:32 / |; s|$| rw - tmpfs tmpfs rw|' >"$TEST_DIR/mountinfo" @@ -92,8 +90,6 @@ for mode in tarball prebuild; do check "$mode: nested mount survives" "0 ./app ./data/mnt/file " "$(CLEAN_INSTALL=1 deploy "$mode" good.tar.gz)" done -# busybox mountpoint compares device numbers and missed a bind mount from the -# same filesystem; mountinfo lists it. setup mkdir -p "$target/data/mnt bind" echo mounted >"$target/data/mnt bind/file" diff --git a/tests/forge-branch-clean-install.sh b/tests/forge-branch-clean-install.sh index 17b85942..fe4d8c76 100644 --- a/tests/forge-branch-clean-install.sh +++ b/tests/forge-branch-clean-install.sh @@ -34,8 +34,6 @@ export GIT_CONFIG_VALUE_0="https://github.com/" export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com -# Every path called mnt* counts as a mount, so no root is needed: the real -# parser reads a mountinfo written from them. eval "real$(declare -f _cs_mounts_under)" _cs_mounts_under() { find "$TEST_DIR" -name 'mnt*' | sed 's/ /\\040/g; s|^|36 25 0:32 / |; s|$| rw - tmpfs tmpfs rw|' >"$TEST_DIR/mountinfo" diff --git a/tests/forge-clean-install.sh b/tests/forge-clean-install.sh index 8dcb300b..7e4e4fae 100644 --- a/tests/forge-clean-install.sh +++ b/tests/forge-clean-install.sh @@ -116,7 +116,6 @@ rc=0 FAKE_AVAIL=900000 _forge_disk_full "$TEST_DIR/dst" || rc=$? check "a failure with space left is not blamed on the disk" 0 "$rc" -# mountinfo: only what is mounted strictly below the target, escapes decoded. cat >"$TEST_DIR/mountinfo" <<'EOF' 22 1 0:20 / / rw - ext4 /dev/root rw 30 22 0:25 / /opt/app rw - zfs tank/app rw @@ -134,14 +133,11 @@ rc=0 _cs_mounts_under /opt/app found "$TEST_DIR/no-mountinfo" || rc=$? check "mountinfo: unreadable is an error" 1 "$rc" -# Every path called mnt* counts as a mount, so no root is needed: the real -# parser reads a mountinfo written from them. eval "real$(declare -f _cs_mounts_under)" _cs_mounts_under() { find "$TEST_DIR" -name 'mnt*' | sed 's/ /\\040/g; s|^|36 25 0:32 / |; s|$| rw - tmpfs tmpfs rw|' >"$TEST_DIR/mountinfo" real_cs_mounts_under "$1" "$2" "$TEST_DIR/mountinfo" } -# node_modules has tens of thousands of directories; mountpoint ran once for each. mkdir -p "$TEST_DIR/bin-count" printf '#!/usr/bin/env bash\necho x >>"%s"\nexit 1\n' "$TEST_DIR/mountpoint.calls" >"$TEST_DIR/bin-count/mountpoint" chmod +x "$TEST_DIR/bin-count/mountpoint"