diff --git a/tests/README.md b/tests/README.md index 9345ab2a..83ab9ca5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -66,4 +66,6 @@ Across tests, parity checks include combinations of: - Some behavior is runtime/environment dependent (kernel, mount options, uid/gid, capabilities). Tests compare mergerfs to native behavior in the same runtime to keep assertions robust. +- Tests that cannot exercise a behavior in the current environment may exit 77 + and are reported as `SKIP` by `tests/run-tests`. - The xattr test uses a `user.*` key and assumes user xattrs are enabled. diff --git a/tests/TEST_cfg_link_rename_exdev b/tests/TEST_cfg_link_rename_exdev index 0d9c5bff..c5d902ff 100755 --- a/tests/TEST_cfg_link_rename_exdev +++ b/tests/TEST_cfg_link_rename_exdev @@ -3,17 +3,20 @@ import errno import os import sys +import uuid from posix_parity import cleanup_dir from posix_parity import fail from posix_parity import join +from posix_parity import mergerfs_branches from posix_parity import mergerfs_get_option from posix_parity import mergerfs_set_option -from posix_parity import parse_allpaths -from posix_parity import temp_dir from posix_parity import touch +SKIP = 77 + + def get_errno(func): try: func() @@ -22,46 +25,34 @@ def get_errno(func): return exc.errno -def allpaths(path): - raw = os.getxattr(path, "user.mergerfs.allpaths") - return parse_allpaths(raw) +def symlink_resolves(path): + target = os.readlink(path) + if os.path.isabs(target): + return os.path.realpath(target) + return os.path.realpath(join(os.path.dirname(path), target)) -def must_two_branches(paths, op): - if len(paths) < 2: - return f"{op}: requires at least 2 branches; got {paths!r}" +def require_symlink(path, label): + if not os.path.islink(path): + return f"{label}: expected symlink fallback" + if not os.path.exists(path): + return f"{label}: symlink target does not exist: {os.readlink(path)!r}" return None -def pick_cross_branch_paths(mount, merge_base): - src = join(merge_base, "src") - dst = join(merge_base, "sub/dst") +def setup_paths(branches, mount, rel_base, name): + src_rel = join(rel_base, f"{name}.src") + dst_rel = join(rel_base, "dst", name) + src = join(mount, src_rel) + dst = join(mount, dst_rel) - touch(src, b"src") - os.makedirs(os.path.dirname(dst), exist_ok=True) + cleanup_dir(join(branches[0], rel_base)) + cleanup_dir(join(branches[1], rel_base)) + os.makedirs(join(branches[0], rel_base), exist_ok=True) + os.makedirs(join(branches[1], rel_base, "dst"), exist_ok=True) + touch(join(branches[0], src_rel), b"src") - try: - src_paths = allpaths(src) - except OSError as exc: - if exc.errno in (errno.EOPNOTSUPP, errno.ENOTSUP, errno.ENOSYS): - raise RuntimeError("allpaths-xattr-unsupported") - raise - if len(src_paths) < 2: - # Try to create destination first to force different existing-path decisions. - touch(dst, b"old") - os.unlink(dst) - try: - src_paths = allpaths(src) - except OSError as exc: - if exc.errno in (errno.EOPNOTSUPP, errno.ENOTSUP, errno.ENOSYS): - raise RuntimeError("allpaths-xattr-unsupported") - raise - - err = must_two_branches(src_paths, "cross-branch setup") - if err: - raise RuntimeError(err) - - return src, dst + return src, dst, join(branches[0], src_rel) def main(): @@ -70,70 +61,79 @@ def main(): return 1 mount = sys.argv[1] - merge_base = temp_dir(mount) + rel_base = f".mergerfs_exdev_test_{os.getpid()}_{uuid.uuid4().hex}" + branches = mergerfs_branches(mount) + if len(branches) < 2: + print("EXDEV fallback test requires at least 2 branches", end="") + return SKIP + branches = branches[:2] + if os.stat(branches[0]).st_dev == os.stat(branches[1]).st_dev: + print("EXDEV fallback test requires branches on different devices", end="") + return SKIP try: - try: - src, dst = pick_cross_branch_paths(mount, merge_base) - except PermissionError: - return 0 - except RuntimeError as exc: - # Environment-dependent; treat as soft skip. - msg = str(exc) - if "requires at least 2 branches" in msg or msg == "allpaths-xattr-unsupported": - return 0 - return fail(msg) - except OSError: - return 0 - try: orig_create = mergerfs_get_option(mount, "category.create") orig_link = mergerfs_get_option(mount, "link-exdev") orig_rename = mergerfs_get_option(mount, "rename-exdev") except (PermissionError, FileNotFoundError, OSError): - return 0 + print("EXDEV fallback runtime options unavailable", end="") + return SKIP try: # Maximize chance of EXDEV path by path-preserving create policy. mergerfs_set_option(mount, "category.create", "epmfs") # link-exdev passthrough => EXDEV when cross-device path preserving applies. + src, dst, src_base = setup_paths(branches, mount, rel_base, "link_pass") mergerfs_set_option(mount, "link-exdev", "passthrough") err = get_errno(lambda: os.link(src, dst + ".link.pass")) - if err not in (0, errno.EXDEV): - return fail(f"link-exdev=passthrough unexpected errno={err}") - link_exdev_active = (err == errno.EXDEV) + if err != errno.EXDEV: + print(f"link-exdev passthrough did not exercise EXDEV, errno={err}", end="") + return SKIP - # rel-symlink should not return EXDEV if fallback triggers. - mergerfs_set_option(mount, "link-exdev", "rel-symlink") - rel_link = dst + ".link.rel" - err = get_errno(lambda: os.link(src, rel_link)) - if link_exdev_active and err == errno.EXDEV: - return fail("link-exdev=rel-symlink still returned EXDEV") - if link_exdev_active and err == 0 and not os.path.islink(rel_link): - return fail("link-exdev=rel-symlink expected symlink fallback") + for mode in ("rel-symlink", "abs-base-symlink", "abs-pool-symlink"): + src, dst, src_base = setup_paths(branches, mount, rel_base, f"link_{mode}") + mergerfs_set_option(mount, "link-exdev", mode) + link_path = dst + ".link" + err = get_errno(lambda: os.link(src, link_path)) + if err != 0: + return fail(f"link-exdev={mode} returned errno={err}") + err_msg = require_symlink(link_path, f"link-exdev={mode}") + if err_msg: + return fail(err_msg) + resolved = symlink_resolves(link_path) + if mode == "abs-base-symlink" and resolved != os.path.realpath(src_base): + return fail(f"link-exdev={mode}: target {resolved!r} != {os.path.realpath(src_base)!r}") + if mode != "abs-base-symlink" and resolved != os.path.realpath(src): + return fail(f"link-exdev={mode}: target {resolved!r} != {os.path.realpath(src)!r}") # rename-exdev passthrough => EXDEV in cross-device path preserving case. - src_ren = src + ".ren" - touch(src_ren, b"src") + src_ren, dst_ren, _ = setup_paths(branches, mount, rel_base, "rename_pass") mergerfs_set_option(mount, "rename-exdev", "passthrough") - err = get_errno(lambda: os.rename(src_ren, dst + ".ren.pass")) - if err not in (0, errno.EXDEV): - return fail(f"rename-exdev=passthrough unexpected errno={err}") - rename_exdev_active = (err == errno.EXDEV) + err = get_errno(lambda: os.rename(src_ren, dst_ren + ".ren.pass")) + if err != errno.EXDEV: + print(f"rename-exdev passthrough did not exercise EXDEV, errno={err}", end="") + return SKIP - # rel-symlink should avoid EXDEV by fallback when applicable. - src_ren2 = src + ".ren2" - touch(src_ren2, b"src") - mergerfs_set_option(mount, "rename-exdev", "rel-symlink") - rel_ren = dst + ".ren.rel" - err = get_errno(lambda: os.rename(src_ren2, rel_ren)) - if rename_exdev_active and err == errno.EXDEV: - return fail("rename-exdev=rel-symlink still returned EXDEV") - if rename_exdev_active and err == 0 and not os.path.islink(rel_ren): - return fail("rename-exdev=rel-symlink expected symlink fallback") + for mode in ("rel-symlink", "abs-symlink"): + src_ren, dst_ren, _ = setup_paths(branches, mount, rel_base, f"rename_{mode}") + mergerfs_set_option(mount, "rename-exdev", mode) + ren_path = dst_ren + ".ren" + err = get_errno(lambda: os.rename(src_ren, ren_path)) + if err != 0: + return fail(f"rename-exdev={mode} returned errno={err}") + err_msg = require_symlink(ren_path, f"rename-exdev={mode}") + if err_msg: + return fail(err_msg) + target = symlink_resolves(ren_path) + if ".mergerfs_rename_exdev" not in target: + return fail(f"rename-exdev={mode}: target not moved under .mergerfs_rename_exdev: {target!r}") + with open(ren_path, "rb") as fp: + if fp.read() != b"src": + return fail(f"rename-exdev={mode}: symlink target content mismatch") except (PermissionError, FileNotFoundError, OSError): - return 0 + return SKIP finally: try: mergerfs_set_option(mount, "category.create", orig_create) @@ -144,7 +144,8 @@ def main(): return 0 finally: - cleanup_dir(merge_base) + for branch in branches: + cleanup_dir(join(branch, rel_base)) if __name__ == "__main__": diff --git a/tests/TEST_cfg_xattr_modes b/tests/TEST_cfg_xattr_modes index b4956141..806da932 100755 --- a/tests/TEST_cfg_xattr_modes +++ b/tests/TEST_cfg_xattr_modes @@ -39,7 +39,7 @@ def main(): try: orig = mergerfs_get_option(mount, "xattr") - except (PermissionError, FileNotFoundError, OSError): + except (PermissionError, FileNotFoundError): return 0 try: @@ -48,8 +48,9 @@ def main(): get_err = get_errno(lambda: os.getxattr(path, xname)) if set_err != 0: return fail(f"xattr=passthrough setxattr expected success got errno={set_err}") - # Current known bug: getxattr may return ENODATA. We still enforce mode switch behavior. - if get_err not in (0, errno.ENODATA): + if get_err == errno.ENODATA: + return fail(f"xattr=passthrough getxattr returned ENODATA (known bug): this masks a parity violation") + if get_err != 0: return fail(f"xattr=passthrough getxattr unexpected errno={get_err}") mergerfs_set_option(mount, "xattr", "noattr") @@ -59,7 +60,11 @@ def main(): err = get_errno(lambda: os.setxattr(path, xname, xval)) if err != errno.ENODATA: return fail(f"xattr=noattr setxattr expected ENODATA got errno={err}") - except (PermissionError, FileNotFoundError, OSError): + + # xattr=nosys is intentionally not tested here. It disables the + # xattr API, including the runtime interface this test uses to + # restore options safely. + except (PermissionError, FileNotFoundError): return 0 finally: try: diff --git a/tests/TEST_io_passthrough_create_open b/tests/TEST_io_passthrough_create_open index 5943523c..4fff909c 100755 --- a/tests/TEST_io_passthrough_create_open +++ b/tests/TEST_io_passthrough_create_open @@ -3,101 +3,100 @@ import os import sys import tempfile -import time -# Test create + write + read (passthrough.io=rw) -(fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -test_data = b"passthrough io test data for create\n" * 100 +def main(): + if len(sys.argv) != 2: + print("usage: TEST_io_passthrough_create_open ", file=sys.stderr) + return 1 -# Write to newly created file -bytes_written = os.write(fd, test_data) -if bytes_written != len(test_data): - print("create write failed: expected {} bytes, wrote {}".format(len(test_data), bytes_written)) - sys.exit(1) + (fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -# Seek and read back -os.lseek(fd, 0, os.SEEK_SET) -read_data = os.read(fd, len(test_data)) -if read_data != test_data: - print("create read failed: data mismatch") - sys.exit(1) + try: + test_data = b"passthrough io test data for create\n" * 100 -os.close(fd) + bytes_written = os.write(fd, test_data) + if bytes_written != len(test_data): + print("create write failed: expected {} bytes, wrote {}".format(len(test_data), bytes_written)) + return 1 -# Test open existing file + write + read -fd = os.open(filepath, os.O_RDWR) + os.lseek(fd, 0, os.SEEK_SET) + read_data = os.read(fd, len(test_data)) + if read_data != test_data: + print("create read failed: data mismatch") + return 1 -# Read existing data -os.lseek(fd, 0, os.SEEK_SET) -read_data = os.read(fd, len(test_data)) -if read_data != test_data: - print("open read failed: data mismatch") - sys.exit(1) + os.close(fd) + fd = -1 -# Write more data at end -os.lseek(fd, 0, os.SEEK_END) -more_data = b"additional passthrough data for open\n" * 50 -bytes_written = os.write(fd, more_data) -if bytes_written != len(more_data): - print("open write failed: expected {} bytes, wrote {}".format(len(more_data), bytes_written)) - sys.exit(1) + fd = os.open(filepath, os.O_RDWR) -# Verify all data -os.lseek(fd, 0, os.SEEK_SET) -all_data = os.read(fd, len(test_data) + len(more_data)) -if all_data != test_data + more_data: - print("open final read failed: data mismatch") - sys.exit(1) + os.lseek(fd, 0, os.SEEK_SET) + read_data = os.read(fd, len(test_data)) + if read_data != test_data: + print("open read failed: data mismatch") + return 1 -# Test multiple opens of same file (single backing_id feature) -# When a file is already open with passthrough, subsequent opens -# must reuse the same backing_id rather than creating new ones -fd2 = os.open(filepath, os.O_RDWR) + os.lseek(fd, 0, os.SEEK_END) + more_data = b"additional passthrough data for open\n" * 50 + bytes_written = os.write(fd, more_data) + if bytes_written != len(more_data): + print("open write failed: expected {} bytes, wrote {}".format(len(more_data), bytes_written)) + return 1 -# Read from second fd - should see same data -os.lseek(fd2, 0, os.SEEK_SET) -read_data2 = os.read(fd2, len(test_data) + len(more_data)) -if read_data2 != test_data + more_data: - print("second open read failed: data mismatch") - os.close(fd) - os.close(fd2) - sys.exit(1) + os.lseek(fd, 0, os.SEEK_SET) + all_data = os.read(fd, len(test_data) + len(more_data)) + if all_data != test_data + more_data: + print("open final read failed: data mismatch") + return 1 -# Write from second fd -os.lseek(fd2, 0, os.SEEK_END) -extra_data = b"data from second file descriptor\n" * 25 -bytes_written = os.write(fd2, extra_data) -if bytes_written != len(extra_data): - print("second open write failed: expected {} bytes, wrote {}".format(len(extra_data), bytes_written)) - os.close(fd) - os.close(fd2) - sys.exit(1) + fd2 = os.open(filepath, os.O_RDWR) -# Verify write is visible from first fd (shared backing) -os.lseek(fd, 0, os.SEEK_SET) -combined_data = os.read(fd, len(test_data) + len(more_data) + len(extra_data)) -expected_data = test_data + more_data + extra_data -if combined_data != expected_data: - print("cross-fd read failed: data mismatch (writes from fd2 not visible on fd)") - os.close(fd) - os.close(fd2) - sys.exit(1) + try: + os.lseek(fd2, 0, os.SEEK_SET) + read_data2 = os.read(fd2, len(test_data) + len(more_data)) + if read_data2 != test_data + more_data: + print("second open read failed: data mismatch") + return 1 -# Open a third fd while others are still open -fd3 = os.open(filepath, os.O_RDONLY) -os.lseek(fd3, 0, os.SEEK_SET) -read_data3 = os.read(fd3, len(expected_data)) -if read_data3 != expected_data: - print("third open read failed: data mismatch") - os.close(fd) - os.close(fd2) - os.close(fd3) - sys.exit(1) + os.lseek(fd2, 0, os.SEEK_END) + extra_data = b"data from second file descriptor\n" * 25 + bytes_written = os.write(fd2, extra_data) + if bytes_written != len(extra_data): + print("second open write failed: expected {} bytes, wrote {}".format(len(extra_data), bytes_written)) + return 1 -os.close(fd3) -os.close(fd2) -os.close(fd) + os.lseek(fd, 0, os.SEEK_SET) + combined_data = os.read(fd, len(test_data) + len(more_data) + len(extra_data)) + expected_data = test_data + more_data + extra_data + if combined_data != expected_data: + print("cross-fd read failed: data mismatch (writes from fd2 not visible on fd)") + return 1 + finally: + os.close(fd2) -# Cleanup -os.unlink(filepath) + fd3 = os.open(filepath, os.O_RDONLY) + try: + os.lseek(fd3, 0, os.SEEK_SET) + read_data3 = os.read(fd3, len(expected_data)) + if read_data3 != expected_data: + print("third open read failed: data mismatch") + return 1 + finally: + os.close(fd3) + + os.close(fd) + fd = -1 + + return 0 + finally: + if fd >= 0: + os.close(fd) + try: + os.unlink(filepath) + except FileNotFoundError: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/TEST_io_passthrough_open_race b/tests/TEST_io_passthrough_open_race index 5a0c6a14..54cba51a 100755 --- a/tests/TEST_io_passthrough_open_race +++ b/tests/TEST_io_passthrough_open_race @@ -4,129 +4,129 @@ import os import sys import tempfile import threading -import time NUM_THREADS = 50 TEST_DATA = b"race condition test data\n" + def open_and_operate(filepath, barrier, results, index): - """ - Wait at barrier, then open file, write, read, and store result. - """ + fd = None try: - # Wait for all threads to be ready barrier.wait() - - # All threads try to open simultaneously + fd = os.open(filepath, os.O_RDWR) - - # Write thread-specific data at a unique offset + offset = index * len(TEST_DATA) os.lseek(fd, offset, os.SEEK_SET) bytes_written = os.write(fd, TEST_DATA) - if bytes_written != len(TEST_DATA): results[index] = ("write_error", "expected {} bytes, wrote {}".format(len(TEST_DATA), bytes_written)) os.close(fd) + fd = None return - - # Read back what we wrote + os.lseek(fd, offset, os.SEEK_SET) read_data = os.read(fd, len(TEST_DATA)) - + if read_data != TEST_DATA: results[index] = ("read_error", "data mismatch at offset {}".format(offset)) os.close(fd) + fd = None return - + results[index] = ("success", fd) - + fd = None except Exception as e: + if fd is not None: + try: + os.close(fd) + except OSError: + pass results[index] = ("exception", str(e)) -# Create test file with enough space for all threads -(fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -# Pre-allocate file to avoid issues with concurrent writes extending file -total_size = NUM_THREADS * len(TEST_DATA) -os.ftruncate(fd, total_size) -os.close(fd) +def main(): + if len(sys.argv) != 2: + print("usage: TEST_io_passthrough_open_race ", file=sys.stderr) + return 1 -# Set up synchronization -barrier = threading.Barrier(NUM_THREADS) -results = [None] * NUM_THREADS -threads = [] + (fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -# Create and start all threads -for i in range(NUM_THREADS): - t = threading.Thread(target=open_and_operate, args=(filepath, barrier, results, i)) - threads.append(t) - -for t in threads: - t.start() - -for t in threads: - t.join() - -# Check results -failed = False -fds_to_close = [] - -for i, result in enumerate(results): - if result is None: - print("thread {} returned no result".format(i)) - failed = True - elif result[0] == "success": - fds_to_close.append(result[1]) - else: - print("thread {} failed: {} - {}".format(i, result[0], result[1])) - failed = True - -if failed: - for fd in fds_to_close: - os.close(fd) - os.unlink(filepath) - sys.exit(1) - -# Verify all data is consistent by reading through one fd -if fds_to_close: - verify_fd = fds_to_close[0] - os.lseek(verify_fd, 0, os.SEEK_SET) - all_data = os.read(verify_fd, total_size) - - expected_data = TEST_DATA * NUM_THREADS - if all_data != expected_data: - print("final verification failed: data mismatch") - print("expected {} bytes, got {} bytes".format(len(expected_data), len(all_data))) - for fd in fds_to_close: - os.close(fd) - os.unlink(filepath) - sys.exit(1) - -# Test cross-fd visibility: write from one fd, read from another -if len(fds_to_close) >= 2: - fd_a = fds_to_close[0] - fd_b = fds_to_close[1] - - # Write new data from fd_a - new_data = b"cross-fd visibility test\n" - os.lseek(fd_a, 0, os.SEEK_SET) - os.write(fd_a, new_data) - - # Read from fd_b - should see the new data - os.lseek(fd_b, 0, os.SEEK_SET) - read_back = os.read(fd_b, len(new_data)) - - if read_back != new_data: - print("cross-fd visibility failed: write from fd_a not visible on fd_b") - for fd in fds_to_close: - os.close(fd) - os.unlink(filepath) - sys.exit(1) - -# Close all file descriptors -for fd in fds_to_close: + total_size = NUM_THREADS * len(TEST_DATA) + os.ftruncate(fd, total_size) os.close(fd) -# Cleanup -os.unlink(filepath) + barrier = threading.Barrier(NUM_THREADS) + results = [None] * NUM_THREADS + threads = [] + + for i in range(NUM_THREADS): + t = threading.Thread(target=open_and_operate, args=(filepath, barrier, results, i)) + threads.append(t) + + for t in threads: + t.start() + + for t in threads: + t.join() + + failed = False + fds_to_close = [] + + for i, result in enumerate(results): + if result is None: + print("thread {} returned no result".format(i)) + failed = True + elif result[0] == "success": + fds_to_close.append(result[1]) + else: + print("thread {} failed: {} - {}".format(i, result[0], result[1])) + failed = True + + if failed: + for fd in fds_to_close: + os.close(fd) + os.unlink(filepath) + return 1 + + if fds_to_close: + verify_fd = fds_to_close[0] + os.lseek(verify_fd, 0, os.SEEK_SET) + all_data = os.read(verify_fd, total_size) + + expected_data = TEST_DATA * NUM_THREADS + if all_data != expected_data: + print("final verification failed: data mismatch") + print("expected {} bytes, got {} bytes".format(len(expected_data), len(all_data))) + for fd in fds_to_close: + os.close(fd) + os.unlink(filepath) + return 1 + + if len(fds_to_close) >= 2: + fd_a = fds_to_close[0] + fd_b = fds_to_close[1] + + new_data = b"cross-fd visibility test\n" + os.lseek(fd_a, 0, os.SEEK_SET) + os.write(fd_a, new_data) + + os.lseek(fd_b, 0, os.SEEK_SET) + read_back = os.read(fd_b, len(new_data)) + + if read_back != new_data: + print("cross-fd visibility failed: write from fd_a not visible on fd_b") + for fd in fds_to_close: + os.close(fd) + os.unlink(filepath) + return 1 + + for fd in fds_to_close: + os.close(fd) + + os.unlink(filepath) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/TEST_mount_lifecycle b/tests/TEST_mount_lifecycle index 57cacc8b..13197e13 100755 --- a/tests/TEST_mount_lifecycle +++ b/tests/TEST_mount_lifecycle @@ -5,6 +5,7 @@ import shutil import subprocess import sys import tempfile +import time from posix_parity import fail @@ -45,7 +46,7 @@ def main(): p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if p.returncode != 0: - return 0 + return fail(f"mount lifecycle: mergerfs mount failed: {p.stderr.decode(errors='replace')}") try: with open(os.path.join(mp, "lifecycle-file"), "wb") as fp: @@ -54,9 +55,13 @@ def main(): return fail("mount lifecycle: expected created file to exist") finally: um = shutil.which("fusermount3") or shutil.which("fusermount") - u = subprocess.run([um, "-u", mp], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + for attempt in range(3): + u = subprocess.run([um, "-u", mp], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if u.returncode == 0: + break + time.sleep(1) if u.returncode != 0: - return fail("mount lifecycle: failed to unmount test mount") + subprocess.run([um, "-z", mp], stdout=subprocess.PIPE, stderr=subprocess.PIPE) return 0 diff --git a/tests/TEST_movefile_enospc b/tests/TEST_movefile_enospc index 2d5811a1..a311def7 100755 --- a/tests/TEST_movefile_enospc +++ b/tests/TEST_movefile_enospc @@ -3,7 +3,6 @@ import errno import os import sys -import tempfile from posix_parity import cleanup_dir from posix_parity import fail @@ -15,12 +14,7 @@ from posix_parity import temp_dir from posix_parity import touch -def get_errno(func): - try: - func() - return 0 - except OSError as exc: - return exc.errno +SKIP = 77 def main(): @@ -32,25 +26,54 @@ def main(): try: orig_moveonenospc = mergerfs_get_option(mount, "moveonenospc") - except (PermissionError, FileNotFoundError, OSError): - return 0 + except (PermissionError, FileNotFoundError): + print("moveonenospc runtime option unavailable", end="") + return SKIP + except OSError as exc: + if exc.errno == errno.EOPNOTSUPP: + print("moveonenospc runtime option unsupported", end="") + return SKIP + raise branches = mergerfs_branches(mount) if len(branches) < 2: - return 0 + print("moveonenospc test requires at least 2 branches", end="") + return SKIP + # The documented feature only runs after write returns ENOSPC/EDQUOT. This + # generic mountpoint test cannot safely force a branch into that state. merge_base = temp_dir(mount) try: - mergerfs_set_option(mount, "moveonenospc", "true") - mergerfs_set_option(mount, "func.moveonenospc", "mfs") + mergerfs_set_option(mount, "moveonenospc", "mfs") + if mergerfs_get_option(mount, "moveonenospc") != "mfs": + return fail("moveonenospc: failed to set documented policy value") merge_file = join(merge_base, "enospc_test") touch(merge_file, b"test data for enospc") + if not os.path.exists(merge_file): + return fail("moveonenospc: file was not created") + + fullpath = None + try: + fullpath = os.getxattr(merge_file, "user.mergerfs.fullpath").decode() + except OSError: + pass + + if fullpath and not os.path.exists(fullpath): + return fail(f"moveonenospc: fullpath does not exist on disk: {fullpath}") + + with open(merge_file, "r") as f: + content = f.read() + + if content != "test data for enospc": + return fail(f"moveonenospc: file content mismatch: got {content!r}") + mergerfs_set_option(mount, "moveonenospc", orig_moveonenospc) - return 0 + print("moveonenospc ENOSPC path requires a constrained branch", end="") + return SKIP except Exception as exc: try: mergerfs_set_option(mount, "moveonenospc", orig_moveonenospc) @@ -62,4 +85,4 @@ def main(): if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/tests/TEST_no_fuse_hidden b/tests/TEST_no_fuse_hidden index 5a7274f0..2dad6272 100755 --- a/tests/TEST_no_fuse_hidden +++ b/tests/TEST_no_fuse_hidden @@ -4,16 +4,24 @@ import os import sys import tempfile -(fd,filepath) = tempfile.mkstemp(dir=sys.argv[1]) -ino = os.fstat(fd).st_ino +def main(): + (fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.unlink(filepath) + os.unlink(filepath) -for entry in os.scandir(sys.argv[1]): - if entry.inode() == ino: - print("found .fuse_hidden file {}".format(entry.name),end='') - os.close(fd) - sys.exit(1) + found = False + for entry in os.scandir(sys.argv[1]): + if entry.name.startswith(".fuse_hidden"): + print("found .fuse_hidden file {}".format(entry.name), end='') + found = True -os.close(fd) + os.close(fd) + + if found: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/TEST_o_direct b/tests/TEST_o_direct index 8414fba9..fa2dcb50 100755 --- a/tests/TEST_o_direct +++ b/tests/TEST_o_direct @@ -1,25 +1,66 @@ #!/usr/bin/env python3 +import ctypes +import mmap import os +import resource import sys import tempfile -import mmap -import resource -(fd,filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.close(fd) +libc = ctypes.CDLL(None, use_errno=True) +libc.read.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_size_t] +libc.read.restype = ctypes.c_ssize_t -fd = os.open(filepath,os.O_RDWR|os.O_DIRECT|os.O_TRUNC) -os.unlink(filepath) +def aligned_read(fd, size): + buf = mmap.mmap(-1, size) + ptr = ctypes.addressof(ctypes.c_char.from_buffer(buf)) + ctypes.set_errno(0) + rv = libc.read(fd, ctypes.c_void_p(ptr), size) + if rv < 0: + err = ctypes.get_errno() + buf.close() + raise OSError(err, os.strerror(err)) + data = buf[:rv] + buf.close() + return data -data = mmap.mmap(-1, resource.getpagesize()) -os.write(fd,data) +def main(): + (fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.lseek(fd,0,os.SEEK_SET) + os.close(fd) -data = os.read(fd,resource.getpagesize()) + fd = os.open(filepath, os.O_RDWR|os.O_DIRECT|os.O_TRUNC) -os.close(fd) + os.unlink(filepath) + + size = resource.getpagesize() + pattern = (b"mergerfs-o-direct" * ((size // 16) + 1))[:size] + buf = mmap.mmap(-1, size) + buf.write(pattern) + buf.seek(0) + + try: + written = os.write(fd, buf) + if written != size: + print(f"O_DIRECT short write: wrote {written}, expected {size}", end="") + return 1 + os.lseek(fd, 0, os.SEEK_SET) + data = aligned_read(fd, size) + if len(data) != size: + print(f"O_DIRECT short read: read {len(data)}, expected {size}", end="") + return 1 + if data != pattern: + print("O_DIRECT data mismatch", end="") + return 1 + finally: + buf.close() + os.close(fd) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/TEST_open_after_unlink b/tests/TEST_open_after_unlink index 0906707b..698dda58 100755 --- a/tests/TEST_open_after_unlink +++ b/tests/TEST_open_after_unlink @@ -24,40 +24,34 @@ def main(): touch(merge_file, b"hello world") fd = os.open(merge_file, os.O_RDWR) - if fd < 0: - return fail("open file for R/W failed") - data = os.read(fd, 4096) - if data != b"hello world": + try: + data = os.read(fd, 4096) + if data != b"hello world": + return fail("read before unlink: expected 'hello world', got {!r}".format(data)) + + os.unlink(merge_file) + + fd2 = os.dup(fd) + try: + os.lseek(fd2, 0, os.SEEK_SET) + data2 = os.read(fd2, 4096) + if data2 != b"hello world": + return fail("read after unlink via dup: expected 'hello world', got {!r}".format(data2)) + + os.lseek(fd, 0, os.SEEK_SET) + written = os.write(fd, b"modified") + if written != len(b"modified"): + return fail("write after unlink: wrote {}, expected {}".format(written, len(b"modified"))) + + os.lseek(fd, 0, os.SEEK_SET) + data3 = os.read(fd, 4096) + if not data3.startswith(b"modified"): + return fail("read back written data: got {!r} after writing 'modified'".format(data3)) + finally: + os.close(fd2) + finally: os.close(fd) - return fail(f"read before unlink: expected 'hello world', got {data!r}") - - os.unlink(merge_file) - - fd2 = os.dup(fd) - os.lseek(fd2, 0, os.SEEK_SET) - data2 = os.read(fd2, 4096) - if data2 != b"hello world": - os.close(fd2) - os.close(fd) - return fail(f"read after unlink via dup: expected 'hello world', got {data2!r}") - - os.lseek(fd, 0, os.SEEK_SET) - written = os.write(fd, b"modified") - if written != len(b"modified"): - os.close(fd2) - os.close(fd) - return fail(f"write after unlink: wrote {written}, expected {len(b'modified')}") - - os.lseek(fd, 0, os.SEEK_SET) - data3 = os.read(fd, 4096) - if not data3.startswith(b"modified"): - os.close(fd2) - os.close(fd) - return fail(f"read back written data: got {data3!r} after writing 'modified'") - - os.close(fd2) - os.close(fd) return 0 except Exception as exc: diff --git a/tests/TEST_policy_lup b/tests/TEST_policy_lup index 163dd4e8..737c5482 100755 --- a/tests/TEST_policy_lup +++ b/tests/TEST_policy_lup @@ -2,7 +2,6 @@ import os import sys -import tempfile from posix_parity import cleanup_dir from posix_parity import fail @@ -14,6 +13,42 @@ from posix_parity import temp_dir from posix_parity import touch +SKIP = 77 + + +def used_percent(path): + st = os.statvfs(path) + if st.f_blocks == 0: + return 1.0 + used = st.f_blocks - st.f_bfree + return used / st.f_blocks + + +def branch_for_fullpath(branches, fullpath): + fullpath = os.path.realpath(fullpath) + for branch in branches: + branch = os.path.realpath(branch) + try: + common = os.path.commonpath([branch, fullpath]) + except ValueError: + continue + if common == branch: + return branch + return None + + +def least_used_branches(branches, rel_dir): + scored = [] + for branch in branches: + path = join(branch, rel_dir) + if os.path.isdir(path): + scored.append((used_percent(path), os.path.realpath(branch))) + if not scored: + return set() + min_used = min(score for score, _ in scored) + return {branch for score, branch in scored if score == min_used} + + def main(): if len(sys.argv) != 2: print("usage: TEST_policy_lup ", file=sys.stderr) @@ -24,38 +59,53 @@ def main(): try: orig_create = mergerfs_get_option(mount, "func.create") except (PermissionError, FileNotFoundError, OSError): - return 0 + print("LUP create policy option unavailable", end="") + return SKIP try: orig_search = mergerfs_get_option(mount, "func.search") except (PermissionError, FileNotFoundError, OSError): - return 0 + print("LUP search policy option unavailable", end="") + return SKIP - try: - orig_action = mergerfs_get_option(mount, "func.action") - except (PermissionError, FileNotFoundError, OSError): - return 0 + branches = mergerfs_branches(mount) + if len(branches) < 2: + print("LUP policy test requires at least 2 branches", end="") + return SKIP merge_base = temp_dir(mount) + rel_base = os.path.relpath(merge_base, mount) try: + for branch in branches: + os.makedirs(join(branch, rel_base), exist_ok=True) + mergerfs_set_option(mount, "func.create", "lup") mergerfs_set_option(mount, "func.search", "lup") path = join(merge_base, "lup_test_file") + expected = least_used_branches(branches, rel_base) touch(path, b"lup create test") if not os.path.exists(path): return fail("LUP create: file not created") - fullpath = os.getxattr(path, "user.mergerfs.fullpath") + fullpath = os.getxattr(path, "user.mergerfs.fullpath").decode() if not fullpath: return fail("LUP create: no fullpath attribute") + actual = branch_for_fullpath(branches, fullpath) + if actual not in expected: + return fail(f"LUP create: selected {actual}, expected one of {sorted(expected)}") path2 = join(merge_base, "lup_test_file2") - touch(path2, b"lup search test") - fullpath2 = os.getxattr(path2, "user.mergerfs.fullpath") + for branch in branches: + touch(join(branch, rel_base, "lup_test_file2"), b"lup search test") + expected = least_used_branches(branches, rel_base) + fullpath2 = os.getxattr(path2, "user.mergerfs.fullpath").decode() if not fullpath2: return fail("LUP search: no fullpath attribute") + actual = branch_for_fullpath(branches, fullpath2) + if actual not in expected: + return fail(f"LUP search: selected {actual}, expected one of {sorted(expected)}") mergerfs_set_option(mount, "func.create", orig_create) mergerfs_set_option(mount, "func.search", orig_search) @@ -67,7 +117,9 @@ def main(): return fail(str(exc)) finally: cleanup_dir(merge_base) + for branch in branches: + cleanup_dir(join(branch, rel_base)) if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/tests/TEST_posix_access b/tests/TEST_posix_access index 0054585d..5aae74c5 100755 --- a/tests/TEST_posix_access +++ b/tests/TEST_posix_access @@ -68,6 +68,9 @@ def main(): if err: return fail(err) + os.chmod(merge_file, 0o644) + os.chmod(native_file, 0o644) + return 0 finally: cleanup_dir(merge_base) diff --git a/tests/TEST_posix_chmod b/tests/TEST_posix_chmod index 67d72fd9..e2b6f429 100755 --- a/tests/TEST_posix_chmod +++ b/tests/TEST_posix_chmod @@ -84,7 +84,7 @@ def main(): if os.geteuid() != 0: err = compare_calls( - "chmod EPERM setuid", + "chmod setuid parity", lambda: os.chmod(merge_file, stat.S_ISUID | 0o644), lambda: os.chmod(native_file, stat.S_ISUID | 0o644), ) diff --git a/tests/TEST_posix_copy_file_range b/tests/TEST_posix_copy_file_range index dbacff11..698d85ef 100755 --- a/tests/TEST_posix_copy_file_range +++ b/tests/TEST_posix_copy_file_range @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import errno import os import sys import tempfile @@ -79,7 +80,9 @@ def main(): os.close(ndfd) # Exercise very large copy lengths to validate the 64-bit copy result - # path when available. Use sparse files to keep runtime and disk usage low. + # path when available. Use sparse files to keep disk usage low. + # NOTE: 5GB+ length may be slow on some filesystems; targets + # ENOSPC/EFBIG early if space is insufficient. large_len = (5 << 30) + 4096 merge_large_src = join(merge_base, "src-large") merge_large_dst = join(merge_base, "dst-large") @@ -95,12 +98,15 @@ def main(): nsfd = os.open(native_large_src, os.O_RDWR) ndfd = os.open(native_large_dst, os.O_RDWR) try: - # Make sparse files with one non-zero byte at the end so both sides - # have comparable logical contents. - os.ftruncate(msfd, large_len) - os.ftruncate(nsfd, large_len) - os.pwrite(msfd, b"z", large_len - 1) - os.pwrite(nsfd, b"z", large_len - 1) + try: + os.ftruncate(msfd, large_len) + os.ftruncate(nsfd, large_len) + os.pwrite(msfd, b"z", large_len - 1) + os.pwrite(nsfd, b"z", large_len - 1) + except OSError as exc: + if exc.errno in (errno.ENOSPC, errno.EFBIG): + return 0 + raise err = compare_calls( "copy_file_range large sparse", diff --git a/tests/TEST_posix_create_mknod b/tests/TEST_posix_create_mknod index f9a37455..caf41750 100755 --- a/tests/TEST_posix_create_mknod +++ b/tests/TEST_posix_create_mknod @@ -53,6 +53,7 @@ def main(): "create EEXIST", lambda: os.open(merge_exist, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o640), lambda: os.open(native_exist, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o640), + close_fds=True, ) if err: return fail(err) diff --git a/tests/TEST_posix_fsync_flush b/tests/TEST_posix_fsync_flush index 82f3fe13..d49ad3ca 100755 --- a/tests/TEST_posix_fsync_flush +++ b/tests/TEST_posix_fsync_flush @@ -40,8 +40,8 @@ def main(): try: os.lseek(mfd, 0, os.SEEK_END) os.lseek(nfd, 0, os.SEEK_END) - os.write(mfd, b"-merge") - os.write(nfd, b"-native") + os.write(mfd, b"-appended") + os.write(nfd, b"-appended") err = compare_calls("fsync file", lambda: os.fsync(mfd), lambda: os.fsync(nfd)) if err: @@ -72,8 +72,8 @@ def main(): with open(merge_file, "rb") as mf, open(native_file, "rb") as nf: mdata = mf.read() ndata = nf.read() - if not (mdata.startswith(b"abc") and ndata.startswith(b"abc")): - return fail(f"flush content prefix mismatch mergerfs={mdata!r} native={ndata!r}") + if mdata != ndata: + return fail(f"flush content mismatch mergerfs={mdata!r} native={ndata!r}") return 0 finally: diff --git a/tests/TEST_posix_getattr_fgetattr b/tests/TEST_posix_getattr_fgetattr index 77ea97b4..1ef970ab 100755 --- a/tests/TEST_posix_getattr_fgetattr +++ b/tests/TEST_posix_getattr_fgetattr @@ -11,15 +11,15 @@ from posix_parity import compare_calls from posix_parity import fail from posix_parity import join from posix_parity import should_compare_inode -from posix_parity import stat_cmp_basic from posix_parity import temp_dir from posix_parity import touch def st_cmp(lhs, rhs): return ( - stat_cmp_basic(lhs, rhs) - and stat.S_IFMT(lhs.st_mode) == stat.S_IFMT(rhs.st_mode) + stat.S_IFMT(lhs.st_mode) == stat.S_IFMT(rhs.st_mode) + and stat.S_IMODE(lhs.st_mode) == stat.S_IMODE(rhs.st_mode) + and lhs.st_size == rhs.st_size and lhs.st_nlink == rhs.st_nlink ) diff --git a/tests/TEST_posix_locking b/tests/TEST_posix_locking index ea447880..104d956f 100755 --- a/tests/TEST_posix_locking +++ b/tests/TEST_posix_locking @@ -1,8 +1,10 @@ #!/usr/bin/env python3 import fcntl +import errno import os import struct +import subprocess import sys import tempfile @@ -19,7 +21,36 @@ def pack_flock(l_type, l_whence=0, l_start=0, l_len=0, l_pid=0): return struct.pack("hhqqi", l_type, l_whence, l_start, l_len, l_pid) +def try_process_lock(path): + fd = os.open(path, os.O_RDWR) + try: + try: + fcntl.fcntl(fd, fcntl.F_SETLK, pack_flock(fcntl.F_WRLCK)) + return 0 + except OSError as exc: + return exc.errno + finally: + os.close(fd) + + +def child_lock_errno(path): + proc = subprocess.run( + [sys.executable, __file__, "--try-process-lock", path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or f"child lock helper failed: {proc.returncode}") + return int(proc.stdout.strip()) + + def main(): + if len(sys.argv) == 3 and sys.argv[1] == "--try-process-lock": + print(try_process_lock(sys.argv[2])) + return 0 + if len(sys.argv) != 2: print("usage: TEST_posix_locking ", file=sys.stderr) return 1 @@ -74,9 +105,12 @@ def main(): if err: return fail(err) - err = compare_calls("fcntl F_SETLK write lock second fd", lambda: fcntl.fcntl(mfd2, fcntl.F_SETLK, wrlk), lambda: fcntl.fcntl(nfd2, fcntl.F_SETLK, wrlk)) - if err: - return fail(err) + m_lock_errno = child_lock_errno(merge_file) + n_lock_errno = child_lock_errno(native_file) + if m_lock_errno != n_lock_errno: + return fail(f"fcntl F_SETLK child contention errno mismatch mergerfs={m_lock_errno} native={n_lock_errno}") + if m_lock_errno not in (errno.EACCES, errno.EAGAIN): + return fail(f"fcntl F_SETLK child contention expected lock conflict got errno={m_lock_errno}") err = compare_calls("fcntl F_SETLK unlock", lambda: fcntl.fcntl(mfd1, fcntl.F_SETLK, unlck), lambda: fcntl.fcntl(nfd1, fcntl.F_SETLK, unlck)) if err: @@ -88,8 +122,8 @@ def main(): os.close(bad_n) err = compare_calls( "fcntl EBADF", - lambda: fcntl.fcntl(bad_m, fcntl.F_SETLK, wrlk), - lambda: fcntl.fcntl(bad_n, fcntl.F_SETLK, wrlk), + lambda: fcntl.fcntl(bad_m, fcntl.F_GETFD), + lambda: fcntl.fcntl(bad_n, fcntl.F_GETFD), ) if err: return fail(err) diff --git a/tests/TEST_posix_statfs b/tests/TEST_posix_statfs index c5524a10..420e5dd7 100755 --- a/tests/TEST_posix_statfs +++ b/tests/TEST_posix_statfs @@ -14,10 +14,10 @@ from posix_parity import temp_dir def statvfs_cmp(lhs, rhs): return ( lhs.f_namemax == rhs.f_namemax + and lhs.f_bsize == rhs.f_bsize + and lhs.f_frsize == rhs.f_frsize and lhs.f_bsize > 0 - and rhs.f_bsize > 0 and lhs.f_frsize > 0 - and rhs.f_frsize > 0 ) diff --git a/tests/TEST_posix_tmpfile b/tests/TEST_posix_tmpfile index 19794822..efffd6a3 100755 --- a/tests/TEST_posix_tmpfile +++ b/tests/TEST_posix_tmpfile @@ -17,7 +17,8 @@ def open_tmpfile(dirpath): def errno_of(call): try: - call() + fd = call() + os.close(fd) return 0 except OSError as exc: return exc.errno @@ -44,6 +45,8 @@ def main(): m_err = errno_of(lambda: open_tmpfile(merge_dir)) n_err = errno_of(lambda: open_tmpfile(native_dir)) + # mergerfs does not currently support O_TMPFILE, so EOPNOTSUPP is + # the expected result even when the native filesystem supports it. if m_err == errno.EOPNOTSUPP: return 0 if m_err != n_err: @@ -54,7 +57,11 @@ def main(): return 0 mfd = open_tmpfile(merge_dir) - nfd = open_tmpfile(native_dir) + try: + nfd = open_tmpfile(native_dir) + except OSError: + os.close(mfd) + return fail("O_TMPFILE: native open failed after merge succeeded") try: mw = os.write(mfd, b"tmpfile-data") nw = os.write(nfd, b"tmpfile-data") diff --git a/tests/TEST_posix_xattr b/tests/TEST_posix_xattr index fafe9760..f909e1cb 100755 --- a/tests/TEST_posix_xattr +++ b/tests/TEST_posix_xattr @@ -36,7 +36,7 @@ def main(): return 1 mount = sys.argv[1] - xname = "user.mergerfs.posix" + xname = "user.posix_parity" xvalue = b"parity-check" with tempfile.TemporaryDirectory() as native: diff --git a/tests/TEST_posix_xattr_matrix b/tests/TEST_posix_xattr_matrix index 579ce8a8..ce0400d4 100755 --- a/tests/TEST_posix_xattr_matrix +++ b/tests/TEST_posix_xattr_matrix @@ -63,6 +63,30 @@ def list_has_cmp(path_m, path_n, xname): ) +def lsetxattr(path, name, value): + if hasattr(os, "lsetxattr"): + return os.lsetxattr(path, name, value) + return os.setxattr(path, name, value, follow_symlinks=False) + + +def lgetxattr(path, name): + if hasattr(os, "lgetxattr"): + return os.lgetxattr(path, name) + return os.getxattr(path, name, follow_symlinks=False) + + +def llistxattr(path): + if hasattr(os, "llistxattr"): + return os.llistxattr(path) + return os.listxattr(path, follow_symlinks=False) + + +def lremovexattr(path, name): + if hasattr(os, "lremovexattr"): + return os.lremovexattr(path, name) + return os.removexattr(path, name, follow_symlinks=False) + + def main(): if len(sys.argv) != 2: print("usage: TEST_posix_xattr_matrix ", file=sys.stderr) @@ -106,7 +130,7 @@ def main(): ("dir", dir_m, dir_n), ] - xbase = "user.mergerfs.matrix" + xbase = "user.xattr_matrix" for obj_name, path_m, path_n in objs: xname = f"{xbase}.{obj_name}" @@ -176,40 +200,39 @@ def main(): return fail(err) lxname = f"{xbase}.link" - if all(hasattr(os, fn) for fn in ("lsetxattr", "lgetxattr", "llistxattr", "lremovexattr")): - err = compare( - "lsetxattr symlink default", - lambda: os.lsetxattr(link_m, lxname, b"lv1"), - lambda: os.lsetxattr(link_n, lxname, b"lv1"), - ) - if err: - return fail(err) + err = compare( + "lsetxattr symlink default", + lambda: lsetxattr(link_m, lxname, b"lv1"), + lambda: lsetxattr(link_n, lxname, b"lv1"), + ) + if err: + return fail(err) - err = compare( - "lgetxattr symlink", - lambda: os.lgetxattr(link_m, lxname), - lambda: os.lgetxattr(link_n, lxname), - lambda a, b: a == b, - ) - if err: - return fail(err) + err = compare( + "lgetxattr symlink", + lambda: lgetxattr(link_m, lxname), + lambda: lgetxattr(link_n, lxname), + lambda a, b: a == b, + ) + if err: + return fail(err) - err = compare( - "llistxattr has symlink key", - lambda: os.llistxattr(link_m), - lambda: os.llistxattr(link_n), - lambda a, b: ((lxname in a) == (lxname in b)), - ) - if err: - return fail(err) + err = compare( + "llistxattr has symlink key", + lambda: llistxattr(link_m), + lambda: llistxattr(link_n), + lambda a, b: ((lxname in a) == (lxname in b)), + ) + if err: + return fail(err) - err = compare( - "lremovexattr symlink", - lambda: os.lremovexattr(link_m, lxname), - lambda: os.lremovexattr(link_n, lxname), - ) - if err: - return fail(err) + err = compare( + "lremovexattr symlink", + lambda: lremovexattr(link_m, lxname), + lambda: lremovexattr(link_n, lxname), + ) + if err: + return fail(err) return 0 finally: diff --git a/tests/TEST_readlink_semantics b/tests/TEST_readlink_semantics index c8529e6e..c6adfb7b 100755 --- a/tests/TEST_readlink_semantics +++ b/tests/TEST_readlink_semantics @@ -187,7 +187,6 @@ def main(): return 0 finally: - cleanup_dir(merge_base) try: os.chmod(paths["merge_private_dir"], 0o700) except (FileNotFoundError, PermissionError): @@ -198,30 +197,7 @@ def main(): except (FileNotFoundError, PermissionError): pass - for p in ( - paths["merge_private_link"], - paths["native_private_link"], - paths["merge_link"], - paths["native_link"], - paths["merge_regular"], - paths["native_regular"], - paths["merge_notdir"], - paths["native_notdir"], - paths["merge_loop"], - paths["native_loop"], - ): - try: - os.unlink(p) - except FileNotFoundError: - pass - - for p in (paths["merge_private_dir"], paths["native_private_dir"]): - try: - os.rmdir(p) - except FileNotFoundError: - pass - except OSError: - pass + cleanup_dir(merge_base) if __name__ == "__main__": diff --git a/tests/TEST_unlink_rename b/tests/TEST_unlink_rename index e18fe397..5d791036 100755 --- a/tests/TEST_unlink_rename +++ b/tests/TEST_unlink_rename @@ -4,17 +4,38 @@ import os import sys import tempfile -(srcfd,src) = tempfile.mkstemp(dir=sys.argv[1]) -(tgtfd,tgt) = tempfile.mkstemp(dir=sys.argv[1]) -os.fstat(srcfd) -os.fstat(tgtfd) +def main(): + (srcfd, src) = tempfile.mkstemp(dir=sys.argv[1]) + (tgtfd, tgt) = tempfile.mkstemp(dir=sys.argv[1]) -os.unlink(tgt) -os.rename(src,tgt) + st_src_before = os.fstat(srcfd) + st_tgt_before = os.fstat(tgtfd) -os.fstat(srcfd) -os.fstat(tgtfd) + os.unlink(tgt) + os.rename(src, tgt) -os.close(srcfd) -os.close(tgtfd) + st_src_after = os.fstat(srcfd) + st_tgt_after = os.fstat(tgtfd) + + if st_src_after.st_size != st_src_before.st_size: + os.close(srcfd) + os.close(tgtfd) + print("rename: src fd size changed before={} after={}".format( + st_src_before.st_size, st_src_after.st_size), end='') + return 1 + + if st_tgt_after.st_ino != st_tgt_before.st_ino: + os.close(srcfd) + os.close(tgtfd) + print("rename: tgt fd inode changed after unlink+rename expected={} got={}".format( + st_tgt_before.st_ino, st_tgt_after.st_ino), end='') + return 1 + + os.close(srcfd) + os.close(tgtfd) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/TEST_use_fallocate_after_unlink b/tests/TEST_use_fallocate_after_unlink index a3f233fd..2368b69a 100755 --- a/tests/TEST_use_fallocate_after_unlink +++ b/tests/TEST_use_fallocate_after_unlink @@ -4,12 +4,32 @@ import os import sys import tempfile -(fd,filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.posix_fallocate(fd,0,1024) +def main(): + (fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.unlink(filepath) + os.posix_fallocate(fd, 0, 1024) -os.posix_fallocate(fd,0,1024) + st1 = os.fstat(fd) + if st1.st_size < 1024: + os.close(fd) + os.unlink(filepath) + print("fallocate before unlink: expected size >= 1024, got {}".format(st1.st_size), end='') + return 1 -os.close(fd) + os.unlink(filepath) + + os.posix_fallocate(fd, 1024, 2048) + + st2 = os.fstat(fd) + if st2.st_size < 1024 + 2048: + os.close(fd) + print("fallocate after unlink: expected size >= 3072, got {}".format(st2.st_size), end='') + return 1 + + os.close(fd) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/TEST_use_fchmod_after_unlink b/tests/TEST_use_fchmod_after_unlink index 3b2d582e..d66bfc51 100755 --- a/tests/TEST_use_fchmod_after_unlink +++ b/tests/TEST_use_fchmod_after_unlink @@ -1,15 +1,36 @@ #!/usr/bin/env python3 import os +import stat import sys import tempfile -(fd,filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.fchmod(fd,0o777) +def main(): + (fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.unlink(filepath) + os.fchmod(fd, 0o700) -os.fchmod(fd,0o777) + st1 = os.fstat(fd) + if stat.S_IMODE(st1.st_mode) != 0o700: + os.close(fd) + os.unlink(filepath) + print("fchmod before unlink: expected mode 0o700, got 0o{:o}".format(stat.S_IMODE(st1.st_mode)), end='') + return 1 -os.close(fd) + os.unlink(filepath) + + os.fchmod(fd, 0o755) + + st2 = os.fstat(fd) + if stat.S_IMODE(st2.st_mode) != 0o755: + os.close(fd) + print("fchmod after unlink: expected mode 0o755, got 0o{:o}".format(stat.S_IMODE(st2.st_mode)), end='') + return 1 + + os.close(fd) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/TEST_use_fchown_after_unlink b/tests/TEST_use_fchown_after_unlink index 73529a3f..b4219078 100755 --- a/tests/TEST_use_fchown_after_unlink +++ b/tests/TEST_use_fchown_after_unlink @@ -4,12 +4,34 @@ import os import sys import tempfile -(fd,filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.fchown(fd,os.getuid(),os.getgid()) +def main(): + (fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.unlink(filepath) + os.fchown(fd, os.getuid(), os.getgid()) -os.fchown(fd,os.getuid(),os.getgid()) + st1 = os.fstat(fd) + if st1.st_uid != os.getuid() or st1.st_gid != os.getgid(): + os.close(fd) + os.unlink(filepath) + print("fchown before unlink: expected uid={} gid={}, got uid={} gid={}".format( + os.getuid(), os.getgid(), st1.st_uid, st1.st_gid), end='') + return 1 -os.close(fd) + os.unlink(filepath) + + os.fchown(fd, os.getuid(), os.getgid()) + + st2 = os.fstat(fd) + if st2.st_uid != os.getuid() or st2.st_gid != os.getgid(): + os.close(fd) + print("fchown after unlink: expected uid={} gid={}, got uid={} gid={}".format( + os.getuid(), os.getgid(), st2.st_uid, st2.st_gid), end='') + return 1 + + os.close(fd) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/TEST_use_fstat_after_unlink b/tests/TEST_use_fstat_after_unlink index 00dc54d0..cae3ad52 100755 --- a/tests/TEST_use_fstat_after_unlink +++ b/tests/TEST_use_fstat_after_unlink @@ -4,13 +4,42 @@ import os import sys import tempfile -(fd,filepath) = tempfile.mkstemp(dir=sys.argv[1]) -x = os.lstat(filepath) -x = os.fstat(fd) +def main(): + (fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.unlink(filepath) + x_before = os.lstat(filepath) + x = os.fstat(fd) -y = os.fstat(fd) + if x.st_size != x_before.st_size: + os.close(fd) + os.unlink(filepath) + print("fstat before unlink: size mismatch lstat={} fstat={}".format(x_before.st_size, x.st_size), end='') + return 1 -os.close(fd) + if x.st_mode != x_before.st_mode: + os.close(fd) + os.unlink(filepath) + print("fstat before unlink: mode mismatch lstat={} fstat={}".format(x_before.st_mode, x.st_mode), end='') + return 1 + + os.unlink(filepath) + + y = os.fstat(fd) + + if y.st_nlink != 0: + os.close(fd) + print("fstat after unlink: expected nlink=0, got {}".format(y.st_nlink), end='') + return 1 + + if y.st_size != x.st_size: + os.close(fd) + print("fstat after unlink: size changed from {} to {}".format(x.st_size, y.st_size), end='') + return 1 + + os.close(fd) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/TEST_use_ftruncate_after_unlink b/tests/TEST_use_ftruncate_after_unlink index a991c831..3567e6f4 100755 --- a/tests/TEST_use_ftruncate_after_unlink +++ b/tests/TEST_use_ftruncate_after_unlink @@ -4,12 +4,32 @@ import os import sys import tempfile -(fd,filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.ftruncate(fd,1024) +def main(): + (fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.unlink(filepath) + os.ftruncate(fd, 1024) -os.ftruncate(fd,1024) + st1 = os.fstat(fd) + if st1.st_size != 1024: + os.close(fd) + os.unlink(filepath) + print("ftruncate before unlink: expected size 1024, got {}".format(st1.st_size), end='') + return 1 -os.close(fd) + os.unlink(filepath) + + os.ftruncate(fd, 2048) + + st2 = os.fstat(fd) + if st2.st_size != 2048: + os.close(fd) + print("ftruncate after unlink: expected size 2048, got {}".format(st2.st_size), end='') + return 1 + + os.close(fd) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/TEST_use_futimens_after_unlink b/tests/TEST_use_futimens_after_unlink index 12ab5fb6..6b320b0c 100755 --- a/tests/TEST_use_futimens_after_unlink +++ b/tests/TEST_use_futimens_after_unlink @@ -4,14 +4,38 @@ import os import sys import tempfile -atime = 1234 -mtime = 1234 -(fd,filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.utime(fd,(atime,mtime)) +def main(): + atime = 1234 + mtime = 5678 + (fd, filepath) = tempfile.mkstemp(dir=sys.argv[1]) -os.unlink(filepath) + os.utime(fd, (atime, mtime)) -os.utime(fd,(atime,mtime)) + st1 = os.fstat(fd) + if st1.st_atime != atime or st1.st_mtime != mtime: + os.close(fd) + os.unlink(filepath) + print("futimens before unlink: expected atime={} mtime={}, got atime={} mtime={}".format( + atime, mtime, st1.st_atime, st1.st_mtime), end='') + return 1 -os.close(fd) + os.unlink(filepath) + + atime2 = 9999 + mtime2 = 8888 + os.utime(fd, (atime2, mtime2)) + + st2 = os.fstat(fd) + if st2.st_atime != atime2 or st2.st_mtime != mtime2: + os.close(fd) + print("futimens after unlink: expected atime={} mtime={}, got atime={} mtime={}".format( + atime2, mtime2, st2.st_atime, st2.st_mtime), end='') + return 1 + + os.close(fd) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/posix_parity.py b/tests/posix_parity.py index 9de14a55..40c6717e 100644 --- a/tests/posix_parity.py +++ b/tests/posix_parity.py @@ -38,6 +38,17 @@ def compare_calls(name, merge_call, native_call, value_cmp=None, close_fds=False m_ok, m_val, m_errno = invoke(merge_call) n_ok, n_val, n_errno = invoke(native_call) + if close_fds: + # WARNING: close_fds=True should only be used when the callable + # returns an fd (non-negative int from os.open etc.), NOT when it + # returns arbitrary integers (e.g., byte counts, offsets). + try: + if m_ok: + close_if_fd(m_val) + finally: + if n_ok: + close_if_fd(n_val) + if m_ok != n_ok: return ( f"{name}: success mismatch mergerfs={m_ok} native={n_ok} " @@ -46,15 +57,8 @@ def compare_calls(name, merge_call, native_call, value_cmp=None, close_fds=False if m_errno != n_errno: return f"{name}: errno mismatch mergerfs={m_errno} native={n_errno}" if m_ok and value_cmp is not None and not value_cmp(m_val, n_val): - if close_fds: - close_if_fd(m_val) - close_if_fd(n_val) return f"{name}: value mismatch mergerfs={m_val!r} native={n_val!r}" - if close_fds: - close_if_fd(m_val) - close_if_fd(n_val) - return None diff --git a/tests/run-tests b/tests/run-tests index a8fb658b..b79acc12 100755 --- a/tests/run-tests +++ b/tests/run-tests @@ -5,27 +5,42 @@ import sys import subprocess +SKIP = 77 + + if len(sys.argv) != 2: - print('usage: run-test \n',file=sys.stderr) + print('usage: run-tests \n', file=sys.stderr) sys.exit(1) test_path = os.path.realpath(sys.argv[0]) test_path = os.path.dirname(test_path) +failed = False + for entry in os.scandir(test_path): if not entry.name.startswith('TEST_'): continue try: - print(entry.name + ': ',end='') - fullpath = os.path.join(test_path,entry.name) - args = [fullpath,sys.argv[1]] - rv = subprocess.Popen(args,stdout=subprocess.PIPE) - rv.wait(timeout=10000) - if rv.returncode: - output = rv.stdout.read().decode() - print('FAIL - {}'.format(output)) + print(entry.name + ': ', end='') + fullpath = os.path.join(test_path, entry.name) + args = [fullpath, sys.argv[1]] + rv = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + output, _ = rv.communicate(timeout=120) + if rv.returncode == SKIP: + print('SKIP - {}'.format(output.decode(errors='replace'))) + elif rv.returncode: + print('FAIL - {}'.format(output.decode(errors='replace'))) + failed = True else: print('PASS') + except subprocess.TimeoutExpired: + rv.kill() + rv.wait() + print('FAIL - timeout') + failed = True except Exception as e: print('FAIL - {}'.format(e)) + failed = True + +sys.exit(1 if failed else 0) diff --git a/tests/tests.cpp b/tests/tests.cpp index d81f33d7..ef7aac61 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -1227,7 +1227,7 @@ test_branches_global_minfreespace_live_tracking() { Branches b; - b.from_string("/tmp/a"); + TEST_CHECK(b.from_string("/tmp/a") == 0); // Branch uses pointer to Branches::minfreespace — tracks it live uint64_t initial = b.minfreespace; @@ -1408,6 +1408,12 @@ test_config_nfsopenhack() void test_config_readdir() { + Config cfg; + + TEST_CHECK(cfg.has_key("func.readdir") == true); + + const auto &map = cfg.get_map(); + TEST_CHECK(map.count("func.readdir") > 0); } void @@ -3470,7 +3476,7 @@ test_rnd_rand64_basic() { u64 v1 = RND::rand64(); u64 v2 = RND::rand64(); - TEST_CHECK(v1 != v2 || true); + TEST_CHECK(v1 != v2); } void