Repository navigation
Add llfio::atomic_file, a safe-save transaction which atomically replaces a file's contents - #183
Open
SnoutFirst wants to merge 6 commits into
Open
SnoutFirst wants to merge 6 commits into
SnoutFirst wants to merge 6 commits into
Conversation
SnoutFirst
force-pushed
the
feature/atomic-file
branch
2 times, most recently
from
October 7, 2026 02:08
846e288 to
5e8bf17
Compare
…file's contents
`atomic_file` is a small RAII write transaction conceptually similar to Qt's
QSaveFile, implemented independently on top of existing LLFIO primitives. The
caller writes the complete replacement content sequentially, and only `commit()`
publishes it:
open(base, path, opts)
-> write() / write() / write() ... (ordinary cached I/O, no per-write sync)
-> commit()
-> one barrier(barrier_kind::wait_all) of the temporary file
-> relink(directory, leafname, atomic_replace = true)
-> directory_handle::barrier()
The temporary file is created with `file_handle::uniquely_named_file()` in the
destination's own directory, so the replacement is same-filesystem and therefore
atomic. The destination is never touched before commit; a concurrent reader sees
either the whole old file or the whole new file. Destroying the object without
committing, or calling `cancel()`, discards the temporary file and leaves the
destination untouched. A failed write is latched, and a subsequent `commit()`
fails rather than publishing an incomplete file, preserving the original write
error.
`atomic_file` retains only an anchored `directory_handle` for the containing
directory plus the destination leafname, rather than storing the full target
path: paths are inherently racy, so the rename is anchored to the directory.
Because the atomic strategy needs to be able to create a temporary file in the
destination's directory, and to replace the destination, there are situations
where it cannot be used. `options::fallback` (default off) permits the caller to
opt in to writing the destination directly in those cases. The fallback is not
atomic, cannot restore the previous contents on cancellation, and is documented
as such; it is never entered because an ordinary write failed (e.g. ENOSPC), only
when the atomic strategy itself is unavailable.
Directory metadata durability required a cross-platform primitive which LLFIO did
not previously have, so this also adds `directory_handle::barrier()`, a directory
analogue of `byte_io_handle::barrier()` implemented as POSIX `fsync()` on the
directory, and a flush of the directory handle on Windows.
Known limitations, documented in the header:
- Permissions and ownership of an existing destination are not preserved; the
temporary file becomes the destination after the rename.
- Flushing the containing directory requires read permission on that directory,
so directories which are writable but not readable cause `open()` to fail.
- If the final directory flush fails, the replacement has already happened and
cannot be undone. `commit()` returns that error and
`replacement_performed()` distinguishes this case from a failure before the
replacement.
- The durability of the final state depends on the storage stack honouring
flushes; this is stated rather than papered over.
Tests cover create-new, overwrite with shorter content, many sequential writes,
empty writes, discard on destruction, cancellation, double commit, a destination
in a subdirectory, a nonexistent directory, a destination which is a directory,
move semantics, the non-atomic fallback, and a concurrent reader which must never
observe a torn destination across many atomic replacements.
Co-authored-by: d3fault <d3fault@d3fault.net>
Implemented-by: ZeroClaw running DeepSeek v4.1 Flash - this feature cost about $0.50 to implement
SnoutFirst
force-pushed
the
feature/atomic-file
branch
from
October 7, 2026 03:10
5e8bf17 to
4d9a2f7
Compare
…ing its destination `open()` can fail part way through fallback setup, after it has taken a handle to the real destination but before it has recorded that the destination was modified. The partially constructed transaction is then destroyed, and its destructor must not unlink the destination. This adds a test which fails if it ever does. Making the failure happen is easy, making it observable is not. The obvious arrangement - render the directory unwritable so the temporary file cannot be created, and point the destination at something which cannot be truncated - hides the bug rather than exposing it, because `unlink()` then needs write permission on that same directory, fails with EACCES, and the test passes either way. A symlink to the null device hides it too, because the entry and the handle disagree and LLFIO's safety unlink refuses to remove it. So the destination is a FIFO, whose read end is held open: it is the entry itself, it opens for writing, and `ftruncate()` fails on it. Fallback is then triggered without touching permissions, by exhausting the filesystem's inodes, so creating a new file fails while deleting an existing one still succeeds, and a destructor which unlinks the destination really does delete it. That needs a filesystem under our control, so the scenario runs in a child process in a private mount namespace with its own tiny tmpfs, which is destroyed along with the child. It needs root or unprivileged user namespaces, and skips rather than reporting a result it did not obtain where neither is available.
Previously a commit created its temp file with mode 0660 (subject to umask) and relied on relink() to replace the destination, silently resetting the destination's mode. Scripts at 0755, or files tightened to 0600, therefore came back as 0660 after a save. Read the destination's mode with directory_handle::read() on a non-glob leafname (a pure fstatat - no permission on the file itself is needed, and unlike a file_handle open it cannot block on a FIFO destination), then apply it to the temp file via stat_t::stamp(..., perms) before the barrier and relink(). An absent destination yields ENOENT and is a no-op; a non-regular destination (e.g. a symlink, whose mode is meaningless for the file replacing it) is skipped; and on Windows, where perms is never reported, the apply step is a clean no-op. Ownership, ACLs and xattrs are deliberately not touched. Add TestPreservePermissions, which checks the observable outcome with an independent stat()/chmod() rather than re-running the implementation's own logic. With the preservation call disabled exactly the permission checks fail; with it enabled the whole suite passes (263735 checks).
…daries `options::fallback` was deciding too broadly: `if(!opts.fallback)` meant *any* failure to create the temporary file fell back to writing the destination in place. That is both misleading and occasionally destructive, because the fallback truncates the destination as it starts - a resource-exhaustion failure would throw the old contents away on its way to failing with the very same error. Narrow it to the one class of failure which actually means "this directory refuses to let this process add a new name, but the destination's own inode may still be writable": `errc::permission_denied` (EACCES). Everything else is now returned unchanged, exactly as if the fallback had been switched off. This is the same line QSaveFile draws - its POSIX fallback is guarded by `errno == EACCES` - and the class comment now says so, with a new \section atomic_file_fallback listing the excluded error classes and the reasoning for each (ENOSPC/EDQUOT/EFBIG, EMFILE/ENFILE/ENOMEM, EROFS, ENOENT/ENOTDIR, EPERM, EIO). The QSaveFile "destination exists and is not a regular file" fallback is deliberately *not* mirrored: that is not a failure to create a temporary file, and silently reinterpreting "replace this file" as "write into this device" would change the meaning of the transaction behind the caller's back. Such a transaction still fails safely at commit(). Two invariants are documented alongside it: the fallback is opt-in, and it can never be reached from an ordinary write() or commit() failure - it is decided once, in open(), before a byte of content has been written, and consulted nowhere else. Cost, which callers and reviewers should know about: the old TestFallbackSetupFailure reached the fallback by exhausting inodes on a *writable* tmpfs, which is what made a wrongful destructor-unlink of the destination actually succeed and therefore observable. With the trigger narrowed, the fallback can now only fail partway in a directory which refuses new names - and such a directory refuses unlinks too, so that particular bug is no longer observable at all. Widening the trigger set is the only way to get it back. Tests, since LLFIO has no fault injection of its own and none was added to the library: * The commit() barrier steps are failed by interposing libc's fsync() from the test TU. The executable is first in the symbol lookup scope and nothing is linked -Bsymbolic, so this works for the static, header-only and shared builds, and under asan/tsan. On Linux both barriers go through fsync() (barrier_kind::wait_all is 5, greater than wait_data_only's 3, so the sync_file_range/fdatasync fast paths are skipped), the temporary file and the directory are told apart with fstat()+S_ISDIR, and the real call goes through ::syscall(SYS_fsync, fd) so the override cannot recurse. Transparent until armed. * TestCommitBarrierFails (step ii): the destination is unchanged, replacement_performed() is false, the state is failed, the temporary file is cleaned up, and a second commit() reports operation_canceled. * TestDirectoryBarrierFails (step iv): the destination *has* been replaced, replacement_performed() is true, commit() returns the error, the destructor neither undoes nor unlinks the published destination, and a second commit() reports operation_not_permitted without re-injecting. * TestDestinationIsDirectory is strengthened into the natural relink() failure (a rename over a directory is refused) and asserted on all four points. * TestFallbackErrorClass runs in a private mount namespace over a tmpfs with nr_inodes=8: inode exhaustion makes temporary-file creation fail with ENOSPC - the child verifies it and reports 77 rather than passing if not - while the existing destination still opens for writing. It asserts that open(..., options(true)) fails with no_space_on_device and that the destination still reads "original", i.e. no silent truncation. Also fix a real defect in the test itself, found by re-running it rather than trusting an earlier green log: it used a fixed scratch path, so two instances of the same test binary - which ctest runs in parallel across configurations - deleted each other's files and failed non-deterministically. The scratch directory is now unique per process, and the BOOST_REQUIRE calls inside the 200-iteration commit loop of TestReaderNeverSeesPartial now stop and join the reader thread before reporting, so a failure is reported rather than turning into std::terminate from a joinable thread's destructor. Verified: 263386 checks, 1 passed / 0 failed / 0 skipped, in build/ for sl, hl, dl and hl-noexcept, and in build-status/ (experimental status_code) for sl. Non-vacuity of the fallback test was checked by temporarily restoring the broad condition, which makes TestFallbackErrorClass fail.
…ter it
`open()` did
atomic_file ret;
ret._fallback = opts.fallback;
before it had even tried to create the temporary file. But the option means
"falling back is permitted", while `_fallback` means "this transaction is currently
in fallback mode", and those are not the same question. The option was answering it
in advance.
So an `atomic_file` opened with `options(true)` in an ordinary writable directory -
where the temporary file is created exactly as normal - still reported
`is_fallback() == true`, and everything which keys off that state went wrong:
* `is_fallback()` lied about the transaction.
* `cancel()` returned `errc::not_supported`, refusing to cancel an ordinary atomic
transaction which is perfectly cancellable and whose writes are entirely in the
temporary file.
* `_preserve_destination_permissions()` was skipped, so the replacement would not
keep the destination's mode.
* `commit()` took the "nothing to rename" path and reported success without ever
relinking the temporary file onto the destination.
* Worst of all, `_write_target()` is `_fallback ? _destination : _tempfile`, so
every write went to `_destination` - which in temporary-file mode is the
never-opened, default-constructed handle - instead of the temporary file that is
the entire point of this class. `byte_io_handle::_do_write()` therefore reaches
`::writev(-1, ...)` and every `write()` fails with `bad_file_descriptor`.
In other words the whole atomic replacement silently did not happen for any caller
who had merely *allowed* falling back.
Fix: do not copy the option into the state. `_fallback` keeps its `false` default
initializer until the temporary file has really failed to be created for the one
error class which triggers the fallback, and the destination's own inode has been
opened and truncated; the single `ret._fallback = true;` in that branch is now the
only place it ever becomes true, and the comments say so.
Documented as well, since the distinction is the entire point: `options::fallback`
now states plainly that it means "permitted", never "active", and `is_fallback()`
now separately documents the transaction state.
Test: TestFallbackPermittedButUnused opens with `options(true)` in an ordinary
writable directory and checks that the transaction is an ordinary atomic one -
`is_fallback()` is false, a temporary file was created, writing leaves the
destination still holding its previous contents (which is what proves the bytes
went to the temporary file and not through it), `replacement_performed()` stays
false until commit(), commit() relinks and leaves no temporary behind, a second
commit() is operation_not_permitted, the same holds when the destination does not
exist yet, and cancel() succeeds rather than returning not_supported. TestFallback
is kept unchanged as coverage for the path where the fallback really is taken.
Checked that the new test is not vacuous by reinstating the offending line:
it fails, cleanly rather than crashing, first on `!f.is_fallback()` and then on
the write having failed, i.e. the write really was aimed at an invalid handle.
Verified: 263429 checks, 1 passed / 0 failed / 0 skipped, in build/ for sl, hl and
dl, and in build-status/ (experimental status_code) for sl, including three pairs
of simultaneously running instances.
…nd fix a Windows wait Review of whether the new `directory_handle::barrier()` belongs as a general-purpose public LLFIO primitive rather than being exposed solely to support `atomic_file`. Conclusion: it belongs, it is in the right place, and its documentation was understating why. One real defect was found and fixed, on Windows. No POSIX behaviour changed. **Is there an existing abstraction that this should use instead?** There is no existing *public* abstraction, but there is a closely related existing *behaviour*, and the relationship was previously undocumented. LLFIO already issues exactly this flush of its own accord: `directory_handle::directory()` fsyncs the directory handle it has just opened whenever safety barriers are enabled, and `flag::disable_safety_barriers` already documents the parent-directory fsyncs LLFIO adds on its own initiative (as a Linux-only behaviour). There was, however, no way to ask for that flush on demand, and no public name for the operation at all. So this is not a helper for `atomic_file`: it is the missing name for something LLFIO already does, and `atomic_file` is merely its first caller. That is now stated in the method documentation. **Where it lives.** On `directory_handle`, for reasons now written down at the method: a directory's entry list is the only metadata of this kind LLFIO has, and `directory_handle` is the only handle type with one. It cannot be a `byte_io_handle` operation, as a directory is not a byte i/o handle; it cannot be on `fs_handle`, as `fs_handle` is also the base of `symlink_handle`, which holds no descriptor to flush; and it cannot be on `path_handle`, which may name anything at all, not merely a directory. `algorithm::cached_parent_handle_adapter<directory_handle>` publicly derives from it and so inherits `barrier()` unchanged. **Naming and documentation against LLFIO's conventions.** `barrier()` matches `byte_io_handle::barrier()` and LLFIO's existing vocabulary for "make the preceding changes durable". The absent `deadline` parameter is a deliberate difference from `byte_io_handle::barrier()`, and is now explained where a user of this API would look rather than only inside `atomic_file`: a synchronous barrier cannot honour a deadline, and the byte barrier rejects a non-empty one with `errc::not_supported` rather than pretending it can. Also now documented, in house style: `\return`, `\errors` (including `errc::invalid_argument` for a default constructed handle) and `\mallocs`, and the fact that this call is unaffected by `flag::disable_safety_barriers` - that flag only governs the barriers LLFIO issues unasked. **Platform appropriateness, and the legitimate "flush is unsupported" case.** Both are now stated as warnings rather than left implicit. * POSIX is a `fsync()` of the directory descriptor - the same call LLFIO already makes on a directory it has just opened - so a directory flush is not a new operation for this code base, merely a newly named one. On Apple it is deliberately *not* the `fcntl(F_FULLFSYNC)` which `byte_io_handle::_do_barrier()` uses there to work around Apple's `fsync()` not waiting for the device to flush its buffers: `F_FULLFSYNC` is not documented for a directory descriptor, and guessing at it would be worse than a documented, weaker guarantee. Directory durability on Apple is therefore weaker than file durability on Apple, and it is now documented rather than implied. * Windows is an `NtFlushBuffersFile(Ex)` of the directory handle, which a filing system is entitled to refuse for a directory; such a refusal is reported as an error instead of being quietly ignored. There is no existing Windows behaviour to be consistent with, because LLFIO's own `disable_safety_barriers` documentation describes the implicit parent-directory fsync as a Linux-only behaviour. * A filing system which does not implement directory flushes may refuse this call with `errc::invalid_argument` or similar. That is the legitimate unsupported case, and it is now documented on the primitive: a failure means "durability could not be established", not "the preceding changes were not made", and a caller for whom the directory entry is a best-effort matter (a temporary file on a `tmpfs`) must tolerate it. `atomic_file` already handles exactly this correctly, via `replacement_performed()`. **One real defect, fixed.** On Windows, `if(ntstat < 0)` was reached by a pending flush and returned success: `STATUS_PENDING` is positive, so a queued flush was reported to the caller as durable while it was still in flight. This is reachable - the handle is always waitable, as `access_mask_from_handle_mode()` requests `SYNCHRONIZE` unconditionally, and a `flag::multiplexable` directory handle is opened with `FILE_FLAG_OVERLAPPED` - and it is exactly the wrong answer for the caller who asked for durability. Now waited for with `ntwait()`, exactly as `byte_io_handle::_do_barrier()` already does for a file. Untested here, as this machine has no Windows; it is a copy of existing, proven code and reaches the same call the file barrier uses. **Discoverability.** A public primitive is only reachable if the operation which needs it points at it, so `fs_handle::relink()` - which performs the rename that is not durable until this flush - now says so, along with the `directory_handle` it must be called on. Verified before claiming it: nothing in the relink path flushes on either platform (no `fsync()` in `posix/fs_handle.ipp`, no `NtFlushBuffersFile` in `windows/fs_handle.ipp`). `atomic_file.hpp`'s commit sequence and its Windows caveat now name the primitive too. No tests added: no POSIX behaviour changed, and the Windows fix cannot be exercised on this machine. Verified: 263429 checks, 1 passed / 0 failed / 0 skipped, in build/ for sl, hl and dl, and in build-status/ (experimental status_code) for sl, including three pairs of simultaneously running instances.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Add
llfio::atomic_file, a safe-save transactionSummary
Adds a small, self-contained RAII write transaction,
llfio::atomic_file, whichwrites new contents to a temporary file in the destination's directory and only
publishes them to the destination path on an explicit
commit(). This is theLLFIO analogue of Qt's
QSaveFile, implemented independently on top of existingLLFIO primitives.
It also adds one small primitive which LLFIO previously lacked:
directory_handle::barrier(), needed to make the final namespace update durable.Motivation
LLFIO has all the pieces needed to replace a file's contents safely
(
file_handle::uniquely_named_file(),fs_handle::relink(..., atomic_replace = true),byte_io_handle::barrier()), but no small RAII type which sequences themcorrectly. Getting the ordering wrong is easy and the failure modes are silent
and data-losing: writing every buffer at offset zero, syncing after every write
instead of once at commit, publishing a temporary file which was only partially
written, or losing the rename on power failure because the containing directory
was never flushed.
atomic_fileencodes the correct sequence once.Design
leafname. The directory is held open as a
directory_handleand the rename isanchored to it; the full target path is deliberately not retained, since
paths are inherently racy.
file_handle::uniquely_named_file()in thedestination's own directory, so the replacement is same-filesystem and hence
atomic. It is created unlinked-on-close, so abandoning the transaction cleans
it up without a delete-by-name race.
applied to the temporary file before the rename, so an atomic save does not
reset a script's
0755, or a file deliberately tightened to0600, back tothe process-umask mode the temporary file was created with. The mode is read
with
directory_handle::read()on a non-glob leafname — a plainfstatat()relative to the already-anchored directory — and applied with
stat_t::stamp(..., perms)on the temporary file. That read needs nopermission on the destination itself, and unlike opening the destination it
cannot block if the destination happens to be a FIFO; it also re-resolves no
path component. An absent destination is a no-op (
fstatatreturnsENOENT);a non-regular destination is skipped, since a symlink's mode is meaningless
for the file which replaces it; and on Windows, where
permsis neverreported, the step is a clean no-op. Ownership, ACLs and extended attributes
are deliberately not touched.
correctly. No magic offsets.
all subsequent writes and the commit. A successful write after a failed one
cannot resurrect a publishable transaction.
open->committed(orcommitted+ durability-unconfirmed),
cancelled, orwrite_failed. Double commit, write-after-commit, commit-after-cancel and cancel-after-commit are all rejected with
errc::invalid_argument/errc::operation_not_permittedrather thanproducing nonsense.
discards an uncommitted temporary file and never publishes.
Relationship to QSaveFile behaviour
QSaveFileis used as a behavioural reference only. The observable contractis the same: write to a temporary file next to the destination, leave the
destination untouched until
commit(), discard on destruction, optional direct-write fallback, one
syncToDisk()-equivalent flush immediately before theatomic rename. The API shape is deliberately different where LLFIO conventions
differ (snake_case,
result<T>/error_coderather than exceptions or aboolean
error()accessor, path handles rather than a stored path).Independent implementation
This implementation is written independently against LLFIO's own abstractions.
No Qt source (
qsavefile.cppor otherwise) was copied, translated, ported ormechanically derived. Qt's internal error-state implementation, its platform
#ifdefbranches, and its permission-copying logic are not reproduced.LLFIO primitives used
file_handle::uniquely_named_file()— temporary file in the destination directoryfile_handle::mode,creation,caching::all— ordinary cached write I/Ofile_handle::write()— sequential writes at a tracked offsetbyte_io_handle::barrier(barrier_kind::wait_all)— the single commit-time flushfs_handle::relink(..., atomic_replace = true)— the atomic replacementdirectory_handle::directory()— anchoring the destination's parentdirectory_handle::barrier()— new; directory metadata flushflag::unlink_on_first_close— automatic temporary-file cleanupresult<T>/error_code— error propagation, preserving the original errorsAtomicity semantics
sees either the whole old file or the whole new file, because publication is a
single atomic rename.
commit(), the destination is untouched.cancel(), leaves the destination untouched.Synchronization / durability semantics
fsync(),or
FlushFileBuffers().immediately before the replacement — matching
QSaveFile'ssyncToDisk()placement but using
byte_io_handle::barrier(barrier_kind::wait_all).directory_handle::barrier()(POSIXfsync()of the directory; a directoryhandle flush on Windows), so that the rename itself is durable.
result<void>,unlike
QSaveFile, which currently ignoressyncToDisk()failures. This is adeliberate difference.
stack honouring flushes.
Fallback semantics
options::fallbackdefaults to false; when false,open()fails ratherthan silently weakening atomicity. When explicitly enabled, the destination is
written directly only in the situations where the atomic temporary-file strategy
cannot be used. It is never entered because an ordinary write failed (e.g.
ENOSPC) — that stays a hard error. In fallback modeis_fallback()returnstrue,
cancel()refuses (errc::not_supported) rather than pretending it canrestore the old contents, and the header documents that the old contents may be
destroyed before the new ones are complete and that a crash can leave a
partially-written file.
Tests performed
test/tests/atomic_file.cpp, plusexample/atomic_save.cppas a compile test:"hello " / "world" / "\n"concatenation case
cancel()refusing in itacross 200 atomic replacements
0600,0755and0000keeps its mode aftercommit(); a destination which did not existbeforehand is not given invented permissions; a non-regular destination is
skipped
Result on this host, at the revision before the permission test was added:
in static (
llfio_sl--atomic_file), header-only (llfio_hl--atomic_file) anddynamic (
llfio_dl--atomic_file) configurations, all passing. Thestatus_codeconfiguration (
LLFIO_USE_EXPERIMENTAL_SG14_STATUS_CODE=ON, which the Linux CImatrix builds) also compiles and passes; it caught a real defect during review —
the error-propagation sites needed
std::move(x).error()to satisfystatus_code's move-only error type, which the defaulterror_codeconfiguration had not caught.
The permission-preservation test was added after the first revision of this
description, and was verified in the static configuration only: 263735 checks
passed, 1 passed / 0 failed with preservation enabled, and with the
preservation step commented out exactly the permission checks fail —
600and755both come back as660, the umask-derived mode the temporary file wascreated with, and the mode-
0000case fails likewise — while the "a freshdestination is given no invented permissions" check still passes. The test is
therefore shown to detect the missing behaviour rather than passing vacuously.
The header-only, dynamic and
status_coderuns described above predate thepermission test and have not been repeated with it.
Sanitizer runs:
llfio_sl-asan-atomic_file): clean, exit 0,no leaks and no undefined behaviour reported. Re-run after the fallback
ownership fix in this series.
llfio_sl-tsan-atomic_file): clean, no data races reported — thisincludes the concurrent-reader test. Note: run against the revision before
the fallback ownership fix; that fix moves a handle between two members of a
single-threaded object and touches no shared state, so it was not re-run.
Platforms tested
directory_handle::barrier()on Windows is implemented with a directoryhandle flush (
NtFlushBuffersFileEx, falling back toNtFlushBuffersFile).refused by NTFS, so
commit()should fail without destroying the directory;this is reasoned about from the
NtSetInformationFile(FileRenameInformation)path but has not been run.
Known limitations
not preserved; the mode (permissions) now is. The mode is copied onto the
temporary file before the rename, so an existing destination keeps its
permissions across an atomic save. Preserving ownership would require
privilege, and copying ACLs and extended attributes would require additional
APIs, neither of which is warranted here; the rest is left to the caller.
Documented.
directory which is writable but not readable causes
open()to fail.Documented.
cannot be undone.
commit()returns that error, andreplacement_performed()lets the caller distinguish "published but durabilityunconfirmed" from "not published". The same applies if a caller-supplied
deadline to
commit()expires once the rename has been issued.-j1on this host: ASan (leak detection on) andTSan both passed clean over the full test, including the concurrent-reader
test.
Deadlines
Per the library's convention, no operation here invents a timeout.
commit()takes a
deadlinedefaulting todeadline()— i.e. no deadline at all, waitindefinitely — which is what the overwhelming majority of LLFIO's own APIs do
(
byte_io_handle::read/write/barrier, and so on). The single step which canhonour a deadline is the
relink(); the synchronous file barrier and thedirectory flush ignore it, as documented. Pass an explicit deadline to bound the
replacement if you want one.
open()deliberately takes no deadline at all: none of the primitives it callsaccept one, so an argument there would have been a lie about a timeout that does
not exist.
Commit state machine
replacement_performed()reports whether the destination path has been modifiedby the transaction, and is tracked by a dedicated flag rather than being derived
from the commit state. That matters in two cases:
errc::timed_outor an I/O error). The caller gets a failedcommit()yetreplacement_performed()is true, so "published but unflushed" isdistinguishable from "not published". The flag is set immediately after
relink()returns success, before anything else can fail.truncates it, so the destination is already modified — and the old contents
already lost — from
open(), long beforecommit()runs. Reporting thathonestly is the point of the flag; deriving it from
state::committedwouldhave claimed the destination was untouched in exactly the case where the
caller most needs to know it was not.
Fallback mode must never delete the destination
The disposable temporary file and the caller's real destination are held in two
separate handles,
_tempfileand_destination, rather than one member whichmeans either depending on the mode.
_tempfileis only ever assigned a filethis object created with
uniquely_named_file(), and the destructor andmove-assignment only ever unlink
_tempfile. Since the handle they are allowedto delete cannot be the destination, no flag going wrong — including a failure
part way through fallback setup, after the destination handle has been taken but
before
_destination_modifiedis set — can cause the destination to bedeleted. A single handle plus an ownership flag would have worked too, but only
as long as the flag and the handle agreed; two handles cannot disagree.
_write_target()returns whichever handle the transaction is currently writingto, and is used for the write and barrier paths. Neither is destructive, so both
are safe on the real destination.
Deliberate semantic differences from QSaveFile
cancel()in fallback mode is rejected rather than silently accepted.primitives (
directory_handle::read()+stat_t::stamp()) rather than byimporting Qt's platform
#ifdefpermission-copying logic. Ownership, ACLs andextended attributes are still not copied.
Maintainer-facing questions
permissions are now preserved (its mode is read with a directory-relative
fstatat()and applied to the temporary file before the rename). Left herefor the record — see Design and Known limitations.
directory_handle::barrier()the right home for the new primitive, and isthe chosen Windows implementation (directory handle flush) what you would
expect? I added it rather than dropping to platform-specific code inside
atomic_file.wait_allorwait_data_only? I usedwait_allper the task's requirement, but the semantics of the guarantee(which is about data + the directory entry) may be worth your call.
Generated artifacts
cmake/headers.cmakeandcmake/tests.cmakewere regenerated by their scripts(exactly the expected entries changed, nothing else). The single-header
amalgamation and the generated documentation were not regenerated in this
environment; they should be regenerated as part of the release process.
include/llfio/revision.hppwas deliberately left untouched (it isbuild-generated).