Debugging Rill Applications
Rill provides a two-level diagnostic infrastructure: runtime telemetry (signal probes, command logging) and an interactive debugger (rill-analyzer). Both are gated behind the debug Cargo feature — zero overhead in production builds.
Architecture
┌─────────────────────────────────────────────────┐
│ rill-lang (core, feature = "debug") │
│ ProbePoint IR + ProbeSlot + DebugControl │
│ 1 new Instr variant, zero-cost when disabled │
└──────────────┬──────────────────────────────────┘
│ depends on
┌──────────────▼──────────────────────────────────┐
│ rill-telemetry (diagnostic infrastructure) │
│ ProbeStateManager + CollectorThread │
│ CommandFormatter + ShmemRegion (IPC) │
└──────────────┬──────────────────────────────────┘
│ depends on
┌──────────────▼──────────────────────────────────┐
│ rill-analyzer (CLI + REPL) │
│ gdb-style interactive debugger │
│ Lua scripting, JSON output │
│ attach/launch via shared memory │
└─────────────────────────────────────────────────┘
All diagnostic data flows from the signal thread (RT) through lock-free SPSC queues to a collector thread (non-RT), which formats and outputs events. No allocations, no locks, no syscalls in the signal path.
Enabling Debugging
Add the debug feature to your Cargo features:
cargo build --features "debug"
For ModularSystem-based applications (using rill-adrift):
cargo run --example chiptune_stc --features "lofi,pipewire,io,debug" -- --file music.stc pipewire
The debug feature activates rill-lang/debug, rill-graph/debug, rill-telemetry/debug, and rill-patchbay/debug.
Signal Probes
How Probes Work
Each graph node gets an automatic probe at its output. The probe captures the first sample of every processed block and pushes it to a lock-free SPSC queue. A collector thread drains the queue and formats the output.
Probes are identified by the node name (node_0, node_1, etc.) and report both the block index and the signal value:
[block 1] probe[0] node_0 = 0
[block 2] probe[0] node_0 = 0
...
[block 20418] probe[0] node_0 = 0.4000
[block 20419] probe[0] node_0 = 0.2888
Probe Lifecycle
build_ir()inserts aProbePointIR instruction after the node'sCallBlock- The engine allocates
ProbeSlots — one per node — each with atomic flags (enabled,break_flag,paused_flag) and an SPSC queue - During processing, the engine captures the output buffer's first sample and pushes a
ProbeFrame { value_bits, block_index }into the queue CollectorThreaddrains the queue and formats the event viaTextFormatter(colored terminal) orJsonFormatter(JSON lines)
Enabling Specific Probes
Probes are auto-enabled in ModularSystem::launch() for each graph node. To enable/disable individual probes, use rill-analyzer:
(rla) enable <probe_id>
(rla) disable <probe_id>
Command Logging
Every SetParameter command that successfully routes to a program parameter is logged. This lets you trace who changes what parameter and when:
[block 17] cmd SetParameter → register_write: Bytes([112, 4, 0, 0, 124, ...])
[block 33] cmd SetParameter → register_write: Bytes([112, 4, 0, 0, 124, ...])
After the KeyFrame API is enabled with debug, the log output includes:
- block_index — which processing block received the command
- command_kind —
SetParameter,ClockTick, etc. - param_name — the parameter being modified
- value_repr — human-readable value representation
Commands that fail to route (parameter name not found, node not found) are silently ignored and do not appear in the command log. This makes the log a reliable indicator of successful parameter application.
Pause and Resume
The debugger can pause the engine between processing blocks. The engine spins on an AtomicBool — no syscalls, no locks:
#![allow(unused)] fn main() { // Spin if paused, until resume while self.debug_control.global_pause.load(Acquire) && !self.debug_control.global_resume.load(Acquire) { std::hint::spin_loop(); } }
The collector thread monitors FLAG_PAUSED in the shared memory region and calls debug_control.pause() / debug_control.cont() accordingly.
Inter-Process Debugging via Shared Memory
For debugging a running process, rill-analyzer uses a shared memory region at /dev/shm/rill-debug-<pid>. The region contains:
Offset Size Field
─────────────────────────────────────
0 4 magic (0x52494C4C = "RILL")
4 4 version
8 8 process_pid
16 8 debugger_pid
24 4 flags (PAUSED | ATTACHED | SHUTDOWN)
28-64 … ring buffer positions
64 ~32KB CmdRingBuffer (debugger → process)
~32KB ~32KB RespRingBuffer (process → debugger)
Each ring buffer is a lock-free SPSC circular buffer. Frames are serialized with serde_cbor. The debugger sends AnalyzerCommand through CmdRingBuffer, the process responds with AnalyzerResponse through RespRingBuffer.
Signal protocol: Only the debugger sends SIGUSR1 to the rill process. The process never sends signals to the debugger — responses are read via polling.
Attach Mode
rill-analyzer attach 12345
- Opens
/dev/shm/rill-debug-12345 - Verifies magic and version
- Registers as debugger (writes its PID)
- Enters REPL — commands go through the shmem ring buffer
Launch Mode
rill-analyzer launch ./my-app -- --flag value
- Creates shmem region
- Forks and executes the target with
RILL_DEBUG_SHMEMin the environment - Child process opens the shmem and sets
FLAG_ATTACHED - Parent waits for the flag, then enters REPL
If the target ends with .json, it's treated as a serialized graph and launched via drift --graph. If it ends with .rll, it's a rill-lang DSL source — compiled and launched via drift.
Lifecycle Logging
When debug feature is enabled, ModularSystem::launch() adds lifecycle logging via the log crate:
rill-adrift: launching rack 'chiptune_stc' — 1 nodes, 1 modules
rill-adrift: rack 'chiptune_stc' engine built — 1 programs
rill-adrift: rack 'chiptune_stc' backend 'pipewire' started
rill-adrift: system launched with 1 rack(s)
rill-adrift: stopping system
Use RUST_LOG=info to see these logs, or integrate with your preferred logger implementation.
RT Safety
All diagnostic data transport uses lock-free atomics and SPSC queues. The signal thread (RT) never allocates, locks, or blocks. The collector thread (non-RT) handles formatting, I/O, and IPC.
Forbidden in the RT path: log::info!, eprintln!, println!, any file or socket I/O. The only permitted path for RT diagnostics is pushing data through SPSC queues and atomics.
Patchbay Inspector
Beyond signal probes, the debug infrastructure can inspect control-path state:
#![allow(unused)] fn main() { // Automaton state (via rill-analyzer) (rla) info automatons // Sensor status (MIDI, OSC) (rla) info sensors }
The PatchbayInspector collects snapshots of Servo automaton state (enabled, value, time) and Sensor status (connected, event count) through DashMap-backed registries.