From 77bcd41bc9b9a886d19810d0a51bf3c11780dd1d Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 10:29:10 -0300 Subject: [PATCH 1/3] fix: dispose native handles on audio/video stream EOS --- packages/livekit-rtc/src/audio_stream.ts | 6 ++++++ packages/livekit-rtc/src/video_stream.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/packages/livekit-rtc/src/audio_stream.ts b/packages/livekit-rtc/src/audio_stream.ts index 12d78232..f2d9dda4 100644 --- a/packages/livekit-rtc/src/audio_stream.ts +++ b/packages/livekit-rtc/src/audio_stream.ts @@ -106,6 +106,9 @@ class AudioStreamSource 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. + this.ffiHandle.dispose(); this.frameProcessor?.close(); break; } @@ -118,6 +121,9 @@ class AudioStreamSource implements UnderlyingSource { cancel() { FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onEvent); 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..b13c6160 100644 --- a/packages/livekit-rtc/src/video_stream.ts +++ b/packages/livekit-rtc/src/video_stream.ts @@ -65,6 +65,9 @@ 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. + this.ffiHandle.dispose(); break; } }; From c861144d1d4f1172073a24886838a6c5e9e47955 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 11:36:55 -0300 Subject: [PATCH 2/3] chore: add changeset --- .changeset/cold-walls-glow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cold-walls-glow.md 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 From 670293cf938df74e16b98606a37ed752a1ee2199 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 11:45:23 -0300 Subject: [PATCH 3/3] fix: guard against double-dispose when cancel() follows EOS with buffered frames --- packages/livekit-rtc/src/audio_stream.ts | 21 +++++++++++++++------ packages/livekit-rtc/src/video_stream.ts | 13 +++++++++++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/livekit-rtc/src/audio_stream.ts b/packages/livekit-rtc/src/audio_stream.ts index f2d9dda4..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; @@ -108,8 +109,13 @@ class AudioStreamSource implements UnderlyingSource { 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. - this.ffiHandle.dispose(); - this.frameProcessor?.close(); + // 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; } }; @@ -120,10 +126,13 @@ class AudioStreamSource implements UnderlyingSource { cancel() { FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onEvent); - 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(); + 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 b13c6160..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({ @@ -67,7 +68,12 @@ class VideoStreamSource implements UnderlyingSource { 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. - this.ffiHandle.dispose(); + // 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; } }; @@ -78,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(); + } } }