skip to content

What does CloseWrite on a *net.TCPConn do, and what does the peer's next Read return?

level: middleimportance: nice to knowfreq 30%

answer

  1. TCP carries two independent streams
  2. end one direction, keep the other
  3. the peer sees a clean end-of-stream
  4. sends a FIN, not a reset
  5. you still must Close the descriptor

basics

~20 s

CloseWrite shuts down only the sending half of a TCP connection. It sends a FIN, so once the peer drains what you already sent, its next Read returns io.EOF — while your side can still read the peer's reply until it closes too.

solid answer

~40 s

A TCP connection is two independent byte streams, and `(*net.TCPConn).CloseWrite` closes just yours. It sends a FIN, so the peer sees a clean end-of-stream: its `Read` returns `io.EOF` and a call like `io.ReadAll` on its side finally returns instead of blocking. Your own reads keep working, which is the whole point — it is how you say "that is the entire request, now answer" on a protocol with no length prefix or terminator. `Close` would tear down both directions and lose the reply. You still have to call `Close` afterwards to release the file descriptor. If you only hold a `net.Conn`, type-assert to `*net.TCPConn`, or assert to a small interface with `CloseWrite() error`, which `*net.UnixConn` and `*tls.Conn` also satisfy.

code

go · 12 lines
go
func exchange(c net.Conn, req []byte) ([]byte, error) {
	defer c.Close() // CloseWrite does not free the descriptor
	if _, err := c.Write(req); err != nil {
		return nil, err
	}
	if tc, ok := c.(*net.TCPConn); ok {
		if err := tc.CloseWrite(); err != nil {
			return nil, err
		}
	}
	return io.ReadAll(c) // reverse direction is still open
}

go deeper

for a junior

Know that a TCP connection has two directions and that a half-close ends only the one you send on. Be able to say the peer's next Read returns io.EOF.

for a middle

Explain the FIN that goes on the wire, why Close would destroy the reply path, and that the method lives on *net.TCPConn rather than the net.Conn interface, so a type assertion is needed.

for a senior

Show when a half-close is the right protocol design at all: unknown-length requests with no terminator, at the cost of never reusing that connection. Pair it with read deadlines so a peer that never answers is still bounded.

for a principal

Weigh half-close against a framed protocol for a connection-heavy service: one exchange per connection costs handshakes and descriptors, and that tradeoff is a wire-format decision other teams inherit.

## Two streams, not one pipe A TCP connection is full duplex: there is a stream from A to B and a separate stream from B to A, and each can end independently. That state — one direction finished, the other still open — is a **half-close**, and it is perfectly legal TCP. Go exposes it on the concrete connection types. `*net.TCPConn` has: ``` func (c *TCPConn) CloseWrite() error func (c *TCPConn) CloseRead() error ``` `CloseWrite` sends a FIN for your sending direction. Everything you already wrote is still delivered; nothing is discarded. Afterwards your writes fail, but your reads keep working normally until the peer closes its own direction. ## What the peer observes The peer drains whatever is still buffered, and then its next `Read` returns `io.EOF` — the same clean end-of-stream it would get from a full `Close`, and distinguishable from an abort. Concretely, a server doing `body, err := io.ReadAll(conn)` blocks forever against a client that keeps its write side open, and returns as soon as that client calls `CloseWrite`. That is the classic use: a protocol where the request length is not known in advance and there is no terminator byte. The client writes the request, half-closes, and reads the response from the still-open reverse stream. ## Why not just Close? `Close` shuts down both directions and releases the descriptor. On a request/response exchange that means you destroy your own read side along with the write side, so the reply you asked for is unreadable — and depending on timing and unread data, the kernel may send an RST rather than a graceful FIN, which the peer sees as a connection reset rather than an orderly end. `CloseWrite` is the narrower tool: end my half, keep yours. You still call `Close` when the exchange is over; `CloseWrite` does not free the file descriptor, so skipping the final `Close` leaks it. ## Getting at the method `net.Conn` is an interface and does **not** declare `CloseWrite`. To reach it, either keep the concrete type from `net.DialTCP`, or type-assert: ``` if tc, ok := c.(*net.TCPConn); ok { _ = tc.CloseWrite() } ``` A better habit in library code is to assert to a capability interface you declare yourself: ``` type closeWriter interface{ CloseWrite() error } ``` because `*net.UnixConn` and `*tls.Conn` implement it too, so the same code path works over a Unix socket or a TLS session. (For TLS this matters more than it looks: the TLS layer must send its own close-notify alert, and its `CloseWrite` does that for you.) `CloseRead` is the mirror and is far rarer — and more dangerous — because data the peer keeps sending after you close your read side has nowhere to go, and the kernel will typically reset the connection. ## Where it interacts with deadlines Half-close and deadlines solve adjacent problems and are easy to confuse. `CloseWrite` gives the peer a **positive signal** that your side is finished, so it can stop waiting. A read deadline is what protects you when no such signal ever arrives — the peer crashed, or a middlebox dropped the flow. A well-behaved wire layer uses both: half-close for the orderly case, deadlines so the disorderly case is bounded rather than a hang. One caution: after `CloseWrite`, the connection is no longer reusable for another request. In a pooled client this means a half-close is a decision to spend the connection, which is why length-prefixed and delimited protocols — the ones that can keep a connection alive for many exchanges — generally do not use it. ## Reading the failure mode If you see a server goroutine parked in `io.ReadAll` or `io.Copy` on a connection that logs say has received the whole request, a missing half-close on the client is a prime suspect: the server is politely waiting for an end-of-stream that the client never sends and, without a read deadline, will wait forever.

  • Why is calling Close instead of CloseWrite wrong for a request/response exchange?
    `Close` ends both directions and releases the descriptor, so the reply you are waiting for becomes unreadable. It can also produce an RST rather than an orderly FIN when data is unread, which the peer reports as a connection reset instead of a clean end of stream. `CloseWrite` ends only your sending half and leaves the reply path intact.
  • You only hold a net.Conn. How do you reach CloseWrite?
    The `net.Conn` interface does not declare it, so type-assert. `c.(*net.TCPConn)` works for TCP, but asserting to a locally declared `interface{ CloseWrite() error }` is better, because `*net.UnixConn` and `*tls.Conn` satisfy it too and the same code then works over Unix sockets and TLS.
  • Can a connection be reused for another request after CloseWrite?
    No. Your sending direction is permanently finished, so nothing further can be written. That makes half-close a decision to spend the connection, which is why protocols designed to reuse a connection for many exchanges use length prefixes or delimiters instead.

It is hanging up your microphone but leaving the earpiece on: the other side hears you finish and can still answer you.

saying these in an interview costs you the question

  • Thinks CloseWrite closes the whole connection
  • Believes the peer sees a connection reset rather than io.EOF
  • Skips the final Close and leaks the file descriptor
  • Expects to write again on the same connection afterwards
  • Looks for CloseWrite on the net.Conn interface itself
  • Uses a half-close on a connection it plans to reuse