Skip to content

Add llfio::atomic_file, a safe-save transaction which atomically replaces a file's contents - #183

Open
SnoutFirst wants to merge 6 commits into
ned14:developfrom
SnoutFirst:feature/atomic-file
Open

SnoutFirst wants to merge 6 commits into
ned14:developfrom
SnoutFirst:feature/atomic-file

Conversation

@SnoutFirst

@SnoutFirst SnoutFirst commented Oct 7, 2026 •

Copy link
Copy Markdown

Add llfio::atomic_file, a safe-save transaction

Summary

Adds a small, self-contained RAII write transaction, llfio::atomic_file, which
writes 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 the
LLFIO analogue of Qt's QSaveFile, implemented independently on top of existing
LLFIO 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 them
correctly. 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_file encodes the correct sequence once.

Design

open(base, path, opts)
  -> write() / write() / write() ...        ordinary cached I/O; no per-write sync
  -> commit()
       -> preserve the destination's permissions onto the temporary file
       -> one barrier(barrier_kind::wait_all) of the temporary file
       -> relink(directory, leafname, atomic_replace = true)
       -> directory_handle::barrier()
  • Anchoring. The destination is split into its containing directory and its
    leafname. The directory is held open as a directory_handle and the rename is
    anchored to it; the full target path is deliberately not retained, since
    paths are inherently racy.
  • Temporary file. Created via file_handle::uniquely_named_file() in the
    destination'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.
  • Permissions preserved. If the destination already exists, its mode is
    applied to the temporary file before the rename, so an atomic save does not
    reset a script's 0755, or a file deliberately tightened to 0600, back to
    the process-umask mode the temporary file was created with. The mode is read
    with directory_handle::read() on a non-glob leafname — a plain fstatat()
    relative to the already-anchored directory — and applied with
    stat_t::stamp(..., perms) on the temporary file. That read needs no
    permission 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 (fstatat returns ENOENT);
    a non-regular destination is skipped, since a symlink's mode is meaningless
    for the file which replaces it; and on Windows, where perms is never
    reported, the step is a clean no-op. Ownership, ACLs and extended attributes
    are deliberately not touched.
  • Sequential writes. An internal offset is tracked, so multiple writes append
    correctly. No magic offsets.
  • Write errors are latched. The first write failure is remembered and blocks
    all subsequent writes and the commit. A successful write after a failed one
    cannot resurrect a publishable transaction.
  • State machine. open -> committed (or committed + durability-
    unconfirmed), cancelled, or write_failed. Double commit, write-after-
    commit, commit-after-cancel and cancel-after-commit are all rejected with
    errc::invalid_argument / errc::operation_not_permitted rather than
    producing nonsense.
  • Move-only, following the existing LLFIO handle types; the destructor
    discards an uncommitted temporary file and never publishes.

Relationship to QSaveFile behaviour

QSaveFile is used as a behavioural reference only. The observable contract
is 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 the
atomic rename. The API shape is deliberately different where LLFIO conventions
differ (snake_case, result<T>/error_code rather than exceptions or a
boolean 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.cpp or otherwise) was copied, translated, ported or
mechanically derived. Qt's internal error-state implementation, its platform
#ifdef branches, and its permission-copying logic are not reproduced.

LLFIO primitives used

  • file_handle::uniquely_named_file() — temporary file in the destination directory
  • file_handle::mode, creation, caching::all — ordinary cached write I/O
  • file_handle::write() — sequential writes at a tracked offset
  • byte_io_handle::barrier(barrier_kind::wait_all) — the single commit-time flush
  • fs_handle::relink(..., atomic_replace = true) — the atomic replacement
  • directory_handle::directory() — anchoring the destination's parent
  • directory_handle::barrier() — new; directory metadata flush
  • flag::unlink_on_first_close — automatic temporary-file cleanup
  • result<T> / error_code — error propagation, preserving the original errors

Atomicity semantics

  • The destination path is never observed partially written: a concurrent reader
    sees either the whole old file or the whole new file, because publication is a
    single atomic rename.
  • Before commit(), the destination is untouched.
  • Destruction without commit, or cancel(), leaves the destination untouched.
  • Fallback mode is not atomic and is documented as such.

Synchronization / durability semantics

  • Writes are ordinary cached writes. There is no per-write barrier, fsync(),
    or FlushFileBuffers().
  • Exactly one storage barrier of the temporary file is issued, at commit time,
    immediately before the replacement — matching QSaveFile's syncToDisk()
    placement but using byte_io_handle::barrier(barrier_kind::wait_all).
  • After the replacement, the containing directory is flushed via the new
    directory_handle::barrier() (POSIX fsync() of the directory; a directory
    handle flush on Windows), so that the rename itself is durable.
  • A barrier error is not swallowed: it is returned through result<void>,
    unlike QSaveFile, which currently ignores syncToDisk() failures. This is a
    deliberate difference.
  • The header states explicitly that durability still depends on the storage
    stack honouring flushes.

Fallback semantics

options::fallback defaults to false; when false, open() fails rather
than 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 mode is_fallback() returns
true, cancel() refuses (errc::not_supported) rather than pretending it can
restore 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, plus example/atomic_save.cpp as a compile test:

  • create-new, and overwrite with shorter content (no stale tail)
  • many sequential writes (1000), and the documented "hello " / "world" / "\n"
    concatenation case
  • empty write
  • discard on destruction (destination untouched, temp cleaned up)
  • explicit cancellation, and no publication after it
  • double commit rejected
  • destination in a subdirectory, and a nonexistent directory
  • destination which is a directory
  • move construction / move assignment
  • non-atomic fallback via a read-only directory, and cancel() refusing in it
  • concurrency: a reader thread asserts it never observes a torn destination
    across 200 atomic replacements
  • permission preservation: an existing destination with mode 0600, 0755 and
    0000 keeps its mode after commit(); a destination which did not exist
    beforehand is not given invented permissions; a non-regular destination is
    skipped

Result on this host, at the revision before the permission test was added:

263653 checks passed
Test case summary: 1 passed 0 failed 0 skipped

in static (llfio_sl--atomic_file), header-only (llfio_hl--atomic_file) and
dynamic (llfio_dl--atomic_file) configurations, all passing. The status_code
configuration (LLFIO_USE_EXPERIMENTAL_SG14_STATUS_CODE=ON, which the Linux CI
matrix builds) also compiles and passes; it caught a real defect during review —
the error-propagation sites needed std::move(x).error() to satisfy
status_code's move-only error type, which the default error_code
configuration 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 — 600 and
755 both come back as 660, the umask-derived mode the temporary file was
created with, and the mode-0000 case fails likewise — while the "a fresh
destination 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_code runs described above predate the
permission test and have not been repeated with it.

Sanitizer runs:

  • ASan with leak detection + UBSan (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.
  • TSan (llfio_sl-tsan-atomic_file): clean, no data races reported — this
    includes 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

  • Linux x86-64 (GCC 14, C++17): static, header-only and dynamic configurations.
  • Windows: not executed here.
    • directory_handle::barrier() on Windows is implemented with a directory
      handle flush (NtFlushBuffersFileEx, falling back to
      NtFlushBuffersFile).
    • Renaming a file over an existing directory on Windows is expected to be
      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

  • Ownership, ACLs and extended attributes of an existing destination are
    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.
  • Flushing the containing directory requires opening it for reading, so a
    directory which is writable but not readable causes open() to fail.
    Documented.
  • If the final directory flush fails, the replacement has already happened and
    cannot be undone. commit() returns that error, and
    replacement_performed() lets the caller distinguish "published but durability
    unconfirmed" from "not published". The same applies if a caller-supplied
    deadline to commit() expires once the rename has been issued.
  • Sanitizer builds were run at -j1 on this host: ASan (leak detection on) and
    TSan 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 deadline defaulting to deadline() — i.e. no deadline at all, wait
indefinitely — 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 can
honour a deadline is the relink(); the synchronous file barrier and the
directory 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 calls
accept 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 modified
by the transaction, and is tracked by a dedicated flag rather than being derived
from the commit state. That matters in two cases:

  • The rename succeeded but a later step failed (the directory flush returning
    errc::timed_out or an I/O error). The caller gets a failed commit() yet
    replacement_performed() is true, so "published but unflushed" is
    distinguishable from "not published". The flag is set immediately after
    relink() returns success, before anything else can fail.
  • Non-atomic fallback mode. Fallback opens the destination for direct writing and
    truncates it, so the destination is already modified — and the old contents
    already lost — from open(), long before commit() runs. Reporting that
    honestly is the point of the flag; deriving it from state::committed would
    have 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, _tempfile and _destination, rather than one member which
means either depending on the mode. _tempfile is only ever assigned a file
this object created with uniquely_named_file(), and the destructor and
move-assignment only ever unlink _tempfile. Since the handle they are allowed
to 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_modified is set — can cause the destination to be
deleted. 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 writing
to, 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

  • Barrier failures are propagated instead of ignored.
  • cancel() in fallback mode is rejected rather than silently accepted.
  • Destination permissions are preserved, as Qt does, but via LLFIO's own
    primitives (directory_handle::read() + stat_t::stamp()) rather than by
    importing Qt's platform #ifdef permission-copying logic. Ownership, ACLs and
    extended attributes are still not copied.
  • Paths are not stored; a directory handle + leafname anchor is used.

Maintainer-facing questions

  1. Permissions — resolved. Originally asked as a question; the destination's
    permissions are now preserved (its mode is read with a directory-relative
    fstatat() and applied to the temporary file before the rename). Left here
    for the record — see Design and Known limitations.
  2. Is directory_handle::barrier() the right home for the new primitive, and is
    the chosen Windows implementation (directory handle flush) what you would
    expect? I added it rather than dropping to platform-specific code inside
    atomic_file.
  3. Should the pre-replace barrier be wait_all or wait_data_only? I used
    wait_all per 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.cmake and cmake/tests.cmake were 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.hpp was deliberately left untouched (it is
build-generated).

@SnoutFirst
SnoutFirst force-pushed the feature/atomic-file branch 2 times, most recently from 846e288 to 5e8bf17 Compare October 7, 2026 02:08
…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
SnoutFirst force-pushed the feature/atomic-file branch from 5e8bf17 to 4d9a2f7 Compare October 7, 2026 03:10
…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

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

Development

Successfully merging this pull request may close these issues.

1 participant