Skip to content

SQC-871 Implements socket serialization for transferring socket over RPC#6863

Draft
Ltadrian wants to merge 1 commit into
cloudflare:mainfrom
Ltadrian:dlapid/jsrpc_socket
Draft

SQC-871 Implements socket serialization for transferring socket over RPC#6863
Ltadrian wants to merge 1 commit into
cloudflare:mainfrom
Ltadrian:dlapid/jsrpc_socket

Conversation

@Ltadrian

@Ltadrian Ltadrian commented Jul 6, 2026

Copy link
Copy Markdown
  • 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
@Ltadrian Ltadrian changed the title Implements socket serialization for transferring socket over RPC SQC-871 Implements socket serialization for transferring socket over RPC Jul 6, 2026
// Serialize the readable and writable streams as separate externals
readable->serialize(js, serializer);

writable->serialize(js, serializer);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With JsReadableStream already landed, this would become JsReadableStream::create(...)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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());

@jasnell jasnell Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would change once JsWritableStream lands

Comment thread src/workerd/api/streams/readable.h
@jasnell

jasnell commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

A few questions...

  1. How does socket upgrade (startTLS) work with a serialized socket?
  2. It looks like isDefaultFetchPort is not set?
  3. There doesn't seem to be any handling of opened state. A socket uses the openedPromise to signal when the socket is actually opened/established. From the looks of it here, it would be possible to serialize a still not-yet-open socket but the receiving side would appear to be opened? It's not clear.
  4. What if the socket is already being closed? Or is already closed?

@dom96 dom96 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +927 to +928
// echo handler, so bytes we write must come back unchanged. This proves both socket.writable
// and socket.readable survive the RPC transfer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about other socket operations like close() and startTls?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

@dom96 dom96 Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this also include other metadata like the state of allowHalfOpen, opened, closed?

Comment on lines +667 to +670
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc comment

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.

3 participants