Skip to content

fix: recover lost pubsub subscriptions after sleep and dead sockets - #125

Open
corrideat wants to merge 15 commits into
mainfrom
121-lost-pubsub-websocket-connection
Open

corrideat wants to merge 15 commits into
mainfrom
121-lost-pubsub-websocket-connection

Conversation

@corrideat

Copy link
Copy Markdown
Member

After a device slept or the network dropped, the pubsub client could silently stop receiving events: it gave up on unanswered subscriptions, kept half-open sockets around, and Chelonia never noticed the gap.

The client now replaces the connection instead of dropping a wanted request, abandons dead sockets right away (ping/connection timeout, offline, wake-up from sleep) and exposes its connection state. Chelonia re-checks its subscriptions after every reconnection and periodically, syncs contracts whose published events don't come back, syncs on a publish or KV write conflict, and keeps the server time estimate correct across sleep. Includes an in-process fake pubsub server and relay for tests, plus docs for the new options, events and status.

Fixes #121. See okTurtles/group-income#3176.

Assisted-by: Crush:claude-opus-5-5

After a device slept or the network dropped, the pubsub client could
silently stop receiving events: it gave up on unanswered subscriptions,
kept half-open sockets around, and Chelonia never noticed the gap.

The client now replaces the connection instead of dropping a wanted
request, abandons dead sockets right away (ping/connection timeout,
offline, wake-up from sleep) and exposes its connection state. Chelonia
re-checks its subscriptions after every reconnection and periodically,
syncs contracts whose published events don't come back, syncs on a
publish or KV write conflict, and keeps the server time estimate
correct across sleep. Includes an in-process fake pubsub server and
relay for tests, plus docs for the new options, events and status.

Fixes #121. See okTurtles/group-income#3176.

Assisted-by: Crush:claude-opus-5-5
@corrideat

corrideat commented Nov 4, 2026 •

Copy link
Copy Markdown
Member Author

/review


AI review started.

The review job timed out.

@taoeffect

Copy link
Copy Markdown
Member

@corrideat if you haven't already please also run a local review with the code-review skill using Opus 5.5 🙏

@taoeffect

taoeffect commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

/review {{kimi}}


AI review started.

@okTurtles okTurtles deleted a comment from github-actions Bot Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

Advanced AI Review

  • Type: Agentic (crush)
  • Model: k3.1-pro-highspeed
Click to expand review

Preparing five read-only review workers in two batches.


Review generated using k3.1-pro-highspeed via Kimi. Comment /review {{kimi}} to re-run.

@taoeffect

Copy link
Copy Markdown
Member

Review by Opus 5.5 xhigh:

Details

Code Review

Base: main (287765e)
Head: 121-lost-pubsub-websocket-connection (c031b6b)
Date: 2026-11-05
Model: Claude Opus 5.5 (anthropic/claude-opus-5-5)

Context: okTurtles/libcheloniajs#125, "fix: recover lost pubsub subscriptions after sleep and dead sockets" (fixes #121).


1. 🔴 new CloseEvent throws on Node 22 (the CI version), so 25 new tests fail

  • Addressed
  • Dismissed

CloseEvent is a global only from Node 23. CI uses Node 22 (.github/workflows/ci.yml:19: node-version: 22). The harness creates a CloseEvent with no fallback:

  • src/pubsub/test-harness.ts:94 (dispatchClose)
  • src/pubsub/test-harness.ts:102 (failConnection)
  • src/pubsub/recovery.test.ts:359

Every close path of the fake server goes through these helpers: killClients(), closeClients(), reset() and FakeWebSocket.close().

Verified locally on this branch:

Node typeof CloseEvent npm test result
v22.23.1 undefined 547 tests, 516 pass, 25 fail (ReferenceError: CloseEvent is not defined)
v24.22.2 function all pass

Failing on Node 22: recovery.test.ts, dead-socket.test.ts, chelonia-pubsub-reconcile.test.ts, chelonia-publish-receipt.test.ts, and the whole of chelonia-kv-set-sync.test.ts and time-sync.test.ts.

The client already has a fallback (createCloseEvent, src/pubsub/index.ts:357-362). Use the same pattern in the harness, export it, and use it in recovery.test.ts:359:

// Node 22 has no global `CloseEvent` (added in Node 23)
export const createCloseEvent = (code: number, reason = '', wasClean = false): Event =>
  typeof CloseEvent === 'function'
    ? new CloseEvent('close', { code, reason, wasClean })
    : Object.assign(new Event('close'), { code, reason, wasClean })

const dispatchClose = (conn: FakeConnection, code: number, reason: string, wasClean: boolean) => {
  conn.state = 'closed'
  conn.socket.readyState = CLOSED
  conn.socket.dispatchEvent(createCloseEvent(code, reason, wasClean))
}

// A connection attempt that fails (or is aborted): `error`, then close 1006
const failConnection = (conn: FakeConnection) => {
  conn.state = 'closed'
  conn.socket.readyState = CLOSED
  conn.socket.dispatchEvent(new Event('error'))
  conn.socket.dispatchEvent(createCloseEvent(1006))
}

2. 🟡 A 409 during publish can sync a released contract and subscribe to it again

  • Addressed
  • Dismissed

src/internals.ts:958-963:

if (!entry.isFirstMessage()) {
  const localHeight = sbp(this.config.stateSelector).contracts[contractID]?.height
  if (localHeight == null || localHeight < entry.height()) {
    await sbp('chelonia/private/in/sync', contractID, { force: true })
  }
}

For a message that isn't the first one, localHeight == null is true only when the contract state was removed. This can happen during the delay(randDelay) at line 951 (up to 1.5 s, outside the contract queue) if chelonia/contract/release runs. Then:

  • chelonia/private/in/sync with force does not check subscriptionSet (internals.ts:774).
  • syncContract adds a contract that is not subscribed to this.pending (internals.ts:2089-2096), so the sync subscribes to it again. Nothing holds a reference to it, so nothing will release it.
  • If the contract was deleted for good (contracts[id] === null), syncContract throws ChelErrorResourceGone (internals.ts:2065-2067) and the publish rejects. Without the sync, the next loop would stop quietly at line 837 ("contract state has been removed").

The old condition (entry.height() === lastAttemptedHeight) could also sync in this case, so the race is not new. But the new == null clause selects this case on purpose. The two other sync paths that this PR adds both guard against it: ensurePublishedEventReceived (internals.ts:314, 321) and kv/set (chelonia.ts:3193).

The kv/set path also catches a failed sync and retries (chelonia.ts:3197-3203). Here, a failed sync (e.g. a network error in latestHEADInfo) goes to the catch at line 977 and fails the publish, even though attempts remain. Make the two paths agree:

            if (!entry.isFirstMessage() && this.subscriptionSet.has(contractID)) {
              const localHeight = sbp(this.config.stateSelector).contracts[contractID]?.height
              if (localHeight == null || localHeight < entry.height()) {
                try {
                  await sbp('chelonia/private/in/sync', contractID, { force: true })
                } catch (e) {
                  console.warn(
                    `[chelonia] Error syncing ${contractID} after a publish conflict; retrying anyway: ${(e as Error)?.message || e}`
                  )
                }
              }
            }

3. 🟡 oldestPendingSince is wrong while disconnected, and covers channels that pending leaves out

  • Addressed
  • Dismissed

Client side. The docs say that since is set only when a request is sent on the current connection (src/pubsub/index.ts:73-76: "requests waiting for a connection don't count"; :79-83). But runWithRetry sets it when it creates the entry, before it checks the socket (src/pubsub/index.ts:436):

const instance: PendingOperation = { since: Date.now() }
client.pendingOperations.tSet(type, channelID, instance)
const send = () => {
  if (client.socket !== socket || socket?.readyState !== WebSocket.OPEN) return

So a sub() call in the connecting, reconnecting or offline state (for example from CONTRACTS_MODIFIED → chelonia/pubsub/update during a reconnect) gets a timestamp, although nothing was sent. getStatus() (index.ts:1118-1124) then reports a non-null oldestPendingSince while disconnected. docs/debugging.md:109-111 tells readers that "a pending list with an old oldestPendingSince means that the relay doesn't answer". After a long time offline, that diagnosis is wrong. The tests don't catch it: dead-socket.test.ts:437-472 calls sub() only while the socket is OPEN.

Set the time on the first real send:

  const instance: PendingOperation = {}
  // ...
    // 3. Send logic
    const payload = getPayload()
    instance.since ??= Date.now()
    socket.send(createRequest(type, payload))

Chelonia side. chelonia/pubsub/status filters pending to the contracts Chelonia wants, but copies oldestPendingSince from the client unchanged (src/chelonia.ts:1643-1644). So pending: [] can come with a non-null oldestPendingSince. Either filter it (have getStatus() return the since of each pending channel), or say in docs/debugging.md and the ChelPubSubStatus comment (src/types.ts:473-474) that it covers all channels.

4. 🟡 onNextOpen() has no timeout, so a reconnection bug makes tests hang instead of fail

  • Addressed
  • Dismissed

src/pubsub/test-harness.ts:643-656 returns a promise that settles only on PUBSUB_RECONNECTION_SUCCEEDED. Tests call onNextOpen(client), then killClients(), then await it with no time limit (e.g. recovery.test.ts:215-220, and the reconnect tests in chelonia-pubsub-reconcile.test.ts). Reconnection is exactly what these tests check. If it breaks, the await never returns. Client retry timers keep the event loop alive, node --test has no default per-test timeout, and ci.yml sets no timeout-minutes. So CI hangs instead of showing a failed assertion, and captureConsole() hides the logs. waitFor already gives up after a timeout "so that assertions can report what went wrong".

When a caller's Promise.race loses (dead-socket.test.ts:89-93), the listener also stays registered and runs callback on a later open.

export function onNextOpen (
  client: PubSubClient,
  callback?: () => void,
  timeout = 5000
): Promise<number> {
  return new Promise((resolve, reject) => {
    const listener = (target: PubSubClient) => {
      if (target !== client) return
      clearTimeout(timer)
      sbp('okTurtles.events/off', PUBSUB_RECONNECTION_SUCCEEDED, listener)
      callback?.()
      resolve(Date.now())
    }
    const timer = setTimeout(() => {
      sbp('okTurtles.events/off', PUBSUB_RECONNECTION_SUCCEEDED, listener)
      reject(new Error(`onNextOpen: no open within ${timeout} ms`))
    }, timeout)
    sbp('okTurtles.events/on', PUBSUB_RECONNECTION_SUCCEEDED, listener)
  })
}

5. 🟡 publishReceiptTimeout docs link to the wrong section and describe the trigger wrongly

  • Addressed
  • Dismissed

docs/configure.md:84 ends with "See When a publish fails". That section (docs/contracts.md:676-702) is about HTTP rejections and does not mention this option. The option is documented in the new section ### Making sure a published event is processed (docs/contracts.md:704).

The same row says "If the pubsub client isn't subscribed to the contract … Chelonia syncs right away". The code syncs right away only when the client is neither subscribed nor subscribing (src/internals.ts:337-343). While a SUB is pending, it waits for the timeout; with publishReceiptTimeout: 0 it never syncs. docs/contracts.md:712-713 says this correctly, so the two pages disagree.

| `publishReceiptTimeout` | `number` | `5000` | How long (in ms) to wait, after the relay accepted an event published to a contract Chelonia is subscribed to, for that event to come back over pubsub before syncing the contract to process it (e.g. the socket is half-open). If the pubsub client is neither subscribed nor subscribing to the contract (or there is no pubsub client), Chelonia syncs right away instead. `0`, or anything that isn't a positive finite number, disables the timed check only. See [Making sure a published event is processed](./contracts.md#making-sure-a-published-event-is-processed). |

6. 🟡 The new test files copy the same setup helpers

  • Addressed
  • Dismissed

The PR adds a shared harness, but the new test files still repeat its setup:

  • Chelonia tests. src/chelonia-kv-set-sync.test.ts repeats src/chelonia-publish-receipt.test.ts almost line for line: the EVENT_HANDLED set (:37-41 vs :35-39), _init/configure/connect (:43-57 vs :63-77), localHeight, retainedContract() (:72-81 vs :87-95), staleContract() (:85-98 vs :99-112) and the /latestHEADinfo/<id> request count (:153, again at :297-299, and :59-60 in the other file). chelonia-pubsub-reconcile.test.ts:68-80 and time-sync.test.ts repeat isOpen and the configure + connect + wait-for-open steps.
  • Pubsub client tests. src/pubsub/recovery.test.ts:12-52 repeats src/pubsub/dead-socket.test.ts:23-85 (URL, server, client list, before/afterEach/after hooks, setup(), isOpen, subscribed).

Move them into src/pubsub/test-harness.ts and pass relay / pubsub in:

export const isOpen = (client: PubSubClient): boolean =>
  client.socket?.readyState === WebSocket.OPEN

// Number of syncs of `contractID` among the relay's requests from position `from`
export const syncsOf = (relay: FakeRelay, contractID: string, from: number): number =>
  relay.requests.slice(from).filter(({ path }) => path === `/latestHEADinfo/${contractID}`).length

// Configures Chelonia against `relay` and connects it to the fake server
export async function connectChelonia (
  relay: FakeRelay,
  config: Record<string, unknown> = {}
): Promise<PubSubClient> {
  await sbp('chelonia/configure', {
    connectionURL: 'http://relay.test',
    fetch: relay.fetch,
    connectionOptions: { minReconnectionDelay: 20 },
    ...config
  })
  const pubsub = sbp('chelonia/connect') as PubSubClient
  if (!(await waitFor(() => isOpen(pubsub)))) throw new Error('not connected')
  return pubsub
}

// A contract that Chelonia has synced and `pubsub` is subscribed to
export async function retainedContract (
  relay: FakeRelay,
  pubsub: PubSubClient
): Promise<TestContract> { /* … */ }

7. ⚪ A request that is never answered makes the client reconnect (and re-sync everything) forever

  • Addressed
  • Dismissed

After the retry budget runs out, the client always replaces the socket (src/pubsub/index.ts:449-457). The next open sets failedConnectionAttempts = -1 (index.ts:770), so backoff never grows and maxRetries never applies to this loop. If the relay never answers one request (a relay bug for one channel, or a relay that ignores kv_filter), the cycle repeats every 20–30 s with the defaults. The old code dropped the request ("Giving up").

Each cycle clears all subscriptions. Each SUB ack then runs Chelonia's subscription-succeeded handler, which force-syncs the contract (src/chelonia.ts:1135-1153), and PUBSUB_RECONNECTION_SUCCEEDED runs chelonia/kv/_onReconnect. So one bad request makes the client re-sync every contract about every 25 s.

Replacing the socket is a deliberate choice; only the missing limit looks unplanned. One option: limit consecutive replacements for unanswered requests, then drop the request and let the periodic reconcile ask again:

    if (attemptNo++ > options.maxOpRetries) {
      if (client.unansweredReplacements >= options.maxRetries) {
        console.warn(`[pubsub] Giving up ${type} for ${channelID} after replacing the connection ${client.unansweredReplacements} times`)
        client.pendingOperations.tDelete(type, channelID)
        return
      }
      client.unansweredReplacements++
      abandonSocket(client, socket, `no response to ${type} for ${channelID}`)
      return
    }

Reset client.unansweredReplacements = 0 in the RESPONSE_TYPE.OK / RESPONSE_TYPE.ERROR handlers.

8. ⚪ With reconnectOnDisconnection: false, one unanswered request now closes the client for good

  • Addressed
  • Dismissed

abandonSocket (src/pubsub/index.ts:456) runs the close handler. That handler reconnects only if client.shouldReconnect && client.options.reconnectOnDisconnection (index.ts:662). With reconnectOnDisconnection: false (an existing option, used in index.test.ts), one slow SUB/UNSUB/KV_FILTER now closes the socket, nothing replaces it, and every other subscription is lost. Before, the client dropped only that request. The option is documented as being about server-side disconnections, and the comment at index.ts:100-103 promises that the connection is "replaced".

    if (attemptNo++ > options.maxOpRetries) {
      if (!options.reconnectOnDisconnection) {
        // Nothing would replace the connection: keep it and drop the request
        console.warn(`[pubsub] Giving up ${type} for channel`, channelID)
        client.pendingOperations.tDelete(type, channelID)
        return
      }
      abandonSocket(client, socket, `no response to ${type} for ${channelID}`)
      return
    }

9. ⚪ The ping timer restarts only on PING, but the docs say "any message"

  • Addressed
  • Dismissed

The new text says the timeout is about any message: src/pubsub/index.ts:87-90 ("How long to wait for the next message"), :392-393, the createClient JSDoc (:488-489) and docs/debugging.md:139 ("when nothing arrived for pingTimeout ms"). But armPingTimeout runs only from open and from the default PING handler (index.ts:828). The wall-clock watchdog uses lastMessageAt, which every message updates (index.ts:693). So the two checks disagree, and a consumer that replaces the PING handler through options.messageHandlers never restarts the timer.

Restart the timer where lastMessageAt is set, and remove the call from the PING handler:

    client.lastMessageAt = Date.now()
    armPingTimeout(client)

Or change the docs to say "the next ping".

10. ⚪ Longer retry delays now keep Node processes alive after destroy()

  • Addressed
  • Dismissed

The old cap Math.min(200, minDelay) was removed (src/pubsub/index.ts:467-470). With the defaults, the last retry timer can now wait up to about 10 s, and destroy() does not cancel it. The new test has to work around this (src/pubsub/dead-socket.test.ts:439-441: "Retry timers are not cleared on destroy(), so a longer interval would keep the process alive."). The timer does nothing when it fires, but it keeps the Node event loop alive, which affects consumers' test suites. src/chelonia.ts:1369 already uses unref for the same problem:

    const retryID = setTimeout(send, delay)
    // Node.js-specific: a retry must not keep the process alive (no-op in browsers)
    ;(retryID as unknown as { unref?: () => void }).unref?.()

Then the workaround comment in the test can go.

11. ⚪ chelonia/pubsub/status counts a contract as subscribed while its UNSUB is in flight

  • Addressed
  • Dismissed

The client keeps a channel in subscriptionSet until the UNSUB is acknowledged (src/pubsub/index.ts:910-914), and getStatus() does not report pending UNSUBs (index.ts:1112-1131). If a contract is released and retained again before the UNSUB ack, chelonia/pubsub/status (src/chelonia.ts:1635-1645) reports it as neither missing nor pending, and chelonia/pubsub/update puts it in leaveSubscribed (chelonia.ts:1599). When the ack arrives, the client drops the channel. The contract stays lost until the next periodic reconcile (up to 60 s), and the new diagnostic says all is well.

The pubsub/update part is older than this PR, but the new status selector repeats it. Report pending UNSUBs from getStatus() and count them as missing:

    const status = client.getStatus()
    const unsubscribing = new Set(status.unsubscribing)
    const subscribed = new Set(status.subscribed.filter((id) => !unsubscribing.has(id)))

A full fix also needs sub() to cancel a pending UNSUB and send a SUB (src/pubsub/index.ts:1204-1211).

12. ⚪ Duplicated "positive finite number" check; the publishReceiptTimeout: 0 test can't catch a fallback to the default

  • Addressed
  • Dismissed

The same check is written twice:

  • src/chelonia.ts:1347-1351: typeof reconcileInterval === 'number' && reconcileInterval > 0 && Number.isFinite(reconcileInterval)
  • src/internals.ts:346: typeof timeout !== 'number' || !(timeout > 0) || !Number.isFinite(timeout)

Number.isFinite already returns false for non-numbers, so the typeof part is redundant. One helper removes the copy:

// A positive finite number of ms, or `undefined` (disabled)
export const positiveDelay = (value: unknown): number | undefined =>
  Number.isFinite(value) && (value as number) > 0 ? value as number : undefined

Separately, src/chelonia-publish-receipt.test.ts:235-244 waits only sleep(500) before it asserts that no sync happened. If a later change treated 0 as "unset" and fell back to 5000, the test would still pass (the timer is unref()'d, internals.ts:350). Unit-test the helper with 0, -1, NaN, Infinity, undefined and 300 instead.

13. ⚪ The reconcile-timer test cannot detect a leaked timer

  • Addressed
  • Dismissed

src/chelonia-pubsub-reconcile.test.ts:313-327 checks "replaces … and stops it once the client is destroyed" only by counting chelonia/pubsub/update calls. A leaked interval cannot change that count: the interval body reconciles only when client.socket?.readyState === WebSocket.OPEN (src/chelonia.ts:1363), and a destroyed client has socket = null (src/pubsub/index.ts:1106). So removing clearInterval(this.pubsubReconcileIntervalID) (chelonia.ts:1344) or the !client.shouldReconnect self-stop (chelonia.ts:1356-1361) would not make the test fail. Watch the timer itself:

it('replaces the periodic reconcile on chelonia/connect and stops it once the client is destroyed', async (t) => {
  const INTERVAL = 100
  const started = t.mock.method(globalThis, 'setInterval')
  const cleared = t.mock.method(globalThis, 'clearInterval')
  // The reconcile timers are the only intervals of INTERVAL ms
  const timers = () => started.mock.calls
    .filter(({ arguments: args }) => args[1] === INTERVAL)
    .map(({ result }) => result)
  const isCleared = (timer: unknown) =>
    cleared.mock.calls.some(({ arguments: args }) => args[0] === timer)

  await connect(INTERVAL)
  await connect(INTERVAL)
  const [first, second] = timers()
  assert.ok(isCleared(first), 'the timer of the first client is still running')
  assert.ok(!isCleared(second))

  pubsub.destroy()
  assert.ok(
    await waitFor(() => isCleared(second), { timeout: INTERVAL * 3 }),
    'the timer outlived the client'
  )
})

14. ⚪ The Giving up assertions check a log line that no longer exists

  • Addressed
  • Dismissed

This PR removed the [pubsub] Giving up … log from runWithRetry. No code in src/ logs it any more (the only match is a thrown error in utils.ts:710). These assertions check wording rather than behaviour:

  • src/pubsub/recovery.test.ts:222 (obs.giveUps = logs.grep(/Giving up/, tOpen).length), :243, :251
  • src/pubsub/recovery.test.ts:343

"Not giving up" means the SUBs stay pending. Check that directly:

    await sleep(OUTAGE)
    // The old client dropped these SUBs after its retries; they must stay wanted
    obs.stillPending = ids.every((id) => client.pendingOperations.tHas(REQUEST_TYPE.SUB, id))
  it('replaces the dead connection instead of giving up on the channels', () => {
    assert.ok(obs.stillPending, 'a SUB was dropped instead of kept for the next connection')
    assert.ok(obs.abandoned >= 1, 'the dead socket was not abandoned')
  })

Delete line 343. At line 243, report obs.abandoned instead of obs.giveUps.

15. ⚪ Missing assertions for UNSUB/KV_FILTER budgets and for the 412 path

  • Addressed

  • Dismissed

  • The SUB test (src/pubsub/recovery.test.ts:339-342) checks 5 sends and the Abandoning socket: no response to sub for u log. The UNSUB test (:367-383) and the KV_FILTER test (:385-397) only check that the connection changed. They still pass if the request is abandoned after one try or with the wrong reason. Add the same two checks:

    assert.equal(first.frames.filter(({ msg }) => msg.type === REQUEST_TYPE.UNSUB).length, 5)
    assert.equal(newLogs.grep(/Abandoning socket: no response to unsub for w$/).length, 1)
  • chelonia/kv/set syncs only on response.status === 409 (src/chelonia.ts:3190-3195). No test in chelonia-kv-set-sync.test.ts shows that a 412 (stale etag) does not sync, so the guard could widen without a test failing:

    it('does not sync on a 412 (a stale etag, not a stale height)', async () => {
      const { contract } = await staleContract()
      relay.onRequest = ({ method, path }) => {
        if (method === 'POST' && path.startsWith('/kv/')) {
          return new Response('', { status: 412, headers: { 'x-cid': '""' } })
        }
      }
      const result = await kvSet(contract, { messageHash: 'x' }, { maxAttempts: 2 })
      assert.ok(result.error instanceof ChelErrorKvMaxAttempts, describeError(result.error))
      assert.deepEqual(result.statuses, [412, 412])
      assert.equal(result.syncs, 0)
    })

16. ⚪ The connectionOptions.* docs row overstates two things

  • Addressed
  • Dismissed

docs/configure.md:82:

  • "Every other field is passed to the pubsub client as is." chelonia/configure merges connectionOptions with turtledash merge, which deep-clones objects through JSON.parse(JSON.stringify(...)). So Infinity becomes null and functions are dropped. Also, chelonia/connect overrides handlers and messageHandlers from its own argument (src/chelonia.ts:1131-1157). This is older than the PR, but the new wording promises the opposite.
  • "the client replaces the connection and sends the request again." This is true only for SUB. Pending UNSUBs and KV filters are cleared on close (src/pubsub/index.ts:659-660); the filter goes out with the next SUB.
| `connectionOptions.*` | pubsub client `Options` | see `src/pubsub/index.ts` | Every other JSON-serializable field is passed to the pubsub client (`chelonia/configure` turns `Infinity` into `null`; event handlers go in the argument of `chelonia/connect`), e.g. `pingTimeout` (…), `opRetryInterval` (`2000`) and `maxOpRetries` (`4`). A SUB/UNSUB/KV filter request that gets no answer after `maxOpRetries` retries (about 10 to 15 x `opRetryInterval` with the defaults) is never dropped: the client replaces the connection and sends the SUBs (with their KV filters) again on the new one; an UNSUB is then complete, since the server drops the old connection's subscriptions. Read by `chelonia/connect`. |

17. ⚪ Harness fidelity: waitFor clock, server.kv() filters, close() checks

  • Addressed

  • Dismissed

  • waitFor uses a patched clock. waitFor sets its deadline from performance.now() (src/pubsub/test-harness.ts:631), which emulateSleep({ monotonic: true }) patches (:697). A wake(ms) during a waitFor moves its deadline by ms (5 minutes in time-sync.test.ts). No current test does this, but it is an easy trap. Capture the real function at import time:

    // Not affected by `emulateSleep({ monotonic: true })`
    const realPerformanceNow = performance.now.bind(performance)
  • server.kv() ignores KV filters. The fake records each connection's filter (test-harness.ts:150-177), but server.kv() (:349-351) notifies every subscriber. A future KV-filter test would see behaviour the real server doesn't have. No test calls it; delete it or apply the filter:

    kv (channelID: string, key: string, data: unknown, cid?: string) {
      for (const conn of subscribersOf(channelID)) {
        const filter = conn.kvFilters.get(channelID)
        if (!filter || filter.includes(key)) {
          deliver(conn, { type: 'kv', channelID, key, data, cid })
        }
      }
    },
  • FakeWebSocket.close() accepts any code and reason (test-harness.ts:238-256). The real API throws InvalidAccessError for codes other than 1000 or 3000–4999, and SyntaxError for a reason over 123 UTF-8 bytes. So the fallback catch in abandonSocket (src/pubsub/index.ts:380-387) never runs in tests, and a bad close code would pass:

    close (code?: number, reason?: string) {
      if (code !== undefined && code !== 1000 && !(code >= 3000 && code <= 4999)) {
        throw new DOMException(`Invalid close code ${code}`, 'InvalidAccessError')
      }
      if (reason !== undefined && new TextEncoder().encode(reason).length > 123) {
        throw new DOMException('Close reason too long', 'SyntaxError')
      }
      // …

18. ⚪ Dead code and copied logic in the harness

  • Addressed

  • Dismissed

  • Unused: the server.latency getter (test-harness.ts:262), relay.requestsTo() (:602-605), and FakeConnection.createdAt / openedAt (:64-65, written but never read).

  • The sub and kv_filter cases repeat the same set-or-delete of the filter (:153-157 and :170-174), and the switch uses literal protocol strings ('sub', 'unsub', 'kv_filter', 'ok', 'ping', 'entry', 'kv', …). Import REQUEST_TYPE, RESPONSE_TYPE and NOTIFICATION_TYPE from ./index.js so that the fake follows the client if a value changes:

    const setKvFilter = (conn: FakeConnection, channelID: string, kvFilter: unknown) => {
      if (Array.isArray(kvFilter)) conn.kvFilters.set(channelID, kvFilter)
      else conn.kvFilters.delete(channelID)
    }

19. ⚪ Small issues in chelonia-kv-set-sync.test.ts

  • Addressed

  • Dismissed

  • Wrong comment. :51-52 says "A lost subscription stays lost" next to pubsubReconcileInterval: 0. But CONTRACTS_MODIFIED still runs chelonia/pubsub/update (src/chelonia.ts:1301), so every later retainedContract() re-subscribes the contracts that earlier tests made stale. The assertions still pass because syncs is filtered by contract ID.

        // No periodic reconcile: a contract lost with `pubsub.unsub()` stays
        // lost until the next `chelonia/pubsub/update` (e.g. the next retain)
        pubsubReconcileInterval: 0,
  • Repeated try/finally. :214-221, :234-241 and :252-260 each reset relay.onRequest in a finally. One afterEach(() => { relay.onRequest = undefined }) does the same and covers new tests.

  • queuedSet test. encryptionKeyName: '#cek' (:287) names a key that createTestContract() doesn't define (only #csk and #sak), so the value is written unencrypted anyway. Remove it. The manual Promise.race + setTimeout (:290-295) does what node:test's per-test { timeout: 5000 } option does.

20. ⚪ Simplify the SUB acknowledgement lookup

  • Addressed
  • Dismissed

src/pubsub/index.ts:889-895 calls tHas, then tGet(...) ?? {} on the same key, then tDelete. Entries are always objects, so one tGet is enough:

      case REQUEST_TYPE.SUB: {
        const pending = client.pendingOperations.tGet(REQUEST_TYPE.SUB, channelID)
        if (pending) {
          const elapsed = pending.since === undefined ? 0 : Date.now() - pending.since
          if (elapsed > client.options.opRetryInterval) {
            console.info(`[pubsub] Late ACK: subscribed to ${channelID} ${elapsed} ms after the request`)
          }
          client.pendingOperations.tDelete(REQUEST_TYPE.SUB, channelID)

21. ⚪ Test comments and titles refer to labels and files that aren't in the repo

  • Addressed

  • Dismissed

  • 3175-report/... "Port of" headers: src/pubsub/dead-socket.test.ts:4, src/pubsub/recovery.test.ts:192.

  • CL-n labels, which nothing in the repo defines. Several are in describe/it titles, so they appear in every test report:

    • CL-1, CL-2, CL-10: src/pubsub/recovery.test.ts:240, 246, 250, 255, 304, 311, 318, 400
    • CL-3, CL-4, CL-5: src/pubsub/dead-socket.test.ts:1-3 and its describe titles
    • CL-6, CL-10: src/chelonia-pubsub-reconcile.test.ts:2, 129, 210, 235, 260
    • CL-7: src/chelonia-publish-receipt.test.ts describe titles
    • CL-8: src/chelonia-kv-set-sync.test.ts:3, 163, 192, 225, 264, 277
    • CL-9: src/time-sync.test.ts:2

Remove them, or replace them with a link to okTurtles/group-income#3176 / #121.

22. ⚪ AGENTS.md now holds implementation detail

  • Addressed
  • Dismissed

The new "Pubsub recovery model" section (AGENTS.md:192-214) describes internals: abandonSocket, close code 4001, maxOpRetries, the wall-clock ping check, and which selector syncs when. The test-harness paragraph (AGENTS.md:385-392) lists helper functions and how they work. AGENTS.md should be a table of contents plus general rules; details belong in code comments and docs. Most of this text already exists in docs/debugging.md and in src/pubsub/index.ts comments, so the copies will drift. Suggested replacement:

### Pubsub recovery

Chelonia must stay subscribed (in `src/pubsub/index.ts`) to every contract in its
`subscriptionSet`. For how the client and Chelonia recover lost subscriptions and dead
sockets, see [docs/debugging.md](docs/debugging.md#pubsub-connection).
Pubsub and network tests use the in-process fakes in `src/pubsub/test-harness.ts` (there is
no WebSocket server in the repo). Example: `src/chelonia-publish-receipt.test.ts`.

23. ⚪ The ESM build compiles and ships the test harness

  • Addressed
  • Dismissed

tsconfig.cjs.json:21-24 excludes the harness from the CJS build, but tsconfig.json:22-23 includes all of src/**/*.ts. So npm run build:esm writes dist/esm/pubsub/test-harness.mjs and .d.mts, and with no files list or .npmignore they go into the npm package. The existing src/test-utils.ts has the same treatment (dist/esm/test-utils.mjs is tracked), so the impact is low. If test-only code should not ship, exclude both from the ESM build too.

24. ⚪ Ambiguous sentence in docs/contracts.md

  • Addressed
  • Dismissed

docs/contracts.md:709-710: "so for a contract Chelonia is subscribed to it checks after every successful publish" first reads as "subscribed to it". Add a comma:

half-open, that never happens, so for a contract Chelonia is subscribed to,
it checks after every successful publish:

@corrideat

Copy link
Copy Markdown
Member Author
Another Opus 5.5 high review

Code Review

Base: main (a9fea04)
Head: 121-lost-pubsub-websocket-connection (c031b6b)
Date: 2026-11-06
Model: claude-opus-5-5 (anthropic-gateway). This was a split review: 5 slices, each reviewed by a read-only crush run worker, and all 5 returned a review. The parent then re-checked every finding below against the merged source and the full diff.

Context: #121, items CL-1 to CL-10. The branch covers every item. On Node 26, npm test passes (572/572), and so do eslint and tsc --noEmit. Finding 1 was reproduced by removing the global CloseEvent, which is how Node 22 behaves.


1. 🔴 The new tests need a global CloseEvent, and Node 22 (the version CI uses) doesn't have one

  • Addressed
  • Dismissed

Two places in the tests create close events with new CloseEvent(...):

  • src/pubsub/test-harness.ts:91-103 (dispatchClose, failConnection). Every fake close(), killClients(), closeClients() and reset() goes through these.
  • src/pubsub/recovery.test.ts:359: first.socket.dispatchEvent(new CloseEvent('close', { code: 1006 }))

Node.js only made CloseEvent a global in v23.0.0: nodejs.org/api/globals.html says "Class: CloseEvent, Added in: v23.0.0". Meanwhile:

  • .github/workflows/ci.yml runs npm test with node-version: 22.
  • README.md:48 says "Should work with Node 22".
  • TypeScript doesn't catch this, because lib: ["dom"] declares the type.

To check, I ran the suites with the global removed:

node --import 'data:text/javascript,delete globalThis.CloseEvent' --import ./scripts/ts-node-register.mjs \
  --test src/pubsub/recovery.test.ts src/chelonia-kv-set-sync.test.ts src/pubsub/dead-socket.test.ts
# ℹ tests 39 / ℹ pass 14 / ℹ fail 19   (ReferenceError: CloseEvent is not defined)

What happens on Node 22:

  • afterEach → server.reset() → killClients() throws, before it resets onRequest, mode and latency.
  • Clean closes throw inside setTimeout callbacks, which scripts/ts-node-register.mjs re-throws.
  • destroy() on a CONNECTING fake socket stops halfway.

The library already has the right fallback (createCloseEvent, src/pubsub/index.ts:357-362). The harness should do the same and export it:

// src/pubsub/test-harness.ts
// `CloseEvent` is only a global from Node.js 23 on (CI runs Node 22)
export const createCloseEvent = (code: number, reason = '', wasClean = false): Event =>
  typeof CloseEvent === 'function'
    ? new CloseEvent('close', { code, reason, wasClean })
    : Object.assign(new Event('close'), { code, reason, wasClean })

const dispatchClose = (conn: FakeConnection, code: number, reason: string, wasClean: boolean) => {
  conn.state = 'closed'
  conn.socket.readyState = CLOSED
  conn.socket.dispatchEvent(createCloseEvent(code, reason, wasClean))
}
const failConnection = (conn: FakeConnection) => {
  conn.state = 'closed'
  conn.socket.readyState = CLOSED
  conn.socket.dispatchEvent(new Event('error'))
  conn.socket.dispatchEvent(createCloseEvent(1006))
}
// src/pubsub/recovery.test.ts:359
first.socket.dispatchEvent(createCloseEvent(1006))

2. 🟡 A 409 in publishEvent now re-subscribes to a contract that was released (or fails on one that was deleted)

  • Addressed
  • Dismissed

src/internals.ts:958-962:

if (!entry.isFirstMessage()) {
  const localHeight = sbp(this.config.stateSelector).contracts[contractID]?.height
  if (localHeight == null || localHeight < entry.height()) {
    await sbp('chelonia/private/in/sync', contractID, { force: true })
  }
}

localHeight == null is exactly what you get when the contract was released or deleted during the random back-off:

  • Released: removeImmediately deletes contracts[contractID]. syncContract doesn't check reference counts. It pushes the contract to this.pending and adds it back to subscriptionSet (src/internals.ts:2089-2097, :2138-2143). The result is that a contract with refcount 0 gets subscribed again.
  • Then the event goes out anyway. On the next loop iteration the state exists again. So instead of stopping with "Not sending message as contract state has been removed" (:837-842), the publish sends the event again.
  • Deleted: contracts[contractID] === null, so syncContract throws ChelErrorResourceGone. The publish then rejects instead of quietly not sending.

The old code had the same flaw, but only on the second consecutive 409. This change triggers it on the first one. It also doesn't match the rest of the change: chelonia/kv/set (src/chelonia.ts:3190-3195) and ensurePublishedEventReceived (src/internals.ts:314) both sync only when this.subscriptionSet.has(contractID).

if (!entry.isFirstMessage() && this.subscriptionSet.has(contractID)) {
  const localHeight = sbp(this.config.stateSelector).contracts[contractID]?.height
  // No local state: the contract was released or deleted meanwhile, and the
  // next attempt stops. Syncing would subscribe to it again
  if (localHeight != null && localHeight < entry.height()) {
    await sbp('chelonia/private/in/sync', contractID, { force: true })
  }
}

3. 🟡 Replacing a connection over an unanswered request never backs off, so a slow server gets a reconnect and resync storm

  • Addressed
  • Dismissed

When the retry budget runs out, runWithRetry now calls abandonSocket(...) (src/pubsub/index.ts:449-457). That runs close, which increments failedConnectionAttempts (:611) and schedules a reconnection (:662-671). But every open resets failedConnectionAttempts = -1 (:770). So getNextRandomDelay() (:1134-1155) always starts from attempt 0: 500 to 1000 ms with the defaults. The cycle is a reconnect after about 1 s, then 20 to 30 s of retry budget, then another reconnect, and the backoff never grows.

The issue itself names the trigger for this: "a slow or overloaded server, for example when every client re-subscribes after a server restart". On every cycle, every client:

  • re-sends all its SUBs on a new socket, so the server loses the work it already did;
  • force-syncs every contract whose SUB does get answered (src/chelonia.ts:1137-1151);
  • runs chelonia/kv/_onReconnect and the new reconcile.

This makes the overload worse. The same loop can also start on a healthy connection where a single request never gets a matching answer, for example a KV_FILTER whose ACK isKvFilterFresh never accepts (:920-928, e.g. the server de-duplicates the keys). Before this change only that one request was dropped. Now the whole connection is torn down every ~30 s, forever.

Suggestion: count connections replaced in a row because of unanswered requests, and use that count in the backoff. Reset it only once a connection has shown that it works, e.g. when the pending SUB map drains:

// PubSubClient
unansweredReplacements: number;

// runWithRetry, 2b
client.unansweredReplacements++
abandonSocket(client, socket, `no response to ${type} for ${channelID}`)

// RESPONSE_TYPE.OK, REQUEST_TYPE.SUB branch, after tDelete
if (!client.pendingOperations.get(REQUEST_TYPE.SUB)?.size) client.unansweredReplacements = 0

// getNextRandomDelay
const attempts = Math.max(client.failedConnectionAttempts, client.unansweredReplacements)
const minDelay = minReconnectionDelay * reconnectionDelayGrowFactor ** attempts

4. 🟡 oldestPendingSince reports SUBs that were never sent, and SUBs that Chelonia doesn't want

  • Addressed
  • Dismissed

The comments say that requests waiting for a connection don't count (src/pubsub/index.ts:73-76, :79-83). docs/debugging.md:108-111 tells apps that "a pending list with an old oldestPendingSince means that the relay doesn't answer". Two things make the value wrong:

  1. since is set when the entry is created, not when it is sent (src/pubsub/index.ts:436: const instance: PendingOperation = { since: Date.now() }).
    • sub() calls runWithRetry whatever the socket state (:1204-1211), and send() returns at once if the socket isn't OPEN (:442).
    • So a sub() while CONNECTING or during a reconnection delay carries a since. That happens, for example, on CONTRACTS_MODIFIED at startup, before the first open.
    • With the defaults, the value then ages for up to 60 s (timeout) or maxReconnectionDelay, as if the relay were ignoring the request.
    • The test at src/pubsub/dead-socket.test.ts:458-462 only covers entries that close re-armed ({}), so it misses this case.
  2. chelonia/pubsub/status filters pending but not oldestPendingSince. In src/chelonia.ts:1637-1644, pending only lists the contracts Chelonia wants, while oldestPendingSince is copied from client.getStatus() as is. That value covers every pending SUB: app-level channels, or contracts released while their SUB was pending. So pending: [] can come with a non-null oldestPendingSince.
// src/pubsub/index.ts, runWithRetry
const instance: PendingOperation = {}
// ...
socket.send(createRequest(type, payload))
// When it was first sent on this connection, not when it was queued
if (instance.since === undefined) instance.since = Date.now()
// src/chelonia.ts, chelonia/pubsub/status
const pendingOps = client.pendingOperations.get(REQUEST_TYPE.SUB)
const wantedPending = wanted.filter((id) => pending.has(id))
let oldestPendingSince: number | null = null
for (const id of wantedPending) {
  const since = pendingOps?.get(id)?.since
  if (since !== undefined && (oldestPendingSince === null || since < oldestPendingSince)) {
    oldestPendingSince = since
  }
}

5. ⚪ The docs overstate or misdirect in several places

  • Addressed

  • Dismissed

  • Not every unanswered request is sent again. Three places say an unanswered SUB, UNSUB or KV filter request "is never dropped ... and sent again on the new connection": AGENTS.md:175-178, docs/configure.md:82 and docs/debugging.md:133-136. Only SUBs are re-sent. The close logic drops pending UNSUBs and KV_FILTERs (src/pubsub/index.ts:650-660), and the filter goes along with the next SUB. The code comment at src/pubsub/index.ts:450-455 describes this correctly. Suggested wording:

    SUBs (with their KV filters) are sent again on the new connection; pending UNSUBs are settled by the close, since the server drops that connection's subscriptions.

  • Wrong link. docs/configure.md:84 (publishReceiptTimeout) links to contracts.md#when-a-publish-fails, which is about HTTP errors. The section that covers this is contracts.md#making-sure-a-published-event-is-processed (docs/contracts.md:704).

  • Two rows now contradict each other. docs/configure.md:81 still says reconnectOnTimeout means "Reconnect when the server stops answering pings". The new row below it says a ping timeout always replaces the connection, which is what the code does. reconnectOnTimeout only controls connectionTimeUsed (src/pubsub/index.ts:1062-1064).

  • "Every other field is passed as is" isn't quite true. docs/configure.md:82 says so, but chelonia/connect overwrites handlers and messageHandlers (src/chelonia.ts:1131-1158).

  • The 409 sync doesn't apply to every caller. docs/kv.md:132 says it does, but without onconflict the call throws before it syncs (src/chelonia.ts:3177-3182), and so does a call with maxAttempts: 1.

  • "Are safe" can be misread. docs/kv.md:142-143 says that "chelonia/kv/queuedSet, chelonia/queueInvocation and the slot API use the public queue and are safe". That is only true for calls that aren't awaited. queueInvocation first queues a no-op on the internal queue (src/chelonia.ts:1584-1586), so awaiting any of them from a side effect already deadlocked. Suggest "are safe to call (but not to await) from a side effect".

6. ⚪ online can call scheduleConnectionAttempt() on a client that was just destroyed

  • Addressed
  • Dismissed

This can happen in src/pubsub/index.ts:737-753 when failedConnectionAttempts has reached maxRetries:

  1. abandonSocket(...) runs close, which emits PUBSUB_RECONNECTION_FAILED.
  2. The default handler for that event (:793-798) calls destroy() synchronously, which sets shouldReconnect = false.
  3. Back in online, !client.socket is true and no attempt is scheduled, so scheduleConnectionAttempt() runs and throws (:1162-1164).
  4. The listener wrapper turns the throw into a spurious PUBSUB_ERROR, and the final updateState is skipped.

Chelonia uses maxRetries: Infinity, so this only affects other users of the client. The guard is cheap:

if (!client.socket && client.shouldReconnect) {
  client.failedConnectionAttempts = 0
  // Abandoning the socket may have scheduled the attempt already
  if (!client.nextConnectionAttemptDelayID) client.scheduleConnectionAttempt()
}

7. ⚪ The ping timer and the wall-clock watchdog disagree on what "a message" is

  • Addressed
  • Dismissed

The comments and docs say pingTimeout is how long to wait for any message:

  • src/pubsub/index.ts:87-90;
  • :392-393;
  • docs/debugging.md:139 ("when nothing arrived for pingTimeout ms").

The watchdog does use lastMessageAt, which every message updates (:693). The setTimeout timer, however, is only re-armed by PING (:828). So a socket that gets ENTRY/KV traffic but no PING is abandoned by one check and considered alive by the other. To make them agree, re-arm the timer wherever lastMessageAt is set:

message (event: MessageEvent) {
  const client = this
  const { data } = event

  client.lastMessageAt = Date.now()
  armPingTimeout(client)
  // ...

Then remove the call from the PING handler. The alternative is to fix the comments to say "ping".

8. ⚪ Duplicate SUB ACKs are now logged at info level

  • Addressed
  • Dismissed

On a slow link, each retry of a SUB that went out before the first ACK arrived gets its own answer. Each of those answers now logs Received unexpected sub ... (already subscribed) at info level (src/pubsub/index.ts:904-906). With dozens of contracts on a slow reconnect, that is 3 to 4 info lines per channel. The Late ACK line already records what is useful here (CL-10). Only not requested points to a real disagreement between client and server:

if (client.subscriptionSet.has(channelID)) {
  // An answer to one of our retries
  console.debug(`[pubsub] Received duplicate sub ACK for ${channelID}`)
} else {
  console.info(`[pubsub] Received unexpected sub for ${channelID} (not requested)`)
}

(src/pubsub/recovery.test.ts:447-451 would need to change to match.)

9. ⚪ Side effects around PUBSUB_STATE_CHANGED and computeState

  • Addressed

  • Dismissed

  • The first event is easy to miss. connect() emits closed -> connecting while createClient is still running (src/pubsub/index.ts:561-563, :1073), before Chelonia has assigned this.pubsub (src/chelonia.ts:1131). A listener that uses the usual client !== this.pubsub filter therefore drops it.

  • A query emits events. getStatus() calls updateState (:1117), so chelonia/pubsub/status can fire PUBSUB_STATE_CHANGED.

  • WebSocket is now needed earlier. computeState reads the global WebSocket.OPEN even when client.socket is null (:964-967). It is now called from createClient (:560) and destroy() (:1108), so createClient(url, { manual: true }) throws without a global WebSocket.

At least document the first two on PUBSUB_STATE_CHANGED (:258-259). For the third, check if (!client.socket) before comparing readyStates, or use socket.OPEN/socket.CONNECTING.

10. ⚪ A sleep while the /time body is being read leaves chelonia/time behind, and nothing notices

  • Addressed
  • Dismissed

In src/time-sync.ts:82-111, the "clock jumped" check (:88-92) runs before await time.text() (:95), but moveCheckpoint() (:111) reads the clocks after that await. If the device sleeps between the headers and the end of the body:

  • wallBase lags by the length of the sleep, because performance.now() stood still while asleep;
  • the checkpoint then moves past the jump, so neither chelonia/time nor the watchdog sees it.

The estimate stays wrong until the next resync, 5 minutes later. That is the CL-9 symptom, though the window is small. To close it, tie the checkpoint to the moment of the sample:

const requestTimeElapsed = performance.now()
const wallRequestTimeElapsed = Date.now()
// ... use wallRequestTimeElapsed in the jump check ...
monotonicBase = newMonotonicBase
// A jump after the sample (e.g., while reading the body) is still detected
moveCheckpoint(wallRequestTimeElapsed, requestTimeElapsed)

11. ⚪ The "positive finite number" check and the unref code are copied three times, and very large values make the timers fire at once

  • Addressed
  • Dismissed

The same delay check and .unref?.() block (with the same comment) appear in:

  • src/chelonia.ts:1346-1351 and :1367-1369;
  • src/internals.ts:345-350;
  • a third variant in src/pubsub/index.ts:390 (isPingTimeoutEnabled).

Values above 2^31-1 ms pass the check, but Node then turns them into 1 ms timers (with a TimeoutOverflowWarning), and browsers fire them at once. A huge pubsubReconcileInterval, meant as "basically never", would therefore run chelonia/pubsub/update in a tight loop.

Separately, the retry setTimeout(send, delay) in runWithRetry (src/pubsub/index.ts:470) is neither tracked nor unref'd. Without the 200 ms cap it can now last up to about 10 s, so it keeps Node processes alive after destroy(). src/pubsub/dead-socket.test.ts:439-442 works around exactly that. A shared helper would cover all of these:

// utils.ts
const MAX_TIMER_DELAY = 2 ** 31 - 1
export const timerDelay = (value: unknown): number | undefined =>
  typeof value === 'number' && value > 0 && Number.isFinite(value)
    ? Math.min(value, MAX_TIMER_DELAY)
    : undefined
export const unrefTimer = <T>(id: T): T => {
  // Node.js-specific: keeps the timer from holding the process open
  ;(id as { unref?: () => void }).unref?.()
  return id
}

12. ⚪ Some new assertions can never fail, and two CL-8 paths are untested

  • Addressed

  • Dismissed

  • The "Giving up" checks. Nothing in src/ logs Giving up any more, so src/pubsub/recovery.test.ts:251 (obs.giveUps === 0) and :343 always pass. CL-1 asks that the SUB stays pending, and that is never checked directly. One way: during the outage, before server.pass(), check ids.every((id) => client.pendingOperations.tHas(REQUEST_TYPE.SUB, id)).

  • The reset test. src/chelonia-publish-receipt.test.ts:249-259 passes even without the instance !== this._instance guard (src/internals.ts:321), because chelonia/reset empties subscriptionSet and the subscriptionSet.has check returns first. Retain the contract again after the reset, before the timeout fires, to cover the guard.

  • The reconcile-interval test. src/chelonia-pubsub-reconcile.test.ts:313-327 passes without the clearInterval at src/chelonia.ts:1344, and without the !client.shouldReconnect exit: the old timer stops by itself, and after destroy() the socket is null, so nothing reconciles either way. Spy on setInterval/clearInterval to detect a timer leak.

  • Unchecked waitFor results. src/time-sync.test.ts:181 and :185 discard them, so the test can silently run a different scenario. Wrap them in assert.ok(...) like the rest of the file.

  • Untested paths. There is no kv/update (slot API) case on a stale contract, although docs/kv.md describes it as the main path and as safe from deadlocks. There is also no case checking that a 412 does not sync (src/chelonia.ts:3191).

  • A key that doesn't exist. src/chelonia-kv-set-sync.test.ts:287 passes encryptionKeyName: '#cek', but createTestContract() has no such key, so the value is written unencrypted without saying so.

13. ⚪ Test helpers are copied across files

  • Addressed
  • Dismissed

test-harness.ts exists to hold shared helpers, but several are copied almost word for word:

  • setup/isOpen/subscribed: src/pubsub/recovery.test.ts:33-52 and src/pubsub/dead-socket.test.ts:62-85. isOpen also has two different shapes across the suite.
  • retainedContract/staleContract: src/chelonia-kv-set-sync.test.ts:72-98 and src/chelonia-publish-receipt.test.ts:87-112.
  • The 409 KV response (src/chelonia-kv-set-sync.test.ts:211, :231).

Six test files also declare const URL = 'http://relay.test', which shadows the global URL constructor. Export these from the harness under distinct names:

// src/pubsub/test-harness.ts
export const RELAY_URL = 'http://relay.test'
export const isOpen = (client: PubSubClient) => () => client.socket?.readyState === WebSocket.OPEN
export const subscribed = (client: PubSubClient, ids: string[]) => () =>
  ids.every((id) => client.subscriptionSet.has(id))
export const kvConflict = () => new Response('', { status: 409, headers: { 'x-cid': '""' } })

14. ⚪ The test harness differs from the real environment in ways that can hide bugs or hang tests

  • Addressed

  • Dismissed

  • emulateSleep({ monotonic: true }) moves performance.now() backwards (src/pubsub/test-harness.ts:697, :701). waitFor() takes its deadline from the same function (:631, :635), so a wake() during a pending waitFor() extends it by the length of the sleep (5 min in time-sync.test.ts). Also, restore() (:711) uses delete performance.now instead of restoring the saved descriptor, unlike emulateNavigator(). Capture const harnessNow = performance.now.bind(performance) at module load and use it in waitFor.

  • onNextOpen() hangs if callback throws (:648-653). The error is swallowed by okTurtles.events/emit and hidden by captureConsole(), and the promise never settles. Wrap the callback in try/catch and call reject(e).

  • FakeWebSocket.close() accepts any code or reason (:238-253). Real sockets throw for codes other than 1000 or 3000-4999, and for reasons over 123 bytes. So the fallback try/catch in abandonSocket (src/pubsub/index.ts:380-387) is never exercised.

  • Unused code. relay.requestsTo(), server.kv(), the server.latency getter, and createdAt/openedAt are not used anywhere. kv() also ignores the recorded kvFilters.

15. ⚪ The "Port of" comments point to a folder that isn't in the repository

  • Addressed
  • Dismissed

src/pubsub/dead-socket.test.ts:4 and src/pubsub/recovery.test.ts:192 say Port of `3175-report/tests/...` . 3175-report/ is an untracked local folder in this checkout, and its number doesn't match the issues the other test files cite (okTurtles/group-income#3176, libcheloniajs#121). Suggested:

// Port of `01-subscription-recovery.test.mjs` from the regression suite attached to
// okTurtles/libcheloniajs#121 (okTurtles/group-income#3176)

16. ⚪ (Older bug) Retaining a contract again while its UNSUB is pending loses the subscription until the next reconcile

  • Addressed
  • Dismissed

The sequence:

  1. Release a contract, which calls unsub(X). X stays in client.subscriptionSet until the UNSUB is answered.
  2. Retain it again right away. chelonia/pubsub/update (src/chelonia.ts:1595-1609) counts X as leaveSubscribed and never calls sub(X). Even if it did, sub() returns early because subscriptionSet.has(X) (src/pubsub/index.ts:1204-1207).
  3. The UNSUB OK then removes X, while Chelonia still wants it.

This bug was there before this change. The new periodic reconcile limits the damage to pubsubReconcileInterval (60 s), but this branch aims for subscriptions that are never lost. Two changes would fix it:

  • chelonia/pubsub/update should treat channels with a pending UNSUB as not subscribed;
  • sub() should cancel a pending UNSUB (tDelete(UNSUB), subscriptionSet.delete) and send the SUB. Requests on one socket are ordered, so the server sees UNSUB, then SUB.

Most recovery tests failed on CI's Node 22, which has no global
CloseEvent; the test harness now creates close events itself. The
helpers that were copied across test files now live in the harness,
and its fake socket rejects what a real one rejects. Assertions that
could never fail now check what they claim, and the KV write paths
that had no tests now have some.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
When the relay rejected a published event as conflicting, Chelonia
synced the contract to catch up, even if the app had released or
deleted it while the request was in flight. That sync subscribed to
the released contract again, and failed on a deleted one. Chelonia
now only syncs contracts that it still wants and has local state for.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
A subscription or KV filter that the relay never answered made the
client replace the connection, and every new connection was opened
after the shortest delay. A slow or overloaded relay then got a storm
of reconnections and resyncs. Replacements in a row now wait longer
each time, like failed connection attempts, until every pending
subscription and KV filter has been answered.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
- The oldest pending subscription in the status only counts requests
  that were actually sent, and Chelonia's status only the ones that
  Chelonia wants, so a slow connection attempt isn't reported as an
  unanswered request.
- Any message from the server, not only a ping, now shows that the
  connection works, as the wake-up check already assumed.
- Coming back online no longer restarts a client that just gave up
  reconnecting.
- The answers to a subscription's retries are logged at debug level,
  not as unexpected at info level.
- The client no longer needs a global WebSocket before it connects,
  and the docs say when the first connection state event fires.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
The server time estimate is based on the moment the reply arrived,
but the reference point for noticing later clock jumps was taken
after its body was read. A sleep in between went unnoticed, and the
estimate stayed behind by the length of the sleep. The reference
point is now the moment the reply arrived.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
Timers fire at once when given a delay above 2^31 - 1 ms (about 24.8
days), so a huge reconcile interval, publish receipt timeout, ping
timeout or connection timeout fired immediately; the connection
timeout then aborted every attempt. Such values now count as the
longest supported delay. A connection timeout that isn't a positive
finite number now disables it, as for the other timeouts, and the
subscription retry timer no longer keeps a Node.js process alive.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
Subscribing to a channel again while its unsubscribe request was
still pending did nothing, so the subscription was lost once the
unsubscribe was answered, until the next periodic reconcile. The
client now cancels the pending unsubscribe and subscribes again, and
Chelonia's reconcile treats such channels as not subscribed. This is
an older bug, found while reviewing the pubsub recovery work.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
The docs said that no request is ever dropped when the client replaces
a connection. Only subscriptions (with their KV filters) are sent
again; pending unsubscribes are settled by the close, and replacements
in a row back off. Also corrects what reconnectOnTimeout does, which
connection options Chelonia sets itself, a broken link, and when the
KV conflict sync and the side-effect safety notes apply.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
A broken reconnection made the pubsub tests wait forever, so CI hung
instead of reporting a failure; waiting for a reconnection now gives up
after a few seconds. The Chelonia test files share their setup through
the test harness, whose fake server now uses the client's protocol
constants. The retry budget tests check every request type, the timer
helpers have their own tests, a test that failed about once in twenty
runs is fixed, and test titles no longer name labels from an external
report.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
When the relay rejects a published event as conflicting, Chelonia
syncs the contract and tries again. If that sync failed (for example
because of a network error), the publish was rejected although
attempts remained. The failure is now logged and the publish tried
again, as chelonia/kv/set already does.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
A request that the relay doesn't answer within its retry budget makes
the client replace the connection. With reconnectOnDisconnection:
false, nothing replaced it, so one slow request closed the client's
only connection and lost every other subscription. In that mode the
client now keeps the connection and drops only that request, as it
did before the recovery work.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
chelonia/configure copied the pubsub connection options through a JSON
clone, which turns Infinity into null and drops functions. Restating
the default maxRetries: Infinity therefore made the client stop
reconnecting after one failed attempt. The options now reach the
client as given, as hooks already did.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
No change in behaviour.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
The publishReceiptTimeout docs said that Chelonia syncs at once when
the pubsub client isn't subscribed to the contract; it also waits
while a subscription is pending. The debugging guide now says what a
relay that never answers one request still costs: a new connection
every 80 to 90 s with the defaults, each one re-syncing every
contract. The pubsub passages of AGENTS.md become a summary that
links to the docs, so the two can't drift apart. Also fixes an
ambiguous sentence.

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5
@corrideat

Copy link
Copy Markdown
Member Author
Opus 5.5 review of latest changes

Code Review

Base: main (a9fea04)
Head: 121-lost-pubsub-websocket-connection (a3a875a)
Date: 2026-11-08
Model: claude-opus-5-5 (anthropic-gateway). This was a split review: 6 slices, each reviewed by a read-only crush run worker, and all 6 returned a review. The parent then re-checked every finding below against the merged source and the full diff.

Context: okTurtles/libcheloniajs#125, "fix: recover lost pubsub subscriptions after sleep and dead sockets" (fixes #121, CL-1 to CL-10). This is the third round. The two reviews of c031b6b were already addressed in the 14 commits after it. Their recorded decisions are respected: replacements over a request that is never answered, the harness in the ESM build, the typeof in timerDelay, and the items listed under "Not in this plan". On Node 26.13.0, npm test passes (603/603). Finding 1 was reproduced with a short script against src/chelonia.ts.


1. 🟡 An undefined field in connectionOptions now overrides the pubsub default, so reconnectOnDisconnection: undefined turns reconnection off

  • Addressed
  • Dismissed

Commit 3c69ca4 added this to chelonia/configure (src/chelonia.ts:537-541):

if (config.connectionOptions && this.config.connectionOptions) {
  Object.assign(this.config.connectionOptions, config.connectionOptions)
}

Object.assign copies own keys whose value is undefined. Before this commit, merge deep-cloned connectionOptions through JSON, which drops such keys, so an undefined field meant "leave alone". That is also what docs/configure.md promises ("Fields you omit are left alone"), and what chelonia/configure does on purpose for journal (src/chelonia.ts:518-523). Now the undefined is stored. chelonia/connect spreads it into the client options (src/chelonia.ts:1140-1141), and createClient lets it override the default (options: { ...defaultOptions, ...options }, src/pubsub/index.ts:556).

Verified: configure({ connectionOptions: { reconnectOnDisconnection: undefined, pingTimeout: undefined } }) followed by chelonia/connect gives a client whose options.reconnectOnDisconnection and options.pingTimeout are both undefined. The consequences:

  • reconnectOnDisconnection: undefined. close never schedules a reconnection (src/pubsub/index.ts:709), and runWithRetry takes the "Giving up" branch instead of replacing the connection (:484). After the first disconnection the client stays offline without saying so, which is exactly what this PR is meant to prevent.
  • pingTimeout or timeout set to undefined. This turns off the ping timer, the wall-clock watchdog and the connection timeout (timerDelay(undefined)).
  • opRetryInterval: undefined. Every retry delay becomes NaN, so retries fire at once.
  • maxOpRetries: undefined. attemptNo++ > undefined is never true, so the connection is never replaced.

Configs built with spreads or conditionals ({ timeout: cfg.timeout }) produce these keys easily. To keep the Infinity fix and skip undefined:

// ...and turns `Infinity` into `null`: `maxRetries: Infinity` would then
// stop reconnecting after one failed attempt. An `undefined` field still
// means "leave alone", as it did with `merge`
if (config.connectionOptions && this.config.connectionOptions) {
  for (const [key, value] of Object.entries(config.connectionOptions)) {
    if (value !== undefined) {
      (this.config.connectionOptions as Record<string, unknown>)[key] = value
    }
  }
}

A test belongs next to the maxRetries: Infinity one in src/chelonia-pubsub-reconcile.test.ts: configure connectionOptions: { reconnectOnDisconnection: undefined }, call server.killClients(), and check that the client still reconnects.

2. 🟡 After about 1016 reconnection attempts or replacements in a row, the reconnection delay becomes NaN, so the client reconnects in a tight loop

  • Addressed
  • Dismissed

getNextRandomDelay (src/pubsub/index.ts:1193-1207):

const attempts = Math.max(client.failedConnectionAttempts, client.unansweredReplacements)
const minDelay = minReconnectionDelay * reconnectionDelayGrowFactor ** attempts
const maxDelay = minDelay * reconnectionDelayGrowFactor
...
  Math.round(minDelay + (0, Math.random)() * (maxDelay - minDelay))

With the defaults (500 ms, factor 2), minDelay overflows to Infinity at attempts = 1016. Then Infinity - Infinity is NaN, and Math.min(cap, NaN) is NaN. Checked with node: the result is 60000 up to 1015 and NaN from 1016 on. A timer with a NaN delay fires at once: after 1 ms in Node, 0 ms in browsers. From then on, every reconnection happens immediately, and PUBSUB_RECONNECTION_SCHEDULED reports delay: NaN.

Neither counter has an upper bound:

  • failedConnectionAttempts. It is only bounded by maxRetries, and Chelonia sets that to Infinity (src/chelonia.ts:341). A relay that is unreachable while navigator.onLine stays true (a server outage, a captive portal) reaches 1016 failed attempts after about 17 h at the 60 s cap. The client then hammers the server. This part predates the PR.
  • unansweredReplacements. This counter is new (src/pubsub/index.ts:497). It never resets while the request stays unanswered. In the documented relay-bug loop ("every 80 to 90 s once the delay reaches maxReconnectionDelay", docs/debugging.md:140-142), it reaches 1016 after about 24 h. The back-off this PR added then disappears, and the client replaces the connection every 20 to 30 s.

The PR already edits this function. Capping minDelay keeps the delays exactly as they are now (once minDelay reaches the cap, the result is the cap anyway) and removes the overflow:

const attempts = Math.max(client.failedConnectionAttempts, client.unansweredReplacements)
// Capped: both counters are unbounded (`maxRetries: Infinity`), and
// `grow ** attempts` overflows to Infinity, which makes the delay NaN
const minDelay = Math.min(
  maxReconnectionDelay,
  minReconnectionDelay * reconnectionDelayGrowFactor ** attempts
)

src/pubsub/index.test.ts already tests the reconnection delay. Two more checks fit there: failedConnectionAttempts = 2000 and unansweredReplacements = 2000 must each give maxReconnectionDelay.

3. 🟡 A lost subscription restored by the periodic reconcile (or by a publish) doesn't reload KV slots, so KV updates sent during the gap stay missing

  • Addressed
  • Dismissed

When the periodic reconcile (src/chelonia.ts:1331-1339, :1368-1370) restores a contract that the pubsub client lost, these steps run:

  1. chelonia/pubsub/update → sub(), and the relay answers OK.
  2. The subscription-succeeded handler force-syncs the contract and then calls chelonia/kv/_onContractResynced (src/chelonia.ts:1156-1157).
  3. _onContractResynced returns at once: if (!this.kvReconnectRefresh.has(contractID)) return (src/kv.ts:2016).
  4. Only chelonia/kv/_onReconnect adds contracts to kvReconnectRefresh (src/kv.ts:2003-2013), and only on a real reconnection.

So the forced sync recovers the contract events that were missed, but not the KV values. Slots with refreshOnReconnect: true (the default) are not loaded again. Pushes from other devices during the gap stay stale until the next reconnection or reload. In Group Income these are read markers and lastLoggedIn, the values behind the original kv/set conflict report. The same gap exists for the other new restore paths that don't go through a reconnection:

  • a SUB that the relay rejected (PUBSUB_SUBSCRIPTION_FAILED) and the periodic reconcile restores;
  • the re-subscribe in ensurePublishedEventReceived (src/internals.ts:331-332).

The fix is to mark the contracts that were lost before subscribing again, so that the sync after their new subscription reloads their slots:

const reconcilePubsub = (trigger: string) => {
  const { missing } = sbp('chelonia/pubsub/status') as ChelPubSubStatus
  if (missing.length) {
    console.info(`[chelonia] pubsub reconcile (${trigger}): subscribing again to ${missing.length} contract(s) missing from the pubsub client`, missing)
    // KV pushes were missed too: reload the slots that a reconnection would
    // reload, after the sync that follows the new subscription
    for (const contractID of missing) {
      const slots = this.kvSlotsByContractID.get(contractID)
      if (slots && [...slots.values()].some((slot) => slot.refreshOnReconnect)) {
        this.kvReconnectRefresh.add(contractID)
      }
    }
  }
  sbp('chelonia/pubsub/update')
}

After a reconnection, _onReconnect already marks every contract, so this only adds the periodic case. The publish path can mark its contract the same way before it calls chelonia/pubsub/update.

4. 🟡 The back-off for replaced connections resets only on an OK, so an ERROR answer (or coming back online) leaves later reconnections slow

  • Addressed
  • Dismissed

unansweredReplacements goes back to 0 only in noteAnsweredRequests (src/pubsub/index.ts:327-334). That function is called only from the OK handlers (:954 for SUB, :979 for KV_FILTER). The ERROR handler removes the pending entry and doesn't call it (:905-910, :924-927):

case REQUEST_TYPE.SUB: {
  console.warn(`[pubsub] Could not subscribe to ${channelID}: ${reason}`)
  if (client.pendingOperations.tDelete(REQUEST_TYPE.SUB, channelID)) {
    sbp('okTurtles.events/emit', PUBSUB_SUBSCRIPTION_FAILED, client, { channelID, reason })
  }
  break
}

Here is how the counter stays high:

  1. An overloaded relay causes a few replacements in a row, so unansweredReplacements = n.
  2. On the connection that finally works, the last pending SUB is answered with an ERROR, for example a contract the relay refuses. Nothing resets the counter.
  3. Each periodic reconcile then sends that SUB again and gets an ERROR again. Nothing resets the counter.

Every ordinary reconnection after that (server restart, network blip) waits 500 * 2^n ms because of the Math.max at :1193, up to 60 s instead of 0.5 to 1 s. This lasts until some unrelated SUB happens to get an OK while nothing else is pending. The same happens if the unanswered request was an UNSUB on a client with no other subscription: the new connection has nothing to answer.

offline/online reset failedConnectionAttempts "so that we'll start a new reconnection loop when we are back online" (:774-776, :802), but they don't reset this counter. A changed network is a new situation for this counter too.

An ERROR is still an answer, so it proves the connection works:

case REQUEST_TYPE.SUB: {
  console.warn(`[pubsub] Could not subscribe to ${channelID}: ${reason}`)
  if (client.pendingOperations.tDelete(REQUEST_TYPE.SUB, channelID)) {
    sbp('okTurtles.events/emit', PUBSUB_SUBSCRIPTION_FAILED, client, { channelID, reason })
    // A rejection is an answer too
    noteAnsweredRequests(client)
  }
  break
}
// ...
case REQUEST_TYPE.KV_FILTER: {
  console.warn(`[pubsub] Could not set KV filter for ${channelID}: ${reason}`)
  if (client.pendingOperations.tDelete(REQUEST_TYPE.KV_FILTER, channelID)) {
    noteAnsweredRequests(client)
  }
  break
}
// online
if (!client.socket && client.shouldReconnect) {
  client.failedConnectionAttempts = 0
  client.unansweredReplacements = 0
  // ...

Other options are to call noteAnsweredRequests(client) at the end of open, which resets only when nothing is outstanding after the re-send, or to reset on any message other than a PING.

5. ⚪ The "is the contract still wanted?" checks run outside the contract queue, so a release that is still queued can be undone

  • Addressed
  • Dismissed

Both new sync paths check this.subscriptionSet synchronously, then call chelonia/private/in/sync, which only queues syncContract:

  • src/internals.ts:322 and :328 (receipt check)
  • src/internals.ts:958-964 (409 path)

chelonia/contract/release also only queues the removal (chelonia/contract/remove, src/chelonia.ts:1747). When the contract queue is busy, for example handling the conflicting event that arrived over pubsub, this order is possible:

  1. The app calls release(). The removal is queued, and subscriptionSet still has the contract.
  2. The check runs, either after the 0 to 1.5 s back-off of a 409 or when the receipt timer fires. It passes and queues syncContract, after the removal.
  3. The removal deletes the state and runs subscriptionSet.delete (src/internals.ts:747).
  4. syncContract finds no state, pushes the contract to pending (src/internals.ts:2097-2104) and syncs from height 0. Handling those events subscribes Chelonia and pubsub to it again.

The contract comes back with no references. In the 409 path, the next loop iteration finds state again and publishes to the released contract. That is the case c4b0550 fixed. The tests (src/chelonia-publish-receipt.test.ts:279-300) only cover an idle queue, where the removal finishes before the check runs. The flaw is not new (main synced with no check at all), but the receipt check adds a second trigger.

Related, during chelonia/reset. The receipt timer is never cancelled. Its guard (instance !== this._instance) only changes at the very end of the reset (src/chelonia.ts:785), after await postCleanupFn?.() (:781). If the timer fires during that await, the sync it starts uses the new AbortController (:778), so the reset's abort doesn't stop it, and it outlives the reset.

Run the checks inside the queue and call syncContract from there. src/internals.ts already does that for other callers:

// Force-syncs `contractID` if Chelonia still wants it and hasn't reached
// `height`. Checked in the contract's queue, i.e. after a queued removal
// (syncing a released contract would subscribe to it again)
const syncIfBehind = function (
  this: CheloniaContext, contractID: string, height: number, beforeSync?: () => void
): Promise<void> {
  return sbp('chelonia/private/queueEvent', contractID, async () => {
    if (!this.subscriptionSet.has(contractID)) return
    const localHeight = sbp(this.config.stateSelector).contracts[contractID]?.height
    if (localHeight == null || localHeight >= height) return
    beforeSync?.()
    await sbp('chelonia/private/in/syncContract', contractID, { force: true })
  })
}

For the reset window, take const { signal } = this.abortController when the timer is armed, and return early from check if signal.aborted. chelonia/kv/set (src/chelonia.ts:3196-3204) has the same pattern and could use the same helper.

6. ⚪ The first connection of a new client is logged as "reconnected" and as lost subscriptions

  • Addressed
  • Dismissed

chelonia/connect doesn't subscribe the new client to this.subscriptionSet. The reconcile after the first open does (src/chelonia.ts:1340-1351). So every chelonia/connect made while Chelonia already has contracts logs every contract at info level as pubsub reconcile (reconnected): subscribing again to N contract(s) missing from the pubsub client. This happens on a second chelonia/connect, or on a connect after the state was loaded. docs/debugging.md:164 describes that line as "Chelonia found subscriptions that the pubsub client had lost", so it sends anyone diagnosing a problem after a loss that never happened.

client.isNew is still true while open emits PUBSUB_RECONNECTION_SUCCEEDED (src/pubsub/index.ts:819, :823), and kvReconnectListener already relies on that:

this.pubsubReconcileListener = (client: PubSubClient) => {
  if (client !== this.pubsub) return
  const trigger = client.isNew ? 'connected' : 'reconnected'
  setTimeout(() => {
    if (client === this.pubsub && client.socket?.readyState === WebSocket.OPEN) {
      reconcilePubsub(trigger)
    }
  }, 0)
}

Optionally, skip the "missing" log line for connected. docs/debugging.md:164 and docs/configure.md:83 would list the new label.

7. ⚪ The 409 sync in kv/set doesn't subscribe the pubsub client again, unlike publishing

  • Addressed
  • Dismissed

The comment at src/chelonia.ts:3190-3195 gives a lost subscription as the reason for the sync. After the sync (:3203-3209), though, nothing asks the pubsub client to subscribe again. The contract stays unsubscribed until the next reconcile, which is up to 60 s away, or never with pubsubReconcileInterval: 0. The publish path does this right away (src/internals.ts:331-332):

              syncedAfterConflict = true
              try {
                await sbp('chelonia/private/in/sync', contractID, { force: true })
              } catch (e) {
                console.warn(/* ... */)
              }
              // Re-subscribe to the contract if the pubsub client lost it (as
              // publishing does); a no-op if it is subscribed or subscribing
              if (this.pubsub) sbp('chelonia/pubsub/update')

The tests that count /latestHEADinfo requests right after kvSet (src/chelonia-kv-set-sync.test.ts) may then also see the sync that the new SUB triggers.

8. ⚪ chelonia/config still turns maxRetries: Infinity into null, so passing its result back to chelonia/configure stops reconnection

  • Addressed
  • Dismissed

3c69ca4 fixed Infinity when it is passed directly to chelonia/configure. chelonia/config still returns cloneDeep(this.config) (src/chelonia.ts:473-479), so the default connectionOptions.maxRetries: Infinity comes back as null, although the comment below says the snapshot "round-trips correctly through chelonia/configure".

sbp('chelonia/configure', sbp('chelonia/config')) therefore stores maxRetries: null. Then failedConnectionAttempts > null is true after the first failure (src/pubsub/index.ts:710), and the client destroys itself. This bug predates the PR, but it is the failure 3c69ca4 set out to remove:

const out = {
  ...cloneDeep(this.config),
  fetch: this.config.fetch,
  reactiveSet: this.config.reactiveSet,
  reactiveDel: this.config.reactiveDel,
  // `cloneDeep` turns `Infinity` (e.g., `maxRetries`) into `null`
  connectionOptions: { ...this.config.connectionOptions }
}

9. ⚪ Some tests can't catch the regression they are named after

  • Addressed

  • Dismissed

  • The "deleted" 409 test (src/chelonia-publish-receipt.test.ts:292-300). Since eb60c96 wraps the sync in a try/catch, removing c4b0550's guards no longer fails this test:

    1. The sync runs and throws ChelErrorResourceGone.
    2. The error is logged as a warning.
    3. The next attempt stops.

    sent === undefined and [409] still hold, and syncsOf can't see the sync, because syncContract throws before it fetches. Assert directly that no sync was attempted, e.g. with an sbp('sbp/filters/selector/add', 'chelonia/private/in/sync', …) filter that records the IDs.

  • The pubsubReconcileInterval: 0 test (src/chelonia-pubsub-reconcile.test.ts:305-313). It waits 250 ms, so treating 0 as unset (falling back to 60000) still passes. The receipt test guards against exactly this for publishReceiptTimeout: 0. Spy on setInterval and assert that no 60000 ms interval is armed:

    const timers = t.mock.method(globalThis, 'setInterval')
    await connect(interval)
    timers.mock.restore()
    assert.ok(!timers.mock.calls.some(({ arguments: args }) => args[1] === 60000))
  • "replaces the periodic reconcile on chelonia/connect and stops it once the client is destroyed" (:339-353). Removing clearInterval (src/chelonia.ts:1353) or the !client.shouldReconnect exit (:1361) doesn't fail it: the readyState === OPEN check (:1368) hides both. The spy test below it (:355-383) is the one that covers these. Remove this test, or merge its title into the spy test.

  • publish().conflicts (src/chelonia-publish-receipt.test.ts:82) counts the text of the warning at src/internals.ts:946. If that text is reworded, assert.equal(published.conflicts, 0) (:160) passes whatever happens. postsOf(from) already gives the statuses of the POST /event requests: assert [409, 200] and [200] instead, which also makes the requests counter unnecessary.

  • "reconciling again" (src/chelonia-pubsub-reconcile.test.ts:336). The reconcile that runs on every reconnection already makes updates > 0 true, so a periodic timer that never resumes goes unnoticed. Reset updates after the reconnection and wait for one more tick.

  • "receives ENTRY notifications for subscribed channels only" (src/pubsub/recovery.test.ts:139-149). The fake server, not the client, filters 'b' out (subscribersOf). The "only" half tests the harness.

10. ⚪ Docs, comments and log text that don't match the code

  • Addressed

  • Dismissed

  • docs/api.md:304. The PUBSUB_ERROR payload is documented as (client, message). It is a string when a handler throws (src/pubsub/index.ts:596), but { message } for a malformed or binary message (:745-747, :755-757). Suggested: `(client, error)` with "error is the message string when a handler threw, or { message } when the relay sent a malformed message (the client is then destroyed)".

  • docs/contracts.md:651 (maxAttempts). It says Chelonia "then syncs the contract". The code only syncs for a contract Chelonia is subscribed to, with local state, and not for OP_CONTRACT (src/internals.ts:958-960).

  • docs/contracts.md:721-722. "Either way … onprocessed fires after that sync" doesn't hold with publishReceiptTimeout: 0, because no timer is armed (src/internals.ts:346-347).

  • The first state event. src/pubsub/index.ts:263-264 and docs/api.md:297 say the first event is closed -> connecting. If the client is created while offline, the initial state is offline (:607, computeState), so the first event is offline -> connecting. With manual: true, createClient emits nothing.

  • A wrong default in the JSDoc. src/pubsub/index.ts:539 says {number?} timeout=5_000, but the default is 60000 (:249). This predates the PR, but it is in the block the PR edits.

  • A log line. src/pubsub/index.ts:959 logs Received unexpected sub for X (not requested). It also shows up when the SUB was requested and unsub() cancelled it before the ACK. (not pending) would be accurate, and docs/debugging.md already says "(or no longer wants)".

  • A comment. src/internals.ts:961-962 says "the next 409 syncs again, as in kv/set". kv/set syncs at most once per call (src/chelonia.ts:3198-3202). Suggested: "(as in kv/set); unlike kv/set, which syncs once per call, the next 409 syncs again".

  • Grammar. docs/debugging.md:273 says "a state other than 'open' or a long-pending SUB point at the connection itself". With "or", the verb agrees with the nearer subject, so it should be "points".

  • A test comment. src/pubsub/recovery.test.ts:413 says "The reason has 92 characters, but 162 bytes". `no response to sub for ${'ñ'.repeat(70)}` is 93 characters and 163 bytes (checked with node). The test is still valid.

11. ⚪ AGENTS.md: a missing module and conflicting advice on test helpers

  • Addressed

  • Dismissed

  • A missing module. The code organization tree (AGENTS.md:63-101) now lists the new pubsub test files, but not the new non-test module src/timer-utils.ts. chelonia.ts, internals.ts and pubsub/index.ts all import it.

  • Conflicting advice. AGENTS.md:351-352 says "Shared test helpers go there too" (in src/pubsub/test-harness.ts). But src/test-utils.ts presents itself as the home of "Shared test helpers" (waitMicrotasks).

  • An incomplete note. AGENTS.md:46 names only src/pubsub/test-harness.ts among the excluded helpers, although tsconfig.cjs.json:22 excludes src/test-utils.ts too.

**Note**: CJS build excludes test files and the test helpers `src/test-utils.ts` and `src/pubsub/test-harness.ts` via `tsconfig.cjs.json`.
├── timer-utils.ts        # timerDelay()/unrefTimer(): disableable, clamped timers that don't keep Node alive
... Shared pubsub and relay test helpers go there too; generic ones (e.g. `waitMicrotasks`)
live in `src/test-utils.ts`.

12. ⚪ Test harness: places where the fake differs from the real WebSocket and fetch, and copied code

  • Addressed
  • Dismissed

No current test depends on any of these, but each is an easy trap for the next test:

  • relay.fetch handles aborts differently from fetch (src/pubsub/test-harness.ts:703-714):
    • an abort after the request started is ignored, although onRequest can hold the answer (time-sync.test.ts, chelonia-publish-receipt.test.ts:264-268);
    • it rejects with a generic AbortError instead of signal.reason;
    • it records requests that were already aborted, so syncsOf() counts them.
  • close() while CONNECTING fires error and close synchronously (:284-288). A real socket goes to CLOSING and fires them in a later task.
  • slow() applies the latency per message (later(), :134). Lowering it while messages are in flight lets later messages overtake earlier ones on the same connection.
  • RequestOverride can't produce an OK (:76). So the KV filter de-dup test builds its own reply with a literal 'ok' and skips deliver()'s latency and checks (src/pubsub/recovery.test.ts:535-547). An { ok: object } override would remove the copy.
  • createCloseEvent exists twice, in src/pubsub/index.ts:391-396 and src/pubsub/test-harness.ts:80-84. A small internal module (e.g. src/pubsub/close-event.ts) that both import would avoid the copy without adding it to the public pubsub exports.

13. ⚪ Small code nits

  • Addressed

  • Dismissed

  • The error handler still clears the ping timer (src/pubsub/index.ts:725-731). PUBSUB_ERROR reaches this handler (:1329-1341) whenever any handler throws, for example a failing ENTRY handler. So an exception while handling a message cancels the timer that message just re-armed, on a socket that stays open. The watchdog still catches a dead socket within about 5 s, and close/abandonSocket clear all timers anyway, so the line can go.

  • Unused default parameters. moveCheckpoint (wallNow = Date.now(), monotonicNow = performance.now()) (src/time-sync.ts:40): every caller passes both arguments, and calling it with none would read the two clocks at slightly different moments. Make both parameters required.

  • An untracked retry timer. setTimeout(() => resync(0), 5000) (src/time-sync.ts:141) is neither tracked nor unref'd. stopClockSync doesn't clear it, so after the last time-sync test the Node process stays alive for up to 5 s. unrefTimer(...) it, as runWithRetry does (src/pubsub/index.ts:516).

  • A local copy of a harness helper. src/chelonia-pubsub-reconcile.test.ts:84, :156-158, :232, :298, :335 and src/chelonia-publish-receipt.test.ts:130 rebuild the harness's subscribed(client, ids) inline.

  • A hand-patched timer spy. The spy test (src/chelonia-pubsub-reconcile.test.ts:355-383) replaces setInterval/clearInterval by hand with a try/finally. t.mock.method(globalThis, …), as used in chelonia-publish-receipt.test.ts, restores itself, and waitFor can replace its fixed sleep(3 * INTERVAL).

@corrideat
corrideat marked this pull request as ready for review November 8, 2026 20:27

This branch was successfully deployed

1 active deployment
CI — a3a875a7 Deployed Nov 8, 2026 by corrideat via build #316
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.

Lost pubsub / WebSocket connection

2 participants