From 09d5f93c6c4f6b4f3f5c2003147cb6eb59356585 Mon Sep 17 00:00:00 2001 From: neagix Date: Thu, 11 Jun 2026 17:25:48 +0200 Subject: [PATCH] Clean up PulseAudio virtual devices after tunnel exits signal-call-tunnel creates PulseAudio module-null-sink and module-remap-source modules via virtual_audio.sh and relies on VirtualAudioDevicePair::drop() to unload them on exit. However, the tunnel is terminated with SIGTERM (Process.destroy()), and Rust Drop implementations do not run on signal termination, so the modules are never unloaded and accumulate across calls. Fix: run teardown in the process.onExit() callback, after the tunnel has exited, by calling pactl list modules short and unloading any module whose arguments contain the stored inputDeviceName or outputDeviceName for the call. --- .../signal/manager/helper/CallManager.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java index ac0a2a49..a39b78d7 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java @@ -409,6 +409,9 @@ public class CallManager implements AutoCloseable { // Monitor process exit process.onExit().thenAcceptAsync(p -> { logger.debug("Tunnel for call {} exited with code {}", callIdUnsigned(state.callId), p.exitValue()); + // SIGTERM (code 143) kills the Rust process without running Drop, + // so VirtualAudioDevicePair::drop() never fires. Clean up here. + teardownVirtualAudioDevices(state); if (activeCalls.containsKey(state.callId)) { endCall(state.callId, "tunnel_exit"); } @@ -748,6 +751,41 @@ public class CallManager implements AutoCloseable { } } + private void teardownVirtualAudioDevices(CallState state) { + var inputDevice = state.inputDeviceName; + var outputDevice = state.outputDeviceName; + if (inputDevice == null && outputDevice == null) { + logger.debug("No virtual audio device names for call {}, skipping teardown", callIdUnsigned(state.callId)); + return; + } + try { + var listProc = new ProcessBuilder("pactl", "list", "modules", "short") + .redirectErrorStream(true) + .start(); + var output = new String(listProc.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + listProc.waitFor(5, TimeUnit.SECONDS); + for (var line : output.lines().toList()) { + var parts = line.split("\t"); + if (parts.length < 1) continue; + var moduleId = parts[0].trim(); + if (moduleId.isEmpty()) continue; + var matches = (inputDevice != null && line.contains(inputDevice)) + || (outputDevice != null && line.contains(outputDevice)); + if (!matches) continue; + try { + new ProcessBuilder("pactl", "unload-module", moduleId) + .start() + .waitFor(5, TimeUnit.SECONDS); + logger.debug("Unloaded PulseAudio module {} for call {}", moduleId, callIdUnsigned(state.callId)); + } catch (Exception e) { + logger.debug("Failed to unload PulseAudio module {} for call {}: {}", moduleId, callIdUnsigned(state.callId), e.getMessage()); + } + } + } catch (Exception e) { + logger.debug("Failed to list PulseAudio modules for call {}", callIdUnsigned(state.callId), e); + } + } + private static long generateCallId() { return new BigInteger(64, new SecureRandom()).longValue(); }