Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
reload_transaction.hpp
Go to the documentation of this file.
1#pragma once
2
25
34#include <pulp/state/store.hpp>
35
36#include <chrono>
37#include <cmath>
38#include <cstddef>
39#include <cstdint>
40#include <exception>
41#include <filesystem>
42#include <memory>
43#include <optional>
44#include <string>
45#include <system_error>
46#include <variant>
47#include <vector>
48
49namespace pulp::format::reload {
50
56 double load_gate_ms = 0.0;
57 double construct_ms = 0.0;
58 double prepare_ms = 0.0;
59 double swap_ms = 0.0;
60 double total_ms = 0.0;
61};
62
87
96
108 std::filesystem::path pack_root;
110 std::vector<std::uint8_t> trusted_public_key;
116 const SignedRevocationList* srl = nullptr;
117};
118
127inline std::optional<ReloadOutcome>
128verify_pack_before_load(const SwapPackTrust& trust, const std::string& library_path) {
129 // 1. Signature (authenticate the manifest) + 2. per-file integrity (the files
130 // match the SIGNED hashes). verify_swap_pack is the sanctioned entry point
131 // for both, in that order. Map its taxonomy onto the reload taxonomy.
132 const auto v = verify_swap_pack(trust.pack_root, trust.manifest, trust.trusted_public_key);
133 if (!v.ok()) {
134 const bool sig = v.status == SwapPackVerify::UntrustedSigner ||
136 return ReloadOutcome{
139 (sig ? "swap-pack signature rejected: " : "swap-pack integrity rejected: ") +
140 v.detail};
141 }
142
143 // Once the pack is authenticated, reject a signer or artifact that has since
144 // been revoked (a leaked signing key must be killable without re-shipping every
145 // consumer). The host authenticates the revocation list against the trusted
146 // revocation key and enforces its monotonic epoch floor before handing it in;
147 // here we only query it. Fail-OPEN on revocation ONLY: with no list this load
148 // still passes the fail-closed signature + integrity gates above, so a
149 // not-yet-known revocation defers killing an already-trusted pack — it can
150 // never admit an unsigned or tampered one. The signer fingerprint is the
151 // lowercase-hex Ed25519 public key; the artifact is any signed file's hash (an
152 // empty query dimension is skipped by is_revoked).
153 if (trust.srl != nullptr) {
154 const std::string signer_fpr = srl_hex_encode(trust.manifest.signer_public_key);
155 if (is_revoked(*trust.srl, signer_fpr, /*artifact_hash=*/"")) {
157 "swap-pack signer key is revoked"};
158 }
159 for (const auto& f : trust.manifest.files) {
160 if (is_revoked(*trust.srl, /*signer_key_fpr=*/"", f.sha256_hex)) {
162 "swap-pack artifact is revoked"};
163 }
164 }
165 }
166
167 // 3. Bind verification to the load: the exact file we are about to dlopen must
168 // be one of the files we JUST hashed. Otherwise a caller could verify a pack
169 // yet load an arbitrary sibling path that was never in the signed set. Using
170 // std::filesystem::equivalent (same file on disk, not string equality) also
171 // defeats a symlink/relative-path detour to an unverified target.
172 bool member = false;
173 for (const auto& f : trust.manifest.files) {
174 std::error_code ec;
175 if (std::filesystem::equivalent(trust.pack_root / f.path, library_path, ec) && !ec) {
176 member = true;
177 break;
178 }
179 }
180 if (!member) {
182 "load target is not a verified member of the swap pack"};
183 }
184 return std::nullopt; // trustworthy — safe to load these exact bytes.
185}
186
199inline std::variant<GatedImage, ReloadOutcome>
200gate_logic_image(const std::string& library_path, const BuildFingerprint& host_fingerprint,
201 const SwapPackTrust* trust = nullptr) {
202 auto reject_quiescible = [](ReloadLibrary& lib, ReloadOutcome::Status status,
203 std::string detail, std::vector<std::string> issues = {}) {
205 return ReloadOutcome{status, std::move(detail), std::move(issues)};
206 };
207
208 // 0. Trust gate — verify signed-pack bytes + integrity (+ revocation) on the
209 // RAW file BEFORE any dlopen. dlopen/LoadLibrary executes a native image's
210 // static constructors immediately, so any signature/revocation check placed
211 // AFTER the load is worthless for native code — the attacker's constructor
212 // already ran. This phase touches only the bytes on disk (no code from the
213 // image is mapped or executed) and fails closed. Skipped when trust is null.
214 if (trust != nullptr) {
215 if (auto rejected = verify_pack_before_load(*trust, library_path)) {
216 return std::move(*rejected);
217 }
218 }
219
220 // 1. Load the image. A failed load has no handle to retain or close.
221 ReloadLibrary lib(library_path);
223
224 // 2. Reload-ABI-version gate — coarsest compatibility check, first.
225 auto abi_version_fn = lib.symbol<ReloadAbiVersionFn>(kAbiVersionSymbol);
226 if (!abi_version_fn)
227 return reject_quiescible(lib, ReloadOutcome::Status::RejectedNoEntryPoints,
228 "missing reload-ABI-version symbol");
229 if (const int v = abi_version_fn(); v != kReloadAbiVersion)
230 return reject_quiescible(lib, ReloadOutcome::Status::RejectedAbiVersion,
231 "reload ABI version " + std::to_string(v) +
232 " != host " + std::to_string(kReloadAbiVersion));
233
234 // 3. Build-fingerprint gate — refuse a C++-ABI-incompatible image before
235 // constructing anything across the seam.
236 auto fingerprint_fn = lib.symbol<ReloadFingerprintFn>(kFingerprintSymbol);
237 if (!fingerprint_fn)
238 return reject_quiescible(lib, ReloadOutcome::Status::RejectedNoEntryPoints,
239 "missing fingerprint symbol");
240 BuildFingerprint logic_fingerprint{};
241 fingerprint_fn(&logic_fingerprint);
242 if (!fingerprints_match(host_fingerprint, logic_fingerprint))
243 return reject_quiescible(lib, ReloadOutcome::Status::RejectedFingerprint,
244 "build fingerprint mismatch",
245 fingerprint_diff(host_fingerprint, logic_fingerprint));
246
247 // 4. Resolve the factory.
248 auto create_fn = lib.symbol<ReloadCreateFn>(kCreateSymbol);
249 if (!create_fn)
250 return reject_quiescible(lib, ReloadOutcome::Status::RejectedNoEntryPoints,
251 "missing create symbol");
252
253 return GatedImage{std::move(lib), create_fn};
254}
255
275 const std::string& library_path,
276 const BuildFingerprint& host_fingerprint,
277 state::StateStore& live_store,
278 const PrepareContext& prepare_ctx,
279 std::vector<ReloadLibrary>& retained_images,
280 const SwapPackTrust* trust = nullptr) {
281
282 // Phase timing. steady_clock; this whole function is control-thread
283 // (off the audio path), so timing here is never an RT concern.
284 using clock = std::chrono::steady_clock;
285 const auto t0 = clock::now();
286 const auto ms = [](clock::time_point a, clock::time_point b) {
287 return std::chrono::duration<double, std::milli>(b - a).count();
288 };
289
290 // Trust (if a signed pack is supplied) + load + gate (ABI version,
291 // fingerprint) + resolve the factory — the shared, fail-closed gate sequence
292 // (see gate_logic_image). Trust verification precedes dlopen.
293 auto gated = gate_logic_image(library_path, host_fingerprint, trust);
294 const auto t_gate = clock::now();
295 if (auto* rejected = std::get_if<ReloadOutcome>(&gated)) {
296 rejected->metrics.load_gate_ms = ms(t0, t_gate);
297 rejected->metrics.total_ms = ms(t0, t_gate);
298 return std::move(*rejected);
299 }
300 GatedImage& image = std::get<GatedImage>(gated);
301 const ReloadCreateFn create_fn = image.create;
302
303 // We are about to instantiate from this image — retain it for the process
304 // lifetime (its code backs the constructed Processor; see reload_library.hpp's
305 // leak policy). create_fn stays valid: the image remains loaded.
306 retained_images.push_back(std::move(image.lib));
307
308 // Stamp partial metrics onto an early-rejection outcome (later phases 0).
309 const auto reject = [&](ReloadOutcome o) {
310 o.metrics.load_gate_ms = ms(t0, t_gate);
311 o.metrics.total_ms = ms(t0, clock::now());
312 return o;
313 };
314
315 // 5. Construct + gate + commit. Wrapped so a throwing candidate (ctor,
316 // define_parameters, prepare) yields a graceful Rejected outcome instead
317 // of escaping as an exception — catching across the seam is sound because
318 // the fingerprint gate above proved an identical exception ABI/stdlib.
319 try {
320 std::unique_ptr<Processor> candidate(create_fn());
321 if (!candidate) {
322 return reject({ReloadOutcome::Status::RejectedNoEntryPoints, "create returned null"});
323 }
324
325 // Parameter-contract gate — the candidate must present the same
326 // automatable surface as the live plugin. Define into a scratch store so
327 // the comparison never touches live state; the candidate is discarded
328 // (destroyed on this control thread) if it fails.
329 state::StateStore scratch;
330 candidate->define_parameters(scratch);
331 if (!param_contracts_match(live_store, scratch)) {
332 return reject({ReloadOutcome::Status::RejectedContract, "parameter contract differs",
333 param_contract_diff(live_store, scratch)});
334 }
335 const auto t_construct = clock::now();
336
337 // Commit: bind the candidate to the LIVE store (same params + current
338 // values by the gate above), prepare, and swap. Cross-module delete is
339 // safe here only because the fingerprint gate proved shell and logic
340 // share one C++ runtime (operator new/delete + heap); the displaced
341 // processor is destroyed on this control thread on return. (A future
342 // static-C++-runtime logic build would route deletion through
343 // kDestroySymbol instead — see reload_abi.hpp.)
344 candidate->set_state_store(&live_store);
345 candidate->prepare(prepare_ctx);
346 const auto t_prepare = clock::now();
347
348 // Behavioral entry-point probe: a candidate can pass every
349 // STATIC gate (load / ABI / fingerprint / contract) yet still misbehave
350 // at runtime — most dangerously by emitting NaN/Inf, which a swap would
351 // push straight to the audio thread and silently corrupt the signal.
352 // Render ONE silence block through the freshly-prepared candidate into
353 // scratch and reject if the output is not finite, so a garbage-producing
354 // build never goes live. Cheap, pre-commit (old DSP stays on rejection),
355 // and a universal invariant — silence in → finite out holds for effects
356 // AND instruments (NaN/Inf is always a bug). Runs inside the try, so a
357 // throwing process() is caught like any other candidate fault.
358 {
359 const int probe_frames =
360 std::max(1, std::min(64, prepare_ctx.max_buffer_size > 0
361 ? prepare_ctx.max_buffer_size : 64));
362 const std::size_t frames = static_cast<std::size_t>(probe_frames);
363 std::vector<float> in_l(frames, 0.0f), in_r(frames, 0.0f);
364 std::vector<float> out_l(frames, 0.0f), out_r(frames, 0.0f);
365 const float* in_ptrs[2] = {in_l.data(), in_r.data()};
366 float* out_ptrs[2] = {out_l.data(), out_r.data()};
367 audio::BufferView<const float> probe_in(in_ptrs, 2, frames);
368 audio::BufferView<float> probe_out(out_ptrs, 2, frames);
369 midi::MidiBuffer probe_mi, probe_mo;
370 candidate->process(probe_out, probe_in, probe_mi, probe_mo, ProcessContext{});
371 for (std::size_t c = 0; c < probe_out.num_channels(); ++c) {
372 auto o = probe_out.channel(c);
373 for (std::size_t n = 0; n < frames; ++n) {
374 if (!std::isfinite(o[n])) {
376 "candidate produced non-finite output on a silence probe"});
377 }
378 }
379 }
380 }
381
382 std::unique_ptr<Processor> displaced = slot.swap(std::move(candidate));
383 (void)displaced; // ~Processor() here — control thread, never the audio thread.
384 const auto t_swap = clock::now();
385
387 ok.metrics.load_gate_ms = ms(t0, t_gate);
388 ok.metrics.construct_ms = ms(t_gate, t_construct);
389 ok.metrics.prepare_ms = ms(t_construct, t_prepare);
390 ok.metrics.swap_ms = ms(t_prepare, t_swap);
391 ok.metrics.total_ms = ms(t0, t_swap);
392 return ok;
393 } catch (const std::exception& e) {
395 std::string("candidate threw: ") + e.what()});
396 } catch (...) {
398 "candidate threw (non-std exception)"});
399 }
400}
401
408public:
410 const BuildFingerprint& host_fingerprint, const PrepareContext& prepare_ctx)
411 : slot_(slot), live_store_(live_store),
412 host_fingerprint_(host_fingerprint), prepare_ctx_(prepare_ctx) {}
413
415 ReloadOutcome reload(const std::string& library_path) {
416 return reload_processor_from_library(slot_, library_path, host_fingerprint_,
417 live_store_, prepare_ctx_, retained_images_);
418 }
419
421 std::size_t retained_image_count() const { return retained_images_.size(); }
422
423private:
425 state::StateStore& live_store_;
426 BuildFingerprint host_fingerprint_;
427 PrepareContext prepare_ctx_;
428 std::vector<ReloadLibrary> retained_images_;
429};
430
431} // namespace pulp::format::reload
Strict build-compatibility fingerprint — the PRIMARY safety gate for DSP hot reload.
Non-owning view over multi-channel audio sample data.
Definition buffer.hpp:30
std::size_t num_channels() const
Definition buffer.hpp:83
std::span< SampleType > channel(std::size_t index)
Get a span over all samples in one channel.
Definition buffer.hpp:58
Definition processor_hotswap_slot.hpp:65
std::unique_ptr< Processor > swap(std::unique_ptr< Processor > next)
Control-thread swap.
Definition processor_hotswap_slot.hpp:100
Definition reload_library.hpp:109
const std::string & error() const noexcept
Definition reload_library.hpp:138
void set_leak_policy(LeakPolicy policy) noexcept
Promote/demote the unload decision after construction (e.g.
Definition reload_library.hpp:145
T symbol(const char *name) const noexcept
Typed convenience over raw_symbol (typically a factory function pointer).
Definition reload_library.hpp:160
bool valid() const noexcept
Definition reload_library.hpp:135
ReloadOutcome reload(const std::string &library_path)
Attempt one reload from library_path. See reload_processor_from_library.
Definition reload_transaction.hpp:415
ReloadSession(ProcessorHotSwapSlot &slot, state::StateStore &live_store, const BuildFingerprint &host_fingerprint, const PrepareContext &prepare_ctx)
Definition reload_transaction.hpp:409
std::size_t retained_image_count() const
Images retained for the process lifetime (one per constructed candidate).
Definition reload_transaction.hpp:421
Collection of timestamped MIDI events within a single audio buffer period.
Definition buffer.hpp:31
Centralized parameter storage with lock-free value access.
Definition store.hpp:164
Definition build_fingerprint.hpp:42
Definition build_fingerprint.hpp:23
std::variant< GatedImage, ReloadOutcome > gate_logic_image(const std::string &library_path, const BuildFingerprint &host_fingerprint, const SwapPackTrust *trust=nullptr)
Load library_path and run the pre-construction gates — reload-ABI version, then build fingerprint — a...
Definition reload_transaction.hpp:200
constexpr const char * kFingerprintSymbol
Definition reload_abi.hpp:46
bool is_revoked(const SignedRevocationList &srl, std::string_view signer_key_fpr, std::string_view artifact_hash)
True iff signer_key_fpr is a revoked signer OR artifact_hash is a revoked artifact in srl.
Definition revocation.hpp:286
std::optional< ReloadOutcome > verify_pack_before_load(const SwapPackTrust &trust, const std::string &library_path)
Verify trust on the RAW bytes on disk and confirm library_path is a member of the verified pack — the...
Definition reload_transaction.hpp:128
Processor *(*)() ReloadCreateFn
Constructs the logic Processor; the caller takes ownership of the result.
Definition reload_abi.hpp:54
SwapPackVerifyResult verify_swap_pack(const std::filesystem::path &root, const SwapPackManifest &manifest, const std::vector< std::uint8_t > &trusted_public_key)
Full fail-closed verify: signature FIRST (binds the file hashes without reading the files),...
Definition swap_pack.hpp:300
bool fingerprints_match(const BuildFingerprint &a, const BuildFingerprint &b)
Field-by-field equality (the gate).
Definition build_fingerprint.hpp:191
@ CloseOnDestroy
dlclose on destruction. Only when the image is provably quiesced.
Definition reload_library.hpp:106
ReloadOutcome reload_processor_from_library(ProcessorHotSwapSlot &slot, const std::string &library_path, const BuildFingerprint &host_fingerprint, state::StateStore &live_store, const PrepareContext &prepare_ctx, std::vector< ReloadLibrary > &retained_images, const SwapPackTrust *trust=nullptr)
Attempt to hot-reload slot from the logic library at library_path.
Definition reload_transaction.hpp:273
std::string srl_hex_encode(const std::vector< std::uint8_t > &bytes)
Encode bytes to lowercase hex.
Definition revocation.hpp:106
void(*)(BuildFingerprint *out) ReloadFingerprintFn
Fills *out with the logic library's own BuildFingerprint.
Definition reload_abi.hpp:52
std::vector< std::string > param_contract_diff(const state::StateStore &live, const state::StateStore &candidate)
Human-readable contract differences (diagnostics).
Definition param_contract.hpp:64
std::vector< std::string > fingerprint_diff(const BuildFingerprint &shell, const BuildFingerprint &logic)
Human-readable per-field differences (diagnostics only). Empty == identical.
Definition build_fingerprint.hpp:204
@ UntrustedSigner
the manifest's signer key is not the trusted key
Definition swap_pack.hpp:90
@ BadSignature
Ed25519 verification failed (or malformed sig/key sizes).
Definition swap_pack.hpp:91
int(*)() ReloadAbiVersionFn
Definition reload_abi.hpp:50
constexpr const char * kAbiVersionSymbol
Definition reload_abi.hpp:45
bool param_contracts_match(const state::StateStore &live, const state::StateStore &candidate)
The gate: the candidate's parameter contract is equivalent to the live one (same set of IDs,...
Definition param_contract.hpp:121
constexpr const char * kCreateSymbol
Definition reload_abi.hpp:47
constexpr int kReloadAbiVersion
Reload entry-point ABI version.
Definition reload_abi.hpp:43
@ ok
Definition offline_sample_bounce.hpp:20
Parameter-contract equivalence check for DSP hot reload.
RT-safe hot-swap slot for a Processor.
The C-ABI contract a hot-reloadable "logic" library exports, and the macro a plugin uses to export it...
Dynamic-library handle for the DSP hot-reload path, with an explicit and deliberately conservative le...
Signed Revocation List (SRL) + namespaced monotonic epoch floor.
Definition prepare_resources.hpp:102
int max_buffer_size
Definition prepare_resources.hpp:104
Process context — passed every audio callback with transport state.
Definition process_context.hpp:89
Definition build_fingerprint.hpp:28
A logic image that passed the pre-construction gates (reload-ABI version + build fingerprint) with it...
Definition reload_transaction.hpp:92
ReloadLibrary lib
Definition reload_transaction.hpp:93
ReloadCreateFn create
Definition reload_transaction.hpp:94
DSP-axis reload timings (control-thread, off the audio path).
Definition reload_transaction.hpp:55
double load_gate_ms
dlopen + ABI/fingerprint gates + resolve create.
Definition reload_transaction.hpp:56
double total_ms
end-to-end (load → swap).
Definition reload_transaction.hpp:60
double swap_ms
slot.swap() + displaced dtor (this thread).
Definition reload_transaction.hpp:59
double prepare_ms
the candidate's prepare().
Definition reload_transaction.hpp:58
double construct_ms
create() + define_parameters + contract gate.
Definition reload_transaction.hpp:57
Definition reload_transaction.hpp:63
std::vector< std::string > issues
Structured per-field diffs (fingerprint/contract).
Definition reload_transaction.hpp:81
Status status
Definition reload_transaction.hpp:79
bool ok() const
Definition reload_transaction.hpp:84
std::string detail
Short human summary (one line).
Definition reload_transaction.hpp:80
Status
Definition reload_transaction.hpp:64
@ RejectedContract
Parameter contract differs (needs full reload).
Definition reload_transaction.hpp:76
@ RejectedRevoked
Swap-pack signer/artifact is on the signed revocation list; rejected before load.
Definition reload_transaction.hpp:70
@ RejectedCandidateThrew
create/define/prepare threw before the swap.
Definition reload_transaction.hpp:77
@ RejectedFingerprint
C++-ABI-incompatible build (fingerprint mismatch).
Definition reload_transaction.hpp:75
@ RejectedNoEntryPoints
Missing a required symbol, or create returned null.
Definition reload_transaction.hpp:74
@ RejectedAbiVersion
Built against a different reload-ABI version.
Definition reload_transaction.hpp:73
@ RejectedSignature
Swap-pack Ed25519 signature/signer verification failed, rejected before the image is loaded.
Definition reload_transaction.hpp:66
@ RejectedIntegrity
Swap-pack per-file SHA-256 failed, or the load target was not a verified member of the pack; rejected...
Definition reload_transaction.hpp:68
@ RejectedLoadFailed
dlopen / LoadLibrary failed.
Definition reload_transaction.hpp:72
@ Swapped
Success: the new processor is live.
Definition reload_transaction.hpp:65
ReloadMetrics metrics
DSP-axis phase timings.
Definition reload_transaction.hpp:82
A signed revocation list.
Definition revocation.hpp:54
Definition swap_pack.hpp:66
std::vector< std::uint8_t > signer_public_key
32 bytes (Ed25519), empty if unsigned
Definition swap_pack.hpp:80
std::vector< SwapPackFile > files
Definition swap_pack.hpp:70
Trust material for verifying a swap-pack-delivered logic image BEFORE it is loaded.
Definition reload_transaction.hpp:107
SwapPackManifest manifest
parsed manifest (incl. signer + signature).
Definition reload_transaction.hpp:109
const SignedRevocationList * srl
The revocation list to enforce, already authenticated by the host against the trusted revocation key ...
Definition reload_transaction.hpp:116
std::filesystem::path pack_root
dir the manifest paths are relative to.
Definition reload_transaction.hpp:108
std::vector< std::uint8_t > trusted_public_key
the pinned Ed25519 key the signer must be.
Definition reload_transaction.hpp:110
Sealed swap-pack manifest + per-file integrity.