mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
perf(launcher): share startup release response with update check
This commit is contained in:
1 parent
73a019967e
commit
aec1519f91
4 files changed
+343
-10
No files matched your search
@@ -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
@@ -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
@@ -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 || {
|
||||
|
||||
Reference in new issue
Block a user