A nightly architectural scan filed a blocker against NodeItem.kt: the
while (true) loop inside Modifier.pointerInput { awaitPointerEventScope { … } }
was claimed to block threads, accumulate uncancelable coroutines on every node
render, starve Dispatchers.Default, and leak memory and battery — “hundreds of
blocked coroutines” with 100+ visible nodes.
This was the second filing of substantially the same claim against the same lines; the first was closed as a false positive with no code change.
The severity claims are refuted. awaitPointerEvent() is a cancellable
suspension point, not a blocking call — it parks the coroutine and consumes no
thread while waiting. The block passed to Modifier.pointerInput(keys) runs in a
coroutine owned by the pointer-input modifier node, which Compose cancels and
relaunches when the keys change or the node detaches. Cancellation is therefore
delivered at awaitPointerEvent(), the loop unwinds, and nothing leaks.
while (true) { awaitPointerEvent() } is the canonical raw-gesture idiom —
detectTapGestures and detectDragGestures use exactly this shape internally.
The strongest evidence is upstream’s own API design: AwaitPointerEventScope is
annotated @RestrictsSuspension, so you cannot call currentCoroutineContext()
inside it at all. A scope that forbids arbitrary suspension is not a scope that
hosts free-running work.
Two things were nevertheless real:
awaitPointerEventScope loops (NodeItem.kt, ClientScreen.kt) were
the only remaining while (true) coroutine loops in composeApp, contradicting
this repo’s own archived compose-lifecycle-management requirement that such
loops gate on isActive. Every scan re-derives the same lead from that
inconsistency.NodeItem.kt’s right-click branch wrapped the entire handler in
catch (_: Throwable) { } whose comment claimed it existed only to probe
event.buttons. Any ScreenCore failure — selectNode, editSelect,
executeCommand — was swallowed silently, with no log line.composeApp/src/commonMain/kotlin/krill/zone/app/NodeItem.kt and
composeApp/src/commonMain/kotlin/krill/zone/app/krillapp/client/ClientScreen.kt:
capture val gestureContext = currentCoroutineContext() in the enclosing
(unrestricted) PointerInputScope lambda and loop on gestureContext.isActive.
Same coroutine, so this is behavior-preserving — cancellation still arrives at
awaitPointerEvent() first — but the lifecycle contract is now stated in the
source instead of being implicit.NodeItem.kt: narrow the blanket catch to the capability probe it was written
for (val isRightClick = try { … event.buttons.isSecondaryPressed } catch { false }),
moving the ScreenCore calls out of the swallow path.composeApp/src/desktopTest/kotlin/krill/zone/app/PointerInputLoopCancellationTest.kt:
source-level guard for both properties across all of commonMain.while (true) whose only suspension point is a cancellable framework
await is not an unbounded loop. Before accepting a leak/thread-starvation
claim, identify what the loop suspends on and whether that call is cancellable.
“Blocks threads” and “suspends until an event arrives” are opposite claims;
scanners routinely conflate them.@RestrictsSuspension before applying a blanket “add isActive” rule.
Inside AwaitPointerEventScope the spec’s literal form does not compile; the
context must be captured in the enclosing scope. A style rule that cannot be
expressed as written is a signal to re-read the rule’s intent, not to force it.catch to the expression it was written to guard. A try block
that grew to wrap real business logic turns a capability probe into a silent
failure sink. If the comment names one expression, the try should contain
exactly that expression.