133 lines
12 KiB
Markdown
133 lines
12 KiB
Markdown
# 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_rollback` — `LandedFileJournal`: `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_pickers` — `pickPackageForImport` 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.bak` → `mybank.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.
|