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
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ open class DefaultBrowser(
return try {
info = http.get<ContraDict>("version")
true
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.debug("Could not start: ${e.message}")
false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,14 @@ open class DefaultConnection(
logger.debug("WS < CDP: ${text.take(owner?.config?.debugStringLimit ?: Defaults.DEBUG_STRING_LIMIT)}")
val received = Serialization.json.decodeFromString<Message>(text)
allMessages.emit(received)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.debug("WebSocket exception while receiving message: {}", e)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
e.printStackTrace()
// Handle disconnect, maybe trigger reconnect logic here
Expand Down Expand Up @@ -160,49 +164,59 @@ open class DefaultConnection(
delay(t)
}

private suspend fun prepareHeadless() = runCatching {
if (prepareHeadlessDone) return@runCatching
val response = runtime.evaluate(
Runtime.EvaluateParameter(
expression = "navigator.userAgent",
userGesture = true,
awaitPromise = true,
returnByValue = true,
allowUnsafeEvalBlockedByCSP = true
),
CommandMode.ONE_SHOT
)
response.result.value?.jsonPrimitive?.content?.let { ua ->
network.setUserAgentOverride(
Network.SetUserAgentOverrideParameter(
userAgent = ua.replace("Headless", "")
private suspend fun prepareHeadless() {
try {
if (prepareHeadlessDone) return
val response = runtime.evaluate(
Runtime.EvaluateParameter(
expression = "navigator.userAgent",
userGesture = true,
awaitPromise = true,
returnByValue = true,
allowUnsafeEvalBlockedByCSP = true
),
CommandMode.ONE_SHOT
)
response.result.value?.jsonPrimitive?.content?.let { ua ->
network.setUserAgentOverride(
Network.SetUserAgentOverrideParameter(
userAgent = ua.replace("Headless", "")
),
CommandMode.ONE_SHOT
)
}
prepareHeadlessDone = true
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
}
prepareHeadlessDone = true
}

private suspend fun prepareExpert() = runCatching {
if (prepareExpertDone) return@runCatching
owner?.let {
page.addScriptToEvaluateOnNewDocument(
Page.AddScriptToEvaluateOnNewDocumentParameter(
source = """
Element.prototype._attachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function () {
return this._attachShadow( { mode: "open" } );
};
""".trimIndent()
),
CommandMode.ONE_SHOT
)
page.enable(
Page.EnableParameter(),
CommandMode.ONE_SHOT
)
private suspend fun prepareExpert() {
try {
if (prepareExpertDone) return
owner?.let {
page.addScriptToEvaluateOnNewDocument(
Page.AddScriptToEvaluateOnNewDocumentParameter(
source = """
Element.prototype._attachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function () {
return this._attachShadow( { mode: "open" } );
};
""".trimIndent()
),
CommandMode.ONE_SHOT
)
page.enable(
Page.EnableParameter(),
CommandMode.ONE_SHOT
)
}
prepareExpertDone = true
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
}
prepareExpertDone = true
}

private fun parseWebSocketUrl(url: String): WebSocketInfo {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dev.kdriver.cdp.domain.*
import dev.kdriver.core.exceptions.EvaluateException
import dev.kdriver.core.tab.Tab
import io.ktor.util.logging.*
import kotlinx.coroutines.CancellationException
import kotlinx.io.files.Path
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
Expand Down Expand Up @@ -231,6 +232,8 @@ open class DefaultElement(
if (viewportJson != null) {
Serialization.json.decodeFromJsonElement<dev.kdriver.core.dom.ViewportData>(viewportJson)
} else null
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
Expand Down
5 changes: 5 additions & 0 deletions core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import dev.kdriver.core.network.*
import io.ktor.http.*
import io.ktor.util.logging.*
import io.ktor.utils.io.core.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.io.buffered
Expand Down Expand Up @@ -491,6 +492,8 @@ open class DefaultTab(
// Try to resolve the node if not found in the local tree
val resolvedNode = try {
dom.resolveNode(nodeId = nid)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
Expand All @@ -514,6 +517,8 @@ open class DefaultTab(
// just add the element itself
items.add(elem)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
continue
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package dev.kdriver.core.connection

import dev.kdriver.cdp.CommandMode
import dev.kdriver.cdp.Serialization
import dev.kdriver.cdp.domain.DOM
import dev.kdriver.core.tab.DefaultTab
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.encodeToJsonElement
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.Test
import kotlin.test.assertFailsWith

/**
* Guards against [CancellationException] being swallowed by broad `catch (e: Exception)` /
* `runCatching {}` blocks in suspend code (audit ISSUE-19).
*
* In Kotlin, `CancellationException` is an `Exception`, so a `catch (e: Exception)` (or
* `runCatching`, which catches `Throwable`) silently absorbs it and breaks cooperative
* cancellation. These tests drive the *real* production code through a stubbed [callCommand],
* so no browser is required.
*/
class CancellationPropagationTest {

/**
* A [DefaultTab] whose CDP transport is replaced by a canned map of method -> response.
* A method may instead throw, simulating the coroutine being cancelled mid-call.
*/
private class StubTab(
scope: CoroutineScope,
private val responder: (method: String) -> JsonElement?,
) : DefaultTab(
websocketUrl = "ws://stub/devtools/page/stub",
messageListeningScope = scope,
targetInfo = DOM_TARGET,
) {
override suspend fun callCommand(
method: String,
parameter: JsonElement?,
mode: CommandMode,
): JsonElement? = responder(method)
}

private fun node(nodeId: Int) = DOM.Node(
nodeId = nodeId,
backendNodeId = nodeId,
nodeType = 1,
nodeName = "HTML",
localName = "html",
nodeValue = "",
)

/**
* `findElementsByText` resolves search hits that aren't in the local tree via
* `dom.resolveNode(...)`, wrapped in `catch (_: Exception) { null }` (DefaultTab.kt ~:492).
* If that CDP call is cancelled, the `CancellationException` must propagate, not be turned
* into "node skipped".
*
* RED (unfixed): the cancellation is swallowed and the function returns normally
* (an empty list), so `assertFailsWith` fails because nothing is thrown.
*/
@Test
fun findElementsByText_propagatesCancellation_fromResolveNode() = runTest {
val tab = StubTab(this) { method ->
when (method) {
"DOM.getDocument" ->
Serialization.json.encodeToJsonElement(DOM.GetDocumentReturn(root = node(1)))
"DOM.performSearch" ->
Serialization.json.encodeToJsonElement(
DOM.PerformSearchReturn(searchId = "s1", resultCount = 1)
)
"DOM.getSearchResults" ->
// A nodeId that is NOT present in the (childless) document root, so the
// code falls into the resolveNode branch.
Serialization.json.encodeToJsonElement(
DOM.GetSearchResultsReturn(nodeIds = listOf(999))
)
"DOM.discardSearchResults" -> null
"DOM.resolveNode" -> throw CancellationException("cancelled during resolveNode")
else -> null
}
}

assertFailsWith<CancellationException> {
tab.findElementsByText("anything")
}
}

private companion object {
val DOM_TARGET = dev.kdriver.cdp.domain.Target.TargetInfo(
targetId = "stub",
type = "page",
title = "",
url = "about:blank",
attached = true,
canAccessOpener = false,
)
}
}
Loading