Files
reasampler/src/shell/package/CLAUDE.md
T

12 KiB

src/shell/package — package filesystem + dialog seam

Scope

The filesystem and dialog acts behind bank-package export/import: streaming package file I/O plus the file-status and exclusive-create acts (package_io), the UTF-8 path conversion every one of them goes through (package_path), the landed-file journal and its rollback delete (package_rollback), and the two file pickers (package_pickers). Those are bytes-only — the package format (magic, manifest, entry layout) is core/package's business. Beside them sit both promptless verbs: export_bank whole, and the import split so its decisions stay testable — import_landing (REAPER-free) decides and writes, while import_bank owns the only REAPER project state this directory touches (the ext-state persist, the undo block, the generation bump). The export direction touches none of it: an export writes no ext state, opens no undo point and never bumps the generation, and what prompting it needs belongs to its skin (shell/actions/package_export_action), not this seam.

Invariants

  • Paths cross this seam as UTF-8 narrow strings and are converted through utf8Path() before ANY filesystem call. This is not decoration: on Windows std::filesystem decodes a narrow path through the runtime ANSI code page (measured GetACP() == 1252), so a bare fs::path(std::string) turns café.rsbank into café.rsbank or fails to open it. Every path a verb hands in or gets back — including listFolderFileNames' results, which go through pathToUtf8() and never string() — is UTF-8. core/util/file_bytes has the un-converted shape, which is why readFilePayload reads through this module's own PackageFileReader instead.
  • Atomic package write, to the limit of a rename. 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. This is process-crash atomic, NOT power-loss atomic: commit() flushes and closes but does not fsync/FlushFileBuffers, so a power cut can still leave a renamed-but-unflushed file. Deliberate — an fsync over a whole sample bank is a real stall, and the failure this design targets is a refused or interrupted export.
  • 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.
  • An empty PayloadBuffer is a failure signal, never an entry. It is the seam's one "nothing to work with" branch, so both PackageFileWriter::appendPayload and writeFileExclusive refuse it — appending it would let a verb commit framing that claims bytes nobody wrote. appendRaw(ptr, 0) stays tolerated: framing has legitimate zero-length edges.
  • No overwrite of a bank-folder file, ever — and the create is the check. writeLandedFile lands through writeFileExclusive (O_EXCL / _O_EXCL), so the refusal of an occupied path is one atomic act rather than an exists() a concurrent writer could win the race against. Collision handling (auto-rename) remains 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. Closed, both halves. pickPackageSavePath's own .rsbank re-append (see its Gotcha below) can turn a confirmed path X into a write target X.rsbank that the dialog never asked about, so exportBank re-checks fileStatus() on the path actually handed to PackageFileWriter — after any extension append — and refuses RefusedDestinationExists until the caller sets allowOverwrite. The caller does not always re-prompt to get there: pickPackageSavePath reports whether it appended (outAppended), and package_export_action pre-grants allowOverwrite whenever it did NOT — an unappended path is exactly what the dialog's own confirm already covered, so asking again would be a second prompt for the same consent. Only an appended path, one the dialog never saw, still costs the verb's own confirm naming that exact path.
  • The rollback delete is prune's ONE carve-out, and only HALF of it is structural. The citation and the full discriminator live at package_rollback.cpp's header. "Did this call create it" is structural: only exclusively-created paths are recorded, resolved absolute at record time so a later CWD change cannot re-aim the delete. "Did anything ever reference it" is a contract the import verb must honour: it MUST call markIndexCommitted() only AFTER the index write has returned success — calling it before, then having that write fail, strands the landed files with no index entry and a journal that now refuses to roll them back — after which rollback() refuses and writeLandedFile refuses. (Destroying an armed journal without calling either does NOT roll it back — see LandedFileJournal's own doc comment.) import_bank honours it: it calls markIndexCommitted() only after persistBankOp has returned.
  • Integrity is proven before the first byte lands, not undone after. landPackage hashes every declared payload against the manifest and only then creates the bank folder, so a damaged package costs no rollback at all and cannot leave debris behind a rollback that itself failed. The second read of each payload is deliberate on a once-per-gesture path — do not fold it into one hash-and-write pass.
  • Both pickers ride GetUserFileName — mode 1 for import, mode 0 for export. There is no platform split and no fallback: main.cpp defines REAPERAPI_IMPLEMENT without REAPERAPI_MINIMAL and aborts the extension load if any single name fails to resolve, so a build that can load us cannot lack it.

Modules

  • package_path — header-only; the ONE UTF-8-narrow → fs::path conversion, so the encoding contract has a single enforcement point.
  • package_io — every filesystem act the verbs need: 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), fileStatus (Present/Absent/Unreadable — export's refusal message must distinguish the last two, and an empty payload cannot), writeFileExclusive (exclusive create + write, self-cleaning on a partial write), and listFolderFileNames (bare UTF-8 names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW.
  • package_rollbackLandedFileJournal: writeLandedFile (exclusive-create land, path resolved absolute, recorded on success only), markIndexCommitted (disarms the journal), and rollback (deletes exactly the recorded set, hard unlink, tolerating a vanished file; refuses once disarmed). REAPER-free; tested without a DAW.
  • package_pickerspickPackageForImport and pickPackageSavePath, both GetUserFileName; pickPackageSavePath also reports whether it appended .rsbank (outAppended), the signal package_export_action uses to skip a redundant overwrite confirm. Neither picker can be exercised in a unit test.
  • export_bank — the promptless export verb, in three composable public steps: surveyBankExport (the read-only plan, report-before-acting), digestSources (measures each entry's length + hashBytes digest, one payload at a time), and writePackageFile (prefix, then each payload re-read and re-verified against that digest before it is appended, then commit). exportBank composes the three and gates on the plan verdict, the incomplete confirm and the destination confirm. The session arrives const: saveToActiveProject, bumpBankGeneration and writeAssignmentRequest are the session's only non-const acts, so a const session cannot reach them and "an export writes no ext state, opens no undo point and never bumps the generation" holds by the type rather than by memory (pruneReclaim, the sole file-deletion path, is const too and sits outside this claim). Reads the session through inline accessors only, which is why its tests link and run without a DAW.
  • import_landing — the import's two halves that decide anything: landPackage (decode, plan, verify EVERY payload's digest, then land through the journal) and applyImportedBank (the new bank's entries plus a birth record per landed file, in one straight-line block). REAPER-free deliberately — all-or-nothing, integrity and birth-record behaviour are assertable without a DAW.
  • import_bank — the promptless import verb over a live ReaSamplerSession: the project directory, the minted bank id, the recordCreated writer, and the one undo-batched persist. REAPER-facing, so it compiles into the extension module rather than into a library with a test target.

package_round_trip_tests is declared here with no library of its own: it drives the same frozen corpus (tests/fixtures/package_compat/) through both verbs, which is where export → import → export payload identity is proven.

Gotchas

  • A crash mid-export strands the .rsbanktmp sibling. It is not a .rsbank (no picker filter matches it), and a later export to the same destination truncates it. A crash mid-import strands a partial bank file under its real name instead — the land is a direct exclusive create, not temp+rename. Either way the debris was never recorded in the tracking ledger, so prune sees a foreign file (not owned, never an orphan) and will not touch it; removal is by hand. [verify — DAW] whether the import verb should pre-clean stale debris when it lands.
  • The picker filter and mode arguments are spelled to GetUserFileName's documented pair format but are [verify — DAW] on all three platforms — neither picker is exercised outside a live REAPER session. GetUserFileName also takes no owner window, so dialog parenting is REAPER's to do; the superseded Win32 path passed GetMainHwnd() explicitly. Also [verify — DAW]: whether mode 0's picker appends an extension from extension_list when the user omits one — pickPackageSavePath re-appends .rsbank itself so the returned path is correct regardless of how that lands (the superseded Win32 path had ofn.lpstrDefExt for this; GetUserFileName has no equivalent parameter). The re-append is suffix-blind: it only skips when the path already ends in .rsbank, so a path carrying a DIFFERENT extension gets .rsbank appended after it (mybank.bakmybank.bak.rsbank), unlike the superseded ofn.lpstrDefExt, which appended only when the path had no extension at all. Defensible for a format-locked export, but a real divergence from the old picker's behavior — whoever tests the picker under [verify — DAW] should expect the double-extension result on a path that already has one.
  • pickPackageSavePath's suggestedPath doubles as the dialog's starting directory when it is a full path. The verbs should seed it from the project directory — passing a bare name leaves the dialog on REAPER's process working directory, which is its install or resource path.
  • readRange(_, 0) returns an empty buffer — indistinguishable from failure, by design (the one "nothing to work with" branch). Cross-track contract, not a local rule: a genuinely zero-length entry cannot round-trip through this seam, so core/package's format layer must not emit one.