diff --git a/apps/darkroom-android/src/lib.rs b/apps/darkroom-android/src/lib.rs index 7ce71bc..89579ae 100644 --- a/apps/darkroom-android/src/lib.rs +++ b/apps/darkroom-android/src/lib.rs @@ -152,8 +152,10 @@ fn android_main(app: slint::android::AndroidApp) { None => log::error!("no internal data path; settings will not persist"), } - // After the data dir and before anything asks whether a model is present. - install_bundled_models(&app); + // After the data dir, because it writes beside the catalog. **Not** before + // the first frame any more — it starts a worker and returns; see the + // function for what it used to cost the launch. + install_bundled_models(app.clone()); // Before `init_with_event_listener`, which takes `app` by value and is the // last moment anything can ask the activity a question. Not an ordering @@ -256,10 +258,56 @@ fn android_main(app: slint::android::AndroidApp) { /// whether or not the tab is opened. Assets are also *stored* rather than /// deflated in the APK (see `assemble-apk.sh`), so unpacking is a copy rather /// than an inflate. +/// +/// # Why it returns before it has done anything +/// +/// **`android_main` runs with the input channel unserviced.** Nothing drains +/// it until Slint reaches `poll_events`, and Slint does not reach `poll_events` +/// until `dr_ui::run` calls `window.run()`, which is the last line of it. So +/// every millisecond spent between the top of `android_main` and that line is a +/// millisecond in which Android's input dispatcher gets no answer, and five +/// thousand of them is an ANR by definition — the system puts "DarkRoom isn't +/// responding" over a window that has never painted, and offers to kill it. +/// +/// This copies **41 MB** on the first launch after an install: 24.9 MB of scene +/// model, 13.6 MB of embedder, 2.5 MB of detector, each read whole out of the +/// APK and written to `/data`. v0.10.0 added the scene model, which is 60% of +/// that total; v0.10.0 is the release the ANR appeared in, and the 8,010 minor +/// faults in its report are what 41 MB of freshly touched pages looks like. +/// +/// So it runs on a worker (NFR-ARCH-1: nothing blocking on the UI executor) and +/// this function returns as soon as the thread is running. Nothing on the +/// launch path waits for it, and no other startup step needs its result. +/// +/// # The window in which a model looks absent, and why that is honest enough +/// +/// Until the copy finishes, `library::face_models` and `library::scene_model` +/// answer `is_file()` about files that are not written yet, so both report +/// their feature unavailable — the same answer they give a build carrying no +/// weights at all, which is the ordinary case this whole path was written +/// around. It is briefly pessimistic rather than wrong, it lasts about as long +/// as it takes to read one screenful of the grid, and the temporary name +/// [`unpack_bundled_models`] writes under is what stops it being worse than +/// pessimistic: a lookup never sees a half-written file, only an absent one. #[cfg(target_os = "android")] -fn install_bundled_models(app: &slint::android::AndroidApp) { +fn install_bundled_models(app: slint::android::AndroidApp) { + // Detached rather than joined: there is no later moment on the launch path + // that wants the answer, and a handle nobody joins is a handle nobody can + // forget to. `AndroidApp` is documented `Send` and `Sync` and is an `Arc` + // internally, so the clone costs a refcount; `asset_manager` is asked for + // on the worker because `AAssetManager` is thread-safe by contract and + // reading the pointer takes only the app's read lock, which `poll_events` + // also only ever holds shared. + std::thread::spawn(move || unpack_bundled_models(&app)); +} + +/// The copy itself, on the worker [`install_bundled_models`] starts. +#[cfg(target_os = "android")] +fn unpack_bundled_models(app: &slint::android::AndroidApp) { use std::io::Read; + let started = std::time::Instant::now(); + // The face names are the **shape-fixed** exports, matching what // `library::face_models` looks for: tract cannot parse either InsightFace // graph with its dynamic input dimension, so what ships here has already @@ -282,12 +330,16 @@ fn install_bundled_models(app: &slint::android::AndroidApp) { let dir = dr_ui::shared_face_models_dir(); let assets = app.asset_manager(); + let mut copied = 0u64; for (asset_path, name) in BUNDLED { let dest = dir.join(name); - // Already unpacked. Not re-read on every launch: this is 15 MB through - // a decompressor on the startup path, and the file does not change - // without the APK changing, at which point the install wiped it anyway. + // Already unpacked. Not re-read on every launch: this is 41 MB of + // copying across the five entries, and the file does not change without + // the APK changing, at which point the install wiped it anyway. It + // matters more now than it did — a launch that skips every entry here + // costs nothing at all, which is what makes the second launch after an + // install cheap even though the first one is not. if dest.is_file() { continue; } @@ -312,13 +364,25 @@ fn install_bundled_models(app: &slint::android::AndroidApp) { // than a missing one. let part = dir.join(format!("{name}.part")); match std::fs::write(&part, &bytes).and_then(|()| std::fs::rename(&part, &dest)) { - Ok(()) => log::info!("installed bundled {name} ({} bytes)", bytes.len()), + Ok(()) => { + copied += bytes.len() as u64; + log::info!("installed bundled {name} ({} bytes)", bytes.len()); + } Err(e) => { log::error!("cannot install {name}: {e}"); let _ = std::fs::remove_file(&part); } } } + + // The figure this whole function is about. Said even when it is zero, so a + // launch that ANRs anyway can be told apart from one that spent its six + // seconds here — on a second launch there is nothing left to copy and the + // line reads `0 bytes`. + log::info!( + "bundled models ready: {copied} bytes copied in {} ms", + started.elapsed().as_millis() + ); } /// TRACES: FR-PLAT-AND-6 diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index ab4562c..32c62e5 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -3808,8 +3808,14 @@ pub fn face_models_dir(account: &Account) -> PathBuf { /// `faces.model_id` (catalog.md §10.1) and not by who is signed in, so two /// accounts have no reason to hold two 15 MB copies of the same weights. This /// is also the only directory an Android build can populate for itself — the -/// entry point extracts the APK's bundled copy here before any store opens, -/// and at that moment no session exists to key a per-account path off. +/// entry point extracts the APK's bundled copy here, and no session exists at +/// that point to key a per-account path off. +/// +/// **That extraction races the first seconds of a launch and is meant to.** It +/// is 41 MB of copying and it used to happen before the first frame, which on a +/// tablet is an ANR (`darkroom-android`'s `install_bundled_models`). So a +/// lookup here can answer "absent" for a model that is on its way; each file is +/// renamed into place, so what a lookup never sees is a half-written one. pub fn shared_face_models_dir() -> PathBuf { data_root().join("models") } diff --git a/ui/dr-ui/src/segmentation.rs b/ui/dr-ui/src/segmentation.rs index b520ec2..c8dc26f 100644 --- a/ui/dr-ui/src/segmentation.rs +++ b/ui/dr-ui/src/segmentation.rs @@ -733,8 +733,11 @@ fn load_scene_model() -> Result { { // Account-independent, like `shared_face_models_dir` and for the same // reason: this runs on a worker with no session in hand. Android - // unpacks the APK's copy to exactly this directory before any store - // opens. + // unpacks the APK's copy to exactly this directory, on a worker of its + // own started at launch — so on the first launch after an install this + // can report the model missing for the couple of seconds the 25 MB copy + // takes. See `shared_face_models_dir` for why it is no longer done + // before the first frame. let dir = crate::library::shared_face_models_dir(); dr_segment::SceneModel::from_path( dir.join("yolo26s-sem-ade20k.onnx"),