Skip to content

fix: stop a non-streaming generation when the client disconnects - #250

Merged
solderzzc merged 2 commits into
SharpAI:mainfrom
HamedShams:fix/non-streaming-client-disconnect
Oct 29, 2026
Merged

solderzzc merged 2 commits into
SharpAI:mainfrom
HamedShams:fix/non-streaming-client-disconnect

Conversation

@HamedShams

@HamedShams HamedShams commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Closes #249.

A non-streaming request whose client disconnects kept generating to max_tokens or EOS, holding its slot the whole time. With the default --parallel 1 every later request queued behind it, and a client that times out and retries (the OpenAI SDKs' default) stacked up one full generation per attempt. The streaming path did not have this problem: its next write fails and tears down the body stream, which cancels the consumer. A non-streaming response writes nothing until the end, so nothing ever told it the client had left.

What changes

  • The router gets a request context that keeps the connection's channel (ConnectionRequestContext); BasicRequestContext drops it. The two POST routes pass the channel to their handlers.
  • ClientDisconnectWatch, a small pipeline handler installed at the head of the pipeline for the span of one non-streaming request, fires once when the client's side closes. Hummingbird enables remote half-closure, so a closed socket arrives as ChannelEvent.inputClosed; a reset arrives as channelInactive. Both are forwarded unchanged. The watch is removed when the handler returns, so a keep-alive connection closing between requests is not mistaken for an abandoned one.
  • cancellingOnClientDisconnect runs the non-streaming generation (prefill plus the .chunk/.info loop) in a task that the watch cancels. Cancellation flows through the AsyncStream to the producer the same way it does for SSE: the model stops after the token in flight, the handler falls out of its loop and releases the slot through the existing path, and the response it builds goes to the closed socket. Both the chat and the /v1/completions non-streaming branches use it. Nothing changes for streaming requests, and the handlers keep working without a channel (the unit tests call them that way).

Like the streaming path, an abandoned request does not reach .info, so it does not save the prompt cache. (The second commit below changes only what happens once .info is reached.)

Tests

  • tests/SwiftLMTests/ClientDisconnectTests.swift: the watch fires once on inputClosed, once on channelInactive, ignores other events and forwards what it sees (EmbeddedChannel); a non-streaming handler fed an endless synthetic stream returns, releases its slot and leaves requests_active at 0 after the watch fires; a normal completion removes the watch; the wrapper runs plainly without a channel.
  • tests/test-contract.sh Test 14: a non-streaming request curl abandons after half a second must leave requests_active at 0 within 5 s and add fewer than 400 tokens to swiftlm_tokens_generated_total. On main it adds the whole answer.

Prefill is not interruptible, so a client that leaves during a long prefill still holds the slot until the first decode step; that is the same as on the streaming path.

What I ran (M1 Max, Xcode 27)

  • swift test --filter SwiftLMTests: 337 passed, 0 failed (main: 326, plus the 11 new across both commits).
  • tests/test-contract.sh with the CI model: 24 passed, 0 failed, 2 skipped (the usual capability skips). Test 14 fails on main, where the abandoned request generated 737 tokens, and passes on the branch with 132. Test 15 fails without the second commit and passes with it (numbers below).
  • The measurements from A non-streaming request keeps generating after its client disconnects, holding the slot until max_tokens or EOS #249, repeated on the branch: one abandoned request no longer delays the next one (2.4 s → 0.03 s), three in a row 12.1 s → 0.05 s, the abandoned /v1/completions request 0.03 s, and the OpenAI SDK timeout-and-retry case 17.5 s → 0.0 s, with each attempt stopping after the tokens generated before the client left instead of running to max_tokens.

Second commit: a tool-call-only turn saves the prompt cache (#245)

Both chat handlers save the prompt cache when the producer's .info arrives, and only did so once a text chunk had been seen. A turn the model answers with nothing but a tool call never yields a chunk, so the save was skipped and the next turn of the agent loop re-prefilled the whole conversation. The !firstToken guard at .info is dropped in both handlers: .info is the producer's last yield, so prefill has finished and the cache is no longer being written, and the save slices the KV state to the prompt length as it does after a text turn. Early exits (stop sequence, disconnect) still skip the save.

  • tests/SwiftLMTests/ToolCallPromptCacheTests.swift: a tool-call-only turn saves once on both handlers, text turns still save once, a stream cut before .info does not save.
  • tests/test-contract.sh Test 15: a tool-call turn (non-streaming, then streaming) followed by its tool result; the follow-up's prompt-cache hit must reuse at least the whole prompt of the tool-call turn. Before the change the follow-up reused 4 and 7 cached tokens of prompts of 176 and 177; after it, all 176 and 177.

A non-streaming request whose client had gone away kept generating to
max_tokens or EOS, holding its slot the whole time. With the default
--parallel 1 every later request queued behind it, and a client that times
out and retries (the OpenAI SDKs' default) left one full generation per
attempt. The streaming path did not have this problem: its next write fails
and tears down the body stream, which cancels the consumer. A non-streaming
response writes nothing until the end, so nothing told it the client had left.

Hummingbird runs the handler inline and enables remote half-closure, so a
closed socket shows up on the pipeline as ChannelEvent.inputClosed (a reset
as channelInactive), and nothing in the server watched for either.

- ConnectionRequestContext keeps the connection's channel; the two POST
  routes pass it to their handlers.
- ClientDisconnectWatch sits at the head of the pipeline for the span of one
  non-streaming request and fires once on inputClosed or channelInactive,
  forwarding both. It is removed when the handler returns, so a keep-alive
  connection closing between requests is not taken for an abandoned one.
- cancellingOnClientDisconnect runs the non-streaming generation in a task
  the watch cancels. Cancellation reaches the producer through the
  AsyncStream as it does for SSE; the handler falls out of its loop and
  releases the slot through the existing path. Both the chat and the
  /v1/completions non-streaming branches use it. Streaming is unchanged, and
  the handlers still run without a channel (as the unit tests call them).

Tests: ClientDisconnectTests (EmbeddedChannel) cover the watch and the
wrapper; contract Test 14 abandons a non-streaming request and checks the
slot is released with only a few tokens generated.
@solderzzc

Copy link
Copy Markdown
Member

Thanks for #250. One related gap that fits this PR (it's the tool-call-only save issue from #245):

In both handleChatStreaming and handleChatNonStreaming, firstToken is only cleared in the .chunk branches. A turn that produces only .toolCall / .rejectedToolCall events never emits a chunk, so at .info the guard if !firstToken, let onPrefillDone is false and the prompt cache is never saved. The next turn (which is exactly the agent loop: tool result → follow-up) then re-prefills the whole prefix.

Suggested fix: drop the !firstToken guard at .info in both handlers:

case .info(let info):
    if let onPrefillDone { await onPrefillDone() }

.info is the producer's last yield, so the race the guard protected against (saving while decode is still writing the same KVCacheSimple) can't happen there. The early-exit paths (stop sequence, disconnect) still skip .info, so they still skip the save, which is what this PR intends.

A test that would cover it: with Qwen2.5-0.5B-Instruct-4bit and a get_weather tool, send a prompt that gets a tool call back, then send the same prefix again and assert the second request reuses the cached prefix. Do this for streaming and non-streaming.

Note: I checked this by reading the code only; I have not run it. I have not checked how promptCache.save behaves with zero generated output, or the activePrefillProgressHook lifecycle on tool-call-only turns.

Happy to open this as a follow-up PR on top of #250 once it merges, or you can fold it in. Whichever you prefer.

Both chat handlers save the prompt cache when the producer's `.info` arrives,
and only did so once a text chunk had been seen. A turn the model answers with
nothing but a tool call never yields a chunk: the parser holds the whole
`<tool_call>` body and emits one `.toolCall`. So the save was skipped and the
next turn of the agent loop (tool result, follow-up) re-prefilled the whole
conversation. This predates the move of the save to `.info`: before that it sat
inside the first-chunk block, which such a turn never entered either.

Drop the first-chunk guard at `.info` in both handlers. `.info` is the
producer's last yield, so prefill has long finished and the cache is no longer
being written; the save slices the KV state to the prompt length, as it does
after a text turn. Early exits (stop sequence, disconnect) still never reach
`.info` and still skip the save.

Tests: ToolCallPromptCacheTests drive both handlers with a synthetic stream (a
tool-call-only turn saves once, text turns still save once, a stream cut before
`.info` does not save); contract Test 15 sends a tool-call turn, non-streaming
and streaming, and checks that the follow-up's prompt-cache hit reuses the
whole prompt of that turn.

Raised in SharpAI#245; suggested for this PR by @solderzzc on SharpAI#250.
@HamedShams

Copy link
Copy Markdown
Contributor Author

Folded in as a second commit.

  • Both .info branches now call onPrefillDone without the !firstToken guard; the comment there says why that is safe (.info is the last yield, so nothing is still writing the cache).
  • On your two open points: a tool-call-only turn does generate tokens (the tool-call body), so the KV state at .info is prompt plus a few decode tokens, exactly as after a text turn, and promptCache.save slices it to the prompt length either way. The prefill progress hook is not touched by the change: it is still cleared at .info and in the streaming handler's defer.
  • Tests: ToolCallPromptCacheTests drives both handlers with a synthetic stream (tool-call-only turn saves once, text turns still save once, a stream cut before .info does not save). Contract Test 15 does what you described with Qwen2.5-0.5B and get_weather: a tool-call turn, non-streaming and then streaming, followed by the tool result; the follow-up's cache hit must reuse at least the whole prompt of the tool-call turn. Before the change the follow-up reused 4 and 7 cached tokens of prompts of 176 and 177; after it, all 176 and 177.

PR description updated to cover both.

@solderzzc
solderzzc merged commit ef76529 into SharpAI:main Oct 29, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A non-streaming request keeps generating after its client disconnects, holding the slot until max_tokens or EOS

2 participants