Skip to content

Commit 44f0efd

Browse files
hhaenselclaude
andcommitted
Add WebSocket keepalive with configurable timeout
- Add keepalive.js with improved pong verification and timeout handling - Add webchannels_keepalive_timeout config option (default 5000ms) - Move keepalive.js loading to Genie's Assets module for consistent availability - Update channels.js to handle keepalive pong responses - Improve keepalive state management and error detection Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 910d24e commit 44f0efd

5 files changed

Lines changed: 215 additions & 6 deletions

File tree

assets/js/channels.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,16 +250,28 @@ Genie.initWebChannel = function(channel = Genie.Settings.webchannels_default_rou
250250
if (WebChannel._onWindowFocus) window.removeEventListener('focus', WebChannel._onWindowFocus);
251251
if (WebChannel._onWindowOnline) window.removeEventListener('online', WebChannel._onWindowOnline);
252252
if (WebChannel._onVisibilityChange) document.removeEventListener('visibilitychange', WebChannel._onVisibilityChange);
253-
253+
254254
if (Genie.Settings.webchannels_autosubscribe) {
255255
unsubscribe(WebChannel);
256256
}
257-
257+
258258
if (WebChannel.socket.readyState === 1) {
259259
WebChannel.socket.close();
260260
}
261261
});
262262

263+
// Initialize keepalive if enabled and initKeepalive function is available
264+
if (Genie.Settings.webchannels_keepalive_frequency > 0 && typeof initKeepalive === 'function') {
265+
try {
266+
initKeepalive(WebChannel);
267+
logDev('Keepalive initialized', { channel: WebChannel.channel });
268+
} catch (e) {
269+
if (isDev()) {
270+
console.error('[Genie.WebChannels] Error initializing keepalive:', e);
271+
}
272+
}
273+
}
274+
263275
Genie.AllWebChannels.push(WebChannel);
264276

265277
return WebChannel
@@ -478,6 +490,17 @@ function subscription_ready(WebChannel) {
478490
}
479491
}
480492
deleteAlert(WebChannel);
493+
494+
// Start keepalive timer after subscription is ready
495+
if (Genie.Settings.webchannels_keepalive_frequency > 0 && typeof keepaliveTimer === 'function') {
496+
try {
497+
keepaliveTimer(WebChannel, 0);
498+
if (isDev()) console.info('[Genie.WebChannels] Keepalive timer started');
499+
} catch (e) {
500+
if (isDev()) console.error('[Genie.WebChannels] Error starting keepalive timer:', e);
501+
}
502+
}
503+
481504
if (isDev()) console.info('Subscription ready');
482505
};
483506

assets/js/keepalive.js

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
** keepalive.js // v2.0.0 // 8 June 2026
3+
** Part of Genie.jl WebChannels
4+
** Keeps alive the websocket connection by sending a ping every x seconds
5+
** where x = Genie.config.webchannels_keepalive_frequency
6+
** Includes pong verification to detect backend channel failures
7+
*/
8+
9+
function keepalive(WebChannel) {
10+
if (WebChannel.lastMessageAt !== undefined) {
11+
dt = Date.now() - WebChannel.lastMessageAt;
12+
// allow for a 200ms buffer
13+
if (dt + 200 < Genie.Settings.webchannels_keepalive_frequency) {
14+
keepaliveTimer(WebChannel, Genie.Settings.webchannels_keepalive_frequency - dt);
15+
return;
16+
}
17+
}
18+
19+
// Don't send keepalive if websocket is disconnected or in wrong state
20+
if (WebChannel.ws_disconnected || !WebChannel.socket || WebChannel.socket.readyState !== 1) {
21+
// Reset pending state since we can't expect a pong anyway
22+
WebChannel._keepalive_pending = false;
23+
return;
24+
}
25+
26+
// Check if previous keepalive is still pending (no pong received)
27+
if (WebChannel._keepalive_pending) {
28+
const timeoutMs = Genie.Settings.webchannels_keepalive_timeout || 5000;
29+
const timeSincePing = Date.now() - WebChannel._keepalive_ping_sent;
30+
31+
if (timeSincePing > timeoutMs) {
32+
if (Genie.Settings.env == 'dev') {
33+
console.warn('[Genie.WebChannels] Keepalive pong not received - backend channel may be unresponsive', {
34+
channel: WebChannel.channel,
35+
timeSincePing: timeSincePing + 'ms',
36+
socketState: WebChannel.socket?.readyState,
37+
wsDisconnected: WebChannel.ws_disconnected
38+
});
39+
}
40+
41+
// Mark channel as not alive
42+
WebChannel.channel_alive = false;
43+
44+
// Only trigger alert and close if socket is still open
45+
// (avoid double-alert if network disconnect already triggered it)
46+
if (WebChannel.socket.readyState === 1 && !WebChannel.ws_disconnected) {
47+
// Trigger reconnection
48+
if (typeof displayAlert === 'function') {
49+
displayAlert(WebChannel, 'Backend channel is not responding. Attempting to reconnect...');
50+
}
51+
52+
// Force reconnection by closing the socket
53+
WebChannel.socket.close(1000, 'Keepalive timeout');
54+
}
55+
56+
// Reset pending state
57+
WebChannel._keepalive_pending = false;
58+
return;
59+
}
60+
}
61+
62+
// Send keepalive ping
63+
if (Genie.Settings.env == 'dev') {
64+
console.info('[Genie.WebChannels] Sending keepalive ping', { channel: WebChannel.channel });
65+
}
66+
67+
WebChannel._keepalive_pending = true;
68+
WebChannel._keepalive_ping_sent = Date.now();
69+
70+
WebChannel.sendMessageTo(WebChannel.channel, 'keepalive', {
71+
'payload': { timestamp: Date.now() }
72+
});
73+
}
74+
75+
function keepaliveTimer(WebChannel, startDelay = Genie.Settings.webchannels_keepalive_frequency) {
76+
clearInterval(WebChannel.keepalive_interval);
77+
clearTimeout(WebChannel._keepaliveTimeout);
78+
WebChannel._keepaliveTimeout = setTimeout(() => {
79+
keepalive(WebChannel);
80+
WebChannel.keepalive_interval = setInterval(() => keepalive(WebChannel), Genie.Settings.webchannels_keepalive_frequency);
81+
}, startDelay);
82+
}
83+
84+
function stopKeepalive(WebChannel) {
85+
clearInterval(WebChannel.keepalive_interval);
86+
clearTimeout(WebChannel._keepaliveTimeout);
87+
WebChannel._keepalive_pending = false;
88+
WebChannel.keepalive_interval = null;
89+
WebChannel._keepaliveTimeout = null;
90+
}
91+
92+
// Initialize keepalive state on WebChannel creation
93+
function initKeepalive(WebChannel) {
94+
WebChannel._keepalive_pending = false;
95+
WebChannel._keepalive_ping_sent = null;
96+
WebChannel.channel_alive = true;
97+
98+
// Public API to check if backend channel is alive
99+
WebChannel.isChannelAlive = function() {
100+
return WebChannel.socket &&
101+
WebChannel.socket.readyState === 1 &&
102+
WebChannel.channel_alive &&
103+
!WebChannel.ws_disconnected;
104+
};
105+
106+
// Backwards compatibility alias for Stipple
107+
WebChannel.isModelAlive = WebChannel.isChannelAlive;
108+
109+
// Register pong handler
110+
WebChannel.messageHandlers.unshift(function(event) {
111+
try {
112+
let ed = event.data.trim();
113+
114+
// Handle base64 encoded payloads
115+
if (ed.startsWith(Genie.Settings.webchannels_base64_marker)) {
116+
ed = atob(ed.substring(Genie.Settings.webchannels_base64_marker.length).trim());
117+
}
118+
119+
if (ed.startsWith('{') && ed.endsWith('}')) {
120+
const payload = JSON.parse(ed, Genie.Revivers.reviver);
121+
122+
// Check for keepalive pong response
123+
if (payload.message === 'keepalive') {
124+
WebChannel._keepalive_pending = false;
125+
WebChannel.channel_alive = true;
126+
127+
if (Genie.Settings.env == 'dev') {
128+
const latency = Date.now() - WebChannel._keepalive_ping_sent;
129+
console.info('[Genie.WebChannels] Keepalive pong received', {
130+
channel: WebChannel.channel,
131+
latency: latency + 'ms'
132+
});
133+
}
134+
135+
// Mark this message as handled so it doesn't propagate further
136+
return true;
137+
}
138+
}
139+
} catch (ex) {
140+
// Ignore parsing errors, let other handlers deal with it
141+
}
142+
143+
return false; // Not handled, continue to other handlers
144+
});
145+
146+
// Reset keepalive state on reconnection
147+
WebChannel.openHandlers.push(function() {
148+
WebChannel._keepalive_pending = false;
149+
WebChannel.channel_alive = true;
150+
if (Genie.Settings.env == 'dev') {
151+
console.info('[Genie.WebChannels] Keepalive state reset on connection', { channel: WebChannel.channel });
152+
}
153+
});
154+
155+
// Clear keepalive state on close
156+
WebChannel.closeHandlers.push(function() {
157+
if (Genie.Settings.env == 'dev') {
158+
console.info('[Genie.WebChannels] Stopping keepalive on close', { channel: WebChannel.channel });
159+
}
160+
stopKeepalive(WebChannel);
161+
WebChannel.channel_alive = false;
162+
});
163+
}

src/Assets.jl

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ function js_settings(channel::String = Genie.config.webchannels_default_route) :
234234
:webchannels_base64_marker => Genie.config.webchannels_base64_marker,
235235
:webchannels_timeout => Genie.config.webchannels_timeout,
236236
:webchannels_keepalive_frequency => Genie.config.webchannels_keepalive_frequency,
237+
:webchannels_keepalive_timeout => Genie.config.webchannels_keepalive_timeout,
237238
:webchannels_server_gone_alert_timeout => Genie.config.webchannels_server_gone_alert_timeout,
238239
:webchannels_connection_attempts => Genie.config.webchannels_connection_attempts,
239240
:webchannels_reconnect_delay => Genie.config.webchannels_reconnect_delay,
@@ -346,16 +347,36 @@ function channels(channel::AbstractString = Genie.config.webchannels_default_rou
346347
end
347348

348349

350+
"""
351+
keepalive() :: String
352+
353+
Outputs the `keepalive.js` file included with the Genie package.
354+
"""
355+
function keepalive() :: String
356+
embedded(Genie.Assets.asset_file(cwd=normpath(joinpath(@__DIR__, "..")), type = "js", file = "keepalive"))
357+
end
358+
359+
360+
"""
361+
keepalive_script() :: String
362+
363+
Outputs the keepalive JavaScript content within `<script>...</script>` tags, for embedding into the page.
364+
"""
365+
function keepalive_script() :: String
366+
string("<script>\n", keepalive(), "\n</script>")
367+
end
368+
369+
349370
"""
350371
channels_script(channel::AbstractString = Genie.config.webchannels_default_route) :: String
351372
352373
Outputs the channels JavaScript content within `<script>...</script>` tags, for embedding into the page.
353374
"""
354375
function channels_script(channel::AbstractString = Genie.config.webchannels_default_route) :: String
376+
keepalive_content = (Genie.config.webchannels_keepalive_frequency > 0) ? "\n$(keepalive())\n" : ""
355377
"""
356378
<script>
357-
$(channels(channel))
358-
</script>
379+
$(channels(channel))$(keepalive_content)</script>
359380
"""
360381
end
361382

src/Configuration.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ App configuration - sets up the app's defaults. Individual options are overwritt
190190
- `path_app::String`: the path to the app files (default "app/")
191191
- `html_parser_close_tag::String`: default " /". Can be changed to an empty string "" so the single tags would not be closed.
192192
- `webchannels_keepalive_frequency::Int`: default `30000`. Frequency in milliseconds to send keepalive messages to webchannel/websocket to keep the connection alive. Set to `0` to disable keepalive messages.
193+
- `webchannels_keepalive_timeout::Int`: default `5000`. Maximum time in milliseconds to wait for a keepalive pong response before marking the channel as unresponsive.
193194
- `env_whitelist`::Vector{<:Union{String, Regex}}: list of environment variables that are allowed to be displayed on the error page in dev mode
194195
"""
195196
Base.@kwdef mutable struct Settings
@@ -257,6 +258,7 @@ Base.@kwdef mutable struct Settings
257258
webchannels_base64_marker::String = "base64:"
258259
webchannels_timeout::Int = 1_000
259260
webchannels_keepalive_frequency::Int = 30_000 # 30 seconds
261+
webchannels_keepalive_timeout::Int = 5_000 # 5 seconds - pong timeout
260262
webchannels_server_gone_alert_timeout::Int = 10_000 # 10 seconds
261263
webchannels_connection_attempts = 10
262264
webchannels_reconnect_delay = 500 # milliseconds

test/tests_Assets.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
using Genie, Genie.Assets
1515
Genie.config.websockets_port = 8000 # state gets affected depending on how tests are run -- let's set it explicitly
1616

17-
@test strip(js_settings()) == strip("window.Genie = {};\nGenie.Settings = {\"websockets_exposed_port\":window.location.port,\"server_host\":\"127.0.0.1\",\"webchannels_autosubscribe\":true,\"webchannels_reconnect_delay\":500,\"env\":\"dev\",\"webchannels_eval_command\":\">eval:\",\"webchannels_alert_overlay\":false,\"websockets_host\":\"127.0.0.1\",\"webchannels_show_alert\":true,\"webthreads_js_file\":\"webthreads.js\",\"webchannels_base64_marker\":\"base64:\",\"webchannels_unsubscribe_channel\":\"unsubscribe\",\"webthreads_default_route\":\"____\",\"webchannels_subscription_trials\":4,\"webchannels_subscribe_channel\":\"subscribe\",\"server_port\":8000,\"webchannels_keepalive_frequency\":30000,\"websockets_exposed_host\":window.location.hostname,\"webchannels_connection_attempts\":10,\"base_path\":\"\",\"websockets_protocol\":window.location.protocol.replace('http', 'ws'),\"webthreads_pull_route\":\"pull\",\"webchannels_default_route\":\"____\",\"webchannels_server_gone_alert_timeout\":10000,\"webchannels_timeout\":1000,\"webthreads_push_route\":\"push\",\"websockets_port\":8000,\"websockets_base_path\":\"\"};") ||
18-
strip(js_settings()) == strip("window.Genie = {};\nGenie.Settings = {\"base_path\":\"\",\"env\":\"dev\",\"server_host\":\"127.0.0.1\",\"server_port\":8000,\"webchannels_alert_overlay\":false,\"webchannels_autosubscribe\":true,\"webchannels_base64_marker\":\"base64:\",\"webchannels_connection_attempts\":10,\"webchannels_default_route\":\"____\",\"webchannels_eval_command\":\">eval:\",\"webchannels_keepalive_frequency\":30000,\"webchannels_reconnect_delay\":500,\"webchannels_server_gone_alert_timeout\":10000,\"webchannels_show_alert\":true,\"webchannels_subscribe_channel\":\"subscribe\",\"webchannels_subscription_trials\":4,\"webchannels_timeout\":1000,\"webchannels_unsubscribe_channel\":\"unsubscribe\",\"websockets_base_path\":\"\",\"websockets_exposed_host\":window.location.hostname,\"websockets_exposed_port\":window.location.port,\"websockets_host\":\"127.0.0.1\",\"websockets_port\":8000,\"websockets_protocol\":window.location.protocol.replace('http', 'ws'),\"webthreads_default_route\":\"____\",\"webthreads_js_file\":\"webthreads.js\",\"webthreads_pull_route\":\"pull\",\"webthreads_push_route\":\"push\"};")
17+
@test strip(js_settings()) == strip("window.Genie = {};\nGenie.Settings = {\"websockets_exposed_port\":window.location.port,\"server_host\":\"127.0.0.1\",\"webchannels_autosubscribe\":true,\"webchannels_reconnect_delay\":500,\"env\":\"dev\",\"webchannels_eval_command\":\">eval:\",\"webchannels_alert_overlay\":false,\"websockets_host\":\"127.0.0.1\",\"webchannels_show_alert\":true,\"webthreads_js_file\":\"webthreads.js\",\"webchannels_base64_marker\":\"base64:\",\"webchannels_unsubscribe_channel\":\"unsubscribe\",\"webthreads_default_route\":\"____\",\"webchannels_subscription_trials\":4,\"webchannels_subscribe_channel\":\"subscribe\",\"server_port\":8000,\"webchannels_keepalive_frequency\":30000,\"webchannels_keepalive_timeout\":5000,\"websockets_exposed_host\":window.location.hostname,\"webchannels_connection_attempts\":10,\"base_path\":\"\",\"websockets_protocol\":window.location.protocol.replace('http', 'ws'),\"webthreads_pull_route\":\"pull\",\"webchannels_default_route\":\"____\",\"webchannels_server_gone_alert_timeout\":10000,\"webchannels_timeout\":1000,\"webthreads_push_route\":\"push\",\"websockets_port\":8000,\"websockets_base_path\":\"\"};") ||
18+
strip(js_settings()) == strip("window.Genie = {};\nGenie.Settings = {\"base_path\":\"\",\"env\":\"dev\",\"server_host\":\"127.0.0.1\",\"server_port\":8000,\"webchannels_alert_overlay\":false,\"webchannels_autosubscribe\":true,\"webchannels_base64_marker\":\"base64:\",\"webchannels_connection_attempts\":10,\"webchannels_default_route\":\"____\",\"webchannels_eval_command\":\">eval:\",\"webchannels_keepalive_frequency\":30000,\"webchannels_keepalive_timeout\":5000,\"webchannels_reconnect_delay\":500,\"webchannels_server_gone_alert_timeout\":10000,\"webchannels_show_alert\":true,\"webchannels_subscribe_channel\":\"subscribe\",\"webchannels_subscription_trials\":4,\"webchannels_timeout\":1000,\"webchannels_unsubscribe_channel\":\"unsubscribe\",\"websockets_base_path\":\"\",\"websockets_exposed_host\":window.location.hostname,\"websockets_exposed_port\":window.location.port,\"websockets_host\":\"127.0.0.1\",\"websockets_port\":8000,\"websockets_protocol\":window.location.protocol.replace('http', 'ws'),\"webthreads_default_route\":\"____\",\"webthreads_js_file\":\"webthreads.js\",\"webthreads_pull_route\":\"pull\",\"webthreads_push_route\":\"push\"};")
1919
end
2020

2121
@safetestset "Embedded assets" begin

0 commit comments

Comments
 (0)