/* ISC License Copyright (c) 2026, Antonio SJ Musumeci Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ /* OPEN FILE MANAGEMENT AND SHARED HANDLE TRACKING =============================================== This file implements FUSE open() with handling for concurrent access to the same file. The key challenge is that multiple threads may simultaneously attempt to open the same file (same nodeid), and we need to: 1. Share a single canonical FileInfo entry in the open_files map for all opens of the same file while still giving each FUSE handle its own FileInfo object and file descriptor 2. Properly reference count to know when to release resources 3. Handle race conditions without deadlocks or resource leaks 4. Support FUSE passthrough mode for performance CONCURRENCY MODEL ----------------- The open_files map (in state.hpp) tracks all currently open files: - Key: nodeid (FUSE inode number) - Value: OpenFile struct { ref_count, backing_id, fi } When a file is opened: 1. First, we check if an entry already exists (using visit()) 2. If it exists, we increment ref_count and create a new per-handle FileInfo by re-opening the canonical file descriptor 3. If it doesn't exist, we create a new FileInfo and try to insert it 4. If insertion fails (another thread inserted first), we clean up and retry (because to use passthrough it MUST be the same underlying file as the backing id was created) THE RETRY LOOP -------------- The MAX_OPEN_RETRIES loop (lines ~294-380) handles the race where multiple threads try to open the same file simultaneously: Thread A: visit() -> no entry -> open file -> try_emplace() -> SUCCESS Thread B: visit() -> no entry -> open file -> try_emplace() -> FAIL Thread B: (cleans up, retries) -> visit() -> found Thread A's entry -> share it This ensures that all threads eventually succeed while sharing the same underlying file resources. The alternative is to not use passthrough on failure but this race should be rare. RELEASE INTERACTION ------------------- release() erases the map entry on the final close. Until then, the canonical FileInfo in the map remains valid and overlapping opens can bump ref_count and duplicate its fd. Secondary handles own their own FileInfo objects and are freed independently during release(). PASSTHROUGH MODE ---------------- FUSE passthrough (Linux 6.9+) allows the kernel to bypass mergerfs for I/O: - We request a backing_id from the kernel via fuse_passthrough_open() - If successful, subsequent reads/writes go directly to the backing file - This achieves near native performance The backing_id is stored in the OpenFile entry so all overlapping open requests share the same passthrough handle. */ #include "fuse_open.hpp" #include "state.hpp" #include "config.hpp" #include "errno.hpp" #include "fileinfo.hpp" #include "fuse_release.hpp" #include "fs_close.hpp" #include "fs_cow.hpp" #include "fs_fchmod.hpp" #include "fs_lchmod.hpp" #include "fs_open.hpp" #include "fs_open_fd.hpp" #include "fs_openat.hpp" #include "fs_path.hpp" #include "fs_stat.hpp" #include "fuse_passthrough.hpp" #include "procfs.hpp" #include "stat_util.hpp" #include "fuse.h" #include #include #include #include #define MAX_OPEN_RETRIES 64 static bool _rdonly(const int flags_) { return ((flags_ & O_ACCMODE) == O_RDONLY); } static int _lchmod_and_open_if_not_writable_and_empty(const fs::path &fullpath_, const int flags_) { int rv; struct stat st; rv = fs::lstat(fullpath_,&st); if(rv < 0) return -EACCES; if(StatUtil::writable(st)) return -EACCES; rv = fs::lchmod(fullpath_,(st.st_mode|S_IWUSR|S_IWGRP)); if(rv < 0) return -EACCES; rv = fs::open(fullpath_,flags_); if(rv < 0) return -EACCES; fs::fchmod(rv,st.st_mode); return rv; } static int _nfsopenhack(const fs::path &fullpath_, const int flags_, const NFSOpenHack nfsopenhack_) { switch(nfsopenhack_) { default: case NFSOpenHack::ENUM::OFF: return -EACCES; case NFSOpenHack::ENUM::GIT: if(::_rdonly(flags_)) return -EACCES; if(fullpath_.string().find("/.git/") == std::string::npos) return -EACCES; return ::_lchmod_and_open_if_not_writable_and_empty(fullpath_,flags_); case NFSOpenHack::ENUM::ALL: if(::_rdonly(flags_)) return -EACCES; return ::_lchmod_and_open_if_not_writable_and_empty(fullpath_,flags_); } } /* The kernel expects being able to issue read requests when running with writeback caching enabled so we must change O_WRONLY to O_RDWR. With writeback caching enabled the kernel handles O_APPEND. Could be an issue if the underlying file changes out of band but that is true of any caching. */ static void _tweak_flags_cache_writeback(int *flags_) { if((*flags_ & O_ACCMODE) == O_WRONLY) *flags_ = ((*flags_ & ~O_ACCMODE) | O_RDWR); if(*flags_ & O_APPEND) *flags_ &= ~O_APPEND; } static bool _calculate_flush(FlushOnClose const flushonclose_, int const flags_) { switch(flushonclose_) { case FlushOnCloseEnum::NEVER: return false; case FlushOnCloseEnum::OPENED_FOR_WRITE: return !::_rdonly(flags_); case FlushOnCloseEnum::ALWAYS: return true; } return true; } static void _config_to_ffi_flags(const int tid_, fuse_file_info_t *ffi_) { switch(cfg.cache_files) { case CacheFiles::ENUM::OFF: ffi_->direct_io = 1; ffi_->keep_cache = 0; ffi_->auto_cache = 0; break; case CacheFiles::ENUM::PARTIAL: ffi_->direct_io = 0; ffi_->keep_cache = 0; ffi_->auto_cache = 0; break; case CacheFiles::ENUM::FULL: ffi_->direct_io = 0; ffi_->keep_cache = 1; ffi_->auto_cache = 0; break; case CacheFiles::ENUM::AUTO_FULL: ffi_->direct_io = 0; ffi_->keep_cache = 0; ffi_->auto_cache = 1; break; case CacheFiles::ENUM::PER_PROCESS: std::string proc_name; proc_name = procfs::get_name(tid_); if(cfg.cache_files_process_names.count(proc_name) == 0) { ffi_->direct_io = 1; ffi_->keep_cache = 0; ffi_->auto_cache = 0; } else { ffi_->direct_io = 0; ffi_->keep_cache = 0; ffi_->auto_cache = 0; } break; } if(cfg.parallel_direct_writes == true) ffi_->parallel_direct_writes = ffi_->direct_io; } static int _open_path(const fs::path &filepath_, const Branch *branch_, const fs::path &fusepath_, fuse_file_info_t *ffi_, const NFSOpenHack nfsopenhack_) { int fd; FileInfo *fi; fd = fs::openat(AT_FDCWD,filepath_,ffi_->flags); if(fd == -EACCES) fd = ::_nfsopenhack(filepath_,ffi_->flags,nfsopenhack_); if(fd < 0) return fd; fi = new FileInfo(fd,branch_,fusepath_,ffi_->direct_io); ffi_->fh = fi->to_fh(); return 0; } static int _open_fd(const int fd_, const Branch *branch_, const fs::path &fusepath_, fuse_file_info_t *ffi_) { int fd; FileInfo *fi; fd = fs::open_fd(fd_,ffi_->flags); if(fd < 0) return fd; fi = new FileInfo(fd,branch_,fusepath_,ffi_->direct_io); ffi_->fh = fi->to_fh(); return 0; } static int _open(const Policy::Search &searchFunc_, const Branches::Ptr ibranches_, const fs::path &fusepath_, fuse_file_info_t *ffi_, const bool link_cow_, const NFSOpenHack nfsopenhack_) { int rv; fs::path filepath; std::vector obranches; rv = searchFunc_(ibranches_,fusepath_,obranches); if(rv < 0) return rv; if(obranches.empty()) return -ENOENT; filepath = obranches[0]->path / fusepath_; if(link_cow_ && fs::cow::is_eligible(filepath,ffi_->flags)) fs::cow::break_link(filepath); rv = ::_open_path(filepath, obranches[0], fusepath_, ffi_, nfsopenhack_); return rv; } constexpr u64 _(const PassthroughIOEnum e_, const u64 m_) { return ((((u64)e_) << 32) | (m_ & O_ACCMODE)); } static int _open(const fuse_req_ctx_t *ctx_, const fs::path &fusepath_, fuse_file_info_t *ffi_) { int rv; auto &of = state.open_files; ::_config_to_ffi_flags(ctx_->pid,ffi_); if(cfg.cache_writeback) ::_tweak_flags_cache_writeback(&ffi_->flags); ffi_->noflush = !::_calculate_flush(cfg.flushonclose,ffi_->flags); for(int i = 0; i < MAX_OPEN_RETRIES; i++) { FileInfo *fi = nullptr; int backing_id = INVALID_BACKING_ID; // The ref increment keeps fuse_release.cpp from erasing and // freeing the canonical entry while we duplicate its fd. // // memory_order_relaxed is sufficient for the increment. The // concurrent_flat_map bucket lock held inside visit() already // provides the happens-before relationship with other mutators // of the same key (the release() erase_if() callback and other // open() visitors.) The lock's release/acquire semantics // publish the ref_count update to subsequent observers of the // same entry, so we only need atomicity from the fetch_add // itself. of.visit(ctx_->nodeid, [&](auto &v_) { v_.second.ref_count.fetch_add(1,std::memory_order_relaxed); fi = v_.second.fi; backing_id = v_.second.backing_id; }); // If the file is already open... if(fi) { rv = ::_open_fd(fi->fd, &fi->branch, fusepath_, ffi_); // If we fail to reopen the already open file we need to // treat it similarly to fuse_release. Since we increased // the ref count without an actual new fileinfo obj we call // FUSE::release() without an obj. Nothing was allocated // into ffi_->fh. _open_fd() failing should really never // happen hence no retries. Just being careful. if(rv < 0) { FUSE::release(ctx_->nodeid); return rv; } switch(_(cfg.passthrough_io,ffi_->flags)) { case _(PassthroughIO::ENUM::RO,O_RDONLY): case _(PassthroughIO::ENUM::WO,O_WRONLY): case _(PassthroughIO::ENUM::RW,O_RDONLY): case _(PassthroughIO::ENUM::RW,O_WRONLY): case _(PassthroughIO::ENUM::RW,O_RDWR): if(not fuse_backing_id_is_valid(backing_id)) return 0; ffi_->backing_id = backing_id; ffi_->passthrough = true; ffi_->keep_cache = false; return 0; } return 0; } // Was not open, do first open, try to insert, if someone beat us // to it in another thread then throw it away and try again. rv = ::_open(cfg.func.open.policy, cfg.branches, fusepath_, ffi_, cfg.link_cow, cfg.nfsopenhack); if(rv < 0) return rv; fi = FileInfo::from_fh(ffi_->fh); if(not fi) return -EBADF; switch(_(cfg.passthrough_io,ffi_->flags)) { case _(PassthroughIO::ENUM::RO,O_RDONLY): case _(PassthroughIO::ENUM::WO,O_WRONLY): case _(PassthroughIO::ENUM::RW,O_RDONLY): case _(PassthroughIO::ENUM::RW,O_WRONLY): case _(PassthroughIO::ENUM::RW,O_RDWR): backing_id = FUSE::passthrough_open(fi->fd); if(fuse_backing_id_is_valid(backing_id)) { ffi_->backing_id = backing_id; ffi_->passthrough = true; ffi_->keep_cache = false; } break; default: break; } bool inserted = of.try_emplace(ctx_->nodeid, backing_id, fi); if(inserted) return 0; // Failed to insert because another thread beat us to // it. Cleanup the resources and try it all again. FUSE::passthrough_close(backing_id); fs::close(fi->fd); delete fi; ffi_->fh = 0; struct timespec duration = {0,((i+1)*1000)}; ::nanosleep(&duration,NULL); } // Perhaps not the best error to return but it is EXTREMELY unlikely // to be hit and if so the system is probably under a lot of // stress. Give the app the opportunity to try it again. If it can't // it'll error probably similarly as having had returned EIO or // similar. return -EINTR; } int FUSE::open(const fuse_req_ctx_t *ctx_, const char *fusepath_, fuse_file_info_t *ffi_) { const fs::path fusepath{fusepath_}; return ::_open(ctx_,fusepath,ffi_); }