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,130 @@
# Plan: `hxprobe` — httpx-based Python probe (Go-client parity)
## Context
The existing Python `latprobe` package measures per-phase HTTP latency with raw
sockets. That gives an excellent DNS/TCP/TLS/TTFB/Transfer breakdown, but the
cost is that it always speaks **HTTP/1.1** and **does not follow redirects**
diverging from the Go implementation, whose `http.DefaultClient` negotiates
**HTTP/2** via ALPN, **follows redirects** (up to 10), pools connections, and
verifies TLS by default (confirmed: `go/internal/probe/probe.go` uses
`http.DefaultClient.Do` with no custom transport/`CheckRedirect`).
Goal: add a **second Python implementation, `hxprobe`**, built on the `httpx`
library so it matches the Go client's protocol capabilities (HTTP/2, redirects,
pooling, TLS verification) **while preserving the full 6-phase timing** that is
latprobe's whole point. Python has no equivalent of Go's `net/http/httptrace`,
so the phase breakdown is recovered by instrumenting httpx's network backend.
This is an **additive, Python-only experiment** — intentionally outside the
CLAUDE.md "Go first, then Python port" flow, since the user explicitly asked for
a library-based Python variant. No Go change is required.
## Approach
New sibling package `python/hxprobe/`, reusing everything reusable from
`latprobe` (dataclasses, aggregation, duration parsing, CLI rendering) so the
only genuinely new code is the httpx probe backend.
### Key design: instrumented httpx transport
`httpx` (sync `httpx.Client`) runs on `httpcore`. To recover per-phase timing we
subclass httpcore's sync network backend and time the phases at the socket
level, letting httpx own HTTP framing, HTTP/2, redirects, and keep-alive:
- `connect_tcp(...)` — reimplement DNS + TCP as separate steps (port the
`socket.getaddrinfo``socket.connect` split already in
`latprobe/probe.py:167-203`), timestamping **DNS** and **TCP connect**
independently, and capturing the resolved IP.
- `start_tls(...)` — timestamp the **TLS handshake**; pull negotiated version /
cipher / peer cert from the SSL object for verbose mode.
- **TTFB** = headers-received minus end-of-TLS (server processing), measured via
`client.stream("GET", ...)` (the `stream()` context yields once response
headers arrive).
- **Transfer** = iterating `resp.iter_raw()` to EOF, minus headers-received.
- **Total** = wraps the whole `measure()` call.
Each `measure()` call uses a **fresh `httpx.Client` (no cross-sample pooling)** so
every `-n` sample yields a full phase breakdown — matching the current
raw-socket `latprobe` behavior rather than Go's pool-reuse quirk.
A per-call trace object (held by the backend instance) records timings and the
`fail_phase` at the exact point a phase raises, giving precise error
classification (`dns`/`connect`/`timeout`/`tls`/`transfer`/`request`) without
guessing from httpx exception types.
**Redirects (followed by default, Go parity):** DNS/connect/TLS are reported
from the **first** connection (mirrors Go's `connectStart.IsZero()` guard);
TTFB/Transfer/Total span the full followed chain. `redirect_count` and the
negotiated `http_version` (`h2` vs `http/1.1`) are surfaced as new verbose
fields — a genuine capability the socket version lacks.
## Files
**New:**
- `python/pyproject.toml` — project metadata; dependency `httpx[http2]` (pulls
`h2`). Makes `latprobe` + `hxprobe` `pip install -e .`-able; tests still run
via `PYTHONPATH`.
- `python/hxprobe/__init__.py`
- `python/hxprobe/probe.py``measure(url, opts) -> latprobe.probe.Result`
(imports & returns the **same `Result`** so aggregation/rendering just work);
`_TimingBackend`, the per-call trace, error classification, verbose capture.
- `python/hxprobe/cli.py` — thin: delegates to `latprobe.cli.run(...)` passing
`measure_fn=hxprobe.probe.measure` (see reuse edit below).
- `python/hxprobe/__main__.py``sys.exit(cli.run(sys.argv[1:], ...))`.
- `python/tests/test_hx_probe.py` — hermetic, local `http.server`: phase
presence, **redirect following** (302 handler — validates the Go-parity
feature), connection-refused → `connect`, black-hole port → `timeout`.
- `python/tests/test_hx_cli.py` — hermetic CLI via `run()` with `io.StringIO`
(mirrors `tests/test_cli.py:73-76`).
- `python/tests/test_integration_hx.py` — live, **excluded from default gate**
(filename starts `test_i…`, so the `test_[!i]*.py` glob skips it): probe a
real HTTP/2 host and assert negotiated `http_version == "h2"`; guard with a
`@skipUnless(_online())` like `tests/test_integration.py:29-40`.
- `docs/usage/py-hxprobe.md` — usage doc (what it does, flags, example with
expected output, and an explicit socket-vs-httpx capability comparison table),
per CLAUDE.md.
**Edited (small, backward-compatible):**
- `python/latprobe/cli.py` — parameterize `run()` and `_run_samples()` with an
injectable `measure_fn` (default = current `latprobe.probe.measure`), so
`hxprobe` reuses all argparse, concurrency, exit-code, text/JSON rendering
logic. Extend `_print_verbose_block` and `_build_json_entry` to show
`http_version` / `redirect_count` **when present** (existing socket path never
sets them → output unchanged).
- `python/latprobe/probe.py` — add optional fields with safe defaults:
`Options.follow_redirects=True`, `Options.http2=True` (ignored by the socket
measure); `VerboseDetail.http_version=""`, `VerboseDetail.redirect_count=0`.
- `Makefile` — add `py-deps` (create `.venv`, `pip install -e python`),
`hx-run` (`cd python && python -m hxprobe $(ARGS)`), and fold `test_hx_*` into
the existing `py-test` gate; add `hx-test-integration` for the live h2 check.
- `CHANGELOG.md` — append a timestamped one-line entry on completion.
**Reused as-is:** `latprobe.aggregate.summarize`,
`latprobe.duration.parse_duration`, `latprobe.probe.{Result,Phase,CertInfo}`,
and the entire `latprobe.cli` renderer via the `measure_fn` injection.
## Flags / behavior
Same surface as `latprobe` (`-n/--count`, `-c/--concurrency`, `--timeout`,
`--fail`, `--json`, `-v/--verbose`) via the reused parser, plus two new opt-outs
for the Go-like defaults: `--no-http2` and `--no-follow-redirects`. Exit codes
06 stay identical to Go/`latprobe`.
## Verification
1. `make py-deps` — create venv, install `httpx[http2]`.
2. `make hx-run ARGS="https://example.com"` — full 6-phase text output renders.
3. `make hx-run ARGS="-v https://www.cloudflare.com"` — verbose block shows
`http_version: h2` and TLS/cert details.
4. **Go-parity spot checks:**
- HTTP/2: `python -m hxprobe -v <h2-host>` reports `h2` where
`python -m latprobe -v <h2-host>` reports HTTP/1.1.
- Redirects: `python -m hxprobe http://github.com` follows to https and shows
`redirect_count > 0` (socket `latprobe` shows a raw 301).
5. `make py-test` — hermetic suite (now including `test_hx_probe.py`,
`test_hx_cli.py`) is green; `latprobe`'s existing tests still pass (proves the
`measure_fn`/`Options`/`VerboseDetail` edits are backward-compatible).
6. `make hx-test-integration` — live test confirms real `h2` negotiation.
7. Confirm `--json` output for `hxprobe` matches the `latprobe` schema plus the
optional `verbose.http_version` / `verbose.redirect_count` keys.

View File

@@ -0,0 +1,151 @@
# Plan: extract `hxprobe` into a fully standalone top-level project
## Context
`hxprobe` currently lives at `python/hxprobe/` and imports shared code from
`latprobe` (`latprobe.probe.{Options,Phase,Result,VerboseDetail,_parse_cert}`,
`latprobe.cli.run`). That coupling was a deliberate reuse choice at the time,
but the user now wants `hxprobe` to be a genuinely independent project — no
`from latprobe import ...` anywhere, duplication accepted — and, per their
follow-up answer, independent enough that it could be `cp -r`'d into its own
repo tomorrow: own `pyproject.toml`, own venv, own Makefile section, own
top-level directory (sibling to `go/` and `python/`), not nested under `python/`.
Confirmed via `git status`/`git log`: everything hxprobe-related
(`python/hxprobe/`, `python/pyproject.toml`, `python/tests/test_hx_*.py`,
`docs/usage/py-hxprobe.md`, the two `docs/plans`/`docs/summaries` entries) is
still uncommitted from this session, and `python/latprobe/cli.py` /
`python/latprobe/probe.py` only diverge from the last commit (`24ea9c9`) by
the reuse-oriented additions made to support hxprobe. So this is a clean,
low-risk restructuring: move already-debugged code, revert latprobe with
`git checkout --`, no git history surgery needed.
## Target layout
```
hxprobe/ (new, top-level, sibling of go/ and python/)
├── pyproject.toml (own manifest: httpx[http2] dependency)
├── hxprobe/
│ ├── __init__.py
│ ├── probe.py (own Options/Phase/Result/VerboseDetail/CertInfo
│ │ + own _parse_cert/_parse_cert_date + existing
│ │ _Trace/_TimingStream/_TimingBackend/
│ │ _TimingTransport/measure() — unchanged logic)
│ ├── aggregate.py (verbatim copy of latprobe/aggregate.py —
│ │ its `from .probe import Result` is already
│ │ package-relative, needs zero edits)
│ ├── duration.py (verbatim copy of latprobe/duration.py — no
│ │ imports at all)
│ ├── cli.py (full standalone CLI — see below)
│ └── __main__.py (unchanged: `from .cli import run`)
└── tests/
├── __init__.py (empty, matches python/tests/__init__.py)
├── test_probe.py (moved from python/tests/test_hx_probe.py)
├── test_cli.py (moved from python/tests/test_hx_cli.py)
└── test_integration.py (moved from python/tests/test_integration_hx.py)
```
`python/` reverts to containing only `latprobe` — zero third-party deps, no
`pyproject.toml`, no venv, exactly its pre-hxprobe state.
## Step-by-step
**1. Move already-debugged files (preserve the bug fixes already made):**
`git mv`/`mv` (untracked, so plain `mv` is fine) `python/hxprobe/{probe.py,__init__.py,__main__.py}`
to `hxprobe/hxprobe/`, and the three `python/tests/test_hx_*.py` /
`test_integration_hx.py` files to `hxprobe/tests/` with the `hx_`/`_hx` name
segments dropped (`test_probe.py`, `test_cli.py`, `test_integration.py`).
Do **not** rewrite these from scratch — they already have the mark_dns /
verbose-on-failure-path / Content-Length-on-keep-alive fixes found during
the original implementation.
**2. Inline the dataclasses into `hxprobe/hxprobe/probe.py`:**
Replace `from latprobe.probe import Options, Phase, Result, VerboseDetail, _parse_cert`
with local definitions copied verbatim from `python/latprobe/probe.py`:
`CertInfo`, `VerboseDetail` (its `http_version`/`redirect_count` fields are
now simply always-meaningful, no more "populated only by hxprobe" caveat
comment needed), `Options` (with `follow_redirects`/`http2` as normal fields,
no more "ignored by socket measure()" caveat), `Phase`, `Result`,
`_parse_cert`, `_parse_cert_date`. Everything else in the file (`_Trace`,
`_TimingStream`, `_TimingBackend`, `_TimingTransport`, `measure()`,
`_classify`, `_unwrap`, `_fill_phases`, `_fill_verbose`) is untouched.
**3. Create `hxprobe/hxprobe/aggregate.py` and `duration.py`:**
Verbatim copies of `python/latprobe/aggregate.py` and `duration.py`.
**4. Write `hxprobe/hxprobe/cli.py` as a full standalone CLI:**
Start from the *current* `python/latprobe/cli.py` (it already has 100% of
the needed logic, including the `--no-http2`/`--no-follow-redirects` flags,
the verbose "Protocol" row, and the JSON `http_version`/`redirect_count`
keys — all added earlier specifically for hxprobe). Strip the
generalization scaffolding that only existed to let `latprobe` share this
code:
- Remove the `MeasureFn` type alias and the `measure_fn` parameter from
`_run_samples`/`run` — call `measure` directly (module-level import from
`.probe`).
- Remove `run()`'s `prog`/`description`/`protocol_flags` parameters —
hardcode `prog="hxprobe"` and the httpx-specific description.
- Make the `--no-http2`/`--no-follow-redirects` `add_argument` calls
unconditional (drop the `if protocol_flags:` guard).
- Everything else (exit codes, `_ArgExit`/`_Parser`, phase-label/verbose
constants, all `_print_*`/`_build_json_entry` rendering) carries over
unchanged — it's already correct standalone logic.
**5. Revert `python/latprobe/` to its pre-hxprobe state:**
`git checkout -- python/latprobe/cli.py python/latprobe/probe.py` (safe:
confirmed these are the only diffs since the last commit, and both diffs
are exactly the reuse scaffolding being removed here).
**6. Clean up the old shared-package artifacts:**
Delete `python/hxprobe/`, `python/pyproject.toml`, `python/.venv/`, and the
three `python/tests/test_hx_*`/`test_integration_hx.py` files (now moved).
**7. Makefile:**
- Remove `PY_VENV`/`PY_VENV_PYTHON` vars and the `py-deps` target; revert
`py-test`/`py-test-integration`/`py-check` to invoke `$(PYTHON)` directly
with no `py-deps` prerequisite (their pre-hxprobe form). Remove `hx-run`/
`hx-test-integration` from the Python section (moving out).
- Add `HX_DIR := hxprobe`, `HX_VENV := $(HX_DIR)/.venv`,
`HX_VENV_PYTHON := $(HX_VENV)/bin/$(PYTHON)` and a new "── hxprobe
(standalone) ──" section: `hx-deps` (idempotent venv+pip install, mirrors
the removed `py-deps`), `hx-run`, `hx-test` (hermetic, `test_[!i]*.py`
glob), `hx-test-integration` (`test_integration.py` pattern), `hx-check`,
`hx-clean`.
- Fold into the umbrella targets: `test: go-test py-test hx-test`,
`check: go-check py-check hx-check`, `clean: go-clean py-clean hx-clean`.
**8. Docs:**
- Rename `docs/usage/py-hxprobe.md``docs/usage/hxprobe.md` (drop the
`py-` prefix — it's no longer part of the Python port). Update: remove the
"reuses `latprobe.cli.run()` in full" claim (now false — say it's a fully
standalone implementation sharing only the *design*, not the code, with
`latprobe`); update setup/Makefile-target sections to `make hx-deps`/
`hx-run`/`hx-test`/`hx-test-integration`; update file paths from
`python/hxprobe/` to `hxprobe/hxprobe/`. Keep the TCP_NODELAY/Nagle finding
and the example transcripts (still accurate — behavior is unchanged, only
location/packaging changed).
- `CHANGELOG.md`: new entry describing the extraction.
- `docs/summaries/`: new dated summary per the CLAUDE.md convention
(leave the original `2026-07-02-00-29-py-hxprobe-httpx.md` as-is — it's a
historical record of that implementation; this is a follow-up).
- `docs/plans/`: save this plan as
`docs/plans/<yyyy-mm-dd-hh-mm>-hxprobe-standalone-project.md` at
implementation start, per CLAUDE.md.
## Verification
1. `rm -rf hxprobe/.venv python/.venv` (fresh state) then `make hx-deps`
creates `hxprobe/.venv`, installs `httpx[http2]` from `hxprobe/pyproject.toml`.
2. `grep -rn "latprobe" hxprobe/` — must return nothing (proves the
decoupling).
3. `make hx-run ARGS="-v http://github.com"` — same output as before (HTTP/2,
1 redirect followed, IP/TLS/cert shown).
4. `make hx-test` — all hermetic hxprobe tests pass standalone.
5. `make hx-test-integration` — live HTTP/2 + redirect tests still pass.
6. `make py-test` — confirms `latprobe`'s own suite is back to its original,
dependency-free form and still green (no `py-deps` needed to run it).
7. `make check` (top-level) — Go + latprobe + hxprobe all green in one gate.
8. `diff <(python -m latprobe --help) <(git show 24ea9c9:python/latprobe/cli.py | ...)`
or simpler: confirm `python -m latprobe --help` output is byte-identical
to before this whole feature existed (no leftover `--no-http2` etc.).

View File

@@ -0,0 +1,115 @@
# Plan: modernize hxprobe's Python toolchain
## Context
`hxprobe` (`hxprobe/`) is a fully standalone Python project (own
`pyproject.toml`, own venv, own tests — see
`docs/summaries/2026-07-02-09-32-hxprobe-standalone-project.md`). Its
toolchain is currently bare-minimum: hand-rolled `python -m venv` + `pip
install -e .`, no linter/formatter, no type checker, no dependency lockfile,
and stdlib `unittest` with a filename-glob convention
(`test_[!i]*.py`/`test_integration.py`) to separate hermetic from live tests.
User confirmed direction (via AskUserQuestion): adopt **uv** for env/deps
(with a lockfile), add **ruff** for lint+format, **skip mypy** for now, and
**swap the test runner to pytest** (existing `unittest.TestCase` classes run
unchanged under pytest — no test-code rewrite) using a proper
`@pytest.mark.integration` marker instead of the filename-glob trick.
Confirmed via inspection: `uv` (v0.11.19) is already installed globally
(Homebrew); `ruff`/`mypy`/`pytest` only exist under an unrelated pyenv 3.12.3
shim, not on the 3.14 interpreter this project targets — `uv` sidesteps that
mismatch by installing everything into the project's own venv. Git remote is
a self-hosted Gitea instance, not GitHub, so no CI is being set up here.
## Changes
**`hxprobe/pyproject.toml`:**
- Add `[dependency-groups]` with `dev = ["pytest>=8.0", "ruff>=0.8"]` (PEP
735, the current uv-native way to declare dev-only deps — keeps the
install-as-a-library `dependencies` list clean).
- Add `[tool.pytest.ini_options]`: `testpaths = ["tests"]` and a registered
`integration` marker (avoids `PytestUnknownMarkWarning`).
- Add `[tool.ruff]`: `target-version = "py311"` (matches `requires-python`)
and `line-length = 100` (close to the codebase's existing longest lines,
~103 chars, to minimize reformatting churn).
- Leave `[build-system]`/`[tool.setuptools]` untouched — build backend
wasn't part of the discussion, setuptools works fine here.
**`hxprobe/.python-version`:** new file, `3.14`, so `uv sync`/`uv run` pin the
same interpreter the rest of the repo uses without relying on `$PATH` order.
**`hxprobe/uv.lock`:** generated by `uv lock` — first real lockfile pinning
`httpx`, `httpcore`, `h2`, `certifi`, and friends. Committed (not gitignored;
lockfiles belong in version control).
**Test changes (runner swap only, no rewrite):**
- `hxprobe/tests/test_integration.py`: add `pytestmark = pytest.mark.integration`
at module level. This replaces the "named `test_integration.py` so the
`test_[!i]*.py` glob skips it" convention — selection becomes `-m
"not integration"` / `-m integration`, independent of filename.
- `test_probe.py`/`test_cli.py`: no changes — they're already hermetic
(local `http.server` fixtures only) and need no marker.
- `unittest.TestCase` classes, `_NEEDS_NET = unittest.skipUnless(...)`, and
the `if __name__ == "__main__": unittest.main()` guards all stay exactly
as-is — pytest natively discovers and runs unittest-style tests and
respects `unittest.skip*` decorators with zero changes required.
**Lint fixes:** run `ruff check --fix` / `ruff format` over `hxprobe/` and
review the diff. One known pre-existing issue it will flag: `import sys` in
`cli.py` is unused (inherited from the original `latprobe/cli.py`) — remove
it. Otherwise expect mostly whitespace/quote-style normalization.
**`Makefile`:** replace the `hx-*` section to run through `uv` instead of a
hand-managed venv:
```makefile
HX_DIR := hxprobe # (drop HX_VENV / HX_VENV_PYTHON — uv owns this now)
hx-deps: cd $(HX_DIR) && uv sync
hx-run: (deps: hx-deps) cd $(HX_DIR) && uv run python -m hxprobe $(ARGS)
hx-lint: (deps: hx-deps) cd $(HX_DIR) && uv run ruff check .
hx-fmt: (deps: hx-deps) cd $(HX_DIR) && uv run ruff format .
hx-test: (deps: hx-deps) cd $(HX_DIR) && uv run pytest tests -m "not integration" -v $(ARGS)
hx-test-integration: (deps: hx-deps) cd $(HX_DIR) && uv run pytest tests -m integration -v $(ARGS)
hx-check: hx-lint hx-test (test gate now includes lint, mirroring go-check's fmt+vet+test bundling)
hx-clean: also removes .pytest_cache / .ruff_cache alongside __pycache__/egg-info
```
Umbrella `test`/`check`/`clean` targets keep delegating to `hx-test`/
`hx-check`/`hx-clean` unchanged.
**`hxprobe/README.md`:** new, minimal — since this project is meant to be
`cp -r`-able to its own repo, it should carry its own quick-start
(`uv sync`, `uv run python -m hxprobe <url>`, `uv run pytest`) rather than
relying on the monorepo's root docs.
**Docs:** update `docs/usage/hxprobe.md`'s "Setup" section (`uv sync`
instead of manual venv+pip) and "Makefile targets" section (add
`hx-lint`/`hx-fmt`, update test invocation description). New
`docs/plans/<timestamp>-hxprobe-toolchain-modernization.md` and
`docs/summaries/<timestamp>-hxprobe-toolchain-modernization.md` per
CLAUDE.md convention. New `CHANGELOG.md` entry.
**`.gitignore`:** already covers `.venv/`/`__pycache__/`/`*.egg-info/`
unanchored; add `.pytest_cache/` and `.ruff_cache/` (new caches these tools
create).
## Verification
1. `cd hxprobe && uv sync` — creates `.venv`, generates/uses `uv.lock`,
installs `httpx[http2]` + dev deps (`pytest`, `ruff`).
2. `make hx-run ARGS="-v http://github.com"` — same output as before
(HTTP/2, 1 redirect, IP/TLS/cert shown) — confirms the runtime behavior
is untouched by the toolchain swap.
3. `make hx-lint` — clean (after fixing whatever `ruff check` surfaces,
including the unused `import sys`).
4. `make hx-test` — all hermetic tests pass under pytest; confirm the
`integration`-marked tests are excluded (test count matches the current
28 hermetic tests).
5. `make hx-test-integration` — the 9 live tests run and pass under the
`integration` marker selection.
6. `make check` (top-level) — Go + latprobe + hxprobe (lint + test) all
green in one gate.
7. `grep -rn "latprobe" hxprobe/` — still zero matches (toolchain change
must not reintroduce coupling).
8. Confirm `python/latprobe/` is untouched (`git diff --stat python/` empty)
— this is a hxprobe-only change.

View File

@@ -0,0 +1,112 @@
# Plan: hxprobe-specific usage doc (case-by-case) + hxprobe-specific Makefile
## Context
`hxprobe` is a fully standalone project (own `pyproject.toml`, `uv.lock`,
`README.md` — see `docs/summaries/2026-07-02-09-32-hxprobe-standalone-project.md`
and `...-toolchain-modernization.md`). Two gaps remain versus the other
Python implementations and versus hxprobe's own "could be `cp -r`'d to its
own repo" design goal:
1. **Docs**: `latprobe`/`phases.py`/`simple.py` each have two usage docs —
the CLAUDE.md-mandated `docs/usage/py-<name>.md` (what/flags/one example)
_and_ a much richer `python/configs/usage-<name>.md` "Runnable Usage
Reference" walking through a full set of concrete cases with real
captured output (confirmed by reading `python/configs/usage-latprobe.md`,
313 lines: basic, verbose × 4 variants, sampling, multi-URL, `--fail`,
JSON × 2, timeout, exit-codes table, Makefile shortcuts). `hxprobe` only
has the first kind. User confirmed: add the second kind, placed _inside_
`hxprobe/` itself (not under `python/configs/`, since hxprobe no longer
lives there) so the doc travels with the project if extracted.
2. **Makefile**: hxprobe currently has no `Makefile` of its own — the only
way to run/test/lint it is through the parent repo's root `Makefile`.
Extracted to its own repo, there'd be no `make` interface left. User
confirmed: add `hxprobe/Makefile`, fully independent from the root
Makefile's existing `hx-*` targets (no delegation either direction —
both keep their own complete logic, at the cost of some duplication).
Confirmed: neither `latprobe` nor `hxprobe` accept a config file (both take
URLs as positional CLI args — only `simple.py`/`phases.py` read the
`python/configs/*.txt` files), so no `.txt`-config-file equivalent is needed
for hxprobe; this is a docs+Makefile-only task.
## Changes
**`hxprobe/USAGE.md`** (new) — modeled directly on
`python/configs/usage-latprobe.md`'s structure and tone (concrete `sh`
command blocks immediately followed by real captured output, real IPs/certs/
timings, brief explanatory notes, `---` section separators). Cases, in order:
1. Basic — single URL
2. Verbose — HTTPS site (shows `Protocol: HTTP/2`, TLS, cert)
3. Verbose — plain HTTP (no TLS block)
4. Verbose — redirect followed by default (`http://github.com` → 200,
`Protocol: HTTP/2 (1 redirect)`) — **hxprobe-specific**, latprobe has no
equivalent
5. `--no-follow-redirects` — same URL, raw `301` instead — **hxprobe-specific**
6. `--no-http2` — forces `Protocol: HTTP/1.1`**hxprobe-specific**
7. Verbose — TLS failure (expired cert, badssl.com)
8. Verbose — DNS failure (empty verbose block, suppressed)
9. Sampling (`-n`) — min/avg/max table, plus verbose+sampling
10. Multiple URLs (parallel probing)
11. `--fail` flag — exit 6 on HTTP 4xx
12. JSON output
13. JSON + verbose (includes `http_version`/`redirect_count` keys)
14. Timeout
15. Exit codes table (same 06 scheme as `latprobe`/Go)
16. Makefile shortcuts — both the new `hxprobe/Makefile` (`make run`,
`make test`, etc., run from inside `hxprobe/`) and the parent repo's
root shortcuts (`make hx-run`, run from the repo root)
Cases 1, 2, 3, 4, 5, 6, 8, 11 can reuse real output already captured earlier
in this session (still accurate — no code changed since). Cases 7, 9, 10,
12, 13, 14 need fresh live runs during implementation to get real numbers
(same standard the other `usage-*.md` docs hold themselves to — no
fabricated timings).
**`docs/usage/hxprobe.md`** (edit) — add a one-line pointer near the top:
"For a full case-by-case runnable reference, see `hxprobe/USAGE.md`." No
other changes; it stays the CLAUDE.md-mandated summary doc.
**`hxprobe/Makefile`** (new) — fully self-sufficient, same auto-generated
`## comment` help style as the root Makefile, short target names (no `hx-`
prefix needed since it's already scoped by being inside `hxprobe/`):
```makefile
PYTHON ?= python3.14
ARGS ?=
.DEFAULT_GOAL := help
help # auto-generated from ## comments, same style as root Makefile
deps # uv sync
run # uv run python -m hxprobe $(ARGS) (deps: deps)
lint # uv run ruff check . (deps: deps)
fmt # uv run ruff format . (deps: deps)
test # uv run pytest tests -m "not integration" -v $(ARGS) (deps: deps)
test-integration # uv run pytest tests -m integration -v $(ARGS) (deps: deps)
check # lint + test
clean # remove __pycache__/*.pyc/*.egg-info/.pytest_cache/.ruff_cache
```
No changes to the root `Makefile` — its existing `hx-*` targets are left
exactly as-is per the "keep both independent" decision.
**Docs housekeeping** (per CLAUDE.md convention): save this plan to
`docs/plans/<timestamp>-hxprobe-usage-doc-and-makefile.md`, write a summary
to `docs/summaries/<timestamp>-hxprobe-usage-doc-and-makefile.md` after
implementation, and append a `CHANGELOG.md` entry.
## Verification
1. `cd hxprobe && make help` — lists all targets with descriptions, works
with zero dependency on the parent repo's Makefile.
2. `cd hxprobe && make run ARGS="-v https://example.com"` — same output as
`make hx-run ARGS="-v https://example.com"` from the repo root (proves
the two Makefiles agree, without one calling the other).
3. `cd hxprobe && make check` — lint + hermetic tests pass (28 tests).
4. `cd hxprobe && make test-integration` — 9 live tests pass.
5. Re-run every command block in `hxprobe/USAGE.md` and confirm the
captured output matches what's printed in the doc (structure must be
stable even if exact millisecond timings drift).
6. Confirm the root `Makefile`'s `hx-*` targets are byte-for-byte unchanged
(`git diff Makefile` shows no `hx-*` section changes from this task).

View File

@@ -0,0 +1,102 @@
# Plan: read target URLs from a file for hxprobe
## Context
`hxprobe` currently only accepts URLs as positional CLI arguments
(`hxprobe/hxprobe/cli.py:368`, `nargs="+"`). The user wants a file-based
input mode too, matching the existing convention `simple.py`/`phases.py`
already use (`python/simple.py:20-30`'s `load_sites()`: plain text, one URL
per line, `#`-comments and blank lines skipped, first whitespace-separated
token taken per line).
User-confirmed decisions:
- **Mutually exclusive** with positional URL args (either pass URLs on the
command line, or `-f FILE`, never both).
- **hxprobe only** — `latprobe` is intentionally left untouched.
- **Add example fixture files** (`hxprobe/configs/*.txt`, mirroring
`python/configs/*.txt`'s exact set: all-ok, dns-failure,
connection-refused, timeout, tls-errors, http-errors, mixed) plus one new
section in the existing `hxprobe/USAGE.md` demonstrating the flag.
## Implementation
**`hxprobe/hxprobe/cli.py`:**
- `urls` positional becomes `nargs="*"` (was `nargs="+"`) — no longer
required on its own, since `-f` is now a second valid source.
- New flag: `-f, --file PATH` — "read URLs from a file, one per line,
`#` comments allowed (mutually exclusive with positional url args)".
Placed right after the `urls` positional definition in the argparse
block, since the two are the two ways of specifying what to probe.
- New helper `_load_urls(path: str) -> list[str]`, duplicating (not
importing) `simple.py`'s `load_sites()` logic — consistent with hxprobe's
established "imports nothing outside its own directory" rule from the
standalone-extraction work.
- After `parser.parse_args()`, manual validation (mirrors the existing
`--timeout` invalid-value handling style — write to the injected
`stderr`, `return EXIT_USAGE`, rather than routing through
`argparse`'s mutually-exclusive-group machinery, which doesn't mix
cleanly with a variadic positional):
- both `ns.urls` and `ns.file` given → `parser.error(...)` (usage error,
consistent with how `_Parser.error()` already handles bad usage)
- neither given → `parser.error(...)`
- `ns.file` given but unreadable (`FileNotFoundError`/`OSError`) →
`stderr.write(...)`; `return EXIT_USAGE`
- `ns.file` given but yields zero URLs → same treatment
- otherwise `urls = _load_urls(ns.file)` or `urls = ns.urls`
**`hxprobe/tests/test_cli.py`:** new hermetic tests — successful multi-URL
run from a file, missing-file error, empty-file error, and the
both-sources-given usage error. Uses a temp file (`tempfile`), no network
needed for the parsing-error cases.
**`hxprobe/configs/*.txt`** (new directory) — same 7 fixtures as
`python/configs/`, adapted:
- `all-ok.txt`, `dns-failure.txt`, `connection-refused.txt`,
`tls-errors.txt` — same URLs, same behavior (DNS/TCP/TLS failures are
identical regardless of HTTP client sophistication); only the header
comments change (`hxprobe -f configs/<name>.txt` instead of
`python3.14 python/simple.py ...`).
- `timeout.txt` — same two targets (`10.255.255.1`, `192.0.2.1`, RFC 5737
TEST-NET-1) as the original; "expected exit code" documents normal-network
behavior (exit 4), same caveat the original file already carries about
network-dependent behavior.
- `http-errors.txt` — same 404 URLs; header comment updated to show the
demo command with `--fail` (hxprobe treats 4xx as success without
`--fail`, unlike `simple.py`, which always raises on HTTPError) —
"expected exit code" becomes 6, not `simple.py`'s blanket 1.
- `mixed.txt` — same mixed set; demo command includes `--fail`; expected
exit code recalculated as the worst code across the included classes
(dns=2, connect=3, tls=5, http=6 with `--fail`) → 6.
- Each header's "Expected exit code" will be verified by actually running
the fixture through `hxprobe -f ...` during implementation, not assumed
from the `simple.py` originals — hxprobe's worst-code-wins exit scheme
(`hxprobe/hxprobe/cli.py:455-481`) differs fundamentally from
`simple.py`'s blanket 0/1.
**`hxprobe/USAGE.md`:** new section "Reading URLs from a file (`-f`)",
placed after the "Multiple URLs" case (same family of "what to probe"
examples) — command + real captured output using `configs/all-ok.txt` or
`configs/mixed.txt`, plus a short list of the other fixture files available
and what each demonstrates.
**Docs housekeeping** (per CLAUDE.md convention): save this plan under
`docs/plans/`, write a summary under `docs/summaries/` after implementation,
append a `CHANGELOG.md` entry.
## Verification
1. `cd hxprobe && uv run python -m hxprobe -f configs/all-ok.txt` — probes
all 3 URLs, exit 0.
2. `cd hxprobe && uv run python -m hxprobe -f configs/dns-failure.txt`
exit 2; `connection-refused.txt` → exit 3; `tls-errors.txt` → exit 5;
`--fail -f configs/http-errors.txt` → exit 6; `--fail -f configs/mixed.txt`
→ exit 6 (confirms the worst-code documented in each header is accurate).
3. `uv run python -m hxprobe https://example.com -f configs/all-ok.txt`
usage error (both sources given).
4. `uv run python -m hxprobe -f /no/such/file` — usage error, clear message.
5. `cd hxprobe && make check` — new hermetic tests pass alongside the
existing 28.
6. Re-run the new `hxprobe/USAGE.md` section's command and confirm captured
output matches what's printed in the doc.
7. `grep -n "import" hxprobe/hxprobe/cli.py` — confirm no new import from
`python/simple.py` or anywhere outside `hxprobe/`.

View File

@@ -0,0 +1,174 @@
# hxprobe simplification plan
## Context
The `hxprobe` Python package works and is well-tested, but it carries several
pieces of **speculative scaffolding** — generality added for things that "might
happen later" — plus some **redundant state** and **duplicated logic**. The
user's explicit directive: keep the code simple, with no unnecessary
abstractions or scaffolding for hypothetical future features.
This plan removes that overhead without changing any observable behavior. All
existing tests in `tests/test_cli.py` and `tests/test_probe.py` must continue
to pass unchanged (they are the behavioral contract). Net effect: ~4050 fewer
lines, fewer moving parts, no new abstractions.
The trigger was a question about the `_Parser._print_message`/`print_help`/
`print_usage` overrides, whose `file=` parameter is accepted but never used —
the classic "kept for a future that never came" smell. Investigation showed the
whole `_Parser` subclass re-implements behavior the standard library already
provides.
---
## Changes
### 1. Delete the `_Parser` subclass — use stdlib stream redirection
**File:** `hxprobe/hxprobe/cli.py` (lines 6094, and the `run()` wiring ~375455)
The `_Parser` class + `_ArgExit` exception (~32 lines) exist only to (a) route
argparse's help/usage/error output to the injected `stdout`/`stderr` streams and
(b) raise instead of calling `sys.exit()`. The standard library already does
both:
- `argparse.ArgumentParser` writes help to `sys.stdout` and usage/errors to
`sys.stderr` by default, and already raises `SystemExit` (not a hard exit) —
so it is already testable.
- `contextlib.redirect_stdout(stdout)` / `redirect_stderr(stderr)` patch the
streams argparse writes to.
**Do:**
- Remove `class _ArgExit`, `class _Parser`, and all four overrides
(`_print_message`, `print_help`, `print_usage`, `error`, `exit`).
- In `run()`, build a plain `argparse.ArgumentParser(prog="hxprobe", ...)` (drop
the `out=`/`err=` kwargs).
- Wrap the parse + the two manual validations in redirection and catch
`SystemExit`:
```python
import contextlib
...
try:
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
ns = parser.parse_args(args)
if ns.urls and ns.file:
parser.error("cannot combine positional url arguments with -f/--file")
if not ns.urls and not ns.file:
parser.error("no URLs given (pass as arguments or with -f/--file)")
except SystemExit as exc:
return EXIT_OK if not exc.code else EXIT_USAGE
```
**Behavior parity (verified against tests):**
- `-h` → argparse prints help to `stdout`, raises `SystemExit(0)` → returns
`EXIT_OK`. (`test_help_shows_hxprobe_prog_name`, `test_help_lists_protocol_flags`)
- `parser.error(...)` → prints `prog: error: msg` + usage to `stderr`, raises
`SystemExit(2)` → mapped to `EXIT_USAGE`. (`test_*_is_usage_error`)
- Note: stdlib `error()` exits with code 2; we normalise any non-zero parse exit
to `EXIT_USAGE` (1) at the single catch site, replacing the per-method code
baked into the old override.
Only the parse/validate block needs redirection; the rest of `run()` keeps
writing directly to the passed `stdout`/`stderr`.
### 2. Trim `_TimingStream.get_extra_info` to the branch that's actually used
**File:** `hxprobe/hxprobe/probe.py` (lines 262275)
`server_addr` and `client_addr` are only ever queried by `httpx/_main.py` (the
`httpx` CLI command), never by the request path hxprobe drives — verified by
grepping the installed `httpcore`/`httpx`. The only branch httpcore's sync
connection path calls is `ssl_object` (and `is_readable`, which we intentionally
leave unhandled → `None`).
**Do:** reduce the method to:
```python
def get_extra_info(self, info: str):
if info == "ssl_object" and isinstance(self._sock, ssl.SSLSocket):
return self._sock
return None
```
### 3. Drop the `_load_urls` "future annotations" scaffolding
**File:** `hxprobe/hxprobe/cli.py` (lines 358369)
The `line.split()[0]` + docstring ("forward-compatible with future
'url key=value' annotations") is scaffolding for a feature that doesn't exist.
Use the stripped line directly and simplify the docstring to describe what it
actually does (one URL per line, `#` comments and blank lines skipped).
`test_reads_urls_from_file` (comments + blanks) still passes.
### 4. Remove redundant `_set` flags in `_Trace`
**File:** `hxprobe/hxprobe/probe.py` (lines 126191)
`_dns_set`, `_connect_set`, `_tls_set` duplicate information already carried by
`Phase.present` on the corresponding `self.dns` / `self.connect` / `self.tls`.
The initial `Phase()` has `present=False`, so the first-hop-wins guard is
identical.
**Do:** delete the three boolean fields; replace each guard, e.g.
`if not self._dns_set:``if not self.dns.present:` (same for connect/tls).
Keeps the redirect first-hop-wins semantics documented at probe.py:114123.
### 5. De-duplicate the failure counting logic
**File:** `hxprobe/hxprobe/cli.py`
The identical "dedupe failures into ordered (phase, message, count)" loop appears
twice: `_print_failure_summary` (lines 242258) and `_build_json_entry`
(lines 314325). Extract one helper next to the other rendering helpers:
```python
def _summarize_failures(failed: list[Result]) -> list[tuple[str, str, int]]:
counts: dict[tuple[str, str], int] = {}
order: list[tuple[str, str]] = []
for r in failed:
key = (r.fail_phase, str(r.err))
if key not in counts:
order.append(key)
counts[key] = 0
counts[key] += 1
return [(ph, msg, counts[(ph, msg)]) for ph, msg in order]
```
Rewrite both call sites to consume it (text side keeps the `1 ×` vs `N ×`
formatting; JSON side maps to `{"phase", "count", "message"}`).
---
## Explicitly NOT changing (considered, kept)
- **The custom httpcore backend** (`_TimingBackend`/`_TimingStream`/
`_TimingTransport`) — this *is* the tool's reason to exist (splitting DNS/TCP,
timing TLS). Not scaffolding.
- **`ThreadPoolExecutor` concurrency** — backs the shipped `-c/--concurrency`
and multi-URL/`-f` features. Real, not speculative.
- **Explicit per-phase dataclass fields** in `probe.py`/`aggregate.py` — a loop
would be shorter but less readable; explicit is clearer and matches the text/
JSON renderers. Leave as-is.
- **`CertInfo.sans`** — not shown in the text block but *is* emitted in JSON
verbose output; it's a real feature, not dead.
---
## Verification
1. **Unit/CLI tests (primary contract):**
```
cd hxprobe && .venv/bin/python -m pytest tests/test_cli.py tests/test_probe.py -q
```
All must pass with no edits to the test files. These already cover: `-h`
help→stdout + exit 0, the four usage errors→stderr + exit 1, DNS/connect/
timeout failures, `--fail`, `--json`, redirects, `--no-http2`,
`--no-follow-redirects`, `-f` file input, and verbose/cert/TLS detail.
2. **Lint:** `cd hxprobe && .venv/bin/ruff check hxprobe/`
3. **Manual smoke (help + error routing, since #1 rewrites that path):**
```
.venv/bin/python -m hxprobe -h # help on stdout, exit 0
.venv/bin/python -m hxprobe # "no URLs given" on stderr, exit 1
.venv/bin/python -m hxprobe https://example.com -v # phases + verbose block
```
4. **Per-CLAUDE.md project conventions:** after implementing, add a
`docs/summaries/<yyyy-mm-dd-hh-mm>-hxprobe-simplification.md` summary and a
`CHANGELOG.md` entry. (No `docs/usage/` change — behavior is unchanged.)

View File

@@ -0,0 +1,136 @@
# hxprobe: end-of-run summary footer for multi-URL runs
## Context
A single scalar exit code is inherently lossy when probing multiple URLs with
different failure classes: today `run()` returns the numeric max across all
URLs (e.g. DNS-fail on one URL + TLS-fail on another → exit `5`, and the DNS
failure is invisible in the code). The failures *are* all printed per-URL, but
there's no consolidated view — for many URLs you must scroll and eyeball each
block to know what happened overall.
The exit code's job is a coarse pass/fail + severity hint for scripts, and it's
a **deliberate cross-implementation contract** shared with `latprobe` and the
Go version (documented in `hxprobe/USAGE.md:475-488`, asserted by 13 tests in
`tests/test_cli.py`). So we keep the worst-code exit unchanged and instead give
humans the full picture the scalar can't: an **end-of-run summary footer** that
tallies every URL's outcome and shows how the exit code was derived.
Decisions (confirmed with the user):
- Exit code: **unchanged** (worst/highest severity across URLs).
- Summary: **text footer, multi-URL only** (`len(urls) > 1`). Single-URL text
output stays byte-identical; JSON output stays a bare array (unchanged).
## Design
### Per-URL classification (reuses existing helpers)
Each URL gets one "worst outcome code" using the *existing* mapping — no new
severity scheme:
- start at `EXIT_OK`;
- for each failed sample: `code = max(code, _phase_code(r.fail_phase))`
(`_phase_code` at `cli.py:31`);
- if `--fail`: for each succeeded sample with `status_code >= 400`:
`code = max(code, EXIT_HTTP)`.
A URL is "ok" iff its code is `EXIT_OK`, else "failed" and bucketed by its code.
A partially-failed URL (some samples ok, some failed) classifies by its worst
sample — consistent with how its own block and the global exit code already
behave.
### Footer format (text, only when `len(urls) > 1`)
Printed once after the last URL block, before `return worst`. Failure classes
use the same `✗` bullet style as `_print_failure_summary` (`cli.py:219-224`).
The final `→ exit N (label)` line explicitly ties the tally to the returned
code — directly answering "why is the exit code what it is". Example (3 URLs):
```
https://example.com (200)
... phase rows ...
http://no.such.host.invalid (FAILED)
✗ dns: [Errno 8] nodename nor servname provided
https://self-signed.badssl.com (FAILED)
✗ tls: certificate verify failed
─────────────────────────────────────────────────
Summary: 3 URLs — 1 ok, 2 failed
✗ dns : 1
✗ tls : 1
→ exit 5 (tls)
```
All-ok multi-URL run → `Summary: 3 URLs — 3 ok`, no `✗` lines, `→ exit 0 (ok)`.
## Changes
All in `hxprobe/hxprobe/cli.py` unless noted.
1. **Reverse label map** next to the exit-code constants (`cli.py:16-32`): a
small `_EXIT_LABELS: dict[int, str]` mapping `EXIT_DNS→"dns"`,
`EXIT_CONNECT→"connect"`, `EXIT_TIMEOUT→"timeout"`, `EXIT_TLS→"tls"`,
`EXIT_HTTP→"http"`, `EXIT_OK→"ok"`. (Inverse of the existing forward mapping;
kept explicit for readability, matching the codebase's style.)
2. **Refactor the accumulation loop** (`cli.py:454-462`) to compute a per-URL
code and fold it into `worst`, collecting `url_codes: list[int]` (one per
URL, index-aligned with `urls`). This also unifies the two "running max"
idioms flagged in
`docs/explanations/2026-07-02-13-25-hxprobe-worst-exit-code-and-render-loop.md`
(`if c > worst` vs `max(...)`) into one — a small simplification bonus.
3. **New `_print_run_summary(urls, url_codes, worst, out)` helper** (near the
other `_print_*` renderers): builds the ok/failed tally + per-class counts
from `url_codes` and writes the footer. No-op guard is the caller's
`len(urls) > 1` check.
4. **Call site** after the loop (`cli.py:470-475`): in text mode only
(`if not ns.json_out and len(urls) > 1:`), call `_print_run_summary(...)`
before `return worst`. JSON path untouched — still `json.dumps(json_items)`
as a bare array.
## Tests (`hxprobe/tests/test_cli.py`)
Add hermetic tests (reuse the existing `_OKHandler`/`_start_server`/`_invoke`
harness and `_free_port` for a refused connection):
- **multi-URL mixed** — one OK server URL + one `http://127.0.0.1:<free>`
(connection refused): assert `code == EXIT_CONNECT`, and the footer strings
are present (`"Summary: 2 URLs"`, `"1 ok"`, `"1 failed"`, `"connect : 1"`,
`"→ exit 3"`).
- **multi-URL all ok** — two OK URLs: assert `code == EXIT_OK` and
`"Summary: 2 URLs — 2 ok"` present; verify no `"✗"` in the footer region.
- **single URL has NO footer** — one OK URL: assert `"Summary:"` NOT in `out`
(locks the multi-URL-only rule).
Backward-compat guard: the footer contains no `"200"` substring, so the
existing `test_reads_urls_from_file` assertion `out.count("200") == 2` still
holds; all 13 existing exit-code tests are unaffected (worst-code unchanged).
## Docs / project conventions (per CLAUDE.md)
- **Copy this approved plan** into
`docs/plans/<yyyy-mm-dd-hh-mm>-hxprobe-run-summary-footer.md` as the first
implementation step (before code) — see the `feedback_plan_mode_docs_plans`
memory note.
- `hxprobe/USAGE.md`: add a short "Multi-URL summary footer" subsection with a
real captured example; add a sentence to the Exit-codes section noting the
footer shows the per-class breakdown behind the scalar. Exit-code table
itself is unchanged.
- `docs/usage/hxprobe.md`: one-line mention if it lists features.
- New `docs/explanations/<ts>-hxprobe-run-summary-footer.md` is optional; the
existing worst-exit-code explanation can get a short "Update:" pointer.
- `CHANGELOG.md`: new timestamped entry.
- `docs/summaries/<ts>-hxprobe-run-summary-footer.md`: implementation summary.
## Verification
1. `cd hxprobe && .venv/bin/python -m pytest tests/test_cli.py tests/test_probe.py -q`
— all existing + new tests pass.
2. `.venv/bin/ruff check hxprobe/` — clean.
3. Manual, capturing exit codes:
```
.venv/bin/python -m hxprobe https://example.com https://example.org # footer, exit 0
.venv/bin/python -m hxprobe https://example.com http://no.such.host.invalid; echo $? # footer w/ dns:1, exit 2
.venv/bin/python -m hxprobe https://example.com # single URL: NO footer, unchanged
.venv/bin/python -m hxprobe --json https://example.com https://example.org # bare JSON array, NO footer
```