Reuse relay connections across pairings - #252
Conversation
a6abc84 to
bfd5f96
Compare
73a3495 to
1da0e29
Compare
bfd5f96 to
8ed4285
Compare
1da0e29 to
05e36bb
Compare
8ed4285 to
c31bc3e
Compare
a67cc69 to
28f3eea
Compare
c31bc3e to
3a0979e
Compare
28f3eea to
10b2532
Compare
452c794 to
a43de21
Compare
a43de21 to
7f2025a
Compare
7f2025a to
c7170b8
Compare
| this.socket.setKeepAlive(keepAlive) | ||
| } | ||
|
|
||
| get reusable() { |
There was a problem hiding this comment.
Reviewing at the request of @marcus-pousette-hp...
The death signal is actually 4 async signals spread across different layers of abstraction. Scares me a little. The different destruction paths and layers of destruction state (RelayPool vs. socket vs. client) make it hard to reason about whether there might be a subtle race between the predicates. Feels like a violation of the isolation of complexity.
There was a problem hiding this comment.
Makes sense, yeap this could be fragile even though it is tested. I did a cleanup commit
that unifies this somewhat, and makes this a bit easier to overview
| await Promise.all([ | ||
| relaySocketsOpened, | ||
| relayStreamsPaired, | ||
| serverSocketsOpened, | ||
| ...clientSockets.map((socket) => once(socket, 'open')) | ||
| ]) |
There was a problem hiding this comment.
| await Promise.all([ | |
| relaySocketsOpened, | |
| relayStreamsPaired, | |
| serverSocketsOpened, | |
| ...clientSockets.map((socket) => once(socket, 'open')) | |
| ]) | |
| await Promise.all(clientSockets.map((socket) => once(socket, 'open')) |
These aren't necessary as since the client connection can't open until the connection is fully established (handshake etc). Timing only matters if we use the server sockets array immediately since it might not have run its callback yet. But we dont until after replies etc.
This line can also will go away if we opt to await the connections individually.
There was a problem hiding this comment.
The relaySocketsOpened & relayStreamsPaired are still not required here:
https://github.com/holepunchto/hyperdht/pull/252/changes#diff-77e8658bbd4f0803c631d07808c79139fb0bb8f533f28dd5268a9bc196613b13R622
We know they are true because the client socket's being open when its forced to relay means the sockets were open and the streams have paired.
There was a problem hiding this comment.
Yeah you are right here too. Fixed 5b4b5cf
| } | ||
| } | ||
|
|
||
| function getOnlyRelayPoolConnection(node) { |
There was a problem hiding this comment.
All helpers should be at the end of the file unless within a given test.
There was a problem hiding this comment.
Looks like while there are less helpers, there are still helpers added in the middle of the tests.
| secondPairing.release() | ||
| secondStream.destroy() | ||
|
|
||
| await waitFor(() => relay._pairing.size === 0) | ||
| await waitFor(() => clientNode._relayPool._entries.size === 0) |
There was a problem hiding this comment.
Why is this necessary? There are no asserts afterwards and this is done via destroying the nodes.
There was a problem hiding this comment.
In the earlier version those waits were implicit cleanup assertions after releasing the second pairing. That was outside this test’s main scope, so I removed that part and kept the test focused on the delayed-unpair race.
There was a problem hiding this comment.
So these aren't required still correct? I only see the relay._pairing.size === 0 removed at the moment.
There was a problem hiding this comment.
Yeah, hmm for some reason I only removed one part by mistake. Fixed in bafe3fd
| const [upgradeServerSocket, relayOnlyServerSocket] = await Promise.all([ | ||
| upgradeServerSocketOpened, | ||
| relayOnlyServerSocketOpened, | ||
| once(upgradeClientSocket, 'open'), | ||
| once(relayOnlyClientSocket, 'open') | ||
| ]) | ||
| await Promise.all([relaySocketsOpened, relayStreamsPaired]) |
There was a problem hiding this comment.
| const [upgradeServerSocket, relayOnlyServerSocket] = await Promise.all([ | |
| upgradeServerSocketOpened, | |
| relayOnlyServerSocketOpened, | |
| once(upgradeClientSocket, 'open'), | |
| once(relayOnlyClientSocket, 'open') | |
| ]) | |
| await Promise.all([relaySocketsOpened, relayStreamsPaired]) | |
| await Promise.all( | |
| once(upgradeClientSocket, 'open'), | |
| once(relayOnlyClientSocket, 'open') | |
| ]) |
Since upgradeServerSocket & relayOnlyServerSocket are the only sockets on their respective servers and aren't needed immediately, these can be set in their respective callbacks. The unused promises etc can be cleaned up then.
There was a problem hiding this comment.
Thanks the sockets are now defined in their callbacks, but another aspect of this suggested change was removing the relaySocketsOpened and the relayStreamsPaired as those are not required because the client socket's being open implies they were done per this comment:
#252 (comment)
There was a problem hiding this comment.
Makes things way simpler, thank you
| this._connections.set(keyString, connection) | ||
| } else { | ||
| connection.setKeepAlive(keepAlive) | ||
| } | ||
|
|
||
| return connection | ||
| } | ||
|
|
||
| _delete(connection) { | ||
| if (this._connections.get(connection.keyString) === connection) { | ||
| this._connections.delete(connection.keyString) | ||
| } |
There was a problem hiding this comment.
Just wanted to note: Technically a connection could be orphaned here if its not reusable when another connection for the same public key is made.
There was a problem hiding this comment.
You are right, good catch! Fixed e4c2908
| if (!this._closeState()) return | ||
|
|
||
| this._releasePairings(false) | ||
| const closed = this.socket.destroyed ? null : once(this.socket, 'close') |
There was a problem hiding this comment.
Events.once does weird stuff if error event is emitted. Just listen once for close only manually
There was a problem hiding this comment.
Good point! that was subtle and fragile.. Fixed 18376ef
e4c2908 to
18376ef
Compare
0798b81 to
7e37d94
Compare
Previously, each relayed app connection opened its own HyperDHT connection to the same relay. Multiple relayed connections through one relay therefore created duplicate relay transport sockets and extra keepalive/session overhead.
This adds an internal relay pool keyed by relay public key. Relay pairings now reuse one relay transport connection when possible, while each app connection still gets its own blind-relay pairing/raw stream.
Adds coverage for two simultaneous relayed app connections through the same relay. Previously this opened four relay transport sockets: client-to-relay and server-to-relay for each app connection. With reuse, the client and server each keep one relay transport socket, and the two app connections are represented as separate blind-relay pairings over those shared sockets.
Context
There are already two related reuse mechanisms, but neither owns blind-relay pairings:
ConnectionPoolreuses app-level HyperDHT connections keyed by remote public key. It returns an existing encrypted app stream and handles duplicate app connections._socketPool/reusableSocketreuses lower-level UDX socket routes.Relay reuse needs different lifecycle semantics. The shared object is the HyperDHT transport connection to the relay, but each app connection still needs its own blind-relay pairing/token/raw stream. When one app connection upgrades to direct or closes, we must release only that pairing, not destroy the shared relay transport if another pairing is still active.
This PR adds a small internal
RelayPoolfor that relay-specific ownership model.Notes
Unpairing delay
When a relayed app connection upgrades to direct, this PR releases only that relay pairing and keeps the shared relay transport open for any other active pairings.
The unpair is delayed because
remote-changedis only a local signal. It means this side moved its raw stream to the direct path, but it does not prove the peer has also finished switching direct. Sendingunpairimmediately can tear down the relay leg while the peer still needs it, which showed up as a timeout in the direct-upgrade regression test.The delay is a pragmatic, non-protocol-changing guard. A longer delay is safer for the upgrade race but keeps relay resources around longer after direct upgrade. A future protocol-level improvement could replace this with an explicit “both sides are direct” release signal before unpairing.
Race Coverage
This is the tricky part of this PR since we re-using relay connection naturally creates a large flake surface around lifecycle mangement.
This PR includes coverage for the relay-pool cases most likely to regress:
Disclaimer: PR was co-written with AI.