Runtime Module¶
The runtime module provides lock-free primitives, logging, assertions, and scope guards. It is the lowest-level subsystem — everything else depends on it (via platform).
Status: stable
Dependencies: platform
Headers: pulp/runtime/runtime.hpp (includes all)
When to Use Which Primitive¶
| Pattern | Primitive | Example |
|---|---|---|
| Single value, latest-wins | std::atomic<T> |
Parameter values, flags |
| Multi-field coherent read | SeqLock<T> |
Transport state (tempo + beat position + time sig) |
| Large data swap | TripleBuffer<T> |
Wavetables, IR buffers, meter data |
| Ordered event stream | SpscQueue<T> |
MIDI events, UI commands |
| Occurrence-only feedback | ActivityChannel<N> |
MIDI pad flashes, clip lights |
| Wake a sleeping worker | WakeSignal |
Audio thread hands a block to a render or disk thread |
| Callable handed to a realtime or worker lane | FixedFunction<Sig, N> |
Task handlers that must not allocate |
| Budgeted optional work | evaluate_runtime_budget() |
Background analysis, voice/cache refresh, validation helpers |
Never use on the audio thread: std::mutex, std::condition_variable, heap allocation, I/O.
SeqLock¶
Lock-free reader/writer for coherent multi-field snapshots. The writer increments a sequence counter (odd = writing, even = complete). The reader retries if the sequence was odd or changed during the read.
#include <pulp/runtime/seqlock.hpp>
struct TransportState {
double tempo_bpm;
double position_beats;
int time_sig_num;
int time_sig_denom;
};
pulp::runtime::SeqLock<TransportState> transport;
// Writer (host thread):
transport.write({120.0, 4.0, 4, 4});
// Reader (audio thread — lock-free, never blocks):
auto state = transport.read();
// Guaranteed: all four fields are from the same write
Use SeqLock when you need to read multiple related fields as a consistent snapshot. If you only need a single float, use std::atomic<float> instead.
When NOT to use SeqLock¶
- Single values: use
std::atomic<T> - Large data (>cache line): use
TripleBuffer<T>— SeqLock spins on contention, which is fine for small structs but wasteful for large ones
TripleBuffer¶
Lock-free latest-value publication. Three buffers rotate: writer publishes to the back, atomically swaps back↔middle. Reader swaps middle↔front if newer, reads front. Neither side ever blocks.
#include <pulp/runtime/triple_buffer.hpp>
struct MeterData {
float peak_left;
float peak_right;
float rms_left;
float rms_right;
};
pulp::runtime::TripleBuffer<MeterData> meters;
// Audio thread (writer):
meters.write({0.8f, 0.7f, 0.5f, 0.4f});
// UI thread (reader):
const auto& data = meters.read();
update_meter_display(data);
Use TripleBuffer for: - Audio → UI meter data - Main thread → audio thread config swaps (wavetables, IR buffers) - Any case where you only care about the latest value, not every value
TripleBuffer vs SpscQueue¶
- TripleBuffer: reader gets the latest value, intermediate values are discarded. No overflow.
- SpscQueue: reader gets every value in order. Can overflow if reader is slow.
SpscQueue¶
Single-producer, single-consumer lock-free FIFO. Uses CHOC's SingleReaderSingleWriterFIFO under the hood.
#include <pulp/runtime/spsc_queue.hpp>
pulp::runtime::SpscQueue<MidiEvent, 256> midi_queue;
// Producer (audio thread):
if (!midi_queue.try_push(event)) {
// Queue full; overflow_count() / telemetry() make this visible.
}
// Consumer (UI thread):
while (auto ev = midi_queue.try_pop()) {
handle_event(*ev);
}
Use SpscQueue for ordered event streams where every event matters (MIDI,
parameter automation points, UI commands). If the consumer falls behind,
producer-side failed pushes increment overflow_count() and appear in
telemetry().
ActivityChannel¶
Lock-free occurrence counters for realtime-to-UI feedback where the UI needs to know that something happened, but does not need an ordered payload for every event. Multiple signals between UI ticks coalesce into one observation.
#include <pulp/runtime/activity_channel.hpp>
enum Voice : std::size_t { Kick, Snare, Hat, VoiceCount };
auto activity = pulp::runtime::make_activity_channel<VoiceCount>();
// Audio thread — lock-free, allocation-free:
activity->signal(Kick);
// UI thread — retain one cursor per lane:
pulp::runtime::ActivityChannel<VoiceCount>::Sequence kick_cursor = 0;
if (activity->consume(Kick, kick_cursor)) {
flash_kick_pad();
}
Give the Processor and editor shared ownership of the channel. That keeps a
host-retained editor safe if the host destroys the Processor first. Use an
SpscQueue instead when every event and its payload must be delivered; use a
TripleBuffer for latest-value meters.
WakeSignal¶
Wakes one sleeping consumer thread from a realtime producer. The producer
publishes its work first (a ring write, a release store) and then calls
notify(), which is a fence and one atomic load: it posts a semaphore only
while the consumer is actually parked, so a busy consumer costs the audio
thread no system call. The consumer waits with a predicate over the same
published state and re-checks it after announcing that it is about to park,
so a wakeup cannot be lost.
#include <pulp/runtime/wake_signal.hpp>
pulp::runtime::WakeSignal wake;
// Audio thread:
ring.push(block);
wake.notify();
// Worker thread:
while (running) {
wake.wait_for([&] { return !ring.empty() || !running; }, std::chrono::seconds(1));
drain(ring);
}
Use it instead of std::condition_variable::notify_* from the audio thread,
which is not realtime safe and loses wakeups unless the producer takes the
mutex. A worker that waits on a WakeSignal needs no short polling timeout,
so it does not wake the CPU while idle. One consumer per signal; posts(),
wakes() and consumer_parked() are diagnostics for tests.
Budget Policy¶
budget_policy.hpp provides a small, deterministic decision helper for
runtime work that may need to degrade under load. Critical audio work always
runs. Interactive work can defer to preserve an audio reserve. Background and
opportunistic work can bypass or shed when budget is exhausted or overload is
active.
#include <pulp/runtime/budget_policy.hpp>
RuntimeBudgetPolicy policy;
policy.critical_audio_reserve = 128;
RuntimeBudgetRequest request;
request.lane = RuntimeWorkLane::Opportunistic;
request.estimated_cost = 64;
request.remaining_budget = 32;
auto decision = evaluate_runtime_budget(request, policy);
if (!decision.should_run()) {
// Upload telemetry or use a cheaper fallback; do not block process().
}
For a group of optional tasks inside one callback or worker tick, use
RuntimeBudgetFrame. It keeps the remaining budget and counts run/defer/shed/
bypass decisions without allocating:
RuntimeBudgetFrame frame(512, policy, overload_active);
if (frame.evaluate(RuntimeWorkLane::Interactive, 64).should_run()) {
refresh_visible_analysis();
}
if (frame.evaluate(RuntimeWorkLane::Opportunistic, 256).should_run()) {
rebuild_noncritical_preview();
}
auto stats = frame.stats(); // publish off the audio thread if needed
This is the intended hook for large graph or instrument consumers: critical audio remains on the prepared path, while optional analysis, preview, and background refresh work degrades through the shared policy.
Cost values passed to RuntimeBudgetFrame should be stable work units, not
wall-clock timing. Current graph and instrument consumers use deterministic
shape counters: graph node/edge/port/block/buffer stats, or voice-pool
polyphony/active/releasing counts. That makes CI fixtures portable while still
pinning whether optional work runs, defers, sheds, or bypasses at scale.
Degraded Optional Work Contract¶
The budget policy is a cooperative decision helper. It does not preempt running audio work, measure CPU cycles, move work to another thread, or automatically virtualize graph nodes or instrument voices. Callers must ask before starting optional work and honor the returned action:
| Action | Caller behavior |
|---|---|
Run |
Execute the optional work inside the caller's prepared budget. |
Defer |
Keep required or interactive work queued for a later frame/tick. |
Shed |
Drop optional work for this frame/tick without producing stale output. |
Bypass |
Use an existing cached/cheap fallback and skip the expensive path. |
Critical audio rendering should not be gated through this helper. Use it for noncritical graph previews, diagnostics, per-voice analysis, background refresh, and validation helpers where a degraded result is acceptable and visible.
Cancellation¶
CancelSource owns a cancellation request and CancelToken observes it. Hand the
token to the work being cancelled and keep the source with whoever may cancel it.
#include <pulp/runtime/cancel.hpp>
pulp::runtime::CancelSource source;
auto token = source.token(); // copyable observer
worker = std::thread([token] {
while (!token.is_cancelled()) { // lock-free; safe on the audio thread
do_a_slice_of_work();
}
});
auto registration = token.register_callback([] { wake_the_ui(); });
bool stopped = token.wait_for_cancellation(std::chrono::seconds(5));
source.cancel(); // idempotent; also runs on destruction
CancelSourceis move-only and cancels when destroyed, so work never outlives its owner without being told to stop.- A default-constructed
CancelTokencan never be cancelled, which makesconst CancelToken& = {}a safe "not cancellable" default parameter. is_cancelled()is one acquire load. It never locks or allocates. Everything else takes a mutex and stays off the audio thread.register_callbackreturns aCancelRegistration. Callbacks run on the cancelling thread, in registration order, or immediately if the token is already cancelled. Destroying a registration waits for a callback that is running on another thread, so captured state is safe to free afterwards.CancellationTokenis the former single type that both requested and observed cancellation. It still works and converts toCancelToken.
FixedFunction¶
std::function can allocate when constructed or copied and needs a copyable target,
so it does not belong on a realtime path. FixedFunction<Signature, Capacity> is a
move-only callable wrapper with inline storage.
#include <pulp/runtime/fixed_function.hpp>
pulp::runtime::FixedFunction<void(int), 32> apply =
[gain = &gain_](int db) { gain->store(db, std::memory_order_relaxed); };
apply(-6);
- Constructing, moving, calling and destroying never allocate, lock or throw.
- A target that is too large, over-aligned or not nothrow-move-constructible is a
static_assertfailure at the construction site, never a silent heap fallback. Capture pointers or indices instead of objects, or raiseCapacity. The default capacity is 32 bytes. - Targets may be move-only (a lambda owning a
unique_ptr). Destroying the wrapper runs the target's destructor on the destroying thread, so capture trivially destructible state for objects that die on the audio thread. - Calling an empty
FixedFunctionis undefined behaviour; test it withexplicit operator boolwhen it can be empty. FixedFunction::fits<F>reports at compile time whetherFcan be stored.format::BackgroundTaskLane, the bounded audio-to-worker command lane, stores its handler in one:lane.start([this](const Task& t) { handle(t); }). The older function-pointer-plus-context overload still works.
Special directories¶
pulp::platform::special_dir (in pulp/platform/special_dir.hpp, which this module
depends on) is the one place that knows where each OS keeps the user's folders.
Settings, presets, content and log code call it instead of carrying their own
#ifdef ladders.
#include <pulp/platform/special_dir.hpp>
using pulp::platform::SpecialDir;
auto logs = pulp::platform::special_dir(SpecialDir::logs) / "MyApp";
auto assets = pulp::platform::special_dir(SpecialDir::bundle_dir); // resource root of this plugin or app
Kinds: home, documents, desktop, music, app_data (per user),
app_data_shared (all users), config (per-user settings), temp, cache,
logs, current_executable, bundle_dir, user_presets. The header lists the
exact path each platform returns.
- An empty path means the location is unavailable. Fail closed instead of falling
back to a shared folder such as
/tmp. - Nothing is created or checked for existence.
bundle_diris the resource root of the binary that contains the Pulp code, so a plugin gets its own bundle, not its host's.current_executableis the host.- Android resolves under the directory given to the existing
set_app_data_dirhook. WebAssembly resolves inside the in-memory file system. set_special_dir_overrideredirects a kind for tests or for mobile hosts that learn their sandbox late.special_dir_for(os, kind, inputs)computes any platform's layout from an explicit environment, which is how the layouts are tested on every host.
ScopeGuard¶
RAII cleanup — runs a callable on scope exit.
#include <pulp/runtime/scope_guard.hpp>
auto guard = pulp::runtime::make_scope_guard([&] {
cleanup_resource();
});
// cleanup_resource() runs when guard goes out of scope
Logging¶
#include <pulp/runtime/log.hpp>
pulp::runtime::log_info("Plugin loaded: {}", name);
pulp::runtime::log_warn("Buffer underrun at sample {}", position);
pulp::runtime::log_error("Failed to open device: {}", device_name);
pulp::runtime::log_with_category("net", pulp::runtime::LogLevel::Info, "connected to {}", host);
Records go to the platform log (os_log, the debugger output window) and stderr. Safe
to call from any thread, but logging is not realtime-safe: formatting allocates and
the writers take locks and do I/O. Never log from process() or any other audio-thread
path. Post the event to a non-realtime thread (an SpscQueue drained by a worker) and
log from there.
Sinks¶
set_log_sink installs one process-wide callback that also receives every record as
(level, category, message). The swap is thread-safe and returns the previous sink.
A sink that throws is ignored, and a record logged from inside a sink is not delivered
back to it. The sink registry is per loaded binary, so a plugin does not receive its
host's records.
#include <pulp/runtime/log_sink.hpp>
// A rotating file in <logs>/MyApp: owner-only, at most 1 MiB x 5 files.
std::string error;
pulp::runtime::FileLogSinkOptions options;
options.app_name = "MyApp";
if (auto file = pulp::runtime::FileLogSink::open(options, &error))
pulp::runtime::set_log_sink(*file);
else
pulp::runtime::log_warn("File logging disabled: {}", error);
// Tests and tooling: keep the last N records in memory.
pulp::runtime::CaptureLogSink capture;
pulp::runtime::ScopedLogSink scope(capture); // restores the previous sink on exit
code_under_test();
REQUIRE(capture.contains("device lost"));
FileLogSinkwrites2026-11-06T17:02:03.123Z [info] category: message, one record per line. Line breaks and control characters inside a record are escaped, so a message cannot forge another record. Records over 8 KiB are truncated.- It rotates by size (
max_file_bytes,max_files) and filters bymin_level. openrefuses and reports a location it cannot trust: a symlinked directory or file, a directory or file writable by group or others, a file owned by someone else, or a file with several hard links. Files are created0600and the directory0700. On POSIX every file operation is relative to the opened directory, so swapping the path for a link afterwards has no effect. Windows has no mode bits: it refuses symlinks and junctions and relies on the per-user logs folder's ACL.- File logging is unavailable under WebAssembly.
CaptureLogSinkis bounded; the oldest record is dropped (and counted) when full. Userecords(),records_at_least(),take()andcontains()to inspect it.
Assertions¶
#include <pulp/runtime/assert.hpp>
PULP_ASSERT(buffer.num_channels() > 0, "Buffer must have channels");
PULP_DBG_ASSERT(sample_rate > 0, "Sample rate must be positive");
PULP_ASSERT is active in all builds. PULP_DBG_ASSERT is active in
debug builds and compiles away in release builds.