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:
100
docs/summaries/2026-07-02-00-29-py-hxprobe-httpx.md
Normal file
100
docs/summaries/2026-07-02-00-29-py-hxprobe-httpx.md
Normal 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.
|
||||
117
docs/summaries/2026-07-02-09-32-hxprobe-standalone-project.md
Normal file
117
docs/summaries/2026-07-02-09-32-hxprobe-standalone-project.md
Normal 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.)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
77
docs/summaries/2026-07-02-11-14-hxprobe-file-input.md
Normal file
77
docs/summaries/2026-07-02-11-14-hxprobe-file-input.md
Normal 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 0–6 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)
|
||||
83
docs/summaries/2026-07-02-12-05-hxprobe-simplification.md
Normal file
83
docs/summaries/2026-07-02-12-05-hxprobe-simplification.md
Normal 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.
|
||||
105
docs/summaries/2026-07-02-14-05-hxprobe-run-summary-footer.md
Normal file
105
docs/summaries/2026-07-02-14-05-hxprobe-run-summary-footer.md
Normal 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 0–6 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.
|
||||
Reference in New Issue
Block a user