fix: reclaim generation slot on error and stream SSE headers before prefill - #170
Conversation
JSON nulls in tool schemas (common in opencode zod/effect output) decode to NSNull via AnyCodable and crash swift-jinja Value.init(any:) before the template is ever rendered — mislabeled as a broken chat template. The SharpAI#142 startup probe used tools: nil so it never hit this path, and opencode also rejected the prefill_progress heartbeat for missing choices. - sanitizeForJinja: recursively drop NSNull / unwrap optionals in TransformersTokenizerBridge.applyChatTemplate (messages, tools, additionalContext) - extend startup probe with a minimal tools array (warn-only; does not abort startup) - include choices: [] in ssePrefillChunk so strict OpenAI chunk validators accept the named heartbeat event - bump swift-jinja 2.3.5 -> 2.5.1 for accurate conversion errors - unit tests for the sanitizer + updated SSE expectations - test-opencode.sh Test 3: tools + prefill-progress combined (SharpAI#75 gap) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…refill - GenerationSlot: release-once guard so any throw after semaphore.wait() returns the slot (deinit fallback); four manual signal() sites removed. Previously one failed request permanently wedged --parallel 1. - Deferred startGeneration into the SSE consumer task so streaming responses put headers + ": connected" on the wire before model prefill; heartbeat hook installed up front; client disconnect cancels the consumer task via onTermination. - Probes showed /v1/models + /health answer instantly during prefill (no server-wide stall), so no Hummingbird timeout change; the real TTFB failure was silent prefill (e.g. 0.13s first byte vs 54.8s prefill on a 42k-token prompt). - Tests: GenerationSlotTests (deinit reclaim, no double-signal); test-server.sh Test 37 (control-plane probes during in-flight generation) and Test 38 (TTFB < 2s with ": connected").
|
Closing temporarily — holding for local testing before review. |
|
@CodeAndCanvas728 Thanks for your contribution and verification, I've been also running test, since you are working on it, I'll let my agent wait for your commit. |
|
Manual soak re-run under healthy-machine conditions after diagnosing an unrelated OS-level low-swap SIGKILL (not this PR).
Reopening for review. |
|
Thanks for the thorough soak run! One blocker: func sanitizeForJinja(_ value: any Sendable) -> (any Sendable)? {
...
func mapValuesDeep(_ transform: (any Sendable) -> (any Sendable)?) -> [String: any Sendable] {With that change applied locally, it builds on 26.3 and all 177 tests pass, including the new The slot-reclaim logic looks good to me. It releases exactly once, Two non-blocking notes:
Since #170 already contains everything in #169, I'd suggest merging #170 once the fix is in, and closing #169. |
Address review on SharpAI#170: - `any Sendable?` -> `(any Sendable)?` in sanitizeForJinja / mapValuesDeep; the unparenthesized form does not compile on Xcode 26.3 (CI pin). - Streaming prefill failures now build the SSE `error` event with JSONSerialization (sseErrorChunk) instead of swapping `"` for `'`, so backslashes and newlines in the message no longer produce invalid JSON. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks for the review — all three addressed in ac26d57:
Side note, not touched here since it predates this PR: the non-streaming 500 handlers for |
|
Thanks @CodeAndCanvas728 , let me turn on CI to get it passed. |
Yes please on the follow-up for the non-streaming 500 handlers. Thanks a lot for your contribution. |
The 6000-number prompt tokenizes to ~42k tokens on Gemma-4. Its prefill materializes full attention in one Metal buffer (28 GB), above the CI runner's 3.5 GB max buffer size, so the server crashed and the stream never reached [DONE]. 1000 numbers (~7k tokens) stays well under the limit; the ': connected' check still catches the original regression. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks! The red job ( The test prompt (6000 numbers) comes to about 42k tokens on Gemma-4. Gemma-4 prefill allocates full attention in a single buffer (28383323840 = 42017 × 42220 × 16 bytes, so it grows with the square of the prompt length). That goes well over the runner's 3.5 GB buffer limit. Qwen 0.5B passed the same test. Fixed in fe9da73 by cutting the prompt to 1000 numbers (about 7k tokens, roughly 0.8 GB). The Separately, a 42k-token prompt that fits in context still crashes the server on Gemma-4. That's a real bug but outside this PR, so I'll handle it separately. The non-streaming 500 follow-up is in #171. It's stacked on this PR, so only its last commit is new. |
Thanks, let me start the CI. |
…er cap fe9da73's ~7k-token prompt still crashed gemma-4-e2b on the CI runner with a 7.4 GB single-buffer malloc (cap 3.5 GB). Prefill attention costs ~149 bytes x n^2 for this model, so the real ceiling is ~4.9k tokens, not ~15k. Use ~2k tokens (~0.6 GB) and correct the comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
I pushed c51752e directly to this branch to shrink Test 38's prompt to ~2k tokens. Why: fe9da73's ~7k-token prompt still exceeded the CI runner's limit. gemma-4-e2b's prefill tried to allocate 7.4 GB in a single Metal buffer, against the runner's 3.5 GB cap:
Prefill costs roughly 149 bytes × n² for this model, so the real ceiling is ~4.9k tokens, not ~15k as the comment said. It now uses #171 is stacked on fe9da73, so it will need a rebase. This change doesn't touch anything in #171, so there should be no conflicts. |
Problem
Three related reliability failures around the
--parallelsemaphore and streaming TTFB:throwaftersemaphore.wait()(container.prepare,container.perform, …) skipped every manualsignal()call site. With the default--parallel 1, one failed request wedged the server for all subsequent generations until restart.container.performbefore returning response headers, so clients saw zero bytes for the entire prefill. Bun's fetch 10s idle timeout then killed the connection and retried — with an ever-grown payload (opencode retry-history growth; their side is tracked in opencode PR #26167, this is the server-side contributor)./v1/modelsstall during generation — diagnosed with probes before any fix (see below).Probe findings (before changing anything)
GET /v1/modelsandGET /healthanswer in ~0 ms even mid-prefill — no server-wide stall, no HummingbirdidleTimeoutchange needed.Fix
GenerationSlot— release-once guard around the semaphore: explicitrelease()on all success paths,deinitas fallback, whichever fires first signals exactly once (local bind so the Task never capturesself). The four scatteredawait semaphore.signal()sites are gone.startGeneration— for streaming,container.perform(prefill) now runs inside the SSE consumer task after the handler returns, so headers + a: connectedSSE comment hit the wire first; prefill heartbeat hook is installed up front; client disconnect cancels the consumer task viaonTermination.Behavior change
errorevent, thendata: [DONE]— previously a 500. Headers are already on the wire by the time prefill runs, so the failure can only be reported in-stream. This matches OpenAI's streaming error behavior. The event payload is{"error":{"message":…,"type":"server_error","code":"internal_error"}}, JSON-encoded viaJSONSerializationso any error text stays valid JSON.Measured
: connectedsent, stream completed./v1/models+/healthboth answered within 3 s.Tests
GenerationSlotTests(new): deinit reclaims without explicit release; explicit release + deinit doesn't double-signal; repeatedrelease()is idempotent — the regression is the leak, and no deterministic post-wait()error trigger exists for text-only test models, so the slot contract is unit-tested rather than e2e-forced.ServerSSETests.testErrorChunkEncodesMessageAsValidJSON(new): an error message containing",\and a newline round-trips through the SSEerrorevent as valid JSON.test-server.shTest 37:/v1/models+/healthrespond during in-flight generation on the default--parallel 1server.test-server.shTest 38: streaming TTFB < 2 s with: connectedbefore a multi-second prefill.swift test: 178/178../tests/test-server.sh: 76/76. Release build:swift build -c release --product SwiftLM(full-product build still hits the known SwiftSoup/SwiftBuddy frontend crash — pre-existing, unrelated).Known limitations (unchanged / out of scope)
eval()(mlx-swift-lm); disconnect during prefill still holds the slot until prefill completes — decode phase does cancel.