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.
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.
- fuse_opt: reformat process_opt_param to project style; return the
rv from sscanf through a named local so the error branch prints
the failing option cleanly.
- cpu.cpp: when parsing /sys/devices/system/cpu/*/topology/core_id,
bail out cleanly on stream read failure rather than recording an
uninitialized core_id.
- fusermount: free(copy) on the success path of parsing the mount
option (previously leaked on every mount), and replace the fixed-
buffer sprintf into procfile with snprintf bounded by the buffer
size.
- maintenance_thread: widen the iteration count from int to u64
so a long-lived daemon doesn't wrap after ~68 years of ticks
(real effect: silence signed-overflow warnings under UBSan).
- ThreadPool::destroy used to iterate _threads directly while another
thread could be adjusting the same vector. Snapshot the vector
under the mutex and iterate the local copy for pthread_cancel /
pthread_join.
- ObjPool::construct now returns NULL when the pooled allocator
returns NULL instead of invoking placement-new on a null
pointer. In destroy, compute to_node(obj) before running the
destructor - the destructor may scrub bytes the to_node helper
reads.
- In the async/sync workers in fuse_loop, msgbuf_alloc can return
NULL under memory pressure. Fail the worker cleanly with a
diagnostic rather than dereferencing. Also introduce a small
_nproc() helper that treats hardware_concurrency()==0 as 1, so
the downstream min/max arithmetic doesn't produce a zero thread
count.
The signal handler reads the global session pointer from an async-
signal context while another thread installs/removes the session
via set/remove_signal_handlers. Use std::atomic<fuse_session*> with
acquire/release ordering so the handler either sees NULL or a fully
published session, never a torn pointer.
Collapse get_node/get_node_nocheck into a single function that
returns NULL when the node is absent rather than abort()ing.
Callers that previously assumed non-NULL now check the return value
and either reply ESTALE/ENOMEM or fall through gracefully; a few
places where the node is load-bearing (e.g. unhash during remove)
use fatal::abort with a descriptive message.
Switch mutex_lock / mutex_unlock pairs to mutex_lockguard so early
returns on error paths no longer leak the lock, and add NULL-safety
around fuse_dh in readdir/readdir_plus, fuse_dirents_init error
returns, the getattr path when both name and fh are empty, and the
msgbuf allocation in fuse_lib_read.
Pull the new xstrdup/xmalloc wrappers from xalloc.hpp for the root
node name so the common-case OOM path terminates with a diagnostic
instead of a silent corrupted node.
Also guard fuse_session_destroy against a NULL session - called
from error-recovery paths.
The low-level init/destroy callbacks were passed a userdata pointer
that mergerfs never consumed - the caller threaded it through
fuse_lowlevel_new only to hand it back unchanged. Remove the
parameter from the ops struct, from fuse_lowlevel_new(_common),
and from the internal fuse_ll struct; update fuse_lib_init and
fuse_lib_destroy accordingly.
Also drop fuse_reply_ioctl_iov: no caller in-tree invokes it and
the public API is defined by this vendored libfuse fork.
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.