Recover from prefill-time deaths and stop respawn racing the MTP reload
Two gaps in the tool-loop respawn retry, both reproduced before fixing. A child that exits during prefill has already accepted the socket, so httpx raises ReadError, WriteError or RemoteProtocolError rather than ConnectError. Those all arrive before the response opens, which is exactly the window where a replay is safe, but the helper only caught ConnectError and gave up. Widen the catch to NetworkError plus RemoteProtocolError. Timeouts stay excluded on purpose: they mean the server is slow, not dead, and retrying one would spend the 20 minute first-token budget twice. Windows resets connections where Linux refuses them, so this also covers the common Windows presentation. _maybe_recover_from_mtp_crash returns False both when the crash is not an MTP crash and when an MTP-free reload is already in flight. Callers read that as permission to respawn, so _respawn_if_dead replayed the crashing MTP kwargs and, by replacing the process, made the in-flight reload abort on its own newer-load check. Skip the respawn while that reload owns the corpse. The guard lives in _respawn_if_dead so the plain chat path gets it too. Regression tests for both, including a guard against retrying prefill timeouts.
This commit is contained in:
parent
b6dfd67b72
commit
db78184be3
3 changed files with 86 additions and 1 deletions
|
|
@ -10544,6 +10544,13 @@ class LlamaCppBackend:
|
|||
# Process is alive: either a concurrent caller already respawned
|
||||
# it (healthy), or this connection error wasn't a dead server.
|
||||
return self._healthy
|
||||
with self._mtp_runtime_fallback_lock:
|
||||
if self._mtp_runtime_fallback_in_progress:
|
||||
# An MTP-free reload already owns this corpse. Replaying the
|
||||
# old kwargs would restart the crashing MTP config and make
|
||||
# that reload abort on its "newer load is active" check.
|
||||
logger.info("Respawn skipped: an MTP-free reload is already recovering.")
|
||||
return False
|
||||
kwargs = self._last_load_kwargs
|
||||
if not kwargs:
|
||||
return False
|
||||
|
|
@ -10569,6 +10576,12 @@ class LlamaCppBackend:
|
|||
effects. Resolve ``base_url`` on each attempt because a respawn may use a new
|
||||
port. The one-retry budget is per model request, not per chat turn, so a long
|
||||
tool loop never discards a completed tool just because an earlier turn recovered.
|
||||
|
||||
A child that dies during prefill has already accepted the socket, so it
|
||||
surfaces as ReadError/WriteError/RemoteProtocolError rather than
|
||||
ConnectError; all of them mean the transport is gone. Timeouts are excluded
|
||||
on purpose: they mean the server is slow, not dead, and replaying one would
|
||||
just spend the first-token budget twice.
|
||||
"""
|
||||
for attempt in range(2):
|
||||
response_opened = False
|
||||
|
|
@ -10578,7 +10591,7 @@ class LlamaCppBackend:
|
|||
response_opened = True
|
||||
yield opened
|
||||
return
|
||||
except httpx.ConnectError as exc:
|
||||
except (httpx.NetworkError, httpx.RemoteProtocolError) as exc:
|
||||
if response_opened:
|
||||
raise
|
||||
if self._maybe_recover_from_mtp_crash(exc):
|
||||
|
|
|
|||
|
|
@ -2422,6 +2422,59 @@ def test_connect_error_retry_is_bounded(monkeypatch):
|
|||
assert len(payloads) == 2
|
||||
|
||||
|
||||
def test_pre_header_transport_errors_also_respawn(monkeypatch):
|
||||
"""A child that dies during prefill already accepted the socket, so it does
|
||||
not surface as ConnectError. Nothing has streamed yet, so replay is safe."""
|
||||
import httpx
|
||||
for exc in (
|
||||
httpx.RemoteProtocolError("server disconnected without sending a response"),
|
||||
httpx.ReadError("connection reset by peer"),
|
||||
httpx.WriteError("broken pipe"),
|
||||
):
|
||||
payloads: list[dict] = []
|
||||
backend = _make_backend(
|
||||
monkeypatch, [exc, [_sse({"content": "Recovered."}), _done()]], payloads
|
||||
)
|
||||
respawn_calls = _patch_successful_respawn(monkeypatch, backend)
|
||||
|
||||
events = list(
|
||||
backend.generate_chat_completion_with_tools(
|
||||
messages = [{"role": "user", "content": "hello"}],
|
||||
tools = [{"type": "function", "function": {"name": "python"}}],
|
||||
max_tool_iterations = 1,
|
||||
)
|
||||
)
|
||||
|
||||
assert respawn_calls == [True], type(exc).__name__
|
||||
assert len(payloads) == 2, type(exc).__name__
|
||||
assert any(e.get("type") == "content" and e.get("text") == "Recovered." for e in events)
|
||||
|
||||
|
||||
def test_prefill_timeout_is_not_retried(monkeypatch):
|
||||
"""A slow-but-alive server must not have its first-token budget spent twice."""
|
||||
import httpx
|
||||
for exc in (httpx.ReadTimeout("no first token"), httpx.PoolTimeout("pool")):
|
||||
payloads: list[dict] = []
|
||||
backend = _make_backend(monkeypatch, [exc], payloads)
|
||||
respawn_calls = _patch_successful_respawn(monkeypatch, backend)
|
||||
|
||||
raised = False
|
||||
try:
|
||||
list(
|
||||
backend.generate_chat_completion_with_tools(
|
||||
messages = [{"role": "user", "content": "hello"}],
|
||||
tools = [{"type": "function", "function": {"name": "python"}}],
|
||||
max_tool_iterations = 1,
|
||||
)
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raised = True
|
||||
|
||||
assert raised, type(exc).__name__
|
||||
assert respawn_calls == [], type(exc).__name__
|
||||
assert len(payloads) == 1, type(exc).__name__
|
||||
|
||||
|
||||
def test_mtp_crash_recovery_wins_over_respawn(monkeypatch):
|
||||
"""An MTP crash reloads without MTP, so never respawn the same config on top."""
|
||||
import httpx
|
||||
|
|
|
|||
|
|
@ -528,6 +528,25 @@ def test_runtime_recovery_is_single_flight(monkeypatch):
|
|||
release.set()
|
||||
|
||||
|
||||
def test_respawn_defers_to_an_inflight_mtp_reload(monkeypatch):
|
||||
# The single-flight "already recovering" False must not read as "not an MTP
|
||||
# crash": respawning would replay the crashing MTP kwargs and make the
|
||||
# in-flight no-MTP reload abort on its "newer load is active" check.
|
||||
b = _recovery_backend()
|
||||
b._mtp_runtime_fallback_in_progress = True
|
||||
loads: list[dict] = []
|
||||
monkeypatch.setattr(b, "load_model", lambda **kwargs: loads.append(kwargs) or True)
|
||||
|
||||
assert b._respawn_if_dead() is False
|
||||
assert loads == []
|
||||
|
||||
# Once that reload finishes, an ordinary respawn works again.
|
||||
b._mtp_runtime_fallback_in_progress = False
|
||||
b._process.returncode = -9 # only the respawn path logs it
|
||||
assert b._respawn_if_dead() is True
|
||||
assert [kw.get("speculative_type") for kw in loads] == ["auto"]
|
||||
|
||||
|
||||
def test_runtime_recovery_rechecks_cancel_before_reload():
|
||||
# recover() must re-check the cancel flag after the death poll (load_model
|
||||
# clears it), so a reload scheduled just before /unload can't resurrect it.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue