cld-toys › Toys › http-from-sockets

Commentary: http-from-sockets

An HTTP/1.1 server on a raw TCP socket — and the discovery that curl will print a complete-looking body to your terminal and then exit 28, while the response it exits 0 on is the one that lied to it.

http-from-sockets/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with http_server.py open beside you. http_server.py is the toy (193 lines); demo.py drives it with a real HTTP client and prints the matrix; test_http_server.py pins the behaviour described here. Stdlib only — the one external dependency is curl, and that is deliberate (see §7). Every transcript below was captured from a real run on macOS 26.5.2 (Darwin 25.5.0, arm64), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], curl 8.7.1 (x86_64-apple-darwin25.0) libcurl/8.7.1, over TCP on loopback.
cd http-from-sockets
python3 demo.py                  # the aha (§6) -- needs curl on PATH
python3 test_http_server.py      # 17 tests, one of them on a real socket

You can also drive one framing by hand, which is the best way to feel it:

python3 http_server.py --mode none --after hold --port 8127
curl -s --max-time 1 http://127.0.0.1:8127/hello ; echo "exit=$?"
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy is a working HTTP/1.1 server with no framework, no http.server, and no third-party anything: a listening socket, a read loop, some string splitting, and a sendall of bytes you assembled by hand. That part takes about forty lines and is not the point.

The point is the other hundred and fifty: ten different ways to end a response body, only some of which actually say where it ended. TCP will not tell the client. TCP has no messages — it has bytes, in order, and no idea that any group of them belongs together.

By the end you should be able to:


2. The problem this mechanism exists to solve

TCP gives you a reliable, ordered byte stream. That is a genuinely hard thing to build and it is worth exactly what it costs — but read the promise carefully, because of the four words reliable, ordered, byte, stream, the last two are the ones that hurt. You will get every byte, in order. You will not be told where anything begins or ends. recv() returns "whatever is in the buffer right now," which may be half a request, or three responses, or one byte.

So any protocol running on TCP has to invent its own idea of "message." That is framing, and there are only three families of answer:

HTTP/1.0 mostly used the third. One request, one response, close, done — so "the body is everything until FIN" was a complete answer and nobody had to think about it. HTTP/1.1 made connections persistent by default, because setting up a TCP connection costs a round trip and a page needs thirty objects. The moment the connection survives the response, EOF stops being available as a terminator: if the client waited for FIN it would wait forever, and if it guessed early it would read your next response as this one's body.

That is the trade this toy is about. Persistent connections bought you a round trip and charged you a framing header. Everything in the matrix in §6 is a way of not paying that charge correctly.


3. Background you need

ConceptWhere it's used in the toyOne link
TCP is a byte stream with no message boundaries read_request's two loops (lines 117–122, 131–136) — it must be willing to call recv again mid-header and mid-body Python socket HOWTO
Message framing: the three body-length rules the whole FRAMINGS table, lines 92–103. This is the one that carries the result. RFC 9112 §6.3
recv(bufsize) returns at most bufsize, and says nothing about the wire read_request(conn, bufsize); §6.6 changes only this integer. Also load-bearing — for what you must not conclude. Python socket HOWTO
CRLF, and the blank line that ends the head head(), lines 28–33 RFC 9112 §2.1
Chunked transfer coding, terminated by a zero-length chunk chunk() and frame_chunked, lines 36–38 and 63–65 MDN: Transfer-Encoding
Persistent connections (keep-alive) serve's after parameter, lines 140–175 RFC 9112 §9
FIN / orderly close as a framing signal after="fin"conn.close(), line 174 RFC 9112 §6.3 rule 8
curl exit codes 0, 18, 28 every row of §6.2 libcurl error codes

The two flagged rows are the whole page. The first decides what happens; the third decides what you are entitled to say about why.


4. The mental model

the server writes these bytes, in this order -------------------------------------------> HTTP/1.1 200 OK\r\n Content-Length: 27\r\n \r\n the framing is the message\n |________________| |___________________| |__| |_________________________| status line headers blank body line ^^^^^^^^^^^^^^^^^^^^ the only thing here that says where the body STOPS what the client sees on the socket, in every case, is just: ...HTTP/1.1 200 OK..Content-Length: 27....the framing is the message. \_________________________ one byte stream _________________________/ it must decide, from the head alone, which rule ends the body: +---------------------------------------------+ | 1. Transfer-Encoding: chunked? | -> read until 0\r\n\r\n | (WINS over Content-Length if both) | +---------------------------------------------+ | 2. Content-Length: N? | -> read exactly N bytes +---------------------------------------------+ | 3. neither | -> read until FIN +---------------------------------------------+ rule 3 is the trap. On a persistent connection there is no FIN coming, so "read until FIN" means "read until your own timeout fires" -- long after the last byte of a perfectly complete body has been printed.

The asymmetry to hold on to: rules 1 and 2 are promises the server makes; rule 3 is an event the client waits for. A promise can be wrong. An event can simply never happen.


5. Reading the source

5.1 The entire wire format, in eleven lines

http_server.py · lines 28–38
def head(status_line, *headers):
    """Status line + headers + the blank line that ends the head."""
    out = status_line + "\r\n"
    for header in headers:
        out += header + "\r\n"
    return (out + "\r\n").encode()


def chunk(part):
    """One chunk of a chunked body: hex length, CRLF, the bytes, CRLF."""
    return b"%x\r\n" % len(part) + part + b"\r\n"

There is no HTTP library under this. head() is string concatenation and one .encode(); the protocol's entire message syntax is "lines ending in CRLF, then an empty one." The + "\r\n" on the return line is the blank line, and it is the only delimiter in HTTP/1.1 that is a delimiter — everything after it is counted, not scanned.

chunk() is worth a second look because the %x is not decoration. Chunk lengths are hexadecimal, so the 13-byte and 14-byte pieces in frame_chunked go on the wire as d and e, not 13 and 14. A client reading them as decimal would ask for 13 bytes and 14 bytes anyway for the first one and get away with it, then ask for 14 and get 20. Bugs like that are why the test suite asserts the exact bytes.

5.2 Three framings that differ only in a header

http_server.py · lines 44–55
def frame_correct(body):
    return head("HTTP/1.1 200 OK", "Content-Type: text/plain",
                "Content-Length: %d" % len(body)) + body


def frame_none(body):
    return head("HTTP/1.1 200 OK", "Content-Type: text/plain") + body


def frame_short(body):
    return head("HTTP/1.1 200 OK", "Content-Type: text/plain",
                "Content-Length: 10") + body

Three functions, one body, and every byte after the head is identical in all three. frame_correct computes the length from the body it is about to send, which is the only version where the header cannot drift. frame_short hard-codes 10 — the thing a real server does implicitly the moment someone adds a suffix to the body after the length was computed, or a gzip layer recompresses it, or a proxy rewrites it.

frame_none is not a broken server so much as an unfinished one. It is exactly what you write first, before you have heard of framing, and it is the version that works perfectly in every manual test where you close the socket afterwards.

5.3 The five bytes that decide chunked

http_server.py · lines 63–70
def frame_chunked(body):
    return (head("HTTP/1.1 200 OK", "Transfer-Encoding: chunked")
            + chunk(body[:13]) + chunk(body[13:]) + b"0\r\n\r\n")


def frame_chunked_noterm(body):
    return (head("HTTP/1.1 200 OK", "Transfer-Encoding: chunked")
            + chunk(body[:13]) + chunk(body[13:]))

The two functions are one expression apart. frame_chunked is 89 bytes, frame_chunked_noterm is 84, and the difference is literally b"0\r\n\r\n" — measured, not asserted; see CF2 in §6.4. Chunked encoding does not need the terminator to deliver the body. It needs it to end. A streaming server that crashes after its last chunk has sent you every byte it owed you and still produced an unterminated message.

5.4 The reader: two loops, because two different rules

http_server.py · lines 106–123
def read_request(conn, bufsize=4096):
    """Read one request; return (request_line, headers, body, recv_sizes).

    Two loops, because two different rules end two different things. The head
    ends at a blank line -- a delimiter you scan for, so you must be willing
    to call recv() again mid-header. The body ends after Content-Length bytes
    -- a count you subtract from. There is no third rule and, if the header is
    absent, no rule at all: `want` is 0 and any body sits unread in the
    kernel's buffer. That asymmetry is the whole toy, seen from the other end.
    """
    buf, sizes = b"", []
    while b"\r\n\r\n" not in buf:
        data = conn.recv(bufsize)
        if not data:                            # client closed: no request
            return None, {}, b"", sizes
        sizes.append(len(data))
        buf += data
    raw_head, rest = buf.split(b"\r\n\r\n", 1)

The condition is while b"\r\n\r\n" not in buf and not if — the head may arrive in pieces, and a server that assumes one recv gives it a whole head works flawlessly against every small client and falls over against a large one. Note also buf.split(..., 1): whatever came in past the blank line is kept in rest rather than discarded, because a single recv can easily hand you the head and the body and the beginning of the next request together.

The if not data: return None is the honest reading of "recv returned zero bytes": on a socket that means the peer has closed, not that it is being quiet. Deleting it costs you an infinite loop the first time something connects and disconnects without speaking — which, as §6.5 shows, happens on every single run of the demo, because demo.py's own readiness probe does exactly that.

http_server.py · lines 130–137
want = int(headers.get("content-length", 0))
while len(rest) < want:
    data = conn.recv(bufsize)
    if not data:
        break
    sizes.append(len(data))
    rest += data
return request_line, headers, rest, sizes

headers.get("content-length", 0) defaulting to 0 is the request-side echo of the whole page: with no length header there is no body, full stop. RFC 9112 §6.3 rule 8 makes this asymmetric on purpose — for a request, "no framing" means length zero; for a response, it means read until close. A request cannot use EOF as its terminator, because the client still wants to read the answer on that same connection.

Deleting these seven lines is the difference between receiving 20 000 bytes and receiving 3 939 of them. That is CF5 in §6.4, and it is measured.

5.5 What happens after the response

http_server.py · lines 158–175
while True:
    conn, _ = lsock.accept()
    conns += 1
    log("connection %d accepted" % conns)
    while True:
        request_line, _, req_body, sizes = read_request(conn, bufsize)
        if request_line is None:
            log("connection %d closed by client" % conns)
            break
        log("  %s | recv sizes %s | request body %d bytes"
            % (request_line, sizes, len(req_body)))
        conn.sendall(build(body))
        if after == "keepalive":
            continue
        if after == "hold":
            time.sleep(HOLD_SECONDS)
        conn.close()
        break

Two nested while Trues, and the inner one is the interesting one: it is persistence. after == "keepalive" means continue — go round and read another request off the same socket, which is what HTTP/1.1 does by default and what makes framing mandatory.

after == "hold" is the experimental apparatus. It writes the response and then simply does nothing for HOLD_SECONDS, keeping the socket open. That is not a pathological server; it is an ordinary keep-alive server that has finished its work and is waiting for your next request. The client cannot tell those two situations apart, and §6 is a table of what it does about that.


6. The demo, and what it proves

Everything below is a verbatim slice of one run of python3 demo.py.

6.1 The pair

========================================================================== 1. the pair -- same body, same server, framing changed ========================================================================== mode=correct exit=0 stdout='the framing is the message\n' mode=none exit=28 stdout='the framing is the message\n' mode=short exit=0 stdout='the framin' 'none' printed all 27 bytes and failed. 'short' printed 10 bytes and succeeded. The exit code reports on the framing, not the body.

The body is b"the framing is the message\n" — 26 characters and a newline, 27 bytes. Read the two failing-looking rows against each other:

If you are writing a script around curl, the first case wakes you up at 3am for a response that was fine and the second silently corrupts your data. The exit code is a statement about framing, not about the body.

6.2 Every framing, held open vs. closed

========================================================================== 2. every framing, held open vs. closed after the response ========================================================================== mode after exit stdout ------------------ ------ ----- ----------------------------- correct hold 0 'the framing is the message\n' correct fin 0 'the framing is the message\n' none hold 28 'the framing is the message\n' none fin 0 'the framing is the message\n' short hold 0 'the framin' short fin 0 'the framin' long hold 28 'the framing is the message\n' long fin 18 'the framing is the message\n' chunked hold 0 'the framing is the message\n' chunked fin 0 'the framing is the message\n' chunked-noterm hold 28 'the framing is the message\n' chunked-noterm fin 18 'the framing is the message\n' close-header-only hold 28 'the framing is the message\n' close-header-only fin 0 'the framing is the message\n' http10 hold 28 'the framing is the message\n' http10 fin 0 'the framing is the message\n' both hold 0 'the framing is the message\n' both fin 0 'the framing is the message\n' both-te-first hold 0 'the framing is the message\n' both-te-first fin 0 'the framing is the message\n' Read the 'fin' column: every framing works once the socket closes. Framing is only load-bearing because the connection is meant to survive the response.

Four things in that table are worth more than the headline.

close-header-only (hold) exits 28. The response says Connection: close in as many words, and the client still hangs. The header is an announcement of intent about the connection; it is not a body terminator. What ends the body under rule 3 is the FIN — an actual TCP segment, produced by an actual close(). Say you will close and then don't, and you have told the client nothing it can use. The fin row is the same response with a real close() after it: exit 0. The header does not frame. The FIN frames.

http10 (hold) exits 28 too. HTTP/1.0's default was close-when-done, so it is tempting to think the version string tells the client "expect an EOF". It does not change what the client does — it still waits for the FIN, and one that never comes times out identically. The version in the status line is not a framing mechanism either.

long and chunked-noterm swap 28 for 18 when the socket closes. long declares Content-Length: 47 (that is 27 + 20, from frame_long) and sends 27. Held open, curl waits for the missing 20 bytes and times out: 28. Closed, the FIN arrives while curl is still 20 bytes short of a promise, and it can say something much more precise — exit 18, CURLE_PARTIAL_FILE, "shorter or larger than expected." Same for the unterminated chunked response. That is the difference between "I gave up" and "you lied to me," and you only get the second diagnosis because there was a promise to check against. none has no promise, so closing it produces exit 0 and no complaint at all — the same bytes, an entirely clean bill of health.

both and both-te-first exit 0 with the correct body. Those responses carry Content-Length: 99 and Transfer-Encoding: chunked, in both possible orders. 99 is a lie about a 27-byte body, and curl ignores it completely, in both orders, because RFC 9112 §6.3 rule 3 says Transfer-Encoding wins and the Content-Length must be dropped. That precedence rule is why the combination is the classic request-smuggling primitive: it is only safe as long as every box in the chain implements the same tie-break. A front-end that believes the length and a back-end that believes the chunks will disagree about where one request ends and the next begins — and the attacker chooses the bytes in the gap.

The boundary — where the effect vanishes Read the fin column top to bottom. Every unframed response — none, close-header-only, http10 — exits 0 with the full body the moment the server closes the socket. If you close the connection after every response, framing headers are decoration and this entire page is about nothing. That is exactly why HTTP/1.0 could get away without them, and exactly what changed in 1.1: framing became mandatory the day the connection stopped ending. Every mechanism here is the cost of keeping a socket alive.

6.3 curl's own account of the truncation

========================================================================== 3. what curl says about 'short', in full (curl -sv, unfiltered) ========================================================================== * Trying 127.0.0.1:8127... * Connected to 127.0.0.1 (127.0.0.1) port 8127 > GET /hello HTTP/1.1 > Host: 127.0.0.1:8127 > User-Agent: curl/8.7.1 > Accept: */* > * Request completely sent off < HTTP/1.1 200 OK < Content-Type: text/plain < Content-Length: 10 < { [27 bytes data] * Excess found writing body: excess = 17, size = 10, maxdownload = 10, bytecount = 10 * Closing connection [exit 0, stdout 'the framin']

This is the whole -v trace, not a selection. Two lines in it settle the question of whether curl was fooled:

{ [27 bytes data] — it received all 27. The bytes were never lost, delayed, or dropped; they were sitting in the buffer.

Excess found writing body: excess = 17, size = 10, maxdownload = 10 — and it noticed. 27 − 10 = 17. curl knows perfectly well that the server sent more than it promised. It discards the excess, closes the connection rather than reuse a stream it can no longer find its place in, and reports success. That is a defensible choice — the alternative, keeping the connection and parsing g is the message\n as the next status line, is response smuggling — but the caller is told nothing. §6.5's keep-alive run is what that defensive close looks like from the outside.

6.4 Counterfactuals

These come from counterfactuals.py, which imports the shipped http_server.py. Real output, not reasoning.

CF1 — which Content-Length value flips the exit code? Sweep the declared length over a body that is really 27 bytes:

declared exit stdout -------- ---- ------------------------------ 24 0 'the framing is the messa' 25 0 'the framing is the messag' 26 0 'the framing is the message' 27 0 'the framing is the message\n' 28 28 'the framing is the message\n' 29 28 'the framing is the message\n' 30 28 'the framing is the message\n'

The flip is between 27 and 28 — one digit in one header. And the two sides are not symmetric in the slightest. Under-declare by any amount and you get exit 0 with a body truncated to exactly the number you claimed; the client has no way to know, because you never told it about the rest. Over-declare by one byte and you get a hang. Being too small is silent; being too large is loud. Almost every framing bug you will meet in production is on the silent side of that boundary.

CF2 — the five bytes.

frame_chunked 89 bytes frame_chunked_noterm 84 bytes difference 5 bytes: b'0\r\n\r\n' chunked exit=0 stdout='the framing is the message\n' chunked-noterm exit=28 stdout='the framing is the message\n'

89 − 84 = 5. Both deliver the identical 27-byte body to stdout. Five bytes of pure metadata are the entire difference between a successful transfer and a timeout.

CF3 — does the client insist on CRLF?

CRLF exit=0 stdout='the framing is the message\n' LF only exit=0 stdout='the framing is the message\n'

This one surprised me, and it is the most quietly instructive result here. Send the head with bare \n line endings, in flat violation of the grammar, and curl accepts it and exits 0. The delimiter it forgives; the count it does not. Robustness in real clients is not evenly distributed — it is concentrated wherever being strict would break existing servers, and absent wherever being lenient would mean guessing where a message ends. You cannot guess a length.

CF4 — parse the request head by scanning for \n\n.

server never finds its delimiter: exit=28 stdout='' (curl's head is CRLF-terminated: CR LF CR LF contains no LF LF) shipped \r\n\r\n reader: exit=0 stdout='the framing is the message\n'

Change the delimiter in read_request from b"\r\n\r\n" to b"\n\n" and the server never emits a response at all, because CR LF CR LF contains no two adjacent LFs. Note which side is lenient here: curl-the-client accepted my malformed LF-only response in CF3, and curl-the-client's own request is strictly CRLF. Be conservative in what you send.

CF5 — delete the body-reading loop.

shipped (loop) server received 20000 of 20000 body bytes (recvs=5, total recv'd=20157, so this request's head was 157 bytes) no body loop server received 3939 of 20000 body bytes (recvs=1, total recv'd=4096, so this request's head was 157 bytes)

Remove while len(rest) < want: — i.e. assume one recv hands you the whole request — and a 20 000-byte upload arrives as 3 939 bytes. The arithmetic: one recv(4096) returned 4 096 bytes, of which 157 were the head, leaving 4096 − 157 = 3 939 of body. No error is raised anywhere. The server just silently processes a fifth of the upload.

(That head is 157 bytes here and 156 in §6.6, because these counterfactuals run on ephemeral five-digit ports while the demo uses the fixed four-digit 8127, and the port number lives inside the Host: header. Framing arithmetic is unforgiving that way.)

CF6 — the one number that moves between runs.

run 1 exit=28 stdout='the framing is the message\n' stderr='curl: (28) Operation timed out after 1001 milliseconds with 27 bytes received' run 2 exit=28 stdout='the framing is the message\n' stderr='curl: (28) Operation timed out after 1002 milliseconds with 27 bytes received'

1001 vs 1002. This message is the clearest single sentence in the whole toy — "timed out … with 27 bytes received", the failure and the complete body in one line — and it is also the reason demo.py runs curl with -s and never prints elapsed time. That millisecond figure is the only thing standing between this page and a reproducible transcript.

6.5 Two requests down one connection

========================================================================== 4. two requests down one connection, with Content-Length: 10 ========================================================================== (server log below; connection 1 is always start_server's readiness probe, which connects and closes without sending a request) mode=correct exit=0 stdout='the framing is the message\nthe framing is the message\n' | listening on 127.0.0.1:8127 mode=correct after=keepalive bufsize=4096 | connection 1 accepted | connection 1 closed by client | connection 2 accepted | GET /a HTTP/1.1 | recv sizes [78] | request body 0 bytes | GET /b HTTP/1.1 | recv sizes [78] | request body 0 bytes | connection 2 closed by client mode=short exit=0 stdout='the framinthe framin' | listening on 127.0.0.1:8127 mode=short after=keepalive bufsize=4096 | connection 1 accepted | connection 1 closed by client | connection 2 accepted | GET /a HTTP/1.1 | recv sizes [78] | request body 0 bytes | connection 2 closed by client | connection 3 accepted | GET /b HTTP/1.1 | recv sizes [78] | request body 0 bytes | connection 3 closed by client

This is the price of the lie, and it is paid in connections. With correct framing, curl url/a url/b fetches both over one connection (number 2 — number 1 is the demo's readiness probe, which connects and closes without speaking, and is a real illustration of why read_request handles a zero-byte recv). With Content-Length: 10, curl uses two connections, 2 and 3, because after each response it finds 17 unaccounted-for bytes and refuses to reuse a stream it has lost its place in.

So a framing bug does not only corrupt the body. It quietly destroys keep-alive — the entire reason the framing header was needed — and converts every request into a fresh TCP handshake. On loopback that is free. Across the Atlantic it is another round trip per request, forever, and it will show up in your latency graphs as a mystery rather than as an error, because every one of those requests exits 0.

6.6 That 4096 is mine, not the network's

========================================================================== 5. the same 20000-byte upload, read with three buffer sizes ========================================================================== bufsize=4096 curl exit=0 POST /upload HTTP/1.1 | recv sizes [4096, 4096, 4096, 4096, 3772] | request body 20000 bytes bufsize=1024 curl exit=0 POST /upload HTTP/1.1 | recv sizes [1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 700] | request body 20000 bytes bufsize=65536 curl exit=0 POST /upload HTTP/1.1 | recv sizes [20156] | request body 20000 bytes Same bytes on the wire every time. The only thing that changed is the integer this program passes to recv(). This is recv's buffer bound; nothing here observed a TCP segment.

Identical client, identical upload, three different answers to "how many pieces did it arrive in": 5, 20, or 1.

The arithmetic. Total bytes read is 20 156 every time: 20 000 of body plus a 156-byte request head (POST /upload HTTP/1.1, Host: 127.0.0.1:8127, User-Agent, Accept, Content-Length: 20000, Content-Type, and the blank line). Then:

Mandatory disclosure The 4096 is recv's buffer bound, not TCP segmentation. Nothing in this toy observed a TCP segment boundary, and nothing in it could. recv returns at most bufsize bytes from the socket's receive buffer; how those bytes were sliced into segments on the way in, whether they were coalesced, and where any segment began are all invisible above the socket API. If you want segments you need tcpdump, and that is a different toy.

What the experiment does prove is the thing that matters for writing servers: the number of recv calls is a property of your code, not of the message. A reader that treats "one recv" as "one message" is not making an optimistic assumption about the network; it is making a false statement about an API. That is why read_request loops, and why test_recv_count_is_the_buffer_bound_not_the_wire asserts len(sizes) == ceil(len(raw) / bufsize) against an in-memory fake with no network under it at all — the relationship holds because it is arithmetic about a buffer, not physics about a wire.

Note also that ⌈·⌉ is a lower bound in general. On a slower link a recv can legitimately return less than bufsize because that is all that has arrived yet, giving more calls. It can never give fewer. On loopback the buffer is always full when we ask, so the bound is tight and the numbers above were identical across five consecutive runs.

6.7 Reproducibility

A toy whose result depends on a port, a client, a timeout and a socket has an obvious problem: the repo requires byte-identical transcripts. What was done about it, concretely:

With those five in place, five consecutive runs of demo.py produce identical output — 5 704 bytes, SHA-256 8de9778b45af6499… each time.

What remains environment-dependent, and is therefore declared in the demo's own banner rather than hidden:

========================================================================== 0. banner -- the environment these results are valid for ========================================================================== client : curl 8.7.1 (x86_64-apple-darwin25.0) libcurl/8.7.1 (SecureTransport) LibreSSL/3.3.6 zlib/1.2.12 nghttp2/1.68.1 python : 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3 ] platform : macOS-26.5.2-arm64-arm-64bit-Mach-O arm64 transport: TCP over loopback, 127.0.0.1:8127 invoked : curl -s --max-time 1 Exit codes below are curl's opinion, not the protocol's. The recv counts in section 5 are loopback numbers. Nothing here observed a TCP segment boundary.

Every exit code on this page is curl 8.7.1's judgement. A different client — or a different curl — may frame the same bytes the same way and still report differently; the Excess found writing body behaviour in particular is libcurl's policy, not the RFC's. The banner prints your local version, so if your numbers differ from this page's, the first line of the output tells you why.


7. Design decisions and roads not taken

curl is a hard dependency, on purpose. The obvious way to make this toy self-contained is to write a fifty-line Python client. It was rejected outright: a client I wrote, checking framing rules I also wrote, proves nothing except that I am internally consistent. The entire evidentiary value of §6 is that an independent implementation of RFC 9112 — one that predates this toy by twenty-five years and has no idea it exists — reads my bytes and disagrees with me. demo.py therefore checks for curl up front and exits with a message naming it rather than degrading to a fallback.

Ten framings in the core file, rather than one correct server plus broken variants in the demo. The comparison is the toy; the same call as rate-limiter carrying both algorithms and lru-cache carrying both policies. A single correct server would be about ninety lines and would teach the thing everyone already believes.

hold rather than a background thread pool. The response and what happens to the socket afterwards are two independent axes, so they are two independent parameters, and the demo crosses them. Making "hold" an explicit mode rather than an accident of scheduling is what turns "curl sometimes hangs" into a 2×10 table.

One connection at a time, no threads, no selectors. Concurrency is a different mechanism and would double the line count while teaching nothing about framing. The hold mode does block the accept loop for 30 seconds; the demo starts a fresh server per case, which costs about 10ms and keeps the toy honest about what it is.

Chunk sizes 13 and 14, hard-coded. They exist to make the hex length visible (d and e, not 13 and 14) and to prove the client reassembles across chunk boundaries. A single chunk would work and would demonstrate less.

Content-Length: 99 for the both cases. A wrong number, deliberately: if it were 27 you could not tell whether curl honoured it or ignored it. It has to be a lie for the precedence rule to be observable.

Not built: a --max-time-free demo. Without a timeout the unframed cases hang forever and there is no aha, only a stuck terminal. The timeout is not a workaround; the number 28 is the result.


8. What's simplified vs. the real thing


9. Check yourself

Answer before expanding. Each answer is derivable from the source.

Question 1

A response has no Content-Length and no Transfer-Encoding. The server sends the body and keeps the socket open. How many bytes does the client receive, and does the transfer succeed?

Answer

It receives all of them and the transfer fails. This is mode=none, after=hold in §6.2: exit 28 with the complete 27-byte body on stdout. With no framing header the client falls through to RFC 9112 §6.3 rule 8 — read until the connection closes — and no close is coming, so it waits until its own timeout fires. Byte delivery and transfer success are independent here.

Question 2

Your server computes Content-Length before a middleware appends a footer to the body. What does the client see, and what does it report?

Answer

It sees the body truncated to the originally computed length, and reports success. That is mode=short: Content-Length: 10 against 27 bytes gives exit 0 and 'the framin'. CF1 shows the whole shape — every under-declaration from 24 to 27 exits 0 with a body cut to exactly the declared size. The failure is silent by construction, because the client was told the message ended and had no reason to doubt it.

The second-order damage is in §6.5: curl also stops reusing the connection (2 connections for 2 requests instead of 1), so keep-alive dies too.

Question 3

You add Connection: close to an unframed response but a bug leaves the socket open. Does the client survive?

Answer

No — exit 28, same as sending no header at all. That is the close-header-only, hold row. The header states an intention about connection lifetime; the body terminator under rule 3 is the FIN, an actual TCP event. The fin row of the same mode is exit 0. Announcing a close is not performing one.

Question 4

Same unframed response, but the server declares Content-Length: 47 for a 27-byte body and then closes the socket. Why is the exit code 18 rather than 28, and why is that better?

Answer

Because closing while the client is still 20 bytes short of a stated promise lets it distinguish "I gave up waiting" from "you sent less than you said." Exit 18 is CURLE_PARTIAL_FILE. It is better because it is diagnosable: the client can say which side was wrong.

Note what this implies. The unframed version of the same close (none, fin) exits 0 — identical bytes, no complaint, because with no declared length there is nothing to check the delivery against. A wrong Content-Length is more debuggable than a missing one.

Question 5

Your server sends both Content-Length: 99 and Transfer-Encoding: chunked. It works fine in testing. Why is it a security bug?

Answer

Because it works fine only as long as every hop agrees on the tie-break. RFC 9112 §6.3 rule 3 says Transfer-Encoding wins and intermediaries must strip the Content-Length — curl obeys this regardless of header order (both and both-te-first both exit 0 with the correct body). But if a front-end proxy honours the length and the back-end honours the chunks, they disagree about where the request ends. The bytes in the gap are treated as the start of the next request by one of them, and an attacker chooses those bytes. That is HTTP request smuggling (CL.TE / TE.CL).

Question 6

The server logs recv sizes [4096, 4096, 4096, 4096, 3772]. What can you conclude about the network?

Answer

Nothing. That list is a property of the constant passed to recv, not of the wire. §6.6 reads the identical upload as 20 recvs (bufsize 1024) or 1 recv (bufsize 65536); the counts follow ⌈20156 / bufsize⌉. recv returns at most bufsize bytes out of the kernel's receive buffer and reports nothing about how the data was segmented, coalesced, or timed on its way in. Segment boundaries are invisible above the socket API — you would need tcpdump.

The only sound inference is the arithmetic one, and it is a lower bound: a slower link can produce more calls than ⌈total / bufsize⌉, never fewer.

Question 7

Reaching past the toy. You run three replicas behind a load balancer that keeps persistent connections to each. One replica has the frame_short bug. What do your dashboards show?

Answer

Almost certainly a latency and connection-count anomaly, not an error rate. Every affected response exits 0 with a truncated body, so HTTP-status dashboards stay green. What moves is connection churn: as in §6.5, the client side abandons the connection after each bad response rather than reusing it, so that replica's share of new TCP handshakes (and TLS handshakes, in production) climbs while the other two stay flat. Downstream you get corrupt or short payloads roughly a third of the time, surfacing as parse errors in whatever consumes the body — a long way from the replica that caused them.

The generalisation worth keeping: a framing bug degrades into a performance signal plus a data bug, and almost never into an error-code signal.


10. Further reading