SQC-871 Implements socket serialization for transferring socket over RPC#6863
SQC-871 Implements socket serialization for transferring socket over RPC#6863Ltadrian wants to merge 2 commits into
Conversation
Ltadrian
commented
Jul 6, 2026
- Adds TransferredSocketStream class that handles implementing underlying AsyncIoStream IO operations for deserializing socket
- Socket deserialization unwraps raw kj streams and builds non-deferred stream to pass into TransferredSocketStream
- Adds js-rpc-socket-test tests
- Add outbound interceptor test to showcase full end to end use case of loopback binding
RPC - Adds TransferredSocketStream class that handles implementing underlying AsyncIoStream IO operations - Socket serialization/deserialzation unwraps raw kj streams and builds non-deferred sockets - Fix and assert existing test that returns socket over RPC and can read bytes - Add outbound interceptor test to showcase returning socket over RPC
| // Serialize the readable and writable streams as separate externals | ||
| readable->serialize(js, serializer); | ||
|
|
||
| writable->serialize(js, serializer); |
There was a problem hiding this comment.
Be aware the the JsReadableStream and JsWritableStream work necessarily will impact this. JsReadableStream has already landed. readable is no longer a jsg::Ref<ReadableStream> but is wrapped by a JsReadableStream. The serialization story is still a work in progress.
There was a problem hiding this comment.
Thank you I wasn't aware of JsReadableStream before working on this. I've caught up on the implementation in the internal repo workerd and it looks like it hopefully be too much of a lift to refactor some of these parts. I'll keep my eye out for when this lands in public repo 😊 Do you think it would be a blocker to support both JsReadableStream/JsWritableStream before landing this socket serialization?
| // JsRpcCustomEvent is canceled), so their pumpTo() must not be deferred past the context's | ||
| // lifetime. | ||
| sysStreams.readable = newNoDeferredProxyReadableStream(kj::mv(sysStreams.readable), ioContext); | ||
| auto readable = js.alloc<ReadableStream>(ioContext, kj::mv(sysStreams.readable)); |
There was a problem hiding this comment.
With JsReadableStream already landed, this would become JsReadableStream::create(...)
There was a problem hiding this comment.
(JsReadableStream is not yet in the public repo... should arrive there soon)
| // No opened-gate on the writable: the connection is already established (opened resolves below), | ||
| // and passing a gate is unnecessary here. | ||
| auto writable = js.alloc<WritableStream>(ioContext, kj::mv(sysStreams.writable), | ||
| ioContext.getMetrics().tryCreateWritableByteStreamObserver()); |
There was a problem hiding this comment.
this would change once JsWritableStream lands
|
A few questions...
|
dom96
left a comment
There was a problem hiding this comment.
Yeah, like James mentioned, I'd repeat that currently as things stand there is a need for more synchronisation with the various socket states (closed/open). But perhaps that's planned since this is still a draft :)
In general though I think this is a good start. I left a few comments about various things I'd improve as well, but nothing major.
|
Thank you for the initial review, I have spent some extra time addressing the feedback and have added a second commit to this merge request to handle the various states I initially put on TODO: I'll re-review my second commit today again but this seems to be functional now |
(opened/closed/allowHalfOpen/secureTransport) Carry the origin socket's runtime state over JS RPC and reconstruct equivalent behavior on the receiving side. - Wire format: add secureTransport (new enum), isDefaultFetchPort, localAddress, and allowHalfOpen to External.socket; fix the socket external doc comment. - opened: track settled state (OpenedState enum); serialize() throws DataCloneError unless OPENED; deserialize() resolves it from the transferred SocketInfo and marks the socket OPENED. - allowHalfOpen: carried across transfer; when false, re-establish the read-EOF -> write-side auto-close on the receiving side. - closed: extract shared disconnect wiring (used by setupSocket) and resolve `closed` on disconnect for transferred sockets. - deserialize scope: the EOF and closed wiring attach jsg `.then()` continuations (illegal under the no-JS deserialize scope), so defer them to microtasks; kj-side signals are latched synchronously so nothing is missed. - Tests: allowHalfOpen honored across transfer, unopened transfer throws, `closed` resolves on close(); js-rpc-test awaits `opened` before returning.
b7082f5 to
a44dee4
Compare
Merging this PR will improve performance by 9.77%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | bodyWithHeaders[Response] |
34.2 µs | 31.2 µs | +9.77% |
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing Ltadrian:dlapid/jsrpc_socket (a44dee4) with main (a77b7aa)
Footnotes
-
129 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
| // ExplicitEndOutputStream needs an async end() to signal a clean EOF over the RPC ByteStream, | ||
| // but shutdownWrite() is synchronous. Kick off end() as an IoContext task so the clean-EOF RPC | ||
| // completes; dropping without end() would look like an abort to the peer. If there's no active | ||
| // IoContext (e.g. during teardown), just drop, which the ByteStream treats as an abort. |
There was a problem hiding this comment.
I would move the IoContext:current() check to before the destructive kj::mv that consumes the output
| output = kj::none; | ||
| if (IoContext::hasCurrent()) { | ||
| IoContext::current().addTask( | ||
| owned->end().attach(kj::mv(owned)).catch_([](kj::Exception&&) {})); |
There was a problem hiding this comment.
Not really a fan of swallowing the rejection case here. Should this at least log periodically?
| // whenResolved() creates a fresh branch off the `opened` promise without consuming | ||
| // `openedPromiseCopy` (which `close()` relies on). The branch settles in lockstep with `opened`. | ||
| openedPromiseCopy.whenResolved(js) | ||
| .then(js, [this, self = JSG_THIS](jsg::Lock&) { openedState = OpenedState::OPENED; }, |
There was a problem hiding this comment.
Just capture self = JSG_THIS and use self->openedState = ...
|
|
||
| void Socket::trackOpenedState(jsg::Lock& js) { | ||
| // whenResolved() creates a fresh branch off the `opened` promise without consuming | ||
| // `openedPromiseCopy` (which `close()` relies on). The branch settles in lockstep with `opened`. |
There was a problem hiding this comment.
"lockstep" is not entirely acurate. Since continuations run in registration order, if something else is following the openedPromiseCopy promise and is attached first, then that will run before the openedState = ... is set. We just need to verify there are no re-entrancy issues.
| .then(js, [this, self = JSG_THIS](jsg::Lock&) { openedState = OpenedState::OPENED; }, | ||
| [this, self = JSG_THIS](jsg::Lock&, jsg::Value&&) { | ||
| openedState = OpenedState::FAILED; | ||
| }).markAsHandled(js); |
There was a problem hiding this comment.
You're attaching a catch handler and neither the success or failure cases can reject. The markAsHandled(js) here is likely unnecessary/redundant.
| void Socket::wireClosedToDisconnect(jsg::Lock& js, kj::Promise<bool> disconnected) { | ||
| auto& context = IoContext::current(); | ||
| context.awaitIo(js, kj::mv(disconnected)) | ||
| .then(js, [this, self = JSG_THIS](jsg::Lock& js, bool canceled) { |
There was a problem hiding this comment.
Only capture self = JSG_THIS, not this
| break; | ||
| } | ||
|
|
||
| // Serialize the socket metadata, referencing the stream externals |
There was a problem hiding this comment.
| // Serialize the socket metadata, referencing the stream externals | |
| // Serialize the socket metadata, referencing the stream externals | |
| // The call to write is synchronous, so capturing this is safe. |
| js.v8Isolate->EnqueueMicrotask(js.wrapSimpleFunction(js.v8Context(), | ||
| JSG_VISITABLE_LAMBDA((self = socket.addRef(), disconnected = kj::mv(disconnectedOwn)), (self), | ||
| (jsg::Lock & js, const v8::FunctionCallbackInfo<v8::Value>& args) mutable { | ||
| self.get()->wireClosedToDisconnect(js, kj::mv(*disconnected)); |
There was a problem hiding this comment.
Worth noting that if these throw JS exceptions, the error will be swallowed entirely by the EnqueueMicrotask
There was a problem hiding this comment.
That can happen, for instance, if the microtask queue happens to be drained outside of an active IoContext, for instance. Using EnqueueMicrotask might not be the safest here as opposed to creating a resolved promise and attaching a continuation to it (which utilizes the cross-request promise resolve mechanism)
| // the peer disconnects, or rejects on error. | ||
| kj::Promise<void> handleDisconnected( | ||
| kj::AsyncIoStream& connection, kj::PromiseFulfiller<bool>& fulfiller) { | ||
| try { |
| auto deferredCancel = kj::defer([fulfiller = kj::mv(paf.fulfiller)]() mutable { | ||
| // The watch task was canceled without fulfilling the fulfiller (the Socket was GC'd); silently | ||
| // fulfill with true so wireClosedToDisconnect() knows not to resolve `closed`. | ||
| fulfiller->fulfill(true); |
There was a problem hiding this comment.
Should likely check to see if the fulfiller is still waiting first.
| // fulfill with true so wireClosedToDisconnect() knows not to resolve `closed`. | ||
| fulfiller->fulfill(true); | ||
| }); | ||
| auto watchTask = handleDisconnected(connection, fulfiller).attach(kj::mv(deferredCancel)); |
There was a problem hiding this comment.
Is this the only callsite for handleDisconnected? If it is, it's likely better to kj::mv the deferredCancel into the handleDisconnected(...) call rather than passing a bare reference and attaching the deferral.
|
|
||
| kj::Own<ReadableStreamSource> newNoDeferredProxyReadableStream( | ||
| kj::Own<ReadableStreamSource> inner, IoContext& context) { | ||
| return kj::heap<NoDeferredProxyReadableStream>(kj::mv(inner), context); |
There was a problem hiding this comment.
Calling convention typically would put IoContext& context as the first argument.