Skip to content

Commit df9039e

Browse files
fryanpanclaude
andcommitted
ADFA-4328: failing-first repro test for KeyedDebouncingAction channel-close crash
Adds a :common unit test that parks a debounce worker on channel.receive(), cancels the entry, and asserts no ClosedReceiveChannelException escapes to the scope CoroutineExceptionHandler. Red on the pre-fix baseline (cancel() closed the channel before cancelling the job), green with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent df929f6 commit df9039e

2 files changed

Lines changed: 67 additions & 0 deletions

File tree

‎common/build.gradle.kts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ dependencies {
4343
implementation(libs.monitor)
4444

4545
testImplementation(projects.testing.common)
46+
testImplementation(libs.tests.kotlinx.coroutines)
47+
testImplementation(libs.tests.google.truth)
4648
androidTestImplementation(projects.testing.android)
4749

4850
// brotli4j
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.itsaky.androidide.utils
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import kotlinx.coroutines.CoroutineExceptionHandler
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.SupervisorJob
7+
import kotlinx.coroutines.channels.ClosedReceiveChannelException
8+
import kotlinx.coroutines.delay
9+
import kotlinx.coroutines.runBlocking
10+
import org.junit.Test
11+
import java.util.concurrent.atomic.AtomicReference
12+
import kotlin.coroutines.CoroutineContext
13+
import kotlin.time.Duration.Companion.milliseconds
14+
15+
/**
16+
* Repro for ADFA-4328: cancelling a [KeyedDebouncingAction] entry whose worker is
17+
* parked on `channel.receive()` must NOT let a [ClosedReceiveChannelException]
18+
* escape to the scope's uncaught-exception handler.
19+
*
20+
* On the pre-fix baseline, `ActionEntry.cancel()` did `channel.close()` BEFORE
21+
* `job.cancel()`. Closing the channel wakes the parked `receive()` with a
22+
* [ClosedReceiveChannelException] (NOT a CancellationException), which propagates
23+
* uncaught to the [CoroutineExceptionHandler] -> the Sentry crash this ticket fixes.
24+
*
25+
* The fix swaps the order (job.cancel() first) AND wraps the worker loop in a
26+
* try/catch that swallows ClosedReceiveChannelException, so no uncaught exception fires.
27+
*/
28+
class KeyedDebouncingActionCancelTest {
29+
30+
@Test
31+
fun `cancelling a parked worker does not leak a ClosedReceiveChannelException`() = runBlocking {
32+
val uncaught = AtomicReference<Throwable?>(null)
33+
// A plain Job (not Supervisor of the worker) + a handler that records anything
34+
// that escapes the debounce worker coroutine.
35+
val handler = CoroutineExceptionHandler { _, t -> uncaught.set(t) }
36+
val scope = CoroutineScope(SupervisorJob() + handler)
37+
38+
val ctx: CoroutineContext = scope.coroutineContext
39+
40+
val debouncer = KeyedDebouncingAction<String>(
41+
scope = scope,
42+
debounceDuration = 50.milliseconds,
43+
actionContext = ctx,
44+
action = { _, _ -> /* never invoked: we cancel while parked on receive */ },
45+
)
46+
47+
// schedule() creates the entry + launches the worker. With a CONFLATED channel and
48+
// no further sends, the worker debounces the single key, runs the (empty) action,
49+
// then loops back and parks on channel.receive() waiting for the next key.
50+
debouncer.schedule("k")
51+
52+
// Give the worker time to: receive "k", run the empty action, loop, and PARK on
53+
// the next channel.receive(). 200ms >> 50ms debounce window.
54+
delay(200)
55+
56+
// Cancel the entry while the worker is parked on receive().
57+
debouncer.cancelPending("k")
58+
59+
// Let any uncaught exception propagate to the handler.
60+
delay(200)
61+
62+
val leaked = uncaught.get()
63+
assertThat(leaked).isNull()
64+
}
65+
}

0 commit comments

Comments
 (0)