perf(launcher): share startup release response with update check

This commit is contained in:
rdbtCVS committed 2026-10-14 23:52:59 +01:00
1 parent 73a019967e
commit aec1519f91
4 files changed
+343 -10

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
# Shared startup release response
Startup previously fetched `/releases?per_page=100` on two worker threads: one
for game releases and one for updates. It now decodes that response once and
parses game releases once, publishing them before older-launcher fallback
lookups. Manual and four-hour update checks still request fresh data. Release
page retries remain independent. Existing self-update guards still apply.
Run the committed benchmark against the actual backend implementation:
```sh
CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \
cargo test --manifest-path launcher/Cargo.toml --locked --lib \
backend::release_loading_tests::benchmark_shared_startup_response \
-- --ignored --nocapture
```
Measured on 2026-10-14, Linux x86_64, AMD Ryzen 5 5600X, Rust 1.99.0,
unoptimized Cargo test profile with debug information disabled. A shared
`CARGO_TARGET_DIR` was used for local build reuse. Each case warms up 20 times
and takes the median of 201 samples; outputs are validated for equivalence
and passed to `black_box`.
| Scenario | Before median | After median | Ratio | Fetches per startup |
| --- | ---: | ---: | ---: | ---: |
| Serial parsing/decoding work, zero transport delay | 14.701 ms | 7.339 ms | 2.003x | 2 → 1 |
| Concurrent startup workers, 5 ms per mock request | 14.778 ms | 13.642 ms | 1.083x | 2 → 1 |
The measured 201 samples make 402 mock release-list fetches before and 201
after. The concurrent case reduces elapsed time by 7.7%; the two old requests
can overlap, so halving requests does not halve startup latency.
The synthetic 453,197-byte JSON contains 99 game releases (including a draft)
with roughly 4 KiB of notes each and one launcher release with a platform asset.
The mock fetch decodes identical bytes for each request; it does not exercise
HTTP, TLS, GitHub rate limits, filesystem installation scans, GUI rendering or
self-update downloads. The comparison covers backend orchestration, JSON
decoding, sorting, filtering and update selection, not end-to-end launcher
startup or release-build timing. Fallback timing is omitted from the benchmark
and its ordering/error behavior is tested separately. No persistent cache or
extra copy of the game-release vector is introduced.
Validation: 26 launcher library tests passed (benchmark ignored by default);
the benchmark passed explicitly; full launcher all-target check and Clippy
with `-D warnings` passed; root and launcher format checks and `git diff --check`
passed. The GUI compiled but was not launched interactively.
+45 -8
View File
@@ -193,13 +193,19 @@ pub fn launcher_releases() -> Result<Vec<Release>, String> {
/// a day, so the newest launcher release can be older than all of those: then the newest
/// `launcher-v` tags are looked up one by one (a draft's tag is skipped: it has no public release).
fn launcher_releases_in(list: &serde_json::Value) -> Vec<Release> {
launcher_releases_in_with(list, |url| {
get(&url)?.into_json().map_err(|e| e.to_string())
})
}
fn launcher_releases_in_with(
list: &serde_json::Value,
json: impl Fn(String) -> Result<serde_json::Value, String>,
) -> Vec<Release> {
let found = parse_releases(list, LAUNCHER_ASSET, is_launcher_tag);
if found.iter().any(|r| r.asset.is_some()) {
return found;
}
let json = |url: String| -> Result<serde_json::Value, String> {
get(&url)?.into_json().map_err(|e| e.to_string())
};
let api = format!("https://api.github.com/repos/{REPO}");
let Ok(refs) = json(format!(
"{api}/git/matching-refs/tags/{LAUNCHER_TAG_PREFIX}"
@@ -1172,15 +1178,42 @@ pub fn launcher_update(releases: &[Release]) -> Option<Release> {
.cloned()
}
/// One request to GitHub, both kinds of update.
pub fn check_updates() -> Result<Updates, String> {
let list = release_list()?;
/// Load the game's release list and both update kinds from the same GitHub response.
/// Publish the game releases before any fallback lookups for older launcher tags, so those
/// lookups cannot hold up the release pages or opening animation. A list error reaches both.
pub fn releases_and_updates(
on_releases: impl FnOnce(Result<Vec<Release>, String>),
) -> Result<Updates, String> {
releases_and_updates_with(release_list, installed, launcher_releases_in, on_releases)
}
fn releases_and_updates_with(
load: impl FnOnce() -> Result<serde_json::Value, String>,
installed: impl FnOnce() -> Vec<String>,
launchers: impl FnOnce(&serde_json::Value) -> Vec<Release>,
on_releases: impl FnOnce(Result<Vec<Release>, String>),
) -> Result<Updates, String> {
let list = match load() {
Ok(list) => list,
Err(e) => {
on_releases(Err(e.clone()));
return Err(e);
}
};
let releases = parse_releases(&list, ASSET, is_game_tag);
let horizon = horizon_update(&releases, &installed());
on_releases(Ok(releases));
Ok(Updates {
horizon: horizon_update(&parse_releases(&list, ASSET, is_game_tag), &installed()),
launcher: launcher_update(&launcher_releases_in(&list)),
horizon,
launcher: launcher_update(&launchers(&list)),
})
}
/// A fresh release-list request for both kinds of update (plus older-launcher fallback if needed).
pub fn check_updates() -> Result<Updates, String> {
releases_and_updates(|_| {})
}
/// Whether a version installed as an update becomes the selected one: only when the player was
/// on the newest installed version before (or had none).
pub fn select_after_update(selected: Option<&str>, installed_before: &[String]) -> bool {
@@ -1543,6 +1576,10 @@ impl Settings {
}
}
#[cfg(test)]
#[path = "backend/release_loading_tests.rs"]
mod release_loading_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,240 @@
use super::*;
use std::cell::RefCell;
use std::hint::black_box;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
fn fixture() -> String {
let mut list: Vec<_> = (1..=99)
.map(|i| {
serde_json::json!({
"tag_name": format!("v2026.10.14.{i}"),
"name": format!("Build {i}"), "body": "Release notes\r\n".repeat(256),
"published_at": "2026-10-14T12:00:00Z", "draft": i == 99,
"assets": [{"name": ASSET.unwrap_or("unsupported"),
"browser_download_url": "https://example.test/game", "size": 12345}]
})
})
.collect();
list.push(serde_json::json!({
"tag_name": "launcher-v99.0.0", "name": "Launcher", "body": "Notes",
"assets": [{"name": LAUNCHER_ASSET.unwrap_or("unsupported"),
"browser_download_url": "https://example.test/launcher", "size": 6789}]
}));
serde_json::to_string(&list).unwrap()
}
fn installed_fixture() -> Vec<String> {
vec!["v2026.10.14.50".into()]
}
// The former startup paths: each decodes its own response; the update path parses games again.
fn baseline(load: impl Fn() -> Result<serde_json::Value, String>) -> (Vec<Release>, Updates) {
let releases = parse_releases(&load().unwrap(), ASSET, is_game_tag);
let list = load().unwrap();
let updates = Updates {
horizon: horizon_update(
&parse_releases(&list, ASSET, is_game_tag),
&installed_fixture(),
),
launcher: launcher_update(&parse_releases(&list, LAUNCHER_ASSET, is_launcher_tag)),
};
(releases, updates)
}
fn shared(load: impl FnOnce() -> Result<serde_json::Value, String>) -> (Vec<Release>, Updates) {
let mut releases = None;
let updates = releases_and_updates_with(
load,
installed_fixture,
|list| parse_releases(list, LAUNCHER_ASSET, is_launcher_tag),
|r| releases = Some(r.unwrap()),
)
.unwrap();
(releases.unwrap(), updates)
}
#[test]
fn one_response_preserves_both_startup_results() {
let text = fixture();
let requests = AtomicUsize::new(0);
let load = || {
requests.fetch_add(1, Ordering::Relaxed);
Ok(serde_json::from_str(&text).unwrap())
};
let old = baseline(load);
assert_eq!(requests.swap(0, Ordering::Relaxed), 2);
let new = shared(load);
assert_eq!(requests.load(Ordering::Relaxed), 1);
assert_eq!(
serde_json::to_value(old).unwrap(),
serde_json::to_value(new).unwrap()
);
}
#[test]
fn errors_reach_releases_and_updates_without_fallback() {
for error in [
"No connection: offline",
"GitHub's rate limit was reached.",
"invalid JSON",
] {
let mut published = None;
let result = releases_and_updates_with(
|| Err(error.into()),
|| panic!("no installed scan after failure"),
|_| panic!("no fallback after failure"),
|r| published = Some(r),
);
assert_eq!(published.unwrap().unwrap_err(), error);
assert_eq!(result.unwrap_err(), error);
}
}
#[test]
fn publish_games_before_older_launcher_fallback() {
let events = RefCell::new(Vec::new());
let result = releases_and_updates_with(
|| Ok(serde_json::json!([{"tag_name": "v2026.10.14", "assets": []}])),
Vec::new,
|list| {
launcher_releases_in_with(list, |url| {
assert_eq!(events.borrow().first(), Some(&"releases"));
events.borrow_mut().push("fallback");
if url.contains("matching-refs") {
Ok(serde_json::json!([{"ref": "refs/tags/launcher-v99.0.0"}]))
} else {
Ok(
serde_json::json!({"tag_name": "launcher-v99.0.0", "assets": [{
"name": LAUNCHER_ASSET.unwrap_or("unsupported"),
"browser_download_url": "u", "size": 1
}]}),
)
}
})
},
|r| {
assert_eq!(r.unwrap()[0].tag, "v2026.10.14");
events.borrow_mut().push("releases");
},
)
.unwrap();
assert_eq!(*events.borrow(), ["releases", "fallback", "fallback"]);
assert_eq!(result.launcher.is_some(), LAUNCHER_ASSET.is_some());
}
#[test]
fn failed_launcher_fallback_keeps_game_result_and_checks_stay_fresh() {
let requests = AtomicUsize::new(0);
for day in [14, 15] {
let mut published = None;
let updates = releases_and_updates_with(
|| {
requests.fetch_add(1, Ordering::Relaxed);
Ok(serde_json::json!([{"tag_name": format!("v2026.10.{day}")}]))
},
Vec::new,
|list| launcher_releases_in_with(list, |_| Err("offline".into())),
|r| published = Some(r.unwrap()),
)
.unwrap();
assert_eq!(published.unwrap()[0].tag, format!("v2026.10.{day}"));
assert!(updates.launcher.is_none());
}
assert_eq!(requests.load(Ordering::Relaxed), 2);
}
#[test]
#[ignore = "local release-loading benchmark; run explicitly with --ignored --nocapture"]
fn benchmark_shared_startup_response() {
let text = fixture();
let requests = AtomicUsize::new(0);
let load = |delay: Duration| {
requests.fetch_add(1, Ordering::Relaxed);
if !delay.is_zero() {
std::thread::sleep(delay);
}
serde_json::from_str(black_box(&text)).map_err(|e| e.to_string())
};
let old = || baseline(|| load(Duration::ZERO));
let new = || shared(|| load(Duration::ZERO));
assert_eq!(
serde_json::to_value(old()).unwrap(),
serde_json::to_value(new()).unwrap()
);
let concurrent_old = || {
std::thread::scope(|scope| {
let releases = scope.spawn(|| {
parse_releases(&load(Duration::from_millis(5)).unwrap(), ASSET, is_game_tag)
});
let updates = scope.spawn(|| {
let list = load(Duration::from_millis(5)).unwrap();
Updates {
horizon: horizon_update(
&parse_releases(&list, ASSET, is_game_tag),
&installed_fixture(),
),
launcher: launcher_update(&parse_releases(
&list,
LAUNCHER_ASSET,
is_launcher_tag,
)),
}
});
(releases.join().unwrap(), updates.join().unwrap())
})
};
let threaded_new = || {
std::thread::scope(|scope| {
scope
.spawn(|| shared(|| load(Duration::from_millis(5))))
.join()
.unwrap()
})
};
assert_eq!(
serde_json::to_value(concurrent_old()).unwrap(),
serde_json::to_value(threaded_new()).unwrap()
);
fn measure<T>(run: impl Fn() -> T, requests: &AtomicUsize) -> (u128, usize) {
for _ in 0..20 {
black_box(run());
}
requests.store(0, Ordering::Relaxed);
let mut times: Vec<_> = (0..201)
.map(|_| {
let start = Instant::now();
black_box(run());
start.elapsed().as_nanos()
})
.collect();
times.sort_unstable();
(times[100], requests.load(Ordering::Relaxed))
}
println!(
"fixture={} bytes; 100 releases; 20 warmups, 201 samples; median ns",
text.len()
);
for (label, before, after) in [
(
"serial zero-delay work",
measure(old, &requests),
measure(new, &requests),
),
(
"concurrent startup, 5ms/request",
measure(concurrent_old, &requests),
measure(threaded_new, &requests),
),
] {
assert_eq!((before.1, after.1), (402, 201));
println!(
"{label}: before={} after={} ratio={:.3}x requests={}->{}",
before.0,
after.0,
before.0 as f64 / after.0 as f64,
before.1,
after.1
);
}
}
+12 -2
View File
@@ -263,9 +263,8 @@ impl Launcher {
let _ = tx.send(Msg::Reused(reused));
}
});
this.fetch_releases();
this.fetch_startup_releases();
this.fetch_links();
this.check_updates();
this
}
@@ -333,6 +332,17 @@ impl Launcher {
});
}
fn fetch_startup_releases(&mut self) {
self.checked = Some(Instant::now());
let tx = self.tx.clone();
std::thread::spawn(move || {
let updates = backend::releases_and_updates(|releases| {
let _ = tx.send(Msg::Releases(releases));
});
let _ = tx.send(Msg::Updates(updates));
});
}
fn fetch_links(&self) {
let tx = self.tx.clone();
std::thread::spawn(move || {