diff --git a/Makefile b/Makefile index bc76c02b..14d42467 100755 --- a/Makefile +++ b/Makefile @@ -488,6 +488,14 @@ clean: ## Remove object files, keep the binary # default set above does not, so a plain `make fclean` used to reach libft with # SAFE unset -- landing in its SAFE!=1 branch, running the LTO capability probe # and printing its warning just to delete files. +# +# The final `rm -rf build` cannot finish on NFS (the 42 machines' sgoinfre) +# while a shell started from build/bin is still running: NFS keeps a deleted +# file that is still open as build/bin/.nfsXXXX until its last user exits, and +# refuses to remove it ("Device or resource busy"). That made `make re` stop +# here with nothing rebuilt. Such a file is not ours to remove and does not +# block the rebuild, so fclean succeeds when only those remain; anything else +# left behind is retried and fails the target as before. fclean: clean ## Remove objects, the binary and libft's build trees @printf " \033[1;33m⚠\033[0m \033[1;37mRemoving binary\033[0m" >&2 @rm -f $(BIN_DIR)/$(BAPTIZE_SHELL) @@ -496,7 +504,14 @@ fclean: clean ## Remove objects, the binary and libft's build trees @$(MAKE) -C vendor/libft fclean SAFE=$(SAFE) BUILD_DIR=build-$(SAFE_TAG) @rm -rf vendor/libft/build-ft vendor/libft/build-libc vendor/libft/build @printf "\r\033[K \033[1;32m✓\033[0m \033[37mlibft cleaned\033[0m\n" >&2 - @rm -rf build + @if ! rm -rf build 2>/dev/null; then \ + if find build ! -type d ! -name '.nfs*' | grep -q .; then \ + rm -rf build; \ + else \ + printf " \033[1;33m⚠\033[0m \033[37mkept %s: still open in a running process (NFS)\033[0m\n" \ + "$$(find build -name '.nfs*' | xargs)" >&2; \ + fi; \ + fi @printf "\n" >&2 # Run fclean THEN all, strictly in order. As plain prerequisites diff --git a/tests/fclean_nfs_test.py b/tests/fclean_nfs_test.py new file mode 100644 index 00000000..e2d9de9f --- /dev/null +++ b/tests/fclean_nfs_test.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""`make fclean` must not fail on a file NFS keeps for a running process. + +On NFS, deleting a file that a process still has open leaves it behind as +.nfsXXXX until that process exits, and removing it fails with "Device or +resource busy". With a shell from build/bin still running, `make re` used to +stop in fclean on exactly that file, with nothing rebuilt. + +This runs the Makefile's own cleanup recipe (cut out of the Makefile, so the +test cannot drift from it) in a scratch directory next to the repo: + - only a held-open .nfs file left: fclean succeeds and says what it kept; + - a real leftover (an unwritable directory): fclean still fails. +Where the repo is not on NFS no .nfs file ever appears; that is a SKIP, not a +pass. argv[1] (the shell, as for every tests/*.py) is not used. +""" +import os +import shutil +import subprocess +import sys +import tempfile +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FAILS = [] + + +def check(name, ok, detail=""): + print(("ok " if ok else "FAIL ") + name) + if not ok: + FAILS.append(name) + print(" " + detail) + + +def recipe(): + """The cleanup block of the fclean target, from `@if ! rm -rf build` to + its closing `fi`.""" + out, inside = [], False + with open(os.path.join(ROOT, "Makefile")) as f: + for line in f: + if line.startswith("\t@if ! rm -rf build"): + inside = True + if inside: + out.append(line) + if line == "\tfi\n": + return "".join(out) + return None + + +def fclean(d): + return subprocess.run(["make", "-s", "-C", d, "fclean"], + capture_output=True, text=True, timeout=30) + + +def main(): + body = recipe() + check("recipe/found-in-Makefile", body is not None) + if body is None: + return 1 + d = tempfile.mkdtemp(prefix=".fclean-nfs-", dir=ROOT) + holder = None + try: + with open(os.path.join(d, "Makefile"), "w") as f: + f.write("fclean:\n" + body) + os.makedirs(os.path.join(d, "build", "bin")) + held = os.path.join(d, "build", "bin", "held") + with open(held, "w") as f: + f.write("x") + holder = subprocess.Popen(["sh", "-c", "exec 3<\"$1\"; exec sleep 30", + "sh", held]) + time.sleep(0.3) + os.unlink(held) + if not any(n.startswith(".nfs") + for n in os.listdir(os.path.join(d, "build", "bin"))): + print("SKIP not on NFS: no .nfs file is left for an open file") + return 0 + r = fclean(d) + check("nfs-only/fclean-succeeds", r.returncode == 0, r.stderr) + check("nfs-only/says-what-it-kept", "kept build/bin/.nfs" in r.stderr, + r.stderr) + locked = os.path.join(d, "build", "locked") + os.makedirs(locked) + open(os.path.join(locked, "f"), "w").close() + os.chmod(locked, 0o555) + r = fclean(d) + os.chmod(locked, 0o755) + check("real-leftover/fclean-still-fails", r.returncode != 0, r.stderr) + finally: + if holder: + holder.kill() + holder.wait() + # NFS drops the .nfs file only once the holder is gone; retry. + for _ in range(20): + shutil.rmtree(d, ignore_errors=True) + if not os.path.exists(d): + break + time.sleep(0.25) + print("%d failed" % len(FAILS)) + return 1 if FAILS else 0 + + +sys.exit(main())