# SSL EOF through real proxy software: loopback lab

This download backs the counts that the guide at https://ipvolt.com/guides/fix-ssl-unexpected-eof-proxy cites from
named open-source proxy software. The guide's other download, `ssl-eof-lab.zip`, drives a scripted fixture. A fixture
can only show what a client does with a behaviour someone coded, so this lab asks what real programs do: what a proxy
sends when its target is dead, and what Requests, HTTPX and curl print and re-send when a target or the proxy process
goes away at a chosen moment.

**What this does not show:**

- None of this shows what a commercial proxy service runs or does. Everything ran on one host, on loopback, against
  local targets, with dummy credentials and throwaway certificates.
- The counts are per-call results of what the harness provoked, not rates. They say nothing about how often anything
  happens on a real path.
- The origin speaks HTTP/1.1 only, so nothing here covers HTTP/2 or HTTP/3.

The record ran on one Ubuntu 26.04.1 host (kernel 7.0.0-42, x86_64) as an unprivileged user, on 4 November 2026 between
18:57 and 21:55 UTC. Everything bound 127.0.0.1 on ports 28970 to 28979. Credentials were dummies and certificates were
created at run time. In all: 11,816 client calls and 382 raw socket probes across 23 configurations, 22 with a proxy and
one with no proxy. One question ran again in two reruns on 5 November on the same host and software, and those calls are
not in these totals. See "The helper revisions" below.

## Files

| File | What it is |
| --- | --- |
| `real-proxy-README.md` | this file |
| `real-proxy-facts.json` | the figures the guide can cite, with a version map and run date |
| `real-proxy-tables.md` | compact tables per failure and client |
| `results/results.json` | every cell of the record, and every raw probe |
| `results/qnarrow-rerun-2026-11-05/` | the first rerun of 5 November: cells for the record's helper, `dda0794c` (`record-helper.json`, `other-left-out-record-helper.json`, `repeat-no-proxy-record-helper.json`), and for `aa843f4a`, the helper at that time (`current-helper.json`, `other-left-out-current-helper.json`, `repeat-no-proxy-current-helper.json`), and `run.json` with versions, hashes, commands and the comparison. In this folder, `current` means `aa843f4a` |
| `results/qnarrow-rerun-2-2026-11-05/` | the second rerun of 5 November: cells for the current helper, `1194ca7c` (`helper-1194ca7c.json`, `other-left-out-helper-1194ca7c.json`, `repeat-no-proxy-helper-1194ca7c.json`), and `run.json` with versions, hashes, commands and the comparison |
| `scripts/lab.py` | the harness: starts one proxy, runs each client call, writes one JSON line per call |
| `scripts/origin.py` | the loopback TLS origin that counts what arrives. Standard library only |
| `scripts/retry_handshake_eof.py` | the current retry helper, the same file the guide publishes. The harness loads this path |
| `scripts/retry_handshake_eof_record-2026-11-04.py` | the oldest helper, `dda0794c`, behind the 4 November `qnarrow` counts, kept for comparison. Not for your own code |
| `scripts/retry_handshake_eof_first-rerun-2026-11-05.py` | the helper the first rerun measured, `aa843f4a`, kept for comparison. Not for your own code |
| `scripts/lab_other_left_out.py` | adds the reruns' extra cell, the helper with `other` left out, without changing `lab.py` |
| `scripts/mkconfigs.sh` | generates each proxy's config from the packaged default, and the exact diffs |
| `scripts/mkcerts.sh` | creates a throwaway CA and leaf certificate for the origin |
| `scripts/run_all.sh`, `scripts/manifest.sh` | run a plan one proxy at a time; record versions and hashes |
| `scripts/plan-main.txt`, `plan-second.txt`, `plan-third.txt` | the three runs behind the record |
| `scripts/summarize.py`, `summary_tables.py`, `facts.py` | turn raw rows into `results.json`, the tables and the facts |
| `scripts/plan-check.txt`, `compare_to_record.py`, `software.sha256` | written for this package: a shorter plan, a cell-by-cell comparison, and the download checksums. None of them produced a recorded figure |

The raw per-call logs are not in this package. `results.json` holds them aggregated into cells, and a rerun writes your
own.

The files in `scripts/` are the ones behind the record, at their last version, with these changes only: the folder of
unpacked packages is now called `pkgroot`, `summarize.py` takes the lab directory from `LAB_DIR` or the current directory
instead of a fixed path, some comments, docstrings and manifest headings were reworded, and the helper that ran now has
`record-2026-11-04` in its file name. `origin.py`, `mkcerts.sh` and that helper are byte-identical to what ran. The three
runs used successive harness versions. Each later version only added: sing-box and Xray, the `qnarrow` and `qtlsclose`
questions and the origin switches they need, and a read-only record of where an exception was raised. The code of questions
that had already run did not change. `retry_handshake_eof.py`, `retry_handshake_eof_first-rerun-2026-11-05.py` and
`lab_other_left_out.py` were added on 5 November for the reruns below.

### The helper revisions

The `qnarrow` counts in this archive come from three revisions of the retry helper. All three are in `scripts/`.

| File | SHA-256 | Runs that used it |
| --- | --- | --- |
| `retry_handshake_eof_record-2026-11-04.py` | `dda0794c984e2a6e802e0dc0ed7a75865bc4a9f550f8c57e506f03a6e8754c3b` | the record and the first rerun |
| `retry_handshake_eof_first-rerun-2026-11-05.py` | `aa843f4a223b61ff423b62a844870c4cb2e423bc83604bb9dfda21ecfef395a8` | the first rerun |
| `retry_handshake_eof.py` | `1194ca7c10140161158dfcfccf7fd260c2eea6e7ec1e0b54c336049d15931c5a` | the second rerun |

The `qnarrow` cells in `results/results.json` and the `narrow.*` keys in `real-proxy-facts.json` measured `dda0794c`. That file and
`aa843f4a` are kept so you can see what ran. Don't copy either into your own code.

`scripts/retry_handshake_eof.py` is the current helper, the same file the guide publishes. The harness loads that path, so a rerun
now measures the current helper unless you copy another file over it. It differs from `dda0794c` in five ways:

- `other` defaults to 0, so leaving it out still stops the retry loop on an error that may have followed the request.
- It no longer follows `__context__`, so a call made inside an `except` block does not inherit an earlier failure.
- It also follows `reason` when it looks for the cause of an error.
- It also treats `ssl.SSLZeroReturnError` and `ssl.SSLSyscallError` raised inside `do_handshake()` as a connection that ended.
- An error that came up through urllib3's `request()` no longer counts as a handshake death. The request was being written then,
  and a body generator that opens HTTPS can fail in its own handshake there.

`aa843f4a` already had the first four. The fifth is the only change in behaviour from `aa843f4a` to the current file.

Both reruns ran on 5 November 2026 on the same host, with the same software and versions, on the ten configurations the record ran
`qnarrow` on. They used the same harness files and the same plan, the `qnarrow` lines of `scripts/plan-second.txt`. Each run had
100 cells and 262 calls. 90 of the cells run the helper, and 10 are the plain `Retry(total=3)` comparison.

The first rerun ran once with `dda0794c` and once with `aa843f4a`. Receipts, TCP connections at the target, completed handshakes and
HTTP statuses were the same in all 100 cells, in both runs and in the record. Last lines and urllib3 warnings were the same in 99
cells. The other cell is no proxy with a target that closes at once, which is the race in step 7 below. There the last line was a
reset in 3 of 3 calls with `dda0794c`, and the EOF string in the record and with `aa843f4a`. Repeating that case 20 times with each
helper gave a reset in 0 of 20 calls with `dda0794c` and 1 of 20 with `aa843f4a`, so each helper printed both lines. Receipts stayed
at 0 and TCP connections at 4.

The second rerun ran once, with the current helper. All 100 cells were identical to the record and to the `aa843f4a` run in every
field `scripts/compare_to_record.py` compares, last lines included. Against the `dda0794c` run 99 cells were identical. The other
cell is that same race, where the `dda0794c` run had printed the reset. Repeating that case 20 times with the current helper gave a
reset in 2 of 20 calls. Receipts stayed at 0 and TCP connections at 4.

Both reruns added one cell: `HandshakeEOFRetry(total=3)` with `other` left out, POST, and a target that reads the request and drops
it with FIN. Behind tinyproxy, Squid, gost, proxy.py, mitmproxy tunnel and mihomo HTTP, `dda0794c` gave 4 receipts in 18 of 18 calls.
`aa843f4a` and the current helper each gave 1 receipt in 18 of 18. Through Dante, with no proxy and through TLS-inspecting mitmproxy,
which answered 502, each of the three gave 1 receipt in 9 of 9 calls. Through the mihomo rejecting rule each gave 0 receipts in 1 of 1.

These counts do not exercise every change. No question here sends a body from a generator, which is the case the fifth change is for.
No question here makes a call inside an `except` block or calls `died_in_handshake()` directly. No call here printed
`SSLZeroReturnError` or `SSLSyscallError`.

The cells are in `results/qnarrow-rerun-2026-11-05/` and `results/qnarrow-rerun-2-2026-11-05/`, in the same shape as
`results/results.json`. `run.json` in each folder holds the versions, hashes, commands and comparison. To compare a run with the
record yourself, use `python3 scripts/compare_to_record.py results/results.json results/qnarrow-rerun-2-2026-11-05/helper-1194ca7c.json`,
or the same with `record-helper.json` (`dda0794c`) or `current-helper.json` (`aa843f4a`, current at the time) from the first folder.
`results/results.json`, `real-proxy-tables.md` and the figures in `real-proxy-facts.json` describe the 4 November record and did not
change.

## Software

| Program | Version | Where it came from | Check |
| --- | --- | --- | --- |
| Python, `ssl` | 3.14.4, OpenSSL 3.5.5 | Ubuntu 26.04 system Python, in venvs | |
| Requests, urllib3 | 2.34.2, 2.8.0 | PyPI | version pin only |
| HTTPX, httpcore, h11, socksio, PySocks | 0.28.1, 1.0.9, 0.16.0, 1.0.0, 1.7.1 | PyPI | version pin only |
| curl | 8.18.0, OpenSSL 3.5.5 | Ubuntu package 8.18.0-1ubuntu2.8, already on the host | |
| tinyproxy | 1.11.3 | Ubuntu `tinyproxy-bin` and `tinyproxy` 1.11.3-1, unpacked with `dpkg -x` | SHA-256 |
| Squid | 7.7, binary `squid-gnutls` | Ubuntu `squid` and `squid-common` 7.7-0ubuntu0.26.04.1, plus `squid-langpack` 20220130-1build1, `libecap3` 1.0.1-4build1, `libltdl7` 2.5.4-9, unpacked | SHA-256 |
| Dante | 1.4.4 | Ubuntu `dante-server` 1.4.4+dfsg-1build1, unpacked | SHA-256 |
| mitmproxy | 12.3.1, its own OpenSSL 4.0.3 | PyPI, own venv | version pin only |
| proxy.py | 2.4.10 | PyPI, own venv | version pin only |
| gost | 3.3.3 | GitHub release v3.3.3 | SHA-256 matched the release's `checksums.txt` |
| mihomo | 1.19.33, `compatible` build | GitHub release v1.19.33 | SHA-256 matched the asset digest in the GitHub API |
| sing-box | 1.14.4 | GitHub release v1.14.4 | SHA-256 matched the asset digest in the GitHub API |
| Xray | 26.10.31 | GitHub release v26.10.31 | SHA-256 matched the asset digest in the GitHub API and the release's `.dgst` |

`scripts/software.sha256` lists the SHA-256 of every downloaded package and archive as they were on the run date. The Python
packages were pinned by version, not by hash. The dependencies of mitmproxy and proxy.py were not pinned. mihomo, sing-box and
Xray are local proxy clients; they are here because a reader may run one, and the dead-target question depends on it.

## Configuration

Each program ran with its default settings, apart from binding to loopback, writable paths for an unprivileged run and,
where a program ships no default, a minimal config. The table lists every change, including the ones in bold, which are
test adaptations rather than binding or paths. `scripts/mkconfigs.sh` regenerates the files, writes out the minimal ones in
full and writes exact diffs against the packaged defaults to `raw/config-diffs/`. Command lines are in `proxy_defs()` in
`scripts/lab.py`.

| Software | Starting point | Every change |
| --- | --- | --- |
| tinyproxy | packaged `tinyproxy.conf` | Removed `User` and `Group`. `Port 28974`, added `Listen 127.0.0.1`. Moved `DefaultErrorFile`, `StatFile`, `LogFile` and `PidFile` into the lab directory. |
| Squid | packaged `squid.conf` | `http_port 127.0.0.1:28974`. **Added `acl SSL_ports port 28970-28979`, because the default refuses CONNECT to ports other than 443.** Writable `pid_filename`, `cache_log`, `access_log`, `coredump_dir`. Pointed `error_directory`, `icon_directory`, `mime_table`, `err_page_stylesheet`, `unlinkd_program`, `logfile_daemon` and the `conf.d` include at the unpacked package. `netdb_filename none`, `pinger_enable off`. Run with `-N` and `LD_LIBRARY_PATH` for the unpacked libraries. **Targets use `127.0.0.1`, because Squid's lookup of `localhost` chose the IPv6 loopback here and answered 503;** the raw `dead_hostname` probe still uses `localhost`. |
| Dante | packaged `danted.conf`, which defines no listener, method or rule | Removed `user.privileged`, `user.unprivileged`, `user.libwrap`. Added `internal: 127.0.0.1 port = 28975`, `external: 127.0.0.1`, `clientmethod: none`, `socksmethod: none`, and `client pass` and `socks pass` from 127.0.0.1/32 to any destination. |
| mitmproxy tunnel | no config file | `--listen-host 127.0.0.1 --listen-port 28974 --set confdir=...`. **`--ignore-hosts .*`, which makes it tunnel instead of inspect.** `connection_strategy` stays at its default, `eager`. |
| mitmproxy TLS-inspecting | no config file | The same without `--ignore-hosts`. **Plus `--set ssl_verify_upstream_trusted_ca=<lab CA>`, because mitmproxy otherwise refuses the lab origin.** The `mitm-inspect-pure-default` configuration leaves that out, so its control fails by design: it answers 502 for a target it cannot verify. |
| mitmproxy variants | | **`-lazy` adds `--set connection_strategy=lazy`.** `mitm-socks5` is `--mode socks5@28975 --ignore-hosts .*`. |
| gost | no config file | `-L http://127.0.0.1:28974` or `-L socks5://127.0.0.1:28975`. |
| proxy.py | no config file | `--hostname 127.0.0.1 --port 28974`. |
| mihomo | no packaged default, minimal file | Binds loopback, `mode: direct`, `ipv6: false`, `find-process-mode: off`, `external-controller` empty, `dns.enable: false`, `geo-auto-update: false`. **Variants: a rule of `MATCH,REJECT`, and every connection sent to a SOCKS5 node on the dead port.** |
| sing-box | no packaged default, minimal file | `mixed` inbound on 127.0.0.1:28974, `direct` outbound. |
| Xray | no packaged default, minimal file | `http` inbound on 28974, `socks` inbound on 28975 with `noauth`, `freedom` outbound. |

## The origin and the clients

`scripts/origin.py` is one Python process serving TLS 1.3 with ALPN `http/1.1` only. Clients are told to trust its
throwaway CA explicitly. The URL path or port picks the behaviour: answer 200 (`ok`); close at once without reading
(`hs-fin-now`); read the ClientHello, then FIN (`hs-fin-after-hello`) or RST (`hs-rst-after-hello`); read the ClientHello
and hold (`hs-hold`); read the whole request, then FIN with no close_notify (`drop-fin`), close_notify then FIN
(`drop-notify`) or RST (`drop-rst`); hold the request 2.5 s (`hold`); half a body with a length, chunked, or
close-delimited (`half-cl`, `half-chunked`, `half-close`); stop reading a 5 MB upload after the headers (`drop-midbody`).
Port 28978 is stateful (`seq-*`): armed for each call, it fails some connections in the handshake and delivers others.
Port 28979 has no listener and is the dead target.

Clients: Requests with connect timeout 5 s and read timeout 6 s, HTTPX with the same timeouts, curl with `--max-time 12`.
Environment proxy settings were ignored. Each call used a fresh session unless the question says otherwise. One proxy at a
time, one client call at a time. Requests settings: `default_session`, `HTTPAdapter(max_retries=3)`, `Retry(total=3)`,
`Retry(total=3, allowed_methods={"GET"})`, `Retry(total=3, other=0)`, and the same with `backoff_factor=1` where the
question needs a wait. HTTPX settings: `default_client`, `HTTPTransport(retries=3)`. curl: no retry flags (`single`),
`--retry 3`, `--retry 3 --retry-all-errors`, with `--retry-delay 1`, or 2 where the proxy comes back after a kill.

## What each question tested

The name is what `plan-*.txt` uses. The `q` value is what `results.json` uses.

| Name | `q` in the results | What it tested |
| --- | --- | --- |
| `raw` | `raw` | A socket script, not a client library, sends CONNECT or a SOCKS5 request and records what the proxy itself sent and how the stream ended. Dead target, closing targets, healthy control. |
| `control` | `control` | A healthy target through each path, GET and POST. It should return 200 with one receipt. |
| `q1q2` | `q1`, `q2`, `q1-curl-retry`, `q2-curl-retry` | Dead target. Target closes in the handshake, at once, after the ClientHello with FIN, or after the ClientHello with RST. |
| `q3q4` | `q3`, `q3-notify`, `q4`, `*-logged`, `q3-curl-retry`, `q4-curl-retry` | The target reads the whole request, then drops it with FIN, close_notify then FIN, or RST. POST and GET, all retry settings. The `-logged` pass also records how urllib3 classified each failure. |
| `q3seq` | `q3seq` | The first connection is delivered and dropped, and every retry then dies in the handshake, so a retrying client can end on the handshake error while the target holds the request. |
| `q3reuse`, `q3reusev` | `q3reuse`, `q3reusev` | A POST delivered and lost on a connection that has already carried a request. For curl, two URLs in one invocation with no retry flags; `-v` shows curl's own account. |
| `qupload` | `qupload` | A 5 MB POST where the origin reads the headers and stops, so the body never completes. |
| `qtlsclose` | `qtlsclose` | The target completes TLS, then closes without reading. No request can reach its HTTP layer. |
| `half` | `half` | Half a response body, then close. |
| `q5` | `q5`, `q5-handshake` | The harness sees the receipt at the origin and SIGKILLs the proxy before the response. The proxy stays dead (`stay_dead`) or is back after 0.3 s (`restart_0.3s`). `q5-handshake` kills it while the target holds the handshake. |
| `q5c` | `q5c` | The proxy is killed while its own connect to the target is still pending. |
| `q5s` | `q5s` | The tunnel is up, the proxy is frozen with SIGSTOP, the next POST is written, then the proxy is killed. |
| `q6` | `q6` | The proxy is restarted, SIGKILL or SIGTERM, between two requests on a kept-alive session. |
| `q7` | `q7a` | TLS-inspecting mitmproxy, and a client that does not trust its CA. |
| `q9` | `q9`, `q9-raw` | Wrong scheme: a TLS ClientHello at a plain proxy port, and CONNECT at a SOCKS port or SOCKS at an HTTP port. |
| `secrets` | `secrets`, `secrets-verbose` | Whether printed errors carry the proxy password. Dummy credentials, on proxies that do not ask for them. |
| `qnarrow` | `qnarrow` | The narrow retry subclass against each failure, including failing several times and then recovering. The record ran the oldest helper, `dda0794c`. See "The helper revisions". |
| `qotherleftout` | `qnarrow`, setting `narrow(total=3)` | Added on 5 November, in `scripts/lab_other_left_out.py`, and not in `results/results.json`. The helper with `other` left out, POST, and a target that reads the request and drops it with FIN. |
| `qrule` | `qrule` | A healthy target behind mihomo with a rule that rejects everything, or with every connection sent to an upstream node that is down. |

## How to read the counts

A **receipt** is a complete HTTP request, headers and body, read off TLS and counted at the origin by request id during
that call. 4 receipts means the origin received that one request 4 times. A cut upload counts as `partial`, never as a
receipt.

In `results/results.json`, `cells` has one entry per combination of `proxy` (the configuration), `q`, `target`, `method`,
`client`, `setting`, `variant`, `trust`, `wrong_scheme` and `restart_kind`:

- `n` is the number of calls. Value maps count calls: `"receipts": {"4": 5}` means 4 receipts in each of 5 calls.
- `partial` is requests whose headers arrived on a cut upload. `attempts_tcp` and `tls_ok` are the TCP connections and
  completed handshakes the origin saw per call.
- `statuses` and `body_bytes` are what the client got back. `exc_types` and `printed` are the exception and the client's
  last line. `(no exception)` means it returned normally.
- `curl` is the write-out: `connect` is the CONNECT status, `exit` the exit code, `appconnect=0` on a new connection means TLS never
  finished and `>0` that it did. curl also reports 0 on a reused connection, as in `q3reuse` through TLS-inspecting
  mitmproxy (`num_connects=0`, POST at the origin). `connect` is always 000 through SOCKS5.
- `warnings` are urllib3's WARNING lines, and `buckets` its classification from the `-logged` pass.
- `elapsed_min_max` is seconds. The host was shared and the harness ran at low CPU priority, so treat timings as rough.

Printed text was normalised and otherwise left exactly as printed: request id to `id=ID`, object address to `0xADDR`, newline
to ` // `, lab directory to `LAB`. `_ssl.c` line numbers belong to this build. `raw_probes` holds the socket probes, row by row.

In `real-proxy-tables.md` a number is the receipts in every call of that cell. `1x2/2x1` would mean 1 receipt in 2 calls and 2
receipts in 1 call. `n` is calls per cell. A `-` means not run.

In `real-proxy-facts.json` the `drop_receipts.*` keys add up the three drop behaviours (FIN, close_notify then FIN, RST).
`http_tunnel` is tinyproxy, Squid, gost, proxy.py, mitmproxy tunnel with both strategies, mihomo, sing-box and Xray over HTTP.
`socks` is Dante and the SOCKS5 modes of gost, mitmproxy, mihomo, sing-box and Xray. `inspecting` is TLS-inspecting mitmproxy
with both strategies. `direct` is no proxy. `three_retrying_settings` is `HTTPAdapter(max_retries=3)`, `Retry(total=3)` and
`Retry(total=3, allowed_methods={"GET"})`. `{"4": 351}` means 351 calls with 4 receipts each. `configuration_software` names
each configuration, and `coverage_calls` gives calls per question, which are uneven. `scripts/facts.py` defines every counted
figure.

## Check the tables and figures without a rerun

Unpack as in step 1 below. The tables regenerate exactly from the published cells:

```sh
python3 scripts/summary_tables.py results/results.json > tables-check.md
tail -n +4 real-proxy-tables.md | diff - tables-check.md && echo "tables match results.json"
```

Any figure that depends only on one field of a cell can be added up from the same file. This one prints `{'4': 351}` twice:

```sh
python3 - <<'EOF'
import collections, json
HTTP_TUNNEL = {"tinyproxy", "squid", "gost", "proxypy", "mitm-tunnel", "mitm-tunnel-lazy", "mihomo-http", "singbox-http", "xray-http"}
SETTINGS = {"HTTPAdapter(max_retries=3)", "Retry(total=3)", 'Retry(total=3, allowed_methods={"GET"})'}
total = collections.Counter()
for cell in json.load(open("results/results.json"))["cells"]:
    if cell["q"] in ("q3", "q3-notify", "q4") and cell["method"] == "POST" and cell["setting"] in SETTINGS and cell["proxy"] in HTTP_TUNNEL:
        total.update(cell["receipts"])
print(dict(total))
print(json.load(open("real-proxy-facts.json"))["drop_receipts.http_tunnel.three_retrying_settings.POST"])
EOF
```

Figures that join two fields of one call, such as `r=2 status=200`, need the per-call rows, so `scripts/facts.py` needs a full
rerun. `run_date`, `run_windows_utc`, `scope`, `versions` and `configuration_software` were added from the run manifests and
are not computed.

## Rerun it

This needs Ubuntu 26.04 on x86_64, the record's platform, with `python3` and its `venv` module, `curl`, `openssl`, `apt-get`, `dpkg`,
`ss` and `pkill`. No root and no install: packages are unpacked into the lab directory. It downloads about 120 MB and uses about
500 MB of disk. Everything binds 127.0.0.1, and proxies and the origin stop when each configuration ends. On another system the
`apt-get` step differs and the printed text can differ. The harness calls the system curl with `PATH=/usr/bin:/bin`. Run the steps
in one shell: step 1 leaves you in the lab directory, and the rest run from there.

**1. Put the zip in a new, empty directory, unpack it and check the ports are free.** A second copy of this lab, or anything
else on these ports, would disturb the counts.

```sh
python3 -m zipfile -e ssl-eof-real-proxy.zip ssl-eof-real-proxy
cd ssl-eof-real-proxy
ss -ltnH | awk '{print $4}' | grep -E ':2897[0-9]$' || echo "ports 28970-28979 are free"
```

**2. Download the Ubuntu packages and the release archives, then check every one.** Every line of the check must say `OK`.

```sh
mkdir -p debs pkgroot gost/dl mihomo/dl extra/singbox/dl extra/xray/dl
(cd debs && apt-get download tinyproxy-bin=1.11.3-1 tinyproxy=1.11.3-1 squid=7.7-0ubuntu0.26.04.1 squid-common=7.7-0ubuntu0.26.04.1 squid-langpack=20220130-1build1 libecap3=1.0.1-4build1 libltdl7=2.5.4-9 dante-server=1.4.4+dfsg-1build1)
curl -fsSL -o gost/dl/checksums.txt https://github.com/go-gost/gost/releases/download/v3.3.3/checksums.txt
curl -fsSL -o gost/dl/gost_3.3.3_linux_amd64.tar.gz https://github.com/go-gost/gost/releases/download/v3.3.3/gost_3.3.3_linux_amd64.tar.gz
curl -fsSL -o mihomo/dl/mihomo-linux-amd64-compatible-v1.19.33.gz https://github.com/MetaCubeX/mihomo/releases/download/v1.19.33/mihomo-linux-amd64-compatible-v1.19.33.gz
curl -fsSL -o extra/singbox/dl/sing-box-1.14.4-linux-amd64.tar.gz https://github.com/SagerNet/sing-box/releases/download/v1.14.4/sing-box-1.14.4-linux-amd64.tar.gz
curl -fsSL -o extra/xray/dl/Xray-linux-64.zip https://github.com/XTLS/Xray-core/releases/download/v26.10.31/Xray-linux-64.zip
curl -fsSL -o extra/xray/dl/Xray-linux-64.zip.dgst https://github.com/XTLS/Xray-core/releases/download/v26.10.31/Xray-linux-64.zip.dgst
sha256sum -c scripts/software.sha256
```

If your mirror no longer carries one of these package versions, fetch that exact `.deb` from the Ubuntu archive. The check
still applies.

**3. Optional: check the archives against what each project publishes.** The first line must say `OK`. Compare the rest with
`scripts/software.sha256` by eye.

```sh
(cd gost/dl && grep -F gost_3.3.3_linux_amd64.tar.gz checksums.txt | sha256sum -c -)
grep -i sha2-256 extra/xray/dl/Xray-linux-64.zip.dgst
digest() { curl -fsSL "https://api.github.com/repos/$1/releases/tags/$2" | python3 -c 'import json,sys; print(sys.argv[1], next(a["digest"] for a in json.load(sys.stdin)["assets"] if a["name"]==sys.argv[1]))' "$3"; }
digest MetaCubeX/mihomo v1.19.33 mihomo-linux-amd64-compatible-v1.19.33.gz
digest SagerNet/sing-box v1.14.4 sing-box-1.14.4-linux-amd64.tar.gz
digest XTLS/Xray-core v26.10.31 Xray-linux-64.zip
```

**4. Unpack the software and make the Python environments.**

```sh
for deb in debs/*.deb; do dpkg -x "$deb" pkgroot; done
tar -xzf gost/dl/gost_3.3.3_linux_amd64.tar.gz -C gost gost
gunzip -c mihomo/dl/mihomo-linux-amd64-compatible-v1.19.33.gz > mihomo/mihomo
chmod +x mihomo/mihomo
tar -xzf extra/singbox/dl/sing-box-1.14.4-linux-amd64.tar.gz -C extra/singbox --strip-components=1 sing-box-1.14.4-linux-amd64/sing-box
python3 -m zipfile -e extra/xray/dl/Xray-linux-64.zip extra/xray
chmod +x extra/xray/xray
python3 -m venv venv-client
venv-client/bin/pip install -q --disable-pip-version-check requests==2.34.2 urllib3==2.8.0 httpx==0.28.1 httpcore==1.0.9 h11==0.16.0 socksio==1.0.0 PySocks==1.7.1 anyio==4.16.0 certifi==2026.10.23 charset-normalizer==3.5.2 idna==3.20 typing_extensions==4.16.0
python3 -m venv venv-mitm
venv-mitm/bin/pip install -q --disable-pip-version-check mitmproxy==12.3.1
python3 -m venv venv-proxypy
venv-proxypy/bin/pip install -q --disable-pip-version-check proxy.py==2.4.10
```

**5. Create the throwaway certificates and the configs.**

```sh
bash scripts/mkcerts.sh "$PWD/tls"
bash scripts/mkconfigs.sh
```

**6. Run the check plan, detached so a dropped connection does not stop it.** It takes about 25 minutes. It runs tinyproxy, Squid,
gost, Dante and no proxy with every question and repetition the record has for them, then a control pass on proxy.py, mitmproxy,
mihomo, sing-box and Xray.

```sh
setsid nohup timeout -k 30 7200 bash scripts/run_all.sh check scripts/plan-check.txt > /dev/null 2>&1 < /dev/null &
```

Read `raw/check/progress.log` to follow it. It has finished when `raw/check/DONE` exists. To stop early, create `raw/check/STOP`.
It then ends after the current proxy. `raw/check/MANIFEST.txt` records the versions and hashes of your run.

**7. Compare your run with the record.** Run this from the lab directory.

```sh
python3 scripts/summarize.py results-check raw/check
python3 scripts/compare_to_record.py results/results.json results-check/results.json --out results-check/comparison.json --tsv results-check/comparison.tsv
```

`scripts/compare_to_record.py` compares every field of every cell except timings, and classes each cell `identical`,
`same_values` (same values, different split), `within` (every value of yours is recorded, not all recorded ones appeared),
`new_value` or `not_recorded`. It lists the cells worth reading and exits 1 if there are any. Your `qnarrow` cells come from the
current helper and the record's from `dda0794c`. See "The helper revisions".

Receipts are the stable part: only three cells in the record have more than one receipts value, all during proxy.py restarts.
Printed text is not, wherever two things race, and a side of a race the record did not see in that cell comes out as `new_value`.
Expect that on a target that closes at once (with no proxy a client can read a reset instead of the EOF, and curl's line was
`Send failure: Broken pipe` in 26 of 80 calls and `0A000126` in the other 54), on the cut upload, and on a target that completes
TLS and then closes. The figures `upload.*`, `tlsclose.*`, `curl.hs_fin_now.printed` and the text-based splits in `stage.*` move
between runs for the same reason. In one check rerun of five configurations, `upload.final_line_post_handshake_string` went from
35 to 27 and Dante's share from 14 of 160 to 5 of 160, with 0 complete receipts in every cut upload. Other builds of Python,
OpenSSL or curl will print different text.

**8. Check nothing is left running, then delete the directory.** That also removes the throwaway keys.

```sh
ss -ltnH | awk '{print $4}' | grep -E ':2897[0-9]$' || echo "nothing is listening on 28970-28979"
pgrep -af "$PWD/" || echo "no process from this directory"
cd .. && rm -rf ssl-eof-real-proxy
```

### The whole record

The record is three runs of the same driver, about 1 hour 50 minutes in all. In the second run `xray-http` and `xray-socks5`
stop at their time limits (exit 124) because Xray holds connections about 1 s on close. The third run finishes what they did not
reach, which is why their `n` differs from the others. To run the helper the record ran, first copy
`scripts/retry_handshake_eof_record-2026-11-04.py` over `scripts/retry_handshake_eof.py`.

```sh
timeout -k 30 7200 bash scripts/run_all.sh main scripts/plan-main.txt
timeout -k 30 7200 bash scripts/run_all.sh second scripts/plan-second.txt
timeout -k 30 7200 bash scripts/run_all.sh third scripts/plan-third.txt
python3 scripts/summarize.py results-rerun raw/main raw/second raw/third
python3 scripts/facts.py > results-rerun/facts.json
python3 scripts/summary_tables.py results-rerun/results.json > results-rerun/tables.md
```

To run one question, use the harness directly: `venv-client/bin/python -I scripts/lab.py --proxy gost --run mine --questions q3q4:1`.

### The 5 November reruns

After step 5, this runs `qnarrow` with the record's helper and with the current one, and leaves the current helper in place. It takes
about 13 minutes, so keep the shell open.

```sh
grep qnarrow scripts/plan-second.txt > plan-qnarrow.txt
cp scripts/retry_handshake_eof.py current-helper.py
cp scripts/retry_handshake_eof_record-2026-11-04.py scripts/retry_handshake_eof.py
timeout -k 30 1800 bash scripts/run_all.sh qnarrow-record plan-qnarrow.txt
cp current-helper.py scripts/retry_handshake_eof.py
timeout -k 30 1800 bash scripts/run_all.sh qnarrow-current plan-qnarrow.txt
for run in qnarrow-record qnarrow-current; do
  python3 scripts/summarize.py "results-$run" "raw/$run"
  python3 scripts/compare_to_record.py results/results.json "results-$run/results.json"
done
```

For the extra cell, run `venv-client/bin/python -I scripts/lab_other_left_out.py --proxy gost --run other-left-out --questions qotherleftout:3`.
It measures whichever helper is at `scripts/retry_handshake_eof.py`. To run `aa843f4a`, copy
`scripts/retry_handshake_eof_first-rerun-2026-11-05.py` there first and use a new run name. Afterwards, copy `current-helper.py`,
which the block above saved, back over it.

## Limits

- Nothing here describes a commercial proxy service, a real network path or a device. See the top of this file.
- A path that goes silent with no FIN or RST was not tested. It needs a network namespace or firewall rules, which were not
  available, and on loopback a killed process always sends FIN or RST.
- HTTP/2 and HTTP/3 origins were not tested.
- LibreSSL and other OpenSSL builds, aiohttp, pip, Node, browsers, macOS and Windows were not tested here. `_ssl.c:1081` and
  `_ssl.c:2711` belong to this build. libcurl programs that reuse connections were not run, only the curl tool.
- Proxies that require a login were not tested, so there is no 407 path. The dummy credentials were ignored.
- TLS listeners on proxies were not tested, except that mitmproxy answers TLS on its plain port by itself. Squid `ssl-bump` was
  not tested, so mitmproxy is the only TLS-inspecting software here.
- curl exit 52 with zero receipts was not tested. The origin sends close_notify only after reading the request, so exit 52
  appears only after delivery here (128 of 128 calls). That does not show exit 52 proves delivery.
- There was never more than one client at a time, and no load.
- Counts of printed text that depends on a race move between runs. See step 7 above.
- Coverage is uneven, with `n` in each table. The full matrix ran on tinyproxy, Squid, gost HTTP, proxy.py, mitmproxy tunnel and
  inspecting, Dante and mihomo HTTP. sing-box and Xray had no restart-between-requests, frozen-tunnel, pending-connect,
  cut-upload or TLS-then-close runs. The narrow retry ran on eight paths plus TLS-inspecting mitmproxy and the mihomo rejecting
  rule, not on sing-box, Xray or the other SOCKS5 inbounds.
- HTTPX with a `socks5h://` URL at a port that does not answer the SOCKS greeting returned nothing inside the 9 s limit in 8 of 8
  calls, so the harness abandons such a call and records it as no return.
- Not investigated: why proxy.py sometimes refused or reset a connection around its restart (old and new processes overlapping is
  a guess), and why TLS-inspecting mitmproxy's handshake hangs until the client's timeout when the upstream closes at once.
- Timings come from a shared host at low CPU priority.
