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

@@ -83,6 +83,7 @@ fn run() -> Result<(), String> {
eprintln!("busy: {message}");
break;
}
ServiceMessage::Pong => {}
}
}
Message::Close(_) => {

View File

@@ -896,36 +896,66 @@ window.sgclawOnEval = sgclawOnEval;
window.callBackJsToCpp = callBackJsToCpp;
document.getElementById('wi').textContent = SGCLAW_BROWSER_WS_URL;
_log('Connecting to browser WebSocket\u2026');
const sgclawSocket = new WebSocket(SGCLAW_BROWSER_WS_URL);
sgclawSocket.addEventListener('open', async () => {{
document.getElementById('sd').classList.add('on');
document.getElementById('stx').textContent = 'Connected';
_log('<span class="ok">\u2713</span> WebSocket connected');
_task('Connected to browser');
sgclawSocket.send(JSON.stringify({{ type: 'register', role: 'web' }}));
await sgclawReady();
_log('<span class="ok">\u2713</span> Ready signal sent');
_task('Ready \u2014 waiting for commands');
}});
let sgclawSocket = null;
let sgclawReconnectTimer = null;
let sgclawDeferredCommandLogged = false;
sgclawSocket.addEventListener('close', () => {{
document.getElementById('sd').classList.remove('on');
document.getElementById('stx').textContent = 'Disconnected';
_log('<span class="er">\u2717</span> WebSocket disconnected');
_task('Disconnected');
}});
sgclawSocket.addEventListener('message', (event) => {{
console.debug('sgclaw helper received browser frame', event.data);
try {{
var data = String(event.data || '');
if (data.indexOf('@_@') !== -1) {{
sgclawEmitCallback('callBackJsToCpp', {{ raw: data }});
function connectSocket() {{
if (sgclawSocket && (sgclawSocket.readyState === WebSocket.OPEN || sgclawSocket.readyState === WebSocket.CONNECTING)) {{
return;
}}
_log('Connecting to browser WebSocket\u2026');
document.getElementById('stx').textContent = 'Connecting…';
_task('Connecting to browser');
const socket = new WebSocket(SGCLAW_BROWSER_WS_URL);
sgclawSocket = socket;
socket.addEventListener('open', async () => {{
if (sgclawSocket !== socket) {{
return;
}}
}} catch (_e) {{}}
}});
if (sgclawReconnectTimer) {{
clearTimeout(sgclawReconnectTimer);
sgclawReconnectTimer = null;
}}
sgclawDeferredCommandLogged = false;
document.getElementById('sd').classList.add('on');
document.getElementById('stx').textContent = 'Connected';
_log('<span class="ok">\u2713</span> WebSocket connected');
_task('Connected to browser');
socket.send(JSON.stringify({{ type: 'register', role: 'web' }}));
await sgclawReady();
_log('<span class="ok">\u2713</span> Ready signal sent');
_task('Ready \u2014 waiting for commands');
}});
socket.addEventListener('close', () => {{
if (sgclawSocket !== socket) {{
return;
}}
sgclawSocket = null;
document.getElementById('sd').classList.remove('on');
document.getElementById('stx').textContent = 'Disconnected';
_log('<span class="er">\u2717</span> WebSocket disconnected');
_task('Disconnected — reconnecting');
if (!sgclawReconnectTimer) {{
sgclawReconnectTimer = setTimeout(connectSocket, 1000);
}}
}});
socket.addEventListener('message', (event) => {{
if (sgclawSocket !== socket) {{
return;
}}
console.debug('sgclaw helper received browser frame', event.data);
try {{
var data = String(event.data || '');
if (data.indexOf('@_@') !== -1) {{
sgclawEmitCallback('callBackJsToCpp', {{ raw: data }});
}}
}} catch (_e) {{}}
}});
}}
async function sgclawPollCommands() {{
try {{
@@ -936,22 +966,29 @@ async function sgclawPollCommands() {{
const envelope = await response.json();
const command = envelope && envelope.command;
if (!command || !command.action) {{
sgclawDeferredCommandLogged = false;
return;
}}
if (!sgclawSocket || sgclawSocket.readyState !== WebSocket.OPEN) {{
if (!sgclawDeferredCommandLogged) {{
_log('<span class="er">!</span> Browser connection lost — command deferred');
sgclawDeferredCommandLogged = true;
}}
return;
}}
sgclawDeferredCommandLogged = false;
_nc++;
const args = Array.isArray(command.args) ? command.args : [];
_lastCmd=Date.now();_setIdle(false);
_log('<span class="a">\u2192</span> execute <span class="a">'+command.action+'</span>'+(args.length>1?' <span class="u">'+String(args[1]||'').substring(0,50)+'</span>':''));
_task('Executing: '+command.action);
if (sgclawSocket.readyState !== WebSocket.OPEN) {{
return;
}}
sgclawSocket.send(JSON.stringify([window.location.href || SGCLAW_HELPER_URL, command.action, ...args]));
await sgclawPostJson(SGCLAW_COMMAND_ACK_ENDPOINT, {{ type: 'command_ack' }});
}} catch (_error) {{
}}
}}
connectSocket();
setInterval(sgclawPollCommands, 250);
_log('sgClaw Runtime Console initialized');
</script>
@@ -1110,6 +1147,11 @@ mod tests {
assert!(html.contains("ws://127.0.0.1:12345"));
assert!(html.contains(r#"JSON.stringify({ type: 'register', role: 'web' })"#));
assert!(html.contains("sgclawReady"));
assert!(html.contains("connectSocket()"));
assert!(html.contains("setTimeout(connectSocket, 1000)"));
assert!(html.contains("if (!sgclawSocket || sgclawSocket.readyState !== WebSocket.OPEN)"));
assert!(html.contains("Browser connection lost — command deferred"));
assert!(html.contains("sgclawSocket = null;"));
assert!(html.contains("sgclawOnLoaded"));
assert!(html.contains("sgclawOnClickProbe"));
assert!(html.contains("sgclawOnClick"));

View File

@@ -51,6 +51,7 @@ pub enum ServiceMessage {
LogEntry { level: String, message: String },
TaskComplete { success: bool, summary: String },
Busy { message: String },
Pong,
}
fn normalize_optional_field(value: String) -> Option<String> {

View File

@@ -126,7 +126,7 @@ impl ServiceEventSink {
.lock()
.map_err(|_| PipeError::Protocol("service websocket writer lock poisoned".to_string()))?
.send(Message::Text(payload.into()))
.map_err(|err| PipeError::Protocol(format!("service websocket send failed: {err}")))?;
.map_err(|err| map_service_websocket_error(err, "send"))?;
}
Ok(())
}
@@ -249,6 +249,7 @@ pub fn serve_client(
ClientMessage::Connect => send_status_changed(sink.as_ref(), "connected")?,
ClientMessage::Start => send_status_changed(sink.as_ref(), "started")?,
ClientMessage::Stop => send_status_changed(sink.as_ref(), "stopped")?,
ClientMessage::Ping => sink.send_service_message(ServiceMessage::Pong)?,
ClientMessage::SubmitTask {
instruction,
conversation_id,
@@ -335,7 +336,6 @@ pub fn serve_client(
}
}
}
ClientMessage::Ping => {}
}
}
}
@@ -471,6 +471,23 @@ impl Transport for NoopTransport {
}
}
#[cfg(test)]
mod pipe_closed_mapping_tests {
use super::*;
#[test]
fn map_service_websocket_error_treats_connection_aborted_send_as_pipe_closed() {
let err = tungstenite::Error::Io(std::io::Error::from(std::io::ErrorKind::ConnectionAborted));
assert!(matches!(map_service_websocket_error(err, "send"), PipeError::PipeClosed));
}
#[test]
fn map_service_websocket_error_treats_send_after_closing_as_pipe_closed() {
let err = tungstenite::Error::Protocol(tungstenite::error::ProtocolError::SendAfterClosing);
assert!(matches!(map_service_websocket_error(err, "send"), PipeError::PipeClosed));
}
}
#[cfg(test)]
struct ServiceBridgeTransport {
bridge_base_url: String,