You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug] Unawaited backend.stop() on timeout can race the next agent-loop iteration #69
Same pattern as #68, in _runAgentTurn's timeout catch block (lib/features/chat/presentation/chat_notifier.dart:716):
if (e isTimeoutException) backend.stop();
backend.stop() isn't awaited.
Failure scenario
An agent-loop tool-call iteration times out; the unawaited stop() can race with the next agent iteration's own generate() call, truncating it, with the enclosing catch never seeing a failure from the dropped stop() future.
Filed separately from #68 (same bug, different call site — send() vs. the agent tool-calling loop) since they may be fixed independently.
Found during v1.2.0 release-readiness code review of the main...develop diff.
Fix direction
await backend.stop(); before the handler proceeds, and surface/log any error from it instead of dropping it. Consider factoring the shared "stop-on-timeout" logic into one helper used by both send() and _runAgentTurn so this can't drift out of sync again.
@ASahu16 Was about to pick this up and copy the #77 fix into _runAgentTurn's catch block, but while tracing what happens on a fast retry after an agent timeout, I couldn't find a path where the retry gets truncated. I wanted to check with you before opening a PR, since I might be missing something.
How I traced the flow
Say an agent turn (call it A) times out and the user sends again right away (B):
A's generate() stream hits the 120s .timeout(), the catch block in _runAgentTurn runs, and the Dart side stops listening to A. Any tokens A still produces are dropped.
backend.stop() is called without await. It isn't awaited, but invokeMethod sends the platform message synchronously when it's called, so the stopGeneration message goes out right away.
state = AsyncError(...) runs, and the Send button comes back.
The user taps Send, and B's generateStream is sent on the same MethodChannel('llama_engine'). Since both go over the same channel, stopGeneration reaches the plugin before B's generateStream does.
On the plugin side, nativeStopGeneration only sets g_should_stop = true (llama_engine_jni.cpp:561). A's loop checks that flag before each token (llama_engine_jni.cpp:503) and ends.
When B's generateStream reaches the plugin, one of two things happens:
A has already ended: B goes through nativeGenerateStreamInit, which resets g_should_stop = false before generating (llama_engine_jni.cpp:337). So the earlier stop can't carry into B, and B runs fully.
A is still finishing its last token: isGenerating is still true, so the plugin returns BUSY (LlamaEnginePlugin.kt:134). B fails with a visible error and the user can retry.
Everything also runs on a single-thread executor, so A and B can never generate at the same time. From this, the worst case on a fast retry looks like a BUSY error, not a truncated reply, and that seems to be the same with or without the await.
I was also going to add the myGeneration check, but I'm not sure it can trigger in the agent path right now. ChatNotifier.stop() only calls backend.stop() and doesn't clear streamingText, so the screen stays in the streaming state, and Send stays hidden until the catch block sets AsyncError. I couldn't find a way for a newer send() to start while the catch block is awaiting.
What I'm less sure about
The part that does look real is that if backend.stop() throws, nothing handles that error today and nothing gets logged. send() and _ensureSummary already wrap it in a try/catch with log.error.
Am I missing a case where the ordering above doesn't hold, or a different scenario you had in mind when you filed this? I want to make sure I'm fixing what you actually saw rather than what I think is happening.
Activity
ItsPriyamSri commentedon Oct 11, 2026
@ASahu16 Was about to pick this up and copy the #77 fix into
_runAgentTurn's catch block, but while tracing what happens on a fast retry after an agent timeout, I couldn't find a path where the retry gets truncated. I wanted to check with you before opening a PR, since I might be missing something.How I traced the flow
Say an agent turn (call it A) times out and the user sends again right away (B):
generate()stream hits the 120s.timeout(), the catch block in_runAgentTurnruns, and the Dart side stops listening to A. Any tokens A still produces are dropped.backend.stop()is called withoutawait. It isn't awaited, butinvokeMethodsends the platform message synchronously when it's called, so thestopGenerationmessage goes out right away.state = AsyncError(...)runs, and the Send button comes back.generateStreamis sent on the sameMethodChannel('llama_engine'). Since both go over the same channel,stopGenerationreaches the plugin before B'sgenerateStreamdoes.nativeStopGenerationonly setsg_should_stop = true(llama_engine_jni.cpp:561). A's loop checks that flag before each token (llama_engine_jni.cpp:503) and ends.generateStreamreaches the plugin, one of two things happens:nativeGenerateStreamInit, which resetsg_should_stop = falsebefore generating (llama_engine_jni.cpp:337). So the earlier stop can't carry into B, and B runs fully.isGeneratingis still true, so the plugin returnsBUSY(LlamaEnginePlugin.kt:134). B fails with a visible error and the user can retry.Everything also runs on a single-thread executor, so A and B can never generate at the same time. From this, the worst case on a fast retry looks like a
BUSYerror, not a truncated reply, and that seems to be the same with or without theawait.The ticket guard from #77
I was also going to add the
myGenerationcheck, but I'm not sure it can trigger in the agent path right now.ChatNotifier.stop()only callsbackend.stop()and doesn't clearstreamingText, so the screen stays in the streaming state, and Send stays hidden until the catch block setsAsyncError. I couldn't find a way for a newersend()to start while the catch block is awaiting.What I'm less sure about
The part that does look real is that if
backend.stop()throws, nothing handles that error today and nothing gets logged.send()and_ensureSummaryalready wrap it in atry/catchwithlog.error.Am I missing a case where the ordering above doesn't hold, or a different scenario you had in mind when you filed this? I want to make sure I'm fixing what you actually saw rather than what I think is happening.