linux-mirror-bench 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/workflows/integration.yml +2 -2
  2. linux_mirror_bench-0.3.0/.release-please-manifest.json +3 -0
  3. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/AGENTS.md +3 -0
  4. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/CHANGELOG.md +18 -0
  5. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/Dockerfile +10 -4
  6. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/PKG-INFO +35 -7
  7. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/README.md +34 -6
  8. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/pyproject.toml +1 -1
  9. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/probe.py +23 -4
  10. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/scorer.py +39 -0
  11. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/cli.py +61 -11
  12. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/arch.py +9 -1
  13. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/base.py +76 -0
  14. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/debian.py +6 -1
  15. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/fedora.py +34 -3
  16. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/mint.py +4 -0
  17. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/ubuntu.py +8 -2
  18. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/display.py +29 -6
  19. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/models.py +3 -0
  20. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_cli.py +190 -10
  21. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_discovery.py +172 -9
  22. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_display.py +39 -0
  23. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_h3.py +4 -4
  24. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_integration.py +7 -1
  25. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_probe.py +65 -10
  26. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_scorer.py +69 -0
  27. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/uv.lock +113 -32
  28. linux_mirror_bench-0.2.0/.release-please-manifest.json +0 -3
  29. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.dockerignore +0 -0
  30. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.editorconfig +0 -0
  31. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  32. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  33. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  34. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  35. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/dependabot.yml +0 -0
  36. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/workflows/ci.yml +0 -0
  37. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/workflows/release-please.yml +0 -0
  38. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/workflows/release.yml +0 -0
  39. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.gitignore +0 -0
  40. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.markdownlint.yaml +0 -0
  41. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.pre-commit-config.yaml +0 -0
  42. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.release-please-config.json +0 -0
  43. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.tool-versions +0 -0
  44. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.yamllint.yaml +0 -0
  45. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/CODEOWNERS +0 -0
  46. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/CONTRIBUTING.md +0 -0
  47. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/LICENSE +0 -0
  48. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/Makefile +0 -0
  49. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/SECURITY.md +0 -0
  50. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/__init__.py +0 -0
  51. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/__main__.py +0 -0
  52. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/__init__.py +0 -0
  53. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/h3.py +0 -0
  54. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/h3_quic.py +0 -0
  55. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/tls.py +0 -0
  56. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/transport.py +0 -0
  57. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/__init__.py +0 -0
  58. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/distro.py +0 -0
  59. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/__init__.py +0 -0
  60. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/conftest.py +0 -0
  61. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/fixtures/arch_status.json +0 -0
  62. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/fixtures/debian_masterlist.txt +0 -0
  63. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/fixtures/fedora_mirrorlist.txt +0 -0
  64. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/fixtures/mint_mirrors.html +0 -0
  65. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/fixtures/ubuntu_mirrors.txt +0 -0
  66. {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_distro.py +0 -0
@@ -28,8 +28,8 @@ jobs:
28
28
  runs-on: ubuntu-latest
29
29
  timeout-minutes: 15
30
30
  steps:
31
- - uses: actions/checkout@v6
32
- - uses: actions/setup-python@v6
31
+ - uses: actions/checkout@v7
32
+ - uses: actions/setup-python@v7
33
33
  with:
34
34
  python-version: ${{ env.PYTHON_VERSION }}
35
35
  - uses: astral-sh/setup-uv@v7
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.3.0"
3
+ }
@@ -92,6 +92,9 @@ Run a single test: `uv run pytest tests/test_scorer.py::test_weights_shift_ranki
92
92
  ## Adding a new discoverer
93
93
 
94
94
  1. `src/mirror_bench/discovery/<name>.py` implementing `MirrorDiscoverer`.
95
+ `probe_path` must name a small file that upstream rewrites as packages
96
+ change, and `sync_time` must read its generation time, or stale mirrors
97
+ cannot be detected.
95
98
  2. Register in `discovery/__init__.py::DISCOVERERS`.
96
99
  3. Add ID aliases in `distro.py::_ALIASES`.
97
100
  4. Respx-mocked test in `tests/test_discovery.py` + fixture file under
@@ -5,6 +5,24 @@ All notable changes to this project are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
  this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0](https://github.com/MysticRyuujin/mirror-bench/compare/v0.2.0...v0.3.0) (2026-11-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * measure mirror freshness and drop stale mirrors ([#18](https://github.com/MysticRyuujin/mirror-bench/issues/18)) ([462ac42](https://github.com/MysticRyuujin/mirror-bench/commit/462ac42017f1026c6892701aaa0452b791a0c498))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * make completion work without click, and reject error pages as probes ([#19](https://github.com/MysticRyuujin/mirror-bench/issues/19)) ([0c2427d](https://github.com/MysticRyuujin/mirror-bench/commit/0c2427dcb949f394e0118a386407d215d732465b))
19
+ * try https for mirrors listed as http, and write plain output with no TTY ([#17](https://github.com/MysticRyuujin/mirror-bench/issues/17)) ([07d38ee](https://github.com/MysticRyuujin/mirror-bench/commit/07d38eec00a0c5669a728723a13a9d805f6113c8))
20
+
21
+
22
+ ### Dependencies
23
+
24
+ * **actions:** match action versions in integration.yml ([#15](https://github.com/MysticRyuujin/mirror-bench/issues/15)) ([35da73d](https://github.com/MysticRyuujin/mirror-bench/commit/35da73d052acf4f1ea67c2f14f4df0b09a8b8c98))
25
+
8
26
  ## [0.2.0](https://github.com/MysticRyuujin/mirror-bench/compare/v0.1.2...v0.2.0) (2026-11-13)
9
27
 
10
28
 
@@ -20,16 +20,22 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
20
20
  COPY --from=uv /uv /usr/local/bin/uv
21
21
 
22
22
  WORKDIR /app
23
- COPY pyproject.toml README.md LICENSE ./
23
+ COPY pyproject.toml uv.lock README.md LICENSE ./
24
24
  COPY src ./src
25
25
 
26
- # Build the wheel, then install it (+ runtime deps) into a self-contained venv
27
- # under /opt/venv. No dev deps, no sources, no uv binary in the final image.
26
+ # Build the wheel, then install it into a self-contained venv under /opt/venv.
27
+ # Runtime deps come from uv.lock, so the image runs the same versions that CI
28
+ # tests; resolving at build time would pull whatever is newest that day.
29
+ # No dev deps, no sources, no uv binary in the final image.
28
30
  RUN --mount=type=cache,target=/root/.cache/uv \
29
31
  uv venv /opt/venv \
32
+ && uv export --frozen --no-dev --no-emit-project --format requirements.txt \
33
+ --output-file /tmp/requirements.txt \
34
+ && VIRTUAL_ENV=/opt/venv uv pip install --no-cache --require-hashes \
35
+ -r /tmp/requirements.txt \
30
36
  && uv build --wheel \
31
37
  && wheel=$(find dist -maxdepth 1 -name '*.whl' -print -quit) \
32
- && VIRTUAL_ENV=/opt/venv uv pip install --no-cache "${wheel}"
38
+ && VIRTUAL_ENV=/opt/venv uv pip install --no-cache --no-deps "${wheel}"
33
39
 
34
40
  # ---- Runtime stage -------------------------------------------------------
35
41
  FROM python:${PYTHON_VERSION}-slim-bookworm AS runtime
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: linux-mirror-bench
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Cross-distribution Linux package mirror benchmarking tool
5
5
  Project-URL: Homepage, https://github.com/MysticRyuujin/mirror-bench
6
6
  Project-URL: Issues, https://github.com/MysticRyuujin/mirror-bench/issues
@@ -144,6 +144,7 @@ Running `mirror-bench` with no subcommand is equivalent to `mirror-bench bench`.
144
144
  | `--tls13-only` | Hard-exclude mirrors that don't negotiate TLS 1.3. |
145
145
  | `--weights lat=…,thr=…,sec=…` | Override scoring weights (auto-normalized). |
146
146
  | `--no-throughput` | Skip phase 2. Phase-1 latency screen only. |
147
+ | `--max-age HOURS` | Drop mirrors more than this far behind the newest mirror (default 36). `0` keeps all. |
147
148
  | `--http3` | Probe over QUIC the mirrors that advertise HTTP/3. Needs the `http3` extra. |
148
149
  | `--json` | Emit JSON to stdout. Suppresses the table + progress. |
149
150
  | `--csv` | Emit CSV to stdout. Suppresses the table + progress. |
@@ -164,8 +165,9 @@ directly.
164
165
  > The image renders a colored, properly-widened table even without `-it`.
165
166
  > If you want **rich to use your host terminal's exact width** (and react to
166
167
  > resizes), add `-it`; otherwise the image falls back to a sensible 120-col
167
- > table. Set `NO_COLOR=1` if you'd rather have plain text — e.g. when
168
- > redirecting to a file.
168
+ > table. Set `NO_COLOR=1` if you'd rather have plain text with no escape
169
+ > codes — e.g. when redirecting to a file. Progress bars show only when
170
+ > stderr is a terminal.
169
171
 
170
172
  ### Basic invocation
171
173
 
@@ -402,6 +404,9 @@ The mirror lists for those distributions cover x86_64 mirrors only.
402
404
  multi-record format). The list does not say which sites serve HTTPS, so
403
405
  each site is tried as an `https://` and an `http://` candidate.
404
406
  - Fedora: `mirrors.fedoraproject.org/mirrorlist?repo=&arch=&country=`.
407
+ - The Ubuntu and Fedora lists name one scheme for each mirror, often
408
+ `http://` for a host that also serves HTTPS. Each `http://` entry is
409
+ also tried as `https://`.
405
410
  - Linux Mint: scraped from `linuxmint.com/mirrors.php`.
406
411
  - Arch: `archlinux.org/mirrors/status/json/` (JSON, filtered to active
407
412
  HTTP and HTTPS entries).
@@ -409,16 +414,22 @@ The mirror lists for those distributions cover x86_64 mirrors only.
409
414
  If the list cannot be fetched, the command prints the reason and exits
410
415
  with code 1.
411
416
 
412
- 2. **Phase 1 — latency screen.** Concurrent ranged GET (`bytes=0-1023`) on the
413
- distro's canonical small metadata file (`InRelease` for Ubuntu and Debian,
414
- `Release` for Mint, `repomd.xml` for Fedora, `lastsync` for Arch). Three
415
- samples per mirror, **median TTFB** used for ranking.
417
+ 2. **Phase 1 — latency screen.** Concurrent ranged GET (`bytes=0-1023`) on a
418
+ small metadata file that the distribution rewrites as packages change:
419
+ `InRelease` of the `-updates` suite for Ubuntu and Debian, `Release` for
420
+ Mint, `repomd.xml` of the updates repository for Fedora, `lastupdate` for
421
+ Arch. Three samples per mirror, **median TTFB** used for ranking.
416
422
  3. **Phase 2 — throughput test.** Streaming GET of a large file
417
423
  (`Contents-<arch>.gz` for apt, `images/install.img` for Fedora, `extra.db`
418
424
  for Arch) on the top N from phase 1, capped at 5 MiB and 30 seconds.
419
425
  `bytes_per_sec` is measured from first-byte to last-byte so steady-state
420
426
  bandwidth isn't polluted by connect/TLS overhead.
421
427
 
428
+ A mirror that answers a missing file with status 200 and an HTML error
429
+ page fails phase 1 with `soft_404`. A complete phase 2 body under 256 KiB
430
+ fails with `short_body`. If no mirror passes phase 1, a line on stderr
431
+ gives the most common failure; a wrong `--release` is the usual cause.
432
+
422
433
  Both phases follow redirects, so the timing includes the extra hop that a
423
434
  package manager also pays. Only a 2xx final response counts. An `https://`
424
435
  mirror that redirects to `http://` fails with `https_downgrade`. An
@@ -436,6 +447,23 @@ The mirror lists for those distributions cover x86_64 mirrors only.
436
447
  is kept.
437
448
  5. **Render.** A `rich.Table`, or JSON / CSV for scripting.
438
449
 
450
+ ### Freshness
451
+
452
+ A fast mirror that stopped syncing serves old packages. The phase 1 file
453
+ carries the time its content was generated, so each run also reads that
454
+ time, with no extra request.
455
+
456
+ - The `Age` column shows how far a mirror is behind the newest mirror in the
457
+ run: `0`, `23h`, `12d`, or `-` when the file gave no time. JSON and CSV
458
+ carry it as `sync_age_s`.
459
+ - Age is relative to the other mirrors, not to your clock. If the
460
+ distribution published nothing for a week, every current mirror still
461
+ shows `0`.
462
+ - Mirrors more than 36 hours behind are dropped before ranking, and a line on
463
+ stderr says how many. Change the limit with `--max-age HOURS`; `0` keeps
464
+ all mirrors.
465
+ - Age does not change the score. A stale mirror is wrong, not slow.
466
+
439
467
  ### HTTP/3
440
468
 
441
469
  Phase 1 reads the `Alt-Svc` response header. The `HTTP/3` column shows `adv`
@@ -117,6 +117,7 @@ Running `mirror-bench` with no subcommand is equivalent to `mirror-bench bench`.
117
117
  | `--tls13-only` | Hard-exclude mirrors that don't negotiate TLS 1.3. |
118
118
  | `--weights lat=…,thr=…,sec=…` | Override scoring weights (auto-normalized). |
119
119
  | `--no-throughput` | Skip phase 2. Phase-1 latency screen only. |
120
+ | `--max-age HOURS` | Drop mirrors more than this far behind the newest mirror (default 36). `0` keeps all. |
120
121
  | `--http3` | Probe over QUIC the mirrors that advertise HTTP/3. Needs the `http3` extra. |
121
122
  | `--json` | Emit JSON to stdout. Suppresses the table + progress. |
122
123
  | `--csv` | Emit CSV to stdout. Suppresses the table + progress. |
@@ -137,8 +138,9 @@ directly.
137
138
  > The image renders a colored, properly-widened table even without `-it`.
138
139
  > If you want **rich to use your host terminal's exact width** (and react to
139
140
  > resizes), add `-it`; otherwise the image falls back to a sensible 120-col
140
- > table. Set `NO_COLOR=1` if you'd rather have plain text — e.g. when
141
- > redirecting to a file.
141
+ > table. Set `NO_COLOR=1` if you'd rather have plain text with no escape
142
+ > codes — e.g. when redirecting to a file. Progress bars show only when
143
+ > stderr is a terminal.
142
144
 
143
145
  ### Basic invocation
144
146
 
@@ -375,6 +377,9 @@ The mirror lists for those distributions cover x86_64 mirrors only.
375
377
  multi-record format). The list does not say which sites serve HTTPS, so
376
378
  each site is tried as an `https://` and an `http://` candidate.
377
379
  - Fedora: `mirrors.fedoraproject.org/mirrorlist?repo=&arch=&country=`.
380
+ - The Ubuntu and Fedora lists name one scheme for each mirror, often
381
+ `http://` for a host that also serves HTTPS. Each `http://` entry is
382
+ also tried as `https://`.
378
383
  - Linux Mint: scraped from `linuxmint.com/mirrors.php`.
379
384
  - Arch: `archlinux.org/mirrors/status/json/` (JSON, filtered to active
380
385
  HTTP and HTTPS entries).
@@ -382,16 +387,22 @@ The mirror lists for those distributions cover x86_64 mirrors only.
382
387
  If the list cannot be fetched, the command prints the reason and exits
383
388
  with code 1.
384
389
 
385
- 2. **Phase 1 — latency screen.** Concurrent ranged GET (`bytes=0-1023`) on the
386
- distro's canonical small metadata file (`InRelease` for Ubuntu and Debian,
387
- `Release` for Mint, `repomd.xml` for Fedora, `lastsync` for Arch). Three
388
- samples per mirror, **median TTFB** used for ranking.
390
+ 2. **Phase 1 — latency screen.** Concurrent ranged GET (`bytes=0-1023`) on a
391
+ small metadata file that the distribution rewrites as packages change:
392
+ `InRelease` of the `-updates` suite for Ubuntu and Debian, `Release` for
393
+ Mint, `repomd.xml` of the updates repository for Fedora, `lastupdate` for
394
+ Arch. Three samples per mirror, **median TTFB** used for ranking.
389
395
  3. **Phase 2 — throughput test.** Streaming GET of a large file
390
396
  (`Contents-<arch>.gz` for apt, `images/install.img` for Fedora, `extra.db`
391
397
  for Arch) on the top N from phase 1, capped at 5 MiB and 30 seconds.
392
398
  `bytes_per_sec` is measured from first-byte to last-byte so steady-state
393
399
  bandwidth isn't polluted by connect/TLS overhead.
394
400
 
401
+ A mirror that answers a missing file with status 200 and an HTML error
402
+ page fails phase 1 with `soft_404`. A complete phase 2 body under 256 KiB
403
+ fails with `short_body`. If no mirror passes phase 1, a line on stderr
404
+ gives the most common failure; a wrong `--release` is the usual cause.
405
+
395
406
  Both phases follow redirects, so the timing includes the extra hop that a
396
407
  package manager also pays. Only a 2xx final response counts. An `https://`
397
408
  mirror that redirects to `http://` fails with `https_downgrade`. An
@@ -409,6 +420,23 @@ The mirror lists for those distributions cover x86_64 mirrors only.
409
420
  is kept.
410
421
  5. **Render.** A `rich.Table`, or JSON / CSV for scripting.
411
422
 
423
+ ### Freshness
424
+
425
+ A fast mirror that stopped syncing serves old packages. The phase 1 file
426
+ carries the time its content was generated, so each run also reads that
427
+ time, with no extra request.
428
+
429
+ - The `Age` column shows how far a mirror is behind the newest mirror in the
430
+ run: `0`, `23h`, `12d`, or `-` when the file gave no time. JSON and CSV
431
+ carry it as `sync_age_s`.
432
+ - Age is relative to the other mirrors, not to your clock. If the
433
+ distribution published nothing for a week, every current mirror still
434
+ shows `0`.
435
+ - Mirrors more than 36 hours behind are dropped before ranking, and a line on
436
+ stderr says how many. Change the limit with `--max-age HOURS`; `0` keeps
437
+ all mirrors.
438
+ - Age does not change the score. A stale mirror is wrong, not slow.
439
+
412
440
  ### HTTP/3
413
441
 
414
442
  Phase 1 reads the `Alt-Svc` response header. The `HTTP/3` column shows `adv`
@@ -7,7 +7,7 @@ build-backend = "hatchling.build"
7
7
  # neighbors hit PyPI's similarity check. The installed command + import module
8
8
  # are still `mirror-bench` / `mirror_bench` via [project.scripts] below.
9
9
  name = "linux-mirror-bench"
10
- version = "0.2.0"
10
+ version = "0.3.0"
11
11
  description = "Cross-distribution Linux package mirror benchmarking tool"
12
12
  readme = "README.md"
13
13
  license = { text = "AGPL-3.0-or-later" }
@@ -18,6 +18,8 @@ if TYPE_CHECKING:
18
18
  # Phase 1 samples a small ranged window (1 KiB) to keep the latency probe cheap.
19
19
  _LATENCY_RANGE = "bytes=0-1023"
20
20
  _LATENCY_MAX_BYTES = 1024
21
+ # identity: the body is read raw and parsed for the mirror's sync time.
22
+ _LATENCY_HEADERS = {"Range": _LATENCY_RANGE, "Accept-Encoding": "identity"}
21
23
  _LATENCY_DEADLINE_S = 10.0
22
24
 
23
25
  # Phase 2 downloads this many bytes max — caps the cost for slow/misbehaving mirrors.
@@ -97,6 +99,11 @@ async def throughput_test(
97
99
  return out
98
100
 
99
101
 
102
+ def _is_html(body: bytes | bytearray) -> bool:
103
+ # None of the probed files is HTML; Fedora's repomd.xml starts `<?xml`.
104
+ return bytes(body[:64]).lstrip().lower().startswith((b"<!doctype html", b"<html"))
105
+
106
+
100
107
  def _downgraded(mirror: Mirror, resp: httpx.Response) -> bool:
101
108
  """Did an HTTPS mirror send any hop of this exchange over plain HTTP?"""
102
109
  return mirror.is_https and any(
@@ -152,26 +159,29 @@ async def _latency_probe(
152
159
  host: HostInfo,
153
160
  samples: int,
154
161
  ) -> ProbeResult:
155
- url = mirror.url + discoverer.probe_path(host)
162
+ url = discoverer.probe_url(mirror, host)
156
163
  ttfbs: list[float] = []
157
164
  totals: list[float] = []
158
165
  last_resp: httpx.Response | None = None
159
166
  last_bytes = 0
167
+ last_body = b""
160
168
  last_error = "no_samples"
161
169
 
162
170
  for _ in range(samples):
163
171
  started = time.perf_counter()
164
172
  nbytes = 0
173
+ body = bytearray()
165
174
  try:
166
175
  async with (
167
176
  asyncio.timeout(_LATENCY_DEADLINE_S),
168
- client.stream("GET", url, headers={"Range": _LATENCY_RANGE}) as resp,
177
+ client.stream("GET", url, headers=_LATENCY_HEADERS) as resp,
169
178
  ):
170
179
  ttfb_s = time.perf_counter() - started
171
180
  if _downgraded(mirror, resp):
172
181
  return _failed(mirror, "https_downgrade", resp)
173
182
  async for chunk in resp.aiter_raw():
174
183
  nbytes += len(chunk)
184
+ body += chunk
175
185
  # Only a server that ignored Range sends more than the
176
186
  # window. A ranged reply is read to its end, which keeps
177
187
  # the connection reusable for the next sample.
@@ -184,8 +194,12 @@ async def _latency_probe(
184
194
  else:
185
195
  ttfbs.append(ttfb_s * 1000.0)
186
196
  totals.append((time.perf_counter() - started) * 1000.0)
197
+ if resp.is_success and _is_html(body):
198
+ # Some mirrors answer a missing file with 200 and an error page.
199
+ return _failed(mirror, "soft_404", resp)
187
200
  last_resp = resp
188
201
  last_bytes = nbytes
202
+ last_body = bytes(body[:_LATENCY_MAX_BYTES])
189
203
  continue
190
204
  # A mirror that fails before any sample succeeds is not worth the
191
205
  # remaining connect timeouts.
@@ -212,6 +226,7 @@ async def _latency_probe(
212
226
  error=None,
213
227
  final_url=str(last_resp.url),
214
228
  h3_port=_h3_port(last_resp),
229
+ sync_time=discoverer.sync_time(mirror, last_body),
215
230
  )
216
231
 
217
232
 
@@ -255,13 +270,17 @@ async def _throughput_probe(
255
270
 
256
271
  if resp is None or first_byte_at is None:
257
272
  return _failed(mirror, "empty_body", resp)
273
+ if bytes_read < _THROUGHPUT_MIN_BYTES:
274
+ # Each throughput file is megabytes long. A complete body this short
275
+ # is an error page or a stub, and its rate would mean nothing.
276
+ return _failed(mirror, "short_body", resp, bytes_read=bytes_read)
258
277
 
259
278
  total_s = last_byte_at - started
260
279
  # Measure throughput from TTFB to the last byte, skipping connect/TLS overhead,
261
280
  # so the number reflects steady-state bandwidth rather than connection setup cost.
262
281
  transfer_s = last_byte_at - first_byte_at
263
- if bytes_read >= _THROUGHPUT_MIN_BYTES and transfer_s > 0:
264
- bps = bytes_read / transfer_s
282
+ if transfer_s > 0:
283
+ bps: float | None = bytes_read / transfer_s
265
284
  elif total_s > 0:
266
285
  bps = bytes_read / total_s
267
286
  else:
@@ -1,5 +1,6 @@
1
1
  """Weighted composite mirror ranking."""
2
2
 
3
+ import time
3
4
  from dataclasses import replace
4
5
  from typing import TYPE_CHECKING
5
6
 
@@ -11,11 +12,16 @@ if TYPE_CHECKING:
11
12
  # Maximum security points we can award. See _security_score for the breakdown.
12
13
  _MAX_SECURITY = 2.5
13
14
 
15
+ # A sync time this far past the local clock is a broken mirror, not the
16
+ # newest one.
17
+ _FUTURE_SLACK_S = 24 * 3600.0
18
+
14
19
 
15
20
  def score_results(
16
21
  latency_results: Iterable[ProbeResult],
17
22
  throughput_results: dict[str, ProbeResult] | None,
18
23
  weights: Weights,
24
+ ages: dict[str, float] | None = None,
19
25
  ) -> list[Score]:
20
26
  """Rank mirrors. `throughput_results=None` means phase 2 did not run."""
21
27
  throughput_map = throughput_results or {}
@@ -73,6 +79,7 @@ def score_results(
73
79
  composite=composite,
74
80
  probe=r,
75
81
  throughput_probe=tput_probe,
82
+ sync_age_s=(ages or {}).get(r.mirror.url),
76
83
  )
77
84
  )
78
85
 
@@ -123,6 +130,38 @@ def _security_score(probe: ProbeResult, tput_probe: ProbeResult | None) -> float
123
130
  return score
124
131
 
125
132
 
133
+ def sync_ages(
134
+ results: Iterable[ProbeResult], *, now: float | None = None
135
+ ) -> dict[str, float]:
136
+ """Seconds each mirror is behind the newest one, keyed by mirror URL.
137
+
138
+ Mirrors are compared with each other, not with the local clock: the
139
+ distribution may not have published anything for days, and that is not
140
+ the mirrors' fault. Mirrors with no known sync time are left out.
141
+ """
142
+ limit = (time.time() if now is None else now) + _FUTURE_SLACK_S
143
+ times = {
144
+ r.mirror.url: r.sync_time for r in results if r.ok and r.sync_time is not None
145
+ }
146
+ plausible = [t for t in times.values() if t <= limit]
147
+ if not plausible:
148
+ return {}
149
+ newest = max(plausible)
150
+ return {url: max(0.0, newest - t) for url, t in times.items()}
151
+
152
+
153
+ def drop_stale(
154
+ results: Iterable[ProbeResult], ages: dict[str, float], max_age_s: float
155
+ ) -> list[ProbeResult]:
156
+ """Drop mirrors more than `max_age_s` behind; 0 keeps all.
157
+
158
+ A mirror with no known age is kept: nothing shows it is stale.
159
+ """
160
+ if max_age_s <= 0:
161
+ return list(results)
162
+ return [r for r in results if ages.get(r.mirror.url, 0.0) <= max_age_s]
163
+
164
+
126
165
  def prefer_https(results: Iterable[ProbeResult]) -> list[ProbeResult]:
127
166
  """Drop an http:// result when the same site answered over https://.
128
167
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import math
5
+ from collections import Counter
5
6
  from dataclasses import replace
6
7
 
7
8
  import typer
@@ -13,6 +14,7 @@ from rich.progress import (
13
14
  TextColumn,
14
15
  TimeElapsedColumn,
15
16
  )
17
+ from typer._completion_shared import get_completion_script
16
18
  from typer.core import TyperGroup
17
19
 
18
20
  from mirror_bench import display
@@ -94,6 +96,14 @@ Http3Opt = typer.Option(
94
96
  'Needs the optional extra: "linux-mirror-bench[http3]".'
95
97
  ),
96
98
  )
99
+ MaxAgeOpt = typer.Option(
100
+ 36.0,
101
+ "--max-age",
102
+ min=0.0,
103
+ help=(
104
+ "Drop mirrors more than this many hours behind the newest mirror. 0 keeps all."
105
+ ),
106
+ )
97
107
  SkipThroughputOpt = typer.Option(
98
108
  False,
99
109
  "--no-throughput",
@@ -104,7 +114,13 @@ SkipThroughputOpt = typer.Option(
104
114
  def _parse_countries(raw: str | None) -> tuple[str, ...]:
105
115
  if not raw:
106
116
  return ()
107
- return tuple(c.strip().upper() for c in raw.split(",") if c.strip())
117
+ codes = tuple(c.strip().upper() for c in raw.split(",") if c.strip())
118
+ for code in codes:
119
+ # Codes go into mirror-list URLs, so only real alpha-2 shapes pass.
120
+ if len(code) != 2 or not code.isascii() or not code.isalpha():
121
+ msg = f"bad --country code {code!r}; expected two letters, e.g. US"
122
+ raise typer.BadParameter(msg)
123
+ return codes
108
124
 
109
125
 
110
126
  def _parse_weights(raw: str | None) -> Weights:
@@ -179,9 +195,13 @@ def bench_cmd(
179
195
  csv_out: bool = CsvOpt,
180
196
  no_throughput: bool = SkipThroughputOpt,
181
197
  http3: bool = Http3Opt,
198
+ max_age: float = MaxAgeOpt,
182
199
  ) -> None:
183
200
  """Discover mirrors, run a two-phase benchmark, print the ranked table."""
184
201
  _check_output_flags(json_out, csv_out)
202
+ if not math.isfinite(max_age):
203
+ msg = "--max-age must be a finite number of hours"
204
+ raise typer.BadParameter(msg)
185
205
  if http3:
186
206
  # Before discovery, so the flag fails the same way whether or not
187
207
  # any mirror turns out to advertise HTTP/3.
@@ -206,6 +226,7 @@ def bench_cmd(
206
226
  weights=_parse_weights(weights),
207
227
  skip_throughput=no_throughput,
208
228
  http3=http3,
229
+ max_age_hours=max_age,
209
230
  )
210
231
  if no_throughput and cfg.weights.latency + cfg.weights.security <= 0:
211
232
  msg = "--no-throughput needs a latency or security weight above zero"
@@ -238,8 +259,6 @@ def completion_cmd(
238
259
  depend on shell auto-detection and works reliably in sandboxes, CI, and
239
260
  containers. Pipe the output to your shell's completion directory.
240
261
  """
241
- from click.shell_completion import shell_complete
242
-
243
262
  shell = shell.lower()
244
263
  if shell not in {"bash", "zsh", "fish"}:
245
264
  typer.secho(
@@ -249,12 +268,16 @@ def completion_cmd(
249
268
  )
250
269
  raise typer.Exit(code=2)
251
270
 
252
- prog = "mirror-bench"
253
- env_var = "_MIRROR_BENCH_COMPLETE"
254
- cli = typer.main.get_command(app)
255
- rc = shell_complete(cli, {}, prog, env_var, f"{shell}_source")
256
- if rc != 0:
257
- raise typer.Exit(code=rc)
271
+ # typer's own generator: recent typer releases bundle their CLI layer and
272
+ # no longer install click, so click cannot be imported here.
273
+ typer.echo(
274
+ get_completion_script(
275
+ prog_name="mirror-bench",
276
+ complete_var="_MIRROR_BENCH_COMPLETE",
277
+ # The keyword names the target shell; no command is executed.
278
+ shell=shell, # nosec B604
279
+ )
280
+ )
258
281
 
259
282
 
260
283
  @app.command("list")
@@ -310,6 +333,7 @@ async def _run_bench(host: HostInfo, cfg: BenchConfig, *, quiet: bool) -> list[S
310
333
  TimeElapsedColumn(),
311
334
  )
312
335
  console = display.stderr_console()
336
+ quiet = quiet or not console.is_terminal
313
337
 
314
338
  if quiet:
315
339
  latency_results = await probe.latency_screen(
@@ -337,7 +361,33 @@ async def _run_bench(host: HostInfo, cfg: BenchConfig, *, quiet: bool) -> list[S
337
361
  on_progress=on_prog,
338
362
  )
339
363
 
340
- latency_results = scorer.prefer_https(latency_results)
364
+ if not any(r.ok for r in latency_results):
365
+ # An empty table with no reason is the usual result of a wrong
366
+ # --release, so say what the mirrors answered.
367
+ reasons = Counter(r.error or f"http_{r.status}" for r in latency_results)
368
+ typer.secho(
369
+ f"no mirror passed the latency probe ({len(latency_results)} tried; "
370
+ f"most common failure: {reasons.most_common(1)[0][0]}). "
371
+ "Check the release name.",
372
+ fg=typer.colors.YELLOW,
373
+ err=True,
374
+ )
375
+
376
+ # Stale endpoints go first: prefer_https would otherwise keep a stale
377
+ # https:// endpoint and discard its current http:// twin.
378
+ ages = scorer.sync_ages(latency_results)
379
+ fresh = scorer.drop_stale(latency_results, ages, cfg.max_age_hours * 3600)
380
+ fresh_hosts = {r.mirror.host for r in fresh}
381
+ stale_hosts = {r.mirror.host for r in latency_results} - fresh_hosts
382
+ if stale_hosts:
383
+ typer.secho(
384
+ f"dropped {len(stale_hosts)} "
385
+ f"{'mirror' if len(stale_hosts) == 1 else 'mirrors'} more than "
386
+ f"{cfg.max_age_hours:g} h behind the newest",
387
+ fg=typer.colors.YELLOW,
388
+ err=True,
389
+ )
390
+ latency_results = scorer.prefer_https(fresh)
341
391
  if cfg.tls13_only:
342
392
  latency_results = scorer.filter_for_policy(
343
393
  latency_results, https_only=False, tls13_only=True
@@ -377,7 +427,7 @@ async def _run_bench(host: HostInfo, cfg: BenchConfig, *, quiet: bool) -> list[S
377
427
  on_progress=on_prog2,
378
428
  )
379
429
 
380
- scores = scorer.score_results(top_for_phase2, throughput_map, cfg.weights)
430
+ scores = scorer.score_results(top_for_phase2, throughput_map, cfg.weights, ages)
381
431
  if cfg.http3:
382
432
  scores = await _with_h3_probes(scores, min(8, cfg.concurrency))
383
433
  return scores
@@ -1,5 +1,6 @@
1
1
  """Arch Linux mirror discovery via archlinux.org/mirrors/status/json/."""
2
2
 
3
+ import re
3
4
  from typing import TYPE_CHECKING
4
5
 
5
6
  from mirror_bench.discovery.base import (
@@ -7,6 +8,7 @@ from mirror_bench.discovery.base import (
7
8
  MirrorDiscoverer,
8
9
  ensure_trailing_slash,
9
10
  fetch,
11
+ first_int,
10
12
  host_of,
11
13
  )
12
14
  from mirror_bench.models import HostInfo, Mirror
@@ -15,6 +17,7 @@ if TYPE_CHECKING:
15
17
  import httpx
16
18
 
17
19
  _STATUS_JSON = "https://archlinux.org/mirrors/status/json/"
20
+ _UNIX_TIME_RE = re.compile(rb"\A\s*(\d+)\s*\Z")
18
21
 
19
22
 
20
23
  class ArchDiscoverer(MirrorDiscoverer):
@@ -59,7 +62,12 @@ class ArchDiscoverer(MirrorDiscoverer):
59
62
  return mirrors
60
63
 
61
64
  def probe_path(self, host: HostInfo) -> str:
62
- return "lastsync"
65
+ # `lastupdate` changes when the content does; `lastsync` changes on
66
+ # each sync run, so two mirrors with equal content would differ.
67
+ return "lastupdate"
68
+
69
+ def sync_time(self, mirror: Mirror, body: bytes) -> float | None:
70
+ return first_int(body, _UNIX_TIME_RE)
63
71
 
64
72
  def throughput_path(self, host: HostInfo) -> str:
65
73
  # Official Arch mirrors carry x86_64 only, whatever the probing host is.