Skip to content

Fix WebSocket disconnections, server crashes and big-file downloads - #40

Merged
simkim merged 1 commit into
masterfrom
features/fix-large-file-download-timeout
Aug 24, 2026
Merged

Fix WebSocket disconnections, server crashes and big-file downloads#40
simkim merged 1 commit into
masterfrom
features/fix-large-file-download-timeout

Conversation

@simkim

@simkim simkim commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Problems

  • Prod /ws answered 429 Too Many Connections for everyone; Firefox couldn't connect at all.
  • Users reported being disconnected after a couple of minutes.
  • Big files couldn't be downloaded.

Root causes & fixes

Cause Fix
em-websocket 0.3.8 requires Connection: Upgrade with exact casing; nginx-proxy sends upgrade → every handshake → HTTP 500 Normalize the header in /ws before handing over
Each failed handshake leaked a slot of the per-IP limiter (50) → whole NAT locked out with 429 Release the slot on any handshake failure
Heartbeat called @ws.close — doesn't exist in this em-websocket (close_websocket); NoMethodError in an EM timer crashed the whole server on any late pong Correct call + every EM callback/timer guarded
app.rb's ws.onclose replaced WSClient's (single-slot callbacks): gone senders never removed, downloads hung WSClient owns onclose, closes its streams, releases the slot
No flow control: whole file pushed into the socket at disk speed, buffered in server memory, pong stuck behind it Ack-based windowed streaming paced on what the downloader actually consumed; stream_close when a downloader leaves; thin idle timeout disabled on downloads; X-Accel-Buffering: no
Frontend reconnected from both onerror and onclose → sockets doubled on every failure Single reconnect with exponential backoff, stale sockets closed, local shares re-registered after reconnect
Thin mangles [::1]:port Host headers → Rack::Lint 500 in dev SERVER_NAME/SERVER_PORT fixed up for bracketed IPv6 hosts

Verification

  • 42 specs (18 new), 0 failures.
  • End-to-end against a local instance: Connection: upgrade / keep-alive, Upgrade → 101; 60 bogus handshakes then a real client still connects; 150MB transfer to a 3MB/s downloader → md5-identical, sender queue ≤5.4MB, pong answered instantly mid-transfer; silent client closed at 60s with the server still alive; downloader abort → sender told to stop.

Old cached frontends keep working with the new backend (they ignore acks). Deploying restarts the container, which also resets the leaked counters currently locking prod.

Production was returning 429 on /ws and users were dropped mid-transfer.
Several independent bugs:

- em-websocket 0.3.x only accepts "Connection: Upgrade" with that exact
  casing; nginx-proxy sends "upgrade", so every handshake failed with 500.
  Normalize the header before handing over to it.
- Each failed handshake leaked a slot of the per-IP connection limiter
  (only ConnectionError was rescued), locking out the whole NAT after 50
  failures. Release the slot on any failure.
- The heartbeat called @ws.close, which doesn't exist on
  SinatraWebsocket::Connection (close_websocket does). The NoMethodError
  in an EM timer crashed the whole server, disconnecting everyone. Fix the
  call and guard every EM callback so one client can't take the server down.
- app.rb's ws.onclose replaced WSClient's (single-slot callbacks), so
  disconnected senders were never removed and their downloads hung.
- No flow control: senders pushed whole files into the socket at disk
  speed, the server buffered everything in memory and the heartbeat pong
  got stuck behind it. Add ack-based windowed streaming paced on the
  downloader's actual consumption, abort senders when a downloader leaves,
  disable thin's 30s idle timeout on downloads and send
  X-Accel-Buffering: no so nginx streams instead of buffering to disk.
- Frontend scheduled a reconnect from both onerror and onclose, doubling
  sockets on every failure. Single reconnect with backoff, close stale
  sockets, and re-register local shares after reconnecting.
- Thin mangles bracketed IPv6 Host headers into an invalid SERVER_PORT,
  which Rack::Lint rejects in development.
@simkim
simkim merged commit 4926bd4 into master Aug 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant