Propagate cancellation out of the tunnel's returned results (#646) (#660)

Twelve broad `catch (Exception)` clauses in :core:tunnel wrapped a suspend
call and converted cancellation into a *returned* value -- a Warning, a
StorageFailed with a rollback, a NetworkError, `false` -- instead of letting
it propagate. `.claude/rules/coroutines.md` requires propagation; the
consequence is work performed after cancellation.

Fixed, lowest frame first:

  MuxDemux.sendPingAwaitPong          `false`  -> propagate
  TunnelClientImpl.runKeepaliveLoop   `false`  -> propagate
  TunnelClientImpl.healthCheck        `false`  -> propagate
  CtLogMonitor.check x2               Warning  -> propagate
  SelfCertVerifier x3                 Warning  -> propagate
  OnboardingFlow (clear())            StorageFailed + rollback -> propagate
  CertProvisioningFlow (clearCerts()) StorageFailed + rollback -> propagate
  RelayApiClient.executeRequest       NetworkError -> propagate
  KtorWebSocketFactory.connect        onFailure/onClosing -> silent teardown

The keepalive stack is the reason for the ordering. #650 added a cancellation
rethrow at WakeReconnectDecider:56 that could not fire, because both frames
below it swallowed cancellation into `false` first. A guard above an
already-swallowing callee is dead code that looks identical in a diff to one
that works, so the swallow is fixed at the lowest frame that performs it and
each guard above is covered by a test that reddens when it alone is reverted.

MuxDemux and KtorWebSocketFactory discriminate on the *job* rather than the
exception type. Not every CancellationException at those frames is the
caller's: MuxDemux.closeAllQuietly deliberately cancels each pending ping
waiter when the transport dies (#230), and that must keep reading as a failed
health check. A blanket rethrow there hangs
MuxDemuxTest.awaitPongReturnsFalseWhenTransportClosedQuietly.
KtorWebSocketFactory still fails the bind on the teardown path so an awaiter
parked in sendBinary is released (#420 finding #4); only the listener
notification is dropped.

Four sites listed in the issue are documented in place rather than guarded,
because cancellation cannot reach them:

  CertProvisioningFlow:76, CertRenewalFlow:105/159/200 -- both guarded calls
    (DeviceKeyManager.getOrCreateKeyPair, CsrGenerator.generate) are declared
    non-suspend on their interfaces, so no implementation can suspend.
  CtLogMonitor:68 -- json.decodeFromString is not a suspend function.
  RelayApiClient:281 -- Ktor 3's SaveBody plugin drains the body inside
    block(), so bodyAsText() reads an in-memory buffer; a body that fails
    mid-read surfaces at the outer catch instead. Pinned by a test.

Two sites are deliberately untouched: SecurityCheckSources.kt:25, whose
Warning reaches a user-visible notification and so needs a ux-changes.md
decision, and TunnelSessionManager, which is #638's subject.

27 tests added, two directions per fixed site. All 12 cancellation-direction
tests are red against 54fdcd46; the 15 ordinary-direction tests pass against
it unchanged.


Closes #646

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYSPuh9bZEJkrzvnEpY1QK
This commit is contained in:
monkopedia-coder 2026-08-31 23:17:13 -04:00 • committed by GitHub
parent 9b50f469d3
commit 8479cf563b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1194 additions and 8 deletions

View file

@ -1,5 +1,6 @@
package com.rousecontext.tunnel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -71,6 +72,11 @@ class CertProvisioningFlow(
// Obtain (or generate) the hardware-backed device keypair and build the CSR.
val fqdn = "*.$subdomain.$baseDomain"
val csrResult = try {
// No cancellation rethrow here on purpose (issue #646):
// `DeviceKeyManager.getOrCreateKeyPair` and `CsrGenerator.generate`
// are both declared non-suspend on their interfaces, so no
// implementation can suspend and no coroutine cancellation can
// reach this clause. A guard here would be dead code.
val keyPair = deviceKeyManager.getOrCreateKeyPair()
csrGenerator.generate(fqdn, keyPair)
} catch (e: Exception) {
@ -94,6 +100,11 @@ class CertProvisioningFlow(
certificateStore.storeClientCertificate(certResponse.data.clientCert)
certificateStore.storeRelayCaCert(certResponse.data.relayCaCert)
CertProvisioningResult.Success
} catch (e: CancellationException) {
// MUST stay above the broad catch (issue #646): a cancelled
// write is not a failed write, and the rollback below is
// destructive work performed after cancellation.
throw e
} catch (e: Exception) {
// Narrow rollback: only clear cert-related state. The onboarding
// state (subdomain, integration secrets) is set earlier by

View file

@ -100,6 +100,8 @@ class CertRenewalFlow(
if (certInspector.inspect(currentCert).isExpired) return RenewalResult.CertExpired
val csrResult = try {
// No cancellation rethrow (issue #646): both calls are declared
// non-suspend on their interfaces, so no cancellation can reach here.
val keyPair = deviceKeyManager.getOrCreateKeyPair()
csrGenerator.generate("*.$subdomain.$baseDomain", keyPair)
} catch (e: Exception) {
@ -154,6 +156,8 @@ class CertRenewalFlow(
val currentCert = certificateStore.getCertificate()
val csrResult = try {
// No cancellation rethrow (issue #646): both calls are declared
// non-suspend on their interfaces, so no cancellation can reach here.
val keyPair = deviceKeyManager.getOrCreateKeyPair()
csrGenerator.generate("*.$subdomain.$baseDomain", keyPair)
} catch (e: Exception) {
@ -195,6 +199,8 @@ class CertRenewalFlow(
val currentCert = certificateStore.getCertificate()
val csrResult = try {
// No cancellation rethrow (issue #646): both calls are declared
// non-suspend on their interfaces, so no cancellation can reach here.
val keyPair = deviceKeyManager.getOrCreateKeyPair()
csrGenerator.generate("*.$subdomain.$baseDomain", keyPair)
} catch (e: Exception) {

View file

@ -1,5 +1,6 @@
package com.rousecontext.tunnel
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -22,6 +23,12 @@ interface CtLogFetcher {
*
* Network errors or malformed responses produce [SecurityCheckResult.Warning]
* since the check cannot be completed.
*
* A **cancelled** check is not a degraded one and must not produce a Warning:
* the check was never run, so there is nothing to report. Both suspend calls
* below rethrow cancellation ahead of their broad catch (issue #646). The layer
* directly beneath already does this -- see [CompositeCtLogFetcher] -- and
* swallowing it one frame up would defeat that entirely.
*/
class CtLogMonitor(
private val certificateStore: CertificateStore,
@ -39,6 +46,11 @@ class CtLogMonitor(
suspend fun check(): SecurityCheckResult {
val subdomain = try {
certificateStore.getSubdomain()
} catch (e: CancellationException) {
// MUST stay above the broad catch: CancellationException is an
// Exception on the JVM, so catching Exception alone would refile a
// scope teardown as a user-facing security warning.
throw e
} catch (e: Exception) {
return SecurityCheckResult.Warning(
"Could not retrieve subdomain: ${e.message}"
@ -56,6 +68,10 @@ class CtLogMonitor(
val responseBody: String
try {
responseBody = ctLogFetcher.fetch(domain)
} catch (e: CancellationException) {
// As above. CompositeCtLogFetcher rethrows cancellation from both
// of its own catches; this is the frame that used to swallow it.
throw e
} catch (e: Exception) {
return SecurityCheckResult.Warning(
"Could not reach CT log service: ${e.message}"
@ -66,6 +82,9 @@ class CtLogMonitor(
try {
entries = json.decodeFromString<List<CtLogEntry>>(responseBody)
} catch (e: Exception) {
// No cancellation rethrow here on purpose: `decodeFromString` is not
// a suspend function, so no coroutine cancellation can reach this
// clause. A guard here would be dead code (issue #646).
return SecurityCheckResult.Warning(
"Malformed CT log response: ${e.message}"
)

View file

@ -7,8 +7,11 @@ import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readBytes
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
@ -42,21 +45,57 @@ class KtorWebSocketFactory(
}
// Normal close
listener.onClosing(1000, "WebSocket closed")
} catch (e: Exception) {
if (handle.isBound()) {
listener.onClosing(1006, e.message ?: "Connection lost")
} catch (e: CancellationException) {
// MUST stay above the broad catch (issue #646): a torn-down
// scope is not a transport failure, so the listener is told
// nothing -- the mirror of what #644 fixed one layer up in
// TunnelClientImpl.connect.
//
// Gated on the job, not on the exception type. A cancellation
// that arrives while this coroutine is still active did not come
// from our teardown -- it came from the session -- and that IS a
// transport failure the listener has to hear about, or the tunnel
// sits CONNECTED over a dead socket. Only our own cancellation
// takes the silent path.
if (currentCoroutineContext().isActive) {
reportFailure(handle, listener, e)
} else {
// Awaiters of sendBinary/sendText would otherwise hang
// forever waiting for sessionDeferred. Propagate the failure
// so they observe the connect error.
handle.failBind(e)
listener.onFailure(e)
// The bind is still failed: that is the whole point of
// failBind (#420 finding #4), since an awaiter parked in
// sendBinary has no other way to learn the session will
// never arrive. Completing it with the cancellation releases
// the awaiter and tells it the truth.
if (!handle.isBound()) handle.failBind(e)
throw e
}
} catch (e: Exception) {
reportFailure(handle, listener, e)
}
}
return handle
}
/**
* Tell [listener] the session is gone. Split out so the cancellation clause
* can reuse it for a session-side cancellation without duplicating the
* bound / not-yet-bound distinction.
*/
private fun reportFailure(
handle: KtorWebSocketHandle,
listener: WebSocketListener,
e: Throwable
) {
if (handle.isBound()) {
listener.onClosing(1006, e.message ?: "Connection lost")
} else {
// Awaiters of sendBinary/sendText would otherwise hang forever
// waiting for sessionDeferred. Propagate the failure so they
// observe the connect error.
handle.failBind(e)
listener.onFailure(e)
}
}
}
private class KtorWebSocketHandle : WebSocketHandle {

View file

@ -1,7 +1,10 @@
package com.rousecontext.tunnel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -155,6 +158,19 @@ class MuxDemux(private val log: (LogLevel, String) -> Unit = { _, _ -> }) {
*
* The optional [nonce] exists for tests; production code should leave it
* unset so a fresh random nonce is generated.
*
* The **caller's** cancellation propagates rather than reading as a missed
* Pong (issue #646). This is the lowest frame in the keepalive stack, so it
* is the one that has to get this right: the guards above it in
* `TunnelClientImpl.runKeepaliveLoop` / `healthCheck`, and the one #650
* added in `WakeReconnectDecider`, are all unreachable while this frame
* converts cancellation into `false`.
*
* Not every `CancellationException` here is the caller's, which is why the
* catch below tests the job rather than the exception type. [closeAllQuietly]
* *cancels* each pending ping waiter when the transport dies (issue #230),
* and that must still surface as a failed health check -- a dead relay is
* not a cancelled caller. `ensureActive` is what separates the two.
*/
suspend fun sendPingAwaitPong(timeoutMillis: Long, nonce: ULong = randomNonce()): Boolean {
val send = onOutgoingFrame ?: return false
@ -163,6 +179,16 @@ class MuxDemux(private val log: (LogLevel, String) -> Unit = { _, _ -> }) {
return try {
send(MuxFrame.Ping(nonce))
withTimeoutOrNull(timeoutMillis) { waiter.await() } != null
} catch (_: CancellationException) {
// MUST stay above the broad catch: CancellationException is an
// Exception on the JVM, so catching Exception alone turned a scope
// teardown into a missed Pong (#646). But `closeAllQuietly` cancels
// the waiter on a dead transport (#230), which must still read as a
// missed Pong -- so propagate only when it is *this* coroutine that
// was cancelled. `withTimeoutOrNull` handles its own timeout by
// returning null, so it never lands here.
currentCoroutineContext().ensureActive()
false
} catch (_: Exception) {
false
} finally {

View file

@ -1,5 +1,7 @@
package com.rousecontext.tunnel
import kotlinx.coroutines.CancellationException
/**
* Orchestrates first-run device onboarding: registers the device with the relay
* to get a subdomain assignment, then provisions the ACME server + relay CA
@ -114,6 +116,13 @@ class OnboardingFlow(
certificateStore.storeIntegrationSecrets(registerData.secrets)
}
RegisterOutcome.Success(registerData.subdomain)
} catch (e: CancellationException) {
// MUST stay above the broad catch (issue #646). A cancelled write is
// not a failed write, and the rollback below is real destructive
// work performed after cancellation: `clear()` drops the subdomain
// the relay has already assigned, leaving the device registered
// upstream and blank locally.
throw e
} catch (e: Exception) {
certificateStore.clear()
RegisterOutcome.Failure(OnboardingResult.StorageFailed(e))

View file

@ -10,6 +10,7 @@ import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -279,6 +280,13 @@ class RelayApiClient(
val body = try {
response.bodyAsText()
} catch (_: Exception) {
// No cancellation rethrow (issue #646): Ktor's SaveBody
// plugin drains the response inside `block()` above, so by
// the time this runs it reads an in-memory buffer with no
// cancellable suspension point, and a body that genuinely
// fails mid-read surfaces from `block()` instead. Measured
// by RelayApiClientCancellationTest; a guard here would be
// dead code.
""
}
RelayApiResult.Error(
@ -287,6 +295,13 @@ class RelayApiClient(
)
}
}
} catch (e: CancellationException) {
// MUST stay above the broad catch (issue #646): CancellationException
// is an Exception on the JVM, and a returned NetworkError reaches
// OnboardingFlow, which folds it into a terminal OnboardingResult with
// no suspension point in between -- so a cancelled onboarding would
// hand the UI a "registration failed" outcome instead of unwinding.
throw e
} catch (e: Exception) {
RelayApiResult.NetworkError(cause = e)
}

View file

@ -1,6 +1,7 @@
package com.rousecontext.tunnel
import java.security.MessageDigest
import kotlinx.coroutines.CancellationException
/**
* Verifies that a presented certificate matches the fingerprints
@ -30,6 +31,15 @@ import java.security.MessageDigest
* and we MUST Alert rather than silently re-trusting whatever cert is
* presented. The backfill is therefore a strictly one-time-per-install
* migration, not a permanent self-heal.
*
* Cancellation (issue #646): every store call below is `suspend`, and each of
* the three broad catches rethrows cancellation ahead of itself. A cancelled
* verify is a check that never ran, not a degraded one, so it must not surface
* a [SecurityCheckResult.Warning]. The backfill catch matters most: the
* fingerprint write and the marker write are deliberately ordered so a failed
* fingerprint write cannot claim bootstrap completed, and swallowing a
* cancellation between them would report exactly the half-written state that
* ordering exists to prevent.
*/
class SelfCertVerifier(private val certificateStore: CertificateStore) {
@ -45,6 +55,10 @@ class SelfCertVerifier(private val certificateStore: CertificateStore) {
val knownFingerprints: Set<String>
try {
knownFingerprints = certificateStore.getKnownFingerprints()
} catch (e: CancellationException) {
// MUST stay above the broad catch: CancellationException is an
// Exception on the JVM.
throw e
} catch (e: Exception) {
return SecurityCheckResult.Warning(
"Could not retrieve known fingerprints: ${e.message}"
@ -70,6 +84,8 @@ class SelfCertVerifier(private val certificateStore: CertificateStore) {
private suspend fun handleEmptyFingerprints(leafFingerprint: String): SecurityCheckResult {
val markerPresent = try {
certificateStore.hasFingerprintBootstrapMarker()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
return SecurityCheckResult.Warning(
"Could not check fingerprint bootstrap marker: ${e.message}"
@ -95,6 +111,8 @@ class SelfCertVerifier(private val certificateStore: CertificateStore) {
// surface the corruption path (empty set + marker present) incorrectly.
certificateStore.writeFingerprintBootstrapMarker()
SecurityCheckResult.Verified
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
SecurityCheckResult.Warning(
"Could not backfill missing fingerprint: ${e.message}"

View file

@ -399,6 +399,13 @@ class TunnelClientImpl(
if (muxDemux !== demux) return
val alive = try {
demux.sendPingAwaitPong(timeoutMillis = keepaliveTimeoutMillis)
} catch (e: CancellationException) {
// MUST stay above the broad catch (issue #646). Counting a
// cancelled Ping as a miss lets an ordinary teardown accumulate
// into a synthesised ConnectionFailed("Keepalive Pings missed
// N times"). Only reachable because MuxDemux.sendPingAwaitPong
// now propagates instead of returning false.
throw e
} catch (_: Exception) {
false
}
@ -426,10 +433,22 @@ class TunnelClientImpl(
}
}
/**
* A cancelled health check propagates rather than reporting a dead tunnel
* (issue #646). `WakeReconnectDecider` reacts to `false` by calling
* `disconnect()` and `connect()` -- work performed after cancellation --
* and the cancellation rethrow #650 added there could not fire while this
* frame (and `MuxDemux.sendPingAwaitPong` below it) swallowed cancellation
* into `false` first.
*/
override suspend fun healthCheck(timeout: Duration): Boolean {
val demux = muxDemux ?: return false
return try {
demux.sendPingAwaitPong(timeoutMillis = timeout.inWholeMilliseconds)
} catch (e: CancellationException) {
// MUST stay above the broad catch: CancellationException is an
// Exception on the JVM.
throw e
} catch (_: Exception) {
false
}

View file

@ -0,0 +1,127 @@
package com.rousecontext.tunnel
import java.io.IOException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
/**
* What [CtLogMonitor.check] does with a **cancellation** raised by one of the
* suspend calls it guards (#646).
*
* Two of the three broad `catch (Exception)` clauses in `check` sit around a
* suspend call, so a scope teardown mid-check came back as
* [SecurityCheckResult.Warning] -- a security *finding* about a check that was
* simply never run. `.claude/rules/coroutines.md` requires cancellation to
* propagate.
*
* The layer directly below already gets this right: `CompositeCtLogFetcher`
* rethrows cancellation at both of its catches. Swallowing it one frame up
* defeats that entirely.
*
* No test here cancels the surrounding scope. A cancelled scope would make the
* assertion ambiguous -- the test's own cancellation is indistinguishable from
* the one under test -- so each collaborator raises cancellation directly while
* the job stays active. That is also the shape a stray
* `java.util.concurrent.CancellationException` takes (a cancelled `Future`
* inside a store implementation), which is the one vector that survives the
* three suspend `SecurityCheckPreferences` calls sitting between `check()`
* returning and `SecurityCheckWorker` reaching `notifier.postInfo`.
*
* Each site is pinned in both directions: cancellation must come back as
* cancellation, and an ordinary I/O failure must still come back as a Warning.
*
* The third catch (`json.decodeFromString`) is deliberately unguarded and has
* no test here: `decodeFromString` is not a suspend function, so no coroutine
* cancellation can reach that clause. A rethrow there would be dead code of
* exactly the kind #646's comments warn about.
*/
class CtLogMonitorCancellationTest {
@Test
fun `cancellation from getSubdomain propagates instead of becoming a Warning`() = runBlocking {
val monitor = monitorOver(
CancellingSubdomainStore(CancellationException("worker scope torn down"))
)
val thrown = assertFailsWith<Throwable> { monitor.check() }
assertTrue(
thrown is CancellationException,
"a cancelled CT check must propagate cancellation, got " +
"${thrown.javaClass.name}: ${thrown.message}"
)
}
@Test
fun `an IO failure from getSubdomain is still reported as a Warning`(): Unit = runBlocking {
val monitor = monitorOver(CancellingSubdomainStore(IOException("keystore unavailable")))
val result = monitor.check()
assertIs<SecurityCheckResult.Warning>(result)
assertEquals(
"Could not retrieve subdomain: keystore unavailable",
result.reason,
"the ordinary failure path must be unchanged"
)
}
@Test
fun `cancellation from the CT log fetcher propagates instead of becoming a Warning`() =
runBlocking {
val monitor = monitorOver(
SecurityCertificateStore(subdomain = "abc123"),
ThrowingCtLogFetcher(CancellationException("worker scope torn down"))
)
val thrown = assertFailsWith<Throwable> { monitor.check() }
assertTrue(
thrown is CancellationException,
"a cancelled CT fetch must propagate cancellation, got " +
"${thrown.javaClass.name}: ${thrown.message}"
)
}
@Test
fun `an IO failure from the CT log fetcher is still reported as a Warning`(): Unit =
runBlocking {
val monitor = monitorOver(
SecurityCertificateStore(subdomain = "abc123"),
ThrowingCtLogFetcher(IOException("crt.sh unreachable"))
)
val result = monitor.check()
assertIs<SecurityCheckResult.Warning>(result)
assertEquals(
"Could not reach CT log service: crt.sh unreachable",
result.reason,
"the ordinary failure path must be unchanged"
)
}
private fun monitorOver(store: CertificateStore, fetcher: CtLogFetcher = FakeCtLogFetcher()) =
CtLogMonitor(
certificateStore = store,
ctLogFetcher = fetcher,
expectedIssuers = setOf("C=US, O=Google Trust Services, CN=WE1"),
baseDomain = "rousecontext.com"
)
/** Store whose `getSubdomain` raises whatever the test hands it. */
private class CancellingSubdomainStore(private val failure: Throwable) :
CertificateStore by SecurityCertificateStore() {
override suspend fun getSubdomain(): String? = throw failure
}
/** Fetcher that raises whatever the test hands it. */
private class ThrowingCtLogFetcher(private val failure: Throwable) : CtLogFetcher {
override suspend fun fetch(domain: String): String = throw failure
}
}

View file

@ -0,0 +1,276 @@
package com.rousecontext.tunnel
import java.io.IOException
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Timeout
/**
* What the keepalive path does when the **caller** is cancelled while a Ping is
* in flight (#646).
*
* Three broad catches sit on top of each other here, and the order they are
* fixed in is the whole point:
*
* ```
* MuxDemux.sendPingAwaitPong catch (_: Exception) { false }
* TunnelClientImpl.runKeepaliveLoop catch (_: Exception) { false } -> counts a miss
* TunnelClientImpl.healthCheck catch (_: Exception) { false } -> "tunnel is dead"
* ```
*
* #650 added a cancellation rethrow to `WakeReconnectDecider`, above
* `healthCheck`. It could not fire: both frames below it converted cancellation
* into `false` first. **A guard's presence is not evidence of its
* reachability**, and the swallow has to be fixed at the lowest frame that
* performs it or every guard above stays dead. So the [MuxDemux] test here is
* the load-bearing one; the two [TunnelClientImpl] tests only become meaningful
* once it passes.
*
* The cost of getting this wrong is concrete: a cancelled Ping increments the
* miss counter until [TunnelClientImpl] synthesises
* `ConnectionFailed("Keepalive Pings missed N times")` out of an ordinary
* teardown, and a cancelled `healthCheck` reads as a dead tunnel, after which
* the wake path proceeds to `disconnect()` and `connect()` -- work performed
* after cancellation.
*
* ## Why these tests cancel a job rather than throwing a cancellation
*
* [MuxDemux.sendPingAwaitPong] discriminates on the *job*, not on the exception
* type, and it has to: [MuxDemux.closeAllQuietly] **cancels** every pending ping
* waiter when the transport dies (issue #230), and that must keep reading as a
* failed health check -- a dead relay is not a cancelled caller. A blanket
* rethrow there hangs `MuxDemuxTest.awaitPongReturnsFalseWhenTransportClosedQuietly`.
* So the cancellation tests below cancel the calling job, which is the real
* production vector, and the discriminator itself is pinned by the
* "transport torn down under an active caller" test.
*
* Cancelling the caller is not ambiguous here the way it would be for a thrown
* result: the defect returns `Result.success(false)` and the fix returns a
* failure, so the two are distinguishable without relying on which cancellation
* arrived.
*/
@Timeout(value = 60, unit = TimeUnit.SECONDS, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
class KeepaliveCancellationTest {
@Test
fun `cancelling the caller propagates out of sendPingAwaitPong`() = runBlocking {
val demux = MuxDemux()
demux.onOutgoingFrame = { awaitCancellation() }
var outcome: Result<Boolean>? = null
val pinger = launch {
outcome = runCatching { demux.sendPingAwaitPong(timeoutMillis = LONG_TIMEOUT_MS) }
}
delay(SETTLE_MS)
pinger.cancel()
pinger.join()
val result = assertNotNull(outcome, "the ping coroutine never recorded an outcome")
assertTrue(
result.isFailure,
"a cancelled Ping must propagate cancellation rather than read as a missed " +
"Pong, but came back as $result"
)
assertTrue(
result.exceptionOrNull() is CancellationException,
"expected cancellation, got ${result.exceptionOrNull()}"
)
}
@Test
fun `an IO failure from the outgoing frame sink is still a missed Pong`(): Unit = runBlocking {
val demux = MuxDemux()
demux.onOutgoingFrame = { throw IOException("socket closed") }
assertFalse(
demux.sendPingAwaitPong(timeoutMillis = LONG_TIMEOUT_MS),
"a genuinely broken transport must still come back as a missed Pong"
)
}
@Test
fun `a transport torn down under an active caller is still a missed Pong`() = runBlocking {
// Issue #230's property, re-pinned here because the cancellation guard
// added for #646 is the code that has to preserve it: closeAllQuietly
// cancels the waiter, and the caller is NOT cancelled, so this must stay
// `false` rather than propagating.
val demux = MuxDemux()
demux.onOutgoingFrame = { /* swallow: the relay is dead */ }
var outcome: Result<Boolean>? = null
val pinger = launch {
outcome = runCatching { demux.sendPingAwaitPong(timeoutMillis = LONG_TIMEOUT_MS) }
}
delay(SETTLE_MS)
demux.closeAllQuietly()
pinger.join()
assertEquals(
Result.success(false),
outcome,
"a dead relay is not a cancelled caller: this must stay a failed health check"
)
}
@Test
fun `cancelling the caller propagates out of healthCheck`() = runBlocking {
val client = TunnelClientImpl(
scope = this,
webSocketFactory = HangingSendFactory(),
keepaliveIntervalMillis = QUIET_KEEPALIVE_MS
)
client.connect(URL)
var outcome: Result<Boolean>? = null
val checker = launch { outcome = runCatching { client.healthCheck(LONG_TIMEOUT) } }
delay(SETTLE_MS)
checker.cancel()
checker.join()
val result = assertNotNull(outcome, "the health check never recorded an outcome")
assertTrue(
result.isFailure,
"a cancelled health check must propagate cancellation so the guard in " +
"WakeReconnectDecider can fire, but came back as $result"
)
assertTrue(
result.exceptionOrNull() is CancellationException,
"expected cancellation, got ${result.exceptionOrNull()}"
)
coroutineContext.cancelChildren()
}
@Test
fun `a broken transport still makes healthCheck report false`(): Unit = runBlocking {
val client = TunnelClientImpl(
scope = this,
webSocketFactory = FailingSendFactory(IOException("socket closed")),
keepaliveIntervalMillis = QUIET_KEEPALIVE_MS
)
client.connect(URL)
assertFalse(
client.healthCheck(SHORT_TIMEOUT),
"a genuinely broken transport must still report the tunnel as dead"
)
coroutineContext.cancelChildren()
}
@Test
fun `tearing the tunnel scope down mid-Ping is not counted as a missed Ping`() = runBlocking {
val logs = CopyOnWriteArrayList<String>()
// The client's own scope, so it can be torn down while the test's scope
// stays alive to make the assertion.
val tunnelScope = CoroutineScope(coroutineContext + Job(coroutineContext[Job]))
val client = TunnelClientImpl(
scope = tunnelScope,
webSocketFactory = HangingSendFactory(),
log = { _, message -> logs += message },
keepaliveIntervalMillis = BRISK_KEEPALIVE_MS,
keepaliveMaxMisses = 5
)
client.connect(URL)
// The loop is now parked inside the Ping, which is where a service
// shutdown finds it.
delay(SETTLE_MS)
tunnelScope.cancel()
delay(SETTLE_MS)
assertTrue(
logs.none { it.contains("keepalive Ping missed") },
"a cancelled Ping is teardown, not a missed Pong, but the loop logged: $logs"
)
}
@Test
fun `a broken transport still trips the keepalive miss counter`() = runBlocking {
val errors = mutableListOf<TunnelError>()
val client = TunnelClientImpl(
scope = this,
webSocketFactory = FailingSendFactory(IOException("socket closed")),
keepaliveIntervalMillis = BRISK_KEEPALIVE_MS,
keepaliveMaxMisses = 1
)
val collector = launch { client.errors.collect { errors += it } }
delay(SETTLE_MS)
client.connect(URL)
delay(SETTLE_MS)
assertTrue(
errors.any {
it is TunnelError.ConnectionFailed && it.message?.contains("Keepalive") == true
},
"the ordinary dead-tunnel path must be unchanged, but errors held $errors"
)
assertEquals(TunnelState.DISCONNECTED, client.state.value)
collector.cancel()
coroutineContext.cancelChildren()
}
/**
* Reports the handshake as open immediately, then never completes a send:
* exactly where a scope teardown finds the keepalive.
*/
private class HangingSendFactory : WebSocketFactory {
override fun connect(url: String, listener: WebSocketListener): WebSocketHandle {
listener.onOpen()
return object : WebSocketHandle {
override suspend fun sendBinary(data: ByteArray): Boolean = awaitCancellation()
override suspend fun sendText(text: String): Boolean = awaitCancellation()
override suspend fun close(code: Int, reason: String) = Unit
}
}
}
/** Reports the handshake as open, then fails every send outright. */
private class FailingSendFactory(private val failure: Throwable) : WebSocketFactory {
override fun connect(url: String, listener: WebSocketListener): WebSocketHandle {
listener.onOpen()
return object : WebSocketHandle {
override suspend fun sendBinary(data: ByteArray): Boolean = throw failure
override suspend fun sendText(text: String): Boolean = throw failure
override suspend fun close(code: Int, reason: String) = Unit
}
}
}
private companion object {
const val URL = "ws://127.0.0.1:1/tunnel"
/** Longer than any test's lifetime: the Pong wait must not time out. */
const val LONG_TIMEOUT_MS = 600_000L
val LONG_TIMEOUT = 600.seconds
val SHORT_TIMEOUT = 1.seconds
/** Far beyond any test's lifetime: the keepalive loop must not interfere. */
const val QUIET_KEEPALIVE_MS = 600_000L
const val BRISK_KEEPALIVE_MS = 30L
const val SETTLE_MS = 250L
}
}

View file

@ -0,0 +1,148 @@
package com.rousecontext.tunnel
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.plugins.websocket.WebSockets
import java.net.ServerSocket
import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.jupiter.api.Timeout
/**
* What [KtorWebSocketFactory] does when its scope is torn down while the
* WebSocket handshake is still in flight (#646).
*
* The broad `catch (Exception)` around the whole session lifecycle reported a
* cancelled connect to the listener as `onFailure` / `onClosing(1006, …)` --
* a transport *failure* synthesised out of a scope teardown, which is the
* mirror image of the defect #644 fixed one layer up in
* `TunnelClientImpl.connect`.
*
* The handle's `sessionDeferred` is still completed on the cancellation path.
* That is the whole point of `failBind` (#420 finding #4): an awaiter parked in
* `sendBinary` has no other way to learn the session will never arrive.
* Completing it *with the cancellation* both unblocks the awaiter and tells it
* the truth, where reporting `onFailure` did neither. Only the listener
* notification is dropped.
*
* The guard is gated on the job rather than on the exception type. A
* cancellation raised by the *session* while this coroutine is still active is
* a transport failure the listener must still hear about -- otherwise the
* tunnel sits CONNECTED over a dead socket, the #558 symptom. The second test
* pins that branch so the discriminator is not just asserted in a comment.
*
* The peer for the teardown test is a bound-but-never-accepted [ServerSocket]: the TCP
* connection completes out of the accept backlog and the HTTP upgrade response
* never arrives, so the launched coroutine is reliably parked inside the `try`
* when the cancellation lands. [KtorWebSocketFactoryFailBindTest] pins the
* ordinary connect-failure direction against a dead port.
*/
@Timeout(value = 60, unit = TimeUnit.SECONDS, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
class KtorWebSocketFactoryCancellationTest {
@Test
fun `a cancelled handshake is not reported to the listener as a failure`() = runBlocking {
val peer = ServerSocket(0)
try {
val listener = RecordingListener()
val inner = CoroutineScope(coroutineContext + Job(coroutineContext[Job]))
val factory = KtorWebSocketFactory(scope = inner)
factory.connect("ws://127.0.0.1:${peer.localPort}/tunnel", listener)
// Long enough that the upgrade request is certainly sent and the
// coroutine is parked waiting for a response that never comes.
delay(HANDSHAKE_SETTLE_MS)
assertTrue(
listener.failures.isEmpty() && listener.closings.isEmpty(),
"precondition: the handshake must still be in flight, but the listener " +
"already saw failures=${listener.failures} closings=${listener.closings}"
)
inner.cancel()
delay(TEARDOWN_SETTLE_MS)
assertTrue(
listener.failures.isEmpty(),
"a cancelled handshake is teardown, not a transport failure, but the " +
"listener was told: ${listener.failures}"
)
assertTrue(
listener.closings.isEmpty(),
"a cancelled handshake must not synthesise a close, but got " +
"${listener.closings}"
)
} finally {
peer.close()
coroutineContext.cancelChildren()
}
}
@Test
fun `a cancellation from the session is still reported to the listener`() = runBlocking {
// The caller is not cancelled: the session itself raises cancellation.
// That is a broken transport, not teardown, so the listener must be told
// -- and the awaiter must still be released.
val listener = RecordingListener()
val factory = KtorWebSocketFactory(
scope = this,
httpClient = HttpClient(MockEngine { throw CancellationException("session died") }) {
install(WebSockets)
}
)
factory.connect("ws://relay.test/tunnel", listener)
withTimeout(FAILURE_TIMEOUT_MS) { listener.firstFailure.await() }
coroutineContext.cancelChildren()
}
@Test
fun `a genuine connect failure is still reported to the listener`() = runBlocking {
val listener = RecordingListener()
// Nothing is listening on port 1, so webSocketSession() throws before
// the session is bound -- the path KtorWebSocketFactoryFailBindTest
// covers from the awaiter's side.
KtorWebSocketFactory(scope = this).connect("ws://127.0.0.1:1/tunnel", listener)
withTimeout(FAILURE_TIMEOUT_MS) { listener.firstFailure.await() }
coroutineContext.cancelChildren()
}
private class RecordingListener : WebSocketListener {
val failures = mutableListOf<Throwable>()
val closings = mutableListOf<Pair<Int, String>>()
val firstFailure = CompletableDeferred<Throwable>()
override fun onOpen() = Unit
override fun onBinaryMessage(data: ByteArray) = Unit
override fun onClosing(code: Int, reason: String) {
closings += code to reason
}
override fun onFailure(error: Throwable) {
failures += error
firstFailure.complete(error)
}
}
private companion object {
const val HANDSHAKE_SETTLE_MS = 500L
const val TEARDOWN_SETTLE_MS = 500L
const val FAILURE_TIMEOUT_MS = 5_000L
}
}

View file

@ -0,0 +1,119 @@
package com.rousecontext.tunnel
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.ByteChannel
import java.io.IOException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
/**
* What `RelayApiClient.executeRequest` does with a **cancellation** raised by
* the request it guards (#646).
*
* ```
* try {
* val response = block() // <- the request itself
* ...
* val body = try { response.bodyAsText() } // <- see below
* catch (_: Exception) { "" }
* } catch (e: Exception) { RelayApiResult.NetworkError(cause = e) }
* ```
*
* The outer catch turned a cancelled request into a returned
* [RelayApiResult.NetworkError], and this one reaches a caller with nothing
* suspending in between. `OnboardingFlow.registerAndPersist` maps a
* `NetworkError` to an [OnboardingResult] and `execute` folds and returns it
* without another suspension point, so a cancelled onboarding hands the UI a
* concrete "registration failed" outcome instead of unwinding -- work performed
* after cancellation, which `.claude/rules/coroutines.md` forbids.
*
* ## Why the inner catch is deliberately left alone
*
* #646 lists `bodyAsText()` as a second site. It is not reachable by
* cancellation, and the last test here is the measurement rather than an
* argument: Ktor 3's `SaveBody` plugin (installed by default) drains the
* response body inside `block()`, so by the time `bodyAsText()` runs it is
* reading an in-memory buffer with no cancellable suspension point, and a body
* that genuinely fails mid-read surfaces from `block()` as a
* `ClosedByteChannelException` at the **outer** catch. A rethrow at the inner
* catch would be exactly the dead guard #646's own comments warn about --
* `WakeReconnectDecider:56` in the same family -- so it is documented in place
* instead of added.
*/
class RelayApiClientCancellationTest {
@Test
fun `cancellation raised by the request propagates instead of becoming NetworkError`() =
runBlocking {
val client = clientOver(
MockEngine { throw CancellationException("onboarding scope torn down") }
)
val thrown = assertFailsWith<Throwable> { client.requestSubdomain(TOKEN) }
assertTrue(
thrown is CancellationException,
"a cancelled relay request must propagate cancellation, got " +
"${thrown.javaClass.name}: ${thrown.message}"
)
}
@Test
fun `a genuine transport failure is still reported as NetworkError`(): Unit = runBlocking {
val client = clientOver(MockEngine { throw IOException("relay unreachable") })
val result = client.requestSubdomain(TOKEN)
assertIs<RelayApiResult.NetworkError>(result)
assertEquals(
"relay unreachable",
result.cause.message,
"the ordinary failure path must be unchanged"
)
}
@Test
fun `a body that fails mid-read surfaces from the request, not the body read`(): Unit =
runBlocking {
// Pins the reachability claim in the class kdoc: the inner
// `bodyAsText()` catch never sees this failure, because SaveBody
// already drained the channel inside `block()`. If a future Ktor
// upgrade (or a client built without SaveBody) moves the read back
// to `bodyAsText()`, this test flips to `RelayApiResult.Error` and
// the inner catch becomes worth guarding.
val client = clientOver(
MockEngine {
respond(
content = ByteChannel().apply { cancel(IOException("reset mid-body")) },
status = HttpStatusCode.InternalServerError
)
}
)
val result = client.requestSubdomain(TOKEN)
assertIs<RelayApiResult.NetworkError>(result)
}
private fun clientOver(engine: MockEngine) = RelayApiClient(
baseUrl = "http://relay.test",
httpClient = HttpClient(engine) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
}
)
private companion object {
const val TOKEN = "fake-firebase-id-token"
}
}

View file

@ -0,0 +1,163 @@
package com.rousecontext.tunnel
import java.io.IOException
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
/**
* What [SelfCertVerifier] does with a **cancellation** raised by one of the
* three suspend store calls it guards (#646).
*
* All three catches sit around `suspend` members of [CertificateStore], so a
* scope teardown mid-verify came back as [SecurityCheckResult.Warning] -- the
* same "security finding about a check that never ran" shape as
* [CtLogMonitorCancellationTest].
*
* The third one is the sharpest: [SelfCertVerifier] backfills a fingerprint and
* *then* writes the one-shot bootstrap marker, deliberately in that order, so a
* failed fingerprint write does not claim bootstrap completed (issue #210).
* Swallowing cancellation between the two turns a teardown into exactly the
* half-written state that ordering exists to prevent, and reports it as a
* Warning rather than letting the caller retry.
*
* As in [CtLogMonitorCancellationTest], no test cancels the surrounding scope:
* the collaborator raises cancellation directly so the assertion is about the
* code under test and not about the test's own teardown.
*/
class SelfCertVerifierCancellationTest {
@Test
fun `cancellation from getKnownFingerprints propagates instead of becoming a Warning`() =
runBlocking {
val verifier = SelfCertVerifier(
FingerprintStore(onGetKnown = CancellationException("worker scope torn down"))
)
val thrown = assertFailsWith<Throwable> { verifier.verify(listOf(LEAF)) }
assertTrue(
thrown is CancellationException,
"a cancelled verify must propagate cancellation, got " +
"${thrown.javaClass.name}: ${thrown.message}"
)
}
@Test
fun `an IO failure from getKnownFingerprints is still reported as a Warning`(): Unit =
runBlocking {
val verifier = SelfCertVerifier(
FingerprintStore(onGetKnown = IOException("Storage unavailable"))
)
val result = verifier.verify(listOf(LEAF))
assertIs<SecurityCheckResult.Warning>(result)
assertTrue(
result.reason.startsWith("Could not retrieve known fingerprints:"),
"the ordinary failure path must be unchanged, got: ${result.reason}"
)
}
@Test
fun `cancellation from the bootstrap marker read propagates instead of a Warning`() =
runBlocking {
val verifier = SelfCertVerifier(
FingerprintStore(onMarkerRead = CancellationException("worker scope torn down"))
)
val thrown = assertFailsWith<Throwable> { verifier.verify(listOf(LEAF)) }
assertTrue(
thrown is CancellationException,
"a cancelled marker read must propagate cancellation, got " +
"${thrown.javaClass.name}: ${thrown.message}"
)
}
@Test
fun `an IO failure on the bootstrap marker read is still reported as a Warning`(): Unit =
runBlocking {
val verifier = SelfCertVerifier(
FingerprintStore(onMarkerRead = IOException("Storage unavailable"))
)
val result = verifier.verify(listOf(LEAF))
assertIs<SecurityCheckResult.Warning>(result)
assertTrue(
result.reason.startsWith("Could not check fingerprint bootstrap marker:"),
"the ordinary failure path must be unchanged, got: ${result.reason}"
)
}
@Test
fun `cancellation during the fingerprint backfill propagates instead of a Warning`() =
runBlocking {
// Issue #210 ordering: the fingerprint is stored, then the marker is
// written. A cancellation between them must not be reported as a
// completed-but-degraded check.
val verifier = SelfCertVerifier(
FingerprintStore(onMarkerWrite = CancellationException("worker scope torn down"))
)
val thrown = assertFailsWith<Throwable> { verifier.verify(listOf(LEAF)) }
assertTrue(
thrown is CancellationException,
"a cancelled backfill must propagate cancellation, got " +
"${thrown.javaClass.name}: ${thrown.message}"
)
}
@Test
fun `an IO failure during the fingerprint backfill is still reported as a Warning`(): Unit =
runBlocking {
val verifier = SelfCertVerifier(
FingerprintStore(onMarkerWrite = IOException("Storage unavailable"))
)
val result = verifier.verify(listOf(LEAF))
assertIs<SecurityCheckResult.Warning>(result)
assertTrue(
result.reason.startsWith("Could not backfill missing fingerprint:"),
"the ordinary failure path must be unchanged, got: ${result.reason}"
)
}
/**
* Store with an empty fingerprint set and no bootstrap marker -- the state
* that drives [SelfCertVerifier] through all three guarded calls -- with an
* injectable failure at each one.
*/
private class FingerprintStore(
private val onGetKnown: Throwable? = null,
private val onMarkerRead: Throwable? = null,
private val onMarkerWrite: Throwable? = null
) : CertificateStore by SecurityCertificateStore() {
override suspend fun getKnownFingerprints(): Set<String> {
onGetKnown?.let { throw it }
return emptySet()
}
override suspend fun hasFingerprintBootstrapMarker(): Boolean {
onMarkerRead?.let { throw it }
return false
}
override suspend fun storeFingerprint(fingerprint: String) = Unit
override suspend fun writeFingerprintBootstrapMarker() {
onMarkerWrite?.let { throw it }
}
}
private companion object {
/** Any non-empty DER stand-in; only its SHA-256 is ever taken. */
val LEAF = byteArrayOf(0x30, 0x03, 0x02, 0x01, 0x00)
}
}

View file

@ -0,0 +1,191 @@
package com.rousecontext.tunnel
import java.io.IOException
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
/**
* What the two provisioning flows do with a **cancellation** raised while they
* are persisting freshly-issued state (#646).
*
* Both wrap a run of `suspend` [CertificateStore] writes in a broad
* `catch (Exception)` whose handler performs a **rollback**:
*
* - [OnboardingFlow] calls `certificateStore.clear()` and returns
* [OnboardingResult.StorageFailed];
* - [CertProvisioningFlow] calls `certificateStore.clearCertificates()` and
* returns [CertProvisioningResult.StorageFailed].
*
* A cancelled write is not a failed write. The rollback is the consequence that
* actually lands here: it is real destructive work performed *after*
* cancellation, which `.claude/rules/coroutines.md` forbids outright, and in
* [OnboardingFlow]'s case `clear()` drops the subdomain the relay has already
* assigned -- the device is registered upstream and blank locally.
*
* Each flow is pinned in both directions, and each cancellation test asserts
* the rollback did **not** run, not merely that something was thrown: the
* rollback is the damage, so its absence is the property worth holding.
*
* As elsewhere in this sweep the scope is never cancelled by the test -- the
* store raises cancellation directly, so nothing about the assertion depends on
* the test's own teardown.
*/
class StorageRollbackCancellationTest {
private val mockServer = MockRelayServer()
@BeforeTest
fun setUp() = mockServer.start()
@AfterTest
fun tearDown() = mockServer.stop()
@Test
fun `cancelling an onboarding write propagates and does not roll back`() = runBlocking {
val store = RollbackRecordingStore(
onIdentityWrite = CancellationException("onboarding scope torn down")
)
val thrown = assertFailsWith<Throwable> { onboardingOver(store).execute(TOKEN, FCM) }
assertTrue(
thrown is CancellationException,
"a cancelled onboarding write must propagate cancellation, got " +
"${thrown.javaClass.name}: ${thrown.message}"
)
assertEquals(
0,
store.clearCalls,
"cancellation must not trigger the onboarding rollback: a cancelled write is " +
"not a failed write, and clear() drops the subdomain the relay already assigned"
)
}
@Test
fun `an IO failure during an onboarding write still rolls back and reports it`(): Unit =
runBlocking {
val store = RollbackRecordingStore(onIdentityWrite = IOException("Disk full"))
val result = onboardingOver(store).execute(TOKEN, FCM)
assertIs<OnboardingResult.StorageFailed>(result)
assertEquals(
1,
store.clearCalls,
"the ordinary failure path must still roll back"
)
}
@Test
fun `cancelling a cert-provisioning write propagates and does not roll back`() = runBlocking {
val store = RollbackRecordingStore(
onCertWrite = CancellationException("provisioning scope torn down")
)
store.storeSubdomain("abc123")
val thrown = assertFailsWith<Throwable> { provisioningOver(store).execute(TOKEN) }
assertTrue(
thrown is CancellationException,
"a cancelled cert write must propagate cancellation, got " +
"${thrown.javaClass.name}: ${thrown.message}"
)
assertEquals(
0,
store.clearCertificatesCalls,
"cancellation must not trigger the cert rollback"
)
}
@Test
fun `an IO failure during a cert write still rolls back and reports it`(): Unit = runBlocking {
val store = RollbackRecordingStore(onCertWrite = IOException("Disk full"))
store.storeSubdomain("abc123")
val result = provisioningOver(store).execute(TOKEN)
assertIs<CertProvisioningResult.StorageFailed>(result)
assertEquals(
1,
store.clearCertificatesCalls,
"the ordinary failure path must still roll back"
)
}
private fun onboardingOver(store: CertificateStore) = OnboardingFlow(
relayApiClient = RelayApiClient(baseUrl = mockServer.baseUrl),
certificateStore = store
)
private fun provisioningOver(store: CertificateStore) = CertProvisioningFlow(
csrGenerator = CsrGenerator(),
relayApiClient = RelayApiClient(baseUrl = mockServer.baseUrl),
certificateStore = store,
deviceKeyManager = InMemoryDeviceKeyManager()
)
/**
* Store that fails a chosen family of writes and counts rollback calls.
* Everything else is delegated to a real in-memory store so the flows reach
* the guarded write in their normal state.
*/
private class RollbackRecordingStore(
private val onIdentityWrite: Throwable? = null,
private val onCertWrite: Throwable? = null,
private val delegate: InMemoryCertificateStore = InMemoryCertificateStore()
) : CertificateStore by delegate {
var clearCalls = 0
private set
var clearCertificatesCalls = 0
private set
override suspend fun storeSubdomain(subdomain: String) {
onIdentityWrite?.let { throw it }
delegate.storeSubdomain(subdomain)
}
override suspend fun storeIntegrationSecrets(secrets: Map<String, String>) {
onIdentityWrite?.let { throw it }
delegate.storeIntegrationSecrets(secrets)
}
override suspend fun storeCertificate(pemChain: String) {
onCertWrite?.let { throw it }
delegate.storeCertificate(pemChain)
}
override suspend fun storeClientCertificate(pemChain: String) {
onCertWrite?.let { throw it }
delegate.storeClientCertificate(pemChain)
}
override suspend fun storeRelayCaCert(pem: String) {
onCertWrite?.let { throw it }
delegate.storeRelayCaCert(pem)
}
override suspend fun clear() {
clearCalls++
delegate.clear()
}
override suspend fun clearCertificates() {
clearCertificatesCalls++
delegate.clearCertificates()
}
}
private companion object {
const val TOKEN = "fake-firebase-id-token"
const val FCM = "fake-fcm-registration-token"
}
}