Land the package filesystem shell: streaming atomic package_io, journaled rollback carve-out, asymmetric platform pickers
This commit is contained in:
@@ -92,3 +92,4 @@ enable_testing()
|
||||
add_subdirectory(src/core)
|
||||
add_subdirectory(src/app)
|
||||
add_subdirectory(src/shell/instrument)
|
||||
add_subdirectory(src/shell/package)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# src/shell/package — package filesystem + dialog seam
|
||||
|
||||
## Scope
|
||||
|
||||
The filesystem and dialog acts behind bank-package export/import: streaming package
|
||||
file I/O (`package_io`), the landed-file journal and its rollback delete
|
||||
(`package_rollback`), and the platform file pickers (`package_pickers`). This seam
|
||||
is bytes-only — the package format (magic, manifest, entry layout) is
|
||||
`core/package`'s business, and the export/import verbs that orchestrate both do not
|
||||
live here yet. No REAPER project state is touched in this directory: no ext-state
|
||||
read or write, no undo block, no generation bump — those belong to the verbs.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Atomic write.** A package accumulates in a `.rsbanktmp` sibling in the
|
||||
destination directory and reaches the destination only through `commit()`'s
|
||||
rename (the mono-collapse temp+rename precedent). A failed, aborted, or abandoned
|
||||
write leaves the destination absent or holding its prior contents — never a
|
||||
partial `.rsbank`.
|
||||
- **Streaming, both ways — at most ONE entry's payload in memory.** Writes append
|
||||
one payload at a time; reads seek and materialize one range at a time. The claim
|
||||
is structural, not aspirational: every payload crosses this seam as a move-only
|
||||
`PayloadBuffer`, and `PayloadBuffer::alive()` is the seam counter the tests
|
||||
assert against. There is no read-whole-package or write-whole-package entry
|
||||
point; do not add one.
|
||||
- **The rollback delete is prune's ONE carve-out, cited not restated.** The
|
||||
citation and the discriminator live at `package_rollback.cpp`'s header. The
|
||||
journal makes the discriminator structural: only paths its own `writeLandedFile`
|
||||
successfully created are recorded, and `rollback()` consumes only the record — a
|
||||
path this import did not write cannot be handed to it.
|
||||
- **No overwrite of a bank-folder file, ever.** `writeLandedFile` refuses an
|
||||
existing destination outright; collision handling (auto-rename) is the import
|
||||
plan's job upstream. The package writer itself DOES replace an existing
|
||||
destination — the export save dialog's own overwrite confirm is the consent —
|
||||
and that asymmetry is deliberate.
|
||||
- **The two pickers are asymmetric, and the asymmetry is real.** Import rides
|
||||
REAPER's own `GetUserFileNameForRead` (both platforms); export goes native —
|
||||
Win32 `GetSaveFileNameW` / SWELL `BrowseForSaveFile` — because the always-present
|
||||
REAPER surface offers no save picker. Do not symmetrize; the newer
|
||||
`GetUserFileName(mode=0)` alternative and why it is not used are recorded in
|
||||
`package_pickers.cpp`'s header.
|
||||
|
||||
## Modules
|
||||
|
||||
- `package_io` — the streaming filesystem seam: `PayloadBuffer` (move-only payload + the `alive()` seam counter), `PackageFileWriter` (append-only temp+atomic-rename writer), `PackageFileReader` (seek-and-read one range per call, range-checked against the real file size), `readFilePayload` (one source file as one entry's payload), and `listFolderFileNames` (bare names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW.
|
||||
- `package_rollback` — `LandedFileJournal`: `writeLandedFile` (temp+rename land, recorded on success only, refuses an existing destination and an empty payload) and `rollback` (deletes exactly the recorded set, hard unlink — nothing ever referenced these bytes — tolerating a vanished file). REAPER-free; tested without a DAW.
|
||||
- `package_pickers` — the two pickers in one platform TU (`#ifdef _WIN32` / `#else swell/swell.h`, the `draw_kit`/`prune_fs` split): `pickPackageForImport` (REAPER read picker) and `pickPackageSavePath` (native save dialog, UTF-8 in/out on Windows). Compile-only until the verbs land; nothing here can be exercised in a unit test.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- A crash mid-write strands the `.rsbanktmp` sibling. It is not a `.rsbank` (no
|
||||
picker filter matches it), and a later export to the same destination truncates
|
||||
it — but one stranded in the BANK folder by a mid-import crash is a foreign file
|
||||
to prune (not owned, so never an orphan) until removed by hand. `[verify — DAW]`
|
||||
whether the import verb should pre-clean stale `.rsbanktmp` names when it lands.
|
||||
- The picker `defext`/filter strings are spelled to the Win32 `lpstrDefExt`
|
||||
convention (no dot) but are `[verify — DAW]` on all three platforms — neither
|
||||
picker is exercised outside a live REAPER session.
|
||||
- `readRange(_, 0)` returns an empty buffer — indistinguishable from failure, by
|
||||
design (the one "nothing to work with" branch). A genuinely zero-length entry
|
||||
cannot round-trip through this seam; the format layer must not emit one.
|
||||
@@ -0,0 +1,20 @@
|
||||
# The filesystem + dialog seam for bank packages. package_io / package_rollback are
|
||||
# REAPER-free (standard filesystem only), so the pure-library/test helpers fit and
|
||||
# their tests run without a DAW. The export/import verbs that drive all three targets
|
||||
# are not in this directory yet.
|
||||
|
||||
reasampler_pure_library(package_io SOURCES package_io.cpp LINK PUBLIC file_bytes)
|
||||
reasampler_test(package_io LINK package_io)
|
||||
|
||||
reasampler_pure_library(package_rollback SOURCES package_rollback.cpp LINK PUBLIC package_io)
|
||||
reasampler_test(package_rollback LINK package_rollback)
|
||||
|
||||
# The platform pickers touch the REAPER API and the native save dialog, so no test
|
||||
# target can exercise them; declared as a library so both picker paths stay compiled.
|
||||
add_library(package_pickers STATIC package_pickers.cpp)
|
||||
target_include_directories(package_pickers PUBLIC ${REASAMPLER_SRC_DIR})
|
||||
target_include_directories(package_pickers PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
if(NOT WIN32)
|
||||
# Match the loadable modules: SWELL is provided by the host REAPER at runtime.
|
||||
target_compile_definitions(package_pickers PRIVATE SWELL_PROVIDED_BY_APP)
|
||||
endif()
|
||||
@@ -0,0 +1,170 @@
|
||||
// package_io.cpp — see package_io.h for the seam's contract. Non-throwing at the
|
||||
// boundary: every filesystem call uses the error_code form so no filesystem_error
|
||||
// crosses into a REAPER action body.
|
||||
|
||||
#include "shell/package/package_io.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
#include "core/util/file_bytes.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
std::atomic<int> g_alivePayloads{0};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PayloadBuffer
|
||||
|
||||
PayloadBuffer::PayloadBuffer(std::vector<std::uint8_t> bytes)
|
||||
: bytes_(std::move(bytes)), counted_(!bytes_.empty()) {
|
||||
if (counted_) g_alivePayloads.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
PayloadBuffer::~PayloadBuffer() { release(); }
|
||||
|
||||
PayloadBuffer::PayloadBuffer(PayloadBuffer&& other) noexcept
|
||||
: bytes_(std::move(other.bytes_)), counted_(other.counted_) {
|
||||
// The count transfers with the bytes — a move must never double-count.
|
||||
other.bytes_.clear();
|
||||
other.counted_ = false;
|
||||
}
|
||||
|
||||
PayloadBuffer& PayloadBuffer::operator=(PayloadBuffer&& other) noexcept {
|
||||
if (this != &other) {
|
||||
release();
|
||||
bytes_ = std::move(other.bytes_);
|
||||
counted_ = other.counted_;
|
||||
other.bytes_.clear();
|
||||
other.counted_ = false;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
int PayloadBuffer::alive() { return g_alivePayloads.load(std::memory_order_relaxed); }
|
||||
|
||||
void PayloadBuffer::release() {
|
||||
if (counted_) g_alivePayloads.fetch_sub(1, std::memory_order_relaxed);
|
||||
counted_ = false;
|
||||
bytes_.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PackageFileWriter
|
||||
|
||||
PackageFileWriter::PackageFileWriter(std::string destAbsPath)
|
||||
: destPath_(std::move(destAbsPath)), tempPath_(destPath_ + ".rsbanktmp") {
|
||||
out_.open(tempPath_, std::ios::binary | std::ios::trunc);
|
||||
ok_ = static_cast<bool>(out_);
|
||||
}
|
||||
|
||||
PackageFileWriter::~PackageFileWriter() {
|
||||
if (!done_) abort();
|
||||
}
|
||||
|
||||
bool PackageFileWriter::appendRaw(const std::uint8_t* data, std::size_t len) {
|
||||
if (!ok_ || done_) return false;
|
||||
if (len == 0) return true;
|
||||
out_.write(reinterpret_cast<const char*>(data),
|
||||
static_cast<std::streamsize>(len));
|
||||
ok_ = static_cast<bool>(out_);
|
||||
return ok_;
|
||||
}
|
||||
|
||||
bool PackageFileWriter::appendPayload(const PayloadBuffer& payload) {
|
||||
return appendRaw(payload.data(), payload.size());
|
||||
}
|
||||
|
||||
bool PackageFileWriter::commit() {
|
||||
if (done_) return false;
|
||||
if (ok_) {
|
||||
out_.flush();
|
||||
ok_ = static_cast<bool>(out_);
|
||||
}
|
||||
out_.close();
|
||||
if (!ok_) {
|
||||
abort();
|
||||
return false;
|
||||
}
|
||||
// rename() replaces the destination in one step (the mono-collapse precedent):
|
||||
// prior contents survive until the replacement is known-complete, and a failed
|
||||
// rename self-cleans the temp rather than littering it.
|
||||
std::error_code ec;
|
||||
fs::rename(tempPath_, destPath_, ec);
|
||||
if (ec) {
|
||||
fs::remove(tempPath_, ec);
|
||||
done_ = true;
|
||||
ok_ = false;
|
||||
return false;
|
||||
}
|
||||
done_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void PackageFileWriter::abort() {
|
||||
if (done_) return;
|
||||
out_.close();
|
||||
std::error_code ec;
|
||||
fs::remove(tempPath_, ec);
|
||||
done_ = true;
|
||||
ok_ = false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PackageFileReader
|
||||
|
||||
PackageFileReader::PackageFileReader(const std::string& srcAbsPath) {
|
||||
std::error_code ec;
|
||||
const std::uintmax_t sz = fs::file_size(srcAbsPath, ec);
|
||||
if (ec) return;
|
||||
in_.open(srcAbsPath, std::ios::binary);
|
||||
if (!in_) return;
|
||||
size_ = static_cast<std::uint64_t>(sz);
|
||||
ok_ = true;
|
||||
}
|
||||
|
||||
PayloadBuffer PackageFileReader::readRange(std::uint64_t offset, std::uint64_t length) {
|
||||
// Overflow-safe range check: length is capped by the real file size before any
|
||||
// allocation happens, so a hostile offset/length pair cannot demand the moon.
|
||||
if (!ok_ || length == 0 || length > size_ || offset > size_ - length) {
|
||||
return PayloadBuffer{};
|
||||
}
|
||||
in_.clear(); // a prior failed read must not poison this one
|
||||
in_.seekg(static_cast<std::streamoff>(offset));
|
||||
if (!in_) return PayloadBuffer{};
|
||||
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(length));
|
||||
in_.read(reinterpret_cast<char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(length));
|
||||
if (static_cast<std::uint64_t>(in_.gcount()) != length) return PayloadBuffer{};
|
||||
return PayloadBuffer(std::move(bytes));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
PayloadBuffer readFilePayload(const std::string& absPath) {
|
||||
return PayloadBuffer(util::readFileBytes(absPath));
|
||||
}
|
||||
|
||||
std::vector<std::string> listFolderFileNames(const std::string& dirAbsPath) {
|
||||
std::vector<std::string> names;
|
||||
std::error_code ec;
|
||||
// Manual iterator form (it.increment(ec)) keeps the loop non-throwing on a
|
||||
// mid-iteration failure, matching prune_fs's enumerate.
|
||||
fs::directory_iterator it(dirAbsPath, ec);
|
||||
for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) {
|
||||
const auto& entry = *it;
|
||||
std::error_code reg_ec;
|
||||
if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials
|
||||
names.push_back(entry.path().filename().string());
|
||||
}
|
||||
std::sort(names.begin(), names.end());
|
||||
return names;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,112 @@
|
||||
// shell/package/package_io — streaming filesystem seam for bank packages: append one
|
||||
// payload at a time through a temp-file + atomic-rename writer, seek and read one
|
||||
// payload at a time back out. Bytes only: what a package contains is core/package's
|
||||
// business, never this seam's. Blocking I/O — UI-thread actions only, never the
|
||||
// audio thread.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// One entry's payload, and the seam counter that makes "never more than one entry in
|
||||
// memory" assertable: alive() counts every buffer currently holding bytes, so the
|
||||
// streaming claim is a test CHECK against this counter rather than a memory
|
||||
// measurement. Move-only — copying a payload would silently double the held bytes.
|
||||
class PayloadBuffer {
|
||||
public:
|
||||
PayloadBuffer() = default;
|
||||
explicit PayloadBuffer(std::vector<std::uint8_t> bytes);
|
||||
~PayloadBuffer();
|
||||
PayloadBuffer(PayloadBuffer&& other) noexcept;
|
||||
PayloadBuffer& operator=(PayloadBuffer&& other) noexcept;
|
||||
PayloadBuffer(const PayloadBuffer&) = delete;
|
||||
PayloadBuffer& operator=(const PayloadBuffer&) = delete;
|
||||
|
||||
const std::uint8_t* data() const { return bytes_.data(); }
|
||||
std::size_t size() const { return bytes_.size(); }
|
||||
bool empty() const { return bytes_.empty(); }
|
||||
const std::vector<std::uint8_t>& bytes() const { return bytes_; }
|
||||
|
||||
// Buffers currently holding at least one byte, process-wide.
|
||||
static int alive();
|
||||
|
||||
private:
|
||||
void release();
|
||||
|
||||
std::vector<std::uint8_t> bytes_;
|
||||
bool counted_ = false;
|
||||
};
|
||||
|
||||
// Streaming atomic writer. Bytes accumulate in "<dest>.rsbanktmp" beside the
|
||||
// destination (same directory, so the final rename never crosses a volume); the
|
||||
// destination itself is touched only by commit()'s rename, so a failed, aborted, or
|
||||
// abandoned write leaves it absent or holding its prior contents — never a partial
|
||||
// file. Destruction without commit() aborts and removes the temp. commit() REPLACES
|
||||
// an existing destination: the export save dialog's own overwrite confirm is the
|
||||
// consent (the never-overwrite rule for bank-folder files lives in
|
||||
// LandedFileJournal, upstream of this writer). Neither copyable nor movable, and
|
||||
// append-only — there is deliberately no way to hand it a whole package at once.
|
||||
class PackageFileWriter {
|
||||
public:
|
||||
explicit PackageFileWriter(std::string destAbsPath);
|
||||
~PackageFileWriter();
|
||||
PackageFileWriter(const PackageFileWriter&) = delete;
|
||||
PackageFileWriter& operator=(const PackageFileWriter&) = delete;
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
// Framing/header bytes. False on a failed or already-finished writer.
|
||||
bool appendRaw(const std::uint8_t* data, std::size_t len);
|
||||
// One entry's bytes. Same contract as appendRaw.
|
||||
bool appendPayload(const PayloadBuffer& payload);
|
||||
// Flush, close, rename over the destination. False (and self-cleaning: the temp
|
||||
// is removed, the destination untouched) on any failure or on a second call.
|
||||
bool commit();
|
||||
// Close and remove the temp; the destination is never touched. Idempotent.
|
||||
void abort();
|
||||
|
||||
const std::string& destPath() const { return destPath_; }
|
||||
const std::string& tempPath() const { return tempPath_; }
|
||||
|
||||
private:
|
||||
std::string destPath_;
|
||||
std::string tempPath_;
|
||||
std::ofstream out_;
|
||||
bool ok_ = false;
|
||||
bool done_ = false;
|
||||
};
|
||||
|
||||
// Seek-and-read reader: exactly one payload is materialized per readRange call, and
|
||||
// there is deliberately no read-whole-file entry point. Empty buffer on ANY failure
|
||||
// — unopenable file, zero length, out of range, short read — so the caller has one
|
||||
// "nothing to work with" branch (file_bytes' contract).
|
||||
class PackageFileReader {
|
||||
public:
|
||||
explicit PackageFileReader(const std::string& srcAbsPath);
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
std::uint64_t fileSize() const { return size_; }
|
||||
// Bytes [offset, offset+length). Range-checked against the real file size, so a
|
||||
// hostile layout can never demand an allocation past the file's end.
|
||||
PayloadBuffer readRange(std::uint64_t offset, std::uint64_t length);
|
||||
|
||||
private:
|
||||
std::ifstream in_;
|
||||
std::uint64_t size_ = 0;
|
||||
bool ok_ = false;
|
||||
};
|
||||
|
||||
// One source file read whole as one entry's payload — a bank file IS the streaming
|
||||
// unit, so whole-file here is one entry, released before the next is read. Empty on
|
||||
// any failure, per readFileBytes.
|
||||
PayloadBuffer readFilePayload(const std::string& absPath);
|
||||
|
||||
// Bare file names (regular files only, never a path) in dirAbsPath, sorted so
|
||||
// callers see a deterministic order; empty on a missing or unreadable folder.
|
||||
std::vector<std::string> listFolderFileNames(const std::string& dirAbsPath);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,93 @@
|
||||
// package_pickers.cpp — see package_pickers.h. Export is native rather than REAPER
|
||||
// API: the SDK's newer GetUserFileName(mode=0) could save, but it resolves to null
|
||||
// on older REAPER builds this extension still loads in, while GetSaveFileNameW /
|
||||
// BrowseForSaveFile are always present. main.cpp owns the REAPER API pointers; this
|
||||
// TU gets them extern.
|
||||
|
||||
#include "shell/package/package_pickers.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <commdlg.h>
|
||||
#else
|
||||
#include "swell/swell.h"
|
||||
#endif
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetUserFileNameForRead
|
||||
#define REAPERAPI_WANT_GetMainHwnd
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
bool pickPackageForImport(std::string& outAbsPath) {
|
||||
outAbsPath.clear();
|
||||
if (!GetUserFileNameForRead) return false;
|
||||
char buf[4096];
|
||||
buf[0] = '\0';
|
||||
// defext spelled without the dot, matching Win32 lpstrDefExt. [verify — DAW]
|
||||
// whether REAPER's picker applies it to the shown filter.
|
||||
if (!GetUserFileNameForRead(buf, "Import bank package", "rsbank")) return false;
|
||||
outAbsPath = buf;
|
||||
return !outAbsPath.empty();
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
bool pickPackageSavePath(const std::string& suggestedFileName, std::string& outAbsPath) {
|
||||
outAbsPath.clear();
|
||||
|
||||
wchar_t file[4096];
|
||||
file[0] = L'\0';
|
||||
if (!suggestedFileName.empty()) {
|
||||
const int wrote = MultiByteToWideChar(CP_UTF8, 0, suggestedFileName.c_str(),
|
||||
-1, file, 4096);
|
||||
if (wrote <= 0) file[0] = L'\0'; // unconvertible suggestion -> empty box
|
||||
}
|
||||
|
||||
OPENFILENAMEW ofn{};
|
||||
ofn.lStructSize = sizeof(ofn);
|
||||
ofn.hwndOwner = GetMainHwnd ? GetMainHwnd() : nullptr;
|
||||
ofn.lpstrFilter =
|
||||
L"ReaSampler bank package (*.rsbank)\0*.rsbank\0All files (*.*)\0*.*\0";
|
||||
ofn.lpstrFile = file;
|
||||
ofn.nMaxFile = 4096;
|
||||
ofn.lpstrTitle = L"Export bank package";
|
||||
ofn.lpstrDefExt = L"rsbank";
|
||||
// NOCHANGEDIR: REAPER's process-wide working directory is not ours to move.
|
||||
ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR |
|
||||
OFN_HIDEREADONLY;
|
||||
if (!GetSaveFileNameW(&ofn)) return false;
|
||||
|
||||
const int need =
|
||||
WideCharToMultiByte(CP_UTF8, 0, file, -1, nullptr, 0, nullptr, nullptr);
|
||||
if (need <= 0) return false;
|
||||
std::string utf8(static_cast<std::size_t>(need), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, file, -1, &utf8[0], need, nullptr, nullptr);
|
||||
if (!utf8.empty() && utf8.back() == '\0') utf8.pop_back();
|
||||
outAbsPath = std::move(utf8);
|
||||
return !outAbsPath.empty();
|
||||
}
|
||||
|
||||
#else // SWELL (macOS / Linux)
|
||||
|
||||
bool pickPackageSavePath(const std::string& suggestedFileName, std::string& outAbsPath) {
|
||||
outAbsPath.clear();
|
||||
// GetOpenFileName-style pair list; the literal's implicit terminator supplies the
|
||||
// closing double-NUL. [verify — DAW] the exact filter strings SWELL accepts.
|
||||
static const char kExtList[] = "ReaSampler bank package (*.rsbank)\0*.rsbank\0";
|
||||
char fn[4096];
|
||||
fn[0] = '\0';
|
||||
if (!BrowseForSaveFile("Export bank package", nullptr,
|
||||
suggestedFileName.empty() ? nullptr
|
||||
: suggestedFileName.c_str(),
|
||||
kExtList, fn, static_cast<int>(sizeof(fn)))) {
|
||||
return false;
|
||||
}
|
||||
outAbsPath = fn;
|
||||
return !outAbsPath.empty();
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,21 @@
|
||||
// shell/package/package_pickers — the two package file pickers, and they are
|
||||
// deliberately asymmetric: import rides REAPER's own read picker
|
||||
// (GetUserFileNameForRead, both platforms); export goes native — Win32
|
||||
// GetSaveFileNameW / SWELL BrowseForSaveFile — because the always-present REAPER
|
||||
// surface offers no save picker. Do not symmetrize; the rationale is in the TU.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// REAPER's read picker. True with outAbsPath set iff the user chose a file.
|
||||
bool pickPackageForImport(std::string& outAbsPath);
|
||||
|
||||
// Native save picker, pre-filled with suggestedFileName (a bare name, e.g.
|
||||
// "MyBank.rsbank"). True with outAbsPath set iff the user chose a destination; the
|
||||
// dialog's own overwrite confirm has already run by then.
|
||||
bool pickPackageSavePath(const std::string& suggestedFileName, std::string& outAbsPath);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,40 @@
|
||||
// package_rollback.cpp — see package_rollback.h. The rollback delete below runs
|
||||
// under the ONE carve-out from prune's exclusive file-deletion authority, stated at
|
||||
// src/shell/persist/prune_fs.cpp:5-11; it satisfies that discriminator by
|
||||
// construction — every recorded path was created by this journal's own
|
||||
// writeLandedFile, and the abandoned index mutation means nothing ever referenced it.
|
||||
|
||||
#include "shell/package/package_rollback.h"
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
bool LandedFileJournal::writeLandedFile(const std::string& absPath,
|
||||
const PayloadBuffer& payload) {
|
||||
if (payload.empty()) return false;
|
||||
std::error_code ec;
|
||||
if (fs::exists(absPath, ec) || ec) return false;
|
||||
PackageFileWriter writer(absPath);
|
||||
if (!writer.appendPayload(payload)) return false; // dtor aborts; temp removed
|
||||
if (!writer.commit()) return false;
|
||||
paths_.push_back(absPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
RollbackResult LandedFileJournal::rollback() {
|
||||
RollbackResult result;
|
||||
for (const std::string& path : paths_) {
|
||||
std::error_code ec;
|
||||
const bool removed = fs::remove(path, ec);
|
||||
if (removed) ++result.deletedCount;
|
||||
else if (ec) ++result.failedCount;
|
||||
else ++result.alreadyAbsentCount; // no error, nothing there
|
||||
}
|
||||
paths_.clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,45 @@
|
||||
// shell/package/package_rollback — the files ONE import call has landed, as a
|
||||
// journal: writes record themselves on success, and rollback() deletes exactly what
|
||||
// is recorded — a path this import did not write is structurally impossible to hand
|
||||
// it. The deletion carve-out this satisfies is cited at package_rollback.cpp's
|
||||
// header.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "shell/package/package_io.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
struct RollbackResult {
|
||||
int deletedCount = 0;
|
||||
int alreadyAbsentCount = 0; // vanished between land and rollback — not a failure
|
||||
int failedCount = 0; // locked / permission — recorded, never thrown
|
||||
};
|
||||
|
||||
// The evidence for the rollback discriminator: only paths this journal's own
|
||||
// writeLandedFile successfully created are recorded, so rollback() can never touch a
|
||||
// byte this import did not write.
|
||||
class LandedFileJournal {
|
||||
public:
|
||||
// Lands one payload at absPath through the atomic temp+rename writer and records
|
||||
// the path on success. REFUSES an existing destination — a bank-folder file is
|
||||
// never overwritten; collision handling is the import plan's job, upstream. An
|
||||
// empty payload is refused too: it signals an upstream read failure, never a
|
||||
// real entry.
|
||||
bool writeLandedFile(const std::string& absPath, const PayloadBuffer& payload);
|
||||
|
||||
// Deletes exactly the recorded files and clears the journal, so a second call is
|
||||
// a no-op. Hard unlink, not trash: nothing ever referenced these bytes.
|
||||
RollbackResult rollback();
|
||||
|
||||
const std::vector<std::string>& landedPaths() const { return paths_; }
|
||||
bool empty() const { return paths_.empty(); }
|
||||
|
||||
private:
|
||||
std::vector<std::string> paths_;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,228 @@
|
||||
// Standalone tests for shell/package/package_io — no REAPER, no framework. Pins the
|
||||
// two properties the seam exists for: an interrupted or failed write leaves the
|
||||
// destination absent or holding its prior contents (failure injected at the writer
|
||||
// seam — abandonment, open failure, rename failure), and a multi-entry round trip
|
||||
// holds at most one entry's payload, asserted against PayloadBuffer::alive() — the
|
||||
// seam counter — rather than a memory measurement.
|
||||
|
||||
#include "../src/shell/package/package_io.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static std::vector<std::uint8_t> patternBytes(std::size_t n, std::uint8_t seed) {
|
||||
std::vector<std::uint8_t> v(n);
|
||||
for (std::size_t i = 0; i < n; ++i)
|
||||
v[i] = static_cast<std::uint8_t>(seed + i * 7u);
|
||||
return v;
|
||||
}
|
||||
|
||||
static void writeScratchFile(const std::string& path,
|
||||
const std::vector<std::uint8_t>& bytes) {
|
||||
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
||||
f.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
|
||||
static std::vector<std::uint8_t> readAll(const std::string& path) {
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
static void testPayloadCounterTracksMovesNotCopies() {
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
{
|
||||
PayloadBuffer a(patternBytes(4, 1));
|
||||
CHECK(PayloadBuffer::alive() == 1);
|
||||
PayloadBuffer b = std::move(a);
|
||||
CHECK(PayloadBuffer::alive() == 1); // the count moved with the bytes
|
||||
PayloadBuffer c;
|
||||
c = std::move(b);
|
||||
CHECK(PayloadBuffer::alive() == 1);
|
||||
CHECK(c.size() == 4);
|
||||
}
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
{
|
||||
PayloadBuffer empty;
|
||||
CHECK(PayloadBuffer::alive() == 0); // holding nothing counts as nothing
|
||||
}
|
||||
}
|
||||
|
||||
static void testStreamingRoundTripHoldsOnePayload() {
|
||||
const std::string dest = "pkg_io_scratch.rsbank";
|
||||
const std::vector<std::uint8_t> header = patternBytes(16, 0xA0);
|
||||
const std::vector<std::vector<std::uint8_t>> entries = {
|
||||
patternBytes(1000, 1), patternBytes(500, 2), patternBytes(1, 3)};
|
||||
|
||||
std::vector<std::string> srcs;
|
||||
for (std::size_t i = 0; i < entries.size(); ++i) {
|
||||
srcs.push_back("pkg_io_src" + std::to_string(i) + ".bin");
|
||||
writeScratchFile(srcs[i], entries[i]);
|
||||
}
|
||||
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
std::vector<std::pair<std::uint64_t, std::uint64_t>> layout; // offset, length
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
CHECK(writer.ok());
|
||||
CHECK(writer.appendRaw(header.data(), header.size()));
|
||||
std::uint64_t offset = header.size();
|
||||
for (std::size_t i = 0; i < entries.size(); ++i) {
|
||||
PayloadBuffer p = readFilePayload(srcs[i]);
|
||||
CHECK(p.bytes() == entries[i]);
|
||||
CHECK(PayloadBuffer::alive() == 1); // exactly one entry in memory
|
||||
CHECK(writer.appendPayload(p));
|
||||
layout.emplace_back(offset, p.size());
|
||||
offset += p.size();
|
||||
}
|
||||
CHECK(PayloadBuffer::alive() == 0); // each released before the next
|
||||
CHECK(writer.commit());
|
||||
CHECK(!writer.commit()); // a second commit is refused
|
||||
}
|
||||
CHECK(!fs::exists(dest + ".rsbanktmp"));
|
||||
CHECK(fs::exists(dest));
|
||||
|
||||
{
|
||||
// Scoped: the reader holds the file open, and Windows refuses to delete an
|
||||
// open file — cleanup below needs it closed first.
|
||||
PackageFileReader reader(dest);
|
||||
CHECK(reader.ok());
|
||||
CHECK(reader.fileSize() == header.size() + 1000 + 500 + 1);
|
||||
for (std::size_t i = 0; i < entries.size(); ++i) {
|
||||
PayloadBuffer p = reader.readRange(layout[i].first, layout[i].second);
|
||||
CHECK(PayloadBuffer::alive() == 1); // one entry per readRange, no more
|
||||
CHECK(p.bytes() == entries[i]);
|
||||
}
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
for (const std::string& s : srcs) fs::remove(s, ec);
|
||||
fs::remove(dest, ec);
|
||||
}
|
||||
|
||||
static void testAbandonedWriteLeavesNoDestination() {
|
||||
const std::string dest = "pkg_io_abandon.rsbank";
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
const std::vector<std::uint8_t> some = patternBytes(64, 9);
|
||||
CHECK(writer.appendRaw(some.data(), some.size()));
|
||||
// no commit — destruction is the injected interruption
|
||||
}
|
||||
CHECK(!fs::exists(dest));
|
||||
CHECK(!fs::exists(dest + ".rsbanktmp"));
|
||||
}
|
||||
|
||||
static void testAbortPreservesPriorContents() {
|
||||
const std::string dest = "pkg_io_prior.rsbank";
|
||||
const std::vector<std::uint8_t> prior = patternBytes(32, 0x40);
|
||||
writeScratchFile(dest, prior);
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
const std::vector<std::uint8_t> some = patternBytes(64, 9);
|
||||
CHECK(writer.appendRaw(some.data(), some.size()));
|
||||
writer.abort();
|
||||
CHECK(!writer.appendRaw(some.data(), some.size())); // dead after abort
|
||||
CHECK(!writer.commit());
|
||||
}
|
||||
CHECK(readAll(dest) == prior);
|
||||
CHECK(!fs::exists(dest + ".rsbanktmp"));
|
||||
std::error_code ec;
|
||||
fs::remove(dest, ec);
|
||||
}
|
||||
|
||||
static void testOpenFailureIsInert() {
|
||||
const std::string dest = "pkg_io_no_such_dir/x.rsbank";
|
||||
PackageFileWriter writer(dest);
|
||||
CHECK(!writer.ok());
|
||||
const std::vector<std::uint8_t> some = patternBytes(8, 1);
|
||||
CHECK(!writer.appendRaw(some.data(), some.size()));
|
||||
CHECK(!writer.commit());
|
||||
CHECK(!fs::exists("pkg_io_no_such_dir"));
|
||||
}
|
||||
|
||||
static void testCommitRenameFailureSelfCleans() {
|
||||
// A directory squatting on the destination makes the final rename fail — a real
|
||||
// injected commit failure, not a simulated one.
|
||||
const std::string dest = "pkg_io_dir.rsbank";
|
||||
std::error_code ec;
|
||||
fs::create_directory(dest, ec);
|
||||
CHECK(!ec);
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
CHECK(writer.ok());
|
||||
const std::vector<std::uint8_t> some = patternBytes(8, 1);
|
||||
CHECK(writer.appendRaw(some.data(), some.size()));
|
||||
CHECK(!writer.commit());
|
||||
}
|
||||
CHECK(fs::is_directory(dest)); // prior state intact
|
||||
CHECK(!fs::exists(dest + ".rsbanktmp"));
|
||||
fs::remove(dest, ec);
|
||||
}
|
||||
|
||||
static void testReaderEdges() {
|
||||
PackageFileReader missing("pkg_io_no_such_file.rsbank");
|
||||
CHECK(!missing.ok());
|
||||
CHECK(missing.readRange(0, 1).empty());
|
||||
|
||||
const std::string path = "pkg_io_edges.bin";
|
||||
const std::vector<std::uint8_t> bytes = patternBytes(10, 5);
|
||||
writeScratchFile(path, bytes);
|
||||
{
|
||||
// Scoped: the reader must be closed before the cleanup remove below.
|
||||
PackageFileReader reader(path);
|
||||
CHECK(reader.ok());
|
||||
CHECK(reader.fileSize() == 10);
|
||||
CHECK(reader.readRange(5, 10).empty()); // past the end
|
||||
CHECK(reader.readRange(10, 1).empty()); // starts at the end
|
||||
CHECK(reader.readRange(0, 0).empty()); // zero length is failure, one branch
|
||||
const PayloadBuffer slice = reader.readRange(2, 3);
|
||||
CHECK(slice.bytes() ==
|
||||
std::vector<std::uint8_t>(bytes.begin() + 2, bytes.begin() + 5));
|
||||
}
|
||||
std::error_code ec;
|
||||
fs::remove(path, ec);
|
||||
}
|
||||
|
||||
static void testListFolderFileNames() {
|
||||
const std::string dir = "pkg_io_listdir";
|
||||
std::error_code ec;
|
||||
fs::create_directory(dir, ec);
|
||||
writeScratchFile(dir + "/b.bin", patternBytes(2, 1));
|
||||
writeScratchFile(dir + "/a.bin", patternBytes(2, 2));
|
||||
fs::create_directory(dir + "/sub", ec);
|
||||
writeScratchFile(dir + "/sub/c.bin", patternBytes(2, 3));
|
||||
|
||||
const std::vector<std::string> names = listFolderFileNames(dir);
|
||||
CHECK(names == (std::vector<std::string>{"a.bin", "b.bin"})); // sorted, bare, non-recursive
|
||||
CHECK(listFolderFileNames("pkg_io_no_such_dir").empty());
|
||||
|
||||
fs::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testPayloadCounterTracksMovesNotCopies();
|
||||
testStreamingRoundTripHoldsOnePayload();
|
||||
testAbandonedWriteLeavesNoDestination();
|
||||
testAbortPreservesPriorContents();
|
||||
testOpenFailureIsInert();
|
||||
testCommitRenameFailureSelfCleans();
|
||||
testReaderEdges();
|
||||
testListFolderFileNames();
|
||||
|
||||
if (g_fail == 0) std::printf("package_io: all tests passed\n");
|
||||
else std::printf("package_io: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Standalone tests for shell/package/package_rollback — no REAPER, no framework.
|
||||
// Pins the discriminator's mechanics: a file is recorded only when this journal's
|
||||
// own write landed it, a pre-existing destination is refused untouched, and
|
||||
// rollback deletes exactly the recorded set — a bystander file beside them stays,
|
||||
// and a vanished file is tolerated rather than failed.
|
||||
|
||||
#include "../src/shell/package/package_rollback.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static std::vector<std::uint8_t> patternBytes(std::size_t n, std::uint8_t seed) {
|
||||
std::vector<std::uint8_t> v(n);
|
||||
for (std::size_t i = 0; i < n; ++i)
|
||||
v[i] = static_cast<std::uint8_t>(seed + i * 7u);
|
||||
return v;
|
||||
}
|
||||
|
||||
static void writeScratchFile(const std::string& path,
|
||||
const std::vector<std::uint8_t>& bytes) {
|
||||
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
||||
f.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
|
||||
static std::vector<std::uint8_t> readAll(const std::string& path) {
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
static void testLandRecordsOnSuccessOnly() {
|
||||
LandedFileJournal journal;
|
||||
const std::string path = "rb_land.bin";
|
||||
const std::vector<std::uint8_t> bytes = patternBytes(32, 1);
|
||||
CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes)));
|
||||
CHECK(readAll(path) == bytes);
|
||||
CHECK(journal.landedPaths() == (std::vector<std::string>{path}));
|
||||
CHECK(!fs::exists(path + ".rsbanktmp"));
|
||||
journal.rollback();
|
||||
CHECK(!fs::exists(path));
|
||||
}
|
||||
|
||||
static void testExistingDestinationRefusedUntouched() {
|
||||
LandedFileJournal journal;
|
||||
const std::string path = "rb_existing.bin";
|
||||
const std::vector<std::uint8_t> original = patternBytes(16, 0x60);
|
||||
writeScratchFile(path, original);
|
||||
|
||||
CHECK(!journal.writeLandedFile(path, PayloadBuffer(patternBytes(8, 1))));
|
||||
CHECK(readAll(path) == original); // never overwritten
|
||||
CHECK(journal.empty()); // a refused write is not recorded
|
||||
|
||||
const RollbackResult result = journal.rollback();
|
||||
CHECK(result.deletedCount == 0);
|
||||
CHECK(fs::exists(path)); // rollback cannot touch a file it did not write
|
||||
std::error_code ec;
|
||||
fs::remove(path, ec);
|
||||
}
|
||||
|
||||
static void testEmptyPayloadRefused() {
|
||||
LandedFileJournal journal;
|
||||
CHECK(!journal.writeLandedFile("rb_empty.bin", PayloadBuffer{}));
|
||||
CHECK(!fs::exists("rb_empty.bin"));
|
||||
CHECK(journal.empty());
|
||||
}
|
||||
|
||||
static void testRollbackDeletesExactlyTheRecordedSet() {
|
||||
LandedFileJournal journal;
|
||||
CHECK(journal.writeLandedFile("rb_a.bin", PayloadBuffer(patternBytes(8, 1))));
|
||||
CHECK(journal.writeLandedFile("rb_c.bin", PayloadBuffer(patternBytes(8, 2))));
|
||||
writeScratchFile("rb_bystander.bin", patternBytes(8, 3)); // not journal-written
|
||||
|
||||
const RollbackResult result = journal.rollback();
|
||||
CHECK(result.deletedCount == 2);
|
||||
CHECK(result.alreadyAbsentCount == 0);
|
||||
CHECK(result.failedCount == 0);
|
||||
CHECK(!fs::exists("rb_a.bin"));
|
||||
CHECK(!fs::exists("rb_c.bin"));
|
||||
CHECK(fs::exists("rb_bystander.bin")); // exactly the given files, nothing else
|
||||
CHECK(journal.empty());
|
||||
|
||||
const RollbackResult second = journal.rollback(); // cleared: a no-op
|
||||
CHECK(second.deletedCount == 0);
|
||||
CHECK(fs::exists("rb_bystander.bin"));
|
||||
std::error_code ec;
|
||||
fs::remove("rb_bystander.bin", ec);
|
||||
}
|
||||
|
||||
static void testVanishedFileIsToleratedNotFailed() {
|
||||
LandedFileJournal journal;
|
||||
CHECK(journal.writeLandedFile("rb_gone.bin", PayloadBuffer(patternBytes(8, 1))));
|
||||
std::error_code ec;
|
||||
fs::remove("rb_gone.bin", ec); // vanished between land and rollback
|
||||
CHECK(!ec);
|
||||
|
||||
const RollbackResult result = journal.rollback();
|
||||
CHECK(result.deletedCount == 0);
|
||||
CHECK(result.alreadyAbsentCount == 1);
|
||||
CHECK(result.failedCount == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testLandRecordsOnSuccessOnly();
|
||||
testExistingDestinationRefusedUntouched();
|
||||
testEmptyPayloadRefused();
|
||||
testRollbackDeletesExactlyTheRecordedSet();
|
||||
testVanishedFileIsToleratedNotFailed();
|
||||
|
||||
if (g_fail == 0) std::printf("package_rollback: all tests passed\n");
|
||||
else std::printf("package_rollback: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user