Skip to content

Day 117 — Bidirectional Streaming

Month 5 · Week 1 · ⬅ Day 116 · Day 118 ➡ · Journal index

🎯 Learning Objective

Implement a full-duplex (bidirectional) streaming RPC where both sides read and write concurrently, and get the goroutine + termination discipline right.

📚 Topics

  • rpc Chat(stream Msg) returns (stream Msg) and the generated bidi stream interface
  • Concurrent Send/Recv, half-close, error propagation via errgroup/channels

📖 Reading / Sources

📝 Notes

  • Bidirectional = both stream keywords: rpc Chat(stream ChatMessage) returns (stream ChatMessage). The two directions are independent — ordering is only guaranteed within a direction, not across them → [[grpc]].
  • The natural shape: the server loops Recv() and Send()s replies; the client runs two goroutines — one pumping Send, one draining Recv — coordinated by a channel or sync.WaitGroup/errgroup → [[goroutines]].
  • Termination is a half-close handshake. The client signals "no more requests" with stream.CloseSend(); the server's Recv then returns io.EOF and it can finish, which the client sees as io.EOF on its Recv. Each side closes its own write direction.
  • The one-goroutine-per-direction axiom is mandatory here: concurrent Sends (or concurrent Recvs) corrupt framing. Sending and receiving simultaneously is fine because they're separate streams → [[channel-axioms]].
  • Propagate the first error and cancel the rest: if Recv fails, cancel the context so the Send goroutine unblocks. golang.org/x/sync/errgroup with a derived context is the idiomatic way (or a plain done channel).
  • The shared context still governs everything: cancellation or deadline tears down both directions at once.

💻 Code Examples

// Server: echo each message back, in order, until the client half-closes.
func (s *server) Chat(stream chatv1.ChatService_ChatServer) error {
    for {
        msg, err := stream.Recv()
        if errors.Is(err, io.EOF) {
            return nil // client called CloseSend -> we're done
        }
        if err != nil {
            return err
        }
        if err := stream.Send(&chatv1.ChatMessage{Text: "echo: " + msg.GetText()}); err != nil {
            return err
        }
    }
}
// Client: one goroutine sends, the main goroutine receives.
stream, _ := client.Chat(ctx)

go func() {
    for _, t := range []string{"hi", "there"} {
        if err := stream.Send(&chatv1.ChatMessage{Text: t}); err != nil {
            return
        }
    }
    stream.CloseSend() // half-close: no more outgoing messages
}()

for {
    in, err := stream.Recv()
    if errors.Is(err, io.EOF) {
        break // server finished
    }
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(in.GetText())
}

The framing that carries messages in both directions is the same 5-byte frame as the half-duplex case: examples/month-05/framing · Run: go run ./examples/month-05/framing

🏋️ Exercises / Practice

Exercise Status Link
Length-prefixed frame round-trip (both directions use it) exercises/month-05/week-1/frames

🐛 Mistakes Made

  • Called stream.Send from two goroutines to "go faster" → garbled stream. Reverted to a single sender goroutine.
  • Forgot CloseSend() on the client, so the server's Recv never saw io.EOF and the RPC hung. Half-close is required.

❓ Open Questions

  • Best pattern for surfacing the send goroutine's error to the caller? (Leaning: errgroup.Group with the stream context so the first failure cancels both directions.)

🧠 Active Recall (answer without looking)

  1. Q: Why does a bidi client typically need two goroutines?
    A

The two directions are independent, so you want to Send and Recv concurrently. One goroutine pumps sends (and CloseSends when done); another drains receives until io.EOF. They must be separate goroutines because concurrent sends (or concurrent receives) on one stream are not allowed. 2. Q: What sequence cleanly shuts down a bidi stream from the client?

A

The client calls CloseSend() (half-close its write side). The server's Recv returns io.EOF, it finishes its handler, and the client's Recv then returns io.EOF. Each side closes only its own outgoing direction.

🪶 Feynman Reflection

Bidirectional streaming is two independent pipes over one connection. Each side reads its pipe and writes the other. The discipline is small but strict: one goroutine per direction, CloseSend to say "I'm done writing", and io.EOF to learn the peer is done. Cancel the context to kill both pipes at once.

🕳️ Knowledge Gaps

  • Coordinating many concurrent bidi streams (e.g., a chat fan-out hub) without leaking goroutines.
  • Flow-control interplay when both directions are hot.

✅ Summary

I can implement a bidirectional streaming RPC with a single-sender/single-receiver goroutine model, half-close correctly, and propagate cancellation across both directions.

⏭️ Next Steps / Prep for Tomorrow

  • Day 118: deadlines, metadata, and the status-code error model — the cross-cutting concerns of every RPC.

Time spent Difficulty Confidence
90 min 🟦🟦🟦⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: docs(journal): bidirectional streaming (day 117)