From 2e5b5cb186468e8f9eaa48b2ab165b068f82282d Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 3 Sep 2026 14:50:28 +0100 Subject: [PATCH] Don't attach the background blur processor to an ended camera track If the camera track has already ended by the time the blur processor is applied to it, MediaStreamTrackProcessor cannot be constructed and setProcessor rejects. Both call sites used `void`, so this surfaced as Unhandled promise rejection: TypeError: Failed to construct 'MediaStreamTrackProcessor': Input track cannot be ended (element-call-rageshakes#17225). Skip ended tracks, and catch and log processor attach/detach failures instead of leaking them. --- src/livekit/TrackProcessorContext.test.ts | 62 +++++++++++++++++++++++ src/livekit/TrackProcessorContext.tsx | 44 +++++++++++----- 2 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 src/livekit/TrackProcessorContext.test.ts diff --git a/src/livekit/TrackProcessorContext.test.ts b/src/livekit/TrackProcessorContext.test.ts new file mode 100644 index 000000000..732fa5308 --- /dev/null +++ b/src/livekit/TrackProcessorContext.test.ts @@ -0,0 +1,62 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, it, vi } from "vitest"; +import { type LocalVideoTrack } from "livekit-client"; +import { + type BackgroundOptions, + type ProcessorWrapper, +} from "@livekit/track-processors"; + +import { applyProcessor } from "./TrackProcessorContext"; + +const processor = {} as ProcessorWrapper; + +function mockTrack( + readyState: MediaStreamTrackState, + current?: ProcessorWrapper, +): LocalVideoTrack { + return { + mediaStreamTrack: { readyState }, + getProcessor: vi.fn().mockReturnValue(current), + setProcessor: vi.fn().mockResolvedValue(undefined), + stopProcessor: vi.fn().mockResolvedValue(undefined), + } as unknown as LocalVideoTrack; +} + +describe("applyProcessor", () => { + it("attaches the processor to a live track", () => { + const track = mockTrack("live"); + applyProcessor(track, processor); + expect(track.setProcessor).toHaveBeenCalledWith(processor); + }); + + it("does not attach the processor to an ended track", () => { + const track = mockTrack("ended"); + applyProcessor(track, processor); + expect(track.setProcessor).not.toHaveBeenCalled(); + }); + + it("does not surface a rejected setProcessor", async () => { + const track = mockTrack("live"); + vi.mocked(track.setProcessor).mockRejectedValue( + new TypeError("Input track cannot be ended"), + ); + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + applyProcessor(track, processor); + await new Promise((r) => setTimeout(r, 0)); + process.off("unhandledRejection", unhandled); + expect(unhandled).not.toHaveBeenCalled(); + }); + + it("stops the processor when none is wanted", () => { + const track = mockTrack("live", processor); + applyProcessor(track, undefined); + expect(track.stopProcessor).toHaveBeenCalled(); + }); +}); diff --git a/src/livekit/TrackProcessorContext.tsx b/src/livekit/TrackProcessorContext.tsx index 21cd609ea..96897929f 100644 --- a/src/livekit/TrackProcessorContext.tsx +++ b/src/livekit/TrackProcessorContext.tsx @@ -19,6 +19,7 @@ import { useMemo, } from "react"; import { type LocalVideoTrack } from "livekit-client"; +import { logger } from "matrix-js-sdk/lib/logger"; import { combineLatest, map, type Observable } from "rxjs"; import { useObservable } from "observable-hooks"; @@ -65,6 +66,34 @@ export function useTrackProcessorObservable$(): Observable { return state$; } +/** + * Attaches or detaches the processor so that the track matches the desired + * state, without throwing. + */ +export function applyProcessor( + videoTrack: LocalVideoTrack, + processor: ProcessorWrapper | undefined, +): void { + if (processor && !videoTrack.getProcessor()) { + // A MediaStreamTrackProcessor cannot be constructed on an ended track + // (e.g. the camera was stopped while the processor was being applied), + // and setProcessor rejects with a TypeError. The track is going away + // anyway, so there is nothing to attach to. + if (videoTrack.mediaStreamTrack.readyState === "ended") { + logger.debug("Not attaching video processor to an ended track"); + return; + } + videoTrack.setProcessor(processor).catch((e) => { + logger.warn("Failed to attach video processor", e); + }); + } + if (!processor && videoTrack.getProcessor()) { + videoTrack.stopProcessor().catch((e) => { + logger.warn("Failed to stop video processor", e); + }); + } +} + /** * Updates your video tracks to always use the given processor. */ @@ -78,13 +107,7 @@ export const trackProcessorSync = ( .subscribe(([videoTrack, processorState]) => { if (!processorState) return; if (!videoTrack) return; - const { processor } = processorState; - if (processor && !videoTrack.getProcessor()) { - void videoTrack.setProcessor(processor); - } - if (!processor && videoTrack.getProcessor()) { - void videoTrack.stopProcessor(); - } + applyProcessor(videoTrack, processorState.processor); }); }; @@ -94,12 +117,7 @@ export const useTrackProcessorSync = ( const { processor } = useTrackProcessor(); useEffect(() => { if (!videoTrack) return; - if (processor && !videoTrack.getProcessor()) { - void videoTrack.setProcessor(processor); - } - if (!processor && videoTrack.getProcessor()) { - void videoTrack.stopProcessor(); - } + applyProcessor(videoTrack, processor); }, [processor, videoTrack]); };