diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3326,6 +3326,7 @@ dependencies = [
  "lightcraft-geom",
  "lightcraft-meta",
  "lightcraft-raw",
+ "lightcraft-testkit",
  "log",
  "proptest",
  "serde",
@@ -3498,6 +3499,7 @@ dependencies = [
 name = "lightcraft-fetch"
 version = "0.14.0"
 dependencies = [
+ "lightcraft-testkit",
  "log",
  "rustls",
  "rustls-rustcrypto",
@@ -3630,6 +3632,7 @@ version = "0.14.0"
 dependencies = [
  "jpeg-encoder",
  "lightcraft-raster",
+ "lightcraft-testkit",
  "log",
  "siphasher",
  "zune-core",
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -458,6 +458,13 @@ adapters per backend, corpus, fonts) and every skip by reason. It writes `target
 `target/ci/summary.md`, a block to paste into a PR (`--summary-md` prints it); `--hardware` also runs the
 GPU tests on every backend that has a hardware adapter. Nothing is uploaded.
 
+libtest has no per-test timeout either, so test waits go through `lightcraft_testkit::wait_until` and
+`recv_within`, which fail the test with what it waited for. `LIGHTCRAFT_TIME_SCALE=3` gives every such
+limit three times as long on a slow or shared machine. A watchdog ends a test binary (exit status 124)
+when a watched test (every ui-egui headless test, or one that calls `lightcraft_testkit::watch()`) has
+run for `LIGHTCRAFT_TEST_WATCHDOG_MIN` minutes (default 30, times the time scale; `0` turns it off),
+printing the watched tests still running.
+
 **Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
 
 ```sh
diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml
--- a/crates/catalog/Cargo.toml
+++ b/crates/catalog/Cargo.toml
@@ -22,4 +22,6 @@ web-time = { workspace = true }
 
 [dev-dependencies]
 lightcraft-geom = { workspace = true }
+# deadlines stretched by LIGHTCRAFT_TIME_SCALE
+lightcraft-testkit = { workspace = true }
 proptest = { workspace = true }
diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs
--- a/crates/catalog/src/tests.rs
+++ b/crates/catalog/src/tests.rs
@@ -723,7 +723,10 @@ fn chains_of_smart_albums_stay_linear() {
     }
     let start = std::time::Instant::now();
     assert_eq!(c.album_count(AlbumId(26)), 20);
-    assert!(start.elapsed() < std::time::Duration::from_secs(2), "took {:?}", start.elapsed());
+    // a regression guard, not a speed measurement: linear is microseconds, 2^25 passes take minutes
+    // (stretched by LIGHTCRAFT_TIME_SCALE for a loaded machine, never shrunk below 2 s)
+    let limit = lightcraft_testkit::scaled(std::time::Duration::from_secs(2)).max(std::time::Duration::from_secs(2));
+    assert!(start.elapsed() < limit, "took {:?}", start.elapsed());
 }
 
 /// A smart album's own album filter (not only its rules) counts in loops: A filtered to "in A" is
diff --git a/crates/engine/src/availability.rs b/crates/engine/src/availability.rs
--- a/crates/engine/src/availability.rs
+++ b/crates/engine/src/availability.rs
@@ -300,12 +300,19 @@ mod tests {
         let mut a = Availability::default();
         let caller = std::thread::current().id();
         let on_caller = Arc::new(AtomicUsize::new(0));
-        let c = on_caller.clone();
+        // a sleeping NAS: every check waits until the test opens the gate (or gives up, so a
+        // broken build fails instead of hanging)
+        let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
+        let finished = Arc::new(AtomicUsize::new(0));
+        let (c, g, f) = (on_caller.clone(), gate.clone(), finished.clone());
         a.set_probe(Arc::new(move |p| {
             if std::thread::current().id() == caller {
                 c.fetch_add(1, Ordering::SeqCst);
             }
-            std::thread::sleep(Duration::from_millis(200)); // a sleeping NAS
+            let (open, cv) = &*g;
+            let limit = lightcraft_testkit::scaled(Duration::from_secs(5)).max(Duration::from_secs(5));
+            drop(cv.wait_timeout_while(held(open), limit, |open| !*open).unwrap_or_else(PoisonError::into_inner));
+            f.fetch_add(1, Ordering::SeqCst);
             p == "here"
         }));
         let woke = Arc::new(AtomicUsize::new(0));
@@ -313,14 +320,13 @@ mod tests {
         a.run_in_background(Arc::new(move || {
             w.fetch_add(1, Ordering::SeqCst);
         }));
-        let t0 = std::time::Instant::now();
         assert_eq!(a.exists("here"), None, "unknown at first");
         assert_eq!(a.exists("gone"), None);
-        assert!(t0.elapsed() < Duration::from_millis(150), "the caller never waits for the probe");
-        let deadline = std::time::Instant::now() + Duration::from_secs(20);
-        while (a.exists("here").is_none() || a.exists("gone").is_none()) && std::time::Instant::now() < deadline {
-            std::thread::sleep(Duration::from_millis(10));
-        }
+        // the gate is still shut, so a caller that waited for a check could not have got here yet
+        assert_eq!(finished.load(Ordering::SeqCst), 0, "the caller never waits for the probe");
+        *held(&gate.0) = true;
+        gate.1.notify_all();
+        wait_until("both answers", || a.exists("here").is_some() && a.exists("gone").is_some());
         assert_eq!(a.exists("here"), Some(true));
         assert_eq!(a.exists("gone"), Some(false));
         assert!(a.is_offline("gone") && !a.is_offline("here") && !a.is_offline("unknown"));
@@ -329,12 +335,10 @@ mod tests {
         assert!(a.gone_generation() >= 2);
     }
 
+    /// Wait for `done` (at most 20 s, stretched by `LIGHTCRAFT_TIME_SCALE`).
+    #[track_caller]
     fn wait_until(what: &str, done: impl Fn() -> bool) {
-        let deadline = std::time::Instant::now() + Duration::from_secs(20);
-        while !done() {
-            assert!(std::time::Instant::now() < deadline, "timed out waiting: {what}");
-            std::thread::sleep(Duration::from_millis(2));
-        }
+        lightcraft_testkit::wait_until(Duration::from_secs(20), what, done);
     }
 
     fn held<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
@@ -483,12 +487,18 @@ mod tests {
         let mut a = Availability::default();
         let log = Arc::new(Mutex::new(Vec::<String>::new()));
         let l = log.clone();
-        // the worker is held inside the fourth check until the sweep is told to give way
+        // the worker is held inside the fourth check (and says it got there) until the sweep is told to
+        // give way; the hold gives up after a while, so a failed test leaves no worker spinning
         let hold = Arc::new(std::sync::atomic::AtomicBool::new(true));
-        let h = hold.clone();
+        let entered = Arc::new(std::sync::atomic::AtomicBool::new(false));
+        let (h, e) = (hold.clone(), entered.clone());
         a.set_probe(Arc::new(move |p| {
-            while p == "sweep 3" && h.load(Ordering::SeqCst) {
-                std::thread::sleep(Duration::from_millis(1));
+            if p == "sweep 3" {
+                e.store(true, Ordering::SeqCst);
+                let t = std::time::Instant::now();
+                while h.load(Ordering::SeqCst) && t.elapsed() < lightcraft_testkit::scaled(Duration::from_secs(30)).max(Duration::from_secs(30)) {
+                    std::thread::sleep(Duration::from_millis(1));
+                }
             }
             held(&l).push(p.to_string());
             true
@@ -497,9 +507,12 @@ mod tests {
         let busy = busy_flag(&a);
         busy.store(false, Ordering::SeqCst);
         assert!(a.sweep((0..100).map(|i| format!("sweep {i}")).collect()));
-        wait_until("a few checks", || held(&log).len() >= 3);
+        // the fourth check has started (so giving way now can't keep it from starting)
+        wait_until("the worker inside the fourth check", || entered.load(Ordering::SeqCst));
+        assert_eq!(held(&log).len(), 3);
         busy.store(true, Ordering::SeqCst);
         hold.store(false, Ordering::SeqCst);
+        wait_until("the check in flight", || held(&log).len() >= 4);
         std::thread::sleep(YIELD_POLL * 2);
         let stopped = held(&log).len();
         assert_eq!(stopped, 4, "stopped part way, after the check it was in");
diff --git a/crates/engine/src/denoise/gpu_tests.rs b/crates/engine/src/denoise/gpu_tests.rs
--- a/crates/engine/src/denoise/gpu_tests.rs
+++ b/crates/engine/src/denoise/gpu_tests.rs
@@ -282,20 +282,30 @@ fn a_set_up_runs_beside_the_cpu_and_removes_its_marker() {
 fn a_set_up_that_takes_too_long_is_given_up_on() {
     let dir = folder("slowsetup");
     let marker = dir.join(GPU_SETUP_MARKER);
-    let started = web_time::Instant::now();
-    let slow = || -> Result<(), String> {
-        std::thread::sleep(Duration::from_millis(600));
+    // a card whose set-up is stuck until the test lets it go (it gives up after a while, so a caller
+    // that waits for it fails the test instead of hanging it)
+    let gate = Arc::new(AtomicBool::new(false));
+    let finished = Arc::new(AtomicBool::new(false));
+    let (g, f) = (gate.clone(), finished.clone());
+    let slow = move || -> Result<(), String> {
+        let t = web_time::Instant::now();
+        while !g.load(Ordering::SeqCst) && t.elapsed() < lightcraft_testkit::scaled(Duration::from_secs(5)).max(Duration::from_secs(5)) {
+            std::thread::sleep(Duration::from_millis(1));
+        }
+        f.store(true, Ordering::SeqCst);
         Ok(())
     };
     let r = set_up_card(&marker, Duration::from_millis(50), slow, || ());
     assert!(r.as_ref().is_err_and(|e| e.contains("took longer") && worth_retrying(e)), "{r:?}");
-    assert!(started.elapsed() < Duration::from_millis(500), "the caller does not wait for the card");
+    assert!(!finished.load(Ordering::SeqCst), "the caller does not wait for the card");
     // while the first is still stuck, another set-up of the same model does not start a second one
     let again = set_up_card(&marker, Duration::from_secs(5), || Ok(()), || ());
     assert!(again.is_err_and(|e| e.contains("still setting up") && worth_retrying(&e)));
     // once it ends its marker goes, and the card can be set up again
+    gate.store(true, Ordering::SeqCst);
+    let started = web_time::Instant::now();
     while marker.exists() {
-        assert!(started.elapsed() < Duration::from_secs(10), "the stuck set-up ended");
+        assert!(started.elapsed() < lightcraft_testkit::scaled(Duration::from_secs(10)), "the stuck set-up ended");
         std::thread::sleep(Duration::from_millis(20));
     }
     while SETTING_UP.lock().unwrap().iter().any(|m| m == &marker) {
diff --git a/crates/engine/src/devices.rs b/crates/engine/src/devices.rs
--- a/crates/engine/src/devices.rs
+++ b/crates/engine/src/devices.rs
@@ -247,10 +247,10 @@ mod tests {
         Device { name: name.into(), path: format!("/v/{name}/DCIM"), root: format!("/v/{name}") }
     }
 
-    /// Wait (bounded) for `f`.
+    /// Wait (bounded: 20 s, stretched by `LIGHTCRAFT_TIME_SCALE`) for `f`.
     fn until(f: impl Fn() -> bool) -> bool {
         let t = std::time::Instant::now();
-        while t.elapsed() < Duration::from_secs(20) {
+        while t.elapsed() < lightcraft_testkit::scaled(Duration::from_secs(20)) {
             if f() {
                 return true;
             }
@@ -261,15 +261,21 @@ mod tests {
 
     #[test]
     fn slow_scans_never_block_and_hot_plug_still_arrives() {
-        // a scanner that takes 1.5 s (a sleeping network drive) and counts its runs
-        const SLOW: Duration = Duration::from_millis(1500);
+        // a scanner held until the test opens its gate (a sleeping network drive) that counts its runs;
+        // it gives up after a while so a get() that waits for it fails the test instead of hanging it
         let mounted = Arc::new(Mutex::new(Vec::<Device>::new()));
         let runs = Arc::new(AtomicUsize::new(0));
-        let (m, r) = (mounted.clone(), runs.clone());
+        let finished = Arc::new(AtomicUsize::new(0));
+        let gate = Arc::new(std::sync::atomic::AtomicBool::new(false));
+        let (m, r, f, g) = (mounted.clone(), runs.clone(), finished.clone(), gate.clone());
         let watch = DeviceWatch::new(
             move || {
                 r.fetch_add(1, Ordering::SeqCst);
-                std::thread::sleep(SLOW);
+                let t = std::time::Instant::now();
+                while !g.load(Ordering::SeqCst) && t.elapsed() < lightcraft_testkit::scaled(Duration::from_secs(5)).max(Duration::from_secs(5)) {
+                    std::thread::sleep(Duration::from_millis(1));
+                }
+                f.fetch_add(1, Ordering::SeqCst);
                 m.lock().unwrap().clone()
             },
             Duration::from_millis(20),
@@ -280,21 +286,23 @@ mod tests {
             c.fetch_add(1, Ordering::SeqCst);
         });
         // cold: served at once (empty) while one scan runs in the background
-        let t = std::time::Instant::now();
         for _ in 0..50 {
             assert!(watch.get().is_empty());
         }
-        assert!(t.elapsed() < SLOW / 2, "get() waited for the scan: {:?}", t.elapsed());
+        // the scan is still held: a get() that waited for it could not have returned yet
+        assert_eq!(finished.load(Ordering::SeqCst), 0, "get() waited for the scan");
         assert!(watch.refreshing());
+        gate.store(true, Ordering::SeqCst);
         assert!(until(|| !watch.refreshing()));
         assert_eq!(runs.load(Ordering::SeqCst), 1, "requests while scanning are coalesced");
         assert_eq!(changes.load(Ordering::SeqCst), 0, "nothing changed");
         // a card is plugged in: the next stale request starts a rescan, still without waiting
+        gate.store(false, Ordering::SeqCst);
         mounted.lock().unwrap().push(card("EOS_DIGITAL"));
         std::thread::sleep(Duration::from_millis(40));
-        let t = std::time::Instant::now();
         assert!(watch.get().is_empty(), "the old list is served while rescanning");
-        assert!(t.elapsed() < SLOW / 2, "warm get() waited: {:?}", t.elapsed());
+        assert_eq!(finished.load(Ordering::SeqCst), 1, "warm get() waited for the rescan");
+        gate.store(true, Ordering::SeqCst);
         assert!(until(|| changes.load(Ordering::SeqCst) == 1), "a changed scan notifies");
         assert_eq!(watch.get(), [card("EOS_DIGITAL")]);
         // explicit requests scan synchronously
diff --git a/crates/engine/src/tests/segment.rs b/crates/engine/src/tests/segment.rs
--- a/crates/engine/src/tests/segment.rs
+++ b/crates/engine/src/tests/segment.rs
@@ -92,7 +92,7 @@ fn a_failing_download_ends_with_an_error_and_never_blocks() {
                 line.clear();
             }
             if let Some(released) = first.take() {
-                let _ = released.recv_timeout(Duration::from_secs(10));
+                let _ = released.recv_timeout(lightcraft_testkit::scaled(Duration::from_secs(30)).max(Duration::from_secs(30)));
             }
             let _ = c.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 13\r\nConnection: close\r\n\r\n<h1>404</h1>\n");
         }
@@ -103,22 +103,23 @@ fn a_failing_download_ends_with_an_error_and_never_blocks() {
     let mut s = Session::with_demo();
     s.segmenter.dir = Some(dir.clone());
     s.segmenter.mirrors_file = Some(mirrors.clone());
-    let t = Instant::now();
     let r = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap();
     assert_eq!(r["started"], true);
-    assert!(t.elapsed() < Duration::from_secs(1), "starting a download returns at once");
+    // the mirror holds its answer until `release`: still running means the call did not wait for it
+    assert!(s.segmenter.download_status().running, "starting a download returns at once");
     // a second request while it runs starts nothing
     let again = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap();
     assert!(again["started"] == false || again["downloading"] == false);
+    // AI mask requests meanwhile: an error while the mirror still holds the download, never a wait for it
+    let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string();
+    assert!(e.contains(NOT_INSTALLED), "{e}");
+    assert!(s.segmenter.download_status().running, "the AI mask request did not wait for the download");
     let _ = release.send(());
     let t = Instant::now();
     while s.segmenter.download_status().running {
-        assert!(t.elapsed() < Duration::from_secs(30), "the download never ended");
-        // AI mask requests meanwhile: an error at once, never a wait
-        let q = Instant::now();
+        assert!(t.elapsed() < lightcraft_testkit::scaled(Duration::from_secs(30)), "the download never ended");
         let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string();
         assert!(e.contains(NOT_INSTALLED), "{e}");
-        assert!(q.elapsed() < Duration::from_millis(500));
         std::thread::sleep(Duration::from_millis(20));
     }
     let st = s.segmenter.download_status();
diff --git a/crates/fetch/Cargo.toml b/crates/fetch/Cargo.toml
--- a/crates/fetch/Cargo.toml
+++ b/crates/fetch/Cargo.toml
@@ -15,3 +15,7 @@ rustls = { version = "0.23", default-features = false, features = ["std", "tls12
 rustls-rustcrypto = "0.0.2-alpha"
 webpki-roots = "1"
 sha2 = { version = "0.10", default-features = false, features = ["std"] }
+
+[dev-dependencies]
+# deadlines stretched by LIGHTCRAFT_TIME_SCALE
+lightcraft-testkit = { workspace = true }
diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs
--- a/crates/fetch/src/http.rs
+++ b/crates/fetch/src/http.rs
@@ -606,33 +606,51 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result<Res
 mod tests_connect {
     use super::*;
 
+    /// `d` stretched by `LIGHTCRAFT_TIME_SCALE`, never shrunk: the bounds here sit on the client's
+    /// fixed [`TICK`] (how often a wait looks at Cancel), which a scale below 1 doesn't shorten.
+    fn stretched(d: Duration) -> Duration {
+        lightcraft_testkit::scaled(d).max(d)
+    }
+
     fn limits(cancel: &AtomicBool) -> Limits<'_> {
-        Limits { connect: Duration::from_millis(300), stall: Duration::from_secs(1), cancel }
+        Limits { connect: stretched(Duration::from_millis(300)), stall: stretched(Duration::from_secs(1)), cancel }
     }
 
     /// A name lookup that hangs is given up on at the connect timeout, and Cancel stops it at once (issue #1532).
     #[test]
     fn a_hung_name_lookup_times_out_and_can_be_cancelled() {
         let cancel = AtomicBool::new(false);
+        // (every time here is stretched by LIGHTCRAFT_TIME_SCALE, so the resolver stays far slower than the bounds)
         let slow = || {
-            std::thread::sleep(Duration::from_secs(10));
+            std::thread::sleep(stretched(Duration::from_secs(10)));
             Ok(Vec::new())
         };
         let t = Instant::now();
-        let r = lookup("slow.example", slow, Instant::now() + Duration::from_millis(300), &limits(&cancel));
+        let r = lookup("slow.example", slow, Instant::now() + stretched(Duration::from_millis(300)), &limits(&cancel));
         assert!(matches!(&r, Err(HttpError::Connect(m)) if m.contains("timed out")), "{r:?}");
-        assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed());
-        // Cancel while the lookup is waiting on the resolver, not before it starts
+        assert!(t.elapsed() < stretched(Duration::from_secs(2)), "{:?}", t.elapsed());
+        // Cancel while the lookup is waiting on the resolver (100 ms after it started), not before it starts
+        let (began, has_begun) = std::sync::mpsc::channel();
+        let signalling = move || {
+            let _ = began.send(());
+            std::thread::sleep(stretched(Duration::from_secs(10)));
+            Ok(Vec::new())
+        };
         let t = Instant::now();
         let r = std::thread::scope(|scope| {
-            scope.spawn(|| {
+            let cancel = &cancel;
+            scope.spawn(move || {
+                lightcraft_testkit::recv_within(&has_begun, Duration::from_secs(30), "the resolver starts");
                 std::thread::sleep(Duration::from_millis(100));
                 cancel.store(true, Ordering::Relaxed);
             });
-            lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel))
+            lookup("slow.example", signalling, Instant::now() + stretched(Duration::from_secs(30)), &limits(cancel))
         });
         assert!(matches!(r, Err(HttpError::Cancelled)), "{r:?}");
-        assert!(t.elapsed() >= Duration::from_millis(100) && t.elapsed() < Duration::from_secs(1), "{:?}", t.elapsed());
+        // at least the 100 ms before Cancel; then noticed within a TICK (the margin is for a slow machine)
+        let waited = t.elapsed();
+        let limit = Duration::from_millis(100) + stretched(Duration::from_millis(900));
+        assert!(waited >= Duration::from_millis(100) && waited < limit, "{waited:?} (limit {limit:?})");
         // and a lookup asked for after Cancel doesn't start
         assert!(matches!(lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel)), Err(HttpError::Cancelled)));
         // a lookup that answers is used
@@ -647,10 +665,11 @@ mod tests_connect {
         let cancel = AtomicBool::new(false);
         // non-routable addresses: each either hangs until the deadline or fails at once, never longer
         let addrs: Vec<SocketAddr> = (1..=5).map(|i| format!("10.255.255.{i}:81").parse().unwrap()).collect();
+        // five full timeouts would take 2 s; one shared one 400 ms
         let t = Instant::now();
-        let r = connect_any("blackhole", &addrs, Instant::now() + Duration::from_millis(400), &limits(&cancel));
+        let r = connect_any("blackhole", &addrs, Instant::now() + stretched(Duration::from_millis(400)), &limits(&cancel));
         assert!(r.is_err());
-        assert!(t.elapsed() < Duration::from_millis(1500), "{:?}", t.elapsed());
+        assert!(t.elapsed() < stretched(Duration::from_millis(1500)), "{:?}", t.elapsed());
     }
 }
 
diff --git a/crates/fetch/src/tests.rs b/crates/fetch/src/tests.rs
--- a/crates/fetch/src/tests.rs
+++ b/crates/fetch/src/tests.rs
@@ -99,7 +99,8 @@ fn handle(mut s: TcpStream, hits: &AtomicUsize) {
             // half the file, then nothing
             let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes());
             let _ = s.write_all(&body[..body.len() / 2]);
-            std::thread::sleep(Duration::from_secs(5));
+            // (stretched with the client's timeouts, so it stays well past the stall timeout)
+            std::thread::sleep(stretched(Duration::from_secs(5)));
         }
         "slow" => {
             let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes());
@@ -136,8 +137,16 @@ fn tmp(name: &str) -> PathBuf {
     d
 }
 
+/// `d` stretched by `LIGHTCRAFT_TIME_SCALE`, never shrunk: the timeouts and bounds here sit on the
+/// test server's fixed pace (20 ms per kilobyte) and the client's fixed 250 ms poll.
+fn stretched(d: Duration) -> Duration {
+    lightcraft_testkit::scaled(d).max(d)
+}
+
+/// Short timeouts, stretched by `LIGHTCRAFT_TIME_SCALE` (a loaded machine can leave a working
+/// connection quiet for longer).
 fn quick() -> Options {
-    Options { connect_timeout: Duration::from_secs(2), stall_timeout: Duration::from_millis(600), attempts: 2, token_env: None }
+    Options { connect_timeout: stretched(Duration::from_secs(2)), stall_timeout: stretched(Duration::from_millis(600)), attempts: 2, token_env: None }
 }
 
 fn run(files: &[FileSpec<'_>], mirrors: &[String], dir: &Path, cancel: &AtomicBool) -> (Result<(), DownloadError>, Vec<Progress>) {
@@ -283,7 +292,8 @@ fn a_stalled_server_times_out_and_the_next_mirror_finishes() {
     let t = Instant::now();
     let (r, _) = run(&[pinned()], &[format!("{}/stall", srv.base), format!("{}/good", srv.base)], &dir, &AtomicBool::new(false));
     r.unwrap();
-    assert!(t.elapsed() < Duration::from_secs(4), "{:?}", t.elapsed());
+    // two stalled tries (600 ms each) and the good mirror, not two waits for the server to hang up (5 s each)
+    assert!(t.elapsed() < stretched(Duration::from_secs(4)), "{:?}", t.elapsed());
     assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
     let _ = std::fs::remove_dir_all(&dir);
 }
@@ -294,14 +304,18 @@ fn cancel_stops_quickly_and_keeps_the_part_for_later() {
     let dir = tmp("cancel");
     let cancel = Arc::new(AtomicBool::new(false));
     let c = cancel.clone();
+    let part = part_path(&dir, "model.bin");
+    // cancel once part of the file is written (the slow server takes 6 s for the whole of it)
     std::thread::spawn(move || {
-        std::thread::sleep(Duration::from_millis(400));
+        lightcraft_testkit::wait_until(Duration::from_secs(30), "the first bytes of the part", || {
+            std::fs::metadata(&part).is_ok_and(|m| m.len() > 0)
+        });
         c.store(true, Ordering::SeqCst);
     });
     let t = Instant::now();
     let (r, _) = run(&[pinned()], &[format!("{}/slow", srv.base)], &dir, &cancel);
     assert_eq!(r, Err(DownloadError::Cancelled));
-    assert!(t.elapsed() < Duration::from_secs(3), "{:?}", t.elapsed());
+    assert!(t.elapsed() < stretched(Duration::from_secs(3)), "{:?}", t.elapsed());
     assert!(!dir.join("model.bin").exists());
     let kept = std::fs::metadata(part_path(&dir, "model.bin")).map(|m| m.len()).unwrap_or(0);
     assert!(kept > 0 && kept < 300_000, "{kept}");
diff --git a/crates/gpu/tests/shutdown.rs b/crates/gpu/tests/shutdown.rs
--- a/crates/gpu/tests/shutdown.rs
+++ b/crates/gpu/tests/shutdown.rs
@@ -40,9 +40,9 @@ fn nothing_renders_on_the_gpu_once_the_process_is_ending() {
         })
     };
     if has_gpu {
-        while rendered.load(Ordering::SeqCst) == 0 && !refused.load(Ordering::SeqCst) {
-            std::thread::yield_now();
-        }
+        lightcraft_testkit::wait_until(Duration::from_secs(120), "the worker's first GPU render (or the GPU refusing it)", || {
+            rendered.load(Ordering::SeqCst) > 0 || refused.load(Ordering::SeqCst)
+        });
         assert!(rendered.load(Ordering::SeqCst) > 0, "the GPU renders before the shutdown ({:?})", lightcraft_gpu::last_fallback());
     }
 
diff --git a/crates/preview/Cargo.toml b/crates/preview/Cargo.toml
--- a/crates/preview/Cargo.toml
+++ b/crates/preview/Cargo.toml
@@ -15,3 +15,6 @@ jpeg-encoder = { workspace = true }
 zune-jpeg = { workspace = true }
 zune-core = { workspace = true }
 log = { workspace = true }
+
+[dev-dependencies]
+lightcraft-testkit = { workspace = true }
diff --git a/crates/preview/src/pool.rs b/crates/preview/src/pool.rs
--- a/crates/preview/src/pool.rs
+++ b/crates/preview/src/pool.rs
@@ -42,6 +42,10 @@ struct Shared<S, R> {
     live: Mutex<Vec<std::thread::ThreadId>>,
     #[cfg(not(target_arch = "wasm32"))]
     ended: Condvar,
+    /// Tests: how many times [`stop`] waited for a running job (a drop after a shutdown, or by
+    /// the pool's own job, must not wait at all).
+    #[cfg(all(test, not(target_arch = "wasm32")))]
+    stop_waits: Arc<std::sync::atomic::AtomicUsize>,
 }
 
 /// How long dropping a pool waits for the jobs still running (see [`JobPool::shutdown`]).
@@ -87,6 +91,8 @@ impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
                 live: Mutex::new(Vec::new()),
                 #[cfg(not(target_arch = "wasm32"))]
                 ended: Condvar::new(),
+                #[cfg(all(test, not(target_arch = "wasm32")))]
+                stop_waits: Arc::default(),
             }),
             tx,
             rx,
@@ -166,6 +172,12 @@ impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
         }
     }
 
+    /// Tests: the count of [`stop`]'s waits for a running job (it outlives the pool).
+    #[cfg(all(test, not(target_arch = "wasm32")))]
+    pub(crate) fn stop_waits(&self) -> Arc<std::sync::atomic::AtomicUsize> {
+        self.shared.stop_waits.clone()
+    }
+
     /// Worker threads that have not ended (0 before the first job, and on wasm).
     pub fn live_workers(&self) -> usize {
         #[cfg(not(target_arch = "wasm32"))]
@@ -329,6 +341,8 @@ fn stop<S, R>(shared: &Shared<S, R>, workers: &mut Vec<std::thread::JoinHandle<(
     let mut live = shared.live.lock().unwrap_or_else(|e| e.into_inner());
     while live.iter().any(|id| *id != me) {
         let Some(left) = timeout.checked_sub(t0.elapsed()).filter(|d| !d.is_zero()) else { break };
+        #[cfg(test)]
+        shared.stop_waits.fetch_add(1, Ordering::SeqCst);
         live = shared.ended.wait_timeout(live, left).unwrap_or_else(|e| e.into_inner()).0;
     }
     let running: Vec<std::thread::ThreadId> = live.iter().copied().filter(|id| *id != me).collect();
diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs
--- a/crates/preview/src/tests.rs
+++ b/crates/preview/src/tests.rs
@@ -4,6 +4,12 @@ use lightcraft_raster::Rgba8;
 
 use super::*;
 
+/// `d` stretched by `LIGHTCRAFT_TIME_SCALE`, never shrunk: for a job held past the pool's fixed
+/// 2 s drop wait, and for bounds above a fixed lower one.
+fn stretched(d: std::time::Duration) -> std::time::Duration {
+    lightcraft_testkit::scaled(d).max(d)
+}
+
 #[test]
 fn cleared_generation_cannot_read_or_repopulate_memory_or_disk() {
     let dir = std::env::temp_dir().join(format!("lc-thumbnail-generation-{}-{}", std::process::id(), next_tick()));
@@ -182,17 +188,14 @@ fn pool_priorities_dedupe_and_drop() {
         1000,
         Box::new(move || {
             let (m, cv) = &*g;
-            let mut open = m.lock().unwrap();
-            while !*open {
-                open = cv.wait(open).unwrap();
-            }
+            let limit = lightcraft_testkit::scaled(std::time::Duration::from_secs(60));
+            let (_open, waited) = cv.wait_timeout_while(m.lock().unwrap(), limit, |open| !*open).unwrap();
+            assert!(!waited.timed_out(), "the test never opened the gate");
             999
         }),
     );
     // wait until the worker took the gate job
-    while p.queued() > 0 {
-        std::thread::yield_now();
-    }
+    lightcraft_testkit::wait_until(std::time::Duration::from_secs(30), "the worker takes the gate job", || p.queued() == 0);
     p.submit(1, 1, 5, Box::new(|| 1)); // off-screen thumb
     p.submit(2, 1, 10, Box::new(|| 2)); // visible thumb
     p.submit(3, 1, 100, Box::new(|| 3)); // loupe
@@ -207,13 +210,10 @@ fn pool_priorities_dedupe_and_drop() {
         cv.notify_all();
     }
     let mut order = Vec::new();
-    while order.len() < 4 {
-        if let Some(d) = p.try_recv() {
-            order.push((d.slot, d.key, d.result));
-        } else {
-            std::thread::yield_now();
-        }
-    }
+    lightcraft_testkit::wait_until(std::time::Duration::from_secs(30), "four jobs done", || {
+        order.extend(std::iter::from_fn(|| p.try_recv()).map(|d| (d.slot, d.key, d.result)));
+        order.len() >= 4
+    });
     assert_eq!(order, vec![(999, 0, 999), (3, 1, 3), (2, 2, 22), (1, 1, 1)]);
 }
 
@@ -240,10 +240,10 @@ fn pool_with_a_running_job() -> (JobPool<u8, ()>, std::sync::mpsc::Sender<()>) {
         1,
         Box::new(move || {
             let _ = started.send(());
-            let _ = released.recv_timeout(std::time::Duration::from_secs(60));
+            let _ = released.recv_timeout(stretched(std::time::Duration::from_secs(60)));
         }),
     );
-    has_started.recv().unwrap();
+    lightcraft_testkit::recv_within(&has_started, std::time::Duration::from_secs(30), "the job starts");
     (p, release)
 }
 
@@ -265,7 +265,7 @@ fn pool_drop_waits_for_running_job() {
             f.store(true, Ordering::SeqCst);
         }),
     );
-    has_started.recv().unwrap();
+    lightcraft_testkit::recv_within(&has_started, std::time::Duration::from_secs(30), "the job starts");
     let q = queued_ran.clone();
     p.submit(2, 0, 1, Box::new(move || q.store(true, Ordering::SeqCst)));
     drop(p);
@@ -280,20 +280,22 @@ fn pool_shutdown_reports_workers_and_drop_does_not_wait_again() {
     let t0 = std::time::Instant::now();
     assert!(!p.shutdown(std::time::Duration::from_millis(100)), "the job is still running");
     let waited = t0.elapsed();
-    assert!(waited >= std::time::Duration::from_millis(100) && waited < std::time::Duration::from_secs(30), "{waited:?}");
+    assert!(waited >= std::time::Duration::from_millis(100) && waited < stretched(std::time::Duration::from_secs(30)), "{waited:?}");
     assert_eq!(p.live_workers(), 1);
     // stopped: nothing new is queued, and the job that ends is noticed
     p.submit(2, 0, 1, Box::new(|| ()));
     assert_eq!(p.queued(), 0);
     drop(release);
-    assert!(p.shutdown(std::time::Duration::from_secs(30)));
+    assert!(p.shutdown(lightcraft_testkit::scaled(std::time::Duration::from_secs(30))));
     assert_eq!(p.live_workers(), 0);
 
     let (mut p, _release) = pool_with_a_running_job();
     assert!(!p.shutdown(std::time::Duration::ZERO));
-    let t0 = std::time::Instant::now();
+    let waits = p.stop_waits();
+    let before = waits.load(std::sync::atomic::Ordering::SeqCst);
     drop(p);
-    assert!(t0.elapsed() < std::time::Duration::from_secs(1), "dropped after a shutdown: no second wait");
+    // (the job runs until `_release` goes: a second wait would have had something to wait for)
+    assert_eq!(waits.load(std::sync::atomic::Ordering::SeqCst), before, "dropped after a shutdown: no second wait");
 }
 
 #[test]
@@ -303,7 +305,7 @@ fn pool_drop_is_bounded_when_a_job_never_ends() {
     let t0 = std::time::Instant::now();
     drop(p);
     let waited = t0.elapsed();
-    assert!(waited >= std::time::Duration::from_secs(1) && waited < std::time::Duration::from_secs(30), "{waited:?}");
+    assert!(waited >= std::time::Duration::from_secs(1) && waited < stretched(std::time::Duration::from_secs(30)), "{waited:?}");
 }
 
 #[test]
@@ -311,15 +313,14 @@ fn pool_stops_after_a_job_panicked_and_when_dropped_by_its_own_job() {
     // a worker that unwound is not waited for
     let mut p: JobPool<u8, ()> = JobPool::new(1);
     p.submit(1, 0, 1, Box::new(|| panic!("a job panics (expected in this test)")));
-    while p.live_workers() > 0 {
-        std::thread::yield_now();
-    }
-    assert!(p.shutdown(std::time::Duration::from_secs(30)));
+    lightcraft_testkit::wait_until(std::time::Duration::from_secs(30), "the panicked worker ends", || p.live_workers() == 0);
+    assert!(p.shutdown(lightcraft_testkit::scaled(std::time::Duration::from_secs(30))));
 
     // the last owner of a pool is one of its jobs: the worker must not wait for (or join) itself
     let slot: Arc<std::sync::Mutex<Option<JobPool<u8, ()>>>> = Arc::new(std::sync::Mutex::new(None));
     let (dropped, has_dropped) = std::sync::mpsc::channel();
     let mut p: JobPool<u8, ()> = JobPool::new(1);
+    let waits = p.stop_waits();
     let s = slot.clone();
     p.submit(
         1,
@@ -332,14 +333,14 @@ fn pool_stops_after_a_job_panicked_and_when_dropped_by_its_own_job() {
                 }
                 std::thread::yield_now();
             };
-            let t0 = std::time::Instant::now();
             drop(own);
-            let _ = dropped.send(t0.elapsed());
+            let _ = dropped.send(());
         }),
     );
     *slot.lock().unwrap() = Some(p);
-    let waited = has_dropped.recv_timeout(std::time::Duration::from_secs(30)).unwrap();
-    assert!(waited < std::time::Duration::from_secs(1), "{waited:?}");
+    lightcraft_testkit::recv_within(&has_dropped, std::time::Duration::from_secs(30), "the job drops its own pool");
+    // the dropping job is the only one running: any wait would be for itself
+    assert_eq!(waits.load(std::sync::atomic::Ordering::SeqCst), 0, "the job's drop waited for itself");
 }
 
 #[test]
@@ -688,16 +689,16 @@ fn pool_wait_keeps_the_finished_job_for_try_recv() {
     let mut p: JobPool<u8, u8> = JobPool::new(1);
     assert!(!p.wait(std::time::Duration::from_millis(10)), "nothing runs");
     p.submit(1, 0, 1, Box::new(|| 7));
-    assert!(p.wait(std::time::Duration::from_secs(10)));
+    assert!(p.wait(lightcraft_testkit::scaled(std::time::Duration::from_secs(10))));
     assert!(p.wait(std::time::Duration::ZERO), "kept until taken");
     assert_eq!(p.try_recv().map(|d| d.result), Some(7));
     assert!(p.try_recv().is_none());
 }
 
-/// Wait up to `secs` for `f` to hold.
+/// Wait up to `secs` (stretched by `LIGHTCRAFT_TIME_SCALE`) for `f` to hold.
 fn eventually(secs: u64, mut f: impl FnMut() -> bool) -> bool {
     let t0 = std::time::Instant::now();
-    while t0.elapsed() < std::time::Duration::from_secs(secs) {
+    while t0.elapsed() < lightcraft_testkit::scaled(std::time::Duration::from_secs(secs)) {
         if f() {
             return true;
         }
@@ -725,14 +726,14 @@ fn a_slot_runs_one_job_at_a_time() {
             most.fetch_max(now, Ordering::SeqCst);
             let _ = started.send(n);
             if let Some(w) = wait {
-                let _ = w.recv_timeout(std::time::Duration::from_secs(30));
+                let _ = w.recv_timeout(stretched(std::time::Duration::from_secs(30)));
             }
             running.fetch_sub(1, Ordering::SeqCst);
             n
         })
     };
     p.submit(1, 1, 9, job(1, Some(released)));
-    assert_eq!(has_started.recv().unwrap(), 1);
+    assert_eq!(lightcraft_testkit::recv_within(&has_started, std::time::Duration::from_secs(30), "slot 1's first job starts"), 1);
     // two newer requests for the busy slot: the second replaces the first, neither starts
     p.submit(1, 2, 9, job(2, None));
     p.submit(1, 3, 9, job(3, None));
@@ -783,7 +784,7 @@ fn deferred_writes_are_bounded_and_drain_to_disk() {
         assert!(cache.queued_bytes() <= MAX_QUEUED_BYTES, "{} bytes queued", cache.queued_bytes());
     }
     let t = std::time::Instant::now();
-    while cache.queued_bytes() > 0 && t.elapsed() < std::time::Duration::from_secs(120) {
+    while cache.queued_bytes() > 0 && t.elapsed() < lightcraft_testkit::scaled(std::time::Duration::from_secs(120)) {
         std::thread::sleep(std::time::Duration::from_millis(20));
     }
     assert_eq!(cache.queued_bytes(), 0, "the queue drains");
diff --git a/crates/raw/tests/it/robust.rs b/crates/raw/tests/it/robust.rs
--- a/crates/raw/tests/it/robust.rs
+++ b/crates/raw/tests/it/robust.rs
@@ -104,7 +104,10 @@ fn hostile_dimensions_are_rejected_quickly() {
     let bytes = TiffWriter::default().write(&[ifd]).unwrap();
     let t0 = std::time::Instant::now();
     assert!(lightcraft_raw::decode(&bytes).is_err());
-    assert!(t0.elapsed().as_secs_f64() < 2.0);
+    // a regression guard, not a speed measurement: rejecting it takes milliseconds, reading the 7 GB
+    // frame it claims would take far longer (stretched by LIGHTCRAFT_TIME_SCALE for a loaded machine)
+    let limit = lightcraft_testkit::scaled(std::time::Duration::from_secs(5)).max(std::time::Duration::from_secs(5));
+    assert!(t0.elapsed() < limit, "took {:?} (limit {limit:?})", t0.elapsed());
 }
 
 /// A Bayer DNG whose `BlackLevel` is a FLOAT holding NaN (or infinity), and a float CFA DNG with
diff --git a/crates/testkit/src/deadline.rs b/crates/testkit/src/deadline.rs
new file mode 100644
--- /dev/null
+++ b/crates/testkit/src/deadline.rs
@@ -0,0 +1,196 @@
+//! Deadlines for test waits. Stable libtest has no per-test timeout, so a wait that never ends
+//! hangs its test binary with no message (and keeps the app's workers alive). These helpers give
+//! every wait a limit and fail the test with what it was waiting for.
+//!
+//! Every limit is stretched by [`time_scale`] (`LIGHTCRAFT_TIME_SCALE`), so a slow or shared
+//! machine can give all of them more time at once.
+
+use std::sync::OnceLock;
+use std::sync::mpsc::{Receiver, RecvTimeoutError};
+use std::time::{Duration, Instant};
+
+/// The environment variable that stretches (or shrinks) every test time limit: a factor, `1` by
+/// default. `LIGHTCRAFT_TIME_SCALE=3` gives each wait three times as long.
+pub const TIME_SCALE_ENV: &str = "LIGHTCRAFT_TIME_SCALE";
+
+/// The smallest factor [`time_scale`] returns: shrinking limits further only makes tests flaky.
+pub const MIN_TIME_SCALE: f64 = 0.1;
+/// The largest factor [`time_scale`] returns: past it a limit is as good as none.
+pub const MAX_TIME_SCALE: f64 = 1000.0;
+
+/// The longest a poll of [`wait_until`] sleeps between two checks of its condition.
+const MAX_POLL: Duration = Duration::from_millis(50);
+
+/// The factor every test time limit is multiplied by: `LIGHTCRAFT_TIME_SCALE`, read once per
+/// process. Unset or empty means `1`. A number is clamped to [`MIN_TIME_SCALE`]..=[`MAX_TIME_SCALE`];
+/// anything else (not a number, NaN, infinite, zero or negative) is ignored with a note on stderr,
+/// and the factor stays `1`.
+pub fn time_scale() -> f64 {
+    static SCALE: OnceLock<f64> = OnceLock::new();
+    *SCALE.get_or_init(|| {
+        let value = std::env::var(TIME_SCALE_ENV).ok();
+        let (scale, note) = parse_time_scale(value.as_deref());
+        if let Some(note) = note {
+            eprintln!("{TIME_SCALE_ENV}: {note}");
+        }
+        scale
+    })
+}
+
+/// `d` stretched by [`time_scale`]. Saturates at [`Duration::MAX`] instead of overflowing.
+pub fn scaled(d: Duration) -> Duration {
+    scale_by(d, time_scale())
+}
+
+/// `d × scale`, saturating; a factor that isn't a positive number leaves `d` as it is.
+fn scale_by(d: Duration, scale: f64) -> Duration {
+    if !scale.is_finite() || scale <= 0.0 {
+        return d;
+    }
+    Duration::try_from_secs_f64(d.as_secs_f64() * scale).unwrap_or(Duration::MAX)
+}
+
+/// The factor `LIGHTCRAFT_TIME_SCALE=value` means, and a note when the value was ignored or
+/// clamped.
+fn parse_time_scale(value: Option<&str>) -> (f64, Option<String>) {
+    let Some(text) = value.map(str::trim).filter(|v| !v.is_empty()) else { return (1.0, None) };
+    match text.parse::<f64>() {
+        Ok(s) if s.is_finite() && s > 0.0 => {
+            let clamped = s.clamp(MIN_TIME_SCALE, MAX_TIME_SCALE);
+            let note = (clamped != s).then(|| format!("{text} is outside {MIN_TIME_SCALE}..={MAX_TIME_SCALE}; using {clamped}"));
+            (clamped, note)
+        }
+        _ => (1.0, Some(format!("{text:?} is not a positive number; using 1"))),
+    }
+}
+
+/// Wait until `cond` holds, checking it with a growing pause (1 ms up to 50 ms). Fails the test
+/// (panics) with `"{what}" not reached after {elapsed}` once [`scaled`]`(timeout)` has passed.
+/// `what` names the state waited for, as the failure should read: `"the scan's first row"`.
+///
+/// Also bounds waits on other threads (a gated worker job, an injected probe): the panic ends
+/// that thread's job instead of leaving it spinning after the test is gone.
+#[allow(clippy::panic)] // failing the test with what it waited for is the point
+#[track_caller]
+pub fn wait_until(timeout: Duration, what: &str, mut cond: impl FnMut() -> bool) {
+    let limit = scaled(timeout);
+    let start = Instant::now();
+    let mut pause = Duration::from_millis(1);
+    loop {
+        if cond() {
+            return;
+        }
+        let elapsed = start.elapsed();
+        if elapsed >= limit {
+            panic!("\"{what}\" not reached after {elapsed:.2?} (limit {limit:?}: {timeout:?} × {TIME_SCALE_ENV} {})", time_scale());
+        }
+        std::thread::sleep(pause.min(limit.saturating_sub(elapsed)));
+        pause = (pause * 2).min(MAX_POLL);
+    }
+}
+
+/// Receive the next message from `rx`, waiting at most [`scaled`]`(timeout)`. Fails the test
+/// (panics) with `"{what}" not received after {elapsed}` when it doesn't come, or when every
+/// sender is gone first.
+#[allow(clippy::panic)] // failing the test with what it waited for is the point
+#[track_caller]
+pub fn recv_within<T>(rx: &Receiver<T>, timeout: Duration, what: &str) -> T {
+    let limit = scaled(timeout);
+    let start = Instant::now();
+    match rx.recv_timeout(limit) {
+        Ok(v) => v,
+        Err(RecvTimeoutError::Timeout) => {
+            panic!("\"{what}\" not received after {:.2?} (limit {limit:?}: {timeout:?} × {TIME_SCALE_ENV} {})", start.elapsed(), time_scale())
+        }
+        Err(RecvTimeoutError::Disconnected) => panic!("\"{what}\" never sent: the sender hung up after {:.2?}", start.elapsed()),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+
+    #[test]
+    fn the_time_scale_reads_a_positive_factor_and_ignores_the_rest() {
+        assert_eq!(parse_time_scale(None), (1.0, None));
+        assert_eq!(parse_time_scale(Some("")), (1.0, None));
+        assert_eq!(parse_time_scale(Some("  ")), (1.0, None));
+        assert_eq!(parse_time_scale(Some("2.5")), (2.5, None));
+        assert_eq!(parse_time_scale(Some(" 3 ")), (3.0, None));
+        for bad in ["0", "-2", "NaN", "nan", "inf", "-inf", "fast", "1e400", "0x10"] {
+            let (scale, note) = parse_time_scale(Some(bad));
+            assert_eq!(scale, 1.0, "{bad}");
+            assert!(note.is_some_and(|n| n.contains("not a positive number")), "{bad}");
+        }
+        let (scale, note) = parse_time_scale(Some("1e9"));
+        assert_eq!(scale, MAX_TIME_SCALE);
+        assert!(note.is_some_and(|n| n.contains("outside")));
+        let (scale, note) = parse_time_scale(Some("0.0001"));
+        assert_eq!(scale, MIN_TIME_SCALE);
+        assert!(note.is_some());
+    }
+
+    #[test]
+    fn scaling_saturates_instead_of_overflowing() {
+        assert_eq!(scale_by(Duration::from_secs(10), 2.0), Duration::from_secs(20));
+        assert_eq!(scale_by(Duration::from_secs(10), 0.5), Duration::from_secs(5));
+        assert_eq!(scale_by(Duration::MAX, MAX_TIME_SCALE), Duration::MAX);
+        assert_eq!(scale_by(Duration::from_secs(10), f64::NAN), Duration::from_secs(10));
+        assert_eq!(scale_by(Duration::from_secs(10), -1.0), Duration::from_secs(10));
+        assert_eq!(scale_by(Duration::ZERO, 3.0), Duration::ZERO);
+    }
+
+    #[test]
+    fn wait_until_returns_once_the_condition_holds() {
+        let checks = AtomicUsize::new(0);
+        let start = Instant::now();
+        wait_until(Duration::from_secs(30), "the third check", || checks.fetch_add(1, Ordering::SeqCst) >= 2);
+        assert_eq!(checks.load(Ordering::SeqCst), 3);
+        assert!(start.elapsed() < Duration::from_secs(10), "it doesn't wait out the limit");
+    }
+
+    #[test]
+    fn wait_until_sees_a_change_made_by_another_thread() {
+        let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
+        let f = flag.clone();
+        let t = std::thread::spawn(move || {
+            std::thread::sleep(Duration::from_millis(30));
+            f.store(true, Ordering::SeqCst);
+        });
+        wait_until(Duration::from_secs(30), "the flag", || flag.load(Ordering::SeqCst));
+        t.join().unwrap();
+    }
+
+    #[test]
+    #[should_panic(expected = "\"the gate opens\" not reached after")]
+    fn wait_until_fails_the_test_with_what_it_waited_for() {
+        wait_until(Duration::from_millis(20), "the gate opens", || false);
+    }
+
+    #[test]
+    fn recv_within_returns_the_message() {
+        let (tx, rx) = std::sync::mpsc::channel();
+        let t = std::thread::spawn(move || {
+            std::thread::sleep(Duration::from_millis(20));
+            tx.send(7).unwrap();
+        });
+        assert_eq!(recv_within(&rx, Duration::from_secs(30), "the job's result"), 7);
+        t.join().unwrap();
+    }
+
+    #[test]
+    #[should_panic(expected = "\"the job starts\" not received after")]
+    fn recv_within_fails_the_test_when_nothing_comes() {
+        let (_tx, rx) = std::sync::mpsc::channel::<()>();
+        recv_within(&rx, Duration::from_millis(20), "the job starts");
+    }
+
+    #[test]
+    #[should_panic(expected = "\"the job starts\" never sent: the sender hung up")]
+    fn recv_within_fails_the_test_when_the_sender_is_gone() {
+        let (tx, rx) = std::sync::mpsc::channel::<()>();
+        drop(tx);
+        recv_within(&rx, Duration::from_secs(30), "the job starts");
+    }
+}
diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs
--- a/crates/testkit/src/lib.rs
+++ b/crates/testkit/src/lib.rs
@@ -20,6 +20,17 @@
 //! `cargo xtask ci` lists the skips by need and reason in its summary and in
 //! `target/ci/report.json`. A test that runs part of its checks (e.g. only the CPU half) records
 //! the part it could not run the same way.
+//!
+//! **Deadlines.** Stable libtest has no per-test timeout, so a wait with no limit hangs its binary
+//! with no message. Waits go through [`wait_until`] (a condition) and [`recv_within`] (a channel),
+//! which fail the test with what they waited for; a hand-made limit (a condvar's
+//! `wait_timeout_while`) uses [`scaled`]. Every limit is stretched by [`time_scale`]
+//! (`LIGHTCRAFT_TIME_SCALE`, default 1) on a slow or shared machine.
+//!
+//! **Watchdog.** [`watch`] registers the running test; a test still registered after
+//! `LIGHTCRAFT_TEST_WATCHDOG_MIN` minutes (default 30, × the time scale; `0` turns it off) makes
+//! the watchdog print the watched tests still running and end the binary (exit status 124). ui-egui's
+//! `Headless::new` calls it in that crate's unit tests. See [`watchdog`] for the defaults' reasons.
 #![forbid(unsafe_code)]
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 
@@ -27,6 +38,12 @@ use std::fmt::Write as _;
 use std::io::Write as _;
 use std::path::Path;
 
+mod deadline;
+pub mod watchdog;
+
+pub use deadline::{MAX_TIME_SCALE, MIN_TIME_SCALE, TIME_SCALE_ENV, recv_within, scaled, time_scale, wait_until};
+pub use watchdog::{DEFAULT_WATCHDOG_MINUTES, WATCHDOG_ENV, WATCHDOG_EXIT_CODE, Watch, watch, watched};
+
 /// The environment variable naming the file skips are appended to (JSON lines).
 pub const SKIP_LOG_ENV: &str = "LIGHTCRAFT_SKIP_LOG";
 
diff --git a/crates/testkit/src/watchdog.rs b/crates/testkit/src/watchdog.rs
new file mode 100644
--- /dev/null
+++ b/crates/testkit/src/watchdog.rs
@@ -0,0 +1,421 @@
+//! A watchdog for test binaries. Stable libtest has no per-test timeout: a test stuck in a wait
+//! keeps its binary (and the app's render workers) alive for good, and the run only looks busy.
+//! A test that calls [`watch`] (ui-egui's `Headless::new` does it for every headless test) is
+//! registered under its thread's name, which libtest sets to the test's path. One thread per
+//! process checks the registered tests; once one of them has run longer than the limit it prints
+//! every registered test still running, with how long each has run, and ends the process with
+//! [`WATCHDOG_EXIT_CODE`]. A
+//! stuck run then ends with a list of names instead of hanging.
+//!
+//! **The limit** is `LIGHTCRAFT_TEST_WATCHDOG_MIN` minutes per test (fractions allowed),
+//! [`DEFAULT_WATCHDOG_MINUTES`] when unset, stretched by [`crate::time_scale`]. `0` or `off`
+//! turns the watchdog off (for a test paused in a debugger). Anything else that isn't a positive
+//! number is ignored with a note on stderr, and the default applies.
+//!
+//! **Why 30 minutes by default, and on.** Off unless set would leave exactly the runs that need it
+//! unguarded: an ad-hoc `cargo test` that hangs. The limit is per test, not per binary, so a long
+//! binary (the ui-egui unit tests take minutes at 4 threads, far more on a cold debug build on a
+//! loaded machine) never trips it; the slowest single test takes about a minute under load, so 30
+//! minutes is far past anything but a test that will never finish, and `LIGHTCRAFT_TIME_SCALE`
+//! stretches it on a machine that is slower still.
+//!
+//! **How it ends the process.** A helper thread prints the report and calls `std::process::exit`
+//! with [`WATCHDOG_EXIT_CODE`], so a stuck run normally leaves no crash report or core dump
+//! behind. The process is stuck by definition, so that can get stuck too: a stuck test may hold
+//! stderr's lock, an exit handler (a GPU driver's) may hang. The watchdog thread itself owns the
+//! hard deadline and does no I/O: it aborts the process if it still runs [`ABORT_AFTER_EXIT`]
+//! later, or at once if the helper thread can't be started.
+//!
+//! For testkit's own tests only, `LIGHTCRAFT_TEST_WATCHDOG_GRACE_MS` replaces that grace period
+//! (whole milliseconds, held to 100 ms..=10 min; anything else keeps [`ABORT_AFTER_EXIT`]), so
+//! the test of the abort doesn't wait ten seconds.
+//!
+//! The report goes straight to the process's stderr: libtest captures the output of a test's
+//! threads (the watchdog's thread starts from the first watched test's), and a captured report
+//! would be lost with the process.
+
+use std::fmt::Write as _;
+use std::io::Write as _;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex, Once, OnceLock, PoisonError, Weak};
+use std::time::{Duration, Instant};
+
+/// The environment variable holding the per-test limit in minutes (`0` or `off`: no watchdog).
+pub const WATCHDOG_ENV: &str = "LIGHTCRAFT_TEST_WATCHDOG_MIN";
+
+/// The per-test limit in minutes when [`WATCHDOG_ENV`] is unset, before [`crate::time_scale`].
+pub const DEFAULT_WATCHDOG_MINUTES: f64 = 30.0;
+
+/// The exit status of a test binary the watchdog ended: 124, as `timeout(1)` uses.
+pub const WATCHDOG_EXIT_CODE: i32 = 124;
+
+/// How long printing the report and exiting may take before the watchdog aborts the process.
+pub const ABORT_AFTER_EXIT: Duration = Duration::from_secs(10);
+
+/// Replaces [`ABORT_AFTER_EXIT`], for testkit's own tests (milliseconds).
+const GRACE_ENV: &str = "LIGHTCRAFT_TEST_WATCHDOG_GRACE_MS";
+
+/// The grace period `LIGHTCRAFT_TEST_WATCHDOG_GRACE_MS=value` means: whole milliseconds held to
+/// 100 ms..=10 min; unset, empty or not a whole number keeps [`ABORT_AFTER_EXIT`].
+fn parse_grace(value: Option<&str>) -> Duration {
+    match value.map(str::trim).and_then(|v| v.parse::<u64>().ok()) {
+        Some(ms) => Duration::from_millis(ms.clamp(100, 600_000)),
+        None => ABORT_AFTER_EXIT,
+    }
+}
+
+/// One registered test.
+struct Entry {
+    id: u64,
+    test: String,
+    since: Instant,
+    /// Alive while the registering thread runs: libtest runs each test on a thread of its own, so
+    /// a registration whose guard was leaked (or kept in a static) is dropped with its thread
+    /// instead of tripping the watchdog later in the run.
+    thread: Weak<()>,
+}
+
+static WATCHED: Mutex<Vec<Entry>> = Mutex::new(Vec::new());
+static NEXT_ID: AtomicU64 = AtomicU64::new(0);
+
+thread_local! {
+    static THREAD_ALIVE: Arc<()> = Arc::new(());
+}
+
+/// A test's registration with the watchdog; dropping it (at the end of the test, also when the
+/// test panics) ends the registration.
+#[must_use = "the test is watched only while the guard lives: `let _watch = watch();`"]
+#[derive(Debug)]
+pub struct Watch {
+    id: u64,
+}
+
+impl Drop for Watch {
+    fn drop(&mut self) {
+        let mut watched = WATCHED.lock().unwrap_or_else(PoisonError::into_inner);
+        watched.retain(|e| e.id != self.id);
+    }
+}
+
+/// Register the current test (by its thread's name) with the watchdog until the returned guard
+/// is dropped, and start the watchdog thread on the first call. Hold the guard for the whole test:
+/// `let _watch = lightcraft_testkit::watch();`.
+pub fn watch() -> Watch {
+    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
+    let thread = std::thread::current();
+    let test = thread.name().unwrap_or("<unnamed thread>").to_owned();
+    let alive = THREAD_ALIVE.try_with(Arc::downgrade).unwrap_or_default();
+    WATCHED.lock().unwrap_or_else(PoisonError::into_inner).push(Entry { id, test, since: Instant::now(), thread: alive });
+    if let Some(limit) = limit() {
+        start(limit);
+    }
+    Watch { id }
+}
+
+/// The per-test limit ([`WATCHDOG_ENV`] × [`crate::time_scale`]), read once; `None` when off.
+fn limit() -> Option<Duration> {
+    static LIMIT: OnceLock<Option<Duration>> = OnceLock::new();
+    *LIMIT.get_or_init(|| {
+        let value = std::env::var(WATCHDOG_ENV).ok();
+        let (minutes, note) = parse_minutes(value.as_deref());
+        if let Some(note) = note {
+            eprintln!("{WATCHDOG_ENV}: {note}");
+        }
+        let minutes = minutes?;
+        // a limit too large for a Duration is no limit
+        Duration::try_from_secs_f64(minutes * 60.0).ok().map(crate::scaled)
+    })
+}
+
+/// The minutes `LIGHTCRAFT_TEST_WATCHDOG_MIN=value` means (`None`: off) and a note when the value
+/// was ignored.
+fn parse_minutes(value: Option<&str>) -> (Option<f64>, Option<String>) {
+    let Some(text) = value.map(str::trim).filter(|v| !v.is_empty()) else { return (Some(DEFAULT_WATCHDOG_MINUTES), None) };
+    if text.eq_ignore_ascii_case("off") {
+        return (None, None);
+    }
+    match text.parse::<f64>() {
+        Ok(0.0) => (None, None),
+        Ok(m) if m.is_finite() && m > 0.0 => (Some(m), None),
+        _ => (Some(DEFAULT_WATCHDOG_MINUTES), Some(format!("{text:?} is not a number of minutes (or 0 / off); using {DEFAULT_WATCHDOG_MINUTES}"))),
+    }
+}
+
+/// Start the watchdog thread, once per process.
+fn start(limit: Duration) {
+    static STARTED: Once = Once::new();
+    STARTED.call_once(|| {
+        // read here, not when the watchdog fires: a stuck process may hold the environment's lock
+        let grace = parse_grace(std::env::var(GRACE_ENV).ok().as_deref());
+        // check often enough that a short limit (tests of the watchdog) isn't overshot by much
+        let poll = (limit / 20).clamp(Duration::from_millis(10), Duration::from_secs(5));
+        let spawned = std::thread::Builder::new().name("lightcraft-testkit-watchdog".into()).spawn(move || {
+            loop {
+                std::thread::sleep(poll);
+                let report = overdue(&WATCHED.lock().unwrap_or_else(PoisonError::into_inner), Instant::now(), limit);
+                if let Some(report) = report {
+                    end_process(report, grace);
+                }
+            }
+        });
+        if let Err(e) = spawned {
+            eprintln!("{WATCHDOG_ENV}: can't start the test watchdog ({e}); hung tests won't be stopped");
+        }
+    });
+}
+
+/// End the process for a stuck test, on the watchdog thread. A helper thread prints `report` and
+/// exits with [`WATCHDOG_EXIT_CODE`]; this thread does no I/O (stderr's lock may be held by the
+/// stuck test) and aborts the process if it is still running `grace` ([`ABORT_AFTER_EXIT`]) later,
+/// or at once when the helper can't be started.
+fn end_process(report: String, grace: Duration) -> ! {
+    let reporter = std::thread::Builder::new().name("lightcraft-testkit-watchdog-exit".into()).spawn(move || {
+        // straight to stderr: `eprintln!` would go to the capture of the test that started the watchdog
+        let _ = writeln!(std::io::stderr(), "{report}");
+        std::process::exit(WATCHDOG_EXIT_CODE)
+    });
+    if reporter.is_ok() {
+        std::thread::sleep(grace);
+    }
+    std::process::abort()
+}
+
+/// When a live registered test has run for `limit` or longer: the report printed before the
+/// process ends, naming every live registered test, longest-running first.
+fn overdue(entries: &[Entry], now: Instant, limit: Duration) -> Option<String> {
+    let mut live: Vec<(&str, Duration)> =
+        entries.iter().filter(|e| e.thread.strong_count() > 0).map(|e| (e.test.as_str(), now.saturating_duration_since(e.since))).collect();
+    live.sort_by_key(|&(_, ran)| std::cmp::Reverse(ran));
+    let &(stuck, ran) = live.first().filter(|(_, ran)| *ran >= limit)?;
+    let mut report = format!(
+        "lightcraft-testkit watchdog: `{stuck}` has run for {}, past the limit of {} ({WATCHDOG_ENV} × {}); \
+         ending the test binary. Watched tests still running:",
+        minutes(ran),
+        minutes(limit),
+        crate::TIME_SCALE_ENV,
+    );
+    for (test, ran) in &live {
+        let _ = write!(report, "\n    {test} ({})", minutes(*ran));
+    }
+    Some(report)
+}
+
+/// `95.5 s` → `1m 35.5s`.
+fn minutes(d: Duration) -> String {
+    let secs = d.as_secs_f64();
+    let whole = (secs / 60.0).floor();
+    format!("{whole}m {:.1}s", secs - whole * 60.0)
+}
+
+/// The names of the watched tests still running, as the watchdog would list them (a test can
+/// check that it is watched).
+pub fn watched() -> Vec<String> {
+    let watched = WATCHED.lock().unwrap_or_else(PoisonError::into_inner);
+    watched.iter().filter(|e| e.thread.strong_count() > 0).map(|e| e.test.clone()).collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Set for the child process [`a_stuck_test_ends_its_binary_with_the_tests_still_running`] starts.
+    const CHILD_ENV: &str = "LIGHTCRAFT_TESTKIT_WATCHDOG_CHILD";
+
+    fn entry(test: &str, since: Instant, thread: &Arc<()>) -> Entry {
+        Entry { id: 0, test: test.into(), since, thread: Arc::downgrade(thread) }
+    }
+
+    #[test]
+    fn the_limit_reads_minutes_and_off() {
+        assert_eq!(parse_minutes(None), (Some(DEFAULT_WATCHDOG_MINUTES), None));
+        assert_eq!(parse_minutes(Some(" ")), (Some(DEFAULT_WATCHDOG_MINUTES), None));
+        assert_eq!(parse_minutes(Some("45")), (Some(45.0), None));
+        assert_eq!(parse_minutes(Some("0.5")), (Some(0.5), None));
+        assert_eq!(parse_minutes(Some("0")), (None, None));
+        assert_eq!(parse_minutes(Some("OFF")), (None, None));
+        for bad in ["-1", "NaN", "inf", "ten", "1e400"] {
+            let (minutes, note) = parse_minutes(Some(bad));
+            assert_eq!(minutes, Some(DEFAULT_WATCHDOG_MINUTES), "{bad}");
+            assert!(note.is_some(), "{bad}");
+        }
+    }
+
+    #[test]
+    fn a_test_past_the_limit_is_reported_with_everything_still_running() {
+        let base = Instant::now();
+        let (alive, gone) = (Arc::new(()), Arc::new(()));
+        let entries = vec![
+            entry("ui::quick", base + Duration::from_secs(50), &alive),
+            entry("ui::stuck", base, &alive),
+            entry("ui::leaked_by_a_finished_thread", base, &gone),
+        ];
+        drop(gone);
+        let limit = Duration::from_secs(60);
+        assert_eq!(overdue(&entries, base + Duration::from_secs(59), limit), None, "nothing is past the limit yet");
+        let report = overdue(&entries, base + Duration::from_secs(60), limit).unwrap();
+        assert!(report.contains("`ui::stuck` has run for 1m 0.0s, past the limit of 1m 0.0s"), "{report}");
+        let stuck = report.find("\n    ui::stuck (1m 0.0s)").unwrap();
+        let quick = report.find("\n    ui::quick (0m 10.0s)").unwrap();
+        assert!(stuck < quick, "longest first: {report}");
+        assert!(!report.contains("leaked"), "a finished thread's registration doesn't count: {report}");
+    }
+
+    #[test]
+    fn nothing_is_overdue_without_live_tests() {
+        let base = Instant::now();
+        assert_eq!(overdue(&[], base + Duration::from_secs(3600), Duration::from_secs(1)), None);
+        let gone = Arc::new(());
+        let entries = vec![entry("t", base, &gone)];
+        drop(gone);
+        assert_eq!(overdue(&entries, base + Duration::from_secs(3600), Duration::from_secs(1)), None);
+    }
+
+    #[test]
+    fn a_watch_registers_the_test_until_it_ends() {
+        let me = std::thread::current().name().unwrap_or("<unnamed thread>").to_owned();
+        let count = || watched().iter().filter(|t| **t == me).count();
+        let before = count();
+        let w = watch();
+        assert_eq!(count(), before + 1);
+        drop(w);
+        assert_eq!(count(), before);
+    }
+
+    #[test]
+    fn a_test_that_panics_or_leaks_its_watch_is_unregistered() {
+        let named = |name: &str| watched().iter().filter(|t| *t == name).count();
+        let panicked = std::thread::Builder::new()
+            .name("testkit::panicking_test".into())
+            .spawn(|| {
+                let _watch = watch();
+                panic!("the test fails");
+            })
+            .unwrap()
+            .join();
+        assert!(panicked.is_err());
+        assert_eq!(named("testkit::panicking_test"), 0, "unwinding drops the guard");
+        std::thread::Builder::new().name("testkit::leaking_test".into()).spawn(|| std::mem::forget(watch())).unwrap().join().unwrap();
+        assert_eq!(named("testkit::leaking_test"), 0, "the thread ended with the test");
+    }
+
+    /// Run as a child by [`a_stuck_test_ends_its_binary_with_the_tests_still_running`]: a test stuck
+    /// in a wait. Ends on its own after a minute, so a watchdog that never fires fails the parent
+    /// rather than hanging it.
+    #[test]
+    #[ignore = "a stuck test: run as a child process by a_stuck_test_ends_its_binary_with_the_tests_still_running"]
+    fn stuck_child() {
+        if std::env::var_os(CHILD_ENV).is_none() {
+            return;
+        }
+        let _watch = watch();
+        std::thread::sleep(Duration::from_secs(60));
+    }
+
+    /// Run as a child by [`a_stuck_test_holding_stderr_still_ends_its_binary`]: a test stuck while
+    /// it holds stderr's lock, so the report can't be printed.
+    #[test]
+    #[ignore = "a stuck test: run as a child process by a_stuck_test_holding_stderr_still_ends_its_binary"]
+    fn stuck_child_holding_stderr() {
+        if std::env::var_os(CHILD_ENV).is_none() {
+            return;
+        }
+        let _watch = watch();
+        let _stderr = std::io::stderr().lock();
+        std::thread::sleep(Duration::from_secs(60));
+    }
+
+    /// How a child test run by [`run_stuck_child`] ended.
+    struct Child {
+        /// `None`: still running after the deadline, and killed.
+        status: Option<std::process::ExitStatus>,
+        stderr: String,
+        ran: Duration,
+        /// Run under `prlimit --core=1`, so an abort leaves no core dump.
+        no_core: bool,
+    }
+
+    /// Run the ignored test `name` of this binary as a child under a watchdog of 0.3 s (and `env`),
+    /// killing it if it still runs after `deadline`.
+    fn run_stuck_child(name: &str, env: &[(&str, &str)], deadline: Duration) -> Child {
+        let start = Instant::now();
+        let exe = std::env::current_exe().unwrap();
+        let configure = |mut c: std::process::Command| {
+            // captured, as in a normal run: the report must get out anyway
+            c.args(["--ignored", "--exact", name, "--test-threads", "1"])
+                .env(CHILD_ENV, "1")
+                .env(WATCHDOG_ENV, "0.005")
+                .env(crate::TIME_SCALE_ENV, "1")
+                .envs(env.iter().copied())
+                .stdout(std::process::Stdio::null())
+                .stderr(std::process::Stdio::piped());
+            c
+        };
+        // Linux: an aborted child leaves no core dump. A limit of 1 byte (not 0) also stops a
+        // core_pattern pipe (systemd-coredump, WSL's crash capture); without prlimit, run it as is.
+        let limited = if cfg!(target_os = "linux") {
+            let mut c = std::process::Command::new("prlimit");
+            c.arg("--core=1:1").arg(&exe);
+            configure(c).spawn().ok()
+        } else {
+            None
+        };
+        let no_core = limited.is_some();
+        let mut child = match limited {
+            Some(c) => c,
+            None => configure(std::process::Command::new(&exe)).spawn().unwrap(),
+        };
+        let ended = loop {
+            if child.try_wait().unwrap().is_some() {
+                break true;
+            }
+            if start.elapsed() > deadline {
+                let _ = child.kill();
+                break false;
+            }
+            std::thread::sleep(Duration::from_millis(20));
+        };
+        let ran = start.elapsed();
+        let out = child.wait_with_output().unwrap();
+        Child { status: ended.then_some(out.status), stderr: String::from_utf8_lossy(&out.stderr).into_owned(), ran, no_core }
+    }
+
+    #[test]
+    fn the_grace_period_reads_whole_milliseconds_within_bounds() {
+        assert_eq!(parse_grace(None), ABORT_AFTER_EXIT);
+        assert_eq!(parse_grace(Some("")), ABORT_AFTER_EXIT);
+        assert_eq!(parse_grace(Some(" 500 ")), Duration::from_millis(500));
+        assert_eq!(parse_grace(Some("0")), Duration::from_millis(100));
+        assert_eq!(parse_grace(Some("99999999999999999")), Duration::from_secs(600));
+        for bad in ["-5", "1.5", "NaN", "soon", "18446744073709551616"] {
+            assert_eq!(parse_grace(Some(bad)), ABORT_AFTER_EXIT, "{bad}");
+        }
+    }
+
+    #[test]
+    fn a_stuck_test_ends_its_binary_with_the_tests_still_running() {
+        let c = run_stuck_child("watchdog::tests::stuck_child", &[], crate::scaled(Duration::from_secs(45)));
+        let (ran, stderr) = (c.ran, &c.stderr);
+        let status = c.status.unwrap_or_else(|| panic!("the watchdog didn't end the stuck test in {ran:?}: {stderr}"));
+        assert_eq!(status.code(), Some(WATCHDOG_EXIT_CODE), "the watchdog ends the run: {stderr}");
+        assert!(stderr.contains("lightcraft-testkit watchdog: `watchdog::tests::stuck_child` has run for"), "{stderr}");
+        assert!(stderr.contains("\n    watchdog::tests::stuck_child ("), "{stderr}");
+    }
+
+    /// The report can't be printed while the stuck test holds stderr's lock: the watchdog thread
+    /// aborts the process once the grace period has passed instead of waiting for it.
+    #[test]
+    fn a_stuck_test_holding_stderr_still_ends_its_binary() {
+        let grace = Duration::from_millis(500);
+        let c = run_stuck_child("watchdog::tests::stuck_child_holding_stderr", &[(GRACE_ENV, "500")], crate::scaled(Duration::from_secs(45)));
+        let (ran, stderr) = (c.ran, &c.stderr);
+        let status = c.status.unwrap_or_else(|| panic!("the stuck test still ran after {ran:?}: {stderr}"));
+        assert!(!status.success(), "{status:?}");
+        assert_ne!(status.code(), Some(WATCHDOG_EXIT_CODE), "it can't have printed and exited: {stderr}");
+        assert!(ran >= grace, "aborted after the grace period, not before: {ran:?}");
+        #[cfg(unix)]
+        if c.no_core {
+            use std::os::unix::process::ExitStatusExt as _;
+            assert!(!status.core_dumped(), "the abort left a core dump: {status:?}");
+        }
+    }
+}
diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs
--- a/crates/ui-egui/src/headless.rs
+++ b/crates/ui-egui/src/headless.rs
@@ -269,6 +269,10 @@ pub struct Headless {
     /// create a prompt after the host input was already filtered for the previous frame.
     #[cfg(test)]
     after_raw_input_hook: Option<Box<dyn FnMut(&mut LightcraftApp)>>,
+    /// The test driving this session is watched while it lives: a test stuck past the watchdog's
+    /// limit ends the binary with the names of the tests still running (`lightcraft_testkit::watch`).
+    #[cfg(test)]
+    _watch: lightcraft_testkit::Watch,
 }
 
 /// A headless run (`lightcraft-cli snapshot`, tests) is a one-shot process: however it ends, it
@@ -311,6 +315,8 @@ impl Headless {
             last_cursor: egui::CursorIcon::Default,
             #[cfg(test)]
             after_raw_input_hook: None,
+            #[cfg(test)]
+            _watch: lightcraft_testkit::watch(),
         }
     }
 
diff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs
--- a/crates/ui-egui/src/headless/tests.rs
+++ b/crates/ui-egui/src/headless/tests.rs
@@ -96,6 +96,20 @@ fn a_rejected_model_file_says_why_in_settings() {
 use super::*;
 use std::sync::Arc;
 
+/// Every headless test is watched (issue #3062): one stuck past the watchdog's limit ends the
+/// test binary with its name instead of hanging it.
+#[test]
+fn a_headless_session_watches_its_test() {
+    let me = std::thread::current().name().unwrap_or_default().to_owned();
+    let mine = || lightcraft_testkit::watched().iter().filter(|t| **t == me).count();
+    assert_eq!(mine(), 0);
+    let app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services { png: None, ..Default::default() });
+    let h = Headless::new(app, [400.0, 300.0], 1.0);
+    assert_eq!(mine(), 1);
+    drop(h);
+    assert_eq!(mine(), 0, "the watch ends with the session");
+}
+
 /// The pretend host has a clipboard, as a desktop does: text the UI copies lands in it, and a
 /// cut, copy or paste request (from a context menu) comes back as that event on the next frame.
 #[test]
@@ -3265,9 +3279,7 @@ fn browsing_the_same_folder_again_does_not_restart() {
     let (g, n) = (gate.clone(), started.clone());
     h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| {
         n.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
-        while !g.load(std::sync::atomic::Ordering::Relaxed) {
-            std::thread::sleep(Duration::from_millis(5));
-        }
+        lightcraft_testkit::wait_until(Duration::from_secs(60), "the test opens the probe's gate", || g.load(std::sync::atomic::Ordering::Relaxed));
         Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() })
     }));
     let browse = |h: &mut Headless| h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t);
@@ -3384,9 +3396,7 @@ fn folder_scan_cancel_and_busy() {
     let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
     let g = gate.clone();
     h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| {
-        while !g.load(std::sync::atomic::Ordering::Relaxed) {
-            std::thread::sleep(Duration::from_millis(5));
-        }
+        lightcraft_testkit::wait_until(Duration::from_secs(60), "the test opens the probe's gate", || g.load(std::sync::atomic::Ordering::Relaxed));
         Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() })
     }));
     let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t);
diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs
--- a/crates/ui-egui/src/panels/activity.rs
+++ b/crates/ui-egui/src/panels/activity.rs
@@ -23,6 +23,11 @@ pub const WIDTH: f32 = 280.0;
 const MARGIN: i8 = 8;
 const CROSS: f32 = 16.0;
 
+/// Whether `task` has a row yet: from [`SHOW_AFTER_MS`] on.
+pub(crate) fn due(task: &TaskInfo) -> bool {
+    task.age_ms >= SHOW_AFTER_MS
+}
+
 /// "3 of 25", "12 of 340 MB", "40 %"; empty while the amount of work isn't known.
 pub fn count_text(t: &TaskInfo) -> String {
     if t.total == 0 {
@@ -62,7 +67,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
     }
     // look again soon: a young task becomes due, bars move
     ctx.request_repaint_after(std::time::Duration::from_millis(100));
-    let tasks: Vec<&TaskInfo> = all.iter().filter(|t| t.age_ms >= SHOW_AFTER_MS).collect();
+    let tasks: Vec<&TaskInfo> = all.iter().filter(|t| due(t)).collect();
     if tasks.is_empty() {
         return;
     }
diff --git a/crates/ui-egui/src/tests/activity.rs b/crates/ui-egui/src/tests/activity.rs
--- a/crates/ui-egui/src/tests/activity.rs
+++ b/crates/ui-egui/src/tests/activity.rs
@@ -6,18 +6,31 @@ use lightcraft_engine::activity::{Cancel, TaskInfo, Unit};
 use serde_json::json;
 
 use crate::headless::Headless;
-use crate::panels::activity::{WIDTH, count_text};
+use crate::panels::activity::{SHOW_AFTER_MS, WIDTH, count_text, due};
 use crate::{LightcraftApp, Services};
 
-// generous: merges and renders run on worker threads that a busy machine (a full workspace test run) slows a lot
-const T: Duration = Duration::from_secs(60);
+/// Generous: merges and renders run on worker threads that a busy machine (a full workspace test run)
+/// slows a lot. Stretched by `LIGHTCRAFT_TIME_SCALE`.
+fn t() -> Duration {
+    stretched(Duration::from_secs(60))
+}
+
+/// `d` stretched by `LIGHTCRAFT_TIME_SCALE`, never shrunk: the waits here sit on fixed times (the
+/// stand-in drive's 100 ms per file, the Auto Import listing every few seconds).
+fn stretched(d: Duration) -> Duration {
+    lightcraft_testkit::scaled(d).max(d)
+}
 
 fn demo() -> Headless {
     let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
     let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
     // let the first renders finish: a render still running when the test binary exits can crash in the
     // GPU driver's teardown (the preview pool doesn't join its workers)
-    h.settle(T);
+    h.settle(t());
+    // and whatever the demo itself runs in the stack: on a loaded machine it outlives half a second
+    // and its row would be counted with the test's own
+    let ended = h.step_until(t(), |h| h.app.session.activity.list().is_empty());
+    assert!(ended, "the demo's own tasks never ended: {:?}", h.app.session.activity.list());
     h
 }
 
@@ -44,7 +57,7 @@ fn wait_visible(h: &mut Headless) {
 fn click(h: &mut Headless, id: &str) {
     h.step();
     h.step();
-    assert_eq!(h.request("ui.clickWidget", json!({"id": id}), T)["ok"], true, "{id}");
+    assert_eq!(h.request("ui.clickWidget", json!({"id": id}), t())["ok"], true, "{id}");
     for _ in 0..4 {
         h.step();
     }
@@ -57,14 +70,16 @@ fn rows_appear_after_half_a_second_and_cancel_by_click() {
     g.progress(2, 5);
     h.step();
     // a frame on a loaded machine can outlast the half second: only judge a task still young after it
-    if h.app.session.activity.list()[0].age_ms < crate::panels::activity::SHOW_AFTER_MS {
+    // (`a_task_has_a_row_from_half_a_second_on` checks the threshold itself without the clock)
+    let young = h.app.session.activity.list().iter().any(|t| t.id == g.id() && !due(t));
+    if young {
         assert!(!has(&h, &format!("activity:row:{}", g.id())), "not before 0.5 s");
     }
     wait_visible(&mut h);
     assert!(has(&h, &format!("activity:row:{}", g.id())));
     click(&mut h, &format!("activity:cancel:{}", g.id()));
     assert!(g.is_cancelled());
-    let inspect = h.request("ui.inspect", json!({}), T);
+    let inspect = h.request("ui.inspect", json!({}), t());
     assert_eq!(inspect["result"]["activity"][0]["cancelling"], true, "{}", inspect["result"]["activity"]);
 }
 
@@ -97,6 +112,29 @@ fn not_cancellable_rows_have_no_cross() {
     assert!(!has(&h, &format!("activity:cancel:{}", g.id())));
 }
 
+/// The threshold the stack draws by, without the wall clock (a test that waits for real time can't tell
+/// a row shown too early from a frame that took half a second on a loaded machine).
+#[test]
+fn a_task_has_a_row_from_half_a_second_on() {
+    let task = |age_ms| TaskInfo {
+        id: 1,
+        kind: "export",
+        label: "Exporting".into(),
+        done: 0,
+        total: 0,
+        unit: Unit::Count,
+        detail: String::new(),
+        cancellable: true,
+        cancelling: false,
+        age_ms,
+    };
+    assert_eq!(SHOW_AFTER_MS, 500);
+    assert!(!due(&task(0)), "a task just started has no row");
+    assert!(!due(&task(SHOW_AFTER_MS - 1)), "nor one a moment short of half a second");
+    assert!(due(&task(SHOW_AFTER_MS)), "half a second old: a row");
+    assert!(due(&task(u64::MAX)));
+}
+
 #[test]
 fn count_text_by_unit() {
     let mut t = TaskInfo {
@@ -127,7 +165,7 @@ fn stale_cancel_is_harmless() {
     wait_visible(&mut h);
     let id = g.id();
     drop(g);
-    let r = h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), T);
+    let r = h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), t());
     assert_eq!(r["ok"], false, "an error result, not a panic: {r}");
     h.step();
     assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast);
@@ -202,15 +240,15 @@ fn slow_writer(ms: u64) -> crate::SharedWrite {
 fn start_export(h: &mut Headless, n: usize) {
     let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(n).map(|p| p.0).collect();
     assert_eq!(ids.len(), n, "the demo library has {n} photos");
-    h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), T);
-    h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), T);
+    h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t());
+    h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t());
     h.step();
     if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog {
         *full_size = false;
         *resize = lightcraft_engine::export::Resize::long_edge(64);
         *dir = "/lc-test-out".into();
     }
-    let r = h.request("ui.dialog.confirm", json!({}), T);
+    let r = h.request("ui.dialog.confirm", json!({}), t());
     assert_eq!(r["ok"], true, "{r}");
 }
 
@@ -220,12 +258,12 @@ fn export_shows_a_row_and_stops_on_activity_cancel() {
     h.app.services.write_shared = Some(slow_writer(200));
     start_export(&mut h, 5);
     assert!(h.app.export.is_some(), "running in the background");
-    let tasks = h.request("ui.inspect", json!({}), T)["result"]["activity"].clone();
+    let tasks = h.request("ui.inspect", json!({}), t())["result"]["activity"].clone();
     assert_eq!(tasks[0]["kind"], "export", "{tasks}");
     assert_eq!(tasks[0]["total"], 5, "{tasks}");
     let id = tasks[0]["id"].as_u64().unwrap();
-    assert_eq!(h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), T)["ok"], true);
-    assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none()));
+    assert_eq!(h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), t())["ok"], true);
+    assert!(h.step_until(t(), |h| h.app.export.is_none()));
     assert!(h.app.session.activity.list().is_empty());
     assert!(h.app.last_export_result.as_ref().is_some_and(|r| r["cancelled"] == true), "{:?}", h.app.last_export_result);
     assert!(!has(&h, "button:exportCancel"), "the old window is gone");
@@ -236,7 +274,7 @@ fn dead_export_worker_leaves_no_row() {
     let mut h = demo();
     h.app.services.write_shared = Some(std::sync::Arc::new(|_: &str, _: &[u8]| -> Result<(), String> { panic!("synthetic writer panic") }));
     start_export(&mut h, 2);
-    assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none()));
+    assert!(h.step_until(t(), |h| h.app.export.is_none()));
     assert!(h.app.session.activity.list().is_empty());
 }
 
@@ -264,7 +302,7 @@ fn gated_import(tag: &str, n: usize, gate: Option<std::sync::Arc<std::sync::atom
             && index >= lightcraft_engine::import::batch_size()
         {
             let t0 = std::time::Instant::now();
-            while !gate.load(std::sync::atomic::Ordering::Acquire) && t0.elapsed() < Duration::from_secs(60) {
+            while !gate.load(std::sync::atomic::Ordering::Acquire) && t0.elapsed() < t() {
                 std::thread::sleep(Duration::from_millis(1));
             }
         }
@@ -277,7 +315,7 @@ fn gated_import(tag: &str, n: usize, gate: Option<std::sync::Arc<std::sync::atom
         })
     }));
     let mut h = Headless::new(LightcraftApp::new(s, Services { png: None, ..Default::default() }), [1200.0, 800.0], 1.0);
-    h.settle(T);
+    h.settle(t());
     (h, dir)
 }
 
@@ -287,11 +325,11 @@ fn import_cancelled_by_command_reports_cancelled() {
     let (mut h, dir) = slow_import("cancel", n);
     let undo0 = h.app.session.undo.len();
     crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();
-    assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.as_ref().is_some_and(|t| t.imported >= 2)));
+    assert!(h.step_until(t(), |h| h.app.import.as_ref().is_some_and(|t| t.imported >= 2)));
     let tasks = h.app.session.activity.list();
     assert_eq!(tasks.first().map(|t| t.kind), Some("import"), "{tasks:?}");
     h.app.session.activity.cancel(tasks[0].id).unwrap();
-    assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none()));
+    assert!(h.step_until(t(), |h| h.app.import.is_none()));
     assert!(h.app.session.activity.list().is_empty());
     assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("Import cancelled")), "{:?}", h.app.ui.toast);
     assert!(h.app.session.catalog.len() < n, "stopped part-way");
@@ -306,15 +344,15 @@ fn a_photo_chosen_during_an_import_stays_selected() {
     let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
     let (mut h, dir) = gated_import("chosen", 20, Some(gate.clone()));
     crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();
-    assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.as_ref().is_some_and(|t| t.imported >= 2)));
+    assert!(h.step_until(t(), |h| h.app.import.as_ref().is_some_and(|t| t.imported >= 2)));
     // the newest photo so far, never the import's first
     let chosen = h.app.session.catalog.photos().map(|p| p.id).max().expect("photos arrived");
-    let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [chosen.0]}}), T);
+    let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [chosen.0]}}), t());
     assert_eq!(r["ok"], true, "{r}");
     // held at the gate until now, so the import cannot have ended however slowly the frames ran
     assert!(h.app.import.is_some(), "still importing");
     gate.store(true, std::sync::atomic::Ordering::Release);
-    assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none()));
+    assert!(h.step_until(t(), |h| h.app.import.is_none()));
     assert_eq!(h.app.session.catalog.len(), 20, "all imported");
     assert_eq!(h.app.session.selection.active, Some(chosen));
     let _ = std::fs::remove_dir_all(&dir);
@@ -330,7 +368,7 @@ fn scan_shows_a_row_and_cancel_closes_it() {
     assert!(tasks[0].cancellable);
     h.app.session.activity.cancel(tasks[0].id).unwrap();
     // like the old Cancel: the scan is dropped at once, without waiting for the file being read
-    assert!(h.step_until(Duration::from_secs(10), |h| h.app.scan.is_none()));
+    assert!(h.step_until(stretched(Duration::from_secs(10)), |h| h.app.scan.is_none()));
     assert!(h.app.session.activity.list().is_empty());
     for _ in 0..3 {
         h.step();
@@ -356,7 +394,7 @@ fn bracket(tag: &str) -> (Headless, std::path::PathBuf) {
     session.execute("library.select", &json!({"ids": ids})).unwrap();
     let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
     let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
-    h.settle(T);
+    h.settle(t());
     (h, dir)
 }
 
@@ -372,7 +410,7 @@ fn final_merge_shows_a_row_and_cancels() {
     assert!(rows[0].cancellable);
     assert_eq!(rows[0].total, 1000);
     h.app.session.activity.cancel(rows[0].id).unwrap();
-    assert!(h.step_until(T, |h| h.app.merge.final_task.is_none()));
+    assert!(h.step_until(t(), |h| h.app.merge.final_task.is_none()));
     assert!(h.app.merge.last_result.is_none(), "nothing merged");
     assert_eq!(h.app.session.catalog.len(), before, "no photo added");
     assert!(h.app.session.activity.list().is_empty());
@@ -397,7 +435,7 @@ fn merge_dialog_remembers_the_last_settings_per_kind() {
     crate::merge::start_final(&mut h.app, &opts).unwrap();
     let row = h.app.session.activity.list()[0].id;
     h.app.session.activity.cancel(row).unwrap();
-    assert!(h.step_until(T, |h| h.app.merge.final_task.is_none()));
+    assert!(h.step_until(t(), |h| h.app.merge.final_task.is_none()));
     crate::merge::open(&mut h.app, "merge.hdr").unwrap();
     let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") };
     assert_eq!(shown, &opts);
@@ -443,21 +481,21 @@ fn batch_merge_runs_each_set_in_turn() {
     let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
     let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
     // the merge ordering does not need the grid's nine thumbnails; one Detail render is enough
-    let r = h.request("ui.set", json!({"view": "detail", "filmstrip": false}), T);
+    let r = h.request("ui.set", json!({"view": "detail", "filmstrip": false}), t());
     assert_eq!(r["ok"], true, "{r}");
-    assert!(h.settle(T), "the imported photos and the active preview settle");
+    assert!(h.settle(t()), "the imported photos and the active preview settle");
     let r = crate::menus::run_ui_command(&mut h.app, "merge.hdrBatch", &json!({})).unwrap().unwrap();
     assert_eq!(r["sets"], 3);
     assert_eq!(h.app.merge.batch.len(), 2, "one running, two queued");
     let rows = h.app.session.activity.list();
     assert_eq!((rows.len(), rows.first().map(|r| r.kind)), (1, Some("merge")));
     // the first merge finishes and the second starts by itself
-    assert!(h.step_until(T, |h| h.app.merge.batch.len() == 1 && h.app.merge.final_task.is_some()));
+    assert!(h.step_until(t(), |h| h.app.merge.batch.len() == 1 && h.app.merge.final_task.is_some()));
     assert_eq!(h.app.session.catalog.len(), 10);
     // cancelling the second drops the third
     let rows = h.app.session.activity.list();
     h.app.session.activity.cancel(rows[0].id).unwrap();
-    assert!(h.step_until(T, |h| h.app.merge.final_task.is_none()));
+    assert!(h.step_until(t(), |h| h.app.merge.final_task.is_none()));
     assert!(h.app.merge.batch.is_empty());
     assert_eq!(h.app.session.catalog.len(), 10, "nothing more merged");
     assert!(h.app.session.activity.list().is_empty());
@@ -477,7 +515,7 @@ fn find_missing_shows_an_indeterminate_row() {
     assert_eq!(rows.len(), 1, "{rows:?}");
     assert_eq!((rows[0].kind, rows[0].label.as_str()), ("findMissing", "Find Missing Photos"));
     assert_eq!((rows[0].total, rows[0].cancellable), (0, false), "indeterminate, no ✕");
-    assert!(h.step_until(T, |h| h.app.tasks.is_empty()));
+    assert!(h.step_until(t(), |h| h.app.tasks.is_empty()));
     assert!(h.app.session.activity.list().is_empty(), "the row goes with the task");
     let _ = std::fs::remove_dir_all(dir);
 }
@@ -489,7 +527,7 @@ fn auto_import_listing_has_no_row() {
     std::fs::create_dir_all(&dir).unwrap();
     h.app.session.import_defaults.auto_folder = Some(dir.to_string_lossy().to_string());
     // the watched folder is listed every few seconds, quietly
-    assert!(h.step_until(Duration::from_secs(10), |h| h.app.tasks.is_running("Auto Import")));
+    assert!(h.step_until(stretched(Duration::from_secs(10)), |h| h.app.tasks.is_running("Auto Import")));
     assert!(h.app.session.activity.list().is_empty(), "{:?}", h.app.session.activity.list());
     h.app.session.import_defaults.auto_folder = None;
     let _ = std::fs::remove_dir_all(dir);
diff --git a/crates/ui-egui/src/tests/offline.rs b/crates/ui-egui/src/tests/offline.rs
--- a/crates/ui-egui/src/tests/offline.rs
+++ b/crates/ui-egui/src/tests/offline.rs
@@ -42,8 +42,8 @@ impl Calls {
 }
 
 /// The sleeping drive: every file-system call waits until [`Nas::wake`] (or takes one of the
-/// calls [`Nas::let_through`] allows). A watchdog wakes it after [`WATCHDOG`] and records that it
-/// had to (some frame was waiting for the drive).
+/// calls [`Nas::let_through`] allows). A watchdog wakes it after [`WATCHDOG`] (× the test time
+/// scale) and records that it had to (some frame was waiting for the drive).
 #[derive(Default)]
 struct Nas {
     /// (awake, calls let through while asleep)
@@ -58,7 +58,7 @@ impl Nas {
         let dog = nas.clone();
         std::thread::spawn(move || {
             let state = dog.state.lock().unwrap();
-            let (mut state, _) = dog.changed.wait_timeout_while(state, WATCHDOG, |s| !s.0).unwrap();
+            let (mut state, _) = dog.changed.wait_timeout_while(state, lightcraft_testkit::scaled(WATCHDOG), |s| !s.0).unwrap();
             if !state.0 {
                 dog.forced.store(true, Ordering::SeqCst);
                 state.0 = true;
@@ -69,8 +69,13 @@ impl Nas {
     }
     fn wait(&self) {
         let state = self.state.lock().unwrap();
-        let mut state = self.changed.wait_while(state, |s| !s.0 && s.1 == 0).unwrap();
-        if !state.0 {
+        // bounded without the watchdog thread too, should it be gone: it wakes the drive after
+        // WATCHDOG, so only a call nothing will ever wake gets to twice that
+        let limit = lightcraft_testkit::scaled(WATCHDOG * 2);
+        let (mut state, waited) = self.changed.wait_timeout_while(state, limit, |s| !s.0 && s.1 == 0).unwrap();
+        if waited.timed_out() {
+            self.forced.store(true, Ordering::SeqCst);
+        } else if !state.0 {
             state.1 -= 1;
         }
     }
diff --git a/crates/ui-egui/src/tests/sync.rs b/crates/ui-egui/src/tests/sync.rs
--- a/crates/ui-egui/src/tests/sync.rs
+++ b/crates/ui-egui/src/tests/sync.rs
@@ -349,9 +349,7 @@ fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() {
     let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
     let g = gate.clone();
     h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| {
-        while !g.load(std::sync::atomic::Ordering::Relaxed) {
-            std::thread::sleep(Duration::from_millis(5));
-        }
+        lightcraft_testkit::wait_until(Duration::from_secs(60), "the test opens the probe's gate", || g.load(std::sync::atomic::Ordering::Relaxed));
         Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() })
     }));
     crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap();
diff --git a/crates/ui-egui/src/widgets/album_picker.rs b/crates/ui-egui/src/widgets/album_picker.rs
--- a/crates/ui-egui/src/widgets/album_picker.rs
+++ b/crates/ui-egui/src/widgets/album_picker.rs
@@ -450,7 +450,11 @@ mod tests {
         let open: Vec<u64> = (0..200u64).map(|f| f * 101 + 1).collect();
         assert_eq!(visible_tree(&v, &open).len(), v.len());
         assert_eq!(search(&v, "folder 7 wedding").len(), want);
-        assert!(start.elapsed() < std::time::Duration::from_secs(1), "took {:?}", start.elapsed());
+        // a regression guard, not a speed measurement: this takes some 20 ms in a debug build; a path
+        // worked out per row by scanning every album takes seconds (stretched by LIGHTCRAFT_TIME_SCALE,
+        // never shrunk below 1 s)
+        let limit = lightcraft_testkit::scaled(std::time::Duration::from_secs(1)).max(std::time::Duration::from_secs(1));
+        assert!(start.elapsed() < limit, "took {:?}", start.elapsed());
     }
 
     /// Every word typed is in the album's path (folders included), in any case; names that start
diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,6 +1,6 @@
 # Architecture
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (`cargo xtask ci`: one integration-test binary per crate, doctests apart, one WASM check) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (test deadlines and the test watchdog, `lightcraft-testkit`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
 
 How LightCraft is built today. The longer design document (`plan/architecture.md`) is local-only
 (`plan/` is gitignored); this page is the committed, current summary. Sizes are Rust lines counted
@@ -83,10 +83,19 @@ time, skips) and `target/ci/summary.md` (Markdown for a PR; `--summary-md` print
 no host name, user name or absolute path, and nothing is uploaded. `--hardware` adds a step that runs
 the `lightcraft-gpu` tests once per backend with a hardware adapter (`LIGHTCRAFT_GPU_BACKEND`).
 
+libtest has no per-test timeout, so a test wait never goes without a limit: `lightcraft_testkit::wait_until`
+(a condition, polled with backoff) and `recv_within` (a channel) fail the test with what they waited for,
+and a hand-made limit uses `scaled`. `LIGHTCRAFT_TIME_SCALE` (default 1, clamped to 0.1–1000) stretches
+every limit at once on a slow machine. `lightcraft_testkit::watch()` registers the running test with a
+per-process watchdog (ui-egui's `Headless::new` does it in that crate's unit tests): once a watched test
+has run for `LIGHTCRAFT_TEST_WATCHDOG_MIN` minutes (default 30 × the time scale; `0` or `off` disables
+it) it prints the watched tests still running and exits with status 124, so a hung run ends with names.
+
 ## Revision history
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | minor | Quality gates: test deadlines (`wait_until`, `recv_within`, `LIGHTCRAFT_TIME_SCALE`) and the test watchdog |
 | 2026-10-23 | minor | Quality gates: one integration-test binary per crate, doctests apart, nextest on request, one WASM check |
 | 2026-10-11 | minor | Quality gates: the CI machine probe, visible skips (`testkit`) and `target/ci/report.json` |
 | 2026-10-10 | major | Created from the code (crate sizes, layers, data model, rendering, control, gates) |
