feat(antares): Libra Worktree v2 — pinned lower, effective diff, commit-finalize, chain forks - #65
Ivanbeethoven wants to merge 6 commits into
Conversation
…it-finalize, chain forks
Worktree Control Protocol v2 for ScorpioFS-backed Libra worktrees:
- POST /antares/worktrees (v2 attach): mounting pins the lower to the
current trunk revision (DicfuseManager::for_base_path_and_refs, one
per-revision store with a `-rev-` suffixed directory)
- GET /worktrees/{id}/state: effective diff (upper vs pinned lower,
per-path git blob OID compare) + FNV-1a generation for optimistic
concurrency
- POST /worktrees/{id}/commit-finalize: two-phase transaction — validate
generation/base/committed content, then quiesce, clear exactly the
committed upper entries, and remount over the new pinned lower with
rollback; retries are idempotent
- POST /worktrees/{id}/refresh: switch the pinned lower to a new revision
- chain forks: seal the source upper into a shared read-only frozen layer
(atomic rename, zero copy), stack it under the child, flatten on
finalize/refresh; unpinned sources downgrade to materialize
- upper_fork: reflink-first delta copy with fstat consistency checks for
materialize forks; whiteout-aware; never hardlinks
Fixes found by end-to-end testing against real mega2 + FUSE:
- Antares stat mode defaults to accurate everywhere (scorpio.toml shipped
"fast", which made getattr report size 0 for unread files; the kernel
then cached that attr and truncated every read to empty, so `libra
status` showed thousands of bogus modified files). Size probes now also
retry once, and commit-finalize pre-warms the committed paths.
- Upper layer directories created by a root daemon are chowned to
SUDO_USER: the passthrough write path executes with the requesting
user's credentials, so a root-owned upper rejected every user write.
- Pinned stores skip the deep directory prewarm (a large monorepo
prewarm saturates the remote and starves the concurrent user fetches a
finalize needs).
- Daemon-created FUSE mountpoints are cleaned up on teardown; stale
mount DELETEs unmount the FUSE session.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4f76972345
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| .join(parent) | ||
| .join(format!("{WHITEOUT_PREFIX}{file_name}")) | ||
| } | ||
| EffectiveKind::Added | EffectiveKind::Modified => upper_dir.join(rel), |
There was a problem hiding this comment.
Reject committed paths outside the upper root
When committed_paths contains an absolute path or .. component, upper_dir.join(rel) can resolve outside the worktree and remove_file/remove_dir_all will delete that host path. This is directly reachable when new_base_revision already equals the current pin, because that finalize branch performs cleanup without tree verification; a daemon running as root could therefore delete arbitrary files. Validate every path as a normalized mount-relative path before joining it.
Useful? React with 👍 / 👎.
| entry.state = MountLifecycle::Quiescing; | ||
| if let Err(e) = entry.fuse.unmount().await { |
There was a problem hiding this comment.
Revalidate the generation after quiescing
The generation is checked before resolving and warming the new lower, while the FUSE mount remains writable until this unmount. A user edit to a committed path during that potentially long interval is therefore not reflected in the checked generation and is then unconditionally removed by the cleanup below, losing the concurrent edit. Quiesce first and rescan before deleting, or otherwise prevent writes across validation and cleanup.
Useful? React with 👍 / 👎.
| let _ = Self::remount_with_lower( | ||
| &mountpoint, | ||
| old_dicfuse, | ||
| &upper_dir, | ||
| cl_dir.as_deref(), | ||
| &sealed_chain, | ||
| ) |
There was a problem hiding this comment.
Restore cleaned upper entries when remount fails
If mounting the verified new lower fails after remove_committed_upper_entries succeeds, this rollback only remounts the old lower; it cannot restore the upper files and whiteouts that were already deleted. Added files disappear and modified/deleted paths revert to the old projection even though the response reports a failed switch. Stage or rename the entries transactionally and restore them before remounting the previous projection.
Useful? React with 👍 / 👎.
| for entry in scan_upper_entries(upper_dir)? { | ||
| // The base is the chain first (nearest layer wins, whiteouts honored); | ||
| // the Dicfuse projection answers only what the chain does not. | ||
| let base = match chain_base_for(chain, &entry.rel_path) { |
There was a problem hiding this comment.
Include sealed-layer changes in the effective diff
For a chain fork with a fresh empty upper, this loop scans no entries at all and reports the worktree clean even though the sealed layers contain the inherited modifications, additions, and deletions relative to Dicfuse. The chain is only consulted as a base for upper entries, so its own delta is absent from changes and from the optimistic generation. Build the effective view of all chain layers against the pinned projection before overlaying the live upper.
Useful? React with 👍 / 👎.
| if meta.file_type().is_symlink() { | ||
| let target = fs::read_link(&layer_entry)?; | ||
| std::os::unix::fs::symlink(target, &upper_target)?; | ||
| } else { | ||
| fs::copy(&layer_entry, &upper_target)?; |
There was a problem hiding this comment.
Preserve whiteout names while flattening chains
For a sealed .wh.foo entry, layer_entry correctly names the physical whiteout but the copy destination remains upper_target, which is the logical foo path. Flattening during finalize or refresh therefore converts a deletion into an empty added file, and the new lower exposes that empty file instead of keeping the path deleted. Whiteouts must be copied to the corresponding .wh.<name> destination.
Useful? React with 👍 / 👎.
| for layer in chain.iter().rev() { | ||
| for entry in scan_upper_entries(layer)? { | ||
| let upper_target = upper_dir.join(&entry.rel_path); | ||
| if upper_target.exists() || upper_target.is_symlink() { | ||
| continue; |
There was a problem hiding this comment.
Keep nearest-layer precedence during flattening
The loop visits farthest layers first, but this existence check also treats files copied from those layers as pre-existing upper entries. When two sealed layers modify the same path, the farther copy is installed first and the nearer copy is skipped, reversing the live overlay's nearest-first precedence; the same problem prevents a nearer whiteout from replacing a farther file. Track entries that existed in the upper before flattening, or process nearest-first while skipping later duplicates.
Useful? React with 👍 / 👎.
| // The child inherits the source's pin: a fork of a pinned worktree | ||
| // must not silently fall back to the moving trunk tip. | ||
| pinned_refs: source_pinned, | ||
| sealed_chain: Vec::new(), |
There was a problem hiding this comment.
Materialize the source's existing sealed chain
When the source was itself created by a chain fork, fork_upper copies only its current writable upper, but the child is created with an empty sealed_chain. All inherited edits that still reside in the source's older sealed layers consequently vanish from a materialized child. The materialize path must flatten/copy the complete effective delta or carry the existing chain into the child.
Useful? React with 👍 / 👎.
| if let Err(e) = std::fs::rename(&source_upper, &frozen) { | ||
| entry.state = MountLifecycle::Ready; | ||
| return Err(ServiceError::FuseFailure(format!( | ||
| "chain fork: failed to seal the source upper: {e}" | ||
| ))); |
There was a problem hiding this comment.
Restore the source mount when sealing fails
At this point the source FUSE session has already been successfully unmounted. If the atomic rename fails, the code merely marks the entry Ready and returns, leaving the source mountpoint unmounted while control-plane state advertises it as usable. The failure path must remount the original upper/lower stack before restoring Ready.
Useful? React with 👍 / 👎.
| let target = match request.target_revision.as_deref().map(str::trim) { | ||
| Some(r) if !r.is_empty() => r.to_string(), | ||
| _ => resolve_latest_revision(config::base_url(), &path) | ||
| .await | ||
| .map_err(|error| { | ||
| ServiceError::Internal(format!( | ||
| "Antares worktree-state scan task failed for mount {}: {}", | ||
| mount_id, error | ||
| )) | ||
| })??; | ||
| .map_err(ServiceError::Internal)?, | ||
| }; |
There was a problem hiding this comment.
Validate the expected base before refreshing
RefreshRequest.expected_base_revision is never inspected in this implementation. A stale client that believes the worktree is based on an older revision can therefore switch the lower anyway, defeating the advertised optimistic base check and potentially changing the projection underneath newer client state. Compare the supplied expectation with the current bound base and return BaseMismatch before flattening or switching.
Useful? React with 👍 / 👎.
| entry.fuse = new_fuse; | ||
| entry.pinned_refs = Some(new_refs.clone()); | ||
| // Flattened above: the chain no longer applies to the new revision. | ||
| entry.sealed_chain = Vec::new(); |
There was a problem hiding this comment.
Advance the bound base after a successful finalize
The successful switch updates pinned_refs but never updates entry.base_revision, and the response likewise returns the old value. After the client commits from B to C, a subsequent finalize using C as expected_base_revision is rejected against stale B, so the protocol cannot progress beyond one commit. Persist and return the new bound revision as part of the successful transaction.
Useful? React with 👍 / 👎.
crates.io's libfuse-fs 0.2.0 already exposes everything Antares needs (`Config::whiteout_format`, `PassthroughFs::new`, `import`); the previous `new_antares_passthroughfs_layer_with` helper only wrapped those. The layer builder now lives in scorpiofs (`new_antares_passthrough_layer`) and the `[patch.crates-io]` section is gone.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 96be7e6f5c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| )); | ||
| } | ||
| (EffectiveKind::Added | EffectiveKind::Modified, Some(item)) => { | ||
| if let Some(expected) = &path.content_hash { |
There was a problem hiding this comment.
Require content hashes before cleaning committed files
When a client omits content_hash for an added or modified path, this branch accepts any blob merely because the path exists in the target revision. Finalize then removes the upper entry, so a target revision containing different content silently replaces the user's committed bytes despite the advertised per-path optimistic lock. Reject missing hashes for non-deletions rather than skipping the comparison.
Useful? React with 👍 / 👎.
| // Re-list: a file added or removed while we walked would otherwise be lost. | ||
| // (Content changes within a stable path set are caught per-file instead.) | ||
| let after = list_delta(src_upper)?; | ||
| if after == delta { | ||
| stats.retries += total.retries; | ||
| return Ok(stats); |
There was a problem hiding this comment.
Preserve a coherent snapshot during materialized forks
In a materialized fork while the source remains writable, equality here only proves that the set of path names stayed constant. For example, the copier can capture old a, then the parent updates a and b, and then capture new b; each per-file check passes and the final listing is unchanged, although the child's {old a, new b} state never existed in the parent. Quiesce the source or validate a whole-layer metadata generation before accepting the copy.
Useful? React with 👍 / 👎.
| let _ = Self::remount_with_lower( | ||
| &mountpoint, | ||
| old_dicfuse, | ||
| &upper_dir, | ||
| cl_dir.as_deref(), | ||
| &sealed_chain, | ||
| ) | ||
| .await; |
There was a problem hiding this comment.
Install the rollback mount after a failed refresh
If mounting the target lower fails, the rollback AntaresFuse returned here is immediately discarded instead of being stored back in the mount entry. The entry consequently remains Quiescing and still contains the old fuse whose mount handle was taken during unmount, even though the response claims the previous projection was remounted; subsequent operations reject the mount and it can no longer be managed correctly.
Useful? React with 👍 / 👎.
| match &base_revision { | ||
| Some(actual) if actual != expected => { | ||
| return Ok(CommitFinalizeResponse { | ||
| state: "conflict".into(), | ||
| code: Some("BASE_MISMATCH".into()), | ||
| detail: Some(format!( | ||
| "bound base revision is {actual}, client expected {expected}" | ||
| )), | ||
| base_revision: base_revision.unwrap_or_default(), | ||
| lower_revision: pinned_refs, | ||
| generation, | ||
| cleaned_paths: Vec::new(), | ||
| }); | ||
| } | ||
| _ => {} |
There was a problem hiding this comment.
Treat an unbound base as an explicit mismatch
When expected_base_revision is supplied but the mount's base_revision is None, the wildcard arm accepts the request and proceeds with finalize. Thus a client explicitly expecting revision B can switch and clean an unbound or incorrectly restored worktree instead of receiving BASE_MISMATCH; only an actual base equal to the expectation should pass this check.
Useful? React with 👍 / 👎.
| entry.fuse = new_fuse; | ||
| entry.pinned_refs = Some(target.clone()); | ||
| // Flattened above: the chain no longer applies to the new revision. | ||
| entry.sealed_chain = Vec::new(); |
There was a problem hiding this comment.
Reclaim orphaned layers when clearing a sealed chain
After a chained mount refreshes, clearing sealed_chain removes the daemon's only record of those directories when no other mount still references them. The later mount-delete collector only examines the entry's current chain, so this common sequence—delete the sibling, refresh the remaining mount, then delete it—leaves every sealed layer permanently on disk. Check remaining references and remove newly orphaned layers when the chain is cleared.
Useful? React with 👍 / 👎.
… §1) Serve a resolved MST/2 snapshot view in the Dicfuse slot of the Antares overlay, opt-in via `mst2_lower_enabled` (spec 15 §3: the legacy reader and the snapshot reader are explicit modes, never a silent fallback): - `impl Layer for Mst2Fuse` (src/snapshot/layer.rs): root inode, OCI whiteout convention, and `getattr_with_mapping` — the union filesystem's copy-up asks the lower layer for a raw stat64, and the trait default (ENOSYS) makes every write that needs a copy-up fail. - Mst2Fuse answers EROFS for every mutation, including the `*_with_context` variants the copy-up path consults, so the union filesystem routes creation to the upper layer with the requesting user's credentials instead of falling back to a daemon-owned (root) creation. - `AntaresFuse::with_lower_override` plus config (`mst2_lower_enabled`/`base_url`/`auth_token`/`scope`); `create_mount` resolves the view and injects it when enabled. Also fixes a pre-existing copy-up ownership bug affecting both backends: the overlay preserves the *lower* layer's uid/gid when it materializes a node in the upper layer, and both Dicfuse and the snapshot view reported uid=0/gid=0, so copied-up directories were root-owned and the user could not create files inside them. Attributes now report the mount owner (src/util/mount_owner.rs, derived from SUDO_USER with an euid fallback). Verified against a local monoengine MST/2 stack: mounted content is byte-identical to a git clone of the same commit; cold full-tree metadata walk 2.13s vs 22.2s for the per-directory Dicfuse path (10.4x); writes at the mount root, inside copied-up lower directories, and over existing lower files (truncate) all work on both backends. One boundary remains — appending to a lower file whose copy-up has not happened yet fails with ENOENT; the full evidence chain and the remaining hypotheses are in mst2-impl/MST2-LOWER-KNOWN-ISSUES.md.
The lower projection answers content identity in its own domain — the Dicfuse path speaks the mega content-hash (git blob OID), an MST/2 snapshot view speaks sha256(raw) (spec 03 §1) — so the effective diff must hash the upper layer in the same domain or it cannot tell "edited back to base" from "modified". Adds LowerHashKind, hash_content(), the LowerView trait and DicfuseLower (the legacy implementation). Pure addition: no call site changes yet, so behaviour is unchanged; the MST/2 view and the effective_changes rewire follow. Design: mst2-impl/P3-HASH-DOMAIN-DESIGN.md
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a96dec096a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| for path in committed { | ||
| let item = lower_item_for(new_store, &path.path).await; | ||
| match (&path.kind, item) { | ||
| (EffectiveKind::Deleted, None) => {} |
There was a problem hiding this comment.
Propagate lookup failures when verifying deletions
When the target revision's directory lookup fails due to a timeout, HTTP error, or store I/O error, lower_item_for discards every ensure_dir_loaded error and returns None, so this arm treats the failure as proof that the committed deletion exists. Finalize can then remove the upper whiteout and expose a file that is actually still present in the target lower. Preserve an absent-versus-error distinction and accept a deletion only after a successful lookup confirms absence.
Useful? React with 👍 / 👎.
| if let Some(lower) = mst2_lower_layer().await? { | ||
| fuse = fuse.with_lower_override(lower); |
There was a problem hiding this comment.
Use the mounted MST/2 view for control-plane comparisons
When mst2_lower_enabled is true, this replaces only the FUSE lower; worktree_state_v2, generation calculation, and committed-path verification still query the mount's Dicfuse store. In particular, an attach with an explicit historical lower_revision can expose the latest MST/2 snapshot while reporting changes and validating cleanup against the historical Dicfuse revision, so clean files can be reported dirty and finalize can remove upper entries based on content the mounted lower does not serve. Route effective-diff and verification through the same lower view installed here.
Useful? React with 👍 / 👎.
| let mut fuse = AntaresFuse::new( | ||
| mountpoint.to_path_buf(), | ||
| dicfuse, | ||
| upper_dir.to_path_buf(), | ||
| cl_dir.map(PathBuf::from), | ||
| ) | ||
| .await | ||
| .and_then(|fuse| fuse.with_frozen_layers(frozen)) |
There was a problem hiding this comment.
Preserve the MST/2 lower during remounts
With MST/2 mode enabled, every successful commit-finalize or refresh calls this helper after unmounting, but it constructs a plain AntaresFuse and never reapplies with_lower_override. The operation therefore silently changes the live filesystem from the configured MST/2 snapshot to Dicfuse; recovery follows the same plain construction path. Carry the selected lower mode and fixed snapshot identity through remount and recovery instead of dropping the override.
Useful? React with 👍 / 👎.
| cl: None, | ||
| mountpoint: Some(mountpoint.to_string_lossy().to_string()), | ||
| upper_dir: None, | ||
| pinned_refs: Some(lower_revision.clone()), |
There was a problem hiding this comment.
Include the pinned revision in attach idempotency
When an attach retries with an existing job_id and the same repository path, create_mount's idempotent fast path compares only path/CL fields and ignores this pinned_refs value. If latest advanced between retries, or the caller supplies a different lower revision while retaining the same base or omitting it, attach returns the old mount but echoes the newly resolved revision in AttachWorktreeResponse, causing the client to believe it received a projection that is not mounted. Validate the pin and mountpoint on an idempotent hit, or report the existing mount's actual revision.
Useful? React with 👍 / 👎.
… (P3 steps 2-3) `effective_changes` now takes a `&dyn LowerView` instead of the Dicfuse store: the comparison base and the upper-side hashing both use the lower's own identity domain (git blob OID for Dicfuse, sha256 raw bytes for an MST/2 snapshot view), so "edited back to base" and "modified" stay tellable apart regardless of which projection is being served. - Mst2Fuse::digest_for_path: content digest of a path in the fixed view, with the same on-demand page loads a lookup would make. - Mst2Lower / DicfuseLower: the two LowerView implementations. - All seven effective_changes call sites route through lower_view_for(entry) or an explicit DicfuseLower, so the Dicfuse path is behaviour-identical. MST/2-lowered mounts refuse commit-finalize and refresh with a typed error (their lower moves by resolving a new snapshot, not by re-pinning Dicfuse — that finalize path is P3 step 5), are skipped on daemon restart recovery, and are persisted with a marker so recovery never silently serves the Dicfuse projection instead (spec 15 §3). Verified end to end on both backends: Dicfuse reports git-oid changes and a working finalize; MST/2 reports sha256-domain changes for copied-up content and refuses finalize with a typed error.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1fc51bf7ef
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| let cloned = try_reflink(src, dst, before.size()); | ||
| let written_bytes = if cloned { | ||
| stats.reflink_used += 1; | ||
| before.size() | ||
| } else { | ||
| fs::copy(src, dst).map_err(io_err(dst))? | ||
| }; | ||
| fs::set_permissions(dst, fs::Permissions::from_mode(before.mode() & 0o7777)) | ||
| .map_err(io_err(dst))?; |
There was a problem hiding this comment.
Preserve user ownership in materialized forks
When the daemon runs through sudo, both the reflink destination and fs::copy destination are created as root, and this code preserves only the mode bits. AntaresFuse::new subsequently chowns only the staging directory itself, not these descendants, so a materialized child inherits root-owned 0644 files and 0755 directories that the invoking user cannot modify through the passthrough mount. Copy or recursively assign the source/invoking-user ownership before mounting the child.
Useful? React with 👍 / 👎.
| xattr: true, | ||
| do_import: true, | ||
| writeback: false, | ||
| whiteout_format: ANTARES_WHITEOUT_FORMAT, |
There was a problem hiding this comment.
Generate OCI whiteouts for CL deletions
This configures every passthrough layer, including the CL layer, to recognize OCI .wh.<name> whiteouts, but build_cl_layer still handles a deleted CL entry by calling create_whiteout, which on Linux creates a character device at the logical target path. With CAP_MKNOD this entry is not interpreted under the selected OCI format and therefore does not mask the Dicfuse file; without that capability, creating any CL containing a deletion fails outright. Emit an OCI marker for CL deletions as well.
Useful? React with 👍 / 👎.
| std::os::unix::fs::symlink(target, source_new_upper.join(".libra")).map_err(|e| { | ||
| ServiceError::FuseFailure(format!( | ||
| "chain fork: failed to re-create the parent VCS pointer: {e}" | ||
| )) | ||
| })?; |
There was a problem hiding this comment.
Roll back when recreating the source pointer fails
If recreating .libra fails after the replacement source FUSE session has been mounted, ? returns before that session is installed in the mount entry. Dropping the local source_fuse tears down the replacement, while the table still contains the old unmounted fuse in Quiescing state and the original upper remains renamed to the sealed path, leaving the source worktree unusable after a failed fork. This error path needs the same rename/remount and lifecycle rollback as the earlier rebuild failures.
Useful? React with 👍 / 👎.
…ing (P3 step 5) MST/2-lowered mounts move their lower by resolving the latest snapshot, not by re-pinning the Dicfuse projection. Both worktree-v2 mutations gain an MST/2 branch: - refresh: dirty-check against the current view, resolve latest, idempotent AlreadyAtTarget on the same snapshot, quiesce, remount over the new view, roll back to the previous view on failure. - commit-finalize: generation optimistic lock, resolve latest, verify the committed set in the view's own domain (upper content sha256 vs the snapshot entry digest — the client's git-OID content_hash is not comparable across domains), clear exactly the committed upper entries, remount, roll back. - `remount_with_mst2_lower`: rebuilds the overlay with the snapshot view in the Dicfuse slot (AntaresFuse::with_lower_override). Entry updates: mst2_lower holds the view, pinned_refs records the snapshot id for observability. persist_state records mst2_lower and recovery skips such mounts with a warning — the snapshot view is resolved per process, and rebuilding with Dicfuse would silently serve a different base (spec 15 §3).
Summary
Implements the Worktree Control Protocol v2 between ScorpioFS and Libra (spec:
docs/scorpiofs-libra-complete-spec-v1.md,docs/scorpiofs-libra-protocol-v2.md):POST /antares/worktrees): mounting pins the lower projection to the current trunk revision; one per-revision Dicfuse store per(base_path, refs)with a-rev-suffixed directory.GET /worktrees/{id}/state): upper vs pinned lower compared per-path by git blob OID; FNV-1a generation for optimistic concurrency.POST /worktrees/{id}/commit-finalize): two-phase transaction — validate generation/base/committed content hashes, then quiesce, clear exactly the committed upper entries, remount over the new pinned lower, with rollback; retries are idempotent.POST /worktrees/{id}/refresh): switch the pinned lower to a new revision.SUDO_USER— the passthrough write path executes with the requesting user's credentials, so a root-owned upper rejected every user write (EACCESon first write).Fixes found by end-to-end testing (real mega2 + FUSE)
libra status:scorpio.tomlshippedantares_dicfuse_stat_mode = "fast". Fast mode made getattr report size 0 for files never read in this revision; the kernel cached that attr and truncated every subsequent read to empty (verified via strace:read(...) = 0with no FUSE_READ reaching the daemon), so status computed empty-blob hashes and reported thousands of bogus modified files after a finalize/refresh. Stat mode is now accurate everywhere (one probe per file per revision, persisted in size.db), size probes retry once, and commit-finalize pre-warms the committed paths.Test plan
cargo test --lib— 124 passedlibra sync(add+commit+push+finalize) → clone-back verification → refresh; two consecutive rounds greenDepends only on the published
libfuse-fs 0.2.0from crates.io — no path/patch dependencies. (0.2.0 already exposes the Antares layer API; the layer helper now lives in scorpiofs.)MST/2 snapshot view as an overlay lower layer (spec 12 §1)
The same branch now also serves a resolved MST/2 snapshot view in the Dicfuse slot of the Antares overlay — the convergence step
mst2-impl/CONVERGENCE-ROADMAP.mddescribes. Opt-in viamst2_lower_enabled; per spec 15 §3 the legacy reader and the snapshot reader are explicit modes and neither silently falls back to the other.impl Layer for Mst2Fuse(src/snapshot/layer.rs): the read semantics were already T10-verified, so the adapter is theLayerimpl plus the two contract points the union filesystem actually consults:getattr_with_mapping— copy-up asks the lower layer for a rawstat64; the trait default (ENOSYS) fails every write that needs a copy-up.create_with_context/mkdir_with_context/symlink_with_context. Answering the trait default ENOSYS there makes copy-up fall back to a daemon-owned (root) creation, after which the user cannot write into the copied-up directory.AntaresFuse::with_lower_override+ config (mst2_lower_enabled/base_url/auth_token/scope);create_mountresolves the view and injects it.Measured (local monoengine MST/2 stack, mega+rk8s monorepo, 5160 files)
git cloneof the same commitstatsizesAlso fixes a pre-existing copy-up ownership bug (both backends)
The overlay preserves the lower layer's uid/gid when it materializes a node in the upper layer (
create_upper_dir/copy_regfile_up). Both Dicfuse and the snapshot view reporteduid=0/gid=0(empty_file_attr), so copied-up directories were root-owned and the user could not create files inside them (EACCES) — a bug on the production Dicfuse path too, simply not covered by the earlier E2E (which only created files at the mount root or overwrote existing files). Attributes now report the mount owner (src/util/mount_owner.rs:SUDO_USERwith an euid fallback). Verified on both backends: creating a file inside a lower directory,mkdirinside a lower directory, and truncating an existing lower file all succeed, withupper/<mount>/megaowned by the invoking user.Known boundary: appending to a lower file whose copy-up has not happened
echo x >> <lower file>fails withENOENTunder the MST/2 lower (the Dicfuse path is unaffected). The evidence chain, all measured:strace: theopenat(..., O_WRONLY|O_CREAT|O_APPEND)succeeds; thewritereturns ENOENT.lookup, no OPEN, no WRITE — theFUSE_WRITEnever reaches the daemon, so neither unionfs'sreal_handlepath norMst2Fuse::write(which answers EROFS) is involved.O_TRUNCopens work because the kernel is forced to send OPEN for them, which is what triggers the copy-up.no_opennegotiation (zero"open is not supported"records;Config.no_opendefaults to false withdo_import: true), daemon crashes, missing inodes ingetattr_with_mapping, and attr TTL length (60 s → 2 s reproduced identically; reverted).Current hypothesis: copy-up is triggered by the kernel's OPEN, and for some write modes (append,
r+) the kernel does not send one — so the write lands on a lower file that was never copied up. Next steps need kernel-side visibility (/sys/kernel/debug/fuse,dmesg) or a libfuse-fs change that performs the copy-up from the write fallback. Full write-up:mst2-impl/MST2-LOWER-KNOWN-ISSUES.md.