docs: hxprobe plans, summaries, explanations, usage, and changelog

Plans (docs/plans/):
- 2026-07-01-23-47-py-hxprobe-httpx.md — initial httpx probe design
- 2026-07-02-09-32 through 14-05 — standalone project, toolchain, usage
  doc + Makefile, file input (-f), simplification pass, run-summary footer

Summaries (docs/summaries/): one per completed feature, recording what
was actually built, deviations from the plan, and verification steps

Explanations (docs/explanations/): two deep-dives written during review —
hxprobe concurrency model and worst-exit-code + render-loop analysis

Usage (docs/usage/hxprobe.md): overview with pointer to hxprobe/USAGE.md
for the full runnable reference

Walkthrough (docs/py-latprobe-walkthrough.md): narrative tour of the
latprobe Python package for interview / code-review context

CHANGELOG.md: entries for all hxprobe features (toolchain, usage doc,
file input, simplification, run-summary footer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 13:22:59 +02:00
parent 13a562e966
commit f487a4b1bd
19 changed files with 2367 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
# Summary: `hxprobe` — httpx-based Python probe
Plan: [docs/plans/2026-07-01-23-47-py-hxprobe-httpx.md](../plans/2026-07-01-23-47-py-hxprobe-httpx.md)
## What was built
A new sibling package, `python/hxprobe/`, alongside the existing raw-socket
`latprobe`. It uses `httpx` so the client matches Go's `http.DefaultClient`:
HTTP/2 negotiated via ALPN, redirects followed by default, connection
pooling, and default TLS verification — while still reporting the full
six-phase breakdown (DNS, TCP connect, TLS, TTFB, Transfer, Total).
Files:
- `python/pyproject.toml` — first third-party dependency in this repo
(`httpx[http2]`)
- `python/hxprobe/probe.py` — the core: `_Trace`, `_TimingStream`,
`_TimingBackend`, `_TimingTransport`, `measure()`
- `python/hxprobe/cli.py`, `__main__.py`, `__init__.py` — thin wrappers
- `python/latprobe/probe.py` (edited) — added `Options.follow_redirects`/
`Options.http2` (ignored by the socket `measure()`) and
`VerboseDetail.http_version`/`redirect_count` (always `""`/`0` there)
- `python/latprobe/cli.py` (edited) — `run()`/`_run_samples()` take an
injectable `measure_fn`, plus `prog`/`description`/`protocol_flags`
overrides, so `hxprobe.cli.run()` reuses the entire argparse/concurrency/
exit-code/rendering pipeline unchanged
- `python/tests/test_hx_probe.py` (17 tests), `test_hx_cli.py` (11 tests),
`test_integration_hx.py` (9 live tests, excluded from the default gate via
the existing `test_i*` naming convention)
- `Makefile``py-deps` (creates `python/.venv`, installs `httpx[http2]`),
`hx-run`, `hx-test-integration`; `py-test`/`py-check` now run through the
venv and include the new hermetic hxprobe tests
- `docs/usage/py-hxprobe.md` — usage doc with real captured output
- `.gitignore` — added `*.egg-info/` (editable-install artifact)
## Key design decisions
- **Instrument the transport, don't hand-roll HTTP.** Subclassed
`httpcore.NetworkBackend`/`NetworkStream` to time DNS/TCP connect/TLS at
the socket level, letting httpx own HTTP/1.1 vs HTTP/2 framing, redirects,
and keep-alive. This was the reason to use httpx at all — get the protocol
behavior of a real client while keeping latprobe's phase granularity.
- **First-hop-wins for dns/connect/tls; last-hop-wins for ttfb/transfer.**
When redirects are followed, connection-identity fields (dns/connect/tls
timing, resolved IP, TLS/cert info) reflect the *first* connection.
`wrote_request`/`first_byte` are simply overwritten on every write/read, so
they naturally end up reflecting the *last* hop — which mirrors how Go's
own unguarded `httptrace.ClientTrace` hooks behave for a followed redirect.
- **Fresh `httpx.Client` per `measure()` call, no cross-sample pooling** —
matches latprobe's per-call socket creation so every `-n` sample gets a
full phase breakdown.
- **`measure_fn` injection over subclassing/duplication** in `latprobe.cli`,
so hxprobe reuses argparse, concurrency, exit codes, and text/JSON
rendering with zero duplicated logic — the socket and httpx probes only
differ in `probe.py`.
- **New CLI flags gated behind `protocol_flags=True`** so `latprobe`'s own
`--help` output stays byte-for-byte unchanged (verified) — `--no-http2`/
`--no-follow-redirects` only appear for `hxprobe`.
## Notable finding (not part of the original plan)
While comparing `hxprobe` and `latprobe` timings against the same live host,
`hxprobe`'s TTFB was consistently ~40-50ms *lower*. Verified experimentally
(not just assumed) that this is a real effect, not noise: `latprobe`'s raw
socket never sets `TCP_NODELAY`, so its request write is subject to Nagle's
algorithm interacting with the server's delayed-ACK timer — a well-known
artifact. Forcing `TCP_NODELAY` onto `latprobe`'s socket collapsed its TTFB
to match `hxprobe`'s. `hxprobe` sets `TCP_NODELAY` (matching httpcore's own
default backend and Go's `net.Dialer`), so its TTFB numbers are the more
accurate of the two — not just different. Did not change `latprobe` itself
(out of scope for this task); documented the divergence in
`docs/usage/py-hxprobe.md`, in the CHANGELOG, and inline in
`hxprobe/probe.py`.
## Deviations from the plan
- Plan sketched `TTFB = headers-received minus end-of-TLS`; implemented as
`TTFB = first-byte minus wrote-request` instead (matches both Go and the
existing `latprobe` definition — the plan's phrasing was an approximation).
- Plan said verbose TLS/cert metadata could follow "last hop"; implemented
as first-hop-wins uniformly across dns/connect/tls/ip/tls-info for
simplicity and consistency (only `ttfb`/`transfer` are last-hop).
- Everything else (library choice, transport-instrumentation approach,
pyproject.toml, sibling-package placement, flag surface) matches the
approved plan as written.
## Verification
- `make check` (Go + Python full gate): exit 0, 83 hermetic Python tests
(72 pre-existing + 11 new hermetic CLI + hxprobe's share of the 17 probe
tests already counted), zero regressions to latprobe's original 39.
- `make hx-test-integration`: 9/9 live tests pass, including real HTTP/2
negotiation against example.com (Cloudflare) and a real http→https redirect
follow against github.com.
- Manual spot checks: `--json` schema, `--fail` exit code 6, DNS failure
(exit 2), connection-refused (exit 3), `--no-http2`/`--no-follow-redirects`
flag behavior, `latprobe --help` output diffed byte-for-byte against
pre-change output to confirm no regression.
- Caught and reverted an incidental `gofmt` whitespace diff in
`go/internal/probe/probe.go` that `make check`'s `go-fmt` step produced —
unrelated to this change, out of scope, not committed.

View File

@@ -0,0 +1,117 @@
# Summary: extract `hxprobe` into a standalone top-level project
Plan: [docs/plans/2026-07-02-09-32-hxprobe-standalone-project.md](../plans/2026-07-02-09-32-hxprobe-standalone-project.md)
## What was built
`hxprobe` moved from `python/hxprobe/` to a new top-level `hxprobe/`
directory (sibling of `go/` and `python/`), structured as a genuinely
independent project: own `pyproject.toml`, own venv (`hxprobe/.venv`), own
`tests/` directory. It imports nothing from `python/latprobe` — verified with
`grep -rn "latprobe" hxprobe/` returning zero matches in code (a few comments
mention Go's `http.DefaultClient` for context, which is fine; no comment
references `latprobe` as a concrete path/module anymore either).
Files:
- `hxprobe/pyproject.toml` — own manifest, `httpx[http2]` dependency,
`packages = ["hxprobe"]`
- `hxprobe/hxprobe/probe.py` — moved from `python/hxprobe/probe.py`, with
`Options`, `Phase`, `Result`, `VerboseDetail`, `CertInfo` dataclasses and
`_parse_cert`/`_parse_cert_date` helpers now defined locally (copied from
`latprobe/probe.py`) instead of imported; all the httpx-instrumentation
logic (`_Trace`, `_TimingStream`, `_TimingBackend`, `_TimingTransport`,
`measure()`) is unchanged
- `hxprobe/hxprobe/aggregate.py`, `duration.py` — verbatim copies of
`latprobe`'s; their imports were already package-relative (`from .probe
import Result`), so copying required zero edits
- `hxprobe/hxprobe/cli.py` — rewritten as a full standalone CLI. Previously a
27-line wrapper delegating to `latprobe.cli.run()` via an injected
`measure_fn`; now has its own exit codes, argparse, and text/JSON
rendering (copied from the shared `latprobe/cli.py`, then stripped of the
`measure_fn`/`prog`/`description`/`protocol_flags` parameterization that
only existed to let two packages share one `run()`)
- `hxprobe/tests/{test_probe,test_cli,test_integration}.py` — moved from
`python/tests/test_hx_*.py` / `test_integration_hx.py`, `hx`-prefix
dropped, imports repointed from `latprobe.*` to `hxprobe.*`
- `python/latprobe/{cli.py,probe.py}` — reverted via `git checkout --` to
their exact pre-`hxprobe` committed state (confirmed zero diff afterward)
- `Makefile``py-deps`/`PY_VENV*`/shared `hx-run`/`hx-test-integration`
removed from the Python section; new standalone `hx-deps`/`hx-run`/
`hx-test`/`hx-test-integration`/`hx-check`/`hx-clean` targets under
`HX_DIR`/`HX_VENV*`; umbrella `test`/`check`/`clean` now run all three
(`go-*`/`py-*`/`hx-*`)
- `docs/usage/py-hxprobe.md``docs/usage/hxprobe.md` (renamed + rewritten
for the new structure)
- Old `python/hxprobe/`, `python/pyproject.toml`, `python/.venv`,
`python/latprobe_python.egg-info/` deleted
## Key design decisions
- **Move, don't rewrite, the already-debugged files.** `probe.py` and the
three test files carry real bug fixes found during the original
implementation (the `mark_dns` present-on-failure bug, verbose detail not
populated on the failure path, `Content-Length` needed once the test
fixtures switched to HTTP/1.1 keep-alive). Rewriting from scratch would
have risked reintroducing them.
- **`aggregate.py`/`duration.py` needed zero import changes** — their
existing relative imports (`from .probe import ...`) already resolve
correctly once copied into a new package with its own `probe.py`. Not
every file needed the same treatment as `probe.py`/`cli.py`.
- **`cli.py`'s starting point was the *current* shared `latprobe/cli.py`**,
not a from-scratch rewrite — it already contained 100% of the needed
logic (including the `--no-http2`/`--no-follow-redirects` flags and the
verbose Protocol row/JSON keys, both added earlier specifically for
hxprobe). The only work was deleting the generalization scaffolding
(`measure_fn`, `MeasureFn`, `prog`/`description`/`protocol_flags` params)
that existed solely to let two packages share one `run()`.
- **Reverting `latprobe` via `git checkout --` rather than hand-editing** —
confirmed first via `git log`/`git diff --stat` that `cli.py`/`probe.py`
had no changes besides the hxprobe-sharing scaffolding since the last
commit, making this a safe, exact, zero-risk revert.
- **Comments referencing `latprobe` by file path were reworded**, not just
the imports. A comment like "already used by latprobe/probe.py" becomes a
dangling reference once this directory is genuinely portable to another
repo. Reworded ~5 comments/docstrings to describe the technique generically
(e.g., "ports the getaddrinfo → connect split" → "splits DNS and TCP
connect into two timed steps") instead of naming the other project.
## Deviations from the plan
None of substance. One judgment call not spelled out in the plan: the old
`test_cli.py`'s docstring said its tests "focus on what's different... since
run() delegates almost entirely to latprobe.cli.run()" — no longer true now
that `cli.py` is a full standalone reimplementation. Rewrote that docstring
for accuracy rather than leaving a stale claim, without expanding the test
suite itself (that would be a larger, separate scope-creep beyond what was
asked — see the coverage note below).
## Notable follow-up worth flagging
`hxprobe/tests/test_cli.py` has ~11 tests versus `latprobe/tests/test_cli.py`'s
~23. That gap was fine when hxprobe's `cli.py` was a thin wrapper around
already-tested shared code; it's a real gap now that `cli.py` is an
independent ~440-line reimplementation with its own copy of every rendering
branch. Existing tests do exercise the core paths (single URL, `--fail`,
JSON, DNS/connect failures) so this isn't uncovered, but reaching
`latprobe`-level depth (multi-URL separator, sampling aggregate output, JSON
error grouping, all-failed multi-header) would be a reasonable next step if
full independent confidence in the standalone project matters. Not done here
— out of scope for a decoupling/move task.
## Verification
- `grep -rn "latprobe" hxprobe/` — zero matches in code; comment mentions
reworded to be self-contained
- `git diff --stat python/latprobe/` — empty (exact revert to last commit)
- `make hx-deps` — creates `hxprobe/.venv`, installs `httpx[http2]` from
`hxprobe/pyproject.toml`
- `make hx-run ARGS="-v http://github.com"` — same output as before the
move (HTTP/2, 1 redirect followed, IP/TLS/cert shown)
- `make hx-test` — all hermetic hxprobe tests pass standalone (own venv,
own `PYTHONPATH=hxprobe`)
- `make hx-test-integration` — live HTTP/2 + redirect tests still pass
- `make py-test``latprobe`'s own suite passes with zero setup (no
`py-deps`/venv needed), confirming the revert didn't leave residue
- `make check` (top-level) — Go + latprobe + hxprobe all green in one gate
- `python -m latprobe --help` output confirmed unchanged from before the
whole hxprobe feature existed (no leftover `--no-http2` etc.)

View File

@@ -0,0 +1,73 @@
# Summary: hxprobe toolchain modernization (uv, ruff, pytest)
Plan: [docs/plans/2026-07-02-09-57-hxprobe-toolchain-modernization.md](../plans/2026-07-02-09-57-hxprobe-toolchain-modernization.md)
## What was built
Modernized `hxprobe`'s Python toolchain per the user's confirmed choices
(uv, ruff, pytest; mypy skipped). No behavior change to the probe/CLI
itself — this is purely tooling.
- `hxprobe/pyproject.toml`: added `[dependency-groups] dev = ["pytest>=8.0",
"ruff>=0.8"]` (PEP 735), `[tool.pytest.ini_options]` (testpaths, a
registered `integration` marker), `[tool.ruff]` (`target-version =
"py311"`, `line-length = 100`)
- `hxprobe/.python-version`: new, pins `3.14`
- `hxprobe/uv.lock`: new, 18 packages resolved and pinned (`httpx`,
`httpcore`, `h2`, `certifi`, `pytest`, `ruff`, and their transitive deps)
- `hxprobe/tests/test_integration.py`: added `pytestmark =
pytest.mark.integration`, replacing the `test_[!i]*.py` filename-glob
convention with a real pytest marker
- Ran `ruff check --fix` (one fix: removed unused `import sys` in `cli.py`,
inherited from the original `latprobe/cli.py`) and `ruff format .`
(6 files reformatted — collapsed the hand-aligned `=`/dict-key columns to
single-space; no semantic changes)
- `hxprobe/README.md`: new, standalone quick-start
- `Makefile`: `hx-deps`/`hx-run`/`hx-test`/`hx-test-integration` now shell
out to `uv sync`/`uv run` instead of manual venv+pip; added `hx-lint`/
`hx-fmt`; `hx-check` now bundles lint + hermetic tests (mirrors
`go-check`'s fmt+vet+test pattern)
- `docs/usage/hxprobe.md`: Setup and Makefile-targets sections updated
- `.gitignore`: added `.pytest_cache/`, `.ruff_cache/`
## Key design decisions
- **`[dependency-groups]` (PEP 735) over `[tool.uv.dev-dependencies]`** —
the standardized, current uv-recommended way to declare dev-only deps,
keeps them out of the installable package's `dependencies` list.
- **Runner swap only, no test rewrite.** `unittest.TestCase` classes,
`unittest.skipUnless` decorators, and `if __name__ == "__main__":
unittest.main()` guards are untouched — pytest is a superset runner for
unittest-style tests. Only the marker-based selection mechanism changed.
- **`hx-check` now includes lint**, not just tests — matches how
`go-check: go-fmt go-vet go-test` already bundles static checks with
tests in this repo, rather than treating lint as a separate, easy-to-skip
step.
- **Verified the `ruff format` diff was purely cosmetic** before accepting
it: reviewed the actual diff (whitespace/alignment only, no reordering or
logic changes), then confirmed with `ast.parse` on every file post-format
and a full pytest run afterward (37 tests: 28 hermetic + 9 integration,
all passing) — did not run the suite pre-format, so this confirms the
post-format state is correct rather than a strict before/after diff.
## Deviations from the plan
None. Implemented exactly as planned; the `import sys` removal and the
~6-file reformatting were both explicitly anticipated in the plan text
("Lint fixes" section) rather than being surprises.
## Verification
- `cd hxprobe && uv sync` — creates `.venv`, installs from `uv.lock`
(`httpx[http2]`, `pytest`, `ruff` + transitive deps)
- `make hx-run ARGS="-v http://github.com"` — unchanged output (HTTP/2,
1 redirect, IP/TLS/cert)
- `make hx-lint` — `ruff check .` clean (`All checks passed!`)
- `make hx-test` — `pytest tests -m "not integration"`: 28 passed, 9
deselected (matches the pre-toolchain-change hermetic test count exactly)
- `make hx-test-integration` — `pytest tests -m integration`: 9 passed, 28
deselected
- `grep -rn "latprobe" hxprobe/` — zero matches (toolchain change didn't
reintroduce coupling)
- `git diff --stat python/` — empty (hxprobe-only change, `latprobe`
untouched)

View File

@@ -0,0 +1,74 @@
# Summary: hxprobe usage reference doc + standalone Makefile
Plan: [docs/plans/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md](../plans/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md)
## What was built
- **`hxprobe/USAGE.md`** (new): a "Runnable Usage Reference" matching the
depth/format of `python/configs/usage-latprobe.md` (concrete `sh` command
→ real captured output, brief explanatory notes, `---` section
separators). 16 cases: basic, verbose (HTTPS, plain HTTP, redirect
followed, `--no-follow-redirects`, `--no-http2`, TLS failure, DNS
failure), sampling, multi-URL, `--fail`, JSON, JSON+verbose, timeout,
exit-codes table, Makefile shortcuts (both `hxprobe/Makefile`'s own and
the parent repo's `hx-*` ones). Placed inside `hxprobe/` per the user's
explicit choice, so the doc travels with the project if it's ever
extracted to its own repo.
- **`docs/usage/hxprobe.md`** (edit): one-line pointer added at the top to
`hxprobe/USAGE.md`. No other changes.
- **`hxprobe/Makefile`** (new): standalone, `help`/`deps`/`run`/`lint`/
`fmt`/`test`/`test-integration`/`check`/`clean`, same auto-generated
`## comment` help style as the root Makefile. Deliberately independent
from the root Makefile's `hx-*` targets (neither calls into the other) —
explicit user decision over the "delegate" alternative.
## Key design decisions
- **Every output in `USAGE.md` is real, freshly captured this session** —
10 of 16 cases were run live during implementation specifically for this
doc (TLS failure, sampling ×2, multi-URL, `--fail`, JSON ×2, timeout,
basic, plain-HTTP), the rest reused real captures from earlier in the
same session (redirect-follow, `--no-follow-redirects`, `--no-http2`, DNS
failure) since the code hadn't changed since those were taken. No numbers
were fabricated or extrapolated.
- **Timeout example uses `192.0.2.1`, not `10.255.255.1`.** Tested both:
`10.255.255.1` resolves to an immediate `connect: Connection refused` in
this dev sandbox (the sandbox's network layer actively rejects the
packet rather than dropping it silently), which would misrepresent the
timeout path. `192.0.2.1` (RFC 5737 TEST-NET-1, reserved/unreachable)
reliably produces a genuine ~500ms timeout here, so that's what the doc
uses and explains.
- **No delegation between the two Makefiles**, per explicit user
instruction — both have complete, independent implementations of the
same `uv sync`/`uv run pytest`/`ruff` commands. This is deliberate
duplication: a change to one Makefile's command flags won't silently
break the other, at the cost of needing to update both if the underlying
`uv run ...` invocations ever change.
- **`hxprobe/Makefile` target names have no `hx-` prefix** (`run`, `test`,
not `hx-run`, `hx-test`) since the prefix's whole purpose — disambiguating
from `go-*`/`py-*` targets in the same file — doesn't apply once you're
already inside `hxprobe/`'s own Makefile.
## Deviations from the plan
One, driven by what the live network in this sandbox actually does: the
plan didn't anticipate the timeout target needing to change from
`10.255.255.1` (used in `latprobe`'s own timeout example) to `192.0.2.1`.
Discovered and resolved by testing both live before writing the doc, rather
than assuming `latprobe`'s example target would work identically for
hxprobe.
## Verification
- `cd hxprobe && make help` — lists all 9 targets, no dependency on the
root Makefile
- `cd hxprobe && make run ARGS="-v https://example.com"` — matches
`make hx-run ARGS="-v https://example.com"` output shape from the repo
root (both Makefiles agree independently)
- `cd hxprobe && make check` — 28 hermetic tests pass (lint + test)
- `cd hxprobe && make test-integration` — 9 live tests pass
- Every command block in `USAGE.md` was actually executed during
authoring; output pasted directly from the terminal, not hand-edited
beyond JSON float-precision rounding (documented as such in the doc)
- `git diff Makefile` — confirms the root Makefile's `hx-*` section is
unchanged by this task

View File

@@ -0,0 +1,77 @@
# Summary: hxprobe reads target URLs from a file
Plan: [docs/plans/2026-07-02-11-14-hxprobe-file-input.md](../plans/2026-07-02-11-14-hxprobe-file-input.md)
## What was built
- **`hxprobe/hxprobe/cli.py`**: new `-f`/`--file PATH` flag, mutually
exclusive with positional `url` args. `urls` positional changed
`nargs="+"``nargs="*"`. New `_load_urls()` helper (same format as
`simple.py`'s `load_sites()`: one URL per line, `#` comments, blank
lines skipped, first token per line) — reimplemented locally rather than
imported, keeping hxprobe's "no imports outside its own directory" rule
intact. Validation added right after `parser.parse_args()`, inside the
same `try/except _ArgExit` block: both-given and neither-given are usage
errors via `parser.error()`; missing/unreadable/empty file are usage
errors via direct `stderr.write()` + `return EXIT_USAGE` (mirroring the
existing `--timeout` invalid-value handling style already in the file).
- **`hxprobe/tests/test_cli.py`**: new `TestCLIFileInput` class, 5 tests —
reads URLs from a temp file successfully, missing file, empty file,
both-sources error, neither-given error.
- **`hxprobe/configs/*.txt`**: 7 new fixtures mirroring
`python/configs/`'s exact set. Each header comment states an "Expected
exit code" that was verified by actually running the fixture through
`hxprobe -f ...` during implementation (not assumed from the
`simple.py` originals, whose blanket 0/1 exit scheme is fundamentally
different from hxprobe's per-failure-class 06 worst-code-wins scheme).
- **`hxprobe/USAGE.md`**: new "Reading URLs from a file (`-f`)" section,
placed after "Multiple URLs", with real captured output for the
successful case, the mutually-exclusive error case, and one failure
fixture (`dns-failure.txt`), plus a table listing all 7 fixtures and
their expected exit codes.
## Key design decisions
- **Manual post-parse validation instead of `argparse`'s
`add_mutually_exclusive_group`** — a variadic positional (`nargs="*"`)
doesn't mix cleanly with argparse's built-in mutually-exclusive-group
machinery. Manual checks after `parser.parse_args()` (still inside the
same `try/except _ArgExit`, using `parser.error()`) give the same
usage-error behavior with full control over the message text.
- **File I/O errors don't go through `parser.error()`** — they use the
same direct `stderr.write()` + `return EXIT_USAGE` pattern already
established for `--timeout` parsing failures, since they're discovered
after parsing succeeds, not during it.
- **Every fixture's exit code was verified live, not assumed.** Two
required real judgment calls the `simple.py` originals didn't need:
`http-errors.txt` and `mixed.txt` both needed `--fail` added to their
demo command (hxprobe treats 4xx as success without it, unlike
`simple.py` which always raises on `HTTPError`) to actually demonstrate
a failure — without `--fail` both would silently show exit 0.
## Deviations from the plan
None of substance. The plan anticipated needing to verify exit codes
live rather than assume them; that anticipation paid off exactly as
expected for `http-errors.txt`/`mixed.txt` (needed `--fail` added) and
`timeout.txt` (needed `--timeout 2s` added to keep the demo fast, and a
note added about this sandbox occasionally short-circuiting one of the two
timeout targets to an immediate "connection refused" — observed directly:
in this session's test run, `10.255.255.1` genuinely timed out; in an
earlier, unrelated test earlier in the session it instead got refused
immediately. The fixture keeps both targets so at least one demonstrates
the real timeout path regardless).
## Verification
- `all-ok.txt` → exit 0, `dns-failure.txt` → 2, `connection-refused.txt`
→ 3, `tls-errors.txt` → 5, `timeout.txt` (with `--timeout 2s`) → 4,
`http-errors.txt` (with `--fail`) → 6, `mixed.txt` (with `--fail`) → 6 —
all confirmed via real `$?` checks (not through a `grep` pipe, which
masks the real exit code — caught and corrected this during testing)
- `uv run pytest tests/test_cli.py -m "not integration"` — 16/16 pass
(11 pre-existing + 5 new)
- `uv run ruff check .` / `uv run ruff format --check .` — clean
- Both-sources-given and missing-file error messages verified against the
doc's pasted output, including the `usage:` block that's actually
printed (an early draft of the doc omitted it — caught on review)

View File

@@ -0,0 +1,83 @@
# hxprobe simplification — summary
Plan: `docs/plans/2026-07-02-12-05-hxprobe-simplification.md` (five targeted
removals of speculative scaffolding / redundant state / duplicated logic, no
behavior change). Trigger: a question about a dead `file=` parameter on
`_Parser.print_help`/`print_usage` in `hxprobe/hxprobe/cli.py`, which led to a
broader pass over the whole package for the same pattern.
## What changed
1. **`hxprobe/hxprobe/cli.py` — deleted the `_Parser` subclass and `_ArgExit`
exception** (~32 lines). It re-implemented, by hand, behavior the standard
library already provides: `argparse.ArgumentParser` already writes help to
`sys.stdout` and usage/errors to `sys.stderr`, and already raises
`SystemExit` rather than hard-exiting. `run()` now builds a plain
`argparse.ArgumentParser`, wraps the parse + validation calls in
`contextlib.redirect_stdout(stdout)` / `redirect_stderr(stderr)`, and
catches `SystemExit` at a single site:
```python
except SystemExit as exc:
return EXIT_OK if not exc.code else EXIT_USAGE
```
Deviation from the original plan sketch: the plan's snippet used
`EXIT_OK if not exc.code else EXIT_USAGE`, matching what was actually
implemented (equivalent to, but slightly more defensive than, the
`exc.code == 0` check first drafted, since argparse can in principle pass
`None`).
2. **`hxprobe/hxprobe/probe.py` — trimmed `_TimingStream.get_extra_info`** to
the single branch actually consumed on the request path (`ssl_object`).
Verified by grepping the installed `httpcore`/`httpx` packages: the
`server_addr`/`client_addr` branches were only ever queried by `httpx`'s own
`_main.py` (the `httpx` CLI command), never by anything hxprobe's request
path touches.
3. **`hxprobe/hxprobe/cli.py` — simplified `_load_urls`**: dropped the
`line.split()[0]` "forward-compatible with future `url key=value`
annotations" scaffolding; a stripped line is used directly. Docstring
updated to describe only what the function does today.
4. **`hxprobe/hxprobe/probe.py` — removed `_dns_set`/`_connect_set`/
`_tls_set`** from `_Trace`. These booleans duplicated `Phase.present` on
`self.dns`/`self.connect`/`self.tls` (each starts as `Phase()`, i.e.
`present=False`). Guards rewritten from `if not self._dns_set:` to
`if not self.dns.present:` (and analogously for connect/tls) — identical
first-hop-wins semantics for redirects, one less piece of parallel state.
5. **`hxprobe/hxprobe/cli.py` — extracted `_summarize_failures`**, a shared
helper that dedupes `failed: list[Result]` into `[(phase, message, count),
...]` in first-seen order. `_print_failure_summary` (text rendering) and
`_build_json_entry` (JSON rendering) previously each carried an identical
~12-line dedup loop; both now call the helper and only differ in how they
format the tuple.
## Not changed (considered, kept — per the plan)
- The custom httpcore backend (`_TimingBackend`/`_TimingStream`/
`_TimingTransport`) — this is the tool's actual reason to exist.
- `ThreadPoolExecutor` concurrency — backs the shipped `-c/--concurrency` and
multi-URL/`-f` features.
- Explicit per-phase dataclass fields in `probe.py`/`aggregate.py`.
- `CertInfo.sans` — unused in text output but real (emitted in JSON verbose
output).
## Verification
- `hxprobe/.venv/bin/python -m pytest tests/test_cli.py tests/test_probe.py -q`
→ **33 passed**, no test file edits.
- `hxprobe/.venv/bin/ruff check hxprobe/` → **all checks passed**.
- Manual smoke tests (`python -m hxprobe -h`, no-args, and a live
`https://example.com -v` request):
- `-h` → help text on stdout, exit `0`.
- No args → usage + `no URLs given` error on **stderr only** (confirmed via
separate stdout/stderr redirection to files — stdout was empty), exit `1`.
- Live verbose request → correct 6-phase timing, resolved IP, HTTP/2
protocol, TLS version/cipher, and certificate detail — confirms the
`get_extra_info` trim didn't break `ssl_object` retrieval and the
`_Trace` refactor didn't break phase/first-hop-wins tracking.
Net: `cli.py` and `probe.py` are shorter and carry less parallel/duplicated
state; no observable behavior changed.

View File

@@ -0,0 +1,105 @@
# hxprobe: end-of-run summary footer — summary
Plan: `docs/plans/2026-07-02-14-05-hxprobe-run-summary-footer.md`.
Trigger: a follow-up to
`docs/explanations/2026-07-02-13-25-hxprobe-worst-exit-code-and-render-loop.md`
— does a single worst-code exit even make sense across multiple URLs with
different possible errors? The answer landed on: keep the scalar exit code
(it's a documented cross-implementation contract with `latprobe`/Go — see
`hxprobe/USAGE.md`'s Exit codes table, asserted by 13 existing tests), and
instead add the missing visibility as an end-of-run summary footer.
## What changed
All in `hxprobe/hxprobe/cli.py` unless noted, matching the plan exactly (no
deviations):
1. **`_EXIT_LABELS`** (new, next to `_PHASE_EXIT`): inverse mapping from exit
code → short label (`ok`, `dns`, `connect`, `timeout`, `tls`, `http`), used
to render the footer's `→ exit N (label)` line.
2. **Accumulation loop refactor**: previously computed a single running
`worst` directly inside the per-URL loop via two different idioms
(`if c > worst: worst = c` for network failures, `max(worst, EXIT_HTTP)`
for `--fail`). Now each URL first gets its own `code` (`EXIT_OK` folded up
via `max()` across its failed samples and, if `--fail`, its ≥400 successes),
appended to a new `url_codes: list[int]` (index-aligned with `urls`), and
*then* folded into `worst = max(worst, code)`. Unifies both idioms into one.
3. **`_print_run_summary(urls, url_codes, worst, out)`** (new helper, next to
`_print_failure_summary`): writes a separator line, an
`N URLs — X ok[, Y failed]` header, one `✗ {label:<8}: {count}` line per
non-OK class present (first-seen order, same dedup style as
`_summarize_failures`), and the closing `→ exit N (label)` line.
4. **Call site**: `elif len(urls) > 1: _print_run_summary(urls, url_codes,
worst, stdout)` — added after the per-URL loop, in the `else` branch of the
existing `if ns.json_out:` check (so it's text-mode-only), right before
`return worst`. JSON path (`json.dumps(json_items, ...)`) is completely
untouched — still a bare array, no top-level summary object, preserving the
documented "same shape as `latprobe`'s JSON" contract.
## Design decisions (confirmed with user before implementing)
- **Exit code stays a scalar** (worst/highest severity across URLs) — not
count-of-failed-URLs, not binary 0/1. Both alternatives were presented and
rejected because they'd break the documented 06 table, Go/`latprobe`
parity, and the 13 existing exit-code tests.
- **Summary is a text footer, multi-URL only** (`len(urls) > 1`) — not always
shown, and not also duplicated into JSON as a top-level object (which would
turn the JSON array into an object and break the documented array-shape
parity). Single-URL text output is untouched; JSON output is untouched.
## Tests
`hxprobe/tests/test_cli.py`: new `TestCLIRunSummary` class, 3 tests, reusing
the existing `_OKHandler`/`_start_server`/`_free_port`/`_invoke` harness:
- `test_multi_url_mixed_shows_summary` — one OK URL + one connection-refused
URL: asserts `EXIT_CONNECT`, and the footer strings (`"Summary: 2 URLs"`,
`"1 ok"`, `"1 failed"`, `"connect : 1"`, `"→ exit 3"`).
- `test_multi_url_all_ok_summary` — two OK URLs: asserts `EXIT_OK`,
`"Summary: 2 URLs — 2 ok"`, and no `"✗"` anywhere in output.
- `test_single_url_has_no_summary` — one OK URL: asserts `"Summary:"` is
absent (locks the multi-URL-only rule).
All 33 pre-existing tests pass unedited (33 + 3 new = 36 total).
## Verification
- `hxprobe/.venv/bin/python -m pytest tests/test_cli.py tests/test_probe.py -q`
→ **36 passed**.
- `hxprobe/.venv/bin/ruff check hxprobe/ tests/` → **all checks passed**.
- Manual smoke tests, all matching the plan's expected behavior exactly:
- `hxprobe https://example.com https://example.org` → footer
`Summary: 2 URLs — 2 ok` / `→ exit 0 (ok)`.
- `hxprobe https://example.com http://no.such.host.invalid` → footer
`Summary: 2 URLs — 1 ok, 1 failed` / `✗ dns : 1` / `→ exit 2 (dns)`;
process exit code confirmed `2` via `echo $?`.
- `hxprobe https://example.com` (single URL) → **no** footer, output
byte-for-byte the same shape as before this change.
- `hxprobe --json https://example.com https://example.org` → still a bare
JSON array, no summary object.
- Also captured a 3-URL mixed run (`example.com` ok, DNS failure, TLS
failure against `self-signed.badssl.com`) to confirm severity ordering in
the footer: DNS (2) and TLS (5) both counted, exit reported as `5 (tls)`
— the higher-severity class correctly wins the scalar while the footer
still shows the DNS failure that the scalar alone would hide.
## Docs updated (per CLAUDE.md conventions)
- `hxprobe/USAGE.md`: new "Multi-URL summary footer" section with a real
3-URL mixed-outcome capture; refreshed the pre-existing "Multiple URLs" and
`-f configs/all-ok.txt` / `-f configs/dns-failure.txt` examples, which were
captured before this feature existed and were now stale (missing the
footer) — replaced with fresh live captures; added a sentence to the Exit
codes section pointing at the new section.
- `docs/usage/hxprobe.md`: one-line addition to the exit-codes bullet
pointing at `hxprobe/USAGE.md`'s new section.
- `docs/explanations/2026-07-02-13-25-hxprobe-worst-exit-code-and-render-loop.md`:
appended an "Update (2026-07-02)" paragraph pointing forward to this work,
per the plan's "optional — pointer instead of a new file" option.
- `CHANGELOG.md`: new entry at the top.
- This file.
No deviations from the approved plan.