From f76225d5cc6a01895591aac8a6de4d903864f12d Mon Sep 17 00:00:00 2001 From: Simba Zhang Date: Wed, 21 Oct 2026 21:52:58 -0700 Subject: [PATCH 1/4] chore: bump mlx-swift-lm to b620ae8 (upstream bb0d5b0 sync, mlx-swift-lm#85) Co-Authored-By: Claude Haiku 5.5 --- mlx-swift-lm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mlx-swift-lm b/mlx-swift-lm index 0573f021..b620ae85 160000 --- a/mlx-swift-lm +++ b/mlx-swift-lm @@ -1 +1 @@ -Subproject commit 0573f021d0a87bd5da5913911aab185d7771bd56 +Subproject commit b620ae85052de2d1278b2ad06e3796315166ccb1 From 39067b0b4b20d4ca0c5c0bea893e45e89e73d2d5 Mon Sep 17 00:00:00 2001 From: Simba Zhang Date: Thu, 22 Oct 2026 08:16:43 -0700 Subject: [PATCH 2/4] ci: capture Swift backtrace when the integration server crashes The server integration job for Qwen2.5-0.5B dies with SIGSEGV during prefill on the mlx-swift-lm b620ae8 bump, intermittently (4 of 5 attempts so far, passing on the rest). The CI log has no stack, so this turns on the Swift runtime backtracer for the E2E step and dumps the macOS crash report on failure. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 219b5f6e..d04c3e5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,6 +190,8 @@ jobs: env: HF_HUB_DOWNLOAD_TIMEOUT: "600" SWIFTLM_TEST_MODEL: ${{ matrix.test_model }} + # Diagnostic: print a Swift backtrace when the server dies with a signal. + SWIFT_BACKTRACE: "enable=yes,interactive=no" run: | chmod +x tests/test-${{ matrix.modality }}.sh for attempt in 1 2 3; do @@ -199,6 +201,16 @@ jobs: sleep 10 done + - name: Dump SwiftLM crash reports on failure + if: failure() + run: | + sleep 5 + ls -la "$HOME/Library/Logs/DiagnosticReports" 2>/dev/null || echo "no DiagnosticReports dir" + for f in $(ls -t "$HOME"/Library/Logs/DiagnosticReports/*SwiftLM* 2>/dev/null | head -2); do + echo "===== $f" + head -c 60000 "$f" + done + - name: Upload test logs on failure if: failure() uses: actions/upload-artifact@v4 From d202c0eea408a2981df60065e7f135d190da931c Mon Sep 17 00:00:00 2001 From: Simba Zhang Date: Thu, 22 Oct 2026 22:04:29 -0700 Subject: [PATCH 3/4] ci: collect the SwiftLM crash report after every integration attempt The integration job retries the server test up to three times, so a crash in attempt 1 that passes on attempt 2 shows as a green job. The crash report dump only ran when the whole job failed, which hid every such crash. Collect the report after each failed attempt instead, limited to reports newer than that attempt's start. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/ci.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d04c3e5c..a8946f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,21 +196,22 @@ jobs: chmod +x tests/test-${{ matrix.modality }}.sh for attempt in 1 2 3; do echo "Attempt $attempt of 3..." + # Diagnostic: collect the SwiftLM crash report from every attempt, not only when the + # job fails, because the job retries and a crash in attempt 1 is otherwise invisible. + touch "$RUNNER_TEMP/attempt-$attempt" if tests/test-${{ matrix.modality }}.sh .build/release/SwiftLM 15413; then exit 0; fi + # ReportCrash writes the report a moment after the server dies, so wait before looking. + sleep 10 + reports=$(find "$HOME/Library/Logs/DiagnosticReports" -name '*SwiftLM*' -newer "$RUNNER_TEMP/attempt-$attempt" 2>/dev/null || true) + if [ -z "$reports" ]; then echo "no new SwiftLM crash report for attempt $attempt"; fi + for f in $reports; do + echo "===== crash report, attempt $attempt: $f" + head -c 60000 "$f" + done if [ "$attempt" -eq 3 ]; then echo "All attempts failed"; exit 1; fi sleep 10 done - - name: Dump SwiftLM crash reports on failure - if: failure() - run: | - sleep 5 - ls -la "$HOME/Library/Logs/DiagnosticReports" 2>/dev/null || echo "no DiagnosticReports dir" - for f in $(ls -t "$HOME"/Library/Logs/DiagnosticReports/*SwiftLM* 2>/dev/null | head -2); do - echo "===== $f" - head -c 60000 "$f" - done - - name: Upload test logs on failure if: failure() uses: actions/upload-artifact@v4 From 2d8a787a85618778ff151950a23ec97693a55c4a Mon Sep 17 00:00:00 2001 From: Simba Zhang Date: Fri, 23 Oct 2026 23:13:03 -0700 Subject: [PATCH 4/4] fix: save prompt cache at producer .info, not first chunk The onPrefillDone save ran on the first streamed token, while the producer had already started the next decode step on the same KVCacheSimple. PromptCache.save read the live key/value arrays on the HTTP thread during that update. With mlx-swift f93decf freeing the old context before replacing it, the race surfaces as a use-after-free (SIGBUS in CI). Move the save to the .info case, which is the producer's last yield, so no cache writes happen after it. Early exits (stop sequence, client disconnect, error before .info) no longer save the prompt cache; multi-turn clients re-prefill those prompts. Co-Authored-By: Claude Haiku 5.5 --- Sources/SwiftLM/Server.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftLM/Server.swift b/Sources/SwiftLM/Server.swift index 5272aa88..54146615 100644 --- a/Sources/SwiftLM/Server.swift +++ b/Sources/SwiftLM/Server.swift @@ -2765,8 +2765,8 @@ func handleChatCompletion( // layers (RotatingKVCache under --ctx-size; this path must run before the // sliding-window one, which misses on any MambaCache), and the generic path below // refuses them: recurrent state cannot be - // trimmed, and the onPrefillDone save runs after the first decode token has been - // fed. Without this, every agent turn re-prefills the whole conversation. Instead: + // trimmed, and the onPrefillDone save runs once the producer has emitted `.info`. + // Without this, every agent turn re-prefills the whole conversation. Instead: // resume from an exact cached prefix, prefill to the turn boundary, snapshot there // synchronously, then generate the few tokens after it. let hybridBoundary = (skipPromptCache || draftModelRef != nil || config.mtp || config.turboKV) @@ -3302,7 +3302,6 @@ func handleChatStreaming( let memSnap = MemoryUtils.snapshot() print("srv slot update: id 0 | prefill done | n_tokens=\(promptTokenCount), t=\(String(format: "%.2f", prefillDur))s, \(String(format: "%.1f", prefillTokPerSec))t/s | OS_RAM=\(String(format: "%.1f", memSnap.os))GB | MEM_DEMAND=\(String(format: "%.1f", memSnap.demand))GB | GPU_MEM=\(String(format: "%.1f", memSnap.gpu))GB") print("srv generate: id 0 | ", terminator: "") - if let onPrefillDone { await onPrefillDone() } firstToken = false } if tokenEcho { @@ -3413,6 +3412,11 @@ func handleChatStreaming( print("[SwiftLM] Rejected tool call: reason=\(rejection.reason) tool=\(rejection.toolName ?? "?") detail=\(rejection.detail ?? "n/a")") case .info(let info): + // `.info` is the producer's last yield, so the cache is no longer written once + // it arrives. The prompt is saved here, not on the first chunk: that raced the + // decode step the producer had already started on the same KVCacheSimple. An + // early exit (stop sequence, disconnect) never reaches `.info` and skips the save. + if !firstToken, let onPrefillDone { await onPrefillDone() } // The chunk count undercounts: tokens the decoder buffers (tool-call // bodies, partial text) emit no chunk. `info` carries the real count. completionTokenCount = info.generationTokenCount @@ -3568,7 +3572,6 @@ func handleChatNonStreaming( let memSnap = MemoryUtils.snapshot() print("srv slot update: id 0 | prefill done | n_tokens=\(promptTokenCount), t=\(String(format: "%.2f", prefillDur))s, \(String(format: "%.1f", prefillTokPerSec))t/s | OS_RAM=\(String(format: "%.1f", memSnap.os))GB | MEM_DEMAND=\(String(format: "%.1f", memSnap.demand))GB | GPU_MEM=\(String(format: "%.1f", memSnap.gpu))GB") print("srv generate: id 0 | ", terminator: "") - if let onPrefillDone { await onPrefillDone() } firstToken = false } if tokenEcho { @@ -3587,6 +3590,9 @@ func handleChatNonStreaming( // See the matching comment in `handleChatStreaming`: log only, no wire shape. print("[SwiftLM] Rejected tool call: reason=\(rejection.reason) tool=\(rejection.toolName ?? "?") detail=\(rejection.detail ?? "n/a")") case .info(let info): + // The producer's last yield; see the matching comment in `handleChatStreaming`. + // The stop-sequence `break` above exits before `.info`, so it skips the save. + if !firstToken, let onPrefillDone { await onPrefillDone() } generationStopReason = info.stopReason completionTokenCount = info.generationTokenCount // real count; see handleChatStreaming }