fix: replace hard timeout with inactivity timeout for model loading (#4707)

The 180s wall-clock timeout would kill model loads on slow connections
even when the download was actively progressing. Now the worker sends
heartbeat status messages every 30s during loading, and the orchestrator
resets its 300s deadline on each one — so it only times out when the
subprocess goes truly silent.
This commit is contained in:
Roland Tannous 2026-03-31 07:35:04 +04:00 committed by GitHub
commit d6d3f59984
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 50 additions and 12 deletions

View file

@ -263,12 +263,17 @@ class InferenceOrchestrator:
except (EOFError, OSError, ValueError):
return None
def _wait_response(self, expected_type: str, timeout: float = 120.0) -> dict:
def _wait_response(self, expected_type: str, timeout: float = 300.0) -> dict:
"""Block until a response of the expected type arrives.
Also handles 'status' and 'error' events during the wait.
Returns the matching response dict.
Raises RuntimeError on timeout or subprocess crash.
The *timeout* is an **inactivity** timeout: it resets whenever the
subprocess sends a status message, so long-running operations (large
downloads, slow model loads) won't be killed as long as the subprocess
keeps reporting progress.
"""
deadline = time.monotonic() + timeout
@ -293,6 +298,8 @@ class InferenceOrchestrator:
if rtype == "status":
logger.info("Subprocess status: %s", resp.get("message", ""))
# Reset deadline — subprocess is still alive and working
deadline = time.monotonic() + timeout
continue
# Other response types during wait — skip
@ -303,7 +310,8 @@ class InferenceOrchestrator:
)
raise RuntimeError(
f"Timeout waiting for '{expected_type}' response after {timeout}s"
f"Timeout waiting for '{expected_type}' response "
f"(no activity for {timeout}s)"
)
def _drain_queue(self) -> list:
@ -625,7 +633,7 @@ class InferenceOrchestrator:
needed_major,
)
self._spawn_subprocess(sub_config)
resp = self._wait_response("loaded", timeout = 180)
resp = self._wait_response("loaded")
# Update local state from response
if resp.get("success"):
@ -672,7 +680,7 @@ class InferenceOrchestrator:
"model_name": model_name,
}
)
resp = self._wait_response("unloaded", timeout = 30)
resp = self._wait_response("unloaded")
# Update local state
self.models.pop(model_name, None)

View file

@ -22,6 +22,7 @@ from loggers import get_logger
import os
import queue as _queue
import sys
import threading
import time
import traceback
from io import BytesIO
@ -114,6 +115,29 @@ def _build_model_config(config: dict):
return mc
def _start_heartbeat(resp_queue: Any, interval: float = 30.0) -> threading.Event:
"""Start a daemon thread that sends periodic status heartbeats.
Returns a stop event set it to terminate the heartbeat thread.
"""
stop = threading.Event()
def _beat():
while not stop.wait(interval):
_send_response(
resp_queue,
{
"type": "status",
"message": "Still loading model...",
"ts": time.time(),
},
)
t = threading.Thread(target = _beat, daemon = True)
t.start()
return stop
def _handle_load(backend, config: dict, resp_queue: Any) -> None:
"""Handle a load command: load a model into the backend."""
try:
@ -173,14 +197,20 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
model_name,
)
success = backend.load_model(
config = mc,
max_seq_length = config.get("max_seq_length", 2048),
load_in_4bit = load_in_4bit,
hf_token = hf_token,
trust_remote_code = trust_remote_code,
gpu_ids = config.get("resolved_gpu_ids"),
)
# Send heartbeats every 30s so the orchestrator knows we're still alive
# (download / weight loading can take a long time on slow connections)
heartbeat_stop = _start_heartbeat(resp_queue, interval = 30.0)
try:
success = backend.load_model(
config = mc,
max_seq_length = config.get("max_seq_length", 2048),
load_in_4bit = load_in_4bit,
hf_token = hf_token,
trust_remote_code = trust_remote_code,
gpu_ids = config.get("resolved_gpu_ids"),
)
finally:
heartbeat_stop.set()
if success:
# Build model_info for the parent to mirror