The Send Path

View Source

How an application write becomes bytes on a socket. Read this before changing anything in quic_connection's send regions, or when a packet count moves and you need to know which stage produced it. Functions are named rather than located by line, so this survives the file changing underneath it.

docs/RECV_PATH.md covers the other direction.

The short version

quic:send_data/4
  -> quic_connection:do_send_data/5        admission, flow control, fragmentation
     -> queue_stream_data/5                when blocked: park it, priority queue
     -> send_stream_chunk_run/8            bulk: many chunks, one bookkeeping pass
     -> send_app_packet_internal/3         one frame, maybe coalesced
        -> send_app_packet_now/3           encrypt, protect, count, hand to socket
           -> flush_socket_batch/1         GSO batch leaves the process

Stage by stage

do_send_data/5 is the entry point for a stream write. It first admits the write against the connection's 16 MiB send-queue ceiling, before anything is sent: a write that would cross it is refused whole with send_queue_full, and the connection state is left untouched. Async writes are always admitted. An admitted write then goes through connection and stream flow control, and is fragmented when larger than one packet can carry. It is sent now, or queued because the congestion window or the peer's window has no room.

queue_stream_data/5 parks a write that cannot go now. It never refuses: part of the write may already be on the wire by then. Entries live in a bucket-per-urgency priority queue (RFC 9218, urgency 0 to 7), so insertion is constant time and the drain order matches stream priority. send_queue_bytes, send_queue_count and send_queue_version track it; the count, not the byte total, decides emptiness, because a FIN-only entry carries no bytes.

process_send_queue/1 drains after an ACK opens the window, after pacing releases, or on a burst continuation. It works through process_send_queue_entry/1 under the burst budget and the congestion controller's approval.

send_stream_chunk_run/8 is the bulk path. Once the first full-size chunk is approved, as many further chunks as congestion control, pacing and the burst budget allow are approved up front, sealed, and handed to the socket in one loop, with loss, congestion, packet-number and counter bookkeeping updated once for the whole run. It rebuilds #state{} once per run rather than once per packet, which is what makes bulk transfer cheap.

send_app_packet_internal/3 is the single-frame entry. With coalescing on it accumulates into pend_payload so several small sends share one packet; with coalescing off it goes straight to send_app_packet_now/3. A payload that would exceed the remaining budget flushes the pending packet first.

send_app_packet_now/3 is where a packet is actually built: select keys by role, assign the packet number from pn_app, encrypt, apply header protection, update the congestion controller and loss tracker, bump counters, and hand the datagram to the socket layer. It matches app_keys = {ClientKeys, ServerKeys} in its head, so it cannot run before the handshake installs keys.

flush_pending_packet/1 emits whatever coalescing accumulated. flush_socket_batch/1 pushes the socket layer's batch out, which is where GSO super-datagrams leave on Linux. Anything queued but unflushed sits until the next event on the connection, so handlers that queue must flush.

Things that surprise people

  • Two write paths, not one. Bulk stream data goes through send_stream_chunk_run/8; everything else (ACKs, control frames, small writes) goes through send_app_packet_internal/3. They meet at send_app_packet_now/3.
  • Coalescing is a send-side field named coalesce, distinct from delivery_coalescing, which is a receive-side owner-message option.
  • The queue is not FIFO. It is eight buckets by urgency, and a requeued remainder goes to the front of its bucket rather than the back, so a partly sent chunk is not overtaken.
  • Counters are the contract. quic_regression_SUITE gates on packets_sent and retransmits, never on a rate. If a change here moves packet counts, that gate is what notices.