From e38804a228a876082ac318a8adfc5b18c6433319 Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Sun, 25 Oct 2026 15:32:57 +0100 Subject: [PATCH] fix: register a camera switch with the runtime and reject it on a closed track --- .../onvoid/webrtc/client/LocalCameraImpl.java | 30 ++++++ .../webrtc/client/RuntimeCloseTests.java | 102 ++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/LocalCameraImpl.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/LocalCameraImpl.java index e5586867..e33563b7 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/LocalCameraImpl.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/LocalCameraImpl.java @@ -61,6 +61,25 @@ public CompletableFuture switchCamera(VideoDevice device) { requireNonNull(device, "device"); CompletableFuture switched = new CompletableFuture<>(); + + if (isEnded()) { + switched.completeExceptionally(new MediaException("The camera track is closed")); + return switched; + } + + // Registered like the creation of a track, as the switch opens a + // camera and uses the factory: a runtime that begins to close either + // waits for it before it frees the factory, or is seen closing here. + LocalMediaImpl.Creation operation; + + try { + operation = media.begin(); + } + catch (PeerClosedException e) { + switched.completeExceptionally(e); + return switched; + } + media.rtc.track(switched, null); // Opening a camera can take a moment, so it happens off the caller's thread. @@ -73,6 +92,12 @@ public CompletableFuture switchCamera(VideoDevice device) { switched.completeExceptionally(e instanceof WebRtcException ? e : new MediaException("Switching to the camera " + device.getName() + " failed", e)); } + finally { + operation.close(); + } + }, () -> { + operation.close(); + switched.completeExceptionally(new PeerClosedException("The runtime is closed")); }); return switched; @@ -82,6 +107,11 @@ private void switchTo(VideoDevice device) { LocalMediaImpl.SharedCamera current; synchronized (this) { + // Closed while the switch was queued, so no camera is opened. + if (!isLive()) { + throw new MediaException("The camera track is closed"); + } + current = camera; } diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/RuntimeCloseTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/RuntimeCloseTests.java index b6ec4932..adb3abc6 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/RuntimeCloseTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/RuntimeCloseTests.java @@ -34,6 +34,7 @@ import dev.onvoid.webrtc.RTCSessionDescription; import dev.onvoid.webrtc.media.audio.AudioDevice; import dev.onvoid.webrtc.media.video.CustomVideoSource; +import dev.onvoid.webrtc.media.video.VideoDevice; import org.junit.jupiter.api.Test; @@ -379,6 +380,75 @@ void switchDeviceAfterClose() throws Exception { await(closed); } + @Test + void closeWhileSwitchingCamera() throws Exception { + // A camera switch is queued when the runtime begins to close. The + // runtime waits for it before it frees the factory, and the switch, + // which finds its track closed, opens no camera. + ManualExecutor control = new ManualExecutor(); + WebRtc runtime = runtime(control); + LocalMediaImpl media = (LocalMediaImpl) runtime.media(); + AtomicBoolean poolStopped = new AtomicBoolean(true); + LocalCameraImpl camera = camera(runtime); + + CompletableFuture switched = camera.switchCamera(camera("Other camera")); + switched.whenComplete((value, error) -> poolStopped.set(runtime.controlPool.isShutdown())); + + // Named among what the runtime waits for, beside the track. + assertEquals(2, media.closeAll().size()); + + CompletableFuture closed = runtime.closeAsync(); + control.runUntil(closed::isDone, TIMEOUT_SECONDS); + await(closed); + + assertFalse(poolStopped.get()); + assertTrackClosed(switched); + } + + @Test + void switchCameraAfterClose() throws Exception { + LocalCameraImpl camera = camera(rtc); + await(camera.closeAsync()); + + // Failed at once, without opening a camera over the factory. + CompletableFuture switched = camera.switchCamera(camera("Other camera")); + + assertTrue(switched.isDone()); + assertTrackClosed(switched); + } + + @Test + void switchCameraRejected() throws Exception { + // The executor refuses the switch, which fails and lets go of its + // registration, so that the runtime does not wait for it. + ManualExecutor control = new ManualExecutor(); + WebRtc runtime = runtime(control); + LocalMediaImpl media = (LocalMediaImpl) runtime.media(); + LocalCameraImpl camera = camera(runtime); + + try { + control.rejecting(true); + CompletableFuture switched = camera.switchCamera(camera("Other camera")); + + assertTrue(switched.isDone()); + ExecutionException e = assertThrows(ExecutionException.class, () -> await(switched)); + assertInstanceOf(PeerClosedException.class, e.getCause()); + assertEquals(1, errors.size(), () -> "Errors: " + errors); + assertInstanceOf(RejectedExecutionException.class, errors.get(0).getCause()); + } + finally { + control.rejecting(false); + errors.clear(); + } + + // Only the track is left. + assertEquals(1, media.closeAll().size()); + + CompletableFuture closed = runtime.closeAsync(); + control.runUntil(closed::isDone, TIMEOUT_SECONDS); + await(closed); + } + @Test void closedPeerNeedsNoTimer() throws Exception { // An operation reaches a peer the closing runtime has closed, after it @@ -449,6 +519,38 @@ private WebRtc runtime(ManualExecutor control) { .build(); } + /** + * A camera track over a custom source, so that no camera is needed. Its + * camera has another user, so that the track's release lets go of its + * share only, and touches no capture device. + */ + private static LocalCameraImpl camera(WebRtc runtime) { + LocalMediaImpl media = (LocalMediaImpl) runtime.media(); + VideoDevice device = camera("Test camera"); + LocalMediaImpl.SharedCamera shared = new LocalMediaImpl.SharedCamera(device.getDescriptor(), + CameraOptions.builder().device(device).build(), null); + shared.users = 2; + + try (LocalMediaImpl.Creation creation = media.begin()) { + CustomVideoSource source = new CustomVideoSource(); + LocalCameraImpl camera = media.register(new LocalCameraImpl(media, + runtime.factory.createVideoTrack("camera", source), shared)); + camera.onEnded(source::dispose); + + return camera; + } + } + + private static VideoDevice camera(String name) { + return new VideoDevice(name, name.toLowerCase().replace(' ', '-')) { }; + } + + private static void assertTrackClosed(CompletableFuture switched) { + ExecutionException e = assertThrows(ExecutionException.class, () -> await(switched)); + assertInstanceOf(MediaException.class, e.getCause()); + assertEquals("The camera track is closed", e.getCause().getMessage()); + } + private static AudioDevice speaker() { return new AudioDevice("Test speaker", "test-speaker") { }; }