skip to content

What must a Go package document when an exported method returns a receive-only `<-chan Event`?

level: middleimportance: should knowfreq 42%

answer

  1. closing is the sender's job, not the receiver's
  2. the caller cannot close what you returned
  3. say when it closes and what stops production
  4. a slow reader blocks, drops, or grows memory
  5. a closed channel never says why

basics

~20 s

When it closes, what stops the producer, whether a slow reader blocks or loses events, and how a terminal error arrives. The caller cannot close a receive-only channel, so the package owns every one of those answers.

solid answer

~50 s

Returning `<-chan Event` hands the caller a receive end and keeps the send end — and closing is the sender's job, since sending on a closed channel panics. The caller literally cannot close it: `close` on a receive-only channel is a compile error. So the doc comment has to answer four things. When is the channel closed, and does closing mean the producing goroutine has exited? What stops production — a `Stop` method, a context passed in, the source going away? What happens to a slow reader: does the producer block, drop events, or is the channel buffered? And how does a terminal error reach the caller, since a closed channel only says "no more", not "why" — usually an `Err` method valid after close, or an error field on the event. If those answers are awkward, that is a signal the API should not return a channel at all: a pull method like `Next(ctx) (Event, error)` has no close contract and gives the error an obvious home.

code

go · 11 lines
go
// Events returns the stream of filesystem events.
// The channel is closed by the watcher's goroutine as its last act,
// after Stop returns. A slow reader blocks the watcher.
// After the channel is closed, Err reports why it ended.
func (w *Watcher) Events() <-chan Event
func (w *Watcher) Err() error
func (w *Watcher) Stop()

// Pull alternative: nothing is started, nothing must be closed,
// and the error has an obvious place to live.
func (w *Watcher) Next(ctx context.Context) (Event, error)

go deeper

for a junior

Remember that the sender closes a channel, never the receiver, and that a receive-only channel cannot be closed at all. If an API hands you one, look for the method that stops the stream.

for a middle

Be able to list the contract points: when the channel closes, what stops production, what a slow reader experiences, and how a terminal error is reported. Explain why the receive-only direction forces the package to answer them.

for a senior

Show the failure you have seen: a caller breaks out of the range early, never calls Stop, and the producer blocks on a send forever. Argue when you would drop the channel entirely for a pull or callback shape.

for a principal

Treat a returned channel as a long-lived promise about buffering and backpressure that embedders will design their own flow control around. Decide whether the coupling is one you want to guarantee across versions, and how you would migrate consumers if it is not.

## What the direction of the channel already decided Writing the result type as `<-chan Event` rather than `chan Event` is not decoration. It is a compile-enforced statement about ownership: - The caller can receive and can `range` over it. - The caller **cannot** send on it, and **cannot** close it — `close` applied to a receive-only channel does not compile. That is the right way round, because closing is inherently the sender's job: a send on a closed channel panics, so only the code that owns sending can know that no further send will happen. Having decided the caller cannot close it, the package has taken on the entire lifetime contract, and the doc comment is where that contract lives. ## The four questions a doc comment must answer ### 1. When does it close, and what does closing mean? "Closed when there are no more events" is not enough. The caller needs to know whether the close is also a liveness guarantee. `range ch` ends when the channel closes; if the producing goroutine closes the channel and then keeps running — flushing, retrying, reconnecting — the caller's loop has ended while your goroutine has not. The strongest contract is: the channel is closed by the producing goroutine as its last act, so a completed `range` means the producer has exited. ### 2. What stops production? Something must, or the producer runs until the process does. The usual answers are a `Stop`/`Close` method, or a `context.Context` supplied when the stream was created. Whichever it is, name it, and say whether the stop is synchronous. "Stop returns after the events channel has been closed" is a promise a caller can build a shutdown sequence on. "Stop signals the watcher to stop" is not. ### 3. What happens to a slow reader? This is the question most channel-returning APIs forget, and it decides the failure mode under load: - **Unbuffered or full buffer, producer blocks.** Backpressure reaches the source. Safe for memory, but now the caller's read speed throttles your component, and a caller who walks away without stopping you leaves a goroutine blocked on a send forever. - **Producer drops when the buffer is full.** Bounded memory, no coupling — but the caller must know events can be missing, and ideally be told how many. - **Unbounded queueing.** Memory grows with the gap between producer and consumer; a caller who stops reading turns a stream into a leak. All three are defensible; silently picking one is not. ### 4. Where does the error go? A closed channel says "no more", never "why". A stream that can fail needs one of: - an `Err() error` method that is valid once the channel is closed — the shape `bufio.Scanner` uses, where `Scan` returns `false` and `Err` explains whether that was end of input or a failure; - an error field on the event type, so failures arrive in order with the data; - a second channel — usually the worst option, because now the caller must select on two channels and you have two close contracts instead of one. ## The alternative: do not return a channel A channel in an exported signature bundles "I started a goroutine" with "you must drain me". Often the caller wants neither. Two shapes avoid the whole contract: - **A pull method**: `func (w *Watcher) Next(ctx context.Context) (Event, error)`. No goroutine is started, nothing must be closed, the error has an obvious home, and cancellation is per call. The caller who wants a channel can build one in three lines and will then own it. - **A push callback or iterator**: the caller supplies a function that is called for each event. Since Go 1.23, range-over-func makes this idiomatic to consume — an `iter.Seq2[Event, error]` reads as an ordinary `for ... range` at the call site and still runs on the caller's goroutine. A good test: if the doc comment for your channel-returning method needs more than two sentences, the channel is probably the wrong return type. ## What goes wrong when the contract is unwritten The common production failure is not a panic — it is a leak. The caller ranges over the channel, breaks out early on the first event they care about, and never calls `Stop`. The producer blocks forever on its next send, holding whatever it had open. Nothing crashes; the goroutine count simply climbs, and it shows up much later as memory that never comes back.

  • Why can a caller not just close the channel themselves when they are done?
    Because the returned type is `<-chan Event`, and `close` on a receive-only channel is a compile-time error. That is deliberate: the sender must own closing, since a send on a closed channel panics. The consequence is that the package must export something else — a `Stop` or a context — that ends the stream.
  • How should a terminal error reach a caller who is ranging over the channel?
    Not through the close, which carries no information. Either put an error field on the event type so it arrives in order, or export an `Err() error` method that is meaningful once the channel is closed — the shape `bufio.Scanner` uses, where the loop ends and `Err` says whether that was the end of input or a failure.
  • What is the argument for a pull method instead of returning a channel?
    A pull method such as `Next(ctx) (Event, error)` starts no goroutine, so there is no close contract, no leak if the caller stops early, and the error has a normal return path. Cancellation is per call rather than per stream. Any caller who genuinely wants a channel can wrap it in a few lines and will then own the goroutine they created.

saying these in an interview costs you the question

  • Says the receiver should close the channel when finished
  • Documents only the event type, never when the channel closes
  • Assumes a closed channel tells the caller why the stream ended
  • Leaves buffering unspecified, so a slow reader silently blocks the producer
  • Returns a second error channel and calls that error handling
  • Believes abandoning a range loop stops the producing goroutine