diff --git a/docs/guide/client-api.md b/docs/guide/client-api.md index 1460d4df..8073bd8f 100644 --- a/docs/guide/client-api.md +++ b/docs/guide/client-api.md @@ -65,6 +65,8 @@ WebRtc rtc = WebRtc.builder() | `closeTimeout(Duration)` | 10 seconds | How long `close()` waits. | | `configureFactory(Consumer)` | none | Anything else of the factory, such as codec factories of your own. | +Every timeout of the client API, these two as well as the gathering timeout and the `Whip` and `Whep` timeouts below, must be positive; none of them can be switched off. A timeout counts in milliseconds, rounded up, so one under a millisecond waits a millisecond. One too long for milliseconds to count waits as long as they can count, about 292 million years. + ### Audio Modes WebRTC feeds the audio a factory captures into every audio sender of that factory, so captured audio and audio you push cannot be mixed in one runtime. The runtime's `AudioMode` picks one of them up front: diff --git a/docs/guide/media/media-files.md b/docs/guide/media/media-files.md index 13e22f5b..e0cc8e24 100644 --- a/docs/guide/media/media-files.md +++ b/docs/guide/media/media-files.md @@ -248,6 +248,8 @@ import java.time.Duration; MediaFileSource source = new MediaFileSource("rtsp://camera.local/stream1", Duration.ofSeconds(5)); ``` +`Duration.ZERO` waits as long as it takes, and is the only limit that does: a positive one waits at least a microsecond, and one too long for microseconds to count waits as long as they can count. A negative one is rejected. + An unreachable server makes the constructor throw an `IOException` once that time is up. A stream that stops sending during playback is reported to the listener's `onError`, and the player is left `PAUSED`. Closing a player never waits for the time limit: it breaks off whatever the player is waiting for at once. A live stream has no end and no length: its `MediaInfo` reports a duration of 0, looping does not apply, and seeking is reported as an error. Pausing stops reading from the server, but the server keeps sending; resuming carries on from where playback paused, which leaves it behind real time by as long as it was paused. To get back to live, close the source and open it again. diff --git a/webrtc-media/src/main/cpp/src/media/MediaReader.cpp b/webrtc-media/src/main/cpp/src/media/MediaReader.cpp index 1c0eb001..8cd9376c 100644 --- a/webrtc-media/src/main/cpp/src/media/MediaReader.cpp +++ b/webrtc-media/src/main/cpp/src/media/MediaReader.cpp @@ -290,7 +290,17 @@ namespace ffmpeg void MediaReader::BeginBlocking() { timed_out_.store(false); - deadline_us_.store(timeout_us_ > 0 ? av_gettime_relative() + timeout_us_ : 0); + + if (timeout_us_ <= 0) { + deadline_us_.store(0); + return; + } + + // Saturated: a timeout too long to count arrives as INT64_MAX, and the + // sum would wrap around into a deadline that has passed already. + const int64_t now = av_gettime_relative(); + + deadline_us_.store(timeout_us_ > INT64_MAX - now ? INT64_MAX : now + timeout_us_); } void MediaReader::EndBlocking() diff --git a/webrtc-media/src/main/java/dev/onvoid/webrtc/media/player/MediaReader.java b/webrtc-media/src/main/java/dev/onvoid/webrtc/media/player/MediaReader.java index 49e9fcde..5fd2052f 100644 --- a/webrtc-media/src/main/java/dev/onvoid/webrtc/media/player/MediaReader.java +++ b/webrtc-media/src/main/java/dev/onvoid/webrtc/media/player/MediaReader.java @@ -19,7 +19,8 @@ import java.io.IOException; import java.nio.file.Path; import java.time.Duration; -import java.util.Objects; + +import dev.onvoid.webrtc.internal.Timeouts; /** * An opened media source, and what it contains. @@ -86,7 +87,8 @@ public MediaReader(String source) throws IOException { * @param source The path or {@code rtsp://} URL of the source to open. * @param timeout How long opening, and later any single read, seek or * close, may wait on the source. {@link Duration#ZERO} - * waits as long as it takes. + * waits as long as it takes; a positive timeout waits at + * least a microsecond. * * @throws IOException if the source cannot be opened in * time, or holds nothing that can be @@ -94,13 +96,11 @@ public MediaReader(String source) throws IOException { * @throws IllegalArgumentException if the timeout is negative. */ public MediaReader(String source, Duration timeout) throws IOException { - Objects.requireNonNull(timeout, "Timeout is null"); - - if (timeout.isNegative()) { - throw new IllegalArgumentException("Timeout is negative: " + timeout); - } + // Zero is the one timeout without limit: a positive one rounds up to a + // microsecond, and one too long to count is as long as can be counted. + Timeouts.requireNonNegative(timeout, "timeout"); - handle = open(source, toMicros(timeout)); + handle = open(source, Timeouts.toMicros(timeout)); } /** @@ -149,16 +149,6 @@ synchronized long detach() { return detaching; } - private static long toMicros(Duration duration) { - try { - return duration.toNanos() / 1000; - } - catch (ArithmeticException e) { - // Longer than nanoseconds can count, which is as good as never. - return 0; - } - } - private static native long open(String source, long timeoutUs) throws IOException; private static native MediaInfo info(long handle); diff --git a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java index 398550fc..ecf86ef2 100644 --- a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java +++ b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java @@ -25,6 +25,7 @@ import java.net.URL; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -90,6 +91,17 @@ void readsDuration() throws Exception { } } + @Test + void opensWithLongestTimeout() throws Exception { + // Longer than microseconds can count. The deadline saturates, rather + // than wrap around into one that has passed already. + String source = asset().toAbsolutePath().toString(); + + try (MediaReader reader = new MediaReader(source, Duration.ofSeconds(Long.MAX_VALUE))) { + assertTrue(reader.getInfo().hasVideo()); + } + } + @Test void readsAviInfo() throws Exception { try (MediaReader reader = new MediaReader(asset(AVI_ASSET))) { diff --git a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/RtspStreamTest.java b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/RtspStreamTest.java index 1b44d2fc..f813a588 100644 --- a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/RtspStreamTest.java +++ b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/RtspStreamTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -126,6 +127,18 @@ void openTimesOut() throws Exception { } } + @Test + void shortTimeoutTimesOut() throws Exception { + // Under a microsecond, which rounded down would be zero, the timeout + // that waits as long as it takes. + try (TestRtspServer server = new TestRtspServer(TestRtspServer.Behavior.SILENT, 0)) { + IOException e = assertTimeoutPreemptively(Duration.ofSeconds(5), () -> assertThrows(IOException.class, + () -> new MediaReader(server.url(), Duration.ofNanos(1)))); + + assertTrue(e.getMessage().toLowerCase().contains("timed out"), e.getMessage()); + } + } + @Test void errorHidesCredentials() throws Exception { try (TestRtspServer server = new TestRtspServer(TestRtspServer.Behavior.SILENT, 0)) { diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java index f001003f..b3a2aa02 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java @@ -37,6 +37,7 @@ import dev.onvoid.webrtc.RTCSdpType; import dev.onvoid.webrtc.RTCSessionDescription; +import dev.onvoid.webrtc.internal.Timeouts; /** * A WHIP or WHEP session: a peer whose offer and answer went over HTTP, and the @@ -190,7 +191,7 @@ private static CompletableFuture negotiate(WebRtc rtc, Peer peer, URI endpo ScheduledFuture deadline = rtc.timer.schedule(() -> fail.accept(new WebRtcException( "The session with " + endpoint + " was not connected within " + timeout)), - timeout.toMillis(), TimeUnit.MILLISECONDS); + Timeouts.toMillis(timeout), TimeUnit.MILLISECONDS); abandoned.thenRun(() -> { deadline.cancel(false); @@ -356,8 +357,10 @@ private static HttpURLConnection open(URI uri, String method, String bearerToken throws IOException { HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); connection.setRequestMethod(method); - connection.setConnectTimeout((int) Math.min(Integer.MAX_VALUE, timeout.toMillis())); - connection.setReadTimeout((int) Math.min(Integer.MAX_VALUE, timeout.toMillis())); + // Never 0, which the connection takes as no timeout at all. + int millis = (int) Math.min(Integer.MAX_VALUE, Timeouts.toMillis(timeout)); + connection.setConnectTimeout(millis); + connection.setReadTimeout(millis); // A redirect would take the bearer token to wherever it points, so it // fails the request instead. connection.setInstanceFollowRedirects(false); 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 4665021e..e5586867 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/LocalCameraImpl.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/LocalCameraImpl.java @@ -26,6 +26,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import dev.onvoid.webrtc.internal.Timeouts; import dev.onvoid.webrtc.media.video.VideoDevice; import dev.onvoid.webrtc.media.video.VideoTrack; @@ -337,7 +338,7 @@ private CompletableFuture send(List senders) { */ private WebRtcException await(CompletableFuture sent, String failure) { try { - sent.get(rtc.operationTimeout.toMillis(), TimeUnit.MILLISECONDS); + sent.get(Timeouts.toMillis(rtc.operationTimeout), TimeUnit.MILLISECONDS); return null; } diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/PeerImpl.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/PeerImpl.java index c829d873..dbd7d6c8 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/PeerImpl.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/PeerImpl.java @@ -59,6 +59,7 @@ import dev.onvoid.webrtc.RTCSignalingState; import dev.onvoid.webrtc.SetSessionDescriptionObserver; import dev.onvoid.webrtc.client.internal.EncodedFrames; +import dev.onvoid.webrtc.internal.Timeouts; import dev.onvoid.webrtc.media.MediaStream; import dev.onvoid.webrtc.media.MediaStreamTrack; import dev.onvoid.webrtc.media.MediaType; @@ -784,7 +785,7 @@ private ScheduledFuture timeOut(CompletableFuture operation) { return rtc.timer.schedule(() -> control.execute(() -> operation.completeExceptionally(new TimeoutException("WebRTC did not finish an operation of " + name + " within " + rtc.operationTimeout))), - rtc.operationTimeout.toMillis(), TimeUnit.MILLISECONDS); + Timeouts.toMillis(rtc.operationTimeout), TimeUnit.MILLISECONDS); } catch (RejectedExecutionException e) { // The runtime is closing: control tasks queued before still run, @@ -932,7 +933,7 @@ private CompletableFuture waitForGathering() { if (gatheringWaiters.remove(future)) { future.complete(null); } - }), options.gatheringTimeout.toMillis(), TimeUnit.MILLISECONDS); + }), Timeouts.toMillis(options.gatheringTimeout), TimeUnit.MILLISECONDS); } catch (RejectedExecutionException e) { // The runtime is closing and has no timer left to wait with. diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/PeerOptions.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/PeerOptions.java index 6e11791d..6bbeebc6 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/PeerOptions.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/PeerOptions.java @@ -30,6 +30,7 @@ import dev.onvoid.webrtc.RTCConfiguration; import dev.onvoid.webrtc.RTCIceServer; import dev.onvoid.webrtc.RTCIceTransportPolicy; +import dev.onvoid.webrtc.internal.Timeouts; /** * How {@link WebRtc#createPeer} sets up a peer. @@ -254,13 +255,7 @@ public Builder receive(MediaKind... kinds) { * @return this builder. */ public Builder gatheringTimeout(Duration timeout) { - requireNonNull(timeout, "timeout"); - - if (timeout.isNegative() || timeout.isZero()) { - throw new IllegalArgumentException("The timeout must be positive, got " + timeout); - } - - this.gatheringTimeout = timeout; + this.gatheringTimeout = Timeouts.requirePositive(timeout, "gathering timeout"); return this; } diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/WebRtc.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/WebRtc.java index 3524311d..111e3848 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/WebRtc.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/WebRtc.java @@ -40,6 +40,7 @@ import java.util.function.Supplier; import dev.onvoid.webrtc.PeerConnectionFactory; +import dev.onvoid.webrtc.internal.Timeouts; import dev.onvoid.webrtc.media.audio.AudioDeviceModule; import dev.onvoid.webrtc.media.audio.AudioDeviceModuleBase; import dev.onvoid.webrtc.media.audio.AudioProcessing; @@ -300,7 +301,7 @@ public void close() { CompletableFuture future = closeAsync(); try { - future.get(closeTimeout.toMillis(), TimeUnit.MILLISECONDS); + future.get(Timeouts.toMillis(closeTimeout), TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -462,7 +463,7 @@ private void shutdown() { */ private void failPending() { try { - controlPool.awaitTermination(closeTimeout.toMillis(), TimeUnit.MILLISECONDS); + controlPool.awaitTermination(Timeouts.toMillis(closeTimeout), TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -492,7 +493,7 @@ private WebRtcException awaitAll(Supplier>> closes, try { CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) - .get(closeTimeout.toMillis(), TimeUnit.MILLISECONDS); + .get(Timeouts.toMillis(closeTimeout), TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -650,7 +651,7 @@ public Builder errorHandler(Consumer handler) { * @return this builder. */ public Builder operationTimeout(Duration timeout) { - this.operationTimeout = positive(timeout); + this.operationTimeout = Timeouts.requirePositive(timeout, "operation timeout"); return this; } @@ -661,7 +662,7 @@ public Builder operationTimeout(Duration timeout) { * @return this builder. */ public Builder closeTimeout(Duration timeout) { - this.closeTimeout = positive(timeout); + this.closeTimeout = Timeouts.requirePositive(timeout, "close timeout"); return this; } @@ -705,15 +706,5 @@ Builder timer(ScheduledExecutorService timer) { public WebRtc build() { return new WebRtc(this); } - - private static Duration positive(Duration duration) { - requireNonNull(duration, "duration"); - - if (duration.isNegative() || duration.isZero()) { - throw new IllegalArgumentException("The duration must be positive, got " + duration); - } - - return duration; - } } } diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java index 244b2965..703f4ce3 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java @@ -26,6 +26,8 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; +import dev.onvoid.webrtc.internal.Timeouts; + /** * Receives a stream from a server with the WebRTC HTTP Egress Protocol (WHEP), * as media servers such as MediaMTX, Janus and Cloudflare Stream offer it: @@ -136,13 +138,7 @@ public Builder receive(MediaKind... kinds) { * @return this builder. */ public Builder timeout(Duration timeout) { - requireNonNull(timeout, "timeout"); - - if (timeout.isNegative() || timeout.isZero()) { - throw new IllegalArgumentException("The timeout must be positive, got " + timeout); - } - - this.timeout = timeout; + this.timeout = Timeouts.requirePositive(timeout, "timeout"); return this; } diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java index 4561e91e..01c4c548 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java @@ -24,6 +24,8 @@ import java.util.List; import java.util.concurrent.CompletableFuture; +import dev.onvoid.webrtc.internal.Timeouts; + /** * Publishes tracks to a server with the WebRTC HTTP Ingestion Protocol (WHIP, * RFC 9725), as media servers and streaming services take it: @@ -127,13 +129,7 @@ public Builder iceServer(String... urls) { * @return this builder. */ public Builder timeout(Duration timeout) { - requireNonNull(timeout, "timeout"); - - if (timeout.isNegative() || timeout.isZero()) { - throw new IllegalArgumentException("The timeout must be positive, got " + timeout); - } - - this.timeout = timeout; + this.timeout = Timeouts.requirePositive(timeout, "timeout"); return this; } diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/internal/Timeouts.java b/webrtc/src/main/java/dev/onvoid/webrtc/internal/Timeouts.java new file mode 100644 index 00000000..0ced9667 --- /dev/null +++ b/webrtc/src/main/java/dev/onvoid/webrtc/internal/Timeouts.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.internal; + +import static java.util.Objects.requireNonNull; + +import java.time.Duration; + +/** + * Checks timeouts and converts them to the unit they are waited for in. + *

+ * A conversion rounds up and saturates. A positive timeout never becomes zero, + * which many waits, such as those of {@code HttpURLConnection} and of FFmpeg, + * take as no limit at all; and one too long for the unit becomes the longest + * the unit can count, rather than failing or wrapping around. So only a + * timeout given as zero comes out as zero, and whether that means no limit is + * for the caller to say. + * + * @author Alex Andres + */ +public final class Timeouts { + + private Timeouts() { + } + + /** + * @param timeout the timeout to check. + * @param name what the timeout is called in the message. + * + * @return the timeout. + * + * @throws IllegalArgumentException if the timeout is zero or negative. + */ + public static Duration requirePositive(Duration timeout, String name) { + requireNonNull(timeout, name); + + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("The " + name + " must be positive, got " + timeout); + } + + return timeout; + } + + /** + * Checks a timeout for which zero means waiting without limit. + * + * @param timeout the timeout to check. + * @param name what the timeout is called in the message. + * + * @return the timeout. + * + * @throws IllegalArgumentException if the timeout is negative. + */ + public static Duration requireNonNegative(Duration timeout, String name) { + requireNonNull(timeout, name); + + if (timeout.isNegative()) { + throw new IllegalArgumentException("The " + name + " must not be negative, got " + timeout); + } + + return timeout; + } + + /** + * @param timeout a timeout that is not negative. + * + * @return the timeout in milliseconds, rounded up, or {@link + * Long#MAX_VALUE} if it is longer. + */ + public static long toMillis(Duration timeout) { + return ceil(timeout, 1_000_000); + } + + /** + * @param timeout a timeout that is not negative. + * + * @return the timeout in microseconds, rounded up, or {@link + * Long#MAX_VALUE} if it is longer. + */ + public static long toMicros(Duration timeout) { + return ceil(timeout, 1_000); + } + + private static long ceil(Duration timeout, long unitNanos) { + if (timeout.isNegative()) { + throw new IllegalArgumentException("The timeout must not be negative, got " + timeout); + } + + long perSecond = 1_000_000_000 / unitNanos; + long fraction = (timeout.getNano() + unitNanos - 1) / unitNanos; + long seconds = timeout.getSeconds(); + + if (seconds > (Long.MAX_VALUE - fraction) / perSecond) { + return Long.MAX_VALUE; + } + + return seconds * perSecond + fraction; + } + +} diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java index 956e61aa..006d11a2 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java @@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.net.SocketTimeoutException; import java.net.URI; import java.nio.ByteBuffer; import java.time.Duration; @@ -212,6 +213,34 @@ void lateAnswerDeleted() throws Exception { } } + @Test + void shortTimeoutBoundsRequest() throws Exception { + // Under a millisecond, which the connection takes as no timeout at all + // if it is rounded down. The session's own deadline is on a timer that + // does not fire, so only the request's timeout can end the wait. + Duration timeout = Duration.ofNanos(1); + ManualTimer timer = new ManualTimer(); + WebRtc client = clientRuntime(builder -> builder.timer(timer)); + + try (TestHttpEndpoint endpoint = new TestHttpEndpoint(offer -> "", 201)) { + CountDownLatch answer = endpoint.holdAnswers(); + + try { + ExecutionException failed = assertThrows(ExecutionException.class, + () -> await(Whep.from(endpoint.uri()).timeout(timeout).play(client))); + + assertInstanceOf(SocketTimeoutException.class, failed.getCause().getCause(), + failed.getCause()::getMessage); + } + finally { + answer.countDown(); + } + } + finally { + client.close(); + } + } + @Test void cancelledStartDeletes() throws Exception { // The caller cancels while the offer is out. The peer must not go on to diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/ManualTimer.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/ManualTimer.java index c0d77192..02150c81 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/ManualTimer.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/ManualTimer.java @@ -29,11 +29,14 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import dev.onvoid.webrtc.internal.Timeouts; + /** * A timer on which no time passes: what is scheduled fires only when a test * fires it, on the test's thread. Tasks are told apart by the delay they were * scheduled with, so that a timeout fires exactly when the test wants it to, - * however long the work it bounds takes. + * however long the work it bounds takes. A delay counts in milliseconds, + * rounded up as the runtime rounds it. */ final class ManualTimer extends AbstractExecutorService implements ScheduledExecutorService { @@ -53,7 +56,7 @@ int fire(Duration delay) { synchronized (this) { for (Task task : tasks) { - if (task.delayMillis == delay.toMillis() && !task.isDone()) { + if (task.delayMillis == Timeouts.toMillis(delay) && !task.isDone()) { due.add(task); } } @@ -78,7 +81,7 @@ synchronized int scheduled(Duration delay) { int count = 0; for (Task task : tasks) { - if (task.delayMillis == delay.toMillis() && !task.isDone()) { + if (task.delayMillis == Timeouts.toMillis(delay) && !task.isDone()) { count++; } } 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 55d5c747..b6ec4932 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/RuntimeCloseTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/RuntimeCloseTests.java @@ -150,6 +150,23 @@ void closeTimeoutReported() throws Exception { } } + @Test + void closeTimeoutBeyondMillis() throws Exception { + // Longer than milliseconds can count. The waits of close take the + // longest they can count instead of failing on the conversion. + WebRtc runtime = WebRtc.builder() + .audio(AudioMode.HEADLESS) + .errorHandler(errors::add) + .closeTimeout(Duration.ofSeconds(Long.MAX_VALUE)) + .build(); + Peer peer = runtime.createPeer(manualOptions()); + + runtime.close(); + + assertEquals(PeerState.CLOSED, peer.state()); + await(runtime.closeAsync()); + } + @Test void closeOnEventThread() throws Exception { // close() does not wait for the events of the peers it closes, which diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/internal/TimeoutsTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/internal/TimeoutsTests.java new file mode 100644 index 00000000..68fadf44 --- /dev/null +++ b/webrtc/src/test/java/dev/onvoid/webrtc/internal/TimeoutsTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +class TimeoutsTests { + + @Test + void onlyZeroStaysZero() { + assertEquals(0, Timeouts.toMillis(Duration.ZERO)); + assertEquals(0, Timeouts.toMicros(Duration.ZERO)); + + // Rounded down, these would be no limit at all to many waits. + assertEquals(1, Timeouts.toMillis(Duration.ofNanos(1))); + assertEquals(1, Timeouts.toMicros(Duration.ofNanos(1))); + assertEquals(1, Timeouts.toMillis(Duration.ofNanos(999_999))); + } + + @Test + void roundsUp() { + assertEquals(2, Timeouts.toMillis(Duration.ofNanos(1_000_001))); + assertEquals(1_001, Timeouts.toMillis(Duration.ofSeconds(1, 1))); + assertEquals(1_000_001, Timeouts.toMicros(Duration.ofSeconds(1, 1))); + + // Whole units stay as they are. + assertEquals(1_500, Timeouts.toMillis(Duration.ofMillis(1_500))); + assertEquals(1_500, Timeouts.toMicros(Duration.ofNanos(1_500_000))); + } + + @Test + void saturates() { + // Exactly the largest count, and the next nanosecond beyond it. + assertEquals(Long.MAX_VALUE, Timeouts.toMillis(Duration.ofMillis(Long.MAX_VALUE))); + assertEquals(Long.MAX_VALUE, Timeouts.toMillis(Duration.ofMillis(Long.MAX_VALUE).plusNanos(1))); + assertEquals(Long.MAX_VALUE, Timeouts.toMicros(Duration.ofNanos(Long.MAX_VALUE).multipliedBy(1_000))); + + // Longer than nanoseconds can count, which once came out as zero. + assertEquals(Long.MAX_VALUE, Timeouts.toMicros(Duration.ofSeconds(Long.MAX_VALUE, 999_999_999))); + assertEquals(Long.MAX_VALUE, Timeouts.toMillis(Duration.ofSeconds(Long.MAX_VALUE))); + } + + @Test + void rejectsNegative() { + Duration negative = Duration.ofNanos(-1); + + assertThrows(IllegalArgumentException.class, () -> Timeouts.toMillis(negative)); + assertThrows(IllegalArgumentException.class, () -> Timeouts.toMicros(negative)); + assertThrows(IllegalArgumentException.class, () -> Timeouts.requireNonNegative(negative, "timeout")); + assertThrows(IllegalArgumentException.class, () -> Timeouts.requirePositive(negative, "timeout")); + assertThrows(NullPointerException.class, () -> Timeouts.requirePositive(null, "timeout")); + assertThrows(NullPointerException.class, () -> Timeouts.requireNonNegative(null, "timeout")); + } + + @Test + void zeroIsUnlimitedOnlyWhereAllowed() { + assertThrows(IllegalArgumentException.class, () -> Timeouts.requirePositive(Duration.ZERO, "timeout")); + assertSame(Duration.ZERO, Timeouts.requireNonNegative(Duration.ZERO, "timeout")); + + Duration tiny = Duration.ofNanos(1); + + assertSame(tiny, Timeouts.requirePositive(tiny, "timeout")); + assertSame(tiny, Timeouts.requireNonNegative(tiny, "timeout")); + } + +}