C12: Networks, byte streams, and protocol boundaries
On this page
- A. Prerequisites and observable goals
- B. The central question: how does one program know what another meant?
- C. Separate the quantities being measured
- D. A stream must be framed incrementally
- E. Connections, HTTP, and security have distinct contracts
- F. Worked trace: two frames across arbitrary chunks
- G. Independent practice
- H. Complete solutions: C12
- I. Retention and readiness
A. Prerequisites and observable goals#
C01–C03 state and buffers; C09 representations; C10 processes and I/O.
Distinguish transport guarantees from application guarantees, reason about latency and throughput, and implement a bounded incremental message decoder.
Level: Degree core; all networking experiments are local or simulated.
B. The central question: how does one program know what another meant?#
A network carries representations of information, not Python objects or C++ structures as abstract values. An application chooses a message format; a transport moves bytes or datagrams; the network layer routes packets; link technologies move frames across individual links. This layering separates responsibilities. An application still has to decide which request a reply belongs to, how to reject malformed input, and what to do when communication stops.
An IP address identifies a network-layer endpoint or interface in a routing context, while a transport port helps distinguish services. DNS maps names to records such as addresses; it is not the connection itself, and a cached lookup can outlive a deployment change. A socket is a programming interface to communication, not a guarantee of successful delivery. A listening socket accepts connections; each accepted connection has its own stream state. A process may handle many sockets using threads or an event loop.
TCP provides a reliable, in-order byte stream while a connection is functioning. It does not preserve application write boundaries, prove that a remote application processed a request, or inherently establish application-level liveness. UDP preserves datagram boundaries, but does not by itself guarantee delivery, ordering, or freedom from duplication. Applications sometimes choose UDP when late data is less useful than fresh data, but then must specify the reliability and congestion behavior they actually need. [TCP]
C. Separate the quantities being measured#
Latency is elapsed time for a specified event, such as a round trip or completed request. Bandwidth is a capacity or rate limit; throughput is the achieved useful work per unit time. Jitter describes variation in delay under an explicit measurement convention. Packet loss is a fraction or count over a stated population. These quantities interact but are not interchangeable.
For a simplified one-way transfer of bytes over a bottleneck rate bytes per second with fixed delay , a lower-order model is
This omits connection setup, retransmission, queueing, encryption, and server work. It is a model to challenge, not a law that every transfer exactly follows. If bytes, bytes/s, and s, the modeled time is s. A faster processor will not remove the s transmission term unless it changes the bottleneck.
Suppose an application permits only one outstanding request and each response requires a ms round trip. Even with negligible computation, it completes at most about such requests/s. Pipelining can improve throughput by allowing independent work to overlap. It does not make each individual request’s propagation delay vanish. A bounded number of outstanding requests also prevents a slow consumer from accumulating an unlimited queue.
D. A stream must be framed incrementally#
We choose a deliberately small protocol: a four-byte unsigned length in network byte order, followed by that many payload bytes. Payloads may contain any bytes, including zero. Length zero is valid; lengths above are rejected. The length counts bytes, not Unicode characters. Text must first be encoded, for example as UTF-8, and decoded only after the complete payload is available.
The decoder maintains a buffer and either no expected payload length or one validated length. Append incoming bytes. If no length is known and at least four bytes are buffered, consume the header and validate it. If enough payload bytes are buffered, emit exactly one payload, reset to the header state, and continue: a single read may contain several messages. Otherwise stop and wait for more data. At end of stream, leftover header or payload bytes indicate truncation.
The invariant is that already emitted payloads correspond exactly to complete frames consumed from the stream prefix; the buffer contains only the unconsumed suffix. A header is consumed once, a payload is emitted once, and no payload is emitted until all its bytes are present. Length validation occurs before waiting for or allocating an attacker-declared huge payload. Our implementation accepts a caller-supplied chunk already allocated in memory; a real socket loop must also cap its read size and overall resource use.
E. Connections, HTTP, and security have distinct contracts#
A successful send or buffered write does not mean the peer has executed the operation. Reads can be short. A graceful end of stream differs from a timeout and from an application-level error response. After a protocol violation, attempting to guess where the next message begins may silently misinterpret data; this lab marks the decoder failed and requires a new instance.
HTTP supplies request methods, targets, headers, status codes, and message semantics. It does not automatically make a state-changing application safe to retry. A JSON object is a serialization format, not an authentication mechanism. TLS protects a connection’s confidentiality and integrity and supports peer authentication under a trust configuration; it does not decide whether an authenticated user is authorized to read a particular record. Project J03 deliberately omits TLS and authentication and therefore binds only to loopback. It is not an Internet service template. [HTTP][TLS]
Recognize a framing problem when the code assumes one read equals one message, splits on a delimiter that can occur inside payloads, or trusts a length before checking a limit. Recognize an application consistency problem when a request might execute twice after a timeout; that is the next module, not something TCP alone solves.
F. Worked trace: two frames across arbitrary chunks#
Encoding b"A" yields header then . Encoding an empty payload yields four zero bytes. Concatenate them. After the first three bytes, no header is complete. After the fourth byte, the decoder knows a one-byte payload is expected. After byte five, it emits b"A". The next four bytes form a zero-length frame, which is emitted immediately; it must not wait for a nonexistent payload byte. Finishing now succeeds because there is neither a partial header nor an incomplete payload.
A sender that closes after the first four bytes instead has announced one payload byte but supplied none. finish() must reject this. Otherwise a truncated request could be mistaken for a clean boundary. Tests should feed the same stream whole, one byte at a time, and at every possible single split, then compare the outputs.
G. Independent practice#
F foundation C standard course work T transfer S optional stretch L runnable lab or project. Attempt the complete set before opening its solutions.
A sender writes one 12-byte frame. The receiver obtains chunks of 2, 3, and 7 bytes. Is TCP malfunctioning? Explain which component must reconstruct the message.
Under the simplified transfer model, compute the time to send bytes with bytes/s and s. Explain why doubling bandwidth does not halve the total modeled time.
A header announces a three-byte payload. Only two payload bytes arrive before end of stream. State the correct decoder behavior and explain why returning those two bytes is not acceptable. What should happen for a zero-length payload?
A game uses UDP for position updates and TCP for inventory changes. Explain why this can be a reasonable starting choice, then identify one correctness responsibility that remains for each channel. Do not claim the transport choice alone makes the game correct.
Implement the length-prefixed encoder and incremental decoder in starters/framing_lab.py. Match the reference API, the -byte limit, zero-length frames, multiple frames per chunk, truncation detection, and failed-state behavior. Run the framing tests and add one chunking pattern not shown in the worked trace.
Optional small hints
For C12.03, distinguish an incomplete prefix from a complete frame. For C12.05, process as many complete frames as are buffered, not just one per feed call.
H. Complete solutions: C12#
Compare the first step where your work differs. A corrected answer is not yet an independent success.
C12.01Show solution
Transport boundaries are not message boundaries
TCP is not malfunctioning. Its application interface is a stream, so the receiver must accumulate bytes and interpret its framing specification. Here the chunks sum to bytes, but even that sum alone does not identify a message unless the protocol supplies a fixed size, delimiter with escaping, length field, or equivalent framing rule. The decoder, not TCP’s packetization, establishes application boundaries. Testing a single convenient read size would miss this contract error.
Back to C12.01C12.02Show solution
Separate the fixed and variable costs
The transmission term is s. Add s for a total modeled time of s. Doubling the rate makes transmission s and the total s, not s. The fixed delay did not halve. The result is approximate because the model omits queueing, setup, and retransmission; measurements that differ should motivate inspecting those omissions rather than changing units until an answer fits.
Back to C12.02C12.03Show solution
A clean end must occur at a frame boundary
Reject the stream as truncated. The two bytes are a proper prefix of the promised three-byte payload, not a valid frame. Returning them silently changes the sender’s declared message and may make parsing or checksums misleading. A zero-length payload is different: its header itself establishes that no payload bytes are required, so emit the empty byte string immediately. The decoder then returns to expecting the next four-byte header.
Back to C12.03C12.04Show solution
Choose delivery semantics, then define application semantics
Frequent position snapshots may supersede older snapshots, so avoiding head-of-line delay from reliable delivery can be useful. The application still needs sequence numbers or timestamps interpreted under a clear policy, stale-update rejection, rate limits, and server authority. Inventory changes need reliable communication but also authorization, validation, ordering rules, and retry-safe transaction semantics. TCP can carry a request whose reply is lost after the inventory commit; retrying it can duplicate an effect unless the application handles identity and deduplication. Either channel also needs congestion and abuse considerations in production.
Back to C12.04C12.05Show solution
Complete reference implementation and verification
The decoder stores only unconsumed input, validates lengths before interpreting payloads, and loops until another complete frame cannot be extracted. A failed decoder refuses later feeds. The tests compare chunking patterns and reject oversize or truncated frames; they establish behavior on those cases, not a production parser security certification. Run python -m unittest discover -s tests -v from the companion root. The reference code is:
"""C12.05: bounded length-prefixed framing over arbitrary byte chunks."""
import struct
MAX_PAYLOAD = 4096
def frame(payload: bytes) -> bytes:
if len(payload) > MAX_PAYLOAD:
raise ValueError("frame too large")
return struct.pack(">I", len(payload)) + payload
class Decoder:
def __init__(self):
self._buffer = bytearray()
self._needed: int | None = None
self._failed = False
def feed(self, chunk: bytes) -> list[bytes]:
if self._failed:
raise ValueError("decoder is failed")
out = []
# Consume incrementally: even a large caller chunk does not cause
# an unbounded internal buffering allocation.
for byte in chunk:
self._buffer.append(byte)
if self._needed is None and len(self._buffer) == 4:
self._needed = struct.unpack(">I", self._buffer)[0]
self._buffer.clear()
if self._needed > MAX_PAYLOAD:
self._failed = True
raise ValueError("advertised frame too large")
if self._needed is not None and len(self._buffer) == self._needed:
out.append(bytes(self._buffer))
self._buffer.clear()
self._needed = None
return out
def finish(self) -> None:
if self._failed or self._buffer or self._needed is not None:
raise ValueError("truncated or invalid stream")
if __name__ == "__main__":
d = Decoder()
wire = frame(b"hello") + frame(b"")
print(d.feed(wire[:3]) + d.feed(wire[3:]))
d.finish()I. Retention and readiness#
Retrieve C09 byte order and C10 bounded queues. Advance when you can draw the decoder state after every chunk, explain why one send need not match one read, and distinguish a transport acknowledgment from an application commit. Continue with C13 and J03 before designing a state-changing network API.