fix(heartbeat): stop restarting the model mid-answer — busy is not down - #308
Merged
Conversation
Chat returned "My reasoning ran on without reaching a final answer" twice in a
row on long prompts. Not a chat bug: the heartbeat was killing the model while
it was answering.
Chain, from the audit log:
08:03:05 service_down LLM/Vision http://localhost:8083/v1/models
08:08:50 service_down LLM/Vision http://localhost:8083/v1/models
08:08:58 qwen3.6 restarted (8s later), restart_time now 21
Those two timestamps are exactly the two failed replies (10:03 / 10:08 CEST).
_check_service() probes the model with a 5s HTTP timeout. mlx_vlm.server is
single-threaded: an 11,216-token prefill at ~800 tok/s blocks it for ~14s, so a
probe that lands during generation times out. check_services_and_alert() then
treated the FIRST failure as down and called _try_restart("qwen3.6") — killing
the in-flight request. The frontend got reasoning with no final answer and
rendered the local-model-glitch notice. Longer context = longer prefill = higher
chance of self-destruction, so big prompts failed most reliably.
No crash was involved: no traceback, no OOM, no jetsam kill, max_memory_restart
unset. It was our own graceful pm2 restart every time.
Fix: distinguish BUSY from DEAD before restarting. New _is_listening() does a
bare TCP connect — a process mid-prefill still has its socket bound, a crashed
one does not. If the port still accepts, log it as busy, clear the failure
counter and skip the restart. Genuine crashes stop listening and still recover
through the existing path.
Verified by running the same scenario against both versions (model busy, port
listening): pre-fix issues restarts ['qwen3.6'], post-fix issues NONE.
tests/test_watchdog.py gains 3 cases and test_builtin_down_triggers_single_restart
now forces _is_listening False so it keeps exercising a genuinely dead service
instead of passing for the wrong reason on a box where :8083 is live.
Full suite: 2740 passed, 78 skipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Chat returned "My reasoning ran on without reaching a final answer" twice in a row on long prompts. It isn't a chat bug — the heartbeat was killing the model while it was answering.
The chain, straight from the audit log
Those two timestamps are exactly the two failed replies (10:03 / 10:08 CEST).
Root cause
_check_service()probes the model with a 5-second HTTP timeout.mlx_vlm.serveris single-threaded — an 11,216-token prefill at ~800 tok/s blocks it for ~14s, so any probe landing during generation times out.check_services_and_alert()then treated the first failure as "down" (if failures[name] == 1:) and called_try_restart("qwen3.6"), killing the in-flight request. The frontend received reasoning with no final answer and rendered the local-model-glitch notice.Longer context → longer prefill → higher chance of self-destruction. Big prompts failed most reliably, which is why it looked random.
Nothing crashed: no traceback, no OOM, no jetsam kill,
max_memory_restartunset. It was our own graceful pm2 restart, every time.Fix
Distinguish BUSY from DEAD before restarting. New
_is_listening()does a bare TCP connect — a process mid-prefill still has its socket bound; a crashed one doesn't. If the port still accepts, log it as busy, clear the failure counter, skip the restart. Genuine crashes stop listening and still recover through the existing path.Verification
Same scenario run against both versions (model busy, port listening):
tests/test_watchdog.pytest_builtin_down_triggers_single_restartnow forces_is_listeningFalse so it keeps exercising a genuinely dead service instead of passing for the wrong reason on a box where:8083is live🤖 Generated with Claude Code