std::filesystem::path is wrong for mergerfs's hot path:
- `branch->path / fusepath_` heap-allocates per FUSE op (3 allocs each
on libstdc++; the SSO buffer is 15 bytes and any joined path exceeds it).
- The implicit `operator string_type()` adds another std::string when
passing to `fs::*` wrappers that take `const std::string&`.
- Per-branch loops rebuild fullpath from scratch every iteration.
The new fs::relpath is a fixed 8192-byte (2 * PATH_MAX) inline buffer,
NUL-terminated, never heap-allocates. The rel data is anchored at offset
REL_OFFSET = 4096; the 4096 bytes before the anchor form the "prefix
area" so a long base/prefix can be written there in place via
set_prefix() without disturbing the rel. The remaining ~4096 bytes
after the rel are available for trailing append (filenames in
readdir-style loops, etc.).
set_prefix's cost is O(prefix size) -- only the actually-used bytes
are touched -- so the buffer size is a stack-footprint question, not a
hot-path cost. FUSE worker threads use the pthread default stack
(~8 MB on Linux); a 4-relpath frame in fuse_link/fuse_rename is 32 KB,
~0.4% of the stack budget.
The per-branch hot loop:
fs::relpath fullpath = fusepath_; // one copy of the rel
for (auto branch : branches) {
fullpath.set_prefix(branch->path); // writes only [start, 4096)
fs::syscall(fullpath);
}
set_prefix only rewrites the prefix area; the rel stays anchored. A
cached _rel_size means no recomputation across iterations.
Microbenchmark (8 branches, 30-char fusepath, -O3, 4 M path builds):
variant ns/build allocs/build
-------------------------------------- -------- ------------
origin/master (std::filesystem::path) 158 3.00
fs::relpath assign_concat (single-shot) 9.1 0
fs::relpath set_prefix (loop-optimized) 4.5 0
35x faster, zero allocations in the hot path.
Contract: paths flow through fs::relpath in canonical form -- prefix
without trailing '/', rel without leading '/'. No sanitization;
violations yield double slashes. Mergerfs satisfies this by
construction:
- libfuse's try_get_path() walks leaf-to-root via prepend(), then
calls consolidate_to_rel(strip=true) which drops the leading '/'
that prepend produces.
- branch paths are realpath-canonicalized at config load so they
never have a trailing slash.
Mergerfs has exactly one path-manipulation type: a relative fusepath.
Branch paths and other absolute paths are stored as std::string -- they
are spliced into the prefix area of an fs::relpath at syscall time but
are never themselves mutated as path objects.
Major changes:
- vendored/libfuse/{include/fs_path.hpp,lib/fs_path.cpp}: new
fs::relpath class (8 KB inline, REL anchor at 4096, set_prefix /
clear_prefix / consolidate_to_rel, _rel_size cache, self-aliasing-
safe assign_concat, sticky ENAMETOOLONG via errnum()).
- 30+ FUSE-op call sites converted from `fullpath = branch / fusepath`
to the `set_prefix` loop pattern (fuse_unlink, fuse_chmod,
fuse_chown, fuse_listxattr, fuse_truncate, fuse_utimens,
fuse_setxattr, fuse_rmdir, fuse_removexattr, fuse_link's
preserve-path and create-path loops, fuse_rename's create-path
and preserve-path loops, fuse_statfs FULL mode, fs::findallfiles,
fs::findonfs).
- fs::* syscall wrappers that previously only took
`const std::string&` got `const char*` overloads so fs::relpath's
implicit `operator const char*()` lands cleanly (fs::link,
fs::lutimens, fs::umount_lazy, fs::open_dir_ro, fs::readlink,
fs::lremovexattr, fs::lstatvfs, fs::info, fs::statvfs_cache).
- fs::exists gained a (fs::relpath&, basepath, ...) overload that
calls set_prefix internally so per-branch policy fan-out loops
share a single fullpath, rewriting only the prefix area on each
iteration.
- All policies (epall, epff, eplfs, eplus, epmfs, eppfrd, ff, lup,
msplfs, msplus, mspmfs, msppfrd, newest) refactored to hoist the
fs::relpath fullpath construction out of the per-branch loop and
use the set_prefix-based fs::exists overload.
- fs::inode::ReaddirCalc::_fusepath migrated from std::string to
fs::relpath with the same resize/append API.
- std::filesystem removed entirely from the project: branches.cpp
and option_parser.cpp's exists/is_directory/equivalent calls now
use direct ::stat(); mergerfs_fsck.cpp's recursive_directory_iterator
was replaced with an opendir-based walk.
Tests (tests/tests.cpp): 27 new fs::relpath test cases covering
construction, set_prefix in/out of REL anchor, prefix()/rel()
accessors, clear_prefix, concat canonical form, self-aliasing
(`p /= "x"`), operator/=, resize zero-fill, swap, copy/move,
equality, implicit conversions, lexically_relative, remove_filename,
_rel_size invariant across every mutator, capacity bounds.
Full suite: 213/213 pass.
- Expose the ioprio enabled flag as an atomic for relaxed inline checks
- Inline SetFrom construction so disabled read/write paths avoid a function call
- Move priority application into a slow helper used only when ioprio is enabled
- Replace atomic shared pointer loads/stores with shared/unique locking.
- Store the configured readdir mode directly as std::string.
- Copy the active ReadDir implementation under lock before invoking it.
- Use relaxed increment under the map bucket lock when duplicating file handles.
- Use acq_rel decrement when releasing and checking for final references.
- Load moved ref_count with relaxed ordering during map insertion.
- Explain that the mutex is per readdir call.
- Document that only dirent deduplication and list append are serialized.
- Clarify that per-branch opendir and getdents64 work runs outside the critical section.
- Replace the per-file Mutex with std::shared_mutex for shared write access.
- Run direct and cached writes under a shared lock while the fd is stable.
- Retry failed writes under a unique lock before move-on-ENOSPC relocation.
Important changes:
- Switch mergerfs and vendored libfuse Makefiles to -std=c++20.
- Select the latest available GCC/G++ or toolset in container builds.
- Verify compiler C++20 support during package installation.
- Fix dynamic SysLog formatting for fmt C++20 checks.
No functional change: libfuse/helper.cpp (fuse_daemonize,
fuse_helper_opt_proc, fuse_parse_cmdline) and mergerfs
fuse_init.cpp (_want helpers) reformatted to match the project's
GNU-style brace placement and aligned parameter lists.
libfuse previously initialized its debug log filepath to
"/dev/stderr", forcing the config layer to reopen stderr on every
load whether or not the user asked for it. Default the libfuse log
path to "" and have the mergerfs config layer only forward the
request to libfuse when the user supplied an explicit path.
Both helpers previously called strcmp/pointer-dereference directly
on their arguments, so a stray NULL (e.g. an unset env string or an
absent xattr) crashed the process. Guard both helpers: same()
returns true only if both pointers are equal, startswith() returns
false if either side is NULL.
The create policy can legitimately return success with an empty
destination list (e.g. every candidate rejected). Previously we
then dereferenced dst_branch[0] and faulted. Return -ENOSPC so the
original write that triggered the move propagates a sensible errno
to userspace.
The branch-aggregation code computed
blocks * f_frsize / min_frsize
as u64 arithmetic. With large filesystems the intermediate product
blocks * f_frsize overflows 64 bits long before the division can
bring the result back into range, producing garbage bavail/bfree
values reported to userspace.
Compute the scale factor once in long double and multiply each
count by it, so the only narrowing happens in the final cast back
to fsblkcnt_t.
Old code only null-terminated when the returned/copied length was
strictly less than the caller-supplied bufsize_, leaving the buffer
unterminated on an exact-fill. Since buf_ is consumed as a C string,
reserve one byte for the terminator up front and unconditionally
terminate.
- ReadDir::_initialized is set by the first thread through the
lazy-init path and read by every subsequent caller; make it
std::atomic<bool> so the read/write pair has a happens-before
relationship.
- The 'no valid mergerfs branch found' error directory entry used
strcpy into a fixed-size dirent buffer. The message happens to
fit today but the buffer is only NAME_MAX wide; switch to a
length-bounded strncpy driven by the constexpr string size.
- The getdents implementations for seq/cor/cosr allocated a msgbuf
without checking for allocation failure and then dereferenced it.
Return -ENOMEM when the allocation fails.
The fake-root and control-file fast paths returned a synthetic stat
without touching the timeout struct. Callers that took those paths
ended up sending whatever the timeout struct happened to hold,
which is uninitialized memory for the control-file path and
whatever a prior call left there for the fake-root path. Kernel
behavior is undefined when the timeout is garbage.
Pass timeout_ into both helpers and set entry/attr from the
configured cache values.
Previously most FUSE ops took a reference to the live cfg.branches
object. With atomic_load now producing a shared_ptr snapshot, pass
it by value: the op works against a single stable view of the
configuration for its whole lifetime, immune to concurrent
reconfiguration.
Also bail out with -ENOENT when the policy returns an empty branch
set rather than dereferencing branches[0]. This was reachable if a
policy rejected every branch (e.g. everything readonly while
looking for a writable target).
Covers access, chmod, chown, create, getattr, getxattr, ioctl,
link, listxattr, mkdir, mknod, open, readdir (cor/cosr),
readlink, removexattr, rename, rmdir, setxattr, statfs, statx,
symlink, truncate, unlink, utimens.
While here, in _getattr only dereference st_->st_mode when lstat
succeeded (it is otherwise uninitialized).
Several FUSE ops fell back to looking up a FileInfo via
state.open_files when the kernel-provided fh was zero, returning
-ENOENT if the lookup failed. This path papered over handle-tracking
bugs and added cost on every call. Use the handle directly and
return -EBADF when it is invalid - the kernel should always hand us
a valid fh for an open resource.
Also simplify FileInfo::from_fh / DirInfo::from_fh and their to_fh
helpers: callers now handle the null-pointer case explicitly so the
ASSERT_NOT_NULL checks are redundant.
Covers fallocate, fchmod, fchown, fgetattr, flock, ftruncate,
futimens, read, release, releasedir, write, statx, ioctl (file +
dir), readdir (sequential, concurrent-on-read,
concurrent-on-search), and the passthrough fast paths in
create/open.
Branches using the default minfreespace held a reference to their
owning Impl's _default_minfreespace. After a copy or move the
pointer still referenced the source Impl, which could become
invalid or diverge after further configuration changes. Switch to
a pointer and re-link it to the destination Impl on copy/move.
While here, convert set_branch_mode to do an atomic_store of a
newly constructed Impl rather than mutating the shared pointer
target in place. Readers holding an atomic_load snapshot are no
longer affected by a concurrent mode change.
ref_count is read and written from multiple FUSE worker threads
during open/release. Make it std::atomic<int> and provide a move
constructor so OpenFile remains movable when the underlying atomic
is non-movable.
rwlock.hpp was left over after migrating to atomic shared pointers
for branches configuration. No code references ReadGuard/WriteGuard
any longer, so drop the header and its stale includes.