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>
24 KiB
24 KiB
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 failedbroken 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 onlen(urls) > 1and 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 = cvsmax(worst, ...)) thatdocs/explanations/2026-07-02-13-25-hxprobe-worst-exit-code-and-render-loop.mdhad flagged as a stylistic wrinkle into a singlemax(...)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-fmulti-URL examples to show the footer (previously stale — captured before this feature existed); added a sentence to the Exit codes sectiondocs/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/_ArgExitscaffolding (~32 lines) that hand-reimplemented whatargparse.ArgumentParseralready does (help→stdout, usage/errors→stderr, raise instead of hard-exit); replaced with a plainArgumentParserwrapped incontextlib.redirect_stdout/redirect_stderr, catchingSystemExitat one sitehxprobe/hxprobe/probe.py: trimmed_TimingStream.get_extra_infoto the one branch (ssl_object) actually consumed on hxprobe's request path — theserver_addr/client_addrbranches were dead (onlyhttpx's own CLI queries them); removed_dns_set/_connect_set/_tls_setfrom_Trace, redundant with thePhase.presentflag already onself.dns/connect/tlshxprobe/hxprobe/cli.py:_load_urlsno longer takes only the first whitespace token per line ("forward-compatible with futurekey=valueannotations" that never materialized); extracted_summarize_failures()to remove a duplicated dedup loop shared by_print_failure_summaryand_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 PATHflag reads URLs from a plain-text file (one per line,#comments, blank lines skipped, first whitespace-separated token per line) — same format assimple.py'sload_sites(), reimplemented locally as_load_urls()rather than imported (hxprobe still imports nothing outside its own directory)-f/--fileis mutually exclusive with positionalurlargs (both or neither given → usage error);urlspositional changed fromnargs="+"tonargs="*"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 mirroringpython/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 againsthxprobe -f ...during implementation, not assumed from thesimple.pyoriginals (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 pathshxprobe/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 ofpython/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 insidehxprobe/itself (notpython/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 of10.255.255.1— the latter resolves to an immediate "connection refused" in this dev sandbox rather than a genuine timeout;192.0.2.1reproduces a real ~500ms timeout reliably docs/usage/hxprobe.md: added a one-line pointer tohxprobe/USAGE.mdat the top; no other changes — it stays the CLAUDE.md-mandated summary dochxprobe/Makefile: new, fully standalone (help,deps,run,lint,fmt,test,test-integration,check,clean) — same auto-generated## commenthelp style as the root Makefile, short target names (nohx-prefix needed insidehxprobe/'s own scope). Deliberately independent from the root Makefile'shx-*targets — neither calls into the other, per explicit user decision (avoids one Makefile's changes silently breaking the other, at the cost of some duplicateduv/pytestinvocation logic)- No changes to the root
Makefile— verified itshx-*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
uvfor environment/dependency management:hxprobe/pyproject.tomlgets a[dependency-groups] dev = ["pytest>=8.0", "ruff>=0.8"]section;hxprobe/.python-versionpins 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 beyondhttpx[http2]>=0.28 - Added
rufffor linting + formatting:[tool.ruff]config (target-version = "py311",line-length = 100); fixed the one real finding (import sysunused incli.py, inherited from the originallatprobe/cli.py); ranruff formatacross 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 discovertopytest. Existingunittest.TestCaseclasses run unchanged (pytest is a superset runner) — no test-code rewrite. Replaced thetest_[!i]*.pyfilename-glob hermetic/integration split with a properpytest.mark.integrationmarker (pytestmark = pytest.mark.integrationintests/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-integrationnow shell out touv sync/uv runinstead of hand-managedpython -m venv+pip install -e .(removedHX_VENV/HX_VENV_PYTHONvars — uv owns this now); addedhx-lint/hx-fmt;hx-checknow runs lint + hermetic tests (mirrorsgo-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/latprobeuntouched, zerolatprobeimports remain inhxprobe/ - 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
hxprobeout ofpython/into a new top-levelhxprobe/directory (sibling ofgo/andpython/), with its ownpyproject.toml, its own venv (hxprobe/.venv), and its ownhxprobe/tests/— structured so it could becp -r'd into a separate repo and work unchanged - Removed every
from latprobe import ...inhxprobe/:probe.pynow defines its ownOptions/Phase/Result/VerboseDetail/CertInfodataclasses and its own_parse_cert/_parse_cert_datehelpers (copied, not shared);aggregate.py/duration.pyare verbatim copies (their imports were already package-relative, so no edits needed);cli.pyis now a full standalone implementation (own exit codes, argparse, text/JSON rendering) instead of delegating tolatprobe.cli.run()via an injectedmeasure_fn - Reverted
python/latprobe/{cli.py,probe.py}to their pre-hxprobestate (git checkout --) — themeasure_fn/prog/description/protocol_flagsinjection points,Options.follow_redirects/http2, andVerboseDetail.http_version/redirect_countonly existed to support the now-removed sharing;python/is back to zero third-party dependencies, nopyproject.toml, no venv - Makefile: removed
py-depsand thePY_VENV*variables;py-test/py-checkreverted to running directly against$(PYTHON); added a new standalonehx-deps/hx-run/hx-test/hx-test-integration/hx-check/hx-cleansection usingHX_DIR/HX_VENV*; umbrellatest/check/cleannow include the hxprobe targets alongside Go and Python (go-*/py-*/hx-*) - Tests moved and renamed to drop the now-redundant
hx_/_hxsegments: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(sametest_[!i]*.pyhermetic-vs-integration exclusion convention aslatprobe) docs/usage/py-hxprobe.mdrenamed todocs/usage/hxprobe.mdand 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 onhttpxinstead of raw sockets, so the client matches Go'shttp.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 httpcoreNetworkBackend/NetworkStream(_TimingBackend/_TimingStream); HTTP framing (HTTP/1.1 or HTTP/2), redirects, and keep-alive stay entirely owned by httpx;measure()returns the samelatprobe.probe.Resultdataclass, so aggregation/rendering are reused unchangedlatprobe/probe.py: addedOptions.follow_redirects/Options.http2(ignored by the raw-socketmeasure()) andVerboseDetail.http_version/redirect_count(always""/0there)latprobe/cli.py:run()/_run_samples()take an injectablemeasure_fn(default: the existing socketmeasure), plusprog/description/protocol_flagsoverrides —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-depsprovisionspython/.venv- Verified experimentally that
latprobe's raw socket — which never setsTCP_NODELAY— pays a real ~40-50ms Nagle/delayed-ACK penalty on its TTFB phase;hxprobesetsTCP_NODELAY(matching httpcore's default and Go'snet.Dialer) and does not. Documented indocs/usage/py-hxprobe.mdas 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-checknow 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/--verboseflag added to thelatprobeCLIprobe.py: newCertInfo+VerboseDetaildataclasses;Options.verbose;Result.detail; TTFB loop now accumulates until\r\n\r\n(unchanged TTFB semantics —t_first_bytestamped on firstrecv(), not when headers complete); captures resolved IP (fromgetaddrinfo), TLS version/cipher/bits (fromsock.version()/sock.cipher()), verified cert (fromgetpeercert()), and all response headerscli.py: verbose text block appended after timing rows in all four output branches (single, aggregate, all-failed, mixed);_VERBOSE_HEADERSpriority 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 --jsondocs/usage/py-latprobe.md: updated with--verboseflag 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 aspython -m latprobeprobe.py: raw-socket HTTP measurement withOptions(timeout)parameter; mirrorsphases.pytechnique; partial phases preserved on failureaggregate.py:summarize(List[Result]) -> Aggregatewith per-phasePhaseStats(min_ms, avg_ms, max_ms)duration.py: parses Go-style duration strings (10s,500ms,2m, bare seconds)cli.py: injectablerun(args, stdout, stderr) -> int; argparse with injected streams (_Parsersubclass);ThreadPoolExecutorconcurrency 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; useshttp.server+ daemon threadspython/tests/test_cli.py: 23 tests — drivescli.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: addedPYTHONPATH=$(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 msalignment) - 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.txtpython/configs/usage.md: runnable shell commands + expected output for every config- Fixed
docs/usage/py-simple.md: 4xx/5xx responses are reported asFAIL(notOK), becauseurlopenraisesHTTPErrorfor 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 alignedOK / FAIL + elapsed msper site- Plain-text config format forward-compatible with future
key=valueannotations - 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; umbrellatest,check,cleannow include Python - User doc:
docs/usage/py-simple.md
2026-07-01 01:23 — Concurrency (Go, Step 7)
- Added
-c/--concurrencyflag: 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 withgo test -race - Added
make go-test-racetarget 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 helpis the default target; auto-generated from##commentsgo-runacceptsARGS=for passing flags,go-lintguards forgolangci-lint.gitignoreupdated with coverage artifacts
2026-07-01 00:49 — Integration tests (Go, Step 6)
- Extracted
run(args, stdout, stderr) intfrommain()to make the CLI testable in-process - Fixed TLS classification bug:
TLSHandshakeDonefires with the error on cert rejection, sotlsErris now captured and checked before the staletlsStart.IsZero()guard run_test.go: 14 in-process tests covering exit codes 0–6, multi-URL, sampling, and JSON structure assertionscli_test.go:TestMainbuilds the real binary; 3 subprocess smoke tests exercise the actualos.Exitpath- All test servers use
httptest+ stdlib;.invalidTLD 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/tlswith distinct exit codes (2–5) - Partial timing preserved up to the failure point (e.g. DNS phase shown on NXDOMAIN)
--timeoutflag (default 10s) applied viacontext.WithTimeout--failflag: HTTP status ≥ 400 → exit code 6 (curl-style)-nsampling 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)
--jsonflag 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/--countflag repeats each URL N times and reports min/avg/max per phaseprobe.Summarizeaggregates 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.Measureperforms an HTTP GET and records wall-clock total timemain.goprints 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.gothat prints usage and exits cleanly when no URL is given - Stub
go/internal/probe/probe.godefining theResulttype andMeasuresignature - Foundation files:
README.md,CLAUDE.md,CHANGELOG.md - Saved initial plan to
docs/plans/2026-07-01-00-08-go-latency-tool.md