Skip to content

LADSPA worker thread leaks permanently on plugin instance drop / cleanup #711

Description

@sohanglal

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;
    }
    ...
}
  1. DfPlugin owns _h: JoinHandle<()>. In Rust, dropping a JoinHandle detaches the thread rather than terminating it.
  2. DfPlugin does not implement Drop to signal the thread to exit.
  3. 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).
  4. However, the worker thread uses if let Ok(...) = controls.try_recv(), silently ignoring TryRecvError::Disconnected.
  5. 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.
  6. 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.
  7. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions