From 9980827ddc9ae3dd2abdad365a0e51700ecea16e Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:57:19 +0100 Subject: [PATCH 1/2] fix: settle pending async sleeps on close --- .../kotlin/com/openai/core/DefaultSleeper.kt | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/openai-java-core/src/main/kotlin/com/openai/core/DefaultSleeper.kt b/openai-java-core/src/main/kotlin/com/openai/core/DefaultSleeper.kt index bd8b250b0..910071f5b 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/DefaultSleeper.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/DefaultSleeper.kt @@ -8,21 +8,57 @@ import java.util.concurrent.CompletableFuture class DefaultSleeper : Sleeper { private val timer = Timer("DefaultSleeper", true) + private val lock = Any() + private val pending = mutableSetOf>() + private var closed = false override fun sleep(duration: Duration) = Thread.sleep(duration.toMillis()) override fun sleepAsync(duration: Duration): CompletableFuture { val future = CompletableFuture() - timer.schedule( + val task = object : TimerTask() { override fun run() { - future.complete(null) + if (synchronized(lock) { pending.remove(future) }) { + future.complete(null) + } } - }, - duration.toMillis(), - ) + } + + synchronized(lock) { + if (closed) { + future.cancel(false) + return future + } + + pending.add(future) + future.whenComplete { _, _ -> + synchronized(lock) { pending.remove(future) } + task.cancel() + } + + try { + timer.schedule(task, duration.toMillis()) + } catch (throwable: Throwable) { + pending.remove(future) + throw throwable + } + } + return future } - override fun close() = timer.cancel() + override fun close() { + val pendingFutures = + synchronized(lock) { + if (closed) { + return + } + closed = true + timer.cancel() + pending.toList().also { pending.clear() } + } + + pendingFutures.forEach { it.cancel(false) } + } } From 28db2acd5e78a2c54d44467ea4f26b18ab2ef2a4 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:57:36 +0100 Subject: [PATCH 2/2] test: cover DefaultSleeper close lifecycle --- .../com/openai/core/DefaultSleeperTest.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 openai-java-core/src/test/kotlin/com/openai/core/DefaultSleeperTest.kt diff --git a/openai-java-core/src/test/kotlin/com/openai/core/DefaultSleeperTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/DefaultSleeperTest.kt new file mode 100644 index 000000000..67842c584 --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/core/DefaultSleeperTest.kt @@ -0,0 +1,55 @@ +package com.openai.core + +import java.time.Duration +import java.util.concurrent.TimeUnit +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class DefaultSleeperTest { + + @Test + fun closeCancelsPendingAsyncSleep() { + val sleeper = DefaultSleeper() + val future = sleeper.sleepAsync(Duration.ofHours(1)) + + sleeper.close() + + assertThat(future.isDone).isTrue() + assertThat(future.isCancelled).isTrue() + } + + @Test + fun completedAsyncSleepRemainsCompletedAfterClose() { + val sleeper = DefaultSleeper() + val future = sleeper.sleepAsync(Duration.ZERO) + + future.get(1, TimeUnit.SECONDS) + sleeper.close() + + assertThat(future.isDone).isTrue() + assertThat(future.isCancelled).isFalse() + assertThat(future.isCompletedExceptionally).isFalse() + } + + @Test + fun closeIsIdempotent() { + val sleeper = DefaultSleeper() + val future = sleeper.sleepAsync(Duration.ofHours(1)) + + sleeper.close() + sleeper.close() + + assertThat(future.isCancelled).isTrue() + } + + @Test + fun sleepAsyncAfterCloseReturnsCancelledFuture() { + val sleeper = DefaultSleeper() + sleeper.close() + + val call = runCatching { sleeper.sleepAsync(Duration.ofSeconds(1)) } + + assertThat(call.isSuccess).isTrue() + assertThat(call.getOrThrow().isCancelled).isTrue() + } +}