Skip to content

fix(operations): make file operations safe and fast on NFS, SMB, FUSE, FAT and case-insensitive filesystems - #1615

Draft
ogarza wants to merge 241 commits into
mainfrom
fix/1530-filesystem-compat
Draft

ogarza wants to merge 241 commits into
mainfrom
fix/1530-filesystem-compat

Conversation

@ogarza

@ogarza ogarza commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Strata's no-clobber file operations relied on renameat2 flags (RENAME_NOREPLACE, RENAME_EXCHANGE), and native copies went through GIO. Many mounts reject those flags with EINVAL, among them NFS, sshfs, ntfs-3g and other libfuse2 daemons, 9p, ceph and older ZFS.

This PR makes these operations work on such mounts. It never overwrites a destination that appeared meanwhile, and never deletes a source the copy doesn't hold. It is the single PR for umbrella #1590: each step lands here as its own group of commits, and it is merged once. The table further down compares main and this PR, filesystem by filesystem.

Step 1: placement, Replace/Merge, rename, Trash

  • One placement ladder for every site: move, staged copy publish, restore, archive extract/compress publish, symlink copy, undo and rename. It tries these in order:

    1. renameat2(NOREPLACE).
    2. For files, linkat. The link counts only once the new name reports the source's identity; the old name is then unlinked after an identity check.
    3. For folders, an exclusive empty placeholder that is renamed over.
    4. On mounts with neither NOREPLACE nor usable hard links (sshfs, vboxsf, rclone, s3fs), an exclusive empty placeholder that is checked and then renamed over.
    5. A copy, only across devices or where no step can place the item.

    EEXIST stays authoritative, and the ladder tells apart the three meanings of EINVAL: flag unsupported, invalid name, and folder into itself.

  • The reserved-name step has a narrow window.

    • The placed item and any existing destination are never at risk. Only a program that overwrites that exact name between the check and the rename loses its write.
    • GLib, and so Nautilus, checks and then renames with no placeholder. So there, even a program that creates the name exclusively in between is overwritten.
    • The trade-off is documented in docs/filesystem-compatibility.md.
  • sshfs and other libfuse daemons without use_ino give each hard link its own inode number. Strata learns this from a probe on its own names and never uses those links to prove a placement.

  • Capabilities are learned per mount and cached by mount ID. The cache is dropped when the mount table changes.

  • Replace and Merge work without Trash or EXCHANGE.

    • The original is moved aside to a hidden name, the new item is published, and the aside copy is then deleted by identity, with rollback on failure.
    • EXCHANGE is used only on in-kernel local filesystems, never FUSE. exfat-fuse reports success for it but simply overwrites, which on main destroyed the original when Trash was unavailable.
    • A same-filesystem move with Replace renames instead of copying.
  • Rename (F2) and undo rename go through the ladder. Case-only renames (foo → Foo) work on vfat, exFAT, SMB, casefold ext4 and exfat-fuse. GVfs locations keep GIO.

  • Trash results are verified. Some FUSE mounts report success without moving anything (GLib #4060).

  • Identity checks compare file type and birth time as well as device and inode, because ext4, NFS, CIFS and ntfs3 reuse inode numbers immediately.

  • Archives:

    • ntfs3 reports zero blocks for small files, and the tar crate used to write zeros for them. TAR now stores members in full.
    • Extraction no longer sets symlink times on FUSE, where sshfs applies them to the link's target.

Step 2: the copy engine

  • One descriptor-based engine replaces GIO wherever both ends are native paths:

    • pastes, Duplicate and Keep Both;
    • cross-filesystem moves;
    • Replace stages and merges;
    • Trash restores.

    It runs on worker threads, opens everything beneath pinned folders, and never replaces an entry. GVfs locations keep GIO.

  • Only complete copies appear.

    • On local POSIX filesystems a file is written to an O_TMPFILE and linked to its name when done.
    • Elsewhere the copy is built in a hidden .strata-copy-* stage, which is safe to delete, and published through the step 1 ladder.
    • Keep Both picks its number when the copy is placed, so a name taken meanwhile gets the next one.
  • Data:

    • Each file is copied with FICLONE, then chunked copy_file_range, then read/write. Network copy_file_range is gated on the kernel releases with the NFS, FUSE and SMB fixes, and NFS requests are capped at 2 MiB.
    • Every native file reports byte progress.
    • Sparse files from local disks keep their holes.
  • Metadata:

    • Order: owner, then xattrs (ACLs last), then mode, then times. user.* xattrs survive on read-only items.
    • Folders keep their mode, times, owner and xattrs. Read-only folders get their exact mode in a last pass.
    • Copies into a setgid folder keep its group.
    • Special bits are dropped when the owner can't be kept.
  • Names:

    • Two source names the destination can't tell apart (Makefile and makefile on FAT, exFAT, SMB or casefold) are saved as makefile (1) and reported. Undoing the move gives the name back.
    • Entries left by lost NFS replies are adopted.
  • Hard links stay linked within one copied item where the destination keeps them.

  • Special files: named pipes are recreated, while sockets and devices are left out and reported.

  • Merges never overwrite.

    • Incoming items are built in a stage inside each merged folder and renamed in.
    • A name taken meanwhile gets the next number and is reported.
    • A stopped merge leaves nothing partial.
  • Moves that copy delete a source entry only if it still matches what was copied:

    • The comparison uses type, size and times, plus inode, change time and link count where those are stable.
    • Hard-linked names the move removes itself are accounted for.
    • On coarse-timestamp mounts (FAT, exFAT, sshfs), a file modified within one tick of being copied is compared by content first.
    • A file written during its copy is copied again, and kept if it changes again.
    • Kept entries are reported and left out of Undo.
  • Durability: nothing that only the copy holds is deleted before the destination is flushed. That covers move sources, Replace originals, merge set-asides, archive originals and restored Trash copies.

    • Each filesystem is flushed the way the lab measured that it reaches the device. The design assumed syncfs; vfat and exFAT also need a folder fsync, and SMB and other FUSE mounts sync each file.
    • ntfs-3g and exfat-fuse, recognised through udev, get one folder sync per batch, which flushes the whole device.
    • Deletions are batched: 256 items, 1 GiB or 5 s.
    • More than eight cross-disk file moves run in parallel.
  • Trash restore copies through the engine where nothing can place the item.

    • Metadata is best-effort.
    • The copy is flushed, then the Trash copy is deleted entry by entry.
    • Anything left behind keeps its .trashinfo and is reported.
    • A restore cancelled mid-copy ends as a cancel, and its partial copy is removed. If the removal can't be proven, the item is reported as failed.
    • Merge undo restores an original only over a path it could check.
  • Undo of a merge or Replace keeps later edits.

    • When a merge or a Replace puts an item over an original that went to the Trash, Strata records what it placed (up to 100,000 entries per operation).
    • Undo deletes the item only if it is unchanged, using the same checks as moves.
    • Otherwise the whole item stays, its original stays in the Trash with its .trashinfo, and the dialog says so.
    • On main, undo permanently deleted whatever was at that path, edits included.
    • Where the Trash is unavailable, items a merge created were also deleted permanently and silently. Undo now does that only for items it recorded and finds unchanged; anything else stays and is reported. Where the Trash works, they still go to the Trash.
  • Completion report: one event carries renamed, left-out and left-behind items.

    • Renames show as a notice, or an information dialog for three or more.
    • Anything left out or left behind shows a warning.
    • A cancelled or failed transfer, and a restore or merge undo that completes with errors, shows one dialog: its outcome, then the report.
    • In a cancel's summary, an item the cancel cut off and rolled back counts as not attempted, not failed. This applies to local transfers, Trash deletes, restores and merge undos.
  • Responsiveness:

    • Paste, restore and delete planning run off the main thread.
    • Folder monitors ignore the phantom events of unnamed temporary files, and query each linked-in file once.
  • Docs and lab:

    • docs/filesystem-compatibility.md gains a "Copying" section, the residual limits, and the lab's flush and claim measurements.
    • scripts/fslab now counts device cache flushes per mount.
    • The full unit suite was rerun on all 14 lab mounts. Every failure left is a test portability issue or a fixture limit of the mount.
  • Speed on the lab (2,000 × 1 KiB, debug build): vfat with flush, 26 s against GIO's 208 s; sshfs, 2–12 s against about 100 s; the rest on par. On exfat-fuse and ntfs-3g, a 300-file move went from about 300 device flushes to 2.

Decisions taken with the owner during step 2:

  • batched flushes with parallel moves;
  • hard links kept within one item;
  • pipes recreated, everything else skipped and reported;
  • setgid group kept;
  • one flush per batch on ntfs-3g and exfat-fuse;
  • content recheck of recently modified files on coarse-timestamp mounts;
  • SMB opens refused during the client's deferred close are retried, for at most 10 s;
  • one dialog per outcome, never a stacked report;
  • items a cancel rolled back count as not attempted;
  • a cancel never reports the transfer as finished, as decided in feat(ui): flush removable drives before unmount and copy success #1193.

Decided by the lead along the way:

  • holes kept as with cp --sparse=auto;
  • a name taken during a merge is numbered and reported;
  • undo gives alias-numbered names back;
  • leftovers are not auto-deleted (step 8).

Still to come on this PR:

  1. Mount profile and names.
  2. Preview handles and delete messages.
  3. The remaining main-thread calls.
  4. Stable identity.
  5. Remote freshness.
  6. Leftover detection.

The checklist in #1590 tracks them.

Known limits:

  • SMB:
    • On SMB2/3, NOREPLACE is enforced only on the client.
    • After this machine writes a file on a share, the kernel client refuses to open another hard-linked name of it until its deferred close ends (closetimeo, 1 s by default). Strata retries for closetimeo plus 0.5 s, at most 10 s. A writer that keeps the file open still makes the copy fail with "Could not copy".
  • Lost replies: after a lost reply (EIO, ETIMEDOUT), an empty placeholder can stay behind, and the item is reported as unconfirmed rather than guessed. This happens on NFS and sshfs.
  • SFTP without posix-rename@openssh.com: nothing can be renamed over a placeholder.
    • Moves and copies fall back to copying, as before.
    • File renames, archive publication and setting a file aside are refused.
  • Identity and timestamps:
    • Mounts without birth time (NFSv3, ntfs-3g, exfat-fuse, sshfs) can't tell a recreated item of the same type and size, with the same times, from the original.
    • Elsewhere, a same-size rewrite within one kernel timestamp tick can pass.
    • An sshfs server clock more than 2 s behind can hide a rewrite.
  • Waiting out the tick: on coarse-timestamp mounts, a move of a just-saved file takes up to 2 s longer (3 s on sshfs).
  • Hard links are kept within one copied item and one merged folder, never from plain SMB shares or sshfs.
  • GVfs destinations get no flush and no content check.
  • Leftovers: a .strata-copy-* stage left by a crash or an unplug stays until it is deleted (step 8).
  • Not tested on real mounts:
    • The batch limits were measured on loop devices in RAM, so real USB flush latency is unmeasured.
    • Restoring from Trash still matches by device and inode (step 6).
    • vboxsf, rclone, s3fs, ZFS, 9p, ceph and libfuse2 builds of exfat-fuse were never tried. Their behaviour is taken from their sources and tested through the seam.

Edge cases by filesystem

Every filesystem edge case this PR handles, against main at the merge base (e7e43f2a). Rows under Any filesystem apply everywhere; the groups below list only what differs.

Any filesystem
Edge case Before (main) After (this PR)
Move within one filesystem where RENAME_NOREPLACE is rejected (#1530) Copied to a hidden stage, copied again to its name, then the source deleted: data written twice Renamed, never copied: a file by hard link, its old name removed once the link proves it; a folder over an empty placeholder. Mounts without usable links: see sshfs
EINVAL from a rename Read as "copy instead" Told apart: an invalid name is reported, a folder into itself refused, a missing flag handled as above
Move across filesystems Staged copy (copied twice where NOREPLACE is missing), then the source deleted One copy (a second pass only where nothing can place it), then each source entry deleted once the copy is flushed
Source changed or added to during that move Only the top item checked; everything inside deleted An entry is deleted only if it still matches what was copied (type, size, times; inode, change time, link count where stable). The rest stays, is reported, and keeps the item out of Undo
Same-size rewrite that leaves the modification time as it was Deleted (nothing was checked) Caught by the change time; where times are coarse (FAT, exFAT, sshfs, HFS, any time with no fraction of a second) the contents are compared before deleting
File written while it is moved Deleted after copying whatever was read Copied once more; if it changes again it stays, and a selected file fails "changed while it was being copied"
Copy moved away before its source is deleted Source deleted Source kept and reported
Source entry that can't be deleted (read-only folder) Move failed halfway: "Could not delete …: Permission denied" Move completes; the entry stays and is reported with the error
Flush before deleting what only the copy holds (move sources, replaced originals, merge set-asides, Trash copies) Removable drives only; an exchanged original or a restored Trash copy never Always (except GVfs destinations), the way each filesystem reaches its device (see below); a failed flush deletes nothing
Moving many files across filesystems One at a time, each source deleted at once Up to 8 at a time (1 on spinning disks); deletions batched per destination (256 items, 1 GiB or 5 s) after one flush. Cancel finishes the batch's deletions
Copy seen under its final name while written Second copies (no NOREPLACE) and merges wrote at real names Only complete copies appear: an unnamed O_TMPFILE linked when done on local POSIX filesystems, otherwise a hidden .strata-copy-* stage
Cancelled or failed copy A second copy left its truncated file at the final name; NFS kept the stage Nothing partial at a real name; files are closed before removal, so NFS leaves no stage unless another program holds a file in it open
Copy cut off by a crash or an unplugged drive A .strata-replacement-* stage, or a half-written file under its final name A file on a local POSIX disk leaves nothing (unnamed until complete); anything else leaves a .strata-copy-* stage, documented as safe to delete
Keep Both and Duplicate Number probed on the UI thread; a name taken meanwhile failed the copy Number chosen when the copy is placed; a name taken meanwhile gets the next one
Replace with Trash Stage copied (twice without NOREPLACE), even for a move within one filesystem One copy; a replacing move within one filesystem renames the source. A Trash that moved nothing falls back
Replace without Trash RENAME_EXCHANGE, the original deleted at once; elsewhere failed after the full copy Exchange only on in-kernel local filesystems, never FUSE; elsewhere the original is set aside and put back on failure. It is deleted only after the copy is flushed; refused before copying if nothing is safe
Merge Overwritten files trashed, then GIO OVERWRITE into the live folder Incoming items built in a hidden stage per merged folder, then renamed in without replacing
Merge without Trash Refused Originals set aside in a hidden folder, put back on failure, removed once the merge is flushed
Undo of a merge or Replace after the item was edited The edited item permanently deleted and the original restored The item kept whole; its original left in the Trash and reported
Merge undo or restore that ends with errors Restored and kept items could vanish from the view until a reload Only what was removed leaves the view; open folders without a monitor reload when a restore ends
Open folders without a monitor after an operation ends Skipped after a cancel, and after a delete or restore, whenever some watched folder had rescanned; reloaded unaffected parent folders Each affected unwatched folder reloads once after any ending (completed, failed, cancelled); watched folders are left to their monitor
Merge undo where the Trash is unavailable Items the merge created deleted permanently, edits included, without asking Deleted only if recorded and unchanged; otherwise kept and reported
Name taken during a merge Overwritten, no undo; a stopped merge left partial files Saved under the next number and reported; a stopped merge leaves nothing partial and reports only what it placed
Rename (F2) Check, then plain rename(): an item created in between is replaced Never replaces: native items go through the same steps as moves
Case-only rename on case-insensitive folders Refused: "already exists" Done in two steps through a hidden name, with rollback
Undo rename A move: two full copies plus a delete where NOREPLACE is missing A rename that never copies; refused where nothing can place it
Trash reports success without moving anything (GLib #4060) Shown as trashed, Undo recorded, .trashinfo removed Checked: an item still in place fails like an unsupported Trash, with no Undo
Restore from Trash without rename flags Files hard-linked; folders copied with strict metadata Placed without copying; copied only where nothing can place it
Restore copy Strict metadata failed restores (user.* on 0444); named pipes refused; Trash copy deleted unflushed Best-effort metadata, pipes recreated; flushed, then the Trash copy deleted entry by entry. What changed or can't be copied stays in Trash with its .trashinfo, reported. A cancel mid-copy ends as a cancel with nothing partial left
Archive extraction and compression publish NOREPLACE or a hard link per file, refused elsewhere; compression published on the UI thread Through the same steps, off the UI thread; Replace without Trash sets aside or exchanges; the original is deleted after the archive is flushed
Copy metadata GIO, by path after close: chown, chmod, times, xattrs By descriptor: owner, xattrs (ACLs last), mode, times; only I/O, stale-handle and connection errors fail
user.* xattrs on read-only items Lost Kept
Copy of another user's setuid file Stayed setuid, owned by the copier Setuid and setgid dropped when the owner can't be kept
Copy into a setgid folder Files took the source's group Everything keeps the folder's group; folders keep setgid
Folder mode, times, owner, xattrs Lost (0700 became 0755) Kept; read-only folders get their mode in a last pass, with no descriptor held per folder
Symlink times in a copy Lost Kept, except on FUSE
Hard links inside a copied folder Split into separate files Kept within the copied item where the destination keeps links
Named pipes inside a copied folder Whole copy aborted Recreated; left out and reported where the destination refuses them
Sockets and device files inside a copied folder Whole copy aborted Left out and reported; a move leaves them at the source
Selected named pipe "Cannot copy …: it is not a regular file…" Recreated where the destination allows it
Symlink in a folder copied where symlinks are refused Copy aborted, naming the hidden stage Still fails, naming the link
Sparse files Holes filled (except reflinks) Holes kept from ext2/3/4, XFS, btrfs, tmpfs, f2fs to anything but FAT, exFAT, ntfs3
Case aliases inside a copy (Makefile, makefile) The later name overwrote the earlier; a move deleted both sources Later name in byte order saved as makefile (1) and reported; undoing a move gives the name back
Names the destination can't store (\, :) Copy failed (FAT: replaced silently) Unchanged (step 3)
Lost replies (EIO, ETIMEDOUT after the step happened) Shown as failures, unverified Resolved by identity, otherwise reported unconfirmed with both names kept; a copy adopts its own entries
Item deleted and recreated with a reused inode number Passed as the original Told apart by type and, where reported, birth time
Data path GIO: clone, one whole-file copy_file_range, splice, read/write Clone, then chunked copy_file_range (gated per network filesystem and kernel), then read/write
Progress Bulk copies: completion only; second copies and restores: none. "Writing to device…" only for removable drives Bytes for every native file, bulk path included; "Writing to device…" during a final flush on internal disks too (not SMB, whose files sync as written)
Completion report None Renamed items, left-out special files and entries left behind: a notice, a dialog, or a warning; one dialog when the operation stopped or completed with errors
Item cut off by Cancel Counted as failed Counted as not attempted when it was rolled back (local transfers, Trash deletes, restores, merge undos); still failed where something may remain (merges, permanent deletes, remote locations)
Folder monitors during copies Merges and second copies wrote in the visible folder Copies appear in one step; phantom events of unnamed files are ignored, and each linked-in file is queried once
Disk calls on the UI thread (paste, restore, delete, compress) Conflict check, Keep Both probes, stage creation, worker counts, restore and delete checks, archive publish All moved to workers except the paste's conflict check and bookmark cleanup after a delete (step 5)
NFS (v3 and v4.x)
Edge case Before (main) After (this PR)
Move within one share (#1530) Copied twice, then the source deleted Renamed: files linked, folders over a placeholder
Copy into a share Written twice (stage, then a second copy) Written once into a hidden stage, then linked or renamed into place
Replace or Merge without Trash (root-owned export) Replace failed after the full copy; Merge refused Original set aside, then removed after the flush
Undo rename, restore Undo copied twice; restore copied folders Placed, never copied
Archive extraction One hard link per extracted file Folder placed in one rename over a placeholder
Lost CREATE, MKDIR, LINK replies Failures The copy adopts its own fresh entry
Item recreated with a reused inode number Passed as the original v4: told apart by birth time where the server reports one; v3 reports none (documented limit)
Copied file times under a v4 write delegation Could become the copy time Set again after close
Copy within a v4.2 share One whole-file server-side COPY: about 3 s for any file over 2 MiB copy_file_range in chunks of at most 2 MiB, from Linux 5.9
Flush before deleting a moved source None syncfs (commits to the server)
Sparse files from an NFS source Holes filled Unchanged on purpose: copied in full (server holes can't be trusted)
Extracted archive member times (v4.2) Set to now Unchanged
SMB/cifs
Edge case Before (main) After (this PR)
Replace or Merge without Trash Replace failed after the full copy (no EXCHANGE); Merge refused Original set aside, then removed after the copy is flushed
Case aliases inside a copy Later name overwrote the earlier; a move deleted both Numbered and reported
Case-only rename Refused: "already exists" Works; the client's cached lookup is revalidated first
Copy within a share Clone and copy_file_range on any kernel Clone and copy_file_range only on kernels with the client's 2023 fixes (6.7, 6.6.7, 6.1.73)
Flush before deleting a moved source None fdatasync of each written file (an SMB FLUSH)
Hard-linked files read from a share Each name copied separately One name of a file opened at a time (the client refuses concurrent opens); links kept where the share reports them
Another name of a file this machine just wrote Open refused for about 1 s (closetimeo); the copy failed Retried for closetimeo plus 0.5 s, at most 10 s
noserverino, or server inode numbers turned off Device and inode compared (effect not measured) Deletion compares type and birth time instead
Windows-reserved names Copy failed or the server renamed silently Unchanged (step 3)
Archive metadata (read-only members; cleanup after failure) Extraction failed Unchanged
sshfs and other libfuse daemons without `use_ino`
Edge case Before (main) After (this PR)
Move within one mount Copied twice, then the source deleted Renamed over an empty placeholder checked just before (files) or to a name just found free (folders); copied only where the server lacks posix-rename
Hard links as proof of a placement Restore and archives linked unchecked Never trusted: each link reports its own inode; a probe learns this
Replace or Merge without Trash Replace failed after the full copy; Merge refused Original set aside through the same steps
Copies, undo rename, restore, archives Copies written twice; undo copied twice; restore linked files unchecked; archives by hard link Placed through the same steps, never copied again
Extracted symlink times Applied to the target; a dangling link failed the extraction Not set
Same-size rewrite of a file the move just read (server-side too) Deleted Files modified within 3 s of the read (1 s tick, plus 2 s for a server clock behind ours) are compared by content first; a mismatch stays and is reported
Flush before deleting a moved source None fdatasync of each written file, then fsync of the folder
exfat-fuse
Edge case Before (main) After (this PR)
Replace without Trash EXCHANGE silently overwrote: the original destroyed Never exchanges on FUSE: the original is set aside
Case aliases inside a copy Later name overwrote the earlier Numbered and reported
Case-only rename Refused: "already exists" Works, although each spelling reports its own inode
libfuse2 builds (no NOREPLACE, no links) Moves copied twice; archives failed Files placed through the reserved-name step; archives work
Flush before deleting Removable syncfs, which flushed nothing Recognised by udev: syncfs plus one folder fsync per batch; otherwise fdatasync per file plus the folder
Same-size rewrite within a time tick Deleted (no check at all) Files modified within 2 s of the read are compared by content first
ntfs-3g
Edge case Before (main) After (this PR)
Move within one volume Copied twice, then the source deleted Renamed: files linked, folders over a placeholder
Move or copy onto it from another disk (#1530) Staged copy, then a second full copy One copy, linked or renamed into place
Replace or Merge without Trash Replace failed after the full copy; Merge refused Original set aside
Restore Folders copied; Trash copy deleted unflushed Placed without copying
Flush before deleting Removable syncfs, which flushed nothing Recognised by udev: syncfs plus one folder fsync per batch; otherwise per file
ntfs3
Edge case Before (main) After (this PR)
TAR and TAR.GZ of small files Stored as zeros: silent data loss Stored in full
Replace without Trash Failed after the full copy (no EXCHANGE) Original set aside
Restore by copying (rarely needed: restores rename) Failed on ntfs3's $LX* attributes $LX* never copied; restore copies pass
Item recreated with a reused inode number Passed as the original Told apart by birth time
Flush before deleting a moved source Removable drives only syncfs, which flushes the device
vfat
Edge case Before (main) After (this PR)
Replace without Trash EXCHANGE, the original deleted at once EXCHANGE; the original deleted only after the flush
Case aliases, including 8.3 short names Numbered silently by an uppercase key Numbered on the drive's own "exists" and reported; undo gives names back
FAT-invalid characters Replaced silently Still replaced silently; names that then collide inside a folder are numbered and reported
Case-only rename Refused: "already exists" Works
Flush before deleting syncfs, which flushed nothing syncfs then a folder fsync, which flushes the drive
Same-size rewrite within the 2 s tick (no change time) Deleted (no check at all) Files modified within 2 s of the read are compared by content first; moving a just-saved file takes up to 2 s longer
Inode numbers reassigned after eviction Compared (effect not measured) Deletion compares type and birth time instead
exFAT (kernel)
Edge case Before (main) After (this PR)
Replace without Trash Failed after the full copy (no EXCHANGE) Original set aside
Case aliases, case-only rename As vfat: numbered silently; renames refused As vfat: numbered and reported; renames work
Flush before deleting syncfs, which flushed nothing syncfs then a folder fsync
Same-size rewrite within a tick Deleted (no check at all) Compared by content within FAT's 2 s window. A source folder that had an entry renamed into it stays whole (exFAT moves its birth time)
ext4
Edge case Before (main) After (this PR)
New file copy Hidden stage, then GIO's own temp file and an fsync per top-level file Unnamed O_TMPFILE, linked when complete: a crash leaves nothing; no fsync for plain copies
Replace without Trash EXCHANGE, the original deleted at once EXCHANGE; the original deleted only after the flush
Item recreated with a reused inode number Passed as the original Told apart by birth time
Sparse files Holes filled Holes kept
Flush before deleting a moved source (internal disk) None syncfs
Casefolded ext4
Edge case Before (main) After (this PR)
Case aliases inside a copy Later name overwrote the earlier; a move deleted both sources Numbered and reported (casefold flag read per folder)
Case-only rename Refused: "already exists" Works
btrfs and XFS
Edge case Before (main) After (this PR)
Move across btrfs subvolumes or mounts Copy (reflinked) plus delete; folder metadata lost Reflinked copy keeping folder metadata; sources deleted entry by entry after syncfs
Replace or Merge on subvol= mounts (no Trash) Replace exchanged and deleted at once; Merge refused Exchange with the delete after the flush; Merge sets aside
Sparse files across filesystems Holes filled Holes kept
tmpfs
Edge case Before (main) After (this PR)
Replace (no Trash) EXCHANGE, the original deleted at once EXCHANGE; the original deleted after the flush
Merge (no Trash) Refused when a file would be overwritten Originals set aside, removed after success
Sparse files Holes filled Holes kept
ZFS
Edge case Before (main) After (this PR)
OpenZFS 2.1 and earlier: move within a dataset Copied twice, then the source deleted Renamed: files linked, folders over a placeholder
Replace without Trash 2.2+: exchanged and deleted at once; 2.1: failed after the copy 2.2+: exchanged, deleted after the flush; 2.1: set aside
Case-insensitive datasets Aliases overwritten; case-only renames refused Aliases numbered and reported; case-only renames work
Sparse files from ZFS Holes filled Unchanged on purpose (some releases report holes over unsynced data)
overlayfs
Edge case Before (main) After (this PR)
Replace and Merge (no Trash) Replace exchanged and deleted at once; Merge refused Original set aside instead (no exchange on overlayfs), deleted after the flush; Merge sets aside
Moving a lower-layer folder (no redirect_dir) Copy plus delete; folder metadata lost Copy keeping folder metadata; sources deleted entry by entry after syncfs
vboxsf, rclone, s3fs
Edge case Before (main) After (this PR)
Move within one share Copied twice, then the source deleted Renamed over a checked placeholder (files; vboxsf folders too)
Copies, Replace without Trash, archives Copies written twice; the others failed Placed through the same step
Restore Copied with strict metadata Placed without copying
Rename (F2) Only GIO's check protected the target Renamed over an exclusively created, checked placeholder
Flush before deleting a moved source None rclone, s3fs: fdatasync per file plus the folder; vboxsf: per file plus syncfs
9p, ceph
Edge case Before (main) After (this PR)
Move within one mount Copied twice, then the source deleted Renamed: files linked, folders over a placeholder
Replace without Trash Failed after the full copy Original set aside
Restore Folders copied Placed without copying
Flush before deleting a moved source None fdatasync per file plus syncfs
GVfs locations (`smb://`, `sftp://`, `mtp://`)
Edge case Before (main) After (this PR)
Native item moved there GIO moved files unchecked; folders: top item checked only Recorded before GIO copies it; each source entry deleted only if unchanged. No flush or content check; a torn copy stays there
GVfs item moved to SMB or FUSE Copy not synced before the source was deleted Files synced one by one before the source is deleted
Keep Both Exists probes block the UI thread Probed without blocking the UI thread; retries on "exists"
Paste conflict check Synchronous on the UI thread Unchanged (step 5)
Item moved from a GVfs location GIO copies it, then deletes it unchecked Unchanged

Visual evidence

Pending upload. Step 2 adds a completion report; step 1 changed no layout. The captures were taken on a private headless display and still need to be attached here:

  1. Before (end of step 1): copying Makefile and makefile onto a case-insensitive folder fails with "An item with that name already exists".
    After: a notice says the destination can't tell these names apart and makefile was saved as makefile (1).
  2. After: information dialog "11 items were saved under new names", with 8 lines and "… and 3 more items".
  3. After: information dialog "Some names changed during the transfer", with an alias section and a name taken during a merge.
  4. Before: copying a folder with a named pipe and sockets fails.
    After: the pipe is recreated, and "Completed with errors" lists the two sockets as left out.
  5. Before: moving that folder to another disk is refused.
    After: "3 items were left in their original folders". The reasons are a log written during the move, a socket, and a file in a read-only folder.
  6. After: one dialog after a cancelled move and after a failed copy, with the outcome first and then the report. The cut-off file counts as not attempted.
  7. After: "Writing to device…" during the final flush of a move to an internal disk.
  8. After: one "Completed with errors" dialog for a restore that failed one item and left parts of another in the Trash.
  9. After: 1, 3 and 5 in a light Omarchy theme.

How to test

Step 1

  1. Mount an NFS export or an sshfs share; scripts/fslab/fslab.sh up provides both inside a container. On that mount, move a large file and a folder into another folder.
    Expected: the moves finish instantly without copying.
  2. Paste the same names again and choose Replace, once with Trash available and once without (for example on a root-owned NFS export).
    Expected: the item is swapped, with no "unexpected system error" and no second copy pass.
  3. On a vfat or SMB mount, rename notes.txt to Notes.txt with F2, then undo.
    Expected: both succeed.

Step 2

  1. Build a test folder on a local disk:
    mkdir -p ~/t/src/ro && cd ~/t/src
    echo a > a && ln a b && mkfifo pipe && truncate -s 1G sparse.img
    python3 -c 'import socket; socket.socket(socket.AF_UNIX).bind("sock")'
    setfattr -n user.note -v hi ro && chmod 0555 ro && touch -d 2001-01-01 ro
    Copy src to another folder on the same disk, then to another disk.
    Expected:
    • ro keeps mode 555, its 2001 time and user.note.
    • a and b share one inode wherever the destination keeps links.
    • pipe is a FIFO.
    • sparse.img takes almost no space on ext4, btrfs or tmpfs.
    • A warning lists sock as left out.
  2. Copy a folder holding Makefile and makefile to a vfat or exFAT stick.
    Expected: makefile (1) appears on the stick, with a notice. If you move the folder there instead and then undo, both names come back.
  3. Run while :; do date >> ~/t/live/log.txt; sleep 0.2; done, then move ~/t/live and a few other files to another disk.
    Expected: everything else moves. log.txt stays, with a warning that it changed during the move.
  4. Move 300 small files, selected individually, to another disk.
    Expected: they copy in parallel, "Writing to device…" shows at the end, and every file arrives.
  5. Cancel a large folder copy halfway, and cancel a Merge halfway.
    Expected: no partial or truncated item under a real name.
  6. Optional: scripts/fslab/fslab.sh test local_operations:: repeats the unit tests on every lab mount.

Related issue

Closes #1530
Closes #1590

ogarza added 30 commits October 12, 2026 22:31
…rough one no-clobber ladder

Moves, staged copy publication and Trash restores now go through one
placement routine: renameat2(NOREPLACE), then a checked hard link for
files or a rename over a fresh empty placeholder for folders, then a
recreated link for symlinks, and a copy only across devices. EINVAL and
EPERM on a user's item are resolved by a probe on Strata's own names, so
an invalid name is reported as such instead of turning a same-volume
move into a copy, and nothing is unlinked without identity proof.

What each mount supports is cached per mount id and dropped on mount
changes. Stages are created and discarded relative to their pinned
parent off the main thread, symlink sources are staged as finished links
and symlink copies are always exclusive. The removable flush reads the
mount table off the main thread. Tests emulate filesystem quirks through
scoped seams instead of the rename closures and the cross-volume flag.

Refs #1530, #1590
… the main thread

Extraction publishes staged roots through the shared placement ladder,
so folders move whole over a placeholder where RENAME_NOREPLACE is
missing, and the per-entry migration from #1586 is removed. A file on a
mount with neither NOREPLACE nor hard links stays in staging with the
new "can't guarantee" reason.

Compression stages, encodes, chmods and publishes in one blocking step.
Keep Both numbers names through the ladder. Replace picks Trash,
EXCHANGE or set-aside before encoding and refuses up front when none is
safe; an original removed without Trash is deleted only after the
removable flush and records no undo entry.

Refs #1530
…urce on same-filesystem moves

Replace now plans in one blocking step: it pins both parents, stats both
items, and picks a strategy before anything is copied. A replacing move
within one filesystem renames the source onto the target through the
ladder. Other replaces copy to a stage, then move the original out of the
way by one of three routes: Trash, EXCHANGE (only on allowlisted
filesystem types, checked fresh with fstatfs), or an identity-checked
set-aside with put-back. When none of these is safe, the replace is
refused with R1 before staging. Originals that were exchanged or set
aside are deleted only after the removable flush.

An item that replaced its original without Trash no longer reports a
created location, so undoing the paste never deletes the only remaining
copy. The other items in the paste still undo normally.
Where Trash is unavailable, a merge moves each original it overwrites
into one hidden .strata-merge-* folder inside the merge target, through
the no-clobber ladder, and deletes that folder by identity only after the
merge succeeded and removable media were flushed. A failed aside or a
cancel puts every original back first; a failed copy puts back what it
can and names the folder that keeps the rest. Set-aside originals are
left out of the merge undo, so undo never deletes the only copy.

The merge takes fat_family from paste instead of reading the mount table
on the main thread, and native Trash staging runs on a worker.
…nly renames

F2 and undo rename of native items now run rename_in_place in one blocking
step. A name that only differs in case on a case-insensitive mount is renamed
through a hidden .strata-rename-* name, moved back on failure, and reported
with the temporary name when that fails too. Where a probe on Strata's own
names confirms the mount has neither RENAME_NOREPLACE nor hard links, a file
is renamed over an exclusive empty file reserved at the new name and a folder
after checking the name is free, so renames keep working there.

Undo rename never copies: remote items use a GIO move without the copy
fallback. The typed-rename prompt checks the name off the main thread and
only treats it as taken when another item holds it.
GLib 2.86 to 2.88.4 can return FALSE without an error and leave the item in
place, and gio-rs then reports success. Native items are now trashed in one
blocking step through g_file_trash directly: FALSE without an error, or the
same item still at its name afterwards, fails as NotSupported with a new
reason, so deletion offers Delete permanently, Replace, Merge and compression
fall back to their aside paths, and no undo is recorded for an item that never
moved. Every trash site uses the shared helper, and the main-thread identity
lstat before trashing is gone.
…nd bound retries

Review fixes for the shared placement ladder:

- place() with an expected identity now checks the entry after every rung,
  including a plain renameat2(NOREPLACE), and moves a substituted item back
  (Changed) instead of placing it.
- A link refused for one item while the mount supports links
  (protected_hardlinks on NFS and similar) is Unsupported, not EPERM, so
  moves fall back to copying again. Nothing is learned from it.
- An ambiguous mkdir error is Exists only when something is at the name, and
  the placeholder check reports I/O errors instead of Exists. Numbered
  extraction and archive names stop after 10,000 attempts.
- The F2/undo last rung now requires no_replace=false and hard_links=false
  for folders too, as the commit that added it states. Known values come
  from the capability cache; only unknown ones are probed, once.
- Probes skip the rename test once it is known and test links for files in
  the same pass; a folder move skips the ".." walk once NOREPLACE is known
  missing, and fd stats use AT_STATX_DONT_SYNC through the seam.
- Staged publication tries rung 1 before any stat.
- Compression records the original's identity before encoding and refuses
  to replace an item swapped in meanwhile; it shares the displaced-original
  removal and the EXCHANGE-rejected fallback with paste replace.
- Move failures keep the item name in pasted and undo messages.
- Hidden-name prefixes live in one place, classified by whether they may
  hold user data; duplicated blocking and test helpers are shared.
A parallel file copy that failed or was cancelled after creating its hidden
stage dropped it without cleanup, leaving a partial .strata-replacement-*
file in the destination. The stage now goes through the identity-checked
discard on every failure. A new file stage takes its identity from the
created fd instead of a second path stat.

Tests cover the cancelled copy and the "staging copy could not be removed"
report after the exclusive re-copy rung.
Restore and archive publication no longer require RENAME_NOREPLACE, and
archive Replace no longer overwrites. The unsafe-code inventory lists the
direct g_file_trash call that works around GLib #4060.
"Could not stage %{path}: %{error}" lost its only user when symlink copies
stopped staging; it is removed from all eleven catalogs.
Replace the eight-argument merge wrapper with a hooks builder and name the probe test's case tuple.
…ithout NOREPLACE

On a mount without RENAME_NOREPLACE where hard links work but one file
refuses them (EPERM from linkat under fs.protected_hardlinks, or EMLINK),
F2 and undo rename now use the same last rung as mounts without links:
reserve the new name with an exclusive empty file, then rename over it.
Folders still need links confirmed missing, and moves keep their copy
fallback. The item's refusal is never learned for the mount.
SMB with POSIX extensions fails open(O_CREAT | O_EXCL) over an existing
folder with EISDIR instead of EEXIST. New-file numbering (GIO's
IsDirectory), the hidden-name loops for probes, compression and replace
stages, and the rename reservation now try the next name instead of
failing. EISDIR from mkdir or any other call is still an error.
The runner still never inherits TMPDIR. An absolute STRATA_TEST_TMPDIR
places the fixtures created with tempfile on another filesystem, which the
real-filesystem lab uses; a relative value is refused before anything starts.
scripts/fslab/fslab.sh runs one privileged, network-less Docker container
that mounts kernel NFS v3/v4.2, Samba over cifs (default, nocase, posix),
vfat, exFAT, ntfs3, ntfs-3g, exfat-fuse and casefolded ext4 over loopback,
then runs syscall probes or the scoped Rust tests with their fixtures on
each mount. CI never runs it.
Record the no-clobber guarantees, the placement ladder, per-mount
capabilities, Replace/Merge/Rename/Trash behaviour, hidden names, a
filesystem capability table, and how to test with fault injection and
the real-filesystem lab.
ext4, ntfs3, NFS and CIFS hand a freed inode number straight to the next
item created, so a (device, inode) identity let a folder that replaced a
file stage pass as the stage and be deleted recursively, and let an
archive re-saved during encoding be replaced.

LocalFileIdentity now also carries the file type and the statx birth
time; birth times are compared only when both sides have one. The delete
walk, restore cleanup, archive publication and placement proofs read
identities through the fs_ops seam, so every check fails closed on a
type change.

File stages are no longer pre-created and copied over with
G_FILE_COPY_OVERWRITE. The copy creates the stage exclusively at a fresh
hidden name, retrying the next name when it is taken, and the stage's
exact identity is recorded afterwards. A failed copy's partial stage is
removed only when it is a regular file with one name. This drops the
relaxation that accepted any regular file at a stage's name, and GIO's
temporary file, rename and fsync per staged file.

The fs_quirks seam gains reused inode numbers and missing birth times,
which reproduce the fslab failures on tmpfs.
Renaming "report" to "Report" was refused as "already exists" on both.

On SMB the client keeps the positive lookup of "Report" from the alias
check for about a second, so the second renameat2(NOREPLACE) failed with
EEXIST. The new spelling is now revalidated with AT_STATX_FORCE_SYNC
before the second step, which costs one syscall on the case-only path.

exfat-fuse (libfuse without use_ino) gives each spelling its own inode
number, so the alias was never recognised. When the identities differ,
the new spelling still counts as the item itself when the folder lists
no such name, the source is the only name equal to it ignoring case,
and type, size and modification time agree.

The fs_quirks seam emulates both, and the case-only rename test runs
each shape.
tar 0.4.46 treats a file that reports zero allocated blocks but a
non-zero size as entirely sparse and never reads it. ntfs3 keeps small
files inside the MFT record and reports st_blocks 0, so TAR and TAR.GZ
archives made from them held zeros: the fslab run on ntfs3 failed
every_compression_format_commits_a_readable_archive and three other
archive tests, and strace showed the source opened and seeked but never
read. ext4 inline data and FUSE filesystems can report the same.

Sparse detection is now off for archives Strata writes, so members are
always read; sparse files are stored in full.
publication_without_rename_flags_moves_unreadable_subtrees_whole left
ro/sub at 0555, so its temporary directory and the empty extraction
stage were never removed. It now records the mode it checks, then
restores write access before asserting.
…bers

The archive swap test told the recreated archive from the original by the
temporary filesystem's real birth time, after a 20 ms sleep. It failed on
filesystems without birth times, such as exfat-fuse in the lab.

Names under `reused_inodes` now report a fake birth time that changes only
when a test marks a new item there with `recreated`, so the test needs
neither the real birth time nor the sleep, and still fails if identities
ignore birth times.
copy_recursively_local read each source file's size with fstat and
checked every folder of a staged copy with g_file_query_exists, both on
the GTK main thread. A folder stage was copied with OVERWRITE only so that
its root, made beforehand, would be skipped, which also probed every
folder inside it.

A recursive copy now says what it meets at the target: nothing, a fresh
folder made beforehand, or entries a merge copies over. A fresh stage's
root is used without a probe and its children are created without
OVERWRITE. The source size comes from the fstat the worker already makes
when it opens the file, and a merge checks for existing folders with an
asynchronous query. Merge keeps its flags.
Once the cifs client turns server inode numbers off, each name of a
hard-linked file reports its own inode number, so the capability probe
could not match its link to the file it created and left
.strata-probe-* files behind.

The probe now also removes an empty probe file whose inode number
changed when its device, type and birth time still match the file it
created, since cifs keeps reporting the server's creation time. A file
that neither its inode number nor its birth time proves is kept. The
fs_quirks seam emulates per-name inode numbers for hard links, with the
creation time all names of a file share.
GIO's make_directory_async reports a cancel even when its worker already
made the folder, so a copy cancelled in that instant never recorded the
folder as its own and left it behind instead of removing it. This made
cancelling_staged_fallback_removes_its_reserved_directory fail now and
then on slow mounts, where the test saw the folder before the result
arrived.

Copy folders are now made with the synchronous call on a worker, whose
result stands. The test skips the placement placeholder and pauses
between main loop passes, so the cancel lands in that window every time.
The container bind-mounted the live scripts/fslab at /lab, so a rebase that
recreated the directory left it running "/lab//deleted" and `down` could no
longer start its teardown. Every fslab.sh command now copies the scripts to
/opt/fslab in the container first, and every docker exec runs from /, which
no lab unmount can remove.
One busy mount stopped `down` before it detached any loop device, which left
every idle lab device attached. `down` now unmounts and detaches everything
that is free, with the same backing-file and exclusive-open checks, deletes
the freed images, and keeps the container, the busy mount, its server and
its loop device record for the next `down`.
After full runs the kernel kept a reference to the NFSv4.2 export once nfsd
had stopped, pinning its loop device and memory on the host until reboot.
Each export is now its own container-private tmpfs, so a leaked reference
pins only that small tmpfs. NFSv4.2 still passes on tmpfs birth times.
ogarza and others added 16 commits October 14, 2026 16:55
… gone

A created item removed before the undo must still be reported as
removed, so a view that lists it drops it.
The test feeds monitor changes to a folder its source never watched.
Since a restore now reloads open folders without a monitor, the source
watches the folder, as a real monitor would.
Since operation changes stopped reloading every column, the finish of a
foreground operation set file_operation_refreshed whenever any watched
column rescanned. A cancel then reported no affected folders, so open
folders without a monitor were never reloaded, and a completed deletion
or restore skipped removing what left those folders. Both now behave as
they do without a rescan; watched folders still reconcile through their
rescan alone.
…arge removal

A foreground deletion or restore of more than 64 items reloaded an open
Recent or camera view at once, and that view reloaded again after the
progress closed whenever its monitor had asked for a rescan, as those
monitors do for every change. The bulk removal now leaves a rescanning
view to the reload that follows the operation.
The reload after a cancel also reloaded every open folder without a
monitor that is an ancestor of an affected path, and checked this by
walking each affected path's ancestors for each open column, which took
hundreds of milliseconds on the main thread after a large cancel. Every
provider already reports the parents of what it changed, so only folders
at or below an affected path reload now.
A background cancel, failure or transfer failure reloaded a remote
destination before its outcome and again when the view or the failure
handling reloaded it. A transfer failure also reloaded watched folders,
twice when they had rescanned, and a foreground failure reloaded
nothing although a paste can fail after placing items. Failures now
reload the open folders without a monitor at or below what the
operation touched, and a background cancel leaves its reload to the
view, as a foreground one does.
… completes

A completed transfer, rename, creation or compression reloaded only the
remote folders it changed, so a local folder without a monitor kept
listing an item moved out of it, and a foreground compression reloaded
nothing. These endings now reload every open folder without a monitor
that the operation changed and leave watched folders to their monitor.
A destination that the transfer then reveals is reloaded once by the
reveal instead, which now also reloads it when it already listed the
items, so a replaced item no longer keeps its old details.

The rename undo tests list a watched folder now, as their monitor
updates assume.
…peration ends

An extraction, or a compression whose result no longer had a pending
destination, reloaded the active folder when it ended, even when that
folder was watched and an open folder without a monitor that received
the result was not the active one. Its result is now selected through
the folder's monitor, and only the folder that received it reloads when
it has no monitor. A cancelled or empty archive operation reloads
nothing here; the cancel reload covers what it changed.
Applying a completed deletion or restore matched every removed location
against every open column, rebuilding each location's parent on the
way. A large removal now groups its locations by folder once and looks
up each column's group; Recent and camera views still filter the whole
set.
…flushes

Every caller of flush_deferred_file_operation_changes passed false for
prefer_refresh, and nothing has read its result since operation
endings stopped tracking whether a column reloaded. The parameter, its
reload-everything branch, the result and the test that only exercised
that branch go.
Keep concurrent arrivals and detectable edits out of destructive undo records. Guard merge move sources with actual destination identities, including fallback copies, and preserve concurrent archive arrivals. Refs #1530, #1590.
Keep a dismissed asynchronous rename from submitting a reopened prompt with the same target and text. Refs #1590.
Keep originals intact and stages unpublished after delayed close errors or cancellation during finalization. Refs #1590.
Initialize capability invalidation independently of folder preference lookups without reading a mount snapshot on this path. Refs #1590.
@l0gicgate

Copy link
Copy Markdown
Contributor

Historical lab measurements moved out of maintained documentation during review. These are the PR author’s recorded measurements, not results from this review’s validation.

Lab measurements

Flushes counted on the backing loop device for one call after writing 16 KiB, over three rounds.
NFS and sshfs serve tmpfs in the lab, so they have no device to count.

Mount Close syncfs fsync of the folder fdatasync of the file
ext4 0 2–4 2 2
vfat with flush, exFAT 0 0 1 1
ntfs3 0 1 1 1
exfat-fuse, ntfs-3g 0 0 1 1
SMB, on the server's device 0 0 0 2

On vfat, exFAT and both FUSE daemons syncfs writes the data back without flushing, and a folder's
fsync alone writes no file data on vfat and exFAT. Both FUSE daemons sync and flush their whole
device for a folder's fsync: after ten unsynced files it costs one flush, and ntfs-3g skips the
flush of a clean device, so ten fdatasync calls also cost one. Moving 300 files to exfat-fuse in
two batches took 302 flushes with a sync per file and 2 with one folder fsync per batch; on
ntfs-3g about 290 and 2.

Moving 1,000 files of 4 KiB with one flush per item cost 1,000 flushes and 16.6 s on vfat, against
13.2 s with the default batches, and 3.7–3.9 s on ntfs3 and exfat-fuse, against 0.13–0.15 s.
Batch limits from 64 items up differ by at most 0.1 s per 1,000 files, and large files show no
difference. The loop devices sit in memory, so the flush latency of real USB media is not measured.

Copying a folder of 2,000 files of 1 KiB with eight workers, in seconds:

Mount No name claimed Every name claimed GIO, one file at a time cp -r
tmpfs, ext4 0.01 0.01–0.02 0.02–0.03 0.01–0.02
vfat with flush 26.0 26.2 208 208
exFAT 0.16 0.15 0.16 0.15
NFSv3 0.09 0.12 0.12 0.09
NFSv4.2 0.12 0.13–0.19 0.16 0.12
SMB, case-insensitive 0.57–0.63 0.61–0.67 0.69–0.80 0.68
sshfs 10.4–13.2 1.9–3.7 97–106 113–129

vfat waits 100 ms at each close of a written file, which bounds every copier there. The lab's SFTP
round trip takes about 46 ms, so the sshfs row compares copiers only.

Keep compatibility and safety rationale while removing field labels, control-flow summaries and test narration. Name independently copied files directly. Refs #1590.
Move historical measurements to the PR, state remaining UI-thread limitations and document checked descriptor close. Refs #1590.
Extract progress, replacement/merge, deletion and restore orchestration. Share native descriptors, names and publication primitives; preserve distinct safety and durability policies. Consolidate syscall injection and repeated test setup without removing distinct behavioral coverage. Refs #1590.
Stage selected symlinks and named pipes before publication. Keep sources and decline destructive undo when a fallback cannot prove ownership; never apply pipe metadata to a concurrent regular file. Refs #1590.
Keep worker-only filesystem assertions intact when GTK tests construct archive fixtures. Refs #1590.
Rescan ambiguous monitor batches instead of allowing asynchronous move metadata to remove a newly recreated entry. Refs #1590.
The existing rename-visibility fixture could lose its setup double-click after F5, reproduced on main. Use focused keyboard activation for setup; retain pointer activation coverage in test_click_modes and all existing rename assertions.
@l0gicgate

Copy link
Copy Markdown
Contributor

Review fixes are pushed through c73a5978; latest verified main (4ec310dc, including #1614) is merged without rebasing or force-pushing.

  • Carry known publication ownership through moves, merge descendants, replacement/copy-out and undo. Concurrent arrivals and detectable pre-record edits no longer become deletion evidence. Unprovable GIO/recreated special-file copies fail closed; unstable destination identities can conservatively retain sources.
  • Preserve fix(trash): handle FUSE mounts that refuse Trash and describe deletion by location #1614’s typed Trash refusal/cache/cleanup alongside verified GLib results, guarded undo and cancellation accounting. Fix private archive publication cleanup, final-close errors and late cancellation.
  • Invalidate dismissed rename requests, initialize mount invalidation independently of preferences, and reconcile monitor batches where a moved name is recreated.
  • Extract operation subsystems and native/environment boundaries; consolidate syscall injection and publication/name primitives. Share 236 repeated test-lock setups while retaining distinct behavioral coverage. Remove redundant comments and correct overbroad documentation claims. Historical lab results are retained above.

Manual verification: on a private display/session bus, cancelling and reopening a typed rename leaves the original intact until explicit confirmation; confirmation preserves contents. Copying a folder containing a regular file and a socket copies the file and reports the skipped socket in one completion warning.

Visual evidence remains pending upload. Current screenshots were captured and inspected, but the available GitHub CLI cannot upload attachments through the editor. They are retained locally for the owner to attach; no local links are being presented as uploaded evidence.

Local validation and scope

All builds and GUI checks used the verified pinned image, task-isolated rootless Podman, private Xvfb and private D-Bus.

  • ./scripts/quality.sh fmt and ./scripts/quality.sh clippy: pass (cargo fmt --all --check; cargo clippy --locked --all-targets --all-features -- -D warnings).
  • STRATA_REQUIRE_DEVICE_TESTS=1 ./scripts/quality.sh test: final complete run 3,052 passed, 0 failed, 10 ignored. Six initial failures were GTK fixture construction invoking worker-only archive I/O; fixed without relaxing production assertions, reproduced/rerun individually, then the complete requested Cargo gate passed.
  • Canonical STRATA_CONTAINER_ENGINE=podman STRATA_E2E_WORKERS=4 ./scripts/e2e.sh: initial complete run 1,111 passed, 3 failed. The recreated-name monitor failure was fixed. A rename fixture’s setup failure reproduced on untouched main and now uses focused keyboard activation; separate pointer click-mode coverage remains. The original folder-undo timeout passed its exact rerun. Final affected run of test_inline_renaming.py, test_rename_visibility.py, test_click_modes.py and the model-preview regression: 129 passed. Unrelated E2E cases were not rerun merely to replace the original summary.
  • python3 -B -m unittest discover -s scripts -p 'test_*.py': 221 passed. Relevant Rust regressions, including i18n coverage, also passed; logs preserve intermediate failures and scoped fixes.
  • No privileged real-filesystem lab, real-share/removable-media tests, or runtime-patch/release validation was performed. No such coverage is claimed. Existing documented identity/timestamp/placeholder limitations remain.

This is not a merge-ready declaration: the PR is still draft, substantive CI is skipped by the draft conditions in ci.yml, and its declared steps 3–8 remain outside the implemented review cleanup. Commit history was preserved; no PR merge was performed.

@l0gicgate
l0gicgate marked this pull request as ready for review October 16, 2026 14:30
@blacksmith-sh

blacksmith-sh Bot commented Oct 16, 2026

Copy link
Copy Markdown

Found 1 test failure on Blacksmith runners:

Failure

Test View Logs
test_settings_text_size_keeps_switches_inside_the_page View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@ogarza
ogarza marked this pull request as draft October 16, 2026 18:26

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants