Skip to content

fix(cloudflare-hub): keep relay pairings across a Durable Object eviction - #224

Merged
Mearman merged 4 commits into
mainfrom
fix/hub-pairings-survive-hibernation
Sep 20, 2026
Merged

Mearman merged 4 commits into
mainfrom
fix/hub-pairings-survive-hibernation

Conversation

@Mearman

@Mearman Mearman commented Sep 20, 2026

Copy link
Copy Markdown
Member

Fixes #223

A response relayed through mesh.exadev.io was lost whenever the pairing had been quiet for more than about ten seconds. Confirmed the hypothesis in the issue by reading the code: the hub is a hibernating Durable Object, the instance is evicted after a short idle period while its sockets stay open, and createRelayHub's pairings and connectionDevice maps are ordinary instance state. A woken instance saw a relay-data frame on a pairing it had never heard of and dropped it, silently, with neither client in a position to notice: no socket closed, so nothing told either side to re-establish anything.

Reproduced it before fixing it. test/hibernation.integration.test.ts builds a hub over fake sockets, pairs two clients, then builds a second hub over the same socket objects and their attachments, which is exactly the pair of things the runtime preserves across an eviction. Five of its six cases failed against the old behaviour, the first with client A receiving nothing at all when B answered.

Each connection's relay state now rides on its own socket as a hibernation attachment, and the hub reassembles itself from ctx.getWebSockets() the first time a woken instance is asked to do anything. I chose an attachment over Durable Object storage for lifetime reasons rather than capacity: the runtime drops an attachment when its socket closes, which is exactly when the state stops meaning anything, so there is nothing to sweep when a client goes away while no instance is running. It also reads synchronously, so the hub is whole before the frame that woke it is routed, and a socket is the only thing that survives an eviction with an identity, so a storage row would have needed an attachment anyway just to say which row belonged to which socket. The 16KiB limit is far beyond a device-id, a few adverts and a pairing list.

Adoption resolves the whole set of sockets at once rather than one at a time, because a pairing names its peer by device-id and that peer is a socket the new instance has yet to look at. A pairing whose peer is not among the survivors has genuinely gone and is dropped rather than half-restored. The legacy unaddressed-relay-data route travels in the same attachment, so a client that omits to-device still reaches the peer it did before.

wire-mesh-core gains exportConnection, restoreConnections and an optional onConnectionStateChanged. createRelayHub() keeps its existing no-argument call shape and nothing about the default behaviour changes, so the Node relay server, this repo's own tests and agent-comms' hub-helpers all carry on unchanged. The hub itself still knows nothing about sockets, attachments or Cloudflare: it reports that a connection's state changed and can render it as device-ids and adverts, and where that goes is the host's business.

The Durable Object class shrinks to an adapter. Everything it used to do lives in src/hibernating-hub.ts, which imports nothing from cloudflare:workers and so can be driven by an ordinary Node test against a fake socket, which is what made the eviction reproduction possible at all.

scripts/idle-relay-live-check.mjs covers the case live-check.mjs cannot, since that one relays the moment it has paired and so never leaves the hub idle long enough to be evicted. The new script pairs, sends a request, then goes quiet for thirty seconds before the response comes back. Both scripts pass against a local wrangler dev, but the script's own header says plainly that local workerd does not evict on production's schedule, so a local pass shows only that a long quiet period is survivable, not that an eviction is. That needs a run against the real deployment, which I will do once this is merged and deployed.

The stale parts of the hub README went with it: it still described the DO as keeping connections alive for its own lifetime, listed hibernation as deferred work, and pointed at a src/hub.ts that no longer exists.

…ing state

A relay hub holds its device registry and its pairings in memory, keyed by
Connection. A host whose instance can be torn down while its connections stay
open has no way to hand either to the successor instance, so a frame arriving
on a surviving connection finds no pairing and is dropped.

exportConnection renders one connection's state as device-ids and adverts
alone, restoreConnections resolves a batch of those back onto the connections
a successor still holds, and onConnectionStateChanged tells the host when a
connection's value has changed so it has something current to persist.
…tion

The instance is discarded after a short idle period while its sockets stay
open, so the hub's registry and pairings went with it. A woken instance then
saw a relay-data frame on a pairing it had never heard of and dropped it, and
neither client could tell: no socket closed, so nobody re-established
anything and the sender simply never got its answer. Anything answered within
a few seconds beat the eviction, anything slower did not, which is exactly
the case a relay matters most for.

Each connection's relay state now rides on its own socket as a hibernation
attachment, and the hub reassembles itself from ctx.getWebSockets() the first
time a woken instance is asked to do anything. The attachment dies with the
socket, so a client that goes away while no instance is running leaves
nothing to sweep, and it is read synchronously, so the hub is whole before
the frame that woke it is routed.

The socket handling moves out of the Durable Object class into
hibernating-hub.ts, which needs no cloudflare:workers import and can
therefore be driven by a test that evicts the hub for real: build a second
one over the same sockets and their attachments, which is precisely what the
runtime preserves.
live-check.mjs relays as soon as it has paired, so it never leaves the hub
quiet long enough to be evicted and cannot see the case that failed in
production. The new script pairs, sends one request, then says nothing for
well past the eviction threshold before the response goes back, and requires
both directions to still work afterwards.

Its own header states what a local run against wrangler dev does and does not
prove, since workerd there does not evict on production's schedule.
@Mearman
Mearman marked this pull request as ready for review September 20, 2026 01:07
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-20T01:15:56.744958Z 0b929f0 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman
Mearman merged commit 37b80ca into main Sep 20, 2026
7 checks passed
@Mearman
Mearman deleted the fix/hub-pairings-survive-hibernation branch September 20, 2026 01:16
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.59.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Relay response is lost when the pairing has been idle long enough for the hub Durable Object to be evicted

1 participant