Description
In ladspa/src/lib.rs, each instantiated plugin spawns an OS worker thread via thread::spawn(get_worker_fn(...)). When the LADSPA host finishes using the plugin and calls cleanup() (which drops DfPlugin), the spawned worker thread is never terminated or joined. It leaks permanently in an infinite loop, causing severe CPU and memory accumulation over time.
Root Cause Analysis
Inside get_worker_fn() in ladspa/src/lib.rs:
loop {
if let Ok((c, v)) = controls.try_recv() {
...
}
let got_samples = {
let mut q = inqueue.lock().unwrap();
...
};
if !got_samples {
sleep(sleep_duration);
continue;
}
...
}
DfPlugin owns _h: JoinHandle<()>. In Rust, dropping a JoinHandle detaches the thread rather than terminating it.
DfPlugin does not implement Drop to signal the thread to exit.
- When
DfPlugin is dropped by the host, its control_tx: ControlProd is dropped. This causes controls.try_recv() inside the worker thread to return Err(TryRecvError::Disconnected).
- However, the worker thread uses
if let Ok(...) = controls.try_recv(), silently ignoring TryRecvError::Disconnected.
- Because the worker thread closure owns a cloned
Arc of inqueue, inqueue is never deallocated. q[0].len() >= df.hop_size is always false once no more audio is pushed.
- The thread executes
sleep(sleep_duration) in an infinite loop. At 48 kHz with a hop size of 480, sleep_duration is 480 / 48000 / 5 = 2 ms.
- Every leaked thread wakes up 500 times per second in
hrtimer_nanosleep, locks its empty mutex, and goes back to sleep.
Impact in Real-World Use (e.g. PipeWire filter-chain)
When using DeepFilterNet as a PipeWire noise cancellation source (via libpipewire-module-filter-chain with node.passive = true), PipeWire activates and suspends the filter graph on demand (e.g., each time an application like speech-to-text dictation, a browser call, or an audio recorder opens and closes the microphone).
Every session transition instantiates a new LADSPA plugin and drops the old one. After a couple days of regular usage:
- Over 200 orphaned worker threads were active in the background.
- Over 100,000 wakeups and context switches per second occurred continuously while the system was idle.
- PipeWire CPU usage climbed to 15-20%+ of a CPU core when idle.
- Virtual memory ballooned to ~9 GB due to thread stack allocations.
Proposed Fix
Handle TryRecvError::Disconnected in get_worker_fn() so that when DfPlugin is dropped and control_tx closes, the worker thread breaks out of the loop and exits cleanly:
--- a/ladspa/src/lib.rs
+++ b/ladspa/src/lib.rs
@@ -116,16 +116,23 @@ fn get_worker_fn(
let mut outframe = Array2::zeros((df.ch, df.hop_size));
let t_audio_ms = df.hop_size as f32 / df.sr as f32 * 1000.;
loop {
- if let Ok((c, v)) = controls.try_recv() {
- log::info!("DF {} | Setting '{}' to {:.1}", id, c, v);
- match c {
- DfControl::AttenLim => df.set_atten_lim(v),
- DfControl::PfBeta => df.set_pf_beta(v),
- DfControl::MinThreshDb => df.min_db_thresh = v,
- DfControl::MaxErbThreshDb => df.max_db_erb_thresh = v,
- DfControl::MaxDfThreshDb => df.max_db_df_thresh = v,
- _ => (),
- }
+ match controls.try_recv() {
+ Ok((c, v)) => {
+ log::info!("DF {} | Setting '{}' to {:.1}", id, c, v);
+ match c {
+ DfControl::AttenLim => df.set_atten_lim(v),
+ DfControl::PfBeta => df.set_pf_beta(v),
+ DfControl::MinThreshDb => df.min_db_thresh = v,
+ DfControl::MaxErbThreshDb => df.max_db_erb_thresh = v,
+ DfControl::MaxDfThreshDb => df.max_db_df_thresh = v,
+ _ => (),
+ }
+ }
+ Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
+ Err(std::sync::mpsc::TryRecvError::Empty) => (),
+ }
+ if Arc::strong_count(&inqueue) <= 1 {
+ break;
}
let got_samples = {
let mut q = inqueue.lock().unwrap();
Testing this patch confirmed that worker threads exit immediately upon plugin drop, preventing thread leakage entirely.
Description
In
ladspa/src/lib.rs, each instantiated plugin spawns an OS worker thread viathread::spawn(get_worker_fn(...)). When the LADSPA host finishes using the plugin and callscleanup()(which dropsDfPlugin), the spawned worker thread is never terminated or joined. It leaks permanently in an infinite loop, causing severe CPU and memory accumulation over time.Root Cause Analysis
Inside
get_worker_fn()inladspa/src/lib.rs:DfPluginowns_h: JoinHandle<()>. In Rust, dropping aJoinHandledetaches the thread rather than terminating it.DfPlugindoes not implementDropto signal the thread to exit.DfPluginis dropped by the host, itscontrol_tx: ControlProdis dropped. This causescontrols.try_recv()inside the worker thread to returnErr(TryRecvError::Disconnected).if let Ok(...) = controls.try_recv(), silently ignoringTryRecvError::Disconnected.Arcofinqueue,inqueueis never deallocated.q[0].len() >= df.hop_sizeis always false once no more audio is pushed.sleep(sleep_duration)in an infinite loop. At 48 kHz with a hop size of 480,sleep_durationis480 / 48000 / 5 = 2 ms.hrtimer_nanosleep, locks its empty mutex, and goes back to sleep.Impact in Real-World Use (e.g. PipeWire filter-chain)
When using DeepFilterNet as a PipeWire noise cancellation source (via
libpipewire-module-filter-chainwithnode.passive = true), PipeWire activates and suspends the filter graph on demand (e.g., each time an application like speech-to-text dictation, a browser call, or an audio recorder opens and closes the microphone).Every session transition instantiates a new LADSPA plugin and drops the old one. After a couple days of regular usage:
Proposed Fix
Handle
TryRecvError::Disconnectedinget_worker_fn()so that whenDfPluginis dropped andcontrol_txcloses, the worker thread breaks out of the loop and exits cleanly:Testing this patch confirmed that worker threads exit immediately upon plugin drop, preventing thread leakage entirely.