Problem
RagStream.cancel() cancels the coroutine job, relying on the CancellationException catch block to post {done: true, cancelled: true} to Flutter. This works for the normal user-cancel path, but there is no guarantee if the coroutine is cancelled outside that path — e.g. Android lifecycle teardown, process death, or a job cancelled before withContext is entered.
If the terminal event is never posted, the Flutter UI stays in "generating" state permanently until the next query arrives and supersedes it.
Fix
Register an invokeOnCompletion handler on the job when it is launched in generateResponse(). This fires regardless of how the coroutine ends (normal completion, cancellation, exception) and can post a terminal event unconditionally if currentJob is still this job:
currentJob = lifecycleScope.launch {
// ... existing body ...
}.also { job ->
job.invokeOnCompletion { cause ->
if (cause is CancellationException || cause != null) {
postTerminalIfCurrent(job, cancelled = cause != null)
}
}
}
Impact
Low probability in practice (normal cancel path works), but could leave the UI stuck if triggered by lifecycle events.
Problem
RagStream.cancel()cancels the coroutine job, relying on theCancellationExceptioncatch block to post{done: true, cancelled: true}to Flutter. This works for the normal user-cancel path, but there is no guarantee if the coroutine is cancelled outside that path — e.g. Android lifecycle teardown, process death, or a job cancelled beforewithContextis entered.If the terminal event is never posted, the Flutter UI stays in "generating" state permanently until the next query arrives and supersedes it.
Fix
Register an
invokeOnCompletionhandler on the job when it is launched ingenerateResponse(). This fires regardless of how the coroutine ends (normal completion, cancellation, exception) and can post a terminal event unconditionally ifcurrentJobis still this job:Impact
Low probability in practice (normal cancel path works), but could leave the UI stuck if triggered by lifecycle events.