forked from storj/drpc
-
Notifications
You must be signed in to change notification settings - Fork 7
drpcquic: add QUIC transport for drpc using quic-go #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
cceddbd
drpc: add MultiplexedTransport interface and require Go 1.25 for QUIC…
eshwarsriramoju b3e58fe
drpcquic: add options, ALPN/TLS helper, and quic-go error mapping
eshwarsriramoju 437a1ee
drpcquic: add Transport/streamTransport (FIN+CancelRead Close) and te…
eshwarsriramoju 7661a49
drpcstream: add NewForReader + readLoop for multiplexed (one stream p…
eshwarsriramoju 8e21b9a
drpcmanager: add QUICManager for one-stream-per-transport multiplexed…
eshwarsriramoju 5f7bfe3
drpcquic: wire client (NewFromMultiplexed + concurrent Invoke), serve…
eshwarsriramoju 3ca415e
drpcquic: add end-to-end tests (streaming, concurrency, cancel, serve…
eshwarsriramoju 4261e4d
Tidy Go module dependencies
eshwarsriramoju 302640d
Add QUIC demo showing drpc echo server over quic-go
eshwarsriramoju b6f519c
Tidy Go module dependencies
eshwarsriramoju a88d6bc
drpcquic: depend on published quic-go v0.59.1 (drop local replace)
eshwarsriramoju 81c4709
drpcquic: add Server type with Serve(ctx, lis) and ListenPacket
eshwarsriramoju b635b4c
drpcquic: don't serialize stream accepts on invoke reads
eshwarsriramoju e658b06
drpcquic: map context deadline to codes.DeadlineExceeded
eshwarsriramoju 5620bd6
drpcquic: inject QUIC peer certs into served ctx; clean-shutdown retu…
eshwarsriramoju File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/ecdsa" | ||
| "crypto/elliptic" | ||
| "crypto/rand" | ||
| "crypto/tls" | ||
| "crypto/x509" | ||
| "crypto/x509/pkix" | ||
| "fmt" | ||
| "log" | ||
| "math/big" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "storj.io/drpc" | ||
| "storj.io/drpc/drpcconn" | ||
| "storj.io/drpc/drpcquic" | ||
| "storj.io/drpc/drpcserver" | ||
| ) | ||
|
|
||
| // trivial string message + encoding (stands in for protobuf) | ||
| type msg struct{ S string } | ||
| type enc struct{} | ||
|
|
||
| func (enc) Marshal(m drpc.Message) ([]byte, error) { return []byte(m.(*msg).S), nil } | ||
| func (enc) Unmarshal(b []byte, m drpc.Message) error { m.(*msg).S = string(b); return nil } | ||
|
|
||
| // a handler that echoes the request | ||
| type echo struct{} | ||
|
|
||
| func (echo) HandleRPC(stream drpc.Stream, rpc string) error { | ||
| in := new(msg) | ||
| if err := stream.MsgRecv(in, enc{}); err != nil { | ||
| return err | ||
| } | ||
| return stream.MsgSend(&msg{S: "echo:" + in.S}, enc{}) | ||
| } | ||
|
|
||
| func devTLS() (server, client *tls.Config) { | ||
| key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | ||
| tmpl := &x509.Certificate{ | ||
| SerialNumber: big.NewInt(1), | ||
| Subject: pkix.Name{CommonName: "demo"}, | ||
| NotBefore: time.Now().Add(-time.Hour), | ||
| NotAfter: time.Now().Add(time.Hour), | ||
| DNSNames: []string{"localhost"}, | ||
| } | ||
| der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) | ||
| cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} | ||
| server = &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{drpcquic.ALPN}} | ||
| client = &tls.Config{InsecureSkipVerify: true, NextProtos: []string{drpcquic.ALPN}} //nolint:gosec | ||
| return | ||
| } | ||
|
|
||
| func main() { | ||
| ctx := context.Background() | ||
| serverTLS, clientTLS := devTLS() | ||
|
|
||
| ln, err := drpcquic.Listen("127.0.0.1:0", serverTLS, drpcquic.Options{}) | ||
| if err != nil { | ||
| log.Fatal(err) | ||
| } | ||
| go func() { _ = drpcquic.Serve(ctx, ln, drpcserver.New(echo{}), drpcquic.Options{}) }() | ||
|
|
||
| mt, err := drpcquic.Dial(ctx, ln.Addr().String(), clientTLS, drpcquic.Options{}) | ||
| if err != nil { | ||
| log.Fatal(err) | ||
| } | ||
| conn := drpcconn.NewFromMultiplexed(mt, drpcconn.Options{}) | ||
| defer conn.Close() | ||
|
|
||
| var wg sync.WaitGroup | ||
| for i := range 5 { // 5 RPCs concurrently, each on its own QUIC stream | ||
| wg.Add(1) | ||
| go func(i int) { | ||
| defer wg.Done() | ||
| out := new(msg) | ||
| if err := conn.Invoke(ctx, "/echo", enc{}, &msg{S: fmt.Sprintf("req-%d", i)}, out); err != nil { | ||
| log.Printf("RPC %d error: %v", i, err) | ||
| return | ||
| } | ||
| fmt.Printf("RPC %d -> %s\n", i, out.S) | ||
| }(i) | ||
| } | ||
| wg.Wait() | ||
| fmt.Println("done — drpc is running over QUIC") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| // Copyright (C) 2026 Storj Labs, Inc. | ||
| // See LICENSE for copying information. | ||
|
|
||
| package drpcmanager | ||
|
|
||
| import ( | ||
| "context" | ||
| "time" | ||
|
|
||
| grpcmetadata "google.golang.org/grpc/metadata" | ||
|
|
||
| "storj.io/drpc" | ||
| "storj.io/drpc/drpcmetadata" | ||
| "storj.io/drpc/drpcstream" | ||
| "storj.io/drpc/drpcwire" | ||
| "storj.io/drpc/internal/drpcopts" | ||
| ) | ||
|
|
||
| // QUICManager manages a drpc.MultiplexedTransport (e.g. a QUIC connection), | ||
| // mapping each drpc logical stream onto its own native stream. Unlike Manager, | ||
| // there is no shared reader, no semaphore, no streamBuffer, and no pkts/pdone | ||
| // signaling: each stream owns its underlying stream and reads it directly via | ||
| // drpcstream.NewForReader. | ||
| type QUICManager struct { | ||
| mt drpc.MultiplexedTransport | ||
| opts Options | ||
| } | ||
|
|
||
| // NewQUIC returns a new QUICManager over the multiplexed transport. | ||
| func NewQUIC(mt drpc.MultiplexedTransport, opts Options) *QUICManager { | ||
| return &QUICManager{mt: mt, opts: opts} | ||
| } | ||
|
|
||
| // Close closes the multiplexed transport, which closes all of its streams. | ||
| func (m *QUICManager) Close() error { return m.mt.Close() } | ||
|
|
||
| // Closed returns a channel closed when the multiplexed transport is closed. | ||
| func (m *QUICManager) Closed() <-chan struct{} { return m.mt.Closed() } | ||
|
|
||
| // Unblocked never blocks in multiplexed mode: there is no single-stream | ||
| // semaphore, so a new stream can always be created. It returns an already | ||
| // closed channel to satisfy the manager interface used by drpcconn.Conn. | ||
| func (m *QUICManager) Unblocked() <-chan struct{} { return closedCh } | ||
|
|
||
| // streamOpts builds per-stream options for a stream on transport tr. It copies | ||
| // m.opts.Stream so concurrent NewClientStream calls do not race on shared | ||
| // option state. The fin channel is deliberately left unset: in multiplexed mode | ||
| // the stream is not coordinated through a manager semaphore, and checkFinished | ||
| // nil-guards the fin channel. | ||
| func (m *QUICManager) streamOpts(kind drpc.StreamKind, rpc string, tr drpc.Transport) drpcstream.Options { | ||
| opts := m.opts.Stream | ||
| drpcopts.SetStreamKind(&opts.Internal, kind) | ||
| drpcopts.SetStreamRPC(&opts.Internal, rpc) | ||
| drpcopts.SetStreamTransport(&opts.Internal, tr) | ||
| if cb := drpcopts.GetManagerStatsCB(&m.opts.Internal); cb != nil { | ||
| drpcopts.SetStreamStats(&opts.Internal, cb(rpc)) | ||
| } | ||
| return opts | ||
| } | ||
|
|
||
| // NewClientStream opens a new outbound stream and wraps it in a drpc stream with | ||
| // its own Reader/Writer and read loop. The caller (drpcconn) writes the invoke | ||
| // sequence on the returned stream. | ||
| func (m *QUICManager) NewClientStream(ctx context.Context, rpc string) (*drpcstream.Stream, error) { | ||
| tr, err := m.mt.OpenStream(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| rd := drpcwire.NewReaderWithOptions(tr, m.opts.Reader) | ||
| wr := drpcwire.NewWriter(tr, m.opts.WriterBufferSize) | ||
| // sid MUST be 1: the reader starts expecting ID{Stream:1, Message:1}. | ||
| return drpcstream.NewForReader(ctx, 1, tr, rd, wr, m.streamOpts(drpc.StreamKindClient, rpc, tr)), nil | ||
| } | ||
|
|
||
| // AcceptTransport accepts the next inbound stream and returns its raw transport | ||
| // WITHOUT reading the invoke. Hand the transport to ServerStream from a | ||
| // per-stream goroutine so that reading one stream's invoke never blocks | ||
| // accepting (or serving) the next stream on the same connection. Doing the | ||
| // invoke read here (as a combined accept+read) would serialize the whole | ||
| // connection behind whichever stream is slowest to send its invoke — exactly | ||
| // the head-of-line blocking running over QUIC is meant to avoid. | ||
| func (m *QUICManager) AcceptTransport(ctx context.Context) (drpc.Transport, error) { | ||
| return m.mt.AcceptStream(ctx) | ||
| } | ||
|
|
||
| // ServerStream reads the invoke (and any preceding metadata) packets off an | ||
| // already-accepted transport tr, then hands the SAME reader to the stream's read | ||
| // loop so no buffered bytes are lost and no second reader races the first. On any | ||
| // error it closes tr, so a failed or slow stream leaks nothing and never affects | ||
| // other streams on the connection. | ||
| func (m *QUICManager) ServerStream(ctx context.Context, tr drpc.Transport) (stream *drpcstream.Stream, rpc string, err error) { | ||
| defer func() { | ||
| if err != nil { | ||
| _ = tr.Close() // don't leak the stream on a failed parse | ||
| } | ||
| }() | ||
|
|
||
| // Optionally bound how long we wait for the client's invoke. | ||
| if to := m.opts.InactivityTimeout; to > 0 { | ||
| if d, ok := tr.(interface{ SetReadDeadline(time.Time) error }); ok { | ||
| _ = d.SetReadDeadline(time.Now().Add(to)) | ||
| defer func() { _ = d.SetReadDeadline(time.Time{}) }() | ||
| } | ||
| } | ||
|
|
||
| rd := drpcwire.NewReaderWithOptions(tr, m.opts.Reader) | ||
|
|
||
| var meta map[string]string | ||
| var metaID uint64 | ||
| for { | ||
| pkt, perr := rd.ReadPacketUsing(nil) | ||
| if perr != nil { | ||
| return nil, "", perr | ||
| } | ||
| switch pkt.Kind { | ||
| case drpcwire.KindInvokeMetadata: | ||
| meta, err = drpcmetadata.Decode(pkt.Data) | ||
| if err != nil { | ||
| return nil, "", err | ||
| } | ||
| metaID = pkt.ID.Stream | ||
|
|
||
| case drpcwire.KindInvoke: | ||
| rpc = string(pkt.Data) | ||
| if metaID == pkt.ID.Stream { | ||
| if m.opts.GRPCMetadataCompatMode { | ||
| grpcMeta := make(map[string][]string, len(meta)) | ||
| for k, v := range meta { | ||
| grpcMeta[k] = []string{v} | ||
| } | ||
| ctx = grpcmetadata.NewIncomingContext(ctx, grpcMeta) | ||
| } else { | ||
| ctx = drpcmetadata.NewIncomingContext(ctx, meta) | ||
| } | ||
| } | ||
| wr := drpcwire.NewWriter(tr, m.opts.WriterBufferSize) | ||
| stream = drpcstream.NewForReader(ctx, pkt.ID.Stream, tr, rd, wr, | ||
| m.streamOpts(drpc.StreamKindServer, rpc, tr)) | ||
| return stream, rpc, nil | ||
|
|
||
| default: | ||
| return nil, "", drpc.ProtocolError.New("expected invoke, got %s", pkt.Kind) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // NewServerStream accepts a new inbound stream and reads its invoke. It is | ||
| // AcceptTransport followed by ServerStream. Prefer the split form when serving | ||
| // many concurrent streams (AcceptTransport in the accept loop, ServerStream in a | ||
| // per-stream goroutine) so the invoke read does not serialize accepts; this | ||
| // combined form remains for single-stream callers and tests. | ||
| func (m *QUICManager) NewServerStream(ctx context.Context) (stream *drpcstream.Stream, rpc string, err error) { | ||
| tr, err := m.mt.AcceptStream(ctx) | ||
| if err != nil { | ||
| return nil, "", err | ||
| } | ||
| return m.ServerStream(ctx, tr) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.