diff --git a/.changeset/cold-walls-glow.md b/.changeset/cold-walls-glow.md new file mode 100644 index 00000000..ef7c5054 --- /dev/null +++ b/.changeset/cold-walls-glow.md @@ -0,0 +1,5 @@ +--- +'@livekit/rtc-node': patch +--- + +Dispose native handles on audio/video stream EOS to prevent FD leaks diff --git a/packages/livekit-rtc/src/audio_stream.ts b/packages/livekit-rtc/src/audio_stream.ts index 12d78232..97cea8b6 100644 --- a/packages/livekit-rtc/src/audio_stream.ts +++ b/packages/livekit-rtc/src/audio_stream.ts @@ -27,6 +27,7 @@ export interface NoiseCancellationOptions { class AudioStreamSource implements UnderlyingSource { private controller?: ReadableStreamDefaultController; private ffiHandle: FfiHandle; + private disposed = false; private sampleRate: number; private numChannels: number; private legacyNcOptions?: NoiseCancellationOptions; @@ -106,7 +107,15 @@ class AudioStreamSource implements UnderlyingSource { case 'eos': FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onEvent); this.controller.close(); - this.frameProcessor?.close(); + // Dispose the native handle so the FD is released on stream end, + // not just when cancel() is called explicitly by the consumer. + // Guard against double-dispose if cancel() is called after EOS + // while buffered frames are still in the ReadableStream queue. + if (!this.disposed) { + this.disposed = true; + this.ffiHandle.dispose(); + this.frameProcessor?.close(); + } break; } }; @@ -117,7 +126,13 @@ class AudioStreamSource implements UnderlyingSource { cancel() { FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onEvent); - this.ffiHandle.dispose(); + if (!this.disposed) { + this.disposed = true; + this.ffiHandle.dispose(); + // Also close the frame processor on cancel for symmetry with the EOS path, + // so resources are released regardless of how the stream ends. + this.frameProcessor?.close(); + } } } diff --git a/packages/livekit-rtc/src/video_stream.ts b/packages/livekit-rtc/src/video_stream.ts index 241433e7..6df0c07d 100644 --- a/packages/livekit-rtc/src/video_stream.ts +++ b/packages/livekit-rtc/src/video_stream.ts @@ -18,6 +18,7 @@ export type VideoFrameEvent = { class VideoStreamSource implements UnderlyingSource { private controller?: ReadableStreamDefaultController; private ffiHandle: FfiHandle; + private disposed = false; constructor(track: Track) { const req = new NewVideoStreamRequest({ @@ -65,6 +66,14 @@ class VideoStreamSource implements UnderlyingSource { case 'eos': FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onEvent); this.controller.close(); + // Dispose the native handle so the FD is released on stream end, + // not just when cancel() is called explicitly by the consumer. + // Guard against double-dispose if cancel() is called after EOS + // while buffered frames are still in the ReadableStream queue. + if (!this.disposed) { + this.disposed = true; + this.ffiHandle.dispose(); + } break; } }; @@ -75,7 +84,10 @@ class VideoStreamSource implements UnderlyingSource { cancel() { FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onEvent); - this.ffiHandle.dispose(); + if (!this.disposed) { + this.disposed = true; + this.ffiHandle.dispose(); + } } }