Skip to content

[core] Client disconnects don't cancel the model stream (per-step flow is cached) — and there is no public API to cancel an in-flight run #1618

Description

@caps-xia

Describe the Bug

When a client disconnects while an agent is streaming (e.g. the SSE consumer cancels its subscription to Runner's event flow), the underlying model call keeps running to completion. Tokens continue to be billed with no consumer attached. In one production incident the model kept generating for 3+ minutes after the client went away (observed via gateway-side logs and token metering).

The cause appears to be BaseLlmFlow.java line 530 (main at the time of writing):

Flowable<Event> currentStepEvents = runOneStep(spanContext, invocationContext).cache();

With RxJava cache(), the upstream connection is only disposed when all subscribers of the cached flow cancel. As long as any subscriber attached by the framework itself (event handling/aggregation within the invocation) is alive, a user-side cancellation cannot reach the model stream.

On top of that, there is no public API to cancel an in-flight run: neither Runner nor InvocationContext nor the session exposes a "stop generating" handle. The only way to stop a long generation is to let it finish.

To Reproduce

  1. Serve an agent behind SSE; start a streaming run with a slow/long model response (or a multi-round tool loop).
  2. Disconnect the SSE client mid-generation and dispose the event-flow subscription.
  3. Observe on the model/gateway side (or via token metering) that generation continues until the model finishes naturally.

Expected behavior

Cancelling the downstream subscription (or any public cancellation entry point) must reach the model stream: the in-flight LLM request is disposed (or aborted at the next chunk/round boundary), and no further rounds are started.

Environment

  • google/adk-java main (1.11.1-SNAPSHOT at the time of writing)
  • Spring AI bridge + SSE transport, but the cache() behavior is in core (BaseLlmFlow) and should affect any transport

Additional context

In production we could only implement "stop generating" with a cooperative cancellation protocol outside the framework: a per-run cancel flag (REST endpoint sets it) that the model-stream loop checks at every chunk and at tool-round boundaries, plus setCancellable hooks for the current round. It works, but it is entirely application-side — the framework neither propagates the cancellation nor offers a canonical way to express it.

Suggested directions (happy to discuss or contribute):

  1. Replace the unbounded cache() with cancellation-propagating semantics (e.g. publish().refCount()-style sharing, or a scoped subscription that disposes the source when the invocation ends/cancels), or
  2. Expose a first-class cancellation handle per run/invocation, and document that model loops should check it at chunk/round boundaries (cooperative cancellation), or
  3. At minimum, document the current behavior prominently — today it silently leaks billable model calls.

Activity

  1. self-assigned this
    on Oct 6, 2026
  2. added theissue type on Oct 6, 2026
  3. sangkyoonnam commented on Oct 6, 2026

    @sangkyoonnam

    Confirmed on main (f866804), and I opened #1621 for the propagation half. The public cancel API stays with #1341, and #1434 doesn't touch cache(), so they compose.

    Repro: a TestLlm that returns Flowable.never().doOnCancel(...), subscribe through runner.runAsync(...) or BaseLlmFlow.run(ctx), dispose after the model subscribes, and doOnCancel never fires. The mechanism differs from a framework subscriber holding the connection open: FlowableCache has no path that cancels upstream at all. CacheSubscription.cancel() only removes the subscriber, so after a disconnect the step runs on with zero subscribers, and a tool call the model emits afterwards still executes.

    #1621 keeps cache() and puts a takeUntil in front of it that fires when a subscriber cancels, so the toList() side still gets the buffer and completion. I'll rebase onto #1565's runStep once it lands. Eight of nine tests pass there; the late-subscriber test needs updating for its defer.

    Thanks for pointing at the exact line, that saved digging. Would you rather take #1621 from here?

  4. hemasekhar-p commented on Oct 6, 2026

    @hemasekhar-p
    Contributor

    Hi @caps-xia, thank you for taking time and reporting this issue.

    @sangkyoonnam, thank you for your contribution and We appreciate you taking the time to submit this pull request. I have reproduced this behavior and through local testing that your fix addresses it.

    Currently, the issue and the associated PR are under review by our team and we will keep you updated if any additional information is required. Thank you.

  5. caps-xia commented on Oct 6, 2026

    @caps-xia
    ContributorAuthor

    Please keep #1621 — you're 8/9 tests in, and takeUntil-in-front-of-cache is exactly the right shape: late-subscriber replay stays intact while cancellation finally reaches upstream.

    Your mechanism write-up also matches our production incident precisely: after the disconnect the step kept running with zero subscribers, and tool rounds the model emitted afterwards still executed. That's why we run a cooperative cancel flag (REST → per-run flag → checked at chunk/round boundaries) alongside — the two approaches compose.

    I'd rather contribute validation than take over: happy to run #1621 against our multi-round tool-loop workloads (disconnect mid-stream, dispose during tool execution, late-subscriber replay) and report results here.

  6. sangkyoonnam commented on Oct 6, 2026

    @sangkyoonnam

    Please do. The head is now a63b9cd, one commit on main. One correction to the numbers: with #1621 on main all 9 new tests pass; 8/9 is the fix ported onto #1565's head, which I'll rebase onto once it lands.

  7. caps-xia commented on Oct 6, 2026

    @caps-xia
    ContributorAuthor

    Validated #1621 (head a63b9cd) in our production environment — a multi-agent log-troubleshooting workload with multi-round tool loops over an OpenAI-compatible gateway (GLM), served via SSE.

    Setup: built the PR branch locally, installed google-adk 1.11.1-SNAPSHOT, switched our agent platform onto it, and ran our full regression suite first — 311 tests green, no behavioral drift.

    Scenario 1 — client disconnects mid-stream (the original incident path):

    Started a streaming troubleshooting run, let it emit its first tool round (a member-lookup call), waited for the model to keep streaming, then closed the browser tab outright — no stop button, no cooperative cancel, just the client vanishing.

    Result: the disconnect is caught on the model stream's own delivery path (the SSE write fails on the next chunk, and the trace shows takeUntil → cache exactly where the fix sits). After the broken pipe, zero further log activity — no additional tool rounds, no follow-up model calls, nothing running in the background. On the unfixed version this exact scenario kept generating for 3+ minutes and even executed tool rounds the model emitted after the disconnect.

    Scenario 2 — cooperative cancellation still works alongside:

    Our platform also runs an application-level cancel (REST stop → per-run flag → checked at chunk/round boundaries). With #1621 underneath, a stop request still closes the run out gracefully with partial results preserved (825 chars of reasoning kept as a partial result) — the two mechanisms compose cleanly, no interference.

    Takeaway: both halves of the problem in this issue are now covered — dispose-based cancellation propagates to the model stream (#1621), and our cooperative flag remains the application-level stop entry point. Thanks @sangkyoonnam — the takeUntil-in-front-of-cache shape held up exactly as designed under real workload.

  8. sangkyoonnam commented on Oct 6, 2026

    @sangkyoonnam

    Thanks for running it on the real workload, that's the evidence I couldn't produce here. I'll link this from #1621 so the reviewers see it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions