fix: release files through git-cdn.magicbean.dev, falling back to git.magicbean.dev #20

Merged
HumanBean merged 1 commit from HumanBean/LongshotLauncher:cdn-downloads into main 2026-11-13 08:47:09 +00:00
Owner

Why

The launcher downloads every release file (the builder, SHA256SUMS, its signature, tools.lock, its own update) straight from git.magicbean.dev, the home server. git-cdn.magicbean.dev serves the same files from Cloudflare's cache, so downloads should come from there first and leave the server alone.

What changed

  • core: Core/CdnFetcher.cs (new) wraps the fetcher. CdnOf() rewrites a https://git.magicbean.dev link whose path is a release file (/<owner>/<repo>/releases/download/...) or a package (/api/packages/...) to https://git-cdn.magicbean.dev + the same path. DownloadAsync tries that first, then the original link. It falls back only on a failure of the CDN (FallsBack()): no answer (connection, TLS, timeout, stall: Status null), or HTTP 403, 404 or 5xx. API calls (GetTextAsync, /api/v1/...), other hosts, and links already on git-cdn are left as they are.
  • core: a size or SHA-256 mismatch does not fall back. Those checks run in the callers (ReleaseManifest.FetchAsync/FetchUncheckedAsync, SelfUpdate.DownloadAsync, Toolchains.DownloadFrom) and throw InvalidDataException as they did before. The fallback catches only FetchException.
  • core: a download the CDN cuts short starts again from the stream's start (SetLength(start), Position = start), so nothing of the CDN's half stays. If the stream can't seek back, the CDN's error is rethrown. All the callers' streams can seek: MemoryStream, the update's FileStream, and Toolchains' CheckedStream, which now seeks back only to its start and resets its hash and count (Core/Toolchains.cs).
  • core: tools from tools.lock already list their git-cdn link first (Longshot #160). Toolchains.Download tries each tool's own links once, in order, through CdnFetcher.Inner, using CdnFetcher.Expand(). The git-cdn link is added in front of an older list that lacks it, and is never tried twice. A link that gives other bytes moves on to the next one, as before. The builder zip and AppImage go through the strict fallback.
  • app: App/LauncherService.cs puts both fetchers (downloads and the update check) behind CdnFetcher.
  • docs: the README's Environment and command line table documents LONGSHOT_LAUNCHER_NO_CDN=1.
  • tests: tests/launcher_core/CdnChecks.cs (38 checks).

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
LONGSHOT_LAUNCHER_NO_CDN=1 Every release file and tool comes from git.magicbean.dev, none from git-cdn CDN on (variable unset) CDN on: it fails over to git.magicbean.dev by itself; the variable is for debugging Debugging a CDN problem - Windows

Measurements

main: 22983c82 (2026-11-13)
this PR: cdn-downloads @ b9c621b3
Hardware: Linux x86_64 desktop (CachyOS), wired home connection
Scene: no game; the launcher's own v1.0.0-rc.1 release, LongshotLauncher-v1.0.0-rc.1-x86_64.AppImage (40,049,144 bytes)
Method: a small harness that compiles Core/ and runs Releases.VersionAsync("v1.0.0-rc.1") (signed SHA256SUMS and .sig) and then SelfUpdate.DownloadAsync on the AppImage (size and SHA-256 checked). It times the AppImage download and logs each URL the inner fetcher fetched. 2 runs each, interleaved (CDN, no CDN, CDN, no CDN); the median of 2 is their mean.
How to reproduce: build the harness against Core/ and run it with cdn/nocdn (or LONGSHOT_LAUNCHER_NO_CDN=1)
Baseline: this PR @ b9c621b3, LONGSHOT_LAUNCHER_NO_CDN=1 (baseline). With the CDN off, the code takes main's download path (the same links on git.magicbean.dev, the same HttpFetcher), so it compares the two hosts with the same harness.

this PR @ b9c621b3, LONGSHOT_LAUNCHER_NO_CDN=1 (baseline) this PR @ b9c621b3 (CDN)
AppImage, run 1 0.97 s (41.1 MB/s) 0.88 s (45.7 MB/s)
AppImage, run 2 1.29 s (31.1 MB/s) 0.91 s (44.1 MB/s)
median 1.13 s (36.1 MB/s) 0.90 s (44.9 MB/s)
== cdn
AppImage verified: 40049144 bytes in 0.88 s = 45.7 MB/s
  text ok   https://git.magicbean.dev/api/v1/repos/MagicBeanDev/LongshotLauncher/releases/tags/v1.0.0-rc.1
  file ok   https://git-cdn.magicbean.dev/MagicBeanDev/LongshotLauncher/releases/download/v1.0.0-rc.1/SHA256SUMS
  file ok   https://git-cdn.magicbean.dev/MagicBeanDev/LongshotLauncher/releases/download/v1.0.0-rc.1/SHA256SUMS.sig
  file ok   https://git-cdn.magicbean.dev/MagicBeanDev/LongshotLauncher/releases/download/v1.0.0-rc.1/LongshotLauncher-v1.0.0-rc.1-x86_64.AppImage
== nocdn
AppImage verified: 40049144 bytes in 0.97 s = 41.1 MB/s
  file ok   https://git.magicbean.dev/MagicBeanDev/LongshotLauncher/releases/download/v1.0.0-rc.1/LongshotLauncher-v1.0.0-rc.1-x86_64.AppImage
== cdn
AppImage verified: 40049144 bytes in 0.91 s = 44.1 MB/s
== nocdn
AppImage verified: 40049144 bytes in 1.29 s = 31.1 MB/s
$ curl -r 0-0 -D - https://git-cdn.magicbean.dev/MagicBeanDev/LongshotLauncher/releases/download/v1.0.0-rc.1/LongshotLauncher-v1.0.0-rc.1-x86_64.AppImage
HTTP/2 206 / content-range: bytes 0-0/40049144 / cf-cache-status: HIT

From this PC the two hosts are close. The CDN's gain is mostly for players far from the server, and in taking the load off it.

Testing

Tested:

  • python -I tests/test_launcher_core.py --strict --longshot <Longshot checkout>: 568 checks on main, 606 here. The new ones cover: the rewrite (a release file, a package, the API, a release page, other hosts, already-git-cdn, http, another port); the fallback for no answer, 403, 404, 500 and 503 before any byte, and for a file the CDN lacks; 400 not falling back; both hosts failing (git.magicbean.dev's error is the one reported); cut short half way into a MemoryStream, the update's file and Toolchains' checked stream; a stream that can't seek, which does not fall back. Also: no fallback on another SHA-256 or an oversize file (FetchAsync, FetchUncheckedAsync, SelfUpdate, the builder zip); each of a tool's links once, in order; and LONGSHOT_LAUNCHER_NO_CDN. Removing the stream reset makes the checks fail.
  • python -I tests/test_release.py: 28 tests, OK (unchanged).
  • dotnet build LongshotLauncher.slnx -c Release -warnaserror: 0 warnings, 0 errors (Linux).
  • Live, Linux: the launcher's v1.0.0-rc.1 SHA256SUMS, .sig and AppImage came through git-cdn (log above). The signature and the AppImage's size and SHA-256 verified.

Not tested:

  • The full launcher window (a builder install and a self-update from a real release) was not run end to end. The harness drives the same core calls.
  • A live CDN outage. The fallback is checked with faults in the tests only.
  • Windows (the same code; no Windows PC here): a builder install and a launcher update there should come from git-cdn.magicbean.dev and verify. No test request opened (not asked for this PR).

Needs other testers: None

Screenshots

Nothing on screen

Risk and rollback

If git-cdn serves a stale or wrong file, the download fails its SHA-256 check as it would have from git.magicbean.dev. It does not fall back silently (except for a tool, whose tools.lock links already moved on after bad bytes). If the CDN is down, each download waits for its failure (the connection error, or at worst the 60 s timeout) before going to git.magicbean.dev. LONGSHOT_LAUNCHER_NO_CDN=1 turns it off for one player; reverting the commit turns it off for all.

Reviewer notes

Start with Core/CdnFetcher.cs (CdnOf, FallsBack, DownloadAsync), then Toolchains.Download and the CheckedStream reset.

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main.
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above). (This repository's set: tests/test_launcher_core.py and tests/test_release.py, both passing.)
  • Tested in the game, or what wasn't is named under Testing.
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests).
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported.
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

If applicable

  • Flags noted, and the switch reference updated (docs/development.md, the README).
  • A game-free test added for the fix, failing on main and passing here.
  • Docs updated.
## Why The launcher downloads every release file (the builder, `SHA256SUMS`, its signature, `tools.lock`, its own update) straight from git.magicbean.dev, the home server. `git-cdn.magicbean.dev` serves the same files from Cloudflare's cache, so downloads should come from there first and leave the server alone. ## What changed - core: `Core/CdnFetcher.cs` (new) wraps the fetcher. `CdnOf()` rewrites a `https://git.magicbean.dev` link whose path is a release file (`/<owner>/<repo>/releases/download/...`) or a package (`/api/packages/...`) to `https://git-cdn.magicbean.dev` + the same path. `DownloadAsync` tries that first, then the original link. It falls back only on a failure of the CDN (`FallsBack()`): no answer (connection, TLS, timeout, stall: `Status` null), or HTTP 403, 404 or 5xx. API calls (`GetTextAsync`, `/api/v1/...`), other hosts, and links already on git-cdn are left as they are. - core: a size or SHA-256 mismatch does **not** fall back. Those checks run in the callers (`ReleaseManifest.FetchAsync`/`FetchUncheckedAsync`, `SelfUpdate.DownloadAsync`, `Toolchains.DownloadFrom`) and throw `InvalidDataException` as they did before. The fallback catches only `FetchException`. - core: a download the CDN cuts short starts again from the stream's start (`SetLength(start)`, `Position = start`), so nothing of the CDN's half stays. If the stream can't seek back, the CDN's error is rethrown. All the callers' streams can seek: `MemoryStream`, the update's `FileStream`, and `Toolchains`' `CheckedStream`, which now seeks back only to its start and resets its hash and count (`Core/Toolchains.cs`). - core: tools from `tools.lock` already list their git-cdn link first (Longshot #160). `Toolchains.Download` tries each tool's own links once, in order, through `CdnFetcher.Inner`, using `CdnFetcher.Expand()`. The git-cdn link is added in front of an older list that lacks it, and is never tried twice. A link that gives other bytes moves on to the next one, as before. The builder zip and AppImage go through the strict fallback. - app: `App/LauncherService.cs` puts both fetchers (downloads and the update check) behind `CdnFetcher`. - docs: the README's *Environment and command line* table documents `LONGSHOT_LAUNCHER_NO_CDN=1`. - tests: `tests/launcher_core/CdnChecks.cs` (38 checks). ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `LONGSHOT_LAUNCHER_NO_CDN=1` | Every release file and tool comes from git.magicbean.dev, none from git-cdn | CDN on (variable unset) | CDN on: it fails over to git.magicbean.dev by itself; the variable is for debugging | Debugging a CDN problem | - | Windows | ## Measurements main: 22983c82 (2026-11-13) this PR: cdn-downloads @ b9c621b3 Hardware: Linux x86_64 desktop (CachyOS), wired home connection Scene: no game; the launcher's own `v1.0.0-rc.1` release, `LongshotLauncher-v1.0.0-rc.1-x86_64.AppImage` (40,049,144 bytes) Method: a small harness that compiles `Core/` and runs `Releases.VersionAsync("v1.0.0-rc.1")` (signed `SHA256SUMS` and `.sig`) and then `SelfUpdate.DownloadAsync` on the AppImage (size and SHA-256 checked). It times the AppImage download and logs each URL the inner fetcher fetched. 2 runs each, interleaved (CDN, no CDN, CDN, no CDN); the median of 2 is their mean. How to reproduce: build the harness against `Core/` and run it with `cdn`/`nocdn` (or `LONGSHOT_LAUNCHER_NO_CDN=1`) Baseline: this PR @ b9c621b3, LONGSHOT_LAUNCHER_NO_CDN=1 (baseline). With the CDN off, the code takes main's download path (the same links on git.magicbean.dev, the same `HttpFetcher`), so it compares the two hosts with the same harness. | | this PR @ b9c621b3, LONGSHOT_LAUNCHER_NO_CDN=1 (baseline) | this PR @ b9c621b3 (CDN) | |---|---|---| | AppImage, run 1 | 0.97 s (41.1 MB/s) | 0.88 s (45.7 MB/s) | | AppImage, run 2 | 1.29 s (31.1 MB/s) | 0.91 s (44.1 MB/s) | | median | 1.13 s (36.1 MB/s) | 0.90 s (44.9 MB/s) | ``` == cdn AppImage verified: 40049144 bytes in 0.88 s = 45.7 MB/s text ok https://git.magicbean.dev/api/v1/repos/MagicBeanDev/LongshotLauncher/releases/tags/v1.0.0-rc.1 file ok https://git-cdn.magicbean.dev/MagicBeanDev/LongshotLauncher/releases/download/v1.0.0-rc.1/SHA256SUMS file ok https://git-cdn.magicbean.dev/MagicBeanDev/LongshotLauncher/releases/download/v1.0.0-rc.1/SHA256SUMS.sig file ok https://git-cdn.magicbean.dev/MagicBeanDev/LongshotLauncher/releases/download/v1.0.0-rc.1/LongshotLauncher-v1.0.0-rc.1-x86_64.AppImage == nocdn AppImage verified: 40049144 bytes in 0.97 s = 41.1 MB/s file ok https://git.magicbean.dev/MagicBeanDev/LongshotLauncher/releases/download/v1.0.0-rc.1/LongshotLauncher-v1.0.0-rc.1-x86_64.AppImage == cdn AppImage verified: 40049144 bytes in 0.91 s = 44.1 MB/s == nocdn AppImage verified: 40049144 bytes in 1.29 s = 31.1 MB/s $ curl -r 0-0 -D - https://git-cdn.magicbean.dev/MagicBeanDev/LongshotLauncher/releases/download/v1.0.0-rc.1/LongshotLauncher-v1.0.0-rc.1-x86_64.AppImage HTTP/2 206 / content-range: bytes 0-0/40049144 / cf-cache-status: HIT ``` From this PC the two hosts are close. The CDN's gain is mostly for players far from the server, and in taking the load off it. ## Testing **Tested:** - `python -I tests/test_launcher_core.py --strict --longshot <Longshot checkout>`: 568 checks on main, **606** here. The new ones cover: the rewrite (a release file, a package, the API, a release page, other hosts, already-git-cdn, http, another port); the fallback for no answer, 403, 404, 500 and 503 before any byte, and for a file the CDN lacks; 400 not falling back; both hosts failing (git.magicbean.dev's error is the one reported); cut short half way into a `MemoryStream`, the update's file and `Toolchains`' checked stream; a stream that can't seek, which does not fall back. Also: no fallback on another SHA-256 or an oversize file (`FetchAsync`, `FetchUncheckedAsync`, `SelfUpdate`, the builder zip); each of a tool's links once, in order; and `LONGSHOT_LAUNCHER_NO_CDN`. Removing the stream reset makes the checks fail. - `python -I tests/test_release.py`: 28 tests, OK (unchanged). - `dotnet build LongshotLauncher.slnx -c Release -warnaserror`: 0 warnings, 0 errors (Linux). - Live, Linux: the launcher's `v1.0.0-rc.1` `SHA256SUMS`, `.sig` and AppImage came through git-cdn (log above). The signature and the AppImage's size and SHA-256 verified. **Not tested:** - The full launcher window (a builder install and a self-update from a real release) was not run end to end. The harness drives the same core calls. - A live CDN outage. The fallback is checked with faults in the tests only. - Windows (the same code; no Windows PC here): a builder install and a launcher update there should come from git-cdn.magicbean.dev and verify. No test request opened (not asked for this PR). **Needs other testers:** None ## Screenshots Nothing on screen ## Risk and rollback If git-cdn serves a stale or wrong file, the download fails its SHA-256 check as it would have from git.magicbean.dev. It does not fall back silently (except for a tool, whose `tools.lock` links already moved on after bad bytes). If the CDN is down, each download waits for its failure (the connection error, or at worst the 60 s timeout) before going to git.magicbean.dev. `LONGSHOT_LAUNCHER_NO_CDN=1` turns it off for one player; reverting the commit turns it off for all. ## Reviewer notes Start with `Core/CdnFetcher.cs` (`CdnOf`, `FallsBack`, `DownloadAsync`), then `Toolchains.Download` and the `CheckedStream` reset. ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [x] Rebased on current `main`. - [x] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). (This repository's set: `tests/test_launcher_core.py` and `tests/test_release.py`, both passing.) - [x] Tested in the game, or what wasn't is named under Testing. - [x] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). - [x] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### If applicable - [x] Flags noted, and the switch reference updated (docs/development.md, the README). - [x] A game-free test added for the fix, failing on `main` and passing here. - [x] Docs updated.
fix: release files through git-cdn.magicbean.dev, falling back to git.magicbean.dev
All checks were successful
CI / Compile and test (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 3s
b9c621b329
The launcher's downloads of release files and packages (the builder, SHA256SUMS and its
signature, tools.lock, the launcher's own update) go first to git-cdn.magicbean.dev, the same
path from Cloudflare's cache, then to git.magicbean.dev when the CDN does not answer or returns
403, 404 or 5xx. A file that fails its size or SHA-256 check still fails; a download the CDN cuts
short starts again from the stream's start. A tool's own links (tools.lock) are tried once each,
in order. LONGSHOT_LAUNCHER_NO_CDN=1 turns the CDN off. The API stays on git.magicbean.dev.
HumanBean deleted branch cdn-downloads 2026-11-13 08:47:09 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/LongshotLauncher!20
No description provided.