Skip to content

realtime: resetHeartbeat's stale-ref clear is gated on autoSendHeartbeat, so worker-mode clients still false-close on reconnect #2625

Description

@InkHubHQ

Describe the bug

A pendingHeartbeatRef armed on one socket can survive a disconnect onto the next, healthy
connection, whose first heartbeat then takes the timeout branch and force-closes it with
"heartbeat timeout" without ever sending a frame. Since the phoenix integration (#2119), the
library handles this correctly for the default configuration — phoenix's resetHeartbeat(), called
from onConnOpen(), clears the stale ref on every open:

// phoenix.cjs.js — onConnOpen(), lines 1577-1587
onConnOpen() {
  ...
  this.establishedConnections++;      // :1581
  this.flushSendBuffer();             // :1582
  if (this.autoSendHeartbeat) {       // :1584 — THE GATE
    this.resetHeartbeat();            // :1585 — only place pendingHeartbeatRef is cleared on open
  }
  this.triggerStateCallbacks("open"); // :1587
}

// resetHeartbeat() — lines 1609-1614
resetHeartbeat() {
  if (this.conn && this.conn.skipHeartbeat) return;
  this.pendingHeartbeatRef = null;    // the clear
  this.clearHeartbeats();
  this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs);
}

The clear only runs when this.autoSendHeartbeat is true. RealtimeClient sets
autoSendHeartbeat = !this.worker (RealtimeClient.js:684, current through 2.112.3 at line 686) —
i.e. it is false for any client configured with worker: true. Worker mode exists specifically so
the heartbeat timer isn't throttled by background-tab timer clamping, so it's a normal production
configuration, not an edge case. For those clients, onConnOpen() never calls resetHeartbeat(), so
a pendingHeartbeatRef stranded by a drop-with-beat-in-flight survives onto the next, healthy
connection exactly as before the fix — that connection's first heartbeat sees a non-null
pendingHeartbeatRef, takes the timeout branch, and force-closes itself with "heartbeat timeout"
having sent zero frames.

So the fix conflates two responsibilities that resetHeartbeat() performs together: clearing stale
heartbeat state (correct to do unconditionally on every open) and arming the auto-send timer (correct
to gate on autoSendHeartbeat, since worker-mode clients drive heartbeats from the worker instead).
Gating both together on autoSendHeartbeat means worker-mode clients get neither.

To Reproduce

Standalone — no app code, only @supabase/realtime-js (which brings in @supabase/phoenix). Fakes a
WebSocket transport and a Worker so the sequence runs deterministically without real timers or a
network.

// repro.ts — run with `npx tsx repro.ts` (or transpile/run any way you like)
import assert from 'node:assert/strict'
import { RealtimeClient } from '@supabase/realtime-js'

// ---- Fake Worker: records the latest instance; ticks only on command (no real setInterval) ----
class FakeWorker {
  onmessage: ((e: { data: { event: string } }) => void) | null = null
  onerror: ((e: { message?: string }) => void) | null = null
  terminated = false
  constructor(_url: string) {}
  postMessage(_m: unknown) {}
  terminate() { this.terminated = true }
  tick() { if (this.onmessage && !this.terminated) this.onmessage({ data: { event: 'keepAlive' } }) }
}
let lastWorker: FakeWorker
;(globalThis as any).Worker = class extends FakeWorker {
  constructor(url: string) { super(url); lastWorker = this } // library terminates+recreates per connection
}
if (typeof URL.createObjectURL !== 'function') (URL as any).createObjectURL = () => 'blob:fake'

// ---- Fake WebSocket transport ----
class FakeWS {
  readyState = 0
  sent: string[] = []
  closedWith: { code: number; reason: string } | null = null
  binaryType = 'arraybuffer'
  onopen: ((e: object) => void) | null = null
  onclose: ((e: object) => void) | null = null
  constructor(public url: string) { instances.push(this) }
  send(d: unknown) { this.sent.push(String(d)) }
  close(code = 1000, reason = '') {
    this.closedWith = { code, reason }
    this.readyState = 3
    queueMicrotask(() => this.onclose?.({ code, reason, wasClean: true }))
  }
}
const instances: FakeWS[] = []
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))

async function main() {
  const statuses: string[] = []
  const client = new RealtimeClient('wss://repro.invalid/realtime/v1', {
    params: { apikey: 'repro-key' },
    transport: FakeWS as any,
    worker: true,                  // production configuration — the gate this bug is about
    heartbeatIntervalMs: 60_000,   // no auto-fire; beats are driven by hand below
    reconnectAfterMs: () => 10,
    heartbeatCallback: (s) => statuses.push(s),
  })
  const beat = () => lastWorker.tick() // worker mode: a beat is driven by the worker's keepAlive tick

  client.connect()
  await wait(30)
  const A = instances[0]
  A.readyState = 1
  A.onopen?.({})
  await wait(50)

  beat() // arm a heartbeat on A
  await wait(30)
  assert.ok(A.sent.some((f) => f.includes('heartbeat')), 'expected a heartbeat frame on A')

  // Drop A with the beat in flight — a Wi-Fi roam, laptop sleep, or server-side close all do this.
  A.readyState = 3
  A.onclose?.({ code: 1006, reason: 'network drop', wasClean: false })

  let B: FakeWS | undefined
  for (let i = 0; i < 200 && !B; i++) { await wait(10); B = instances[1] }
  if (!B) throw new Error('client never reconnected')
  B.readyState = 1
  B.onopen?.({}) // <-- onConnOpen() runs here; autoSendHeartbeat is false, so no clear happens

  await wait(50)
  beat() // first beat on the NEW, healthy socket
  await wait(30)

  console.log('statuses:', statuses)
  console.log('frames on B:', B.sent.filter((f) => f.includes('heartbeat')).length)
  console.log('B closed with:', B.closedWith)
}

main()

Observed vs Expected

worker mode (observed, current behavior) non-worker mode (for contrast — already correct)
statuses ['sent', 'timeout'] ['sent', 'sent']
frames on B 0 1
B closed? { reason: 'heartbeat timeout' } not closed

Expected: worker mode should match the non-worker column — ['sent', 'sent'], 1 frame on B, B stays
open. The stranded ref from A should have been cleared when B opened, exactly as it is for
worker: false.

Proposed fix

Split resetHeartbeat()'s two responsibilities in onConnOpen() instead of gating both together —
hoist the ref clear above the gate, leave only the timer-arming call inside it:

onConnOpen() {
  ...
  this.establishedConnections++;
  this.flushSendBuffer();
  this.pendingHeartbeatRef = null;   // moved out of the gate — unconditional on every open
  this.heartbeatSentAt = null;       // moved out of the gate — unconditional on every open
  if (this.autoSendHeartbeat) {
    this.resetHeartbeat();           // now only (re-)arms the auto-send timer
  }
  this.triggerStateCallbacks("open");
}

(resetHeartbeat() itself keeps clearing the ref when it runs — that's redundant but harmless for the
autoSendHeartbeat: true path, which already worked correctly.) This costs nothing for non-worker
clients and fixes worker-mode clients, whose external heartbeat driver (a Worker's setInterval, in
this app's case) still needs pendingHeartbeatRef cleared on open even though the library isn't the
one arming the next send.

To be explicit about what was already correctly fixed: non-worker mode was correctly fixed by
resetHeartbeat() in the 0.4.x line
— the bug reported here is scoped entirely to
autoSendHeartbeat === false (i.e. worker: true).

System information

  • @supabase/realtime-js 2.108.2 (also affects 2.112.3, the latest checked — identical gate, one line
    later)
  • @supabase/phoenix 0.4.5 (the vendored dependency where the gate actually lives —
    onConnOpen/resetHeartbeat in priv/static/phoenix.cjs.js)
  • Client options: { worker: true, heartbeatIntervalMs: 25000, heartbeatCallback }
  • Repro requires no network and no browser — pure RealtimeClient + fake transport/Worker, as above

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions