Skip to content

Commit 5783375

Browse files
jschwalbejschwalbeclaude
authored
fix(websocket): time out a stalled connect/auth handshake so retry can fire
If the server accepts the TCP connection but never completes the application-layer handshake -- it never returns the WebSocket 101 upgrade, or upgrades but never sends auth_ok -- none of the socket's open/message/close/error events fire. connect() therefore never settles the connection promise and never schedules its onClose retry, so the node sits at "connecting" indefinitely (21 hours in the reported case) until Node-RED is restarted. This happens when the HA host's kernel is alive (answers ARP, completes TCP handshakes) but the HA process is blocked, e.g. a storage stall / uninterruptible I/O wait. Add two timeouts, both routed into the existing onClose retry path: - handshakeTimeout on the ws socket: ws emits 'error' if the opening HTTP handshake (the 101 upgrade) does not complete in time. Covers the never-upgrades case (the deterministic bare-TCP-listener repro). - an overall auth deadline: terminate() the socket if auth_ok has not arrived. Covers upgrade-but-never-authenticates. terminate() (not close()) forces a prompt 'close' even on a wedged half-open socket whose peer will never complete a closing handshake. The timer is cleared on auth_ok and on close. Healthy connections are unaffected. No public API change. Co-authored-by: jschwalbe <jschwalbe@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 868d9e7 commit 5783375

1 file changed

Lines changed: 34 additions & 0 deletions

File tree

src/homeAssistant/createSocket.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ import { ClientEvent } from './Websocket';
1313

1414
const debug = Debug('home-assistant:socket');
1515

16+
// Time budgets for the connect/auth handshake. A half-open connection -- the
17+
// server completes the TCP handshake but never finishes the WebSocket upgrade
18+
// (no 101 response), or upgrades but never sends auth_ok (e.g. a Home Assistant
19+
// wedged in uninterruptible I/O wait) -- otherwise fires no open/message/close/
20+
// error event, so the connect promise never settles and no retry ever runs. The
21+
// node then sits at 'connecting' indefinitely. These timeouts force a teardown
22+
// so the existing close/error retry path below fires as it does for every other
23+
// connection failure.
24+
const HANDSHAKE_TIMEOUT = 15000; // WS HTTP-upgrade (101) must complete within this
25+
const AUTH_TIMEOUT = 45000; // auth_ok must arrive within this after a connect attempt starts
26+
1627
interface HaWebSocket extends WebSocket {
1728
haVersion: string;
1829
}
@@ -49,12 +60,33 @@ export default function createSocket({
4960
eventBus.emit(ClientEvent.Connecting);
5061

5162
const socket = new WebSocket(url, {
63+
handshakeTimeout: HANDSHAKE_TIMEOUT,
5264
rejectUnauthorized: rejectUnauthorizedCerts,
5365
}) as HaWebSocket;
5466

5567
// If invalid auth, we will not try to reconnect.
5668
let invalidAuth = false;
5769

70+
// If auth_ok has not arrived in time, tear the socket down. terminate()
71+
// (not close()) guarantees a prompt 'close' even on a wedged, half-open
72+
// socket whose peer will never complete a closing handshake.
73+
let authTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(
74+
() => {
75+
debug(
76+
'[Auth Phase] auth handshake timed out after %dms, terminating',
77+
AUTH_TIMEOUT,
78+
);
79+
socket.terminate();
80+
},
81+
AUTH_TIMEOUT,
82+
);
83+
const clearAuthTimeout = () => {
84+
if (authTimeout) {
85+
clearTimeout(authTimeout);
86+
authTimeout = undefined;
87+
}
88+
};
89+
5890
const onOpen = async () => {
5991
try {
6092
socket.send(JSON.stringify(auth));
@@ -78,6 +110,7 @@ export default function createSocket({
78110
break;
79111

80112
case MSG_TYPE_AUTH_OK:
113+
clearAuthTimeout();
81114
socket.off('open', onOpen);
82115
socket.off('message', onMessage);
83116
socket.off('close', onClose);
@@ -104,6 +137,7 @@ export default function createSocket({
104137
};
105138

106139
const onClose = () => {
140+
clearAuthTimeout();
107141
// If we are in error handler make sure close handler doesn't also fire.
108142
socket.off('close', onClose);
109143
if (invalidAuth) {

0 commit comments

Comments
 (0)