Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller). Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE + 5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the kernel tear sockets down on flaky networks before the app sent its first keepalive byte — drops at ~10s during long thinking phases. New named constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't re-introduce the misalignment. Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger. Without it, any narrow race between the streaming thread clearing pending_user_message and STREAMS.pop(stream_id) produces a false-positive 'Previous turn did not complete.' marker on a turn that finished correctly (reproducible after every command-approval turn). Defense-in-depth, not the root-cause fix — the actual streaming-thread leak path is tracked separately. Falsy pending_started_at (legacy sidecars) treated as 'old enough' so legitimate legacy-data recovery still works. Plus logger.warning telemetry on every legitimate repair so the next batch of user reports tells us whether the underlying race still fires. Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM, TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New _LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/ RFC1918 heuristic — either signal triggers no-strip. Backward compat preserved for OpenAI-compatible proxies on public hosts (LiteLLM at litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom rule for loopback hosts (see api/config.py and test_model_resolver.py docstring update). Reported by @akarichan8231 in Discord on 2026-05-04. 42 regression tests across: tests/test_issue1623_sse_heartbeat_alignment.py (3) tests/test_issue1624_repair_stale_pending_grace.py (9) tests/test_issue1625_local_server_model_id_preservation.py (30) 4142 -> 4184 passing. 0 regressions.
90 lines
4.0 KiB
Python
90 lines
4.0 KiB
Python
"""Tests for #1623: SSE app heartbeat must fire well under the kernel keepalive timeout.
|
|
|
|
Bug shape: server.py's per-connection TCP keepalive (added v0.50.289 / #1581)
|
|
declares a peer dead at KEEPIDLE=10s + KEEPINTVL=5s * KEEPCNT=3 = 25s. The
|
|
SSE handlers in api/routes.py used a 30s app-level heartbeat. When the LLM
|
|
is thinking and the queue is idle, the kernel could tear down the socket
|
|
before the app sent its first heartbeat byte — flaky-network drops at ~10s
|
|
that the user perceived as "the stream died around 10 seconds in."
|
|
|
|
Fix: lower the heartbeat to 5s at every SSE handler and pin the inequality
|
|
with a regression test so future tuning of either timer can't re-introduce
|
|
the misalignment.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
REPO = Path(__file__).parent.parent
|
|
|
|
|
|
def test_sse_heartbeat_constant_below_kernel_keepalive_window():
|
|
"""The named constant exists and is at most half the kernel keepalive
|
|
timeout (10 + 5*3 = 25s). 5s gives the kernel ~5x headroom."""
|
|
src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
|
|
|
|
# The constant must be defined.
|
|
assert "_SSE_HEARTBEAT_INTERVAL_SECONDS" in src, (
|
|
"Named SSE heartbeat constant must exist (#1623)"
|
|
)
|
|
|
|
# Pull the literal value.
|
|
import re
|
|
m = re.search(r"_SSE_HEARTBEAT_INTERVAL_SECONDS\s*=\s*(\d+)", src)
|
|
assert m, "Could not parse _SSE_HEARTBEAT_INTERVAL_SECONDS literal"
|
|
heartbeat = int(m.group(1))
|
|
|
|
# Reproduce the kernel-keepalive window from server.py setsockopt block.
|
|
server_src = (REPO / "server.py").read_text(encoding="utf-8")
|
|
assert "TCP_KEEPIDLE" in server_src, "TCP_KEEPIDLE must be set on accepted connections"
|
|
keepidle = int(re.search(r"TCP_KEEPIDLE.*?(\d+)\)", server_src, re.S).group(1))
|
|
keepintvl = int(re.search(r"TCP_KEEPINTVL.*?(\d+)\)", server_src, re.S).group(1))
|
|
keepcnt = int(re.search(r"TCP_KEEPCNT.*?(\d+)\)", server_src, re.S).group(1))
|
|
kernel_window = keepidle + keepintvl * keepcnt
|
|
|
|
# The acceptance criterion from the bug: app heartbeat <= kernel window / 2.
|
|
assert heartbeat * 2 <= kernel_window, (
|
|
f"App SSE heartbeat ({heartbeat}s) must be at most half of the kernel "
|
|
f"keepalive window ({kernel_window}s = {keepidle} + {keepintvl}*{keepcnt}). "
|
|
f"Otherwise flaky-network probes can tear down the socket before the "
|
|
f"app sends a heartbeat byte. (#1623)"
|
|
)
|
|
|
|
|
|
def test_no_sse_handler_uses_30s_or_higher_timeout():
|
|
"""No SSE/long-poll handler in routes.py should still be using the old
|
|
30s/25s timeout. Every queue.get(timeout=...) call inside an SSE handler
|
|
must reference the named constant, not a hard-coded number."""
|
|
src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
|
|
|
|
import re
|
|
# Catch q.get(timeout=30), subscriber.get(timeout=30), term.output.get(timeout=25), etc.
|
|
bad = re.findall(r"\.get\(timeout=3[05]\)", src)
|
|
assert not bad, (
|
|
f"Found {len(bad)} SSE handler call(s) still using a 25/30s timeout: {bad}. "
|
|
"All should use _SSE_HEARTBEAT_INTERVAL_SECONDS (#1623)."
|
|
)
|
|
|
|
|
|
def test_each_named_sse_handler_uses_constant():
|
|
"""Each known SSE handler queue-poll site must reference the constant."""
|
|
src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
|
|
|
|
expected_callers = [
|
|
"subscriber.get(timeout=_SSE_HEARTBEAT_INTERVAL_SECONDS)", # main agent SSE
|
|
"term.output.get(timeout=_SSE_HEARTBEAT_INTERVAL_SECONDS)", # terminal SSE
|
|
]
|
|
for caller in expected_callers:
|
|
assert caller in src, (
|
|
f"Expected SSE handler to call {caller!r} (#1623). "
|
|
"If this assertion fails, the SSE heartbeat misalignment may have regressed."
|
|
)
|
|
|
|
# Also: at least 3 sites should be using the constant overall (main agent,
|
|
# terminal, plus the gateway watcher and approval/clarify pollers).
|
|
n_uses = src.count("get(timeout=_SSE_HEARTBEAT_INTERVAL_SECONDS)")
|
|
assert n_uses >= 4, (
|
|
f"Expected at least 4 SSE/long-poll sites using the named constant; found {n_uses}. "
|
|
"Every long-lived idle queue poll must align below the kernel keepalive window."
|
|
)
|