Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6bf03e3
Simplify CI to 2 runners with sequential targets
ejc3 Dec 25, 2025
8ac3cba
Fix CI: use ubuntu-latest for container job (podman needs systemd ses…
ejc3 Dec 25, 2025
a709a36
CI: enable FUSE user_allow_other for tests
ejc3 Dec 25, 2025
fdca9de
Use .local/ for tests to support hardlinks on overlayfs
ejc3 Dec 25, 2025
9bad28b
Use CARGO_MANIFEST_DIR for .local test paths
ejc3 Dec 25, 2025
a458b69
Add diagnostics on hardlink test failure
ejc3 Dec 25, 2025
55ceef4
Fix hardlink unit test to simulate real FUSE behavior
ejc3 Dec 25, 2025
a5e821b
Add diagnostics and skip hardlink tests on unsupported filesystems
ejc3 Dec 25, 2025
316aefb
Fix hardlink tests: detect and skip when AT_EMPTY_PATH unavailable
ejc3 Dec 25, 2025
71f5aa9
Factor out AT_EMPTY_PATH check to common helper
ejc3 Dec 25, 2025
6e9eca4
Create target and cargo-home dirs before container run
ejc3 Dec 25, 2025
e9978f3
Create /mnt/fcvm-btrfs dir for container tests
ejc3 Dec 25, 2025
905df6e
Remove cargo-home mount from container tests
ejc3 Dec 25, 2025
a00d198
Set CARGO_HOME to writable location in container
ejc3 Dec 25, 2025
5c99851
Remove target dir mount from container
ejc3 Dec 25, 2025
526891a
Simplify container tests: run as root, remove userns
ejc3 Dec 25, 2025
f888b37
CI: Run setup-fcvm as explicit step before VM tests
ejc3 Dec 25, 2025
1bbe3b2
CI: Run full test suite in Container job too
ejc3 Dec 25, 2025
caadc31
CI: Configure rootless podman with cgroupfs on buildjet
ejc3 Dec 25, 2025
89033a0
CI: Fix containers.conf format (use [engine] section)
ejc3 Dec 25, 2025
71a2f7f
CI: Use printf for containers.conf (fix heredoc indentation)
ejc3 Dec 25, 2025
1c74bef
Print serial log on setup timeout for debugging
ejc3 Dec 25, 2025
2bfed99
Stream Layer 2 setup serial output at info level
ejc3 Dec 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,24 @@ Why: String matching breaks when JSON formatting changes (spaces, newlines, fiel

If a test fails intermittently, that's a **concurrency bug** or **race condition** that must be fixed, not ignored.

### POSIX Compliance Testing

**fuse-pipe must pass pjdfstest** - the POSIX filesystem test suite.

When a POSIX test fails:
1. **Understand the POSIX requirement** - What behavior does the spec require?
2. **Check kernel vs userspace** - FUSE operations go through the kernel, which handles inode lifecycle. Unit tests calling PassthroughFs directly bypass this.
3. **Use integration tests for complex behavior** - Hardlinks, permissions, and refcounting require the full FUSE stack (kernel manages inodes).
4. **Unit tests for simple operations** - Single file create/read/write can be tested directly.

**Key FUSE concepts:**
- Kernel maintains `nlookup` (lookup count) for inodes
- `release()` closes file handles, does NOT decrement nlookup
- `forget()` decrements nlookup; inode removed when count reaches zero
- Hardlinks work because kernel resolves paths to inodes before calling LINK

**If a unit test works locally but fails in CI:** Add diagnostics to understand the exact failure. Don't assume - investigate filesystem type, inode tracking, and timing.

### Race Condition Debugging Protocol

**Show, don't tell. We have extensive logs - it's NEVER a guess.**
Expand Down Expand Up @@ -470,6 +488,24 @@ make container-test
**Nightly (scheduled):**
- Full benchmarks with artifact upload

### Getting Logs from In-Progress CI Runs

**`gh run view --log` only works after ALL jobs complete.** To get logs from a completed job while other jobs are still running:

```bash
# Get job ID for the completed job
gh api repos/OWNER/REPO/actions/runs/RUN_ID/jobs --jq '.jobs[] | select(.name=="Host") | .id'

# Fetch logs for that specific job
gh api repos/OWNER/REPO/actions/runs/RUN_ID/jobs --jq '.jobs[] | select(.name=="Host") | .id' \
| xargs -I{} gh api repos/OWNER/REPO/actions/jobs/{}/logs 2>&1 \
| grep -E "pattern"
```

### linkat AT_EMPTY_PATH Limitation

fuse-backend-rs hardlinks use `linkat(..., AT_EMPTY_PATH)`. Older kernels require `CAP_DAC_READ_SEARCH` capability; newer kernels (≥5.12ish) relaxed this. BuildJet runs older kernel → ENOENT. Localhost (kernel 6.14) works fine. Hardlink tests detect and skip. See [linkat(2)](https://man7.org/linux/man-pages/man2/linkat.2.html), [kernel patch](https://lwn.net/Articles/565122/).

## PID-Based Process Management

**Core Principle:** All fcvm processes store their own PID (via `std::process::id()`), not child process PIDs.
Expand Down
101 changes: 54 additions & 47 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,50 +13,10 @@ env:
CONTAINER_ARCH: x86_64

jobs:
container-rootless:
name: Container (rootless)
runs-on: buildjet-32vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
with:
path: fcvm
- uses: actions/checkout@v4
with:
repository: ejc3/fuse-backend-rs
ref: master
path: fuse-backend-rs
- uses: actions/checkout@v4
with:
repository: ejc3/fuser
ref: master
path: fuser
- name: make ci-container-rootless
working-directory: fcvm
run: make ci-container-rootless

container-sudo:
name: Container (sudo)
runs-on: buildjet-32vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
with:
path: fcvm
- uses: actions/checkout@v4
with:
repository: ejc3/fuse-backend-rs
ref: master
path: fuse-backend-rs
- uses: actions/checkout@v4
with:
repository: ejc3/fuser
ref: master
path: fuser
- name: make ci-container-sudo
working-directory: fcvm
run: make ci-container-sudo

vm:
name: VM (bare metal)
# Runner 1: Host (bare metal with KVM)
# Runs: test-unit → test-fast → test-root (sequential)
host:
name: Host
runs-on: buildjet-32vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
Expand All @@ -81,7 +41,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y fuse3 libfuse3-dev libclang-dev clang musl-tools \
iproute2 iptables slirp4netns dnsmasq qemu-utils e2fsprogs parted \
podman skopeo busybox-static cpio zstd
podman skopeo busybox-static cpio zstd autoconf automake libtool
- name: Install Firecracker
run: |
curl -L -o /tmp/firecracker.tgz \
Expand All @@ -104,6 +64,53 @@ jobs:
fi
sudo chmod 666 /dev/userfaultfd
sudo sysctl -w vm.unprivileged_userfaultfd=1
- name: make test-vm
# Enable FUSE allow_other for tests
echo "user_allow_other" | sudo tee /etc/fuse.conf
- name: test-unit
working-directory: fcvm
run: make test-unit
- name: setup-fcvm
working-directory: fcvm
run: make setup-fcvm
- name: test-fast
working-directory: fcvm
run: make test-fast
- name: test-root
working-directory: fcvm
run: make test-root

# Runner 2: Container (podman)
# Runs same tests as Host but inside a container
# Needs KVM for VM tests (container mounts /dev/kvm)
container:
name: Container
runs-on: buildjet-32vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
with:
path: fcvm
- uses: actions/checkout@v4
with:
repository: ejc3/fuse-backend-rs
ref: master
path: fuse-backend-rs
- uses: actions/checkout@v4
with:
repository: ejc3/fuser
ref: master
path: fuser
- name: Setup KVM and rootless podman
run: |
sudo chmod 666 /dev/kvm
# Configure rootless podman to use cgroupfs (no systemd session on CI)
mkdir -p ~/.config/containers
printf '[engine]\ncgroup_manager = "cgroupfs"\nevents_logger = "file"\n' > ~/.config/containers/containers.conf
- name: container-test-unit
working-directory: fcvm
run: make container-test-unit
- name: setup-fcvm
working-directory: fcvm
run: make setup-fcvm
- name: container-test
working-directory: fcvm
run: make test-vm
run: make container-test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ sync-test/
*.local.*
*.local
cargo-home/
.local/
5 changes: 2 additions & 3 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ RUN for bin in cargo rustc rustfmt cargo-clippy clippy-driver cargo-nextest carg

# Setup workspace
WORKDIR /workspace/fcvm
RUN mkdir -p /workspace/fcvm /workspace/fuse-backend-rs /workspace/fuser \
&& chown -R testuser:testuser /workspace
RUN mkdir -p /workspace/fcvm /workspace/fuse-backend-rs /workspace/fuser

USER testuser
# Run as root (--privileged container, simpler than user namespace mapping)
CMD ["make", "test-unit"]
10 changes: 5 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,10 @@ endif
# Base test command
NEXTEST := CARGO_TARGET_DIR=target cargo nextest $(NEXTEST_CMD) --release

# Container run command
CONTAINER_RUN := podman run --rm --privileged --userns=keep-id --group-add keep-groups \
# Container run command (runs as testuser via Containerfile USER directive)
CONTAINER_RUN := podman run --rm --privileged \
-v .:/workspace/fcvm -v $(FUSE_BACKEND_RS):/workspace/fuse-backend-rs -v $(FUSER):/workspace/fuser \
-v ./target:/workspace/fcvm/target -v ./cargo-home:/home/testuser/.cargo \
-e CARGO_HOME=/home/testuser/.cargo --device /dev/fuse --device /dev/kvm \
--device /dev/fuse --device /dev/kvm \
--ulimit nofile=65536:65536 --pids-limit=65536 -v /mnt/fcvm-btrfs:/mnt/fcvm-btrfs

.PHONY: all help build clean test test-unit test-fast test-all test-root \
Expand All @@ -56,7 +55,7 @@ build:
@mkdir -p target/release && cp target/$(MUSL_TARGET)/release/fc-agent target/release/fc-agent

clean:
sudo rm -rf target cargo-home
sudo rm -rf target

# Run-only targets (no setup deps, used by container)
_test-unit:
Expand Down Expand Up @@ -96,6 +95,7 @@ container-test-all: setup-fcvm container-build
container-test: container-test-all

container-build:
@sudo mkdir -p /mnt/fcvm-btrfs 2>/dev/null || true
podman build -t $(CONTAINER_TAG) -f Containerfile --build-arg ARCH=$(CONTAINER_ARCH) .

container-shell: container-build
Expand Down
94 changes: 91 additions & 3 deletions fuse-pipe/src/server/passthrough.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,61 @@ mod tests {
#[test]
fn test_passthrough_hardlink() {
let dir = tempfile::tempdir().unwrap();
eprintln!("=== Hardlink unit test diagnostics ===");
eprintln!("tempdir: {:?}", dir.path());

// Check if underlying filesystem supports hardlinks by trying one directly
let test_src = dir.path().join("direct_test.txt");
let test_link = dir.path().join("direct_link.txt");
std::fs::write(&test_src, "test").expect("write direct test file");
match std::fs::hard_link(&test_src, &test_link) {
Ok(()) => {
eprintln!("Direct hardlink: SUPPORTED");
std::fs::remove_file(&test_link).ok();
}
Err(e) => {
eprintln!("Direct hardlink: NOT SUPPORTED - {}", e);
eprintln!("Skipping test - filesystem does not support hardlinks");
std::fs::remove_file(&test_src).ok();
return; // Skip test on filesystems that don't support hardlinks
}
}

// Also test linkat with AT_EMPTY_PATH (used by fuse-backend-rs)
use std::ffi::CString;
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsRawFd;
let test_link2 = dir.path().join("at_empty_test.txt");
let test_link2_name = CString::new("at_empty_test.txt").unwrap();
let dir_fd = std::fs::File::open(dir.path()).expect("open dir");
let src_fd = std::fs::File::options()
.custom_flags(libc::O_PATH)
.read(true)
.open(&test_src)
.expect("open src with O_PATH");
let empty = CString::new("").unwrap();
let res = unsafe {
libc::linkat(
src_fd.as_raw_fd(),
empty.as_ptr(),
dir_fd.as_raw_fd(),
test_link2_name.as_ptr(),
libc::AT_EMPTY_PATH,
)
};
if res == 0 {
eprintln!("linkat with AT_EMPTY_PATH: SUPPORTED");
std::fs::remove_file(&test_link2).ok();
} else {
let err = std::io::Error::last_os_error();
eprintln!("linkat with AT_EMPTY_PATH: FAILED - {}", err);
eprintln!("This means fuse-backend-rs link() will also fail");
eprintln!("Skipping test - AT_EMPTY_PATH not supported");
std::fs::remove_file(&test_src).ok();
return; // Skip test
}
std::fs::remove_file(&test_src).ok();

let fs = PassthroughFs::new(dir.path());

let uid = nix::unistd::Uid::effective().as_raw();
Expand All @@ -1271,25 +1326,58 @@ mod tests {
// Create source file
let resp = fs.create(1, "source.txt", 0o644, libc::O_RDWR as u32, uid, gid, 0);
let (source_ino, fh) = match resp {
VolumeResponse::Created { attr, fh, .. } => (attr.ino, fh),
VolumeResponse::Created { attr, fh, .. } => {
eprintln!("create() returned inode={}, fh={}", attr.ino, fh);
(attr.ino, fh)
}
VolumeResponse::Error { errno } => panic!("Create failed with errno: {}", errno),
_ => panic!("Expected Created response"),
};

// Write to source
// Write to source and release handle
let resp = fs.write(source_ino, fh, 0, b"hardlink test content", uid, gid, 0);
assert!(matches!(resp, VolumeResponse::Written { .. }));
fs.release(source_ino, fh);

// In real FUSE, the kernel calls LOOKUP on the source before LINK.
// This lookup refreshes the inode reference in fuse-backend-rs.
// We must do the same when calling PassthroughFs directly.
let resp = fs.lookup(1, "source.txt", uid, gid, 0);
let source_ino = match resp {
VolumeResponse::Entry { attr, .. } => {
eprintln!("lookup() returned inode={}", attr.ino);
attr.ino
}
VolumeResponse::Error { errno } => {
panic!("Lookup after release failed: errno={}", errno);
}
_ => panic!("Expected Entry response"),
};

// Create hardlink
eprintln!("Calling link(source_ino={}, parent=1, name='link.txt')...", source_ino);
let resp = fs.link(source_ino, 1, "link.txt", uid, gid, 0);
let link_ino = match resp {
VolumeResponse::Entry { attr, .. } => {
eprintln!("link() succeeded with inode={}", attr.ino);
// Hardlinks share the same inode
assert_eq!(attr.ino, source_ino);
attr.ino
}
VolumeResponse::Error { errno } => panic!("Link failed with errno: {}", errno),
VolumeResponse::Error { errno } => {
// Extra diagnostics on failure
let src_path = dir.path().join("source.txt");
let link_path = dir.path().join("link.txt");
eprintln!("=== link() FAILED ===");
eprintln!("errno: {} ({})", errno, std::io::Error::from_raw_os_error(errno));
eprintln!("source.txt exists: {}", src_path.exists());
eprintln!("link.txt exists: {}", link_path.exists());
eprintln!(
"Direct hardlink attempt: {:?}",
std::fs::hard_link(&src_path, dir.path().join("link2.txt"))
);
panic!("Link failed with errno: {}", errno);
}
_ => panic!("Expected Entry response"),
};

Expand Down
Loading
Loading