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.
This commit is contained in:
neagix 2026-06-11 17:25:48 +02:00 committed by neagix
parent 6bef205b3f
commit 09d5f93c6c

View File

@ -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();
}