max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
7 files changed,
+448 insertions,
-38 deletions
| @@ -4751,7 +4751,7 @@ | |||
| 4751 | 4751 | ||
| 4752 | 4752 | [[package]] | |
| 4753 | 4753 | name = "quasi-type" | |
| 4754 | - | version = "0.1.2" | |
| 4754 | + | version = "0.1.3" | |
| 4755 | 4755 | dependencies = [ | |
| 4756 | 4756 | "brotli", | |
| 4757 | 4757 | "font-types", | |
| @@ -6524,7 +6524,7 @@ | |||
| 6524 | 6524 | "serde_with", | |
| 6525 | 6525 | "swift-rs", | |
| 6526 | 6526 | "thiserror 2.0.20", | |
| 6527 | - | "toml 1.1.4+spec-1.1.0", | |
| 6527 | + | "toml 0.9.12+spec-1.1.0", | |
| 6528 | 6528 | "url", | |
| 6529 | 6529 | "urlpattern", | |
| 6530 | 6530 | "uuid", |
| @@ -432,6 +432,11 @@ | |||
| 432 | 432 | // document to hand a stylesheet to. | |
| 433 | 433 | #[cfg(not(target_os = "android"))] | |
| 434 | 434 | quasi::theming::install(&state); | |
| 435 | + | // The runtime a described route hands slow work to, filled in | |
| 436 | + | // here for the same reason as everything above: this is where | |
| 437 | + | // there is an `Arc` to weaken. `Handle::current` is this | |
| 438 | + | // setup task's runtime, which is the app's. | |
| 439 | + | AppState::install_offload(&state, tokio::runtime::Handle::current()); | |
| 435 | 440 | // Closes the 503 window the deferred protocol opens, before | |
| 436 | 441 | // anything can put a window up. | |
| 437 | 442 | #[cfg(not(target_os = "android"))] |
| @@ -374,6 +374,11 @@ | |||
| 374 | 374 | // this is where there is a state to read. See | |
| 375 | 375 | // `quasi::theming`. | |
| 376 | 376 | goingson_desktop::quasi::theming::install(&state); | |
| 377 | + | // The runtime a described route hands slow work to, filled in | |
| 378 | + | // here for the same reason as everything above: this is where | |
| 379 | + | // there is an `Arc` to weaken. `Handle::current` is this | |
| 380 | + | // setup task's runtime, which is the app's. | |
| 381 | + | AppState::install_offload(&state, tokio::runtime::Handle::current()); | |
| 377 | 382 | // Before the window is built, so the 503 window the deferred | |
| 378 | 383 | // protocol opens closes here rather than at first request. | |
| 379 | 384 | quasi_state.set(Arc::clone(&state)); |
| @@ -18,7 +18,7 @@ | |||
| 18 | 18 | }; | |
| 19 | 19 | use rusqlite::params; | |
| 20 | 20 | use std::path::PathBuf; | |
| 21 | - | use std::sync::{Arc, Mutex}; | |
| 21 | + | use std::sync::{Arc, Mutex, OnceLock, Weak}; | |
| 22 | 22 | use synckit_client::{SyncKitClient, SyncKitConfig, SyncStore}; | |
| 23 | 23 | use tauri::{AppHandle, Manager}; | |
| 24 | 24 | use tokio::sync::Mutex as TokioMutex; | |
| @@ -68,6 +68,11 @@ | |||
| 68 | 68 | /// unauthenticated local HTTP endpoint. | |
| 69 | 69 | pub pending_oauth_servers: | |
| 70 | 70 | Arc<Mutex<std::collections::HashMap<u16, crate::oauth::OAuthCallbackServer>>>, | |
| 71 | + | /// How a route hands slow work to the runtime; see [`Offload`]. Empty | |
| 72 | + | /// until `install` fills it, which is after the state is behind an `Arc`. | |
| 73 | + | pub offload: OnceLock<Offload>, | |
| 74 | + | /// What the on-demand backup is doing; see [`BackupRun`]. | |
| 75 | + | pub backup_run: Arc<Mutex<BackupRun>>, | |
| 71 | 76 | pub data_dir: PathBuf, | |
| 72 | 77 | /// Where themes are looked for, in precedence order, with `true` marking a | |
| 73 | 78 | /// directory the user owns. | |
| @@ -128,6 +133,67 @@ | |||
| 128 | 133 | pub port: u16, | |
| 129 | 134 | } | |
| 130 | 135 | ||
| 136 | + | /// How a described route hands slow work to the app. | |
| 137 | + | /// | |
| 138 | + | /// A described handler is `fn(&AppState, Request) -> Result<Response, RouteError>` | |
| 139 | + | /// and stays that way (quasicoherent `dc2f2b46`): the router has no runtime, | |
| 140 | + | /// and giving it one would reach a TUI and a CLI too. So work measured in | |
| 141 | + | /// seconds is the app's to run, and this is the app's half of the bargain — a | |
| 142 | + | /// runtime to hand it to, and an owned handle on the state to hand along with | |
| 143 | + | /// it. A `&AppState` borrow can produce neither on its own, which is the whole | |
| 144 | + | /// reason this exists rather than the route calling `tokio::spawn` itself. | |
| 145 | + | /// | |
| 146 | + | /// [`Weak`] because the handle lives inside the value it points at. Behind a | |
| 147 | + | /// [`OnceLock`] because the state is built before it is installed behind an | |
| 148 | + | /// [`Arc`], so it can only be filled in afterwards — by `install`, at the same | |
| 149 | + | /// site that hands the state to the quasi protocols. | |
| 150 | + | #[derive(Debug, Clone)] | |
| 151 | + | pub struct Offload { | |
| 152 | + | runtime: tokio::runtime::Handle, | |
| 153 | + | state: Weak<AppState>, | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | impl Offload { | |
| 157 | + | /// Run `work` on the app's runtime, handed the state it needs. | |
| 158 | + | /// | |
| 159 | + | /// Answers `false` when the state is already gone, which is shutdown and | |
| 160 | + | /// not an error: there is nothing left to back up. A caller that has | |
| 161 | + | /// already told the reader "this started" wants to know, so this is a | |
| 162 | + | /// `bool` rather than a discarded handle. | |
| 163 | + | pub fn run<Fut>(&self, work: impl FnOnce(Arc<AppState>) -> Fut + Send + 'static) -> bool | |
| 164 | + | where | |
| 165 | + | Fut: std::future::Future<Output = ()> + Send + 'static, | |
| 166 | + | { | |
| 167 | + | let Some(state) = self.state.upgrade() else { | |
| 168 | + | return false; | |
| 169 | + | }; | |
| 170 | + | self.runtime.spawn(work(state)); | |
| 171 | + | true | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// What the on-demand backup is doing, so a screen that started one can say how | |
| 176 | + | /// it went without holding a request open while it runs. | |
| 177 | + | /// | |
| 178 | + | /// The reporting half of [`Offload`]. A route answers | |
| 179 | + | /// [`Outcome::Started`](quasi_router::Outcome::Started) and the region it named | |
| 180 | + | /// re-asks on the renderer's own cadence (`Slot::live`); each re-ask reads this | |
| 181 | + | /// and draws what it finds. Nothing here is a channel: a poll that misses the | |
| 182 | + | /// transition still sees `Finished` on the next one. | |
| 183 | + | #[derive(Debug, Default, Clone, PartialEq, Eq)] | |
| 184 | + | pub enum BackupRun { | |
| 185 | + | /// No on-demand backup has been asked for since the app started, or the | |
| 186 | + | /// last one's outcome has been drawn and cleared. | |
| 187 | + | #[default] | |
| 188 | + | Idle, | |
| 189 | + | /// One is being written now. | |
| 190 | + | Running, | |
| 191 | + | /// The last one finished, and this is what to say about it. The `bool` is | |
| 192 | + | /// whether it worked, so a failure is reported rather than silently | |
| 193 | + | /// looking like an idle screen. | |
| 194 | + | Finished { ok: bool, said: String }, | |
| 195 | + | } | |
| 196 | + | ||
| 131 | 197 | impl AppState { | |
| 132 | 198 | #[instrument(skip(app), name = "AppState::new")] | |
| 133 | 199 | pub async fn new(app: &AppHandle) -> Result<Self, String> { | |
| @@ -286,6 +352,8 @@ | |||
| 286 | 352 | token_refresh_locks: Arc::new(Mutex::new(std::collections::HashMap::new())), | |
| 287 | 353 | pending_oauth_flows: Arc::new(Mutex::new(std::collections::HashMap::new())), | |
| 288 | 354 | pending_oauth_servers: Arc::new(Mutex::new(std::collections::HashMap::new())), | |
| 355 | + | offload: OnceLock::new(), | |
| 356 | + | backup_run: Arc::new(Mutex::new(BackupRun::default())), | |
| 289 | 357 | data_dir: app_data_dir, | |
| 290 | 358 | theme_dirs: crate::commands::theme_dirs(app), | |
| 291 | 359 | config_dir: crate::commands::config_dir(app), | |
| @@ -296,6 +364,67 @@ | |||
| 296 | 364 | }) | |
| 297 | 365 | } | |
| 298 | 366 | ||
| 367 | + | /// Fill in the handle a described route needs to hand work off. | |
| 368 | + | /// | |
| 369 | + | /// Called once, at the site that installs the state behind an [`Arc`] and | |
| 370 | + | /// hands it to the quasi protocols, because that is the first moment both | |
| 371 | + | /// halves exist: an `Arc` to weaken, and a runtime to spawn on. Answers | |
| 372 | + | /// whether it took, which is `false` only when it has been called twice. | |
| 373 | + | /// | |
| 374 | + | /// See [`Offload`] for why a route cannot do this for itself. | |
| 375 | + | pub fn install_offload(state: &Arc<Self>, runtime: tokio::runtime::Handle) -> bool { | |
| 376 | + | state | |
| 377 | + | .offload | |
| 378 | + | .set(Offload { | |
| 379 | + | runtime, | |
| 380 | + | state: Arc::downgrade(state), | |
| 381 | + | }) | |
| 382 | + | .is_ok() | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | /// Whether an on-demand backup is being written right now. | |
| 386 | + | pub fn backup_running(&self) -> bool { | |
| 387 | + | matches!( | |
| 388 | + | *self | |
| 389 | + | .backup_run | |
| 390 | + | .lock() | |
| 391 | + | .unwrap_or_else(std::sync::PoisonError::into_inner), | |
| 392 | + | BackupRun::Running | |
| 393 | + | ) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | /// Take the last on-demand backup's outcome, leaving the record idle. | |
| 397 | + | /// | |
| 398 | + | /// Taken rather than read, because an outcome is said once: the region that | |
| 399 | + | /// asks is the region that was waiting, and a success notice redrawn on | |
| 400 | + | /// every subsequent poll is a screen that never stops congratulating | |
| 401 | + | /// itself. `None` is "nothing has finished since the last time anybody | |
| 402 | + | /// looked", which covers both idle and still-running. | |
| 403 | + | pub fn take_finished_backup(&self) -> Option<(bool, String)> { | |
| 404 | + | let mut run = self | |
| 405 | + | .backup_run | |
| 406 | + | .lock() | |
| 407 | + | .unwrap_or_else(std::sync::PoisonError::into_inner); | |
| 408 | + | match std::mem::take(&mut *run) { | |
| 409 | + | BackupRun::Finished { ok, said } => Some((ok, said)), | |
| 410 | + | // Put back what was not an outcome. `mem::take` left `Idle` behind | |
| 411 | + | // and a run in flight must not be forgotten by someone reading. | |
| 412 | + | still @ BackupRun::Running => { | |
| 413 | + | *run = still; | |
| 414 | + | None | |
| 415 | + | } | |
| 416 | + | BackupRun::Idle => None, | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | /// Record what the on-demand backup is doing; see [`BackupRun`]. | |
| 421 | + | pub fn set_backup_run(&self, run: BackupRun) { | |
| 422 | + | *self | |
| 423 | + | .backup_run | |
| 424 | + | .lock() | |
| 425 | + | .unwrap_or_else(std::sync::PoisonError::into_inner) = run; | |
| 426 | + | } | |
| 427 | + | ||
| 299 | 428 | /// Clone out the underlying sync client, if one is configured. The client is | |
| 300 | 429 | /// owned by the `SyncStore`; the auth/subscription/tier commands reach it here. | |
| 301 | 430 | pub(crate) fn read_recovering(&self) -> Option<Arc<SyncKitClient>> { |
| @@ -90,6 +90,8 @@ | |||
| 90 | 90 | token_refresh_locks: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), | |
| 91 | 91 | pending_oauth_flows: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), | |
| 92 | 92 | pending_oauth_servers: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), | |
| 93 | + | offload: std::sync::OnceLock::new(), | |
| 94 | + | backup_run: Arc::new(std::sync::Mutex::new(crate::state::BackupRun::default())), | |
| 93 | 95 | data_dir: std::path::PathBuf::from("/tmp/goingson-test"), | |
| 94 | 96 | // The tree's own themes, which is the dev fallback `theme_dirs` builds | |
| 95 | 97 | // from `CARGO_MANIFEST_DIR`. A test has no `AppHandle` and so no |
| @@ -82,21 +82,30 @@ | |||
| 82 | 82 | //! other side and its Import & Export section is still out, which is now a | |
| 83 | 83 | //! second thing to close rather than a limit. | |
| 84 | 84 | //! | |
| 85 | - | //! **2. STILL OPEN. A described write cannot be long-running, so "Create Backup" | |
| 86 | - | //! is absent.** Untouched by finding 1's ruling, which is a point that ruling | |
| 87 | - | //! made itself: the on-demand backup takes no path, so it was never the same | |
| 88 | - | //! gap. `create_backup` is the one write on this screen that is genuinely | |
| 89 | - | //! async: the gzip write goes to the blocking pool because it takes seconds on a | |
| 90 | - | //! large database, and freezing the UI on it is a fixed performance finding | |
| 91 | - | //! (Perf S6). A handler is sync and has no runtime, so a described Create Backup | |
| 92 | - | //! could only be that freeze, restored. The automatic backups the scheduler | |
| 93 | - | //! takes are unaffected, and the list, the restore and the delete are all here — | |
| 94 | - | //! it is only the "now" button that has nowhere to go. Filed with finding 1. | |
| 85 | + | //! **2. CLOSED 2026-08-29. A described write could not be long-running, so | |
| 86 | + | //! "Create Backup" was absent.** `create_backup` is the one write on this | |
| 87 | + | //! screen that is genuinely async: the gzip write goes to the blocking pool | |
| 88 | + | //! because it takes seconds on a large database, and freezing the UI on it is a | |
| 89 | + | //! fixed performance finding (Perf S6). A handler is synchronous and has no | |
| 90 | + | //! runtime, so a described Create Backup could only be that freeze, restored. | |
| 95 | 91 | //! | |
| 96 | - | //! This is quasicoherent `82273265` (a write with a best-effort remote half) | |
| 97 | - | //! from a different direction: there the second half was remote, here it is | |
| 98 | - | //! slow, and both are the same fact that a described write is one synchronous | |
| 99 | - | //! call that answers. | |
| 92 | + | //! quasicoherent `dc2f2b46` answered it, and the answer kept the property that | |
| 93 | + | //! made this hard: `Handler<S>` is still `fn(&S, Request) -> Result<Response, | |
| 94 | + | //! RouteError>`, because a router that acquired a runtime would have acquired | |
| 95 | + | //! one for a TUI and a CLI too. What changed is that there is now a word for | |
| 96 | + | //! handing work off — [`quasi_router::Outcome::Started`] — and the offload | |
| 97 | + | //! itself stayed here, where it always belonged. See [`create`] for the route | |
| 98 | + | //! and [`crate::state::Offload`] for the app's half. | |
| 99 | + | //! | |
| 100 | + | //! The reporting half needed nothing new. [`backups_region`] is | |
| 101 | + | //! [`Slot::live`] because the scheduler writes automatic backups into the same | |
| 102 | + | //! directory, so the region was already re-asking on a cadence; a finished | |
| 103 | + | //! on-demand run is reported by [`listing`] on the next such ask. That the | |
| 104 | + | //! channel already existed is the reason this cost a route and not a design. | |
| 105 | + | //! | |
| 106 | + | //! This was quasicoherent `82273265` (a write with a best-effort remote half) | |
| 107 | + | //! from a different direction: there the second half was remote, here it was | |
| 108 | + | //! slow. `82273265` is still open. | |
| 100 | 109 | //! | |
| 101 | 110 | //! **3. A preview and the write it precedes are two requests, and the file may | |
| 102 | 111 | //! change between them.** The preview parses the path and shows what it holds; | |
| @@ -143,7 +152,7 @@ | |||
| 143 | 152 | export_tasks_csv_bytes, list_backups_in, | |
| 144 | 153 | }; | |
| 145 | 154 | use crate::commands::import_external::DuplicateStrategy; | |
| 146 | - | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 155 | + | use crate::state::{AppState, BackupRun, DESKTOP_USER_ID}; | |
| 147 | 156 | ||
| 148 | 157 | #[cfg(test)] | |
| 149 | 158 | mod tests; | |
| @@ -778,12 +787,115 @@ | |||
| 778 | 787 | ||
| 779 | 788 | /// The backups half of the screen. | |
| 780 | 789 | /// | |
| 781 | - | /// "Create Backup" is finding 2: the write it would call is the one thing here | |
| 782 | - | /// that has to be offloaded, and a described handler has nowhere to offload to. | |
| 790 | + | /// Live, and honestly so: this directory gains files without anybody pressing | |
| 791 | + | /// anything, because the scheduler writes automatic backups into it. That was | |
| 792 | + | /// already true before "Create Backup" was describable and is the reason the | |
| 793 | + | /// region can carry a started answer at all — the cadence exists for the | |
| 794 | + | /// automatic half, and the on-demand half rides it. | |
| 795 | + | /// | |
| 796 | + | /// The started answer depends on this. `quasi_http` retargets the region and | |
| 797 | + | /// swaps its contents, deliberately sending no cadence of its own: the | |
| 798 | + | /// `hx-trigger` has to already be on the element, put there by this render. | |
| 799 | + | /// Dropping [`Slot::live`] here would leave a "Creating backup…" stand-in | |
| 800 | + | /// standing forever, so the two move together. | |
| 783 | 801 | fn backups_region(state: &AppState) -> Result<Slot, RouteError> { | |
| 784 | - | Ok(Slot::new(BACKUPS, RegionKind::Pane) | |
| 802 | + | let mut region = Slot::new(BACKUPS, RegionKind::Pane) | |
| 803 | + | .fed_by(Action::get("/data/backups")) | |
| 804 | + | .live() | |
| 785 | 805 | .with(Node::section("Backups")) | |
| 786 | - | .with(backups(state)?)) | |
| 806 | + | .with(Node::Act(Act::new( | |
| 807 | + | "Create Backup", | |
| 808 | + | Action::post("/data/backups/create"), | |
| 809 | + | ))); | |
| 810 | + | ||
| 811 | + | // Read rather than taken: the outcome of a finished run is the asking | |
| 812 | + | // handler's to say, once, as a toast. What belongs in the region is only | |
| 813 | + | // the fact that one is still going. | |
| 814 | + | if state.backup_running() { | |
| 815 | + | region = region.with(Node::pending("Creating backup…")); | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | Ok(region.with(backups(state)?)) | |
| 819 | + | } | |
| 820 | + | ||
| 821 | + | /// The backups region on its own, which is what the live cadence asks for. | |
| 822 | + | /// | |
| 823 | + | /// Also where a finished on-demand run is reported: the region that re-asks is | |
| 824 | + | /// the region that was waiting, so the answer it gets is the natural place to | |
| 825 | + | /// say how it went. Taken and not read, so it is said once. | |
| 826 | + | fn listing(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 827 | + | let finished = state.take_finished_backup(); | |
| 828 | + | let answer = Response::fragment(BACKUPS, Node::Region(backups_region(state)?)); | |
| 829 | + | Ok(match finished { | |
| 830 | + | Some((true, said)) => answer.toast(Tone::Success, said), | |
| 831 | + | Some((false, said)) => answer.toast(Tone::Danger, said), | |
| 832 | + | None => answer, | |
| 833 | + | }) | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | /// Start a backup, and answer that it started. | |
| 837 | + | /// | |
| 838 | + | /// quasicoherent `dc2f2b46`. The one write on this screen that genuinely takes | |
| 839 | + | /// seconds: the gzip goes to the blocking pool because doing it inline froze | |
| 840 | + | /// the UI (Perf S6). The handler stays synchronous — it hands the work to the | |
| 841 | + | /// app's own runtime through [`crate::state::Offload`] and answers | |
| 842 | + | /// [`quasi_router::Outcome::Started`], which says "this began" rather than | |
| 843 | + | /// "this is done" or, as before, saying nothing because the control was absent. | |
| 844 | + | /// | |
| 845 | + | /// Refuses a second run while one is going. Two concurrent full backups are two | |
| 846 | + | /// gzip streams over the same database for no benefit, and the filename is | |
| 847 | + | /// collision-safe rather than idempotent, so the second would land as its own | |
| 848 | + | /// file. | |
| 849 | + | fn create(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 850 | + | if state.backup_running() { | |
| 851 | + | return Ok( | |
| 852 | + | Response::fragment(BACKUPS, Node::Region(backups_region(state)?)) | |
| 853 | + | .toast(Tone::Warning, "A backup is already being written."), | |
| 854 | + | ); | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | let Some(offload) = state.offload.get() else { | |
| 858 | + | // No runtime was installed, which is a host that never called | |
| 859 | + | // `install_offload`. Said rather than swallowed: the alternative is a | |
| 860 | + | // button that reports success and does nothing. | |
| 861 | + | return Err(RouteError::internal( | |
| 862 | + | "this host cannot run a backup in the background", | |
| 863 | + | )); | |
| 864 | + | }; | |
| 865 | + | ||
| 866 | + | state.set_backup_run(BackupRun::Running); | |
| 867 | + | ||
| 868 | + | let handed_off = offload.run(|state| async move { | |
| 869 | + | let outcome = crate::backup_scheduler::create_backup_now(&state).await; | |
| 870 | + | state.set_backup_run(match outcome { | |
| 871 | + | Ok(done) => BackupRun::Finished { | |
| 872 | + | ok: true, | |
| 873 | + | said: format!( | |
| 874 | + | "Backup created: {}.", | |
| 875 | + | std::path::Path::new(&done.file_path) | |
| 876 | + | .file_name() | |
| 877 | + | .map_or_else( | |
| 878 | + | || done.file_path.clone(), | |
| 879 | + | |name| name.to_string_lossy().into_owned() | |
| 880 | + | ) | |
| 881 | + | ), | |
| 882 | + | }, | |
| 883 | + | Err(error) => BackupRun::Finished { | |
| 884 | + | ok: false, | |
| 885 | + | said: format!("Backup failed: {error}"), | |
| 886 | + | }, | |
| 887 | + | }); | |
| 888 | + | }); | |
| 889 | + | ||
| 890 | + | if !handed_off { | |
| 891 | + | // The state is already going away, so nothing will run and nothing will | |
| 892 | + | // report. Put the record back rather than leaving a run marked in | |
| 893 | + | // flight that no longer exists. | |
| 894 | + | state.set_backup_run(BackupRun::Idle); | |
| 895 | + | return Err(RouteError::internal("the app is shutting down")); | |
| 896 | + | } | |
| 897 | + | ||
| 898 | + | Ok(Response::started(BACKUPS, "Creating backup…")) | |
| 787 | 899 | } | |
| 788 | 900 | ||
| 789 | 901 | /// How often automatic backups are taken, and how many are kept. | |
| @@ -1001,10 +1113,11 @@ | |||
| 1001 | 1113 | /// dialog, a browser downloads and a terminal writes beside the process, and | |
| 1002 | 1114 | /// none of that is in the description. | |
| 1003 | 1115 | /// | |
| 1004 | - | /// Create Backup is still absent, and finding 2 is still the reason: its gzip | |
| 1005 | - | /// write goes to the blocking pool and a described write is one synchronous | |
| 1006 | - | /// call. That is `dc2f2b46`, a different decision, and this ruling did not | |
| 1007 | - | /// touch it. | |
| 1116 | + | /// Create Backup was absent for the neighbouring reason -- its gzip write goes | |
| 1117 | + | /// to the blocking pool and a described write is one synchronous call -- and is | |
| 1118 | + | /// here now. `dc2f2b46` answered it separately, by giving the vocabulary a way | |
| 1119 | + | /// to say that work was handed off rather than by letting the handler wait; see | |
| 1120 | + | /// [`create`]. | |
| 1008 | 1121 | fn export_region() -> Slot { | |
| 1009 | 1122 | let mut region = Slot::new("data-export", RegionKind::Pane) | |
| 1010 | 1123 | .with(Node::section("Export")) | |
| @@ -1160,6 +1273,8 @@ | |||
| 1160 | 1273 | .post("/data/import/{kind}/preview", preview) | |
| 1161 | 1274 | .post("/data/import/{kind}", import) | |
| 1162 | 1275 | .post("/data/export/{format}", export) | |
| 1276 | + | .get("/data/backups", listing) | |
| 1277 | + | .post("/data/backups/create", create) | |
| 1163 | 1278 | .post("/data/backups/{name}/restore", restore) | |
| 1164 | 1279 | .post("/data/backups/{name}/delete", delete) | |
| 1165 | 1280 | .post("/data/backups/automatic", save_automatic) |
| @@ -146,15 +146,12 @@ | |||
| 146 | 146 | assert!(page.contains("iCalendar file")); | |
| 147 | 147 | assert!(page.contains("/data/import/csv/preview")); | |
| 148 | 148 | ||
| 149 | - | // Finding 2, and only finding 2 since 2026-08-21. Create Backup is absent | |
| 150 | - | // rather than drawn as a control that does nothing: its write has to be | |
| 151 | - | // offloaded and a described write cannot be. A control that is drawn and | |
| 152 | - | // does nothing is worse than one that is not drawn. | |
| 153 | - | // | |
| 154 | - | // The three exports were in this list until `67881a88` was ruled, and they | |
| 155 | - | // are on the screen now -- asserted in `the_screen_offers_all_three_exports` | |
| 156 | - | // below rather than left as a hole here. | |
| 157 | - | assert!(!page.contains("Create Backup")); | |
| 149 | + | // Nothing is left out of this screen as of 2026-08-29. The three exports | |
| 150 | + | // came back when `67881a88` was ruled and Create Backup when `dc2f2b46` | |
| 151 | + | // was, each asserted in a test of its own below rather than left as a hole | |
| 152 | + | // here: `the_screen_offers_all_three_exports` and | |
| 153 | + | // `create_hands_the_backup_off_and_answers_that_it_started`. | |
| 154 | + | assert!(page.contains("Create Backup")); | |
| 158 | 155 | } | |
| 159 | 156 | ||
| 160 | 157 | #[tokio::test] | |
| @@ -683,9 +680,9 @@ | |||
| 683 | 680 | ] { | |
| 684 | 681 | assert!(page.contains(label), "{label} is not on the screen"); | |
| 685 | 682 | } | |
| 686 | - | // And Create Backup is still absent, for finding 2's reason, which this | |
| 687 | - | // ruling did not touch. | |
| 688 | - | assert!(!page.contains("Create Backup")); | |
| 683 | + | // And Create Backup beside them since `dc2f2b46`, which answered finding 2 | |
| 684 | + | // separately: the handler is still synchronous and hands the gzip off. | |
| 685 | + | assert!(page.contains("Create Backup")); | |
| 689 | 686 | } | |
| 690 | 687 | ||
| 691 | 688 | /// Picking a file is the host's, and the control says so. | |
| @@ -739,3 +736,160 @@ | |||
| 739 | 736 | .screen(screen); | |
| 740 | 737 | assert!(markup.contains("/static/host.js"), "{markup}"); | |
| 741 | 738 | } | |
| 739 | + | ||
| 740 | + | // -- Create Backup, and the started answer it hands back --------------------- | |
| 741 | + | // | |
| 742 | + | // quasicoherent `dc2f2b46`. Finding 2 in this module's header was open until | |
| 743 | + | // there was a word for handing work off; these are what closed it. | |
| 744 | + | ||
| 745 | + | /// The region a started answer aims at has to be re-asking already, because the | |
| 746 | + | /// started answer deliberately sends no cadence of its own. If this stops being | |
| 747 | + | /// true, "Creating backup…" stands on the screen forever. | |
| 748 | + | #[tokio::test] | |
| 749 | + | async fn the_backups_region_re_asks_on_its_own_so_a_started_backup_can_report() { | |
| 750 | + | let (state, _dir) = state().await; | |
| 751 | + | let response = get(&state, "/data"); | |
| 752 | + | let quasi_router::Outcome::Screen(screen) = &response.outcome else { | |
| 753 | + | panic!("the data screen answers a screen"); | |
| 754 | + | }; | |
| 755 | + | let region = screen | |
| 756 | + | .slots | |
| 757 | + | .iter() | |
| 758 | + | .find(|slot| slot.id == "data-backups") | |
| 759 | + | .expect("the backups region is on the screen"); | |
| 760 | + | ||
| 761 | + | assert!(region.live, "the backups list changes without the reader"); | |
| 762 | + | assert!( | |
| 763 | + | region.fed_by.is_some(), | |
| 764 | + | "and a live region with nowhere to ask re-asks nothing" | |
| 765 | + | ); | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | /// The whole of it: press the button, get told it started, and find the file | |
| 769 | + | /// afterwards -- with the handler still synchronous the entire time. | |
| 770 | + | #[tokio::test] | |
| 771 | + | async fn create_hands_the_backup_off_and_answers_that_it_started() { | |
| 772 | + | let (state, _dir) = state().await; | |
| 773 | + | AppState::install_offload(&state, tokio::runtime::Handle::current()); | |
| 774 | + | ||
| 775 | + | let response = post(&state, "/data/backups/create", Params::default()); | |
| 776 | + | ||
| 777 | + | let quasi_router::Outcome::Started { region, message } = &response.outcome else { | |
| 778 | + | panic!("expected work started, got {:?}", response.outcome); | |
| 779 | + | }; | |
| 780 | + | assert_eq!(region, "data-backups"); | |
| 781 | + | assert_eq!(message, "Creating backup…"); | |
| 782 | + | ||
| 783 | + | // The answer came back before the work did, which is the point. | |
| 784 | + | let finished = settle(&state).await; | |
| 785 | + | assert!(finished.0, "the backup worked: {}", finished.1); | |
| 786 | + | assert!( | |
| 787 | + | finished.1.contains("goingson-backup-"), | |
| 788 | + | "it names the file it wrote: {}", | |
| 789 | + | finished.1 | |
| 790 | + | ); | |
| 791 | + | } | |
| 792 | + | ||
| 793 | + | /// Wait for the handed-off run to land, and answer what it said. | |
| 794 | + | /// | |
| 795 | + | /// Polled rather than awaited on a handle: the route keeps no handle, which is | |
| 796 | + | /// the arrangement being tested. A described screen learns the same way, on its | |
| 797 | + | /// own cadence. | |
| 798 | + | async fn settle(state: &AppState) -> (bool, String) { | |
| 799 | + | for _ in 0..200 { | |
| 800 | + | if let Some(outcome) = state.take_finished_backup() { | |
| 801 | + | return outcome; | |
| 802 | + | } | |
| 803 | + | tokio::time::sleep(std::time::Duration::from_millis(25)).await; | |
| 804 | + | } | |
| 805 | + | panic!("the backup never finished"); | |
| 806 | + | } | |
| 807 | + | ||
| 808 | + | /// A finished run is reported to whoever asks next, and reported once. | |
| 809 | + | #[tokio::test] | |
| 810 | + | async fn a_finished_run_is_said_once_and_then_the_region_settles() { | |
| 811 | + | let (state, _dir) = state().await; | |
| 812 | + | state.set_backup_run(crate::state::BackupRun::Finished { | |
| 813 | + | ok: true, | |
| 814 | + | said: "Backup created: goingson-backup-20260829-120000-abcd1234.json.gz.".into(), | |
| 815 | + | }); | |
| 816 | + | ||
| 817 | + | let first = get(&state, "/data/backups"); | |
| 818 | + | assert!(said(&first).contains("Backup created"), "{}", said(&first)); | |
| 819 | + | ||
| 820 | + | // The next poll is a plain list. A success notice redrawn every cadence is | |
| 821 | + | // a screen that never stops congratulating itself. | |
| 822 | + | let second = get(&state, "/data/backups"); | |
| 823 | + | assert_eq!(said(&second), ""); | |
| 824 | + | } | |
| 825 | + | ||
| 826 | + | /// A failure is reported rather than looking like an idle screen, which is the | |
| 827 | + | /// one thing a fire-and-forget offload could plausibly get wrong. | |
| 828 | + | #[tokio::test] | |
| 829 | + | async fn a_failed_run_is_reported_rather_than_going_quiet() { | |
| 830 | + | let (state, _dir) = state().await; | |
| 831 | + | state.set_backup_run(crate::state::BackupRun::Finished { | |
| 832 | + | ok: false, | |
| 833 | + | said: "Backup failed: the disk is full".into(), | |
| 834 | + | }); | |
| 835 | + | ||
| 836 | + | let response = get(&state, "/data/backups"); | |
| 837 | + | assert!( | |
| 838 | + | said(&response).contains("Backup failed"), | |
| 839 | + | "{}", | |
| 840 | + | said(&response) | |
| 841 | + | ); | |
| 842 | + | assert_eq!( | |
| 843 | + | response.notice.as_ref().map(|notice| notice.tone), | |
| 844 | + | Some(makeover_layout::Tone::Danger) | |
| 845 | + | ); | |
| 846 | + | } | |
| 847 | + | ||
| 848 | + | /// Two full gzip streams over one database buys nothing, and the filename is | |
| 849 | + | /// collision-safe rather than idempotent, so the second would land as its own | |
| 850 | + | /// file. Refused, and said. | |
| 851 | + | #[tokio::test] | |
| 852 | + | async fn a_second_create_while_one_is_running_is_refused_and_said() { | |
| 853 | + | let (state, _dir) = state().await; | |
| 854 | + | AppState::install_offload(&state, tokio::runtime::Handle::current()); | |
| 855 | + | state.set_backup_run(crate::state::BackupRun::Running); | |
| 856 | + | ||
| 857 | + | let response = post(&state, "/data/backups/create", Params::default()); | |
| 858 | + | ||
| 859 | + | assert!( | |
| 860 | + | matches!(response.outcome, Outcome::Fragment { .. }), | |
| 861 | + | "a refusal is the region back, not a second start" | |
| 862 | + | ); | |
| 863 | + | assert!( | |
| 864 | + | said(&response).contains("already being written"), | |
| 865 | + | "{}", | |
| 866 | + | said(&response) | |
| 867 | + | ); | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | /// While one is running the region says so, so a reader who reloads the screen | |
| 871 | + | /// mid-backup is not shown a list that looks finished. | |
| 872 | + | #[tokio::test] | |
| 873 | + | async fn while_a_backup_runs_the_region_says_so() { | |
| 874 | + | let (state, _dir) = state().await; | |
| 875 | + | state.set_backup_run(crate::state::BackupRun::Running); | |
| 876 | + | ||
| 877 | + | let page = html(get(&state, "/data/backups")); | |
| 878 | + | assert!(page.contains("Creating backup…"), "{page}"); | |
| 879 | + | } | |
| 880 | + | ||
| 881 | + | /// A host that installed no runtime gets an error, not a button that reports | |
| 882 | + | /// success and does nothing. | |
| 883 | + | #[tokio::test] | |
| 884 | + | async fn without_a_runtime_to_hand_the_work_to_the_button_refuses() { | |
| 885 | + | let (state, _dir) = state().await; | |
| 886 | + | ||
| 887 | + | let error = router() | |
| 888 | + | .handle(&state, Request::post("/data/backups/create")) | |
| 889 | + | .expect_err("no runtime was installed"); | |
| 890 | + | ||
| 891 | + | assert!( | |
| 892 | + | format!("{error:?}").contains("background"), | |
| 893 | + | "it says what is missing: {error:?}" | |
| 894 | + | ); | |
| 895 | + | } |