cld-toys › Toys › tcp-proxy

Commentary: tcp-proxy

A forward proxy that shuffles bytes between many clients and one upstream connection — and the discovery that it multiplexes flawlessly right up to the instant a second request is in flight, and then hands one client's data to another with no bug, no corruption, and every byte delivered in order exactly once.

tcp-proxy/ 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 tcp_proxy.py open beside you. tcp_proxy.py is the toy (one upstream, three proxies); demo.py drives them and prints every transcript on this page; test_tcp_proxy.py pins the behaviour described here. Stdlib only, nothing to install, no external client. 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], over TCP on loopback.
One disclosure up front tcp_proxy.py is 433 lines (357 excluding blanks and comments). The repo's ground rules put the hard ceiling at 300 and say a toy that wants more than that is two toys. This one is over, deliberately and with the author's approval, and the accounting is in the appendix. Read that before you cite this file as an example of the format.
cd tcp-proxy
python3 demo.py                 # the aha (§6) -- byte-identical every run
python3 test_tcp_proxy.py       # 16 tests
python3 demo.py --race          # the same questions with no scripting (§6.8)
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

Appendix — a second mechanism, and the line budget it broke


1. Orientation

This toy exists because of a claim that turned out to be false. The entry it was built from read:

a forward proxy that shuffles bytes between client and upstream, with basic connection pooling. Aha: watch it multiplex many client connections onto few upstream connections.

Build that literally — one shared upstream socket, recv on one side, sendall on the other, no idea what a protocol is — and it does not multiplex. It misdelivers. Not because the relay is buggy: §6.2 shows two well-formed requests going in, two well-formed replies coming back, every byte forwarded in order exactly once, and one client receiving the other client's data.

By the end you should be able to:


2. The problem this mechanism exists to solve

A TCP connection is expensive in a way that is easy to forget on loopback: a three-way handshake before the first byte, a TLS handshake after that in anything real, a file descriptor and two kernel buffers for as long as it lives, and an entry in a conntrack table on every middlebox in between. A service fronting ten thousand clients does not want ten thousand connections to its database. So it keeps a small pool and lends connections out.

That is a resource-management story, and it is the wrong one. Here is the question a pool actually has to answer:

A connection was lent to a borrower. When is it free again?

Return it too early and the next borrower reads the tail of somebody else's response. Return it too late and the pool is smaller than you think it is. There is no third option and no way to defer the question — every checkin is an assertion that the previous exchange is over.

Now notice what kind of assertion that is. "This exchange is over" is identical to "this message ended here", which is the framing question, and TCP does not answer it. TCP promises a reliable ordered byte stream: you get every byte, in order, and no statement whatsoever about which bytes belong together. So a pool that does not parse the protocol has exactly one way to guess that a connection is free — wait until the upstream stops talking for a while — and that is a guess about the upstream's scheduling, not about the message.

The competing goals that make more than one design defensible:

You cannot have both, and this toy is the demonstration. The dumb relay in §6.2 is not a worse multiplexer than the parsing pool in §6.5 — it is not a multiplexer at all, and the mechanism the backlog entry asked us to admire turns out (§6.8) to be serialisation wearing a multiplexing costume.


3. Background you need

ConceptWhere it's used in the toyOne link
TCP is a byte stream with no message boundariesUpstream._handle's while b"\n" in buf (lines 111–113) — one recv can carry half a request or three of themPython socket HOWTO
Message framing by delimiterFramedProxy._read_reply, lines 331–337. This is the line that carries the result.RFC 9112 §9.3.2, Pipelining
Connection pooling: checkout, checkin, capFramedProxy._checkout (lines 273–285) and the finally: on line 303Envoy: connection pooling
Request/response correlationabsent from NaiveProxy by construction — that absence is §6.2. Also load-bearing, for what it makes impossible.RFC 9113 §5, Streams and Multiplexing
recv returns what is buffered, not what was sentevery recv(4096) in the file; the split reply in §6.6 arrives as twoPython socket HOWTO
Head-of-line blockingFramedProxy._checkout's blocking self.free.get(), line 285HAProxy: keep-alive, pipelining, multiplexing and connection pooling
Half-close (shutdown(SHUT_WR)) vs. closePassthroughProxy._relay, lines 378–387 — appendix materialRFC 9293 §3.6.1, Half-Closed Connections
Condition variables as a determinism toolevery wait_* method; §6.7threading.Condition

The two flagged rows are the whole page. The second row is where the result lives; the fourth is why there is nothing you can do about it anywhere else.


4. The mental model

============ the protocol-blind relay: NaiveProxy ========================== client A --. .-- upstream | | client B --+---> [ ONE shared TCP connection ] <----------+ | | client C --' '-- going up: every byte, in order, exactly once. Correct. coming down: "alpha=AAAAAAAAAAAA\n" arrives. Whose is it? +--------------------------------------+ | the bytes say ......... nothing | | the socket says ........ nothing | | TCP says ............... nothing | | the relay must GUESS ... "whoever | | wrote last" | +--------------------------------------+ The guess is right exactly when there is only ever one request in flight -- i.e. when the relay is not multiplexing anything. ============ the request-aware pool: FramedProxy =========================== client A --> parse "GET alpha\n" --> borrow conn 1 --> send | v read to the "\n" <-- the checkout | boundary IS v a parse give conn 1 back | client B --> parse "GET beta\n" --> borrow conn 1 (or 2) --> send Nothing is guessed. The connection goes back into the pool at the delimiter, which is a property of the message, not of the clock.

The sentence to carry away: a pool's checkin is a parse. Everything else about a pool — the cap, the queue, the eviction policy — is bookkeeping you could write in an afternoon. The only hard part is knowing that the last exchange is over, and nothing below layer 7 knows that.


5. Reading the source

5.1 The upstream, and why it has a framing loop of its own

tcp_proxy.py · lines 96–114
    def _handle(self, conn, cid):
        buf = b""
        while True:
            try:
                data = conn.recv(4096)
            except OSError:
                break
            if not data:
                break
            with self.cv:
                self.bytes_in += len(data)
                self.cv.notify_all()
            buf += data
            # The upstream frames on "\n" like everyone else has to. One
            # recv can carry half a request, or three of them.
            while b"\n" in buf:
                line, buf = buf.split(b"\n", 1)
                self._respond(conn, cid, line)
        conn.close()

Start here because it is the honest half of the toy, and because the inner while is the thing the proxies are about to get wrong. The upstream keeps its own buffer and splits on "\n" — it never assumes a recv gave it exactly one request, and it never assumes a recv gave it at most one either. The accounting (self.bytes_in) is bumped inside the lock before the parse, which is what lets the demo say "wait until 16 bytes have arrived" without a sleep; see §6.7.

The important consequence is in the loop's blindness. This upstream will happily parse b"GET alpGET beta" as a request, because that is what arrived between two newlines. It is not being naive: there is no other rule available. A newline-delimited protocol says a newline ends a request, and two clients' bytes concatenated on one connection are indistinguishable from one client's bytes. That is §6.1.

5.2 The naive relay, upward

tcp_proxy.py · lines 203–216
    def handle(self, conn):
        self.clients.append(conn)
        while True:
            try:
                data = conn.recv(4096)
            except OSError:
                break
            if not data:
                break
            self.last = conn            # <-- the routing decision, such as it is
            self.up.sendall(data)
            with self.cv:
                self.forwarded += len(data)
                self.cv.notify_all()

Fourteen lines, and there is nothing wrong with any of them. recv, sendall, count the bytes. self.up is a single socket created once in __init__ and shared by every client thread — which is the backlog entry's "multiplex many client connections onto few upstream connections", implemented as literally as it can be implemented.

self.last = conn is the only line here that is a decision rather than plumbing, and it is the line a reader's instinct blames when the output turns out wrong. §6.3 measures it. It is inert.

5.3 The naive relay, downward — where the guess happens

tcp_proxy.py · lines 218–237
    def _pump_upstream(self):
        while True:
            try:
                data = self.up.recv(4096)
            except OSError:
                return
            if not data:
                return
            if self.routing == "last":
                targets = [self.last] if self.last else []
            else:
                targets = list(self.clients)
            for target in targets:
                try:
                    target.sendall(data)
                except OSError:
                    pass
            with self.cv:
                self.routed += 1
                self.cv.notify_all()

This is the whole downstream path, and the thing to notice is what it does not have access to. data is bytes. There is no request id, no correlation token, no sequence number, and — because the upstream connection is shared — not even a socket identity to fall back on. The two branches are the only two policies you can write with the information available: send it to whoever spoke last, or send it to everybody.

A third option would need a fact that is not on the wire. That is why §6.3 sweeps both and both lose: the failure is not in this function, it is in the protocol's silence about who asked.

5.4 The pool: checkout

tcp_proxy.py · lines 273–285
    def _checkout(self):
        try:
            return self.free.get_nowait()
        except queue.Empty:
            pass
        with self.made_lock:
            if self.made < self.pool_cap:
                self.made += 1
                return (socket.create_connection(self.upstream_addr), b"")
        # At the cap and nothing free: this is head-of-line blocking, and it
        # is the price the pool charges for not opening a connection here.
        self.blocked_gets += 1
        return self.free.get()

Three tiers, in the order every real pool uses them: reuse an idle connection, else open a new one if you are under the cap, else wait. The get_nowait() first is what makes §6.5's cap=16 row read 8 instead of 16 — a borrower that finds a free connection never reaches the "open a new one" branch, so the pool only ever grows to the number of borrowers that were simultaneously unsatisfied. That number is the concurrency level, not the client count and not the cap.

Note that a connection is carried as (socket, leftover_bytes). The leftovers are the reason the pool works at all across a recv that over-read; §5.6.

5.5 The pool: one request, one borrow

tcp_proxy.py · lines 287–308
    def handle(self, conn):
        buf = b""
        while True:
            while b"\n" not in buf:
                try:
                    data = conn.recv(4096)
                except OSError:
                    return
                if not data:
                    return
                buf += data
            line, buf = buf.split(b"\n", 1)
            sock, rest = self._checkout()
            try:
                sock.sendall(line + b"\n")
                reply, rest = self._read_reply(sock, rest)
            finally:
                self.free.put((sock, rest))
            try:
                conn.sendall(reply)
            except OSError:
                return

Compare this against §5.2 and the whole toy is visible in the diff. The naive relay's loop body is "read some bytes, write some bytes". This one's is "read one request, borrow, write one request, read one reply, return, write one reply". The connection is held for exactly the span of one exchange, and that span is delimited at both ends by a parse.

Two details worth the ink:

buf survives the loop iteration (line, buf = buf.split(b"\n", 1)), so a client that pipelines two requests into one write is handled without a second recv. Drop that and you have written a proxy that works in testing and deadlocks against a client that batches.

The finally: puts the connection back even if _read_reply raised. That looks like ordinary hygiene and is actually a small lie the toy tells for brevity — see §8, because returning a connection whose state you do not know is how real pools leak corruption.

5.6 The load-bearing line

tcp_proxy.py · lines 310–337
    def _read_reply(self, sock, rest):
        """Read exactly one reply off a pooled connection.

        The `timeout` branch is pooling without parsing: it returns when the
        upstream goes quiet, a guess about the upstream's scheduling. The
        `framed` branch returns at the delimiter, a fact about the message,
        and keeps any bytes past it with the connection.
        """
        if self.release == "timeout":
            sock.settimeout(self.release_timeout)
            out = rest
            try:
                while True:
                    data = sock.recv(4096)
                    if not data:
                        break
                    out += data
            except (socket.timeout, OSError):
                pass
            sock.settimeout(None)
            return out, b""
        while b"\n" not in rest:
            data = sock.recv(4096)
            if not data:
                return rest, b""
            rest += data
        line, rest = rest.split(b"\n", 1)
        return line + b"\n", rest

Both branches answer the same question — "has this exchange finished?" — and they are not two implementations of one idea. They are two different questions that happen to agree most of the time.

The timeout branch asks: has the upstream been silent for 50 milliseconds? That is a statement about the upstream's scheduler, its disk, its GC pause and the network between you. The framed branch asks: have I seen the delimiter? That is a statement about the message, and it is true or false for reasons that have nothing to do with timing.

§6.6 runs both against an upstream that pauses 120ms in the middle of a 19-byte reply. The delimiter does not notice. The timeout gets every single answer wrong.

The last three lines are the other half of the trick, and they are easy to skim past. rest — everything that arrived after the delimiter — is returned to the caller and stored with the socket in the free queue. Without that, a recv that happened to pull in the front of the next reply would silently discard it, and the pool would corrupt itself at exactly the moments it is busiest.

5.7 The sequencing hooks, which are not decoration

tcp_proxy.py · lines 137–147
    # --- sequencing hooks: wait on a real event, never on a sleep ----------

    def wait_bytes(self, n):
        """Block until the upstream has received at least n bytes."""
        with self.cv:
            self.cv.wait_for(lambda: self.bytes_in >= n, timeout=5)

    def wait_requests(self, n):
        """Block until the upstream has parsed at least n whole requests."""
        with self.cv:
            self.cv.wait_for(lambda: len(self.requests) >= n, timeout=5)

A toy about concurrency whose transcript changes between runs cannot make an argument, because the reader cannot tell a result from a coincidence. These hooks — wait_bytes and wait_requests here, wait_forwarded and wait_routed on NaiveProxy, wait_eofs on PassthroughProxy — are how demo.py gets a byte-identical transcript with zero sleep() calls anywhere: every step waits on a counter of an event that actually happened.

They are also the toy's largest honest cost. They exist for the demo, not for the mechanism, and they are part of why this file is over the line ceiling. See §6.7 for the one that turned out to matter and the appendix for the budget.


6. The demo, and what it proves

Everything in §6.1–§6.6 is a verbatim slice of one run of python3 demo.py. That run is byte-identical across repeats (§6.7).

6.1 Two clients, one connection, bytes interleaved

========================================================================== 1. one upstream connection, two clients, bytes interleaved ========================================================================== client A wrote b'GET alp' then b'ha\n' client B wrote b'GET beta\n' in between what the upstream parsed as requests: request 1 on upstream conn 1: b'GET alpGET beta' request 2 on upstream conn 1: b'ha' what each client received back: client A got b"ERR unknown key 'ha'\n" client B got b"ERR unknown key 'GET alpGET beta'\n" Two well-formed requests went in; two requests nobody sent came out. The proxy forwarded every byte, in order, exactly once.

The arithmetic, because the relay is not losing anything: A wrote 7 bytes then 3, B wrote 9, so 7 + 3 + 9 = 19 bytes reached the upstream. What the upstream parsed was GET alpGET beta (15) + \n + ha (2) + \n = 19 bytes. Nothing was dropped, duplicated or reordered. (test_naive_relay_ forwards_every_byte_exactly_once asserts exactly this: up.bytes_in == px.forwarded == 19.)

Two clients sent two syntactically perfect requests. The upstream received two syntactically perfect requests. They were not the same two.

The interleave here is scripted — A stops mid-request, B writes, A finishes — because a race is not reproducible and a page you cannot re-run is a page you cannot check. The scripting is honest about what it is: this ordering is entirely legal under TCP and the relay has no defence against it. §6.8 runs the unscripted version and it happens on its own.

6.2 The headline: nothing interleaved, and still wrong

This is the section that should hurt. No partial writes, no clever timing — two complete requests, sent one after the other:

========================================================================== 2a. no interleaving at all -- routing='last' ========================================================================== client A asked for alpha, client B asked for beta. both requests arrived whole; nothing was interleaved: request 1 on upstream conn 1: b'GET alpha' request 2 on upstream conn 1: b'GET beta' what each client received back: client A got b'' client B got b'alpha=AAAAAAAAAAAA\nbeta=BBBBBBBBBBBB\n'

Read the two lines at the bottom carefully.

Client B asked for beta and was handed alpha's value. Not a truncated value, not a corrupted one — the correct, complete answer to a question B never asked, from a client B has never heard of. alpha=AAAAAAAAAAAA\n is 19 bytes and beta=BBBBBBBBBBBB\n is 18; B received all 37 of them, in order.

Client A, who asked for alpha, received nothing at all and will sit in recv until it times out.

There is no bug to fix. Walk the relay: A's 10 bytes went upstream (in order, once); the upstream parsed them and produced 19 bytes of reply; the relay received those 19 bytes on the shared socket and had to decide where to send them. By then B had written, so self.last was B. Every individual step is correct. The composition is a cross-client data disclosure produced by a correct byte relay — which, if the values were session tokens instead of AAAAAAAAAAAA, is the entire class of bug.

The precise reason is worth stating plainly: the reply alpha=AAAAAAAAAAAA\n carries no information about whose request it answers. Not "insufficient information" — none. Request/response correlation is a protocol feature (HTTP/2 calls it a stream id, RFC 9113 §5), and a raw TCP reply does not have one.

6.3 The inert line: the routing policy cannot be fixed

The instinct on reading §6.2 is that self.last = conn is the bug and a smarter routing rule would fix it. It is not, and it would not. Here is the other defensible policy, same script, same everything:

========================================================================== 2b. no interleaving at all -- routing='broadcast' ========================================================================== client A asked for alpha, client B asked for beta. both requests arrived whole; nothing was interleaved: request 1 on upstream conn 1: b'GET alpha' request 2 on upstream conn 1: b'GET beta' what each client received back: client A got b'alpha=AAAAAAAAAAAA\nbeta=BBBBBBBBBBBB\n' client B got b'alpha=AAAAAAAAAAAA\nbeta=BBBBBBBBBBBB\n'

broadcast fixes A's starvation and makes the disclosure total: now both clients hold both answers. Under load it is no better — the swept numbers are in §6.8, and across 20 trials of 8 clients × 3 requests, last scored 1–4 right out of 24 and broadcast 0–6. The gap between them is noise.

There is no third policy, and that is the point of the paragraph rather than a throwaway. Any correct rule would have to map a reply to a request, and §5.3 shows the function has nothing to map with. This is a line that looks like a decision and carries none of the behaviour. Knowing which of your lines are inert is worth as much as knowing which are load-bearing; it is the difference between fixing a bug and rewriting a protocol.

6.4 What you fall back to if you refuse to parse

========================================================================== 3. the honest L4 proxy: one upstream conn per client conn ========================================================================== clients=8 requests=24 correct=24 wrong=0 upstream connections opened: 8 ratio: 8 client conns : 8 upstream conns = 1.0:1 Every answer is right, and nothing is multiplexed. This is what you fall back to if you refuse to parse.

24 of 24 correct, and a multiplexing ratio of exactly 1.0:1 — which is to say, none. This is PassthroughProxy, the relay socat writes: one upstream connection per client connection, bytes copied both ways, no protocol knowledge required and no protocol knowledge possible.

It is not a failure. It is the honest price of layer-4 generality, and it is what every TCP-mode load balancer does. What it cannot do is amortise anything: ten thousand clients means ten thousand upstream connections.

6.5 The pool, once the proxy parses

========================================================================== 4. the L7 pool: parse a request, borrow a connection, give it back ========================================================================== cap upstream requests wrong requests/conn ----- --------- -------- ------ ------------- 1 1 24 0 24.0 2 2 24 0 12.0 4 4 24 0 6.0 8 8 24 0 3.0 16 8 24 0 3.0 8 clients, 24 requests, every answer right at every cap. The pool never exceeds the number of requests actually in flight, so cap=16 opens 8 connections and buys nothing over cap=8.

8 clients × 3 requests = 24 requests, and the wrong column is zero on every row. The same 24 requests that the byte relay gets almost entirely wrong are answered correctly through a pool of one connection, because the pool knows where each reply ends.

The requests/conn column is just 24 divided by the connection count: 24/1 = 24.0, 24/2 = 12.0, 24/4 = 6.0, 24/8 = 3.0. And the last row is the interesting one — cap=16 opened 8 connections, not 16, so its ratio is 24/8 = 3.0, identical to cap=8.

That is not a safety net; it is what §5.4 makes inevitable. A pool only grows when a borrower finds nothing free, and at most 8 borrowers can be unsatisfied at once because there are only 8 clients each waiting for a reply before sending again. So:

upstream connections = min(pool_cap, requests in flight)

The multiplexing ratio is clients : min(cap, in-flight), and the cap is only a lever below the concurrency level. Above it, "tune your pool size" stops meaning anything — a second measured boundary, and a cheap one to check in production before you spend a week on pool tuning.

6.6 The load-bearing line, measured

The claim in §5.6 is that _read_reply's two branches are two different questions. Here they are, crossed with two upstream write patterns. Identical proxy, identical pool (cap=1), identical clients.

========================================================================== 5. the load-bearing line: where _read_reply decides a reply ended ========================================================================== Identical proxy, pool (cap=1) and clients. Two ways to decide a reply ended -- at the delimiter, or after 50ms of upstream quiet -- crossed with two upstream write patterns. release upstream write pattern correct wrong --------- -------------------------------- --------- ----- framed one write 8 0 framed pauses mid-reply 8 0 timeout one write 8 0 timeout pauses mid-reply 0 8 Read the last two rows against each other: 8 of 8 becomes 0 of 8 with nothing changed but whether the upstream paused. A timeout is a guess about the upstream's scheduling; a delimiter is a fact about the message.

8 of 8 → 0 of 8, and the only thing that changed between those two rows is whether the upstream emitted its reply in one sendall or paused in the middle. Nothing about the proxy, the pool, the cap or the clients moved.

The arithmetic: the reply alpha=AAAAAAAAAAAA\n is 19 bytes, and the split upstream sends 6 of them (alpha=), waits 120ms, then sends the remaining 13. The borrower's release timeout is 50ms. 50 < 120, so the borrower gives the connection back holding alpha= and no newline — a checkin asserting an exchange had ended when 13 bytes of it were still in the future. The next borrower sends its request onto that connection and reads whatever comes next, which is the tail of somebody else's answer.

Note what the framed row shows: the delimiter reader is completely unaffected by the pause. It is not more robust, it is asking a question the pause cannot influence.

And note the trap in the top-right of the table. timeout + one write scores 8 of 8. A timeout-based pool passes its tests, passes staging, and passes production until the day the upstream gets slow — which is precisely the day your traffic is highest and your logs are least readable. The bug is correlated with load.

6.7 Reproducibility, and the hook that turned out to matter

A toy about concurrency has an obvious problem: the repo requires byte-identical transcripts, and threads do not provide them. What was done, concretely:

The hook that mattered. wait_routed is not padding — §6.1 was subtly raced without it, and looked stable. To see this yourself: delete the single line px.wait_routed(1) from interleave() in demo.py and run the scenario 40 times, collecting distinct transcripts. One such run:

wait_routed(1) present=True -> 1 distinct transcripts in 40 runs 40 x request 1 on upstream conn 1: b'GET alpGET beta' request 2 on upstream conn 1: b'ha' client A got b"ERR unknown key 'ha'\n" client B got b"ERR unknown key 'GET alpGET beta'\n" wait_routed(1) present=False -> 2 distinct transcripts in 40 runs 38 x request 1 on upstream conn 1: b'GET alpGET beta' request 2 on upstream conn 1: b'ha' client A got b"ERR unknown key 'ha'\n" client B got b"ERR unknown key 'GET alpGET beta'\n" 2 x request 1 on upstream conn 1: b'GET alpGET beta' request 2 on upstream conn 1: b'ha' client A got b"ERR unknown key 'GET alpGET beta'\nERR unknown key 'ha'\n" client B got b''

Without the line, the same scenario produces two different transcripts: sometimes A gets both errors and B gets nothing, instead of one each. How often is itself a race — two runs of this check gave 2 in 40 and 8 in 40 — while the requests the upstream parsed stay identical either way, which is exactly why the flaw is invisible on a casual re-run. With the line, both runs of the check gave 40 identical transcripts out of 40.

That is the worst kind of wrong to ship: stable enough to believe on the first three runs, and false often enough to burn whoever checks it later.

The full demo, with every hook in place: 10 consecutive runs byte-identical, 5 079 bytes, SHA-256 ea5bd807bd7fd3b40e207364928fabf6b5f75a8190905c477aa5ac7e860d70b7.

6.8 The boundary — where the effect vanishes

Here is the same protocol-blind relay from §6.1 and §6.2. Same class, same routing policy, same single shared upstream connection. The only change is that no two requests are ever in flight at once:

========================================================================== 6. the boundary: the same byte-shuffler, one request in flight ========================================================================== clients=8 requests=24 correct=24 wrong=0 upstream connections opened: 1 ratio: 8 client conns : 1 upstream conn = 8.0:1 Same protocol-blind relay as sections 1 and 2, and every answer is right. The only thing that changed is that no two requests are ever in flight at once. That mechanism is serialisation.

24 of 24 correct, 8 client connections onto 1 upstream connection, a ratio of 8/1 = 8.0:1 — from a proxy that has never heard of a protocol. The backlog entry's aha is true, exactly here.

And it is worth nothing here, which is the sharpest form of the result. With one request in flight, "whoever wrote last" is not a guess: there is only one candidate, so the routing rule is trivially correct. The thing being admired is not multiplexing. It is serialisation — the same amortisation you would get from a mutex around a single connection, with the same throughput ceiling, and none of the concurrency that made you want a proxy.

Is it a slope or a cliff? Run python3 demo.py --race, which re-asks the question with no gate and no scripting at all. These numbers are a distribution and are not reproducible, which is why they are behind a flag and quarantined here rather than in the demo proper — one run:

========================================================================== RACE A. wrong answers vs requests in flight -- NOT reproducible ========================================================================== Naive shared-upstream relay, 3 requests per client, 20 trials each, no gate and no scripting. The column is a range. clients requests wrong (min-max) mean wrong 1 3 0-0 0.0 2 6 2-4 2.6 3 9 3-8 5.5 4 12 6-12 9.7 8 24 19-24 21.9 Perfect on every trial at one client. The effect appears the moment a second request can be in flight: a cliff, not a slope.

A cliff. One client: 0 wrong out of 3, on 20 trials out of 20. Two clients: 2 to 4 wrong out of 6 — between a third and two thirds of all answers, on the very first step. There is no gentle degradation to monitor your way through and no traffic level below which you are "mostly fine": you are perfect at concurrency 1 and broken at concurrency 2.

The same run also confirms that the parsing pool does not depend on the demo's gate for its correctness — only for the reproducibility of its connection count:

========================================================================== RACE C. the framed pool with no gate at all -- NOT reproducible ========================================================================== cap conns seen wrong trials 1 1-1 0 20 2 2-2 0 20 8 7-8 0 20 The connection count varies with the race; the correctness does not. Section 4's gate exists to pin the left column only.

60 trials, 24 requests each — 1 440 requests, zero wrong answers, with nothing scripted. The conns seen column is exactly the thing the gate exists to pin: at cap=8 the pool opened 7 or 8 connections depending on how the race fell, because "requests in flight" is itself a raced quantity.

So, to place your own system on this: if it has one request outstanding per upstream connection at a time, a byte relay is safe and you are getting serialisation, not multiplexing. The moment two requests can be outstanding on one connection, you need a protocol that can tell them apart — and if you have that, you did not need the byte relay.


7. Design decisions and roads not taken

The backlog entry's aha was replaced, because the prototype disproved it. The entry promised "watch it multiplex many client connections onto few upstream connections". Built literally, it does not multiplex; it misdelivers. Rather than quietly build something else, the toy keeps the literal implementation (NaiveProxy) as the evidence, and the replacement aha is proved by the same program: the pool's checkout boundary is a parse. That is a better result than the one asked for, and it is only available because the first one was run.

A newline-delimited toy protocol, not HTTP. HTTP would make the toy realistic and the transcript illegible — the framing would be buried in headers and the reader would spend their attention on Content-Length rules that another toy already covers. GET key\nkey=value\n puts the delimiter on screen where the argument needs it.

Threads, not selectors or asyncio. An event loop is the right way to write a proxy and the wrong way to teach this one: it would roughly double the line count and spend all of it teaching readiness notification, which is a different mechanism. Threads also make the determinism hooks ordinary condition variables rather than loop internals.

Three proxies in one file, rather than one proxy plus prose. The comparison is the toy — the same call rate-limiter makes carrying two algorithms and lru-cache makes carrying two policies. A single correct pool would be about ninety lines and would teach the thing everyone already believes.

release="timeout" ships as a real code path, not a described one. It would have been cheaper to write "you could release on an idle timeout, but that would break". Half of such claims are wrong. This one is a branch you can run, and §6.6 is its output.

The gate is scaffolding and is admitted as such. Upstream(gate=…) exists only so §6.5's connection counts are pinned. --race shows the ungated numbers, so a reader can see exactly what the scaffolding bought and what it did not change.

Rejected as the headline: head-of-line blocking. _checkout's blocking self.free.get() is real and important, and a reader predicts its shape — ⌈requests/cap⌉ rounds of upstream latency — correctly before running it, which fails the aha test. It is also a wall-clock measurement, so it could not live in a byte-identical demo without breaking the one property §6.7 is built to protect. It is named in §3 and deliberately left unmeasured rather than asserted: no number appears on this page for it, because none was taken.

PassthroughProxy was nearly cut. It costs 56 lines, it takes the file further over the repo's ceiling, and it teaches a second mechanism. It ships anyway — see the appendix for what it buys and what that cost.


8. What's simplified vs. the real thing


9. Check yourself

Question 1

A byte-relay proxy shares one upstream connection between two clients. Both send complete, well-formed requests, and nothing is interleaved on the wire. What does client B receive?

Answer

Client A's answer. That is §6.2: A asked for alpha, B asked for beta, and B received alpha=AAAAAAAAAAAA\nbeta=BBBBBBBBBBBB\n while A received b''. Every byte was forwarded in order exactly once, so the relay is not buggy — the reply simply carries no information about whose request it answers, and the relay's only available rule ("whoever wrote last") points at B.

Question 2

Would routing replies to the client that has been waiting longest fix it?

Answer

No, and neither does anything else you can write in that function. §5.3 shows _pump_upstream receives nothing but bytes: no request id, no correlation token, and — because the upstream connection is shared — not even a socket identity to distinguish senders. §6.3 sweeps both defensible policies: broadcast gives everyone both answers, and under load (§6.8's swept run) last scored 1–4 of 24 and broadcast 0–6 of 24, a difference that is noise.

The information needed to route was never on the wire. This is a protocol problem being mistaken for a routing problem.

Question 3

You set pool_cap = 16 with 8 clients, one request outstanding each. How many upstream connections open, and why?

Answer

8. _checkout (§5.4) tries self.free.get_nowait() first and only opens a connection when nothing is free, so the pool grows to the number of borrowers that were simultaneously unsatisfied — the concurrency level. With 8 clients each waiting for a reply before sending again, that is 8. §6.5's cap=16 row reads 8, with the same 3.0 requests/conn as cap=8.

Generally: upstream connections = min(pool_cap, requests in flight). The cap is only a lever below the concurrency level.

Question 4

Your pool releases a connection when the upstream has been quiet for 50ms, because you did not want to parse the protocol. Your tests pass. What breaks it, and when will you find out?

Answer

Any upstream that pauses mid-response for longer than 50ms — a GC pause, a slow disk read, a large response written in several sends. §6.6: the same pool scores 8 of 8 when the upstream replies in one write and 0 of 8 when it pauses 120ms in the middle of a 19-byte reply. The borrower checks the connection back in holding alpha= and no newline, and the next borrower reads the remaining 13 bytes as its own answer.

You will find out under load, because upstream pauses correlate with load. The timeout is a guess about the upstream's scheduler; the delimiter is a fact about the message.

Question 5

The same byte-relay proxy scores 24/24 with an 8:1 connection ratio. What changed?

Answer

Concurrency dropped to 1. §6.8 runs the identical NaiveProxy with 8 clients and 24 requests, one in flight at a time: 24 correct, 1 upstream connection, 8.0:1. With one outstanding request, "route to whoever wrote last" has exactly one candidate and is trivially right.

That mechanism is serialisation, not multiplexing — the same amortisation a mutex around a single connection would give you, with the same throughput ceiling. And it is a cliff, not a slope: the swept run in §6.8 shows 0 of 3 wrong at one client and 2–4 of 6 wrong at two.

Question 6

Why does _read_reply return leftover bytes alongside the reply, and what breaks if it returns only the reply?

Answer

Because a recv can pull in the end of this reply and the start of the next one in a single call. The framed branch splits at the first \n and returns the remainder as rest, which handle stores with the socket in the free queue (self.free.put((sock, rest)), line 304) so the next borrower starts from it.

Drop it and the pool silently discards whatever had been read ahead — producing exactly the §6.6 corruption, but only at the moments the connection is busiest, which is the hardest possible version to reproduce.

Question 7

Reaching past the toy. You run four replicas of a service, each with a connection pool of 25 to one Postgres configured for max_connections = 100. Traffic doubles and you raise the pool to 50. What have you actually done?

Answer

You have raised the fleet's demand to 4 × 50 = 200 against a limit of 100, so half your checkouts will fail at the database rather than queue at the pool — turning a latency problem into an error. Pool size is a fleet property, and the toy cannot show you that (§8: one process, in-memory state).

And per §6.5, the raise may buy nothing even if it fits: connections opened is min(pool_cap, requests in flight), so if each replica only ever has 20 requests outstanding, 25 and 50 are the same pool. Measure in-flight concurrency before you touch the cap — it is the quantity that binds.


10. Further reading


Appendix — a second mechanism, and the line budget it broke

This is not part of the argument above. The main thread of this page is one mechanism: a pool's checkout boundary is a parse. This appendix is a separate result that fell out of PassthroughProxy, kept because it is worth keeping and flagged because it is a second mechanism in a repo whose whole rule is one mechanism per toy.

The half-close

PassthroughProxy relays bytes in both directions with a thread per direction. When one direction sees EOF, it has to tell the other side. There are two plausible calls:

tcp_proxy.py · lines 366–390
    def _relay(self, src, dst):
        while True:
            try:
                data = src.recv(4096)
            except OSError:
                break
            if not data:
                break
            try:
                dst.sendall(data)
            except OSError:
                break
        if self.half_close:
            try:
                dst.shutdown(socket.SHUT_WR)   # "no more from this side"
            except OSError:
                pass
        else:
            try:
                dst.close()                    # ...and the reply dies with it
            except OSError:
                pass
        with self.cv:
            self.eofs += 1
            self.cv.notify_all()

A client that sends its last request and then calls shutdown(SHUT_WR) is doing an ordinary, legal thing: "I have no more to say, I am still listening." Here is what each of the two calls does to that client, with the upstream gated so it is still holding the reply when the FIN arrives:

========================================================================== APPENDIX. bonus mechanism: what the 1:1 relay does with a FIN ========================================================================== on client EOF the relay calls dst.close() client got b'' on client EOF the relay calls dst.shutdown(SHUT_WR) client got b'alpha=AAAAAAAAAAAA\n'

One call, and the entire response. close() tears down the whole upstream connection, so the reply the upstream was about to write has nowhere to go and the client gets b''. shutdown(SHUT_WR) closes only the write direction, passing the client's "I am done asking" through as a FIN while leaving the reply path open — and all 19 bytes arrive.

TCP connections are two independent half-duplex streams (RFC 9293 §3.6.1) and close() is the operation that forgets that. The mistake is easy to make and essentially invisible in testing, because it only shows up against a client that half-closes and an upstream that had not replied yet.

The line budget

tcp_proxy.py is 433 lines, or 357 excluding blanks and comments. The repo's ground rules say:

Aim at ~150 lines for the core implementation and treat 300 as the hard ceiling … a toy that wants more than 300 is two toys.

This file is over that, by a lot, and the rule is right about why. Where the lines went — these are exact spans, and they sum to 433 with the 12 blank separator lines between them:

regionlinescountwhy it is here
docstring, imports, VALUES, free_port1–4646
Upstream49–151103a real origin server with a framing loop of its own — the toy needs something that can be correctly wrong
_Listener154–18128one accept loop instead of three
NaiveProxy184–24966the evidence for §6.1–§6.3: the backlog entry implemented literally
FramedProxy252–33786the mechanism the page is actually about
PassthroughProxy340–39556this appendix, plus the honest 1:1 baseline in §6.4
client helpers398–43336

Cutting across that table, about 60 lines exist only so the page can be re-run: the five wait_* methods and their banners, the counters and notify_all() blocks that feed them, the gate, and the reply_split/reply_delay knobs. That is the price of putting a concurrent toy in a repo that demands byte-identical transcripts, and it is worth paying — §6.7 shows one missing hook quietly producing two different transcripts from the same scenario.

PassthroughProxy's 56 lines are the genuine luxury. They buy §6.4's 1:1 baseline and this appendix, and the appendix is a second mechanism.

The rule's own advice would be to split: a tcp-proxy toy about the pool boundary, and a separate toy about half-close and connection lifetime. That is probably still the right call, and this page notes it rather than pretending 433 and 300 are compatible numbers. (test_the_toy_is_over_the_repos_line_ceiling pins both figures, so this admission cannot go stale without a test failing.)