Skip to main content

max / goingson

Describe Create Backup, by handing the work off and saying so quasicoherent dc2f2b46 gave the vocabulary Outcome::Started, and this is its first consumer. Finding 2 in the data screen's header is closed: the one write on that screen that genuinely takes seconds is describable now without the handler waiting for it. Handler<S> stays synchronous. The offload stayed in the app, where it already was, and is reachable from a route through AppState::Offload -- a runtime handle plus a Weak on the state, filled in at the site that installs the state behind an Arc. A &AppState borrow can produce neither on its own, which is the whole reason it is a field rather than the route calling tokio::spawn. The reporting half needed nothing new. The backups region is Slot::live because the scheduler writes automatic backups into the same directory, so a finished on-demand run is reported on the next re-ask.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
Author: Max Johnson <me@maxj.phd> · 2026-08-29 17:39 UTC
Signed with PGP, not checked
Commit: d972156b6bb56c647fca99c12681017f763e4653
Parent: 1ef5d2b
7 files changed, +448 insertions, -38 deletions
M Cargo.lock +2 -2
@@ -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 + }