# Changelog All completed features are logged here in reverse-chronological order. --- ## 2026-07-02 14:05 — `hxprobe`: end-of-run summary footer for multi-URL runs - `hxprobe/hxprobe/cli.py`: when more than one URL is probed (positional args or `-f`), a footer is now appended after the last URL block — a tally of every URL's outcome (`N ok`, `M failed` broken down by class) plus the `→ exit N (label)` line tying it to the process exit code. Single-URL text output and JSON output (still a bare array) are both byte-identical to before — new `_print_run_summary()`, gated on `len(urls) > 1` and text mode only - The process **exit code itself is unchanged**: still the highest severity across all URLs (worst-code wins), matching `latprobe`/Go and the 13 existing exit-code tests — a deliberate decision, since that scalar is a documented cross-implementation contract (`hxprobe/USAGE.md`'s Exit codes table). The footer exists to give humans the per-URL breakdown the scalar can't show, not to change what gets returned - New `_EXIT_LABELS` (inverse of `_PHASE_EXIT`) for rendering exit codes as short labels (`dns`, `tls`, `http`, …) in the footer - Refactored the per-URL accumulation loop to compute one worst-code per URL first, then fold into the global `worst` — this also unified the two "running max" idioms (`if c > worst: worst = c` vs `max(worst, ...)`) that `docs/explanations/2026-07-02-13-25-hxprobe-worst-exit-code-and-render-loop.md` had flagged as a stylistic wrinkle into a single `max(...)` call - `hxprobe/tests/test_cli.py`: 3 new hermetic tests (`TestCLIRunSummary`) — multi-URL mixed outcome, multi-URL all-ok, and single-URL-has-no-footer; all 33 pre-existing tests pass unedited - Triggered by a follow-up question: does a single worst-code exit even make sense across multiple URLs with different error classes? Answer: keep the scalar (parity contract) but add the missing visibility as output, not by changing the exit code's semantics - `hxprobe/USAGE.md`: new "Multi-URL summary footer" section with real captured output; refreshed the "Multiple URLs" and `-f` multi-URL examples to show the footer (previously stale — captured before this feature existed); added a sentence to the Exit codes section - `docs/usage/hxprobe.md`: one-line mention pointing at the new section - Plan: `docs/plans/2026-07-02-14-05-hxprobe-run-summary-footer.md` - Summary: `docs/summaries/2026-07-02-14-05-hxprobe-run-summary-footer.md` --- ## 2026-07-02 12:05 — `hxprobe` simplification pass (no behavior change) - `hxprobe/hxprobe/cli.py`: deleted the `_Parser`/`_ArgExit` scaffolding (~32 lines) that hand-reimplemented what `argparse.ArgumentParser` already does (help→stdout, usage/errors→stderr, raise instead of hard-exit); replaced with a plain `ArgumentParser` wrapped in `contextlib.redirect_stdout`/`redirect_stderr`, catching `SystemExit` at one site - `hxprobe/hxprobe/probe.py`: trimmed `_TimingStream.get_extra_info` to the one branch (`ssl_object`) actually consumed on hxprobe's request path — the `server_addr`/`client_addr` branches were dead (only `httpx`'s own CLI queries them); removed `_dns_set`/`_connect_set`/`_tls_set` from `_Trace`, redundant with the `Phase.present` flag already on `self.dns`/`connect`/`tls` - `hxprobe/hxprobe/cli.py`: `_load_urls` no longer takes only the first whitespace token per line ("forward-compatible with future `key=value` annotations" that never materialized); extracted `_summarize_failures()` to remove a duplicated dedup loop shared by `_print_failure_summary` and `_build_json_entry` - Triggered by a question about a dead `file=` parameter on `_Parser.print_help`/`print_usage`; verified no behavior changed — all 33 existing tests pass unedited, plus manual smoke tests of `-h`, no-args (stdout/stderr routing double-checked via separate file redirection), and a live verbose request - Plan: `docs/plans/2026-07-02-12-05-hxprobe-simplification.md` - Summary: `docs/summaries/2026-07-02-12-05-hxprobe-simplification.md` --- ## 2026-07-02 11:14 — `hxprobe`: read target URLs from a file (`-f`/`--file`) - `hxprobe/hxprobe/cli.py`: new `-f`/`--file PATH` flag reads URLs from a plain-text file (one per line, `#` comments, blank lines skipped, first whitespace-separated token per line) — same format as `simple.py`'s `load_sites()`, reimplemented locally as `_load_urls()` rather than imported (hxprobe still imports nothing outside its own directory) - `-f`/`--file` is **mutually exclusive** with positional `url` args (both or neither given → usage error); `urls` positional changed from `nargs="+"` to `nargs="*"` to allow the file-only case - Missing file / unreadable file / empty file all produce a clear usage error (`EXIT_USAGE`) rather than a traceback - `hxprobe/configs/*.txt`: 7 new fixtures mirroring `python/configs/`'s set (all-ok, dns-failure, connection-refused, timeout, tls-errors, http-errors, mixed), each with a verified expected exit code in its header comment — actually run against `hxprobe -f ...` during implementation, not assumed from the `simple.py` originals (whose blanket 0/1 exit scheme differs from hxprobe's per-failure-class 0–6) - `hxprobe/tests/test_cli.py`: 5 new hermetic tests (`TestCLIFileInput`) — successful multi-URL read from a temp file, missing file, empty file, both-sources-given, and neither-given error paths - `hxprobe/USAGE.md`: new "Reading URLs from a file (`-f`)" section with real captured output, placed after "Multiple URLs" - Plan: `docs/plans/2026-07-02-11-14-hxprobe-file-input.md` - Summary: `docs/summaries/2026-07-02-11-14-hxprobe-file-input.md` --- ## 2026-07-02 10:22 — `hxprobe` usage reference doc + standalone Makefile - `hxprobe/USAGE.md`: new "Runnable Usage Reference" matching the depth of `python/configs/usage-latprobe.md` — 16 cases with real captured output (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). Placed inside `hxprobe/` itself (not `python/configs/`) so it travels with the project if extracted to its own repo - Timeout example uses `192.0.2.1` (RFC 5737 TEST-NET-1) instead of `10.255.255.1` — the latter resolves to an immediate "connection refused" in this dev sandbox rather than a genuine timeout; `192.0.2.1` reproduces a real ~500ms timeout reliably - `docs/usage/hxprobe.md`: added a one-line pointer to `hxprobe/USAGE.md` at the top; no other changes — it stays the CLAUDE.md-mandated summary doc - `hxprobe/Makefile`: new, fully standalone (`help`, `deps`, `run`, `lint`, `fmt`, `test`, `test-integration`, `check`, `clean`) — same auto-generated `## comment` help style as the root Makefile, short target names (no `hx-` prefix needed inside `hxprobe/`'s own scope). Deliberately independent from the root Makefile's `hx-*` targets — neither calls into the other, per explicit user decision (avoids one Makefile's changes silently breaking the other, at the cost of some duplicated `uv`/`pytest` invocation logic) - No changes to the root `Makefile` — verified its `hx-*` section is byte-for-byte unchanged - Plan: `docs/plans/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md` - Summary: `docs/summaries/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md` --- ## 2026-07-02 09:57 — `hxprobe` toolchain modernization (uv, ruff, pytest) - Adopted `uv` for environment/dependency management: `hxprobe/pyproject.toml` gets a `[dependency-groups] dev = ["pytest>=8.0", "ruff>=0.8"]` section; `hxprobe/.python-version` pins Python 3.14; `hxprobe/uv.lock` (new, 18 packages resolved) pins every transitive dependency (`httpx`, `httpcore`, `h2`, `certifi`, etc.) for reproducible installs — previously nothing was pinned beyond `httpx[http2]>=0.28` - Added `ruff` for linting + formatting: `[tool.ruff]` config (`target-version = "py311"`, `line-length = 100`); fixed the one real finding (`import sys` unused in `cli.py`, inherited from the original `latprobe/cli.py`); ran `ruff format` across the project (6 files reformatted — mostly collapsing the hand-aligned `=`/dict-key columns to single-space, no semantic changes; verified via full syntax check + test run before and after) - Swapped the test runner from stdlib `unittest discover` to `pytest`. Existing `unittest.TestCase` classes run unchanged (pytest is a superset runner) — no test-code rewrite. Replaced the `test_[!i]*.py` filename-glob hermetic/integration split with a proper `pytest.mark.integration` marker (`pytestmark = pytest.mark.integration` in `tests/test_integration.py`); registered in `[tool.pytest.ini_options]` to avoid unknown-marker warnings - Skipped mypy for now (type hints stay as documentation only) — explicit user decision, not an oversight - `hxprobe/README.md`: new — quick start (`uv sync`, `uv run ...`) for the standalone project, independent of the parent repo's docs - Makefile: `hx-deps`/`hx-run`/`hx-test`/`hx-test-integration` now shell out to `uv sync`/`uv run` instead of hand-managed `python -m venv` + `pip install -e .` (removed `HX_VENV`/`HX_VENV_PYTHON` vars — uv owns this now); added `hx-lint`/`hx-fmt`; `hx-check` now runs lint + hermetic tests (mirrors `go-check`'s fmt+vet+test bundling, previously only ran tests) - `docs/usage/hxprobe.md`: updated Setup and Makefile-targets sections for the new toolchain - `.gitignore`: added `.pytest_cache/`/`.ruff_cache/` - No behavior change to the probe/CLI itself — confirmed identical output before/after, `python/latprobe` untouched, zero `latprobe` imports remain in `hxprobe/` - Plan: `docs/plans/2026-07-02-09-57-hxprobe-toolchain-modernization.md` - Summary: `docs/summaries/2026-07-02-09-57-hxprobe-toolchain-modernization.md` --- ## 2026-07-02 09:32 — `hxprobe` extracted into a standalone top-level project - Moved `hxprobe` out of `python/` into a new top-level `hxprobe/` directory (sibling of `go/` and `python/`), with its own `pyproject.toml`, its own venv (`hxprobe/.venv`), and its own `hxprobe/tests/` — structured so it could be `cp -r`'d into a separate repo and work unchanged - Removed every `from latprobe import ...` in `hxprobe/`: `probe.py` now defines its own `Options`/`Phase`/`Result`/`VerboseDetail`/`CertInfo` dataclasses and its own `_parse_cert`/`_parse_cert_date` helpers (copied, not shared); `aggregate.py`/`duration.py` are verbatim copies (their imports were already package-relative, so no edits needed); `cli.py` is now a full standalone implementation (own exit codes, argparse, text/JSON rendering) instead of delegating to `latprobe.cli.run()` via an injected `measure_fn` - Reverted `python/latprobe/{cli.py,probe.py}` to their pre-`hxprobe` state (`git checkout --`) — the `measure_fn`/`prog`/`description`/`protocol_flags` injection points, `Options.follow_redirects`/`http2`, and `VerboseDetail.http_version`/`redirect_count` only existed to support the now-removed sharing; `python/` is back to zero third-party dependencies, no `pyproject.toml`, no venv - Makefile: removed `py-deps` and the `PY_VENV*` variables; `py-test`/ `py-check` reverted to running directly against `$(PYTHON)`; added a new standalone `hx-deps`/`hx-run`/`hx-test`/`hx-test-integration`/`hx-check`/ `hx-clean` section using `HX_DIR`/`HX_VENV*`; umbrella `test`/`check`/ `clean` now include the hxprobe targets alongside Go and Python (`go-*`/`py-*`/`hx-*`) - Tests moved and renamed to drop the now-redundant `hx_`/`_hx` segments: `test_hx_probe.py` → `hxprobe/tests/test_probe.py`, `test_hx_cli.py` → `hxprobe/tests/test_cli.py`, `test_integration_hx.py` → `hxprobe/tests/test_integration.py` (same `test_[!i]*.py` hermetic-vs-integration exclusion convention as `latprobe`) - `docs/usage/py-hxprobe.md` renamed to `docs/usage/hxprobe.md` and rewritten for the new standalone structure and Makefile targets; the TCP_NODELAY/ Nagle TTFB-accuracy finding is preserved - No behavior change — same CLI flags, same output, same six-phase timing, same HTTP/2/redirect handling as before; this was a pure decoupling/ restructuring - Plan: `docs/plans/2026-07-02-09-32-hxprobe-standalone-project.md` - Summary: `docs/summaries/2026-07-02-09-32-hxprobe-standalone-project.md` --- ## 2026-07-02 00:24 — `hxprobe`: httpx-based Python probe, Go-client parity - New sibling package `python/hxprobe/`, built on `httpx` instead of raw sockets, so the client matches Go's `http.DefaultClient`: HTTP/2 negotiated via ALPN, redirects followed by default, connection pooling, default TLS verification — while still reporting the full six-phase breakdown (DNS, TCP connect, TLS, TTFB, Transfer, Total) - `hxprobe/probe.py`: DNS/TCP connect/TLS timed by instrumenting a custom httpcore `NetworkBackend`/`NetworkStream` (`_TimingBackend`/`_TimingStream`); HTTP framing (HTTP/1.1 or HTTP/2), redirects, and keep-alive stay entirely owned by httpx; `measure()` returns the same `latprobe.probe.Result` dataclass, so aggregation/rendering are reused unchanged - `latprobe/probe.py`: added `Options.follow_redirects`/`Options.http2` (ignored by the raw-socket `measure()`) and `VerboseDetail.http_version`/`redirect_count` (always `""`/`0` there) - `latprobe/cli.py`: `run()`/`_run_samples()` take an injectable `measure_fn` (default: the existing socket `measure`), plus `prog`/`description`/ `protocol_flags` overrides — `hxprobe.cli.run()` reuses the entire argparse, concurrency, exit-code, and text/JSON rendering pipeline unchanged - New CLI flags (hxprobe only, via `protocol_flags=True`): `--no-http2`, `--no-follow-redirects` - `python/pyproject.toml`: first third-party dependency in this repo (`httpx[http2]`); `make py-deps` provisions `python/.venv` - Verified experimentally that `latprobe`'s raw socket — which never sets `TCP_NODELAY` — pays a real ~40-50ms Nagle/delayed-ACK penalty on its TTFB phase; `hxprobe` sets `TCP_NODELAY` (matching httpcore's default and Go's `net.Dialer`) and does not. Documented in `docs/usage/py-hxprobe.md` as a known divergence — `hxprobe`'s TTFB is the more accurate of the two, not just different - 17 new hermetic tests (`tests/test_hx_probe.py`), 11 new hermetic CLI tests (`tests/test_hx_cli.py`), 9 new live integration tests (`tests/test_integration_hx.py`, excluded from the default gate) - Makefile: `py-deps`, `hx-run`, `hx-test-integration`; `py-test`/`py-check` now run via the venv and include the new hermetic hxprobe tests - User doc: `docs/usage/py-hxprobe.md` - Plan: `docs/plans/2026-07-01-23-47-py-hxprobe-httpx.md` --- ## 2026-07-01 12:55 — `--verbose` / `-v` flag for `latprobe` (Python) - `-v`/`--verbose` flag added to the `latprobe` CLI - `probe.py`: new `CertInfo` + `VerboseDetail` dataclasses; `Options.verbose`; `Result.detail`; TTFB loop now accumulates until `\r\n\r\n` (unchanged TTFB semantics — `t_first_byte` stamped on first `recv()`, not when headers complete); captures resolved IP (from `getaddrinfo`), TLS version/cipher/bits (from `sock.version()`/`sock.cipher()`), verified cert (from `getpeercert()`), and all response headers - `cli.py`: verbose text block appended after timing rows in all four output branches (single, aggregate, all-failed, mixed); `_VERBOSE_HEADERS` priority list controls which headers appear in text mode; JSON `"verbose"` object includes all parsed headers, full cert fields, TLS metadata; `"verbose"` key omitted when flag is absent - 9 new hermetic probe tests, 15 new CLI tests covering verbose text and JSON across success, connect-fail, and DNS-fail scenarios - 15 new integration tests for real TLS cert fields (CN, expiry, issuer), IP format, TLS version string, header presence, and verbose text/JSON output - `python/configs/usage-latprobe.md`: runnable reference with real output for all features including verbose and `--verbose --json` - `docs/usage/py-latprobe.md`: updated with `--verbose` flag and examples - Plan: `docs/plans/2026-07-01-12-55-py-latprobe-verbose.md` --- ## 2026-07-01 12:23 — Full `latprobe` Python package (Python, Step 3) - `python/latprobe/` package: full port of the Go CLI, runnable as `python -m latprobe` - `probe.py`: raw-socket HTTP measurement with `Options(timeout)` parameter; mirrors `phases.py` technique; partial phases preserved on failure - `aggregate.py`: `summarize(List[Result]) -> Aggregate` with per-phase `PhaseStats(min_ms, avg_ms, max_ms)` - `duration.py`: parses Go-style duration strings (`10s`, `500ms`, `2m`, bare seconds) - `cli.py`: injectable `run(args, stdout, stderr) -> int`; argparse with injected streams (`_Parser` subclass); `ThreadPoolExecutor` concurrency across URLs; four text-rendering branches (single, aggregate, all-failed, mixed); JSON output; worst-exit-code accumulation - Exit codes: 0 ok, 1 usage, 2 dns, 3 connect, 4 timeout, 5 tls, 6 http≥400 (`--fail`) - `python/tests/test_probe.py`: 9 tests — success, 404, DNS fail, connect refused, TTFB timeout, bad scheme, partial phase invariants; uses `http.server` + daemon threads - `python/tests/test_cli.py`: 23 tests — drives `cli.run()` in-process; covers usage errors, single/aggregate text, failure exit codes, worst-code, `--fail`, JSON schema, JSON ordering, JSON error grouping - Makefile `py-test`: added `PYTHONPATH=$(PY_DIR)`, removed `|| true` - User doc: `docs/usage/py-latprobe.md` --- ## 2026-07-01 12:04 — Per-phase latency measurement (Python, Step 2) - `python/phases.py`: self-contained script; hand-drives raw sockets to time each HTTP phase individually — DNS (`getaddrinfo`), TCP connect, TLS handshake (`ssl.wrap_socket`, HTTPS only), TTFB (sendall → first recv), Transfer, Total - Partial phases preserved on failure (same invariant as Go's `probe.go`) - Error classification mirrors Go's priority: dns → timeout → tls → connect - Output format matches Go's single-sample text layout (14-char labels, `─` separator, `%8.2f ms` alignment) - Input: bare URL args or plain-text config file (same format as `simple.py`) - Exits 0 if all URLs complete without network error; 1 if any failed - User doc: `docs/usage/py-phases.md` --- ## 2026-07-01 11:30 — Error-path example configs for simple.py (Python, Step 1 refinement) - `python/configs/` directory with 7 purpose-built config files, one per error class: `all-ok.txt`, `dns-failure.txt`, `connection-refused.txt`, `timeout.txt`, `tls-errors.txt` (badssl.com), `http-errors.txt` (httpstat.us), `mixed.txt` - `python/configs/usage.md`: runnable shell commands + expected output for every config - Fixed `docs/usage/py-simple.md`: 4xx/5xx responses are reported as `FAIL` (not `OK`), because `urlopen` raises `HTTPError` for non-2xx; added pointer to the example configs --- ## 2026-07-01 10:39 — Simple reachability checker (Python, Step 1) - `python/simple.py`: reads a plain-text site list (one URL per line, `#` comments), issues a GET to each, prints aligned `OK / FAIL + elapsed ms` per site - Plain-text config format forward-compatible with future `key=value` annotations - Exits 0 if all sites responded, 1 if any failed or config is missing - `python/sites.txt`: committed example config - Makefile `py-*` targets added: `py-simple-run`, `py-phases-run`, `py-run`, `py-test`, `py-check`, `py-clean`; umbrella `test`, `check`, `clean` now include Python - User doc: `docs/usage/py-simple.md` --- ## 2026-07-01 01:23 — Concurrency (Go, Step 7) - Added `-c`/`--concurrency` flag: max URLs probed in parallel (0 = auto) - Default auto-concurrency: `min(numURLs, 8)` — scales with workload, caps at 8 - Parallelism is **across URLs only**; the N samples of each URL stay sequential to preserve the accuracy of per-URL min/avg/max statistics - Output buffered and printed in original input order (deterministic for both text and JSON), exit-code accumulation unchanged - Implementation: semaphore channel + `sync.WaitGroup`, each goroutine writes only its own indexed result slot — verified clean with `go test -race` - Added `make go-test-race` target to the Makefile - 3 new tests: `TestRunConcurrentOrder`, `TestConcurrentWorstCode`, `TestConcurrentJSONOrder` - Updated runtime estimates in `docs/custom-usage-examples.md` - User doc: `docs/usage/step-7-concurrency.md` --- ## 2026-07-01 01:05 — Root Makefile - Namespaced targets: `go-build`, `go-run`, `go-test`, `go-test-verbose`, `go-check`, `go-cover`, `go-fmt`, `go-vet`, `go-tidy`, `go-install`, `go-lint`, `go-clean` - Umbrella targets (`build`, `test`, `check`, `fmt`, `vet`, `clean`, `all`) delegate to Go now; `py-*` will slot in during the Python port - `help` is the default target; auto-generated from `##` comments - `go-run` accepts `ARGS=` for passing flags, `go-lint` guards for `golangci-lint` - `.gitignore` updated with coverage artifacts --- ## 2026-07-01 00:49 — Integration tests (Go, Step 6) - Extracted `run(args, stdout, stderr) int` from `main()` to make the CLI testable in-process - Fixed TLS classification bug: `TLSHandshakeDone` fires with the error on cert rejection, so `tlsErr` is now captured and checked before the stale `tlsStart.IsZero()` guard - `run_test.go`: 14 in-process tests covering exit codes 0–6, multi-URL, sampling, and JSON structure assertions - `cli_test.go`: `TestMain` builds the real binary; 3 subprocess smoke tests exercise the actual `os.Exit` path - All test servers use `httptest` + stdlib; `.invalid` TLD for deterministic DNS failures; no external network dependency --- ## 2026-07-01 00:38 — Failure handling (Go, Step 5) - Classified network failures into `dns` / `connect` / `timeout` / `tls` with distinct exit codes (2–5) - Partial timing preserved up to the failure point (e.g. DNS phase shown on NXDOMAIN) - `--timeout` flag (default 10s) applied via `context.WithTimeout` - `--fail` flag: HTTP status ≥ 400 → exit code 6 (curl-style) - `-n` sampling continues on network failure; aggregates successes, reports fail count + cause - JSON output extended with `succeeded`, `failed`, `errors[]` fields - Highest exit code across all URLs/failure types is used as the process exit --- ## 2026-07-01 00:08 — JSON output flag (Go, Step 4) - `--json` flag emits a JSON array with one entry per URL - Schema always uses min/avg/max shape (consistent regardless of `-n`) - Phases absent from the request (e.g. TLS on HTTP) are omitted from the JSON object - Failed URLs are excluded from JSON output and reported to stderr - Text output unchanged and remains the default --- ## 2026-07-01 00:08 — Multiple URLs + sampling (Go, Step 3) - `-n`/`--count` flag repeats each URL N times and reports min/avg/max per phase - `probe.Summarize` aggregates a slice of Results into per-phase PhaseStats - Multi-sample output shows an aligned min/avg/max table with a header row - Single-sample output (n=1) is unchanged from Step 2 --- ## 2026-07-01 00:08 — Per-phase breakdown (Go, Step 2) - Instrumented requests with `net/http/httptrace.ClientTrace` - DNS lookup, TCP connect, TLS handshake, Server/TTFB, Transfer, Total phases - TLS row omitted automatically for plain `http://` URLs - Aligned text output with separator before Total --- ## 2026-07-01 00:08 — Simple total latency (Go, Step 1) - `probe.Measure` performs an HTTP GET and records wall-clock total time - `main.go` prints URL, HTTP status code, and total duration for each URL - Exits with code 1 if any URL fails - Multiple URLs accepted as positional arguments --- ## 2026-07-01 00:08 — Project scaffold (Go, Step 0) - Created project structure: `go/`, `python/`, `docs/plans/`, `docs/usage/` - Initialised Go module `latprobe` (`go/go.mod`) - Minimal `go/main.go` that prints usage and exits cleanly when no URL is given - Stub `go/internal/probe/probe.go` defining the `Result` type and `Measure` signature - Foundation files: `README.md`, `CLAUDE.md`, `CHANGELOG.md` - Saved initial plan to `docs/plans/2026-07-01-00-08-go-latency-tool.md`