Writes, conflicts and atomicity¶
Every change the user makes in the mount reaches the agent as a createItem, modifyItem or
deleteItem. Content always lands on the server through a temp file and a rename, never written
in place, and every upload ends with an lstat whose result becomes the item's version.
The upload protocol¶
The temp name is <dir>/.sshdrive-upload-<mac8>-<uuid>, in the destination's own directory
(stale temp files).
- Enter the in-flight set for the path (below).
- Upload to the temp name, opened with the Mac file's permission bits in the
openattributes (0644for an ordinary file,0755when the local file is executable; the server's umask still applies), assftp putdoes. - Move it into place:
- create: the plain, non-overwriting SFTP
rename(creating); - modify:
lstatthe target for the conflict check, thenposix-rename@openssh.comover it (replacing).
- create: the plain, non-overwriting SFTP
setstatthe mode back (on a modify, the old file's mode) and set the mtime to thecontentModificationDatethe system passed in, truncated to whole seconds, since SFTP v3 carries no more.lstatthe result and write the row (after the upload).- Leave the in-flight set.
Durability: fsync@openssh.com after each upload, when the server offers it.
Creating¶
The plain SFTP rename does not overwrite. OpenSSH's process_rename implements it as link +
unlink, so it fails atomically if anything now holds the name (a hidden link, a collision, a
file created meanwhile). On a filesystem without hard links it falls back to stat + rename,
which still refuses an existing name, with a race between the two calls.
Either way the refusal arrives as a bare FAILURE (the SFTP client). The agent
lstats the destination to confirm it:
lstat of the destination |
Reported as |
|---|---|
| something is there | .filenameCollision |
| nothing is there | an ordinary sync error |
.filenameCollision is not a standing refusal
The system draws no alert for it, puts nothing in the pending set, and retries the create on a
doubling backoff for ever (MQ-014). It may only be answered when the name is about to stop
being taken - the conflict copy, which moves our own file aside. Used as a
permanent refusal it is the .Trash loop of
names and attributes again.
Servers whose plain rename overwrites. Servers that are not OpenSSH may overwrite. The probe
tests this once, in the location root (SQ-034); where it overwrites, every create and rename gets
an lstat preflight instead, and status shows the cost (the CLI).
sshdrive set <name> create-check lstat forces the preflight on a server the user does not trust
on this point.
Replacing¶
posix-rename@openssh.com over the target makes the replacement atomic. The lstat for the
conflict check comes after the upload and immediately before the rename, so the conflict window is
one round trip rather than the length of the upload.
- Owner and group are not restored (that needs root), and hard links to the old inode are broken. Both are documented.
- Without
posix-renamethe server getsremove+rename, a non-atomic window thatstatusreports as a degraded capability. - The directory must be writable. Creating the temp file needs write permission on the
directory, so a file the account can write in a directory it cannot is not saveable through
SSH Drive: the upload fails with
PERMISSION_DENIED, which becomes a sync error. Where the identity is known, the capability mapping (names and attributes) already shows such files locked.
Writing in place was rejected. It would keep the inode, owner, ACLs and hard links, but leaves a truncated file if the connection drops mid-upload, and a partial-file window on every save.
Case-only renames¶
A Finder rename that changes only case (Makefile to makefile) is an ordinary non-overwriting
rename on a case-sensitive server. On a case-insensitive one - a macOS server, a Samba-backed
share - OpenSSH's link fails with EEXIST, the confirming lstat finds a file, and the rule
above would report a collision for a legitimate rename.
So when the lstat confirms a destination and the two names differ only by case or by Unicode
normalisation (APFS is insensitive to both):
- Ask SFTP
realpathfor both names. - If they agree, the names are one file: redo the rename with
posix-rename@openssh.com, which isrename(2)and changes case in place. - Where the server lacks
posix-rename, rename through a temporary third name.
After the upload¶
The lstat in step 5 of the protocol is what keeps the agent's own writes
from coming back as remote changes. The agent:
- records that size and mtime as the item's
content_version(the index); - resets the row's
inodeandmtime_nsto null, because the rename gave the path a new inode thatlstatcannot report; - returns an item from
createItem/modifyItemcarrying the date thelstatread back. That is the truncated date when thesetstatwas honoured, and the server's own write time when the account may not set times, so the system's copy and the server's agree either way.
The next poll, sweep or helper event for the path then finds a version the index already holds, records the fresh inode and ns-mtime, and reports nothing.
The lstat is not optional for a second reason: the system believes whatever version a
modifyItem reply carries (MQ-013). A version the agent invents is one the next sweep will not
recognise, and nothing will correct it.
The in-flight set¶
Between the rename landing and that lstat, a helper event or a concurrent poll would see the path
with its new inode and a version the index does not hold yet, report the agent's own write as a
remote change, and make the system re-fetch the file it just wrote.
So every path with an upload in flight sits in a per-location in-flight set:
- the differ skips dirty paths in it;
- the coalescer holds their events;
- both are released after the post-upload row is written, when the held events find a version the index already holds and report nothing;
- it serialises two saves of one file in quick succession, so the second
modifyItem's conflict check runs against the first's result rather than racing it.
Conflicts¶
A save conflicts when the lstat's size or mtime, or the row's generation, differs from the
corresponding field of the baseVersion the system passed. The remote changed underneath the user.
The generation comes from the index, not the wire
(the index): a remote rewrite of equal size
within the same second is visible only through the inode or nanosecond evidence that bumped it,
and a check that read the lstat alone would overwrite it.
On a conflict the agent:
- Renames the temp file, which already holds the local content, to
<name> (conflicted copy from <Mac name> <date>).<ext>beside it.<Mac name>is this Mac'sLocalHostName, since it is the Mac's content being set aside. - Returns the remote item as current.
- Records a working-set anchor for the new sibling, and logs.
- Calls
NSFileProviderManager.evictItem(identifier:)on the item, so the next open downloads the remote content. The call is retried with a doubling backoff from 0.25 s and given up after seven attempts, logging either way. - Signals the working set.
This mirrors Dropbox and OneDrive and never loses data. The last two steps are what make it work:
- Eviction: the system takes a returned version at face value. It never re-fetches and never
re-offers the edit (
MQ-013), so returning the remote item alone leaves the replica holding the local bytes under the remote version for ever. AnevictItemissued straight after the reply is refusedNSFileProviderErrorNonEvictable(-2008) while the system finishes the modification, hence the retry; the first retry has always been enough (MQ-017). - Signal: the copy is a new sibling in a folder the system has already listed, and a folder is
enumerated once, ever (the root set,
MQ-001). Without the signal its anchor is a row nobody asks for and Finder does not show it.
The check-then-rename is not atomic. A remote write landing in that one round trip is lost, as it would be between any two SFTP clients.
Stale temp files¶
<mac8> in .sshdrive-upload-<mac8>-<uuid> is the first eight hex digits of an identifier minted
once per install and kept at the top level of config.json, so every temp file says which Mac made
it.
| Temp file | Removed |
|---|---|
this Mac's <mac8>, not in the in-flight set |
as soon as the agent lists its directory, however new it is: the upload it belonged to died with a connection or an agent |
another Mac's <mac8> |
once it is 30 days old. Another Mac's upload may legitimately take longer than a day over a slow link, and its own agent removes the file the moment it lists the directory again. |
The ignore patterns elsewhere (names and attributes,
change detection) match .sshdrive-upload-* and need no change.
Deletes¶
| Situation | Behaviour |
|---|---|
| non-empty directory, no recursive option | refused with .deletionRejected |
| non-empty directory, recursive option set | walk the directory on the server with readdir, depth first, re-lstating each directory before descending (security), then remove / rmdir |
target already gone (ENOENT from remove or rmdir) |
success, and the row is removed |
- The walk cannot come from the index: folders Finder never opened have no rows, and an
index-driven
rmdirwould fail withENOTEMPTYon the first unexplored subfolder. - Deleting a ghost succeeds so that a user deleting an item the mass-deletion guard (change detection) is still showing gets what they asked for, not an error.