Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions core/src/commonMain/kotlin/dev/kdriver/core/browser/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
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,

Check warning on line 20 in core/src/commonMain/kotlin/dev/kdriver/core/browser/Config.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/browser/Config.kt#L20

The property commandTimeout is missing documentation. (detekt.UndocumentedPublicProperty)
val autoDiscoverTargets: Boolean = Defaults.AUTO_DISCOVER_TARGETS,
val debugStringLimit: Int = Defaults.DEBUG_STRING_LIMIT,
) {
Expand Down Expand Up @@ -118,6 +119,13 @@
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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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

Check warning on line 32 in core/src/commonMain/kotlin/dev/kdriver/core/browser/ConfigBuilder.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/browser/ConfigBuilder.kt#L32

The property commandTimeout is missing documentation. (detekt.UndocumentedPublicProperty)
var autoDiscoverTargets: Boolean = Defaults.AUTO_DISCOVER_TARGETS
var debugStringLimit: Int = Defaults.DEBUG_STRING_LIMIT

Expand All @@ -49,6 +50,7 @@
expert = expert,
browserConnectionTimeout = browserConnectionTimeout,
browserConnectionMaxTries = browserConnectionMaxTries,
commandTimeout = commandTimeout,
autoDiscoverTargets = autoDiscoverTargets,
debugStringLimit = debugStringLimit
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)"
)
Original file line number Diff line number Diff line change
@@ -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<String>(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) {}

Check warning on line 33 in core/src/jvmTest/kotlin/dev/kdriver/core/connection/CommandTimeoutTest.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/jvmTest/kotlin/dev/kdriver/core/connection/CommandTimeoutTest.kt#L33

This empty block of code can be removed. (detekt.EmptyFunctionBlock)

override fun incoming(): Flow<String> = 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<Throwable>()
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<CommandTimeoutException>(error)
assertEquals("Some.method", timeoutError.method)

// Stop the receive loop so runTest doesn't report it as a leaked coroutine.
connection.close()
}
}
2 changes: 1 addition & 1 deletion docs/home/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down
Loading