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 1 commit 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.
| // echo handler, so bytes we write must come back unchanged. This proves both socket.writable | ||
| // and socket.readable survive the RPC transfer. |
There was a problem hiding this comment.
What about other socket operations like close() and startTls?
There was a problem hiding this comment.
Also tests for allowHalfOpen would be good
| // NOTE: For an RPC-transferred stream this reliably fires only on hard RPC breakage (the | ||
| // underlying CapnpToKjStreamAdapter rejects). A clean peer-initiated close is instead surfaced | ||
| // via readable EOF, which setupSocket() turns into a close when allowHalfOpen is false. | ||
| if (output.get() == nullptr) return kj::NEVER_DONE; |
There was a problem hiding this comment.
Why would output be null here. Also should it be a kj::Maybe if it being null is a possibility?
| [this, remoteAddr = kj::str(remoteAddress)](rpc::JsValue::External::Builder builder) mutable { | ||
| auto socket = builder.initSocket(); | ||
| socket.setRemoteAddress(remoteAddr); | ||
| socket.setSecureTransport(getSecureTransport()); |
There was a problem hiding this comment.
Should this also include other metadata like the state of allowHalfOpen, opened, closed?
| // Read the externals in the same order Socket::serialize() wrote them: (1) socket metadata, | ||
| // (2) the readable stream, (3) the writable stream. The stream externals are consumed here | ||
| // directly (rather than via ReadableStream/WritableStream::deserialize) so that we recover the | ||
| // raw kj half-streams and can rebuild a real AsyncIoStream backing the Socket. |
There was a problem hiding this comment.
Nit: the order here probably doesn't matter, does it?
| auto remoteAddr = kj::str(socketData.getRemoteAddress()); | ||
| auto transport = kj::str(socketData.getSecureTransport()); | ||
|
|
||
| // (2) Readable side: recover the raw input stream from the pushed ByteStream (mirrors |
There was a problem hiding this comment.
Could ReadableStream::deserialize be reused here to avoid duplication? Same for WritableStream::deserialize below.
|
|
||
| // Parse the secure transport | ||
| SecureTransportKind secureTransport = SecureTransportKind::OFF; | ||
| if (transport == "on") { |
There was a problem hiding this comment.
Why is this a string? Can't we encode it as an enum?
| kj::Own<kj::AsyncIoStream> asyncIoStream = | ||
| kj::heap<TransferredSocketStream>(kj::mv(input), kj::mv(output)); | ||
| auto refcountedConnection = kj::refcountedWrapper(kj::mv(asyncIoStream)); | ||
| auto sysStreams = newSystemMultiStream(*refcountedConnection, ioContext); |
There was a problem hiding this comment.
This code and the code below shares a lot with setupSocket. Could you reuse that function to avoid as much duplication as possible?
| blob @15; | ||
| # A Blob, serialized as its MIME type followed by its raw bytes. | ||
|
|
||
| socket @16; |