core: io/sparse_file + io/write_buffer (stage 4)

io/sparse_file — the single O_WRONLY output file (docs/04 §4). open()
posix_fallocate's the full size (falls back to ftruncate on
EOPNOTSUPP/ENOSYS, reported via preallocated()); write_at() pwrites at an
absolute offset, looping short writes and retrying EINTR; sync() is
fdatasync (timer/pause only); advise_dontneed() is
posix_fadvise(DONTNEED); resize() trims a preallocated tail or sizes a
chunked download. errno -> vdm::Error (ENOSPC->disk_full,
EACCES->permission_denied, ENOENT/ENOTDIR/...->path_rejected). No lock on
write_at — POSIX makes each pwrite atomic for a regular file, so N
segment threads writing disjoint ranges is safe (tested, TSan-clean).

io/write_buffer — per-segment accumulate-and-flush buffer, preallocated
at construction; append() only memcpys (no allocation on the write-
callback hot path, docs/04 §8 — asserted by a global-new counter in the
test). Flushes on fill via a caller-supplied FlushFn; a chunk >= capacity
arriving on an empty buffer writes straight through. On a flush error
next_offset() stays at the last durable position. Single-threaded; the
disk-writer-thread handoff is stage 8.

Also: vtest.hpp VT_CHECK_EQ/NE now copy operands (auto, not auto&&) — an
assertion must not outlive a temporary the expression returned a
reference into (ASan caught this on Result<void>{}.error().code).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
This commit is contained in:
2026-09-10 00:41:31 +04:00
co-authored by Claude Sonnet 5
parent e8a6b64c2f
commit bd0a24c87a
9 changed files with 781 additions and 4 deletions
+181
View File
@@ -0,0 +1,181 @@
// vdm/io/sparse_file.cpp
#include "vdm/io/sparse_file.hpp"
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <utility>
namespace vdm::io {
namespace {
Error errno_to_error(int e) noexcept {
switch (e) {
case ENOSPC:
case EDQUOT:
return Error::disk_full;
case EACCES:
case EPERM:
case EROFS:
return Error::permission_denied;
case ENOENT:
case ENOTDIR:
case EISDIR:
case ENAMETOOLONG:
case ELOOP:
return Error::path_rejected;
default:
return Error::io_error;
}
}
ErrorInfo sys_error(std::string_view what, int e) {
return ErrorInfo(errno_to_error(e), std::string(what) + ": " + std::strerror(e));
}
} // namespace
SparseFile::~SparseFile() {
if (fd_ >= 0)
::close(fd_);
}
SparseFile::SparseFile(SparseFile &&o) noexcept
: fd_(std::exchange(o.fd_, -1)),
preallocated_(std::exchange(o.preallocated_, false)),
path_(std::move(o.path_)) {}
SparseFile &SparseFile::operator=(SparseFile &&o) noexcept {
if (this != &o) {
if (fd_ >= 0)
::close(fd_);
fd_ = std::exchange(o.fd_, -1);
preallocated_ = std::exchange(o.preallocated_, false);
path_ = std::move(o.path_);
}
return *this;
}
void SparseFile::reset() noexcept {
fd_ = -1;
preallocated_ = false;
path_.clear();
}
Result<void> SparseFile::open(std::string_view path) {
return open(path, OpenOptions{});
}
Result<void> SparseFile::open(std::string_view path, const OpenOptions &opts) {
if (fd_ >= 0)
return ErrorInfo(Error::internal, "SparseFile already open");
std::string p(path);
int flags = O_WRONLY | O_CREAT | O_CLOEXEC;
if (opts.truncate_existing)
flags |= O_TRUNC;
int fd = ::open(p.c_str(), flags, 0644);
if (fd < 0)
return sys_error("open " + p, errno);
bool prealloc = false;
if (opts.total_size > 0) {
if (opts.preallocate) {
// posix_fallocate returns the error number directly and does not set errno.
int rc = ::posix_fallocate(fd, 0, static_cast<off_t>(opts.total_size));
if (rc == 0) {
prealloc = true;
} else if (rc == EOPNOTSUPP || rc == ENOSYS || rc == EINVAL) {
if (::ftruncate(fd, static_cast<off_t>(opts.total_size)) != 0) {
int e = errno;
::close(fd);
return sys_error("ftruncate " + p, e);
}
} else {
::close(fd);
return sys_error("posix_fallocate " + p, rc);
}
} else if (!opts.truncate_existing) {
// Resuming: make sure the file is at least total_size so pwrite offsets land.
if (::ftruncate(fd, static_cast<off_t>(opts.total_size)) != 0) {
int e = errno;
::close(fd);
return sys_error("ftruncate " + p, e);
}
}
}
fd_ = fd;
preallocated_ = prealloc;
path_ = std::move(p);
return ok();
}
Result<void> SparseFile::write_at(std::uint64_t offset, ConstByteSpan data) {
if (fd_ < 0)
return ErrorInfo(Error::internal, "write_at on a closed SparseFile");
const std::byte *p = data.data();
std::size_t remaining = data.size();
off_t pos = static_cast<off_t>(offset);
while (remaining > 0) {
ssize_t n = ::pwrite(fd_, p, remaining, pos);
if (n < 0) {
if (errno == EINTR)
continue;
return sys_error("pwrite", errno);
}
if (n == 0)
return ErrorInfo(Error::io_error, "pwrite returned 0");
p += n;
pos += n;
remaining -= static_cast<std::size_t>(n);
}
return ok();
}
Result<void> SparseFile::sync() {
if (fd_ < 0)
return ErrorInfo(Error::internal, "sync on a closed SparseFile");
while (::fdatasync(fd_) != 0) {
if (errno == EINTR)
continue;
return sys_error("fdatasync", errno);
}
return ok();
}
void SparseFile::advise_dontneed(std::uint64_t offset, std::uint64_t len) noexcept {
if (fd_ < 0 || len == 0)
return;
::posix_fadvise(fd_, static_cast<off_t>(offset), static_cast<off_t>(len), POSIX_FADV_DONTNEED);
}
Result<void> SparseFile::resize(std::uint64_t size) {
if (fd_ < 0)
return ErrorInfo(Error::internal, "resize on a closed SparseFile");
while (::ftruncate(fd_, static_cast<off_t>(size)) != 0) {
if (errno == EINTR)
continue;
return sys_error("ftruncate", errno);
}
return ok();
}
Result<void> SparseFile::close() {
if (fd_ < 0)
return ok();
int fd = std::exchange(fd_, -1);
int rc = ::close(fd);
reset();
if (rc != 0)
return sys_error("close", errno);
return ok();
}
} // namespace vdm::io
+57
View File
@@ -0,0 +1,57 @@
// vdm/io/write_buffer.cpp
#include "vdm/io/write_buffer.hpp"
#include <algorithm>
#include <cassert>
#include <cstring>
#include <utility>
namespace vdm::io {
WriteBuffer::WriteBuffer(std::uint64_t start_offset, std::size_t capacity, FlushFn flush)
: buf_(capacity), base_(start_offset), flush_(std::move(flush)) {
assert(capacity > 0 && "WriteBuffer capacity must be > 0");
}
Result<void> WriteBuffer::flush_pending() {
if (len_ == 0)
return ok();
VDM_TRY(flush_(base_, ConstByteSpan(buf_.data(), len_)));
base_ += len_;
len_ = 0;
return ok();
}
Result<void> WriteBuffer::append(ConstByteSpan span) {
// On an error return, next_offset() still reflects exactly what is durable; buffered
// (non-durable) bytes and any unconsumed tail of `span` are the caller's to abandon —
// the segment aborts and resumes/restarts from next_offset().
while (!span.empty()) {
// Buffer empty and the incoming chunk fills at least a whole buffer: write it
// straight through — no memcpy, no allocation, just an extra flush call.
if (len_ == 0 && span.size() >= buf_.size()) {
VDM_TRY(flush_(base_, span));
base_ += span.size();
appended_ += span.size();
return ok();
}
const std::size_t room = buf_.size() - len_;
const std::size_t n = std::min(span.size(), room);
std::memcpy(buf_.data() + len_, span.data(), n);
len_ += n;
appended_ += n;
span = span.subspan(n);
if (len_ == buf_.size())
VDM_TRY(flush_pending());
}
return ok();
}
Result<void> WriteBuffer::flush() {
return flush_pending();
}
} // namespace vdm::io