diff --git a/README.md b/README.md index 947e1e516..c013952c4 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ To use kdriver, add the following to your `build.gradle.kts`: ```kotlin dependencies { - implementation("dev.kdriver:core:0.5.10") + implementation("dev.kdriver:core:0.5.11") } ``` diff --git a/build.gradle.kts b/build.gradle.kts index 21f379b0a..501792337 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { allprojects { group = "dev.kdriver" - version = "0.5.10" + version = "0.5.11" project.ext.set("url", "https://github.com/cdpdriver/kdriver") project.ext.set("license.name", "Apache 2.0") project.ext.set("license.url", "https://www.apache.org/licenses/LICENSE-2.0.txt") diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/Config.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/Config.kt index bf607e29e..0b24b3929 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/browser/Config.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/Config.kt @@ -17,6 +17,7 @@ class Config( val expert: Boolean = Defaults.EXPERT, val browserConnectionTimeout: Long = Defaults.BROWSER_CONNECTION_TIMEOUT, val browserConnectionMaxTries: Int = Defaults.BROWSER_CONNECTION_MAX_TRIES, + val commandTimeout: Long = Defaults.COMMAND_TIMEOUT, val autoDiscoverTargets: Boolean = Defaults.AUTO_DISCOVER_TARGETS, val debugStringLimit: Int = Defaults.DEBUG_STRING_LIMIT, ) { @@ -118,6 +119,13 @@ class Config( const val EXPERT: Boolean = false const val BROWSER_CONNECTION_TIMEOUT: Long = 500 const val BROWSER_CONNECTION_MAX_TRIES: Int = 60 + + /** + * Default maximum time, in milliseconds, to wait for a CDP command response before + * throwing a [dev.kdriver.core.exceptions.CommandTimeoutException]. A value <= 0 disables + * the timeout (wait indefinitely). + */ + const val COMMAND_TIMEOUT: Long = 30_000 const val AUTO_DISCOVER_TARGETS: Boolean = true const val DEBUG_STRING_LIMIT: Int = 128 } diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/ConfigBuilder.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/ConfigBuilder.kt index 0d66bc5b3..f2b518da5 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/browser/ConfigBuilder.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/ConfigBuilder.kt @@ -29,6 +29,7 @@ class ConfigBuilder { var expert: Boolean = Defaults.EXPERT var browserConnectionTimeout: Long = Defaults.BROWSER_CONNECTION_TIMEOUT var browserConnectionMaxTries: Int = Defaults.BROWSER_CONNECTION_MAX_TRIES + var commandTimeout: Long = Defaults.COMMAND_TIMEOUT var autoDiscoverTargets: Boolean = Defaults.AUTO_DISCOVER_TARGETS var debugStringLimit: Int = Defaults.DEBUG_STRING_LIMIT @@ -49,6 +50,7 @@ class ConfigBuilder { expert = expert, browserConnectionTimeout = browserConnectionTimeout, browserConnectionMaxTries = browserConnectionMaxTries, + commandTimeout = commandTimeout, autoDiscoverTargets = autoDiscoverTargets, debugStringLimit = debugStringLimit ) diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt b/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt index f2165af33..9863b60f0 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt @@ -4,6 +4,7 @@ import dev.kdriver.cdp.* import dev.kdriver.cdp.domain.* import dev.kdriver.core.browser.Browser import dev.kdriver.core.browser.Config.Defaults +import dev.kdriver.core.exceptions.CommandTimeoutException import dev.kdriver.core.exceptions.ConnectionClosedException import io.ktor.util.logging.* import kotlinx.coroutines.* @@ -18,6 +19,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonPrimitive import kotlin.reflect.KClass import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds /** * Default implementation of the [Connection] interface. @@ -144,7 +146,13 @@ open class DefaultConnection( transport.send(jsonString) logger.debug("WS > CDP: ${jsonString.take(owner?.config?.debugStringLimit ?: Defaults.DEBUG_STRING_LIMIT)}") - val result = deferred.await() + val timeout = owner?.config?.commandTimeout ?: Defaults.COMMAND_TIMEOUT + // A non-null Message.Response means success; null can only come from the timeout, so + // there's no ambiguity with a legitimate value. A timeout <= 0 waits indefinitely. + val result = + if (timeout > 0) withTimeoutOrNull(timeout.milliseconds) { deferred.await() } + ?: throw CommandTimeoutException(method, requestId, timeout) + else deferred.await() result.error?.throwAsException(method) return result.result } finally { @@ -169,33 +177,33 @@ open class DefaultConnection( override suspend fun wait(t: Long?) { updateTarget() val idleEvent: suspend () -> Boolean = { - withTimeoutOrNull(100) { events.first() } == null + withTimeoutOrNull(100.milliseconds) { events.first() } == null } if (t != null) { val start = Clock.System.now().toEpochMilliseconds() - withTimeoutOrNull(t) { + withTimeoutOrNull(t.milliseconds) { // Wait for idle event or timeout while (true) { if (idleEvent()) break - delay(50) + delay(50.milliseconds) } } // Ensure total wait time is at least t milliseconds val elapsed = Clock.System.now().toEpochMilliseconds() - start - if (elapsed < t) delay(t - elapsed) + if (elapsed < t) delay((t - elapsed).milliseconds) } else { // Wait indefinitely for idle event while (true) { if (idleEvent()) break - delay(50) + delay(50.milliseconds) } } } override suspend fun sleep(t: Long) { updateTarget() - delay(t) + delay(t.milliseconds) } private suspend fun prepareHeadless() { diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/exceptions/CommandTimeoutException.kt b/core/src/commonMain/kotlin/dev/kdriver/core/exceptions/CommandTimeoutException.kt new file mode 100644 index 000000000..53d316e5d --- /dev/null +++ b/core/src/commonMain/kotlin/dev/kdriver/core/exceptions/CommandTimeoutException.kt @@ -0,0 +1,31 @@ +package dev.kdriver.core.exceptions + +import kotlinx.serialization.Serializable + +/** + * Exception thrown when a CDP command does not receive a response within the configured timeout. + * + * The timeout is configurable through the browser configuration + * (`commandTimeout`, see [dev.kdriver.core.browser.Config]). + * + * @property method The CDP method that was being called. + * @property requestId The id of the request that timed out, useful to correlate with sent messages. + * @property timeout The maximum time in milliseconds that was waited for the response. + */ +@Serializable +data class CommandTimeoutException( + /** + * The CDP method that was being called. + */ + val method: String, + /** + * The id of the request that timed out, useful to correlate with sent messages. + */ + val requestId: Long, + /** + * The maximum time in milliseconds that was waited for the response. + */ + val timeout: Long, +) : IllegalStateException( + "time ran out while waiting for a response to command: $method (request id $requestId, timeout ${timeout}ms)" +) diff --git a/core/src/jvmTest/kotlin/dev/kdriver/core/connection/CommandTimeoutTest.kt b/core/src/jvmTest/kotlin/dev/kdriver/core/connection/CommandTimeoutTest.kt new file mode 100644 index 000000000..a4c26f6ee --- /dev/null +++ b/core/src/jvmTest/kotlin/dev/kdriver/core/connection/CommandTimeoutTest.kt @@ -0,0 +1,69 @@ +package dev.kdriver.core.connection + +import dev.kdriver.cdp.CommandMode +import dev.kdriver.core.exceptions.CommandTimeoutException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * A command whose response never arrives must fail with [CommandTimeoutException] once the + * configured command timeout elapses, instead of hanging forever. + */ +class CommandTimeoutTest { + + private class SilentTransport : WebSocketTransport { + private val channel = Channel(Channel.UNLIMITED) + override var isActive: Boolean = false + private set + + override suspend fun connect() { + isActive = true + } + + // Never produces a response: the test relies on the timeout firing. + override suspend fun send(message: String) {} + + override fun incoming(): Flow = channel.receiveAsFlow() + + override suspend fun close() { + isActive = false + channel.close() + } + } + + @Test + fun callCommand_failsWithCommandTimeout_whenNoResponse() = runTest(UnconfinedTestDispatcher()) { + val transport = SilentTransport() + val connection = object : DefaultConnection("ws://stub/devtools/page/stub", this) { + override fun createTransport(): WebSocketTransport = transport + } + + // No owner is attached, so the connection falls back to Config.Defaults.COMMAND_TIMEOUT. + // runTest's virtual clock advances time automatically while the call is parked on + // withTimeout, so the timeout fires without any real waiting. + val outcome = CompletableDeferred() + launch { + try { + connection.callCommand("Some.method", null, CommandMode.ONE_SHOT) + } catch (e: Throwable) { + outcome.complete(e) + } + } + + val error = withTimeout(60_000) { outcome.await() } + val timeoutError = assertIs(error) + assertEquals("Some.method", timeoutError.method) + + // Stop the receive loop so runTest doesn't report it as a leaked coroutine. + connection.close() + } +} diff --git a/docs/home/quickstart.md b/docs/home/quickstart.md index 32eda25df..ff2312ee7 100644 --- a/docs/home/quickstart.md +++ b/docs/home/quickstart.md @@ -12,7 +12,7 @@ To install, add the dependency to your `build.gradle.kts`: ```kotlin dependencies { - implementation("dev.kdriver:core:0.5.10") + implementation("dev.kdriver:core:0.5.11") } ```