morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
file_io_ops.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <cerrno>
6#include <cstdint>
7#include <cstdio>
8#include <filesystem>
9#include <fstream>
10#include <functional>
11#include <string>
12#include <string_view>
13#include <system_error>
14
15#include "logger.hpp"
16
17#ifdef _WIN32
18#include <io.h>
19#else
20#include <fcntl.h>
21#include <unistd.h>
22#endif
23
24namespace morph::core {
25
26/// @brief Repeats @p operation while it fails with `EINTR`.
27///
28/// A syscall interrupted by a signal returns a negative result with `errno` set
29/// to `EINTR`, having done nothing; the caller is expected to reissue it. Both
30/// halves of the directory fsync below need that, and inlining the loop at each
31/// put two copies of a two-branch retry in a header where no test can reach
32/// either -- a signal arriving mid-`open` is not something a unit test can
33/// arrange. Factored out, the loop is ordinary code a test drives with a
34/// callable that fails once.
35///
36/// @tparam Operation Callable returning a POSIX-style `int`: negative on
37/// failure, with `errno` set.
38/// @param operation The syscall to issue, and reissue while it reports `EINTR`.
39/// @return @p operation's first result that is not an `EINTR` failure.
40template <typename Operation>
41int retryOnEintr(Operation operation) {
42 int result = operation();
43 while (result < 0 && errno == EINTR) {
44 result = operation();
45 }
46 return result;
47}
48
49/// @brief The raw file-I/O primitives `morph::journal::FileActionLog` and
50/// `morph::offline::FileOfflineQueue` both call, as an injectable
51/// strategy. Every member defaults to the real syscall/stdlib call it
52/// stands in for, so a default-constructed `FileIoOps` is byte-for-byte
53/// what both classes called directly before this seam existed — no
54/// behavior change for a normal caller.
55///
56/// @par Why this exists
57/// Both classes have several branch arms that only run when a real OS-level
58/// file-I/O call fails partway through an otherwise-successful operation
59/// (disk full, fd closed underneath, a permission change racing an exact
60/// window). None of those are reachable from a portable unit test without
61/// this seam, and `FileActionLog` and `FileOfflineQueue` have the identical
62/// gap. A
63/// test constructs a `FileIoOps` whose relevant member fails on demand (or
64/// on the Nth call, or forever) and passes it to the class under test;
65/// every other member stays at its real default, so the rest of the class's
66/// I/O behaves normally around the one injected failure.
67///
68/// @par Thread safety
69/// `FileIoOps` itself is a plain value type with no shared state — copying
70/// or moving one has ordinary value semantics. Whether the *callbacks*
71/// themselves are safe to call from multiple threads concurrently is up to
72/// whatever a test installs; the real default callbacks are exactly the
73/// real syscalls, which already have their own well-defined thread-safety.
74struct FileIoOps {
75 /// @brief Writes @p size bytes from @p buffer to @p file. Mirrors `std::fwrite`.
76 /// @return The number of bytes actually written; short of @p size on failure.
77 std::function<std::size_t(const void* buffer, std::size_t size, std::FILE* file)> fwrite =
78 [](const void* buffer, std::size_t size, std::FILE* file) { return std::fwrite(buffer, 1, size, file); };
79
80 /// @brief Flushes @p file's stdio buffer. Mirrors `std::fflush`.
81 /// @return `0` on success, nonzero on failure.
82 std::function<int(std::FILE* file)> fflush = [](std::FILE* file) { return std::fflush(file); };
83
84 /// @brief Commits @p file's contents to durable storage. POSIX `fsync` /
85 /// Windows `_commit`, resolved from @p file via `fileno`/`_fileno`.
86 /// @return `0` on success, nonzero on failure.
87 std::function<int(std::FILE* file)> fsync = [](std::FILE* file) {
88#ifdef _WIN32
89 return _commit(_fileno(file));
90#else
91 return ::fsync(fileno(file));
92#endif
93 };
94
95 /// @brief Opens @p path in mode @p mode. Mirrors `std::fopen`.
96 /// @return The open file, or `nullptr` on failure.
97 // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) — mirrors std::fopen's own raw-owning-pointer return
98 std::function<std::FILE*(const std::string& path, const char* mode)> fopen = [](const std::string& path,
99 const char* mode) {
100 // NOLINTNEXTLINE(cert-err33-c) — callers check the returned handle themselves
101 return std::fopen(path.c_str(), mode);
102 };
103
104 /// @brief Reports whether @p path can be opened for reading right now.
105 /// Stands in for `std::ifstream{path}`'s own open-succeeded check
106 /// (`repairTornTail()`'s `if (!input)`) — a fault-injection test has
107 /// no way to make a *real* `std::ifstream` construction fail
108 /// without actually breaking the filesystem, so this predicate is
109 /// consulted first; the real default performs the real open
110 /// `std::ifstream` itself would.
111 /// @return `true` if @p path is currently readable.
112 std::function<bool(const std::filesystem::path& path)> canOpenForRead = [](const std::filesystem::path& path) {
113 return static_cast<bool>(std::ifstream{path});
114 };
115
116 /// @brief Truncates/extends @p path to @p newSize bytes. Mirrors
117 /// `std::filesystem::resize_file`.
118 /// @param path Path to resize.
119 /// @param newSize Target size, in bytes.
120 /// @param errorCode Set on failure, cleared on success — same contract as
121 /// `std::filesystem::resize_file`'s own `error_code` overload.
122 std::function<void(const std::filesystem::path& path, std::uintmax_t newSize, std::error_code& errorCode)>
123 resizeFile = [](const std::filesystem::path& path, std::uintmax_t newSize, std::error_code& errorCode) {
124 std::filesystem::resize_file(path, newSize, errorCode);
125 };
126
127 /// @brief Commits a directory's own metadata (new/renamed entries within
128 /// it) to durable storage. `fsync` on a *file* makes only that
129 /// file's data durable -- not the directory entry that names it,
130 /// so a fresh file's creation or a rename can vanish on power loss
131 /// even after the file's own contents were fsynced.
132 /// POSIX: `open(dir, O_RDONLY|O_DIRECTORY)` + `fsync` + `close`. A
133 /// no-op on Windows, documented as such rather than faked --
134 /// `FlushFileBuffers`'s semantics for a directory handle differ
135 /// enough from POSIX `fsync` that pretending otherwise would be
136 /// misleading, and this seam's Windows story is already
137 /// file-`fsync`-only.
138 /// @param dir Directory whose entries were just mutated. A `path::parent_path()`
139 /// of a bare relative filename (e.g. `"queue.ndjson"`, with no
140 /// directory component) is the *empty* path, not `"."` -- treated
141 /// here as the current directory, the same resolution the shell
142 /// and every POSIX call already give an empty path's implicit
143 /// caller, so a relative, directory-less @p path still gets its
144 /// containing directory synced instead of failing `open()` with
145 /// `ENOENT` on every call site that derives @p dir from
146 /// `parent_path()`.
147 /// @return `0` on success, otherwise the `errno` that caused the failure.
148 /// Returning the code rather than a bare `-1` is what lets
149 /// `classifyDirectorySync()` tell "this platform cannot do it" from
150 /// "this platform tried and failed", which the call sites treat very
151 /// differently. A custom (test) sink is free to return any nonzero
152 /// value; anything it does not recognise counts as a real failure.
153 std::function<int(const std::filesystem::path& dir)> syncPath = [](const std::filesystem::path& dir) {
154#ifdef _WIN32
155 (void)dir;
156 return 0;
157#else
158 std::filesystem::path const resolved = dir.empty() ? std::filesystem::path{"."} : dir;
159 // O_CLOEXEC: this fd exists for one fsync, and must not survive into a
160 // child across a concurrent fork/exec.
161 //
162 // POSIX ::open is variadic only to make the third `mode` argument
163 // optional; it is not passed here (no O_CREAT), and there is no
164 // non-variadic spelling of the syscall to prefer.
165 int const dirFd = retryOnEintr([&resolved] {
166 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-vararg)
167 return ::open(resolved.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
168 });
169 if (dirFd < 0) {
170 return errno;
171 }
172 int const result = retryOnEintr([dirFd] { return ::fsync(dirFd); });
173 int const failure = result == 0 ? 0 : errno;
174 ::close(dirFd);
175 return failure;
176#endif
177 };
178};
179
180/// @brief What a nonzero `FileIoOps::syncPath` result means for durability.
181enum class DirectorySync : std::uint8_t {
182 /// @brief The directory entry is on stable storage.
183 durable,
184 /// @brief This platform or mount cannot fsync a directory, or will not let
185 /// this process open one for reading. Nothing is wrong with the
186 /// data; the extra durability simply is not available here.
187 unsupported,
188 /// @brief A real I/O failure. The directory entry may not survive a crash.
189 failed,
190};
191
192/// @brief Whether `rollBackShortWrite` managed to leave the file with no
193/// partial tail, or may have left one behind.
194enum class RollBack : std::uint8_t {
195 /// @brief The failed write left no trace: the next append resumes on a
196 /// record boundary, exactly as if the write had never happened.
197 clean,
198 /// @brief A partial record may still be at the end of the file. It is
199 /// survivable only while it stays the *trailing* line, so the
200 /// caller must not append to this handle again.
201 torn,
202};
203
204/// @brief Classifies a `FileIoOps::syncPath` return value.
205///
206/// A directory fsync needs a **read** handle on the directory, which is a
207/// strictly stronger permission than writing a file inside it. A mode-0300
208/// spool directory (write + search, no read) — an ordinary hardened layout, and
209/// what an SELinux/AppArmor write-without-read policy produces — lets
210/// `fopen(path, "a")` succeed while `open(dir, O_RDONLY|O_DIRECTORY)` fails
211/// `EACCES`. Treating that as fatal made `FileActionLog`, `FileOfflineQueue`
212/// and `SqliteOfflineQueue` unconstructible on layouts where they had worked
213/// for years, for the sake of a durability refinement.
214///
215/// `fsync` on a directory fd is also simply unimplemented on several mounts —
216/// `EINVAL`/`ENOSYS`/`ENOTSUP` from sshfs, gvfs, Docker Desktop's gRPC-FUSE,
217/// WSL drvfs/9p under `/mnt/c`, and some overlay and network filesystems.
218///
219/// None of those is a durability *failure*; they are a durability *ceiling*.
220/// They warn and continue. `EIO` and anything else unrecognised is a genuine
221/// failure and still throws.
222///
223/// @param syncPathResult The value `FileIoOps::syncPath` returned.
224/// @return Which of the three cases @p syncPathResult falls into.
225[[nodiscard]] inline DirectorySync classifyDirectorySync(int syncPathResult) noexcept {
226 if (syncPathResult == 0) {
227 return DirectorySync::durable;
228 }
229 // Not guarded on `_WIN32`. The real `syncPath` is a documented no-op there
230 // and returns 0, so this only ever sees an injected value on Windows -- but
231 // classifying the *value* rather than the platform is what keeps a test
232 // that injects EACCES meaning the same thing everywhere. (It did not: with
233 // the switch compiled out under _WIN32, every nonzero value fell through to
234 // `failed`, and the unsupported-fsync test failed on the clangcl-debug leg
235 // alone.) Every constant below is in <cerrno> on the Microsoft CRT too.
236 switch (syncPathResult) {
237 case EACCES:
238 case EPERM:
239 case EINVAL:
240 case ENOSYS:
241 case ENOTDIR:
242#ifdef ENOTSUP
243 case ENOTSUP:
244#endif
245#if defined(EOPNOTSUPP) && (!defined(ENOTSUP) || EOPNOTSUPP != ENOTSUP)
246 case EOPNOTSUPP:
247#endif
248 return DirectorySync::unsupported;
249 default:
250 break;
251 }
252 return DirectorySync::failed;
253}
254
255/// @brief Returns @p file's current stdio position, wide enough to represent
256/// files past the ~2 GiB `std::ftell` can address through its 32-bit
257/// `long` on an LLP64 platform (Windows, in every build configuration
258/// this project ships — `long` stays 32-bit there even in a 64-bit
259/// binary). A long-lived `FileActionLog`/`FileOfflineQueue` sink with
260/// infrequent rotation can exceed that well within its lifetime; past
261/// it, `std::ftell` returns `-1` (`EOVERFLOW`) on every call, which
262/// `rollBackShortWrite()` already treats as "no offset to roll back
263/// to" and silently skips — quietly reviving the exact torn-tail
264/// merge the rollback exists to prevent, on every platform where this
265/// actually matters, for as long as the process keeps running (the next
266/// restart's `repairTornTail()` is still a backstop, but a long-lived
267/// process that never restarts gets no benefit from it).
268/// @param file Open stdio handle to query.
269/// @return The current position, or a negative value on failure — same
270/// convention as `std::ftell`.
271inline long long wideFtell(std::FILE* file) {
272#ifdef _WIN32
273 return _ftelli64(file);
274#else
275 return ::ftello(file);
276#endif
277}
278
279/// @brief Positions a just-opened append-mode stream at end-of-file.
280///
281/// C11 leaves an append-mode stream's *initial* position implementation-defined,
282/// and the implementations genuinely differ. glibc seeks to end at `fopen`, so
283/// `ftell` immediately reports the file's size. The Microsoft CRT documents the
284/// opposite in as many words -- "If no I/O operation has yet occurred on a file
285/// opened for appending, the file position is the beginning of the file" -- and
286/// musl behaves the same way. Every write still lands at the end regardless;
287/// what differs is only what `ftell` reports before the first one.
288///
289/// That difference is load-bearing here, because `rollBackShortWrite` takes the
290/// pre-write `wideFtell` as the offset to roll back to. On the Microsoft CRT the
291/// *first* write after any open would report offset 0, and a short write there
292/// would roll the file back to zero bytes -- destroying an entire audit journal
293/// or queue backlog rather than removing one partial record. Linux CI cannot
294/// catch it: glibc makes the bug unreachable, and the `cl-debug`/`cl-release`/
295/// `clangcl-*` legs where it is reachable have no test that short-writes before
296/// writing successfully first.
297///
298/// Calling this once after each successful append-mode `fopen` normalises the
299/// position across CRTs, so `wideFtell` means the same thing everywhere.
300///
301/// @param file Stream to position; a null handle is ignored, so this can be
302/// called before the caller's own open-failure check.
303inline void positionAtEnd(std::FILE* file) noexcept {
304 if (file != nullptr) {
305 // NOLINTNEXTLINE(cert-err33-c)
306 std::fseek(file, 0, SEEK_END);
307 }
308}
309
310/// @brief Rolls @p file/@p path back to @p offsetBeforeWrite bytes after a
311/// short write, best-effort.
312///
313/// `resizeFile` truncates the file by path, not through @p file's own file
314/// descriptor, so @p file's buffered stdio position (what a later `ftell`
315/// returns) is never resynced by the truncation itself. Left alone, that
316/// stale position would be captured as the *next* write's "offset before
317/// write" — and if that next write also fails short, the rollback would
318/// truncate to the wrong, stale offset instead of the file's real current
319/// size. The trailing `fseek` closes that gap.
320///
321/// @par Why the flush must succeed before anything is truncated
322/// @p offsetBeforeWrite is a *stream* position, and callers buffer: a
323/// `FileActionLog::append()` is documented as buffered-until-`flush()`, so
324/// `ftell` routinely runs ahead of the file's real on-disk size. Truncating to
325/// a stream offset that exceeds the on-disk size does not shrink the file —
326/// `std::filesystem::resize_file` **grows** it, padding with NUL bytes, and any
327/// later flush then appends the buffered record *after* that padding. The
328/// result is a NUL-bearing interior line that the caller's own reader rejects
329/// for the life of the file: precisely the bricking this rollback exists to
330/// prevent, manufactured by the rollback itself. (Measured:
331/// `ftell`=30 against an on-disk size of 10, `resize_file(30)` yielding a
332/// 30-byte file, and a final 50-byte file of data + 20 NULs + the flushed
333/// record.)
334///
335/// So the flush comes first and its result is checked. If it fails — which on
336/// a full disk is the *likely* case, since that is what made the write short —
337/// nothing is truncated at all: the on-disk contents are unknowable, the
338/// buffered bytes cannot portably be discarded, and a later flush would
339/// re-append them after whatever this call had removed. The file is left as it
340/// is for `repairTornTail()` to trim at the next open. The truncation is also
341/// clamped to the file's actual size, so it can only ever shrink.
342///
343/// `clearerr` is called first because a real short write latches the stream's
344/// error indicator, and `fseek` does not clear it. glibc keeps accepting writes
345/// on an error-flagged stream, but the standard does not require that, and on a
346/// CRT that refuses them one transient `ENOSPC` would leave the log permanently
347/// throwing for the life of the process.
348///
349/// @par Why the caller must act on a `torn` result
350/// When the rollback cannot truncate, the partial bytes stay where the *next*
351/// append would resume. That is survivable only for as long as the torn line
352/// stays the file's trailing line -- which both `FileActionLog` (via
353/// `repairTornTail` at construction) and `FileOfflineQueue` (via `load`'s
354/// tolerance of a torn trailing line) are built to heal. A further successful
355/// append on the *same live handle* ends that: it concatenates onto the partial
356/// bytes with no separating newline, pushing the damage into an interior
357/// position where neither heal applies. Measured, against a queue: the merged
358/// line makes the next open throw a raw parse error instead of loading, so the
359/// whole backlog -- including records written long before the failure -- becomes
360/// unreachable. That is the bricking the rollback exists to prevent, reached
361/// through the rollback rather than around it. A caller that gets `torn` must
362/// therefore refuse further appends on this handle rather than carry on.
363///
364/// @param ioOps Injectable I/O primitives to use for the flush/resize.
365/// @param file Open stdio handle the short write happened on.
366/// @param path Path @p file was opened from.
367/// @param offsetBeforeWrite `wideFtell(file)` as captured immediately before
368/// the short write; negative (a failed query) skips
369/// the resize, since there is no offset to roll back to.
370/// @return `RollBack::clean` when the file was left with no partial tail,
371/// `RollBack::torn` when a partial tail may remain.
372[[nodiscard]] inline RollBack rollBackShortWrite(FileIoOps& ioOps, std::FILE* file, const std::filesystem::path& path,
373 long long offsetBeforeWrite) {
374 std::clearerr(file);
375 if (ioOps.fflush(file) != 0) {
376 // Cannot reason about what reached disk, and cannot drop what did not.
377 // Leaving the file untouched is strictly safer than truncating to an
378 // offset that may exceed its real size -- see the note above.
379 return RollBack::torn;
380 }
381 if (offsetBeforeWrite < 0) {
382 return RollBack::torn; // no offset to roll back to
383 }
384 std::error_code errorCode;
385 auto const onDisk = std::filesystem::file_size(path, errorCode);
386 auto target = static_cast<std::uintmax_t>(offsetBeforeWrite);
387 bool const sizeKnown = !errorCode;
388 if (sizeKnown) {
389 target = std::min(target, onDisk); // only ever shrink
390 }
391 errorCode.clear();
392 ioOps.resizeFile(path, target, errorCode);
393 RollBack const outcome = errorCode ? RollBack::torn : RollBack::clean;
394 // Resync `file`'s stdio position with the (now shorter) file on disk. Safe
395 // to flush against here, and only here: the buffer was emptied above, so
396 // this cannot re-append anything the resize just removed. The return value
397 // is deliberately unchecked -- this is already the failure path, and a
398 // failed reposition leaves the caller no better recovery than the throw it
399 // is about to do anyway.
400 // NOLINTNEXTLINE(cert-err33-c)
401 std::fseek(file, 0, SEEK_END);
402 return outcome;
403}
404
405/// @brief Truncates any bytes following the last newline in @p path.
406///
407/// A crash between a caller's `fwrite` and its next `fsync` can leave a
408/// partial record at the end.
409/// Because every complete record is written newline-terminated in a single
410/// `fwrite`, whatever follows the final newline is by construction an
411/// incomplete record and never a whole one -- which makes discarding it
412/// safe: it can only remove bytes that no reader could ever have decoded.
413/// Complete records, including a malformed *interior* line, are left exactly
414/// as they are; diagnosing those is the caller's own read path's job.
415///
416/// Called from `morph::journal::FileActionLog`'s constructor, which is where
417/// this scan has always lived; lifted out of that class so the logic has one
418/// home. `morph::offline::FileOfflineQueue` deliberately does **not** call it --
419/// see the note in its constructor for why running it there both failed to fix
420/// the case it was added for and cost a documented invariant.
421///
422/// @warning It trims by newline, not by parse, so a *complete* final record
423/// whose only missing byte is the terminating newline is discarded along with a
424/// genuinely torn one. Callers that accept externally-appended records need to
425/// know that before adopting it.
426///
427/// @param ioOps Injectable I/O primitives to use for the read-check/resize.
428/// @param path File to check and, if needed, truncate.
429/// @param logComponent Name to prefix warning log lines with (the calling
430/// class's own name), so a log reader can tell which file the
431/// warning is about without `path` alone disambiguating it.
432inline void repairTornTail(FileIoOps& ioOps, const std::filesystem::path& path, std::string_view logComponent) {
433 std::error_code errorCode;
434 auto const size = std::filesystem::file_size(path, errorCode);
435 if (errorCode || size == 0) {
436 return; // absent or empty: nothing to repair
437 }
438 if (!ioOps.canOpenForRead(path)) {
439 return;
440 }
441 std::ifstream input{path, std::ios::binary};
442 if (!input) {
443 // The probe above said readable and this open still failed (a
444 // permission change or fd exhaustion landing in between). Falling
445 // through would scan nothing, leave `intactEnd` at 0, and truncate
446 // the whole file as if it were one torn record -- so bail instead.
447 // The safety argument above holds only for bytes actually read.
448 ::morph::log::logWarn(std::string{logComponent} + ": could not read " + path.string() +
449 " to check for a torn trailing record; leaving it untouched");
450 return;
451 }
452 std::uintmax_t intactEnd = 0;
453 std::uintmax_t offset = 0;
454 std::string line;
455 while (std::getline(input, line)) {
456 offset += line.size();
457 if (input.eof()) {
458 break; // no trailing newline: this line is the torn remainder
459 }
460 ++offset; // the '\n' getline consumed
461 intactEnd = offset;
462 }
463 if (input.bad()) {
464 // Terminated by an I/O error rather than by end-of-file, so
465 // everything past `intactEnd` is unread rather than established to
466 // be torn. Truncating here would discard complete, fsynced records.
467 ::morph::log::logWarn(std::string{logComponent} + ": read error while checking " + path.string() +
468 " for a torn trailing record; leaving it untouched");
469 return;
470 }
471 if (intactEnd == size) {
472 return;
473 }
474 ioOps.resizeFile(path, intactEnd, errorCode);
475 if (errorCode) {
476 ::morph::log::logWarn(std::string{logComponent} + ": could not truncate torn trailing record in " +
477 path.string() + ": " + errorCode.message());
478 return;
479 }
480 ::morph::log::logWarn(std::string{logComponent} + ": discarded " + std::to_string(size - intactEnd) +
481 " byte(s) of a torn trailing record in " + path.string());
482}
483
484} // namespace morph::core
The raw file-I/O primitives morph::journal::FileActionLog and morph::offline::FileOfflineQueue both c...
std::function< int(std::FILE *file)> fsync
Commits file's contents to durable storage. POSIX fsync / Windows _commit, resolved from file via fil...
std::function< int(std::FILE *file)> fflush
Flushes file's stdio buffer. Mirrors std::fflush.
std::function< bool(const std::filesystem::path &path)> canOpenForRead
Reports whether path can be opened for reading right now. Stands in for std::ifstream{path}...
std::function< void(const std::filesystem::path &path, std::uintmax_t newSize, std::error_code &errorCode)> resizeFile
Truncates/extends path to newSize bytes. Mirrors std::filesystem::resize_file.
std::function< std::size_t(const void *buffer, std::size_t size, std::FILE *file)> fwrite
Writes size bytes from buffer to file. Mirrors std::fwrite.
std::function< int(const std::filesystem::path &dir)> syncPath
Commits a directory's own metadata (new/renamed entries within it) to durable storage....
std::function< std::FILE *(const std::string &path, const char *mode)> fopen
Opens path in mode mode. Mirrors std::fopen.