Networking was straightforward and the filesystem layer was not
KEIBIDROP presents a virtual folder on your desktop; you drop a file in, and it shows up on the other machine, encrypted and peer-to-peer with no cloud involved. The networking and encryption were the straightforward part, but the filesystem is where the real complexity lives.
Every operating system has a different opinion about how files should behave. And every application on those operating systems has its own interpretation of the filesystem API.
macOS: macFUSE works, and macOS behaves in ways that fight sync
macOS uses macFUSE for userspace filesystems, and it works, but macOS has several behaviors that make file sync difficult. Development started on an Intel Mac, where these issues showed up early.
Extended Attributes Everywhere
macOS stores metadata in extended attributes (xattrs) for almost everything: Finder tags, quarantine flags, download origin, Spotlight indexing data. Every time you interact with a file in Finder, it may read or write several xattrs. Your FUSE filesystem must handle getxattr, setxattr, and listxattr correctly, or Finder will behave unpredictably.
One xattr needed special treatment, com.apple.quarantine, because sandboxed apps like Preview.app check this attribute before opening files. On a FUSE mount, Gatekeeper cannot complete its verification and silently refuses to open the file. The fix is to block that xattr entirely; see the implementation in Getxattr() and Listxattr(). For the full story, see macOS Preview and FUSE.
The TextEdit Save Flow
This one took a full day to debug. When you save a file in TextEdit (and many other macOS apps), here is what actually happens:
- Write new content to a temporary file:
.myfile.txt.sb-12345 - Call
renamex_np()withRENAME_SWAPto atomically swap the temp file and the original - Delete the old temporary (which now contains the previous version)
This atomic swap ensures that no reader ever sees a partially written file. For a sync tool, it means that a simple "file changed" event is actually a rename, a swap, and a delete. You need to figure out that the net result is "the file content was updated." The Rename handler deals with this.
// What the sync layer sees:
// 1. Create: .myfile.txt.sb-12345
// 2. Write: .myfile.txt.sb-12345 (new content)
// 3. Rename: myfile.txt -> .myfile.txt.sb-67890 (atomic swap)
// .myfile.txt.sb-12345 -> myfile.txt
// 4. Delete: .myfile.txt.sb-67890
//
// What actually happened: the user pressed Cmd+S
Finder Expectations
Finder calls getattr constantly and expects sub-millisecond responses, so if your getattr implementation needs to check with a remote peer, Finder will show the spinning beach ball, so we cache all metadata locally and update it asynchronously.
negative_vncache: macOS caches ENOENT (file not found) results aggressively. If Finder checks for a file before it exists and gets ENOENT, it will cache that result and refuse to see the file even after it appears. You must be careful about the order of operations when making remote files visible. This is documented in the macFUSE mount options.
Windows: WinFsp is better documented and the semantics differ
Windows uses WinFSP (Windows File System Proxy) as its FUSE equivalent. WinFSP is well documented and more consistent than macFUSE, though its filesystem semantics are different. We have not fully tested KEIBIDROP on Windows yet, but these are the known issues we are preparing for.
Mandatory File Locking
On Unix, file locks are advisory, so a program can open a file even when another program has locked it. On Windows, file locks are mandatory and enforced by the kernel. If Word has a document open, no other process can write to it. Your sync daemon cannot update the file until Word releases the lock.
// On macOS/Linux:
// Process A: open("file.txt", O_RDWR) -> success
// Process B: open("file.txt", O_RDWR) -> success (advisory lock ignored)
// On Windows:
// Process A: CreateFile("file.txt", GENERIC_WRITE, 0, ...) -> success
// Process B: CreateFile("file.txt", GENERIC_WRITE, 0, ...) -> ERROR_SHARING_VIOLATION
Path Semantics
Windows paths are case-insensitive but case-preserving, so File.txt and file.txt are the same file. On macOS with APFS, the default is also case-insensitive, but Linux is case-sensitive. A file named README.md and another named readme.md can coexist on Linux but will collide on Windows and macOS.
MAX_PATH
The classic Windows limitation: paths cannot exceed 260 characters by default. Modern Windows supports long paths via a registry setting (LongPathsEnabled), but many applications still break with long paths. A sync tool must either enforce path length limits or handle the errors when applications fail.
POSIX Emulation Performance
If you use the Git Bash terminal emulator (MSYS2/MinGW POSIX layer) on Windows, file operations like cp are roughly 10x slower than the same operations via Command Prompt or PowerShell. This is because the POSIX emulation layer translates every syscall through an abstraction. For file transfer benchmarks and real-world usage, always test with native Windows tools.
Antivirus Interference
Windows Defender (and other antivirus software) scans every file that is created or modified. When your FUSE filesystem creates a new file, the antivirus will immediately open it for scanning. This triggers additional Open, Read, and Close operations that your filesystem must handle gracefully, and file creation is noticeably slower as a result.
Linux: FUSE is in the kernel, and FUSE 2 and 3 differ
Linux has native FUSE support in the kernel and needs no third-party driver. Andrei handles the Linux testing, and the issues are different from macOS.
FUSE 2 vs FUSE 3
FUSE 2 and FUSE 3 have different APIs, and distros ship one, the other, or both. The cgofuse Go library abstracts this to some degree, but there are behavioral differences. FUSE 3 supports readdirplus for better performance and FUSE 2 does not, so if you use FUSE 3 features, you lose compatibility with older distros.
One concrete issue: Linux FUSE read-ahead fires parallel reads on a single gRPC stream. On macOS those reads are serialized instead, and the difference caused data corruption on Linux until we added a mutex to serialize reads on the stream. The trade-off is that parallelism is lost; multiplexed streams are the proper fix, planned for a future release.
Library Paths
So far we have only tested on Debian and Ubuntu, where the FUSE library is at /usr/lib/x86_64-linux-gnu/libfuse3.so there, but Fedora, Arch, NixOS, and Alpine each put it somewhere different. BSD, Solaris, and other Unix-likes have their own FUSE implementations with their own quirks, and we have not touched any of those yet.
Several processes can hold the same file open at once
Multiple processes can have the same file open simultaneously. The filesystem must track how many open handles exist for each file, because you cannot safely delete or replace a file while it is open.
KEIBIDROP tracks this via OpenFileCounter and the OpenFileHandlers map:
// Simplified from pkg/filesystem/types.go
type OpenFileCounter struct {
count atomic.Int32
}
func (o *OpenFileCounter) Open() int32 {
return o.count.Add(1)
}
func (o *OpenFileCounter) Release() int32 {
return o.count.Add(-1)
}
The reference counting pattern is straightforward, but it interacts with every other part of the system. Rename while the file is open, delete while it is open, or a sync update arriving mid-write: each combination is a potential bug. The details are in Write/Release Race Conditions for the details.
One gRPC Notify handler carries every file event
When a file changes on one peer, the other peer must know about it. KEIBIDROP uses a gRPC Notify handler that processes file events:
// From keibidrop.proto
enum NotifyType {
DISCONNECT = 0;
ADD_DIR = 1;
REMOVE_DIR = 2;
ADD_FILE = 3;
EDIT_FILE = 4;
REMOVE_FILE = 5;
RENAME_FILE = 7;
RENAME_DIR = 8;
}
FUSE operations (create, write, rename, delete) trigger outbound notifications via OnLocalChange. The receiving peer processes these in the Notify handler and updates its local filesystem accordingly. Renames are atomic (not decomposed into delete + add); see the technical deep dive for why.
iOS, Android and locked-down machines have no FUSE at all
FUSE is not available on every platform. iOS and Android have no FUSE support at all, some Linux servers run without it installed, and corporate machines often have policies that prevent FUSE mounts.
KEIBIDROP's non-FUSE mode exposes a programmatic API via pkg/logic/common/logic.go:
// Add a local file to share with the peer
err := peer.AddFile("/path/to/document.pdf")
// List files available from the peer
files, err := peer.ListFiles()
// Pull a specific file from the peer
data, err := peer.PullFile("document.pdf")
The non-FUSE mode uses the same encryption, the same networking, and the same notification protocol. The only difference is that file operations are explicit API calls instead of implicit filesystem operations. This is what the agent CLI (kd) uses under the hood.
File state tracking in non-FUSE mode uses SyncTracker, which maintains LocalFiles and RemoteFiles maps with sync status flags.
Two machines rarely agree on what time it is
File timestamps seem simple until you realize that two machines rarely agree on what time it is.
- Even with NTP, clocks can drift by seconds. If Machine A says a file was modified at 14:00:01 and Machine B says 14:00:03, which is newer? If the NTP correction happens between modifications, timestamps can go backward.
- Timestamp resolution varies: macOS APFS has nanosecond resolution. Windows NTFS has 100-nanosecond resolution. FAT32 has two-second resolution. Linux ext4 has nanosecond resolution. If you sync a file from APFS to FAT32 and back, the timestamp will change.
- FAT32 stores timestamps in local time. NTFS and APFS use UTC. If a USB drive formatted as FAT32 is involved, timestamps shift when you change time zones.
Build it without FUSE first, then add FUSE on top
- Build without FUSE first. Start with a simple API:
AddFile(path)andPullFile(name). Get the networking, encryption, and sync logic working with explicit API calls. Only then add the FUSE layer on top. We did it the other way around and spent time debugging FUSE issues that were actually sync logic bugs. - Add FUSE as optional from day one. Not every platform supports FUSE (iOS, Android, some locked-down corporate machines). Having a clean non-FUSE path means you can support more platforms without maintaining two separate codepaths.
- Test on every target platform early. Development started on an Intel Mac; Andrei joined later on Debian/Ubuntu and found different concurrency issues (see FUSE Deadlocks). Apple Silicon and Windows are still untested. Other Linux distros, BSD, and Solaris are completely unexplored. Each platform exposes different timing and different edge cases.
- Write the state machine formally. We defined sync states informally in code comments and discovered missing transitions through testing. A formal state machine diagram would have caught these gaps earlier.