Select which package(s) are affected
@livekit/rtc-node
Describe the bug
What I'm expecting
AudioSource.close() completes cleanly after BackgroundAudioPlayer finishes playback.
What happens instead
AudioSource.close() throws { code: 'GenericFailure', message: 'trying to drop an invalid handle' } on Linux x64 (node:20-slim / glibc). Does not reproduce on macOS (darwin-arm64).
Reproduction
- Use
BackgroundAudioPlayer from @livekit/agents to play a short audio file (not looped):
const player = new voice.BackgroundAudioPlayer();
await player.start({ room });
const handle = player.play(audioUrl, false);
await handle.waitForPlayout();
await player.close(); // ← throws GenericFailure on Linux
- Run inside a Docker container based on
node:20-slim (Linux x64 glibc)
player.close() → AudioSource.close() → ffiHandle.dispose() throws every time
The error can also be reproduced in isolation with a double-dispose:
const { FfiHandle } = require('@livekit/rtc-ffi-bindings');
const h = new FfiHandle(9999999n);
h.dispose();
h.dispose(); // → { code: 'GenericFailure', message: 'trying to drop an invalid handle' }
Root cause (hypothesis): captureFrame sends requests to the Rust FFI layer and awaits async callbacks via FfiClient.instance.waitFor(...). Multiple frames can be in-flight simultaneously. When AudioSource.close() calls ffiHandle.dispose() synchronously, pending captureAudioFrame callbacks from the Rust tokio runtime arrive after the handle is already gone. On Linux x64, the tokio scheduler delivers these callbacks later than on macOS, so the race hits consistently on Linux but not on macOS.
Logs
[Error: trying to drop an invalid handle] { code: 'GenericFailure' }
System Info
- OS: Linux x64 (Debian slim / glibc) via Docker `node:20-slim`
- Node.js: 20.20.2
- `@livekit/rtc-node`: 0.13.28
- `@livekit/rtc-ffi-bindings`: 0.12.56 (`linux-x64-gnu` binary)
- `@livekit/agents`: 1.4.2
- Does **not** reproduce on macOS darwin-arm64 with identical package versions
LiveKit server version
latest self hosted version
Severity
serious, but I can work around it
Additional Information
The error crashes the Node.js process if not caught, because ffiHandle.dispose() throws synchronously inside the async close() function — the throw propagates as a rejected Promise that bypasses any .catch(() => {}) attached to a later chained call.
Workaround: wrap player.close() in its own try-catch so subsequent cleanup (e.g. room.disconnect()) still runs:
try { await player.close(); } catch (_) {}
try { await room.disconnect(); } catch (_) {}
Related: PR #639 (fix: clear pending timeout in AudioSource.close()) fixed a similar race around this.timeout, but the ffiHandle.dispose() race with in-flight captureAudioFrame callbacks appears to remain on Linux x64.
Select which package(s) are affected
@livekit/rtc-node
Describe the bug
What I'm expecting
AudioSource.close()completes cleanly afterBackgroundAudioPlayerfinishes playback.What happens instead
AudioSource.close()throws{ code: 'GenericFailure', message: 'trying to drop an invalid handle' }on Linux x64 (node:20-slim / glibc). Does not reproduce on macOS (darwin-arm64).Reproduction
BackgroundAudioPlayerfrom@livekit/agentsto play a short audio file (not looped):node:20-slim(Linux x64 glibc)player.close()→AudioSource.close()→ffiHandle.dispose()throws every timeThe error can also be reproduced in isolation with a double-dispose:
Root cause (hypothesis):
captureFramesends requests to the Rust FFI layer and awaits async callbacks viaFfiClient.instance.waitFor(...). Multiple frames can be in-flight simultaneously. WhenAudioSource.close()callsffiHandle.dispose()synchronously, pendingcaptureAudioFramecallbacks from the Rust tokio runtime arrive after the handle is already gone. On Linux x64, the tokio scheduler delivers these callbacks later than on macOS, so the race hits consistently on Linux but not on macOS.Logs
[Error: trying to drop an invalid handle] { code: 'GenericFailure' }System Info
LiveKit server version
latest self hosted version
Severity
serious, but I can work around it
Additional Information
The error crashes the Node.js process if not caught, because
ffiHandle.dispose()throws synchronously inside theasync close()function — the throw propagates as a rejected Promise that bypasses any.catch(() => {})attached to a later chained call.Workaround: wrap
player.close()in its own try-catch so subsequent cleanup (e.g.room.disconnect()) still runs:Related: PR #639 (
fix: clear pending timeout in AudioSource.close()) fixed a similar race aroundthis.timeout, but theffiHandle.dispose()race with in-flightcaptureAudioFramecallbacks appears to remain on Linux x64.