Skip to content

Commit df929f6

Browse files
fryanpanclaude
andcommitted
ADFA-4328: tolerate channel close in KeyedDebouncingAction
Sentry APPDEVFORALL-14C. Cancel job before closing channel, use receiveCatching, add a CoroutineExceptionHandler to the LSP scope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d73ec0b commit df929f6

2 files changed

Lines changed: 45 additions & 23 deletions

File tree

‎common/src/main/java/com/itsaky/androidide/utils/KeyedDebouncingAction.kt‎

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package com.itsaky.androidide.utils
22

33
import com.itsaky.androidide.progress.ICancelChecker
44
import com.itsaky.androidide.tasks.JobCancelChecker
5+
import kotlinx.coroutines.CancellationException
56
import kotlinx.coroutines.CoroutineScope
67
import kotlinx.coroutines.Dispatchers
78
import kotlinx.coroutines.ExperimentalCoroutinesApi
89
import kotlinx.coroutines.Job
910
import kotlinx.coroutines.channels.Channel
11+
import kotlinx.coroutines.channels.ClosedReceiveChannelException
1012
import kotlinx.coroutines.currentCoroutineContext
1113
import kotlinx.coroutines.ensureActive
1214
import kotlinx.coroutines.isActive
@@ -30,8 +32,11 @@ class KeyedDebouncingAction<T: Any>(
3032
val job: Job,
3133
) {
3234
fun cancel() {
33-
channel.close()
35+
// Cancel the job FIRST, then close the channel. Closing the channel first
36+
// wakes a parked receive() with a ClosedReceiveChannelException before the
37+
// job is cancelled, which can crash a worker that has no exception handling.
3438
job.cancel()
39+
channel.close()
3540
}
3641
}
3742

@@ -55,33 +60,41 @@ class KeyedDebouncingAction<T: Any>(
5560
val channel = Channel<T>(Channel.CONFLATED)
5661
val job = scope.launch(actionContext) {
5762
while (isActive) {
58-
var latestKey = channel.receive()
59-
var debouncing = true
60-
while (debouncing) {
61-
debouncing = select {
62-
onTimeout(debounceDuration) { false }
63-
channel.onReceive { newKey ->
64-
latestKey = newKey
65-
true
63+
try {
64+
var latestKey = channel.receive()
65+
var debouncing = true
66+
while (debouncing) {
67+
debouncing = select {
68+
onTimeout(debounceDuration) { false }
69+
channel.onReceive { newKey ->
70+
latestKey = newKey
71+
true
72+
}
6673
}
6774
}
68-
}
6975

70-
ensureActive()
71-
val actionJob = launch {
72-
val cancelChecker = JobCancelChecker(currentCoroutineContext()[Job])
73-
action(latestKey, cancelChecker)
74-
}
76+
ensureActive()
77+
val actionJob = launch {
78+
val cancelChecker = JobCancelChecker(currentCoroutineContext()[Job])
79+
action(latestKey, cancelChecker)
80+
}
7581

76-
select<Unit> {
77-
actionJob.onJoin {}
78-
channel.onReceive { newerKey ->
79-
actionJob.cancel()
80-
channel.trySend(newerKey)
82+
select<Unit> {
83+
actionJob.onJoin {}
84+
channel.onReceive { newerKey ->
85+
actionJob.cancel()
86+
channel.trySend(newerKey)
87+
}
8188
}
82-
}
8389

84-
actionJob.join()
90+
actionJob.join()
91+
} catch (e: ClosedReceiveChannelException) {
92+
// The channel was closed (entry cancelled). Stop the worker cleanly
93+
// instead of letting the exception propagate to an uncaught handler.
94+
break
95+
} catch (e: CancellationException) {
96+
throw e
97+
}
8598
}
8699
}
87100

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import com.itsaky.androidide.projects.FileManager
1717
import com.itsaky.androidide.projects.api.Workspace
1818
import com.itsaky.androidide.utils.KeyedDebouncingAction
1919
import io.sentry.Sentry
20+
import kotlinx.coroutines.CancellationException
21+
import kotlinx.coroutines.CoroutineExceptionHandler
2022
import kotlinx.coroutines.CoroutineName
2123
import kotlinx.coroutines.CoroutineScope
2224
import kotlinx.coroutines.Dispatchers
@@ -66,7 +68,14 @@ internal class CompilationEnvironment(
6668
languageVersion: LanguageVersion = DEFAULT_LANGUAGE_VERSION,
6769
enableParserEventSystem: Boolean = true,
6870
val coroutineScope: CoroutineScope = CoroutineScope(
69-
SupervisorJob() + CoroutineName("CompilationEnv[$name]")
71+
SupervisorJob() + CoroutineName("CompilationEnv[$name]") +
72+
CoroutineExceptionHandler { _, t ->
73+
// Defense in depth: swallow (but log) non-cancellation failures from the
74+
// debounce worker so a ClosedReceiveChannelException can never crash the app.
75+
if (t !is CancellationException) {
76+
logger.warn("Uncaught exception in compilation environment coroutine", t)
77+
}
78+
}
7079
),
7180
) : AbstractCompilationEnvironment(
7281
name = name,

0 commit comments

Comments
 (0)