@wipcomputer/markdown-viewer
Advanced tools
| # Bug: Server hangs from stale SSE/proxy connections | ||
| **Reported:** 2026-02-24 | ||
| **Severity:** High (requires manual server restart) | ||
| **Frequency:** "This ALWAYS happens" (Parker) | ||
| ## Symptom | ||
| The markdown viewer page loads as a blank/stuck page. The URL works after killing the server process and restarting. Happens repeatedly over time, especially when tabs are left open. | ||
| ## Root Cause | ||
| Three problems compound into one hang: | ||
| ### 1. TCP proxy idle timeout kills SSE connections | ||
| `server.js` lines 428-429: | ||
| ```js | ||
| clientSocket.setTimeout(IDLE_TIMEOUT, () => clientSocket.destroy()); | ||
| serverSocket.setTimeout(IDLE_TIMEOUT, () => serverSocket.destroy()); | ||
| ``` | ||
| SSE connections are long-lived by design. The 30-second keepalive ping (line 70-77) writes data from server to client, which resets `serverSocket`'s timeout. But `clientSocket` (browser side) never sends data back on an SSE connection. So after 5 minutes of no browser-to-server traffic, the proxy destroys the client socket, which cascades to destroy the server socket via `clientSocket.on("close")`. | ||
| The browser's `connectSSE()` reconnects after 2 seconds, creating a new connection pair. But the old pair may not clean up fully (half-open state), and this cycle repeats every 5 minutes. | ||
| ### 2. Half-open connections from closed browser tabs | ||
| When a browser tab is closed (or the machine sleeps), the TCP connection may not send a FIN. The proxy doesn't detect this. The SSE response object stays in the `watchers` map. The keepalive timer tries to write to dead sockets, catches the error, removes the client from the Set ... but the proxy socket pair stays allocated. | ||
| Without TCP keepalive enabled on the proxy sockets, half-open connections can persist indefinitely. | ||
| ### 3. No connection limit on the proxy | ||
| Each page load creates at least 2 proxy connections (one for the page, one for SSE). Over time, stale connections accumulate. Node.js has a default max of ~16K file descriptors, but well before that limit, the proxy can become unresponsive if too many connections are in a wedged state. | ||
| ## Fix | ||
| Three changes to `server.js`: | ||
| ### Fix 1: Don't timeout SSE proxy connections | ||
| The proxy shouldn't impose idle timeouts on connections it can't distinguish. Instead, let the HTTP server manage SSE lifecycle. Remove the `setTimeout` on proxy sockets, or set TCP keepalive instead: | ||
| ```js | ||
| // Replace setTimeout with TCP keepalive | ||
| clientSocket.setKeepAlive(true, 60_000); // OS-level probe every 60s | ||
| serverSocket.setKeepAlive(true, 60_000); | ||
| ``` | ||
| TCP keepalive detects dead peers at the OS level. If the browser is gone, the OS will RST the connection after a few failed probes. | ||
| ### Fix 2: Reduce SSE keepalive interval | ||
| Change from 30s to 15s. Faster dead-client detection: | ||
| ```js | ||
| setInterval(() => { | ||
| for (const [, entry] of watchers) { | ||
| for (const client of entry.clients) { | ||
| try { client.write(`:keepalive\n\n`); } | ||
| catch { entry.clients.delete(client); } | ||
| } | ||
| } | ||
| }, 15_000); // was 30_000 | ||
| ``` | ||
| ### Fix 3: Add periodic stale-watcher cleanup | ||
| Watchers with zero clients should be cleaned up on a timer, not just on client disconnect: | ||
| ```js | ||
| // Clean up watchers with no clients every 60s | ||
| setInterval(() => { | ||
| for (const [path, entry] of watchers) { | ||
| if (entry.clients.size === 0) { | ||
| if (entry.watcher) entry.watcher.close(); | ||
| watchers.delete(path); | ||
| } | ||
| } | ||
| }, 60_000); | ||
| ``` | ||
| ## Testing | ||
| 1. Open a markdown file in the viewer | ||
| 2. Close the browser tab (don't navigate away, close it) | ||
| 3. Wait 5+ minutes | ||
| 4. Open the same URL again | ||
| 5. Should load immediately (currently hangs) | ||
| Also test: | ||
| - Leave tab open for 30+ minutes, edit the file, confirm live reload still works | ||
| - Open 10+ tabs to different files, close them all, confirm server recovers |
+5
-0
@@ -6,2 +6,7 @@ # Changelog | ||
| ## 1.2.5 (2026-02-25) | ||
| Fix SSE connection pile-up causing blank pages when multiple tabs open in Chrome | ||
| ## 1.2.4 (2026-02-21) | ||
@@ -8,0 +13,0 @@ |
+1
-1
| { | ||
| "name": "@wipcomputer/markdown-viewer", | ||
| "version": "1.2.4", | ||
| "version": "1.2.5", | ||
| "description": "Live markdown viewer for AI pair-editing. Updates render instantly in any browser.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+24
-2
@@ -191,4 +191,6 @@ #!/usr/bin/env node | ||
| let evtSource = null; | ||
| function connectSSE() { | ||
| const evtSource = new EventSource('/api/events?path=' + encodedPath); | ||
| if (evtSource) { evtSource.close(); evtSource = null; } | ||
| evtSource = new EventSource('/api/events?path=' + encodedPath); | ||
| evtSource.onmessage = async function(event) { | ||
@@ -202,2 +204,3 @@ if (event.data === 'reload') { | ||
| evtSource.close(); | ||
| evtSource = null; | ||
| setTimeout(connectSSE, 2000); | ||
@@ -207,2 +210,13 @@ }; | ||
| connectSSE(); | ||
| // Free SSE connection when tab is hidden (Chrome 6-connection limit). | ||
| // Reconnect when tab becomes visible again. | ||
| document.addEventListener('visibilitychange', function() { | ||
| if (document.hidden) { | ||
| if (evtSource) { evtSource.close(); evtSource = null; } | ||
| } else { | ||
| serverLoad().catch(function(){}); | ||
| connectSSE(); | ||
| } | ||
| }); | ||
| })(); | ||
@@ -328,3 +342,3 @@ </script>`; | ||
| "Content-Type": "text/event-stream", | ||
| "Cache-Control": "no-cache", | ||
| "Cache-Control": "no-cache, no-store", | ||
| Connection: "keep-alive", | ||
@@ -335,2 +349,10 @@ }); | ||
| req.on("close", () => { removeClient(filePath, res); }); | ||
| // Auto-close SSE after 5 minutes to prevent connection pile-up. | ||
| // Client reconnects automatically via EventSource.onerror. | ||
| const maxAge = setTimeout(() => { | ||
| try { res.end(); } catch {} | ||
| removeClient(filePath, res); | ||
| }, 5 * 60 * 1000); | ||
| req.on("close", () => clearTimeout(maxAge)); | ||
| return; | ||
@@ -337,0 +359,0 @@ } |
Unpublished package
Supply chain riskPackage version was not found on the registry. It may exist on a different registry and need to be configured to pull from that registry.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
5025293
0.09%79
1.28%16363
0.12%29
3.57%