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.
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/workflows/integration.yml +2 -2
- linux_mirror_bench-0.3.0/.release-please-manifest.json +3 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/AGENTS.md +3 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/CHANGELOG.md +18 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/Dockerfile +10 -4
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/PKG-INFO +35 -7
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/README.md +34 -6
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/pyproject.toml +1 -1
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/probe.py +23 -4
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/scorer.py +39 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/cli.py +61 -11
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/arch.py +9 -1
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/base.py +76 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/debian.py +6 -1
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/fedora.py +34 -3
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/mint.py +4 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/ubuntu.py +8 -2
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/display.py +29 -6
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/models.py +3 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_cli.py +190 -10
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_discovery.py +172 -9
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_display.py +39 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_h3.py +4 -4
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_integration.py +7 -1
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_probe.py +65 -10
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/test_scorer.py +69 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/uv.lock +113 -32
- linux_mirror_bench-0.2.0/.release-please-manifest.json +0 -3
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.dockerignore +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.editorconfig +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/dependabot.yml +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/workflows/ci.yml +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/workflows/release-please.yml +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.github/workflows/release.yml +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.gitignore +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.markdownlint.yaml +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.pre-commit-config.yaml +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.release-please-config.json +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.tool-versions +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/.yamllint.yaml +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/CODEOWNERS +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/CONTRIBUTING.md +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/LICENSE +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/Makefile +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/SECURITY.md +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/__init__.py +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/__main__.py +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/__init__.py +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/h3.py +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/h3_quic.py +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/tls.py +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/benchmark/transport.py +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/discovery/__init__.py +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/src/mirror_bench/distro.py +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/__init__.py +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/conftest.py +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/fixtures/arch_status.json +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/fixtures/debian_masterlist.txt +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/fixtures/fedora_mirrorlist.txt +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/fixtures/mint_mirrors.html +0 -0
- {linux_mirror_bench-0.2.0 → linux_mirror_bench-0.3.0}/tests/fixtures/ubuntu_mirrors.txt +0 -0
- {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@
|
|
32
|
-
- uses: actions/setup-python@
|
|
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
|
|
@@ -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
|
|
27
|
-
#
|
|
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.
|
|
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
|
|
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
|
|
413
|
-
|
|
414
|
-
`
|
|
415
|
-
|
|
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
|
|
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
|
|
386
|
-
|
|
387
|
-
`
|
|
388
|
-
|
|
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.
|
|
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 =
|
|
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=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|