Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions scripts/browser-connection-fixture/DIAGNOSTIC-2026-10-09.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Bounded Chrome H3 warm-reconnection observation

This is a local diagnostic, not a passive fingerprint comparison or a formal
corpus. Chrome successfully resumed TLS on a new physical H3 connection in
three admitted trials after one successful readiness check. An earlier
readiness attempt failed in the collection script and remains a separate failed
experiment; it was not replaced inside the successful experiment.

## Setup and frozen procedure

The 2026-10-09 run used macOS/arm64, Chrome **154.0.8037.95**, Playwright CLI
**0.1.22**, and Playwright **1.64.0-alpha-1790635538000**. The observer was built
with Go **1.27.2** from base commit
`932e56ca1210d264c682dacf35538a576915d74c` plus this fixture change. It uses the
official `github.com/quic-go/quic-go` **v0.63.0** HTTP/3 server, not the AutoCAR
web-H3 client or its maintained dependency replacement.

One fixture instance served the entire successful experiment, with the same
lab certificate and TLS ticket keys. It listened only on numeric IPv4 loopback,
had a 540-second maximum lifetime, 64-request/32-admitted-connection budgets and the
unchanged 30-second idle limits. It had no TCP listener. The certificate had a
loopback SAN and a one-day validity; no system trust store was changed.

Each readiness/trial used a separately named, nonpersistent headless CLI
session. Within each trial the same process/context performed all three phases.
The four session startup PIDs were distinct and each session was closed. Raw
temporary profile paths were redacted, so the retained command lines do not
independently establish profile-path uniqueness.

Explicit lab flags were `--enable-quic`, one exact
`--origin-to-force-quic-on=127.0.0.1:<port>`, one exact
`--ignore-certificate-errors-spki-list=<lab-pin>`, `--no-proxy-server`, and
`--enable-automation`. Context `ignoreHTTPSErrors` remained false. Chrome's
headless/automation defaults, including its disable-feature flags, were retained
in sanitized runtime metadata. This is not a normal unmodified browser launch.

Before the first target navigation in each session, the collector saved
`Browser.getVersion` and a successful `Browser.getBrowserCommandLine`. It
required the expected product, headless mode and lab switches, rejected global
certificate bypasses/conflicting QUIC or proxy switches, and required exactly
one origin override and SPKI switch.

The successful experiment froze its plan, browser configuration and collector
at **10:22:26 UTC**, before any target navigation. Its fixed sequence was:

1. Navigate `/` once: HTTP 200, navigation Resource Timing `h3`, positive
connection ID, and server TLS 1.3 complete with `did_resume: false`.
2. Fetch a unique `/probe?trial=<label>_reuse` with `credentials: "same-origin"`
and `cache: "no-store"`: HTTP 200/H3, the same physical ID and cold TLS state.
3. Send no target request while waiting up to 50 seconds for that ID's actual
`connection_closed` record. A fixed sleep alone would not pass this gate.
4. Fetch `<label>_warm` once in the same context: HTTP 200/H3, a different
physical ID, and complete TLS 1.3 with `did_resume: true` in both the probe
response and matching server request record.

No sample was retried or replaced in this successful experiment. Full server
logs were retained, including asynchronous closure events. Records must be
joined by physical ID: a previous session's final close can appear in the next
trial's log slice.

## Results and initial collection failure

| Experiment / sample | Cold / reuse / warm IDs | Server TLS resumed | Result |
| --- | --- | --- | --- |
| Initial readiness | 1 / 1 / not attempted | false / false / unknown | Collection failed |
| Revised readiness | 1 / 1 / 2 | false / false / true | Passed |
| Revised trial 1 | 3 / 3 / 4 | false / false / true | Passed |
| Revised trial 2 | 5 / 5 / 6 | false / false / true | Passed |
| Revised trial 3 | 7 / 7 / 8 | false / false / true | Passed |

The initial experiment `h3-warm-20261009` failed at 10:20:38 UTC because its
in-page Fetch collector called `response.status()` instead of reading
`response.status`. Its cold navigation succeeded and the server observed reuse,
but the complete reuse measurement and warm phase were not collected. **Zero
formal trials ran.** Its outputs and frozen inputs were left intact.

The separate `h3-warm-20261009-r2` amendment corrected that property access,
strengthened duplicate-flag and request-accounting checks, and used a fresh
fixture and named sessions without changing browser flags. It ran from
10:22:26 to 10:24:50 UTC. All twelve target requests returned 200/H3. Final
server accounting was one ready event, twelve requests and eight unique closure
events, with no extra target requests. Waiting after reuse took approximately
29.1–29.2 seconds; the remaining interval includes collection overhead. The
close event does not identify the initiator, so this is **observed physical
closure**, not proof of server-initiated idle expiry.

The stopped fixture had no remaining admitted connections. All named browser
sessions closed successfully. No remote host, Docker browser or packet capture
was used for this experiment.

## Provenance and independent functional checks

Local evidence is retained separately in
`output/playwright/h3-warm-20261009/` and
`output/playwright/h3-warm-20261009-r2/`; it is not a public downloadable corpus.
Plans, collectors, command receipts, snapshots, runtime metadata, server logs,
failures and final accounting are retained. Post-run checks confirmed unchanged
frozen inputs, observer source/binary and Chrome launcher/framework hashes.

Selected SHA-256 identities:

| File | SHA-256 |
| --- | --- |
| Observer `main.go` | `84767c3ba1e08389fb9e7d16a97e1da135da9d961f5c2faa2b95e400cbdb6b46` |
| Observer binary | `1adc1f834dbe4a0c516055dceb023c04e759c3356391d59d745f5ce42c10d4e6` |
| Revised frozen plan | `0ada62ac941532a01898d4d9403ea6ce4871d40d0010ccd568085792c33b3af0` |
| Revised collector | `068a2168b4cd3b14f893fcd667dc3dfaf49d653ba8fa01002f7875f32bbe120f` |
| Revised `results.json` | `80cf36ad39e3dd2a39060d6d4b547297c9f08ddfadb8ecbf39aac3687befc114` |
| Revised final server log | `e4bd61741ed6d5a83b986a8618d0381266818ce23b0f88b004d3e3dfd92e2592` |

The fixture's real-Go-peer tests verify cold/reuse/resume state, actual ticket
receipt, both peers' resumed state, physical identity changes, event schemas,
privacy, bounded observers and joined shutdown. Fixture race tests passed ten
repeats, followed by a separate three-repeat run; `make check` also passed.

Separately, ten existing AutoCAR H3 test groups passed three repeats with
`-race` and a 120-second limit:

```sh
go test -race -v ./internal/tunnel \
-run '^TestWebH3(Resumption|ConnectionAuthMultiplexesOneFullTicket$|DefaultChromeInitialWireShape$|WirePacketCounterHandlesCoalescing$)' \
-count=3 -timeout=120s
```

Those tests confirmed cold/reuse/reconnect states `[false false true]` for the
opt-in resumption path; default/disabled/rejected-ticket controls remained
`[false false false]`. Their wire tests found no outbound 0-RTT packets. This is
separate functional evidence, not a same-endpoint paired browser measurement.

## Evidence boundary

The browser result establishes **server-observed TLS resumption on a new H3
connection** in this bounded setup. It does not instrument the browser's own
TLS state or establish wire-level absence of browser 0-RTT. Lab target traffic
was loopback-only; the browser process was not OS-isolated from all background
network activity. It does not prove WAN reliability, throughput, passive
indistinguishability, or parity between installed Chrome 154 and the project's
fixed Chrome handshake template. No default profile, production transport or
release gate changed, and the canceled full corpus was not restarted.
28 changes: 27 additions & 1 deletion scripts/browser-connection-fixture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

This small test origin assigns an ID to each admitted physical QUIC connection.
It helps inspect whether a browser navigation and subsequent fetch reuse a
connection. It is not an AutoCAR proxy, a formal corpus generator, or a
connection, and whether a later physical connection resumes TLS. It is not an
AutoCAR proxy, a formal corpus generator, or a
production traffic optimization.

An explicitly limited [Chrome diagnostic](DIAGNOSTIC-2026-09-22.md) records one
Expand Down Expand Up @@ -45,6 +46,31 @@ H3 from an exit code alone: verify navigation and fetch Resource Timing report
an HTTPS URL does not configure H3; use browser-version-appropriate lab settings
and certificate trust or a narrowly scoped lab pin.

Request log records and `/probe` responses also contain a small `tls` object:
`handshake_complete`, `did_resume`, numeric `version` (772 for TLS 1.3), and
`alpn` (`h3` for this origin). Missing TLS state is explicitly `null`, not an
assertion that a handshake was cold. Unexpected nonempty ALPN values are
replaced by `<other>`; tickets, keys, certificates and arbitrary TLS fields are
never serialized.

Each admitted connection emits exactly one `connection_closed` record with
only `kind` and `connection_id`, after its actual QUIC context ends. Request or
stream completion does not produce that event. Observer count is bounded by the
connection budget, and shutdown joins observers before returning. No close
reason is exposed: the event proves physical closure, not which peer or timer
caused it. Filter by `kind` instead of assuming every line after `ready` is a
request.

For a warm-reconnection diagnostic, keep the same browser process/context,
fixture instance and certificate. Check cold and same-origin reuse requests
have the same ID and `did_resume: false`; wait for that ID's actual close event
without sending target requests; then require a different ID, successful H3
response and `did_resume: true`. Save runtime browser version and command line
before target navigation. Server-observed browser TLS state is not both-peer
instrumentation or wire-level proof about 0-RTT. See the bounded
[warm-reconnection observation](DIAGNOSTIC-2026-10-09.md), including its initial
readiness failure.

## Separate from the WebDriver workload diagnostic

The [existing runner](../stealth-browser/README.md#optional-credentials-diagnostic)
Expand Down
77 changes: 67 additions & 10 deletions scripts/browser-connection-fixture/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,36 @@ func (log *jsonLog) write(value any) error {
type connectionKey struct{}

type requestRecord struct {
Kind string `json:"kind"`
ConnectionID uint64 `json:"connection_id"`
Path string `json:"path"`
Trial string `json:"trial"`
Protocol string `json:"protocol"`
TLS *tlsSummary `json:"tls"`
}

// Keep only non-secret connection state; never serialize tls.ConnectionState.
type tlsSummary struct {
HandshakeComplete bool `json:"handshake_complete"`
DidResume bool `json:"did_resume"`
Version uint16 `json:"version"`
ALPN string `json:"alpn"`
}

func summarizeTLS(state *tls.ConnectionState) *tlsSummary {
if state == nil {
return nil
}
alpn := state.NegotiatedProtocol
if alpn != "" && alpn != http3.NextProtoH3 {
alpn = "<other>"
}
return &tlsSummary{state.HandshakeComplete, state.DidResume, state.Version, alpn}
}

type connectionClosedRecord struct {
Kind string `json:"kind"`
ConnectionID uint64 `json:"connection_id"`
Path string `json:"path"`
Trial string `json:"trial"`
Protocol string `json:"protocol"`
}

type fixture struct {
Expand All @@ -118,6 +143,21 @@ type fixture struct {
requests atomic.Uint64
connections atomic.Uint64
stop context.CancelFunc
observers sync.WaitGroup
}

func (f *fixture) observeConnection(id uint64, conn *quic.Conn) {
// Called only for admitted connections, so the run's existing connection
// budget bounds both goroutines and close records. run joins the HTTP/3
// serving loop and handlers before waiting, so no Add can race with Wait.
f.observers.Add(1)
go func() {
defer f.observers.Done()
<-conn.Context().Done()
if err := f.log.write(connectionClosedRecord{"connection_closed", id}); err != nil {
f.stop()
}
}()
}

func validTrial(value string) bool {
Expand Down Expand Up @@ -158,7 +198,8 @@ func (f *fixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
queryValid = queryValid && r.URL.RawQuery == ""
}
// Never log arbitrary URL text, query values, headers, or peer addresses.
if err := f.log.write(requestRecord{"request", id, path, trial, "HTTP/3.0"}); err != nil {
tlsState := summarizeTLS(r.TLS)
if err := f.log.write(requestRecord{"request", id, path, trial, "HTTP/3.0", tlsState}); err != nil {
f.stop()
http.Error(w, "diagnostic output unavailable", http.StatusServiceUnavailable)
return
Expand Down Expand Up @@ -208,9 +249,10 @@ func (f *fixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet {
_ = json.NewEncoder(w).Encode(struct {
ConnectionID uint64 `json:"connection_id"`
Protocol string `json:"protocol"`
}{id, "HTTP/3.0"})
ConnectionID uint64 `json:"connection_id"`
Protocol string `json:"protocol"`
TLS *tlsSummary `json:"tls"`
}{id, "HTTP/3.0", tlsState})
}
}

Expand Down Expand Up @@ -244,6 +286,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
id := f.connections.Add(1)
if id > uint64(opts.maxConnections) {
_ = conn.CloseWithError(0x100, "connection budget exhausted")
} else {
f.observeConnection(id, conn)
}
return context.WithValue(ctx, connectionKey{}, id)
},
Expand All @@ -252,10 +296,23 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
if err != nil {
return errors.New("could not initialize the HTTP/3 QUIC listener")
}
defer listener.Close()
defer server.Close()
done := make(chan error, 1)
go func() { done <- server.ServeListener(listener) }()
servingDone := make(chan struct{})
go func() {
defer close(servingDone)
done <- server.ServeListener(listener)
}()
defer func() {
// Stop accepting and join the serving loop before closing handlers.
// Server.Close joins its connection handlers (including ConnContext),
// after which no new observer can be registered. Every emitted close
// record comes from actual QUIC context completion, never a timer guess.
_ = listener.Close()
<-servingDone
_ = server.Close()
_ = packet.Close()
f.observers.Wait()
}()
if err := f.log.write(struct {
Kind string `json:"kind"`
Address string `json:"address"`
Expand Down
Loading
Loading