|
| 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