fix(connection): use lightweight heartbeat with single-flight and fail threshold

This commit is contained in:
JOJO 2026-05-05 15:24:26 +08:00
parent 971f29b059
commit 4e7da0de23
3 changed files with 60 additions and 7 deletions

View File

@ -69,6 +69,28 @@ def _is_host_mode_request() -> bool:
return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host"
@status_bp.route('/api/health')
@api_login_required
def get_health():
"""轻量健康检查:用于前端连接心跳,避免触发重型 status 计算。"""
started_at = time.perf_counter()
heartbeat_id = request.headers.get("X-Connection-Heartbeat", "").strip()
username = session.get("username") or "-"
payload = {
"success": True,
"status": "ok",
"server_time_ms": int(time.time() * 1000),
}
elapsed_ms = (time.perf_counter() - started_at) * 1000
if request.args.get("diag") == "1" or elapsed_ms >= 800:
log_conn_diag(
f"health hb={heartbeat_id or '-'} user={username} "
f"elapsed_ms={elapsed_ms:.1f} host_mode={bool(session.get('host_mode'))} "
f"workspace_id={session.get('workspace_id') or '-'}"
)
return jsonify(payload)
@status_bp.route('/api/status')
@api_login_required
@with_terminal

View File

@ -2025,18 +2025,32 @@ export const uiMethods = {
},
async checkConnectionHealth() {
if (this.connectionHeartbeatInFlight) {
connectionDiag('log', 'health-skip-inflight', {
seq: this.connectionHeartbeatSeq,
isConnected: !!this.isConnected,
failCount: this.connectionHeartbeatFailCount
});
return;
}
this.connectionHeartbeatInFlight = true;
const seq = Number(this.connectionHeartbeatSeq || 0) + 1;
this.connectionHeartbeatSeq = seq;
const requestId = `${Date.now()}-${seq}`;
const startedAt = Date.now();
const diagEnabled = isConnectionDiagEnabled();
const statusUrl = diagEnabled ? '/api/status?diag=1' : '/api/status';
const healthUrl = diagEnabled ? '/api/health?diag=1' : '/api/health';
const wasConnected = !!this.isConnected;
let responseStatus: number | null = null;
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeoutId = controller ? window.setTimeout(() => controller.abort(), 5000) : null;
const timeoutMs =
typeof this.connectionHeartbeatRequestTimeoutMs === 'number' &&
this.connectionHeartbeatRequestTimeoutMs > 0
? this.connectionHeartbeatRequestTimeoutMs
: 5000;
const timeoutId = controller ? window.setTimeout(() => controller.abort(), timeoutMs) : null;
try {
const response = await fetch(statusUrl, {
const response = await fetch(healthUrl, {
method: 'GET',
cache: 'no-store',
headers: {
@ -2066,6 +2080,7 @@ export const uiMethods = {
failCountBeforeRecover,
status: responseStatus,
diagEnabled,
endpoint: healthUrl,
conversationId: this.currentConversationId || null,
taskInProgress: !!this.taskInProgress,
streamingMessage: !!this.streamingMessage,
@ -2083,6 +2098,7 @@ export const uiMethods = {
seq,
elapsedMs: this.connectionHeartbeatLastLatencyMs,
status: responseStatus,
endpoint: healthUrl,
visibility: document?.visibilityState || 'unknown'
});
}
@ -2094,12 +2110,20 @@ export const uiMethods = {
const errName = error?.name || 'Error';
const errMessage = error?.message || String(error);
this.connectionHeartbeatLastError = `${errName}: ${errMessage}`;
// 一次失败就先置灰,避免“服务已断但常亮绿色”的误导
const failThreshold =
typeof this.connectionHeartbeatFailThreshold === 'number' &&
this.connectionHeartbeatFailThreshold > 0
? this.connectionHeartbeatFailThreshold
: 3;
const shouldDisconnect = nextFailCount >= failThreshold;
// 改为连续失败阈值后再置灰,避免偶发请求超时导致“假断连”
if (shouldDisconnect) {
this.isConnected = false;
if (wasConnected) {
}
if (wasConnected && shouldDisconnect) {
this.connectionHeartbeatLastChangeAt = Date.now();
}
const shouldLogFail = wasConnected || nextFailCount <= 3 || nextFailCount % 10 === 0;
const shouldLogFail = wasConnected || nextFailCount <= failThreshold || nextFailCount % 10 === 0;
connectionDiag(
shouldLogFail ? 'warn' : 'log',
'health-failed',
@ -2109,9 +2133,12 @@ export const uiMethods = {
elapsedMs: this.connectionHeartbeatLastLatencyMs,
failCount: nextFailCount,
status: responseStatus,
endpoint: healthUrl,
diagEnabled,
wasConnected,
nowConnected: !!this.isConnected,
failThreshold,
shouldDisconnect,
errorName: errName,
errorMessage: errMessage,
timeout: errName === 'AbortError',
@ -2125,6 +2152,7 @@ export const uiMethods = {
{ force: shouldLogFail }
);
} finally {
this.connectionHeartbeatInFlight = false;
if (timeoutId) {
clearTimeout(timeoutId);
}

View File

@ -144,6 +144,9 @@ export function dataState() {
connectionHeartbeatLastError: '',
connectionHeartbeatLastStatusCode: null,
connectionHeartbeatLastChangeAt: 0,
connectionHeartbeatInFlight: false,
connectionHeartbeatFailThreshold: 3,
connectionHeartbeatRequestTimeoutMs: 5000,
connectionHeartbeatIntervalMs: 8000,
connectionHeartbeatDisconnectedIntervalMs: 1000,