Skip to content

AudioSource.close() throws GenericFailure (NAPI double-dispose race) on Linux x64 when pending captureAudioFrame callbacks are in-flight #662

@cladbeshreyansh

Description

@cladbeshreyansh

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

  1. 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
  1. Run inside a Docker container based on node:20-slim (Linux x64 glibc)
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions