fix: harden service websocket reconnect flows

Stabilize the service console and callback-host websocket paths so idle disconnects and mid-task client drops no longer wedge task execution or spam repeated commands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-09 10:34:34 +08:00
parent 96c3bf1dee
commit 57b9be733d
8 changed files with 353 additions and 55 deletions

View File

@@ -386,6 +386,17 @@
};
let socket = null;
let reconnectTimer = null;
let connectTimeoutTimer = null;
let heartbeatTimer = null;
let shouldReconnect = false;
let lastHeartbeatAt = 0;
const reconnectDelayMs = 1500;
const reconnectCloseCode = 4000;
const reconnectCloseReason = "manual_disconnect";
const heartbeatIntervalMs = 15000;
const heartbeatTimeoutMs = 30000;
const connectTimeoutMs = 5000;
function appendRow(kind, text) {
if (elements.emptyState) {
@@ -410,6 +421,59 @@
elements.messageStream.scrollTop = elements.messageStream.scrollHeight;
}
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function clearConnectTimeoutTimer() {
if (connectTimeoutTimer) {
clearTimeout(connectTimeoutTimer);
connectTimeoutTimer = null;
}
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function startHeartbeat() {
stopHeartbeat();
lastHeartbeatAt = Date.now();
heartbeatTimer = setInterval(() => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
if (Date.now() - lastHeartbeatAt > heartbeatTimeoutMs) {
appendRow("error", "heartbeat missed, forcing reconnect");
const activeSocket = socket;
socket = null;
stopHeartbeat();
clearConnectTimeoutTimer();
activeSocket.close();
scheduleReconnect();
return;
}
socket.send(JSON.stringify({ type: "ping" }));
}, heartbeatIntervalMs);
}
function scheduleReconnect() {
clearReconnectTimer();
clearConnectTimeoutTimer();
if (!shouldReconnect) {
return;
}
appendRow("status", "service websocket disconnected, retrying");
reconnectTimer = setTimeout(() => connectOrDisconnectService(true), reconnectDelayMs);
updateUiState();
}
function setValidation(message) {
elements.validationText.textContent = message;
}
@@ -417,7 +481,7 @@
function updateUiState() {
const readyState = socket ? socket.readyState : WebSocket.CLOSED;
const connected = readyState === WebSocket.OPEN;
const connecting = readyState === WebSocket.CONNECTING;
const connecting = readyState === WebSocket.CONNECTING || Boolean(reconnectTimer);
let stateText = "未连接";
let stateValue = "disconnected";
@@ -435,35 +499,68 @@
elements.connectionState.dataset.state = stateValue;
}
function connectOrDisconnectService() {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
socket.close();
function connectOrDisconnectService(forceConnect = false) {
if (!forceConnect && socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
shouldReconnect = false;
clearReconnectTimer();
clearConnectTimeoutTimer();
stopHeartbeat();
socket.close(reconnectCloseCode, reconnectCloseReason);
return;
}
clearReconnectTimer();
clearConnectTimeoutTimer();
const url = elements.wsUrl.value.trim() || defaultWsUrl;
elements.wsUrl.value = url;
shouldReconnect = true;
const nextSocket = new WebSocket(url);
socket = nextSocket;
updateUiState();
connectTimeoutTimer = setTimeout(() => {
if (socket !== nextSocket || nextSocket.readyState !== WebSocket.CONNECTING) {
return;
}
appendRow("error", "service websocket connect timed out");
socket = null;
nextSocket.close();
scheduleReconnect();
}, connectTimeoutMs);
nextSocket.addEventListener("open", () => {
if (socket !== nextSocket) {
return;
}
clearReconnectTimer();
clearConnectTimeoutTimer();
lastHeartbeatAt = Date.now();
startHeartbeat();
appendRow("status", "service websocket connected");
updateUiState();
});
nextSocket.addEventListener("close", () => {
if (socket === nextSocket) {
socket = null;
nextSocket.addEventListener("close", (event) => {
if (socket !== nextSocket) {
return;
}
appendRow("status", "service websocket disconnected");
updateUiState();
socket = null;
clearConnectTimeoutTimer();
stopHeartbeat();
const manualClose = event.code === reconnectCloseCode || event.reason === reconnectCloseReason;
if (manualClose) {
shouldReconnect = false;
appendRow("status", "service websocket disconnected");
updateUiState();
return;
}
scheduleReconnect();
});
nextSocket.addEventListener("error", () => {
if (socket !== nextSocket) {
return;
}
appendRow("error", "service websocket error");
});
@@ -471,6 +568,7 @@
}
function handleMessage(event) {
lastHeartbeatAt = Date.now();
let message;
try {
message = JSON.parse(event.data);
@@ -492,6 +590,8 @@
case "busy":
appendRow("error", message.message);
break;
case "pong":
break;
default:
appendRow("error", "unknown service message: " + event.data);
}