From 0eaf506c7a388ca79a03934a81517777bf109c89 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 27 Sep 2026 14:06:13 +0100 Subject: [PATCH 1/4] Keep a loaded prediction worker ready between keyboard opens Closing the keyboard still kills the worker that held the typed text, then starts a fresh one that loads the model and waits. The next open adopts it instead of reloading. The spare expires after two minutes and starts its activity observer only on its first request. Closes #922 Co-Authored-By: Claude Fable 5.1 --- docs/word-prediction.md | 12 +- src-tauri/src/point_scan_runtime.rs | 2 +- src-tauri/src/prediction/mod.rs | 171 +++++++++++++++++++++++++++- src-tauri/src/prediction/worker.rs | 9 +- 4 files changed, 182 insertions(+), 12 deletions(-) diff --git a/docs/word-prediction.md b/docs/word-prediction.md index 4cb680a8..9be0af7f 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -4,7 +4,7 @@ Word prediction is enabled by default in Scanning settings. Its five-position ro Predictions use only a temporary buffer of successful Switchify keyboard input, starting with the first letter. Existing text, pasted text and hardware keyboard typing are never read into it. Switchify does not inspect fields, selections, passwords or caret positions. Suggestions may therefore appear anywhere the keyboard is open, including password fields or applications without a text field. The buffer records successful input injection; it cannot verify what an application actually accepted. -The buffer holds at most 512 characters, retaining complete Unicode graphemes at its leading boundary. It tracks ordinary characters, spaces, Backspace and accepted completions across keyboard pages and top/bottom docking. Navigation, Delete, Enter, Tab, shortcuts, failed input, external typing/clicks/scrolling and foreground changes clear context. Opening or closing the keyboard, ending scanning, disconnecting and exiting also discard it. The keyboard remains open across foreground changes, resets modifiers and suggestions, and sends subsequent input to the new foreground application. +The buffer holds at most 512 characters, retaining complete Unicode graphemes at its leading boundary. It tracks ordinary characters, spaces, Backspace and accepted completions across keyboard pages and top/bottom docking. Navigation, Delete, Enter, Tab, shortcuts, failed input, external typing/clicks/scrolling and foreground changes clear context. Opening or closing the keyboard, ending scanning, disconnecting and exiting also discard it, by killing the process that held it. The keyboard remains open across foreground changes, resets modifiers and suggestions, and sends subsequent input to the new foreground application. A passive observer records only an activity counter and timestamp, never external text. Prediction is unavailable if this observer cannot start or loses access. Edits made before the observer is ready are discarded because intervening activity cannot be verified. Each queued edit is scoped to its foreground target and the time before injection, so edits preceding an observed external change are discarded. Changes within an application that produce no observed input cannot be detected without inspecting its fields. @@ -12,7 +12,7 @@ A passive observer records only an activity counter and timestamp, never externa One Word prediction setting controls an offline SmolLM2-135M int8 ONNX model. The saved `enhancedWordPrediction` field is retained for compatibility but does not choose an engine. The model spells candidates from subword pieces and reads only the last 256 characters of the temporary buffer, beginning at a word boundary. It never learns from typing. -For a first typed prefix, a fixed local context keeps the model from favoring website names at the start of a document; it adds no user text. The model loads in the worker while keyboard input remains available. Suggestions are blank until loading finishes. A passive badge beside the scan prompt distinguishes loading, a ready keyboard awaiting typed context, no matching suggestions, available suggestions, paused activity tracking, and prediction failure. It never shows typed text and is not a scan target. If loading or inference fails, or a call exceeds 1.5 seconds, the keyboard continues accepting input and offers **Retry predictions** in its toolbar. Retry restarts only the prediction worker, clears its private text context, and leaves the keyboard open; type a new prefix afterward. A 400 ms search budget bounds candidate exploration. A clipped buffer with no complete earlier word yields no suggestions. +For a first typed prefix, a fixed local context keeps the model from favoring website names at the start of a document; it adds no user text. The model loads in the worker while keyboard input remains available, which takes about a second. Suggestions are blank until loading finishes. Only the first keyboard open of a scanning session waits for it; later opens use the spare worker described under Worker process. A passive badge beside the scan prompt distinguishes loading, a ready keyboard awaiting typed context, no matching suggestions, available suggestions, paused activity tracking, and prediction failure. It never shows typed text and is not a scan target. If loading or inference fails, or a call exceeds 1.5 seconds, the keyboard continues accepting input and offers **Retry predictions** in its toolbar. Retry restarts only the prediction worker, clears its private text context, and leaves the keyboard open; type a new prefix afterward. A 400 ms search budget bounds candidate exploration. A clipped buffer with no complete earlier word yields no suggestions. Candidates contain ASCII letters and apostrophes and must have sufficient model probability. There is no vocabulary filter: any word the model finds likely can be suggested, including swearing, because the person typing chose it. Single-letter candidates are limited to “a” and “I”. Up to five suggestions are shown: the first, third and fifth are the most likely single words, and the second and fourth are the two most likely two-word phrases that begin with one of the top three words, ranked by the probability of the pair. When fewer phrases are found within an extra 200 ms, single words fill the remaining slots, and the other way round. Accepting a phrase inserts the rest of its first word, a space, the second word and a trailing space. @@ -64,6 +64,12 @@ The model files are too large to commit. `npm run prediction-model` downloads th A separate process owns the buffer, activity observer and model. Private bounded inherited pipes carry successful edits and results to native rendering. Text and suggestions are not sent to the React UI, diagnostic history or telemetry. One request is outstanding at a time with a two-second deadline. Timeout stops predictions until the keyboard is reopened; ordinary keyboard operation remains available. Closing the keyboard, ending scanning, or exiting kills and reaps the worker. +### Spare worker + +Loading the model takes about a second, so a keyboard that had a working worker leaves a spare behind when it closes. The worker that held the typed text is killed and reaped first. A new process is then started, which loads the model and waits. It has received no request, so it holds no text, and it starts its activity observer only on its first request, so it observes nothing while it waits. The next keyboard open adopts it and suggestions are ready without a reload. + +At most one spare exists, and only between keyboard opens. It is killed and reaped after two minutes without a keyboard open, when scanning ends, when Word prediction is turned off, on Retry predictions and on exit. A spare started for different switch keys, or one that has exited, is discarded and a new worker is started instead. While it waits, the spare holds the loaded model in memory, roughly 250 MB. + Before accepting a suggestion, the worker checks its token, edit revision, foreground identity and external activity. The main process checks its generation and foreground again before injection. Verification and native insertion cannot be atomic across applications; the target can still change in that short interval. ## Validation @@ -81,6 +87,6 @@ For native validation, use disposable synthetic text in Notepad and a browser on 3. Change pages and docking, type punctuation and numbers, and return to Letters. Verify context survives these layout changes and Backspace edits the tracked buffer. 4. Use navigation, shortcuts, failed edits and external keyboard/mouse activity. Verify suggestions clear and a new first letter starts fresh context. 5. Change foreground apps with locked modifiers selected. Verify the keyboard stays open, modifiers and suggestions reset, and later keys go to the new app. -6. Close the keyboard, stop scanning, disconnect and exit. Verify input releases and the prediction worker exits. Test observer failure separately; typing should remain usable without predictions. +6. Close the keyboard, stop scanning, disconnect and exit. Verify input releases and the prediction worker exits. After closing the keyboard one spare worker remains; reopen within two minutes and verify suggestions appear without the loading badge, then verify the spare exits after two minutes idle and when scanning stops. Test observer failure separately; typing should remain usable without predictions. Compilation and fake-adapter tests do not establish native application compatibility. Record live results separately. diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index caf69a2a..7c8d877a 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -151,7 +151,7 @@ impl Adapter for PointScan { return result.map(|()| None); } Request::OpenKeyboard | Request::OpenMouse | Request::OpenPoint => { - crate::prediction::stop(); + crate::prediction::close(); crate::scan_executor::activate(request) } Request::MouseDrag => { diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index bd76088f..bc947a1c 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -7,7 +7,7 @@ pub mod worker; use crate::scan_keyboard::{Key, Keyboard, Modifier, Page, Stroke}; use std::{ cell::RefCell, - path::Path, + path::{Path, PathBuf}, process::{Child, ChildStdin, Command, Stdio}, sync::atomic::{AtomicU8, Ordering}, sync::mpsc, @@ -135,9 +135,44 @@ impl Drop for Client { self.terminate(); } } +/// How long a spare worker waits for the keyboard to be opened again. +const SPARE_IDLE: Duration = Duration::from_secs(120); + +/// A worker started when the keyboard closed, so the next open finds the +/// model loaded. It has received no request, so it holds no text and has not +/// started its activity observer. +struct Spare { + client: Client, + ignored: Vec, + since: Instant, +} +impl Spare { + fn usable(&mut self, ignored: &[u32], now: Instant) -> bool { + self.ignored == ignored + && now.duration_since(self.since) < SPARE_IDLE + && matches!(self.client.child.try_wait(), Ok(None)) + } +} +/// The spare worker for this keyboard open, if one is still usable. Any +/// other spare is killed and reaped. +fn adopt(spare: &mut Option, ignored: &[u32], now: Instant) -> Option { + let mut spare = spare.take()?; + spare.usable(ignored, now).then_some(spare.client) +} +fn expire(spare: &mut Option, now: Instant) { + if spare + .as_ref() + .is_some_and(|s| now.duration_since(s.since) >= SPARE_IDLE) + { + *spare = None; + } +} #[derive(Default)] struct Service { client: Option, + /// What the worker was started with, to start its replacement. + model: Option, + ignored: Vec, failed: bool, generation: u64, outstanding: Option, @@ -275,6 +310,7 @@ impl Service { } } thread_local! { static SERVICE: RefCell = RefCell::new(Service::default()); } +thread_local! { static SPARE: RefCell> = const { RefCell::new(None) }; } // 0 = idle, 1 = starting, 2 = running. A failed or disabled hook can retry. static KEYBOARD_ACTIVITY: AtomicU8 = AtomicU8::new(0); @@ -318,8 +354,38 @@ pub fn keyboard_input_context() -> Option { activity: epoch, }) } +/// End prediction: the worker, its private text context and any spare +/// worker are killed and reaped. pub fn stop() { SERVICE.with(|s| *s.borrow_mut() = Service::default()); + SPARE.with(|s| *s.borrow_mut() = None); +} +/// The keyboard closed. The worker and its private text context are killed +/// and reaped as in `stop`, then a fresh worker loads the model for the next +/// open. Only a keyboard that had a working worker gets one. +pub fn close() { + let service = SERVICE.with(|s| std::mem::take(&mut *s.borrow_mut())); + let Service { + client, + model, + ignored, + .. + } = service; + let worked = client.is_some(); + drop(client); + SPARE.with(|s| { + let mut spare = s.borrow_mut(); + if let (true, Some(model)) = (worked, model) { + *spare = Client::start(&model, ignored.clone()) + .ok() + .map(|client| Spare { + client, + ignored, + since: Instant::now(), + }); + } + expire(&mut spare, Instant::now()); + }); } /// Restart only prediction. The keyboard and its scan session remain open, /// while the old worker and its private text context are discarded. @@ -413,7 +479,7 @@ fn model_resource( pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ignored: &[String]) { let Some(keyboard) = keyboard else { - stop(); + close(); return; }; if !enabled { @@ -440,13 +506,20 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign return; } if s.client.is_none() { - let ignored = ignored + let ignored: Vec = ignored .iter() .filter_map(|name| crate::switch_input::prediction_key_code(name)) .collect(); - s.client = resource(app) - .and_then(|path| Client::start(path.parent().ok_or(())?, ignored)) - .ok(); + s.client = SPARE + .with(|spare| adopt(&mut spare.borrow_mut(), &ignored, Instant::now())) + .or_else(|| { + let path = resource(app).ok()?; + Client::start(path.parent()?, ignored.clone()).ok() + }); + s.model = resource(app) + .ok() + .and_then(|path| path.parent().map(Path::to_owned)); + s.ignored = ignored; if s.client.is_none() { s.failed = true; keyboard.predictions(None, true); @@ -783,6 +856,92 @@ mod tests { assert!(client.child.try_wait().unwrap().is_some()); assert!(start.elapsed() < Duration::from_secs(3)); } + fn sleeper() -> Client { + #[cfg(target_os = "windows")] + let mut command = { + let mut c = Command::new("powershell.exe"); + c.args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep -Seconds 60", + ]); + c + }; + #[cfg(target_os = "macos")] + let mut command = { + let mut c = Command::new("/bin/sleep"); + c.arg("60"); + c + }; + command.stdin(Stdio::piped()); + Client::spawn(command).expect("fake worker starts") + } + fn spare(ignored: &[u32], since: Instant) -> Option { + Some(Spare { + client: sleeper(), + ignored: ignored.to_vec(), + since, + }) + } + #[test] + fn a_waiting_spare_worker_is_adopted_by_the_next_keyboard() { + let now = Instant::now(); + let mut slot = spare(&[32], now); + let id = slot.as_ref().unwrap().client.child.id(); + let mut client = adopt(&mut slot, &[32], now + Duration::from_secs(5)).unwrap(); + assert!(slot.is_none()); + assert_eq!(client.child.id(), id); + assert!(client.child.try_wait().unwrap().is_none()); + } + #[test] + fn a_stale_spare_worker_is_discarded_not_adopted() { + let now = Instant::now(); + // Started for other switch keys. + let mut slot = spare(&[32], now); + assert!(adopt(&mut slot, &[13], now).is_none()); + assert!(slot.is_none()); + // Waited too long. + let mut slot = spare(&[32], now); + assert!(adopt(&mut slot, &[32], now + SPARE_IDLE).is_none()); + assert!(slot.is_none()); + // Already exited. + let mut slot = spare(&[32], now); + let child = &mut slot.as_mut().unwrap().client.child; + child.kill().unwrap(); + child.wait().unwrap(); + assert!(adopt(&mut slot, &[32], now).is_none()); + } + #[test] + fn an_idle_spare_worker_expires() { + let now = Instant::now(); + let mut slot = spare(&[], now); + expire(&mut slot, now + SPARE_IDLE - Duration::from_secs(1)); + assert!(slot.is_some()); + expire(&mut slot, now + SPARE_IDLE); + assert!(slot.is_none()); + } + #[test] + fn closing_without_a_working_worker_starts_no_spare_and_stop_kills_one() { + stop(); + SERVICE.with(|slot| { + *slot.borrow_mut() = Service { + failed: true, + model: Some(PathBuf::from("unused")), + generation: 4, + ..Default::default() + }; + }); + close(); + SERVICE.with(|slot| assert_eq!(slot.borrow().generation, 0)); + SPARE.with(|slot| assert!(slot.borrow().is_none())); + // Closing again keeps a spare that is waiting; ending prediction kills it. + SPARE.with(|slot| *slot.borrow_mut() = spare(&[], Instant::now())); + close(); + SPARE.with(|slot| assert!(slot.borrow().is_some())); + stop(); + SPARE.with(|slot| assert!(slot.borrow().is_none())); + } #[test] fn bounded_private_frames_reject_invalid_lengths() { assert!( diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 14ac462f..fc28040f 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -453,14 +453,19 @@ pub fn run_from_args() -> bool { }); } activity::set_ignored(ignored); - let tracked = activity::start(); #[cfg(any(target_os = "windows", target_os = "macos"))] { let database = Database::open(&path); - let mut engine = Engine::new(database, tracked); + let mut engine = Engine::new(database, false); let mut input = std::io::stdin().lock(); let mut output = std::io::stdout().lock(); + let mut observing = false; while let Ok(request) = receive::(&mut input) { + // The first request means a keyboard is open. A spare worker + // waiting for one loads the model and observes nothing. + if !std::mem::replace(&mut observing, true) { + engine.tracked = activity::start(); + } send(&mut output, &engine.respond(request))?; } } From 3da92bfa40f588454bebedf2a2462480c9a491d3 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 27 Sep 2026 14:11:36 +0100 Subject: [PATCH 2/4] Test the spare worker hand-over and the deferred observer Review follow-up: prove the text-holding worker is dead before the spare starts, prove the observer starts once on the first request, resolve the model once, compare switch keys as a set, and correct the docs on when a later open still loads the model. Co-Authored-By: Claude Fable 5.1 --- docs/word-prediction.md | 6 +- src-tauri/src/prediction/mod.rs | 109 ++++++++++++++++++++--------- src-tauri/src/prediction/worker.rs | 98 +++++++++++++++++++++++--- 3 files changed, 165 insertions(+), 48 deletions(-) diff --git a/docs/word-prediction.md b/docs/word-prediction.md index 9be0af7f..3cb7d9be 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -12,7 +12,7 @@ A passive observer records only an activity counter and timestamp, never externa One Word prediction setting controls an offline SmolLM2-135M int8 ONNX model. The saved `enhancedWordPrediction` field is retained for compatibility but does not choose an engine. The model spells candidates from subword pieces and reads only the last 256 characters of the temporary buffer, beginning at a word boundary. It never learns from typing. -For a first typed prefix, a fixed local context keeps the model from favoring website names at the start of a document; it adds no user text. The model loads in the worker while keyboard input remains available, which takes about a second. Suggestions are blank until loading finishes. Only the first keyboard open of a scanning session waits for it; later opens use the spare worker described under Worker process. A passive badge beside the scan prompt distinguishes loading, a ready keyboard awaiting typed context, no matching suggestions, available suggestions, paused activity tracking, and prediction failure. It never shows typed text and is not a scan target. If loading or inference fails, or a call exceeds 1.5 seconds, the keyboard continues accepting input and offers **Retry predictions** in its toolbar. Retry restarts only the prediction worker, clears its private text context, and leaves the keyboard open; type a new prefix afterward. A 400 ms search budget bounds candidate exploration. A clipped buffer with no complete earlier word yields no suggestions. +For a first typed prefix, a fixed local context keeps the model from favoring website names at the start of a document; it adds no user text. The model loads in the worker while keyboard input remains available, which takes about a second. Suggestions are blank until loading finishes. Later keyboard opens normally skip this wait by using the spare worker described under Worker process. A passive badge beside the scan prompt distinguishes loading, a ready keyboard awaiting typed context, no matching suggestions, available suggestions, paused activity tracking, and prediction failure. It never shows typed text and is not a scan target. If loading or inference fails, or a call exceeds 1.5 seconds, the keyboard continues accepting input and offers **Retry predictions** in its toolbar. Retry restarts only the prediction worker, clears its private text context, and leaves the keyboard open; type a new prefix afterward. A 400 ms search budget bounds candidate exploration. A clipped buffer with no complete earlier word yields no suggestions. Candidates contain ASCII letters and apostrophes and must have sufficient model probability. There is no vocabulary filter: any word the model finds likely can be suggested, including swearing, because the person typing chose it. Single-letter candidates are limited to “a” and “I”. Up to five suggestions are shown: the first, third and fifth are the most likely single words, and the second and fourth are the two most likely two-word phrases that begin with one of the top three words, ranked by the probability of the pair. When fewer phrases are found within an extra 200 ms, single words fill the remaining slots, and the other way round. Accepting a phrase inserts the rest of its first word, a space, the second word and a trailing space. @@ -68,7 +68,7 @@ A separate process owns the buffer, activity observer and model. Private bounded Loading the model takes about a second, so a keyboard that had a working worker leaves a spare behind when it closes. The worker that held the typed text is killed and reaped first. A new process is then started, which loads the model and waits. It has received no request, so it holds no text, and it starts its activity observer only on its first request, so it observes nothing while it waits. The next keyboard open adopts it and suggestions are ready without a reload. -At most one spare exists, and only between keyboard opens. It is killed and reaped after two minutes without a keyboard open, when scanning ends, when Word prediction is turned off, on Retry predictions and on exit. A spare started for different switch keys, or one that has exited, is discarded and a new worker is started instead. While it waits, the spare holds the loaded model in memory, roughly 250 MB. +At most one spare exists, and only between keyboard opens. It is killed and reaped after two minutes without a keyboard open, whenever scanning ends or restarts, such as after saving settings, when Word prediction is turned off, on Retry predictions and on exit. A spare started for different switch keys, or one that has exited, is discarded and a new worker is started instead. A keyboard whose worker failed leaves no spare. In each of these cases, and on the first open of a scanning session, the next open loads the model again. A keyboard reopened within about a second adopts a spare that is still loading and shows the loading badge until it finishes. While it waits, the spare holds the loaded model in memory, roughly 250 MB. Before accepting a suggestion, the worker checks its token, edit revision, foreground identity and external activity. The main process checks its generation and foreground again before injection. Verification and native insertion cannot be atomic across applications; the target can still change in that short interval. @@ -87,6 +87,6 @@ For native validation, use disposable synthetic text in Notepad and a browser on 3. Change pages and docking, type punctuation and numbers, and return to Letters. Verify context survives these layout changes and Backspace edits the tracked buffer. 4. Use navigation, shortcuts, failed edits and external keyboard/mouse activity. Verify suggestions clear and a new first letter starts fresh context. 5. Change foreground apps with locked modifiers selected. Verify the keyboard stays open, modifiers and suggestions reset, and later keys go to the new app. -6. Close the keyboard, stop scanning, disconnect and exit. Verify input releases and the prediction worker exits. After closing the keyboard one spare worker remains; reopen within two minutes and verify suggestions appear without the loading badge, then verify the spare exits after two minutes idle and when scanning stops. Test observer failure separately; typing should remain usable without predictions. +6. Close the keyboard, stop scanning, disconnect and exit. Verify input releases and the prediction worker exits. After closing the keyboard one spare worker remains; reopen after a few seconds and within two minutes, and verify the loading badge clears almost immediately, then verify the spare exits after two minutes idle and when scanning stops. Test observer failure separately; typing should remain usable without predictions. Compilation and fake-adapter tests do not establish native application compatibility. Record live results separately. diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index bc947a1c..1fced8ad 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -364,6 +364,9 @@ pub fn stop() { /// and reaped as in `stop`, then a fresh worker loads the model for the next /// open. Only a keyboard that had a working worker gets one. pub fn close() { + close_with(Client::start); +} +fn close_with(start: impl FnOnce(&Path, Vec) -> Result) { let service = SERVICE.with(|s| std::mem::take(&mut *s.borrow_mut())); let Service { client, @@ -376,13 +379,11 @@ pub fn close() { SPARE.with(|s| { let mut spare = s.borrow_mut(); if let (true, Some(model)) = (worked, model) { - *spare = Client::start(&model, ignored.clone()) - .ok() - .map(|client| Spare { - client, - ignored, - since: Instant::now(), - }); + *spare = start(&model, ignored.clone()).ok().map(|client| Spare { + client, + ignored, + since: Instant::now(), + }); } expire(&mut spare, Instant::now()); }); @@ -506,19 +507,19 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign return; } if s.client.is_none() { - let ignored: Vec = ignored + let mut ignored: Vec = ignored .iter() .filter_map(|name| crate::switch_input::prediction_key_code(name)) .collect(); - s.client = SPARE - .with(|spare| adopt(&mut spare.borrow_mut(), &ignored, Instant::now())) - .or_else(|| { - let path = resource(app).ok()?; - Client::start(path.parent()?, ignored.clone()).ok() - }); - s.model = resource(app) + ignored.sort_unstable(); + ignored.dedup(); + let model = resource(app) .ok() .and_then(|path| path.parent().map(Path::to_owned)); + s.client = SPARE + .with(|spare| adopt(&mut spare.borrow_mut(), &ignored, Instant::now())) + .or_else(|| Client::start(model.as_deref()?, ignored.clone()).ok()); + s.model = model; s.ignored = ignored; if s.client.is_none() { s.failed = true; @@ -828,25 +829,7 @@ mod tests { #[test] fn cancellation_kills_and_reaps_a_blocked_worker() { - #[cfg(target_os = "windows")] - let mut command = { - let mut c = Command::new("powershell.exe"); - c.args([ - "-NoProfile", - "-NonInteractive", - "-Command", - "Start-Sleep -Seconds 60", - ]); - c - }; - #[cfg(target_os = "macos")] - let mut command = { - let mut c = Command::new("/bin/sleep"); - c.arg("60"); - c - }; - command.stdin(Stdio::piped()); - let mut client = Client::spawn(command).expect("fake worker starts"); + let mut client = sleeper(); let start = Instant::now(); assert!(client .replies @@ -921,6 +904,64 @@ mod tests { expire(&mut slot, now + SPARE_IDLE); assert!(slot.is_none()); } + fn alive(pid: u32) -> bool { + #[cfg(target_os = "windows")] + let output = Command::new("tasklist") + .args(["/NH", "/FI", &format!("PID eq {pid}")]) + .output(); + #[cfg(target_os = "macos")] + let output = Command::new("/bin/ps") + .args(["-o", "pid=", "-p", &pid.to_string()]) + .output(); + String::from_utf8_lossy(&output.unwrap().stdout).contains(&pid.to_string()) + } + #[test] + fn closing_kills_the_worker_that_held_text_before_starting_a_spare() { + stop(); + let worker = sleeper(); + let held = worker.child.id(); + assert!(alive(held)); + SERVICE.with(|slot| { + *slot.borrow_mut() = Service { + client: Some(worker), + model: Some(PathBuf::from("model")), + ignored: vec![13, 32], + edit: vec![InputScope::capture().record(Edit::Append("test".into()))], + ..Default::default() + }; + }); + close_with(|model, ignored| { + assert!( + !alive(held), + "the old worker is gone before the spare starts" + ); + assert_eq!(model, Path::new("model")); + assert_eq!(ignored, [13, 32]); + Ok(sleeper()) + }); + SERVICE.with(|slot| { + let s = slot.borrow(); + assert!(s.client.is_none()); + assert!(s.edit.is_empty()); + }); + SPARE.with(|slot| { + let mut slot = slot.borrow_mut(); + let spare = slot.as_mut().unwrap(); + assert_ne!(spare.client.child.id(), held); + assert_eq!(spare.ignored, [13, 32]); + assert!(spare.client.child.try_wait().unwrap().is_none()); + }); + // A spare that could not start is not retried. + SERVICE.with(|slot| { + let mut s = slot.borrow_mut(); + s.client = Some(sleeper()); + s.model = Some(PathBuf::from("model")); + }); + close_with(|_, _| Err(())); + SPARE.with(|slot| assert!(slot.borrow().is_none())); + close_with(|_, _| panic!("no worker closed, so none is started")); + stop(); + } #[test] fn closing_without_a_working_worker_starts_no_spare_and_stop_kills_one() { stop(); diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index fc28040f..33e76f09 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -428,6 +428,27 @@ pub fn interleave(words: Vec, phrases: Vec) -> Vec { } row } +/// Answers requests until the pipe closes. The observer starts on the first +/// request, which means a keyboard is open: a spare worker waiting for one +/// loads the model and observes nothing. Starting can take up to 500 ms of +/// the parent's two-second reply deadline, which holds because the first +/// request is sent before anything is typed and so runs no inference. +fn serve( + engine: &mut Engine, + input: &mut impl Read, + output: &mut impl Write, + start: impl FnOnce() -> bool, +) -> Result<(), ()> { + let mut start = Some(start); + while let Ok(request) = receive::(input) { + if let Some(start) = start.take() { + engine.tracked = start(); + } + send(output, &engine.respond(request))?; + } + Ok(()) +} + pub fn run_from_args() -> bool { let args: Vec<_> = std::env::args_os().collect(); if args.get(1).is_none_or(|s| s != ARG) { @@ -457,17 +478,12 @@ pub fn run_from_args() -> bool { { let database = Database::open(&path); let mut engine = Engine::new(database, false); - let mut input = std::io::stdin().lock(); - let mut output = std::io::stdout().lock(); - let mut observing = false; - while let Ok(request) = receive::(&mut input) { - // The first request means a keyboard is open. A spare worker - // waiting for one loads the model and observes nothing. - if !std::mem::replace(&mut observing, true) { - engine.tracked = activity::start(); - } - send(&mut output, &engine.respond(request))?; - } + serve( + &mut engine, + &mut std::io::stdin().lock(), + &mut std::io::stdout().lock(), + activity::start, + )?; } Ok(()) }; @@ -495,6 +511,66 @@ mod tests { fn append(s: &str) -> RecordedEdit { edit(Edit::Append(s.into())) } + fn query(revision: u64, edits: Vec) -> Vec { + let mut frame = Vec::new(); + send( + &mut frame, + &Request::Query { + generation: 0, + edits, + revision, + shift: Shift::Off, + caps: false, + sentence_start: false, + }, + ) + .unwrap(); + frame + } + #[test] + fn the_observer_starts_once_on_the_first_request() { + use std::cell::Cell; + let started = Cell::new(0); + let start = || { + started.set(started.get() + 1); + true + }; + // A spare worker: the pipe closes before any request arrives. + let mut idle = Engine::new(Database::fixture(), false); + let mut output = Vec::new(); + serve(&mut idle, &mut std::io::Cursor::new([]), &mut output, start).unwrap(); + assert_eq!(started.get(), 0); + assert!(!idle.tracked); + assert!(output.is_empty()); + + let mut e = engine(); + e.tracked = false; + let input = [query(1, vec![append("wa")]), query(2, vec![append("t")])].concat(); + let mut output = Vec::new(); + serve(&mut e, &mut std::io::Cursor::new(input), &mut output, start).unwrap(); + assert_eq!(started.get(), 1); + assert!(e.tracked); + let mut replies = std::io::Cursor::new(output); + for _ in 0..2 { + assert!(matches!( + receive::(&mut replies), + Ok(Response::Suggestions { tracking: true, .. }) + )); + } + + // An observer that cannot start leaves prediction untracked. + let mut e = engine(); + let input = query(1, vec![]); + let mut output = Vec::new(); + serve( + &mut e, + &mut std::io::Cursor::new(input), + &mut output, + || false, + ) + .unwrap(); + assert!(!e.tracked); + } #[test] fn phrases_take_every_second_slot_and_accept_as_one_suffix() { let w = |s: &[&str]| s.iter().map(|s| (*s).to_owned()).collect::>(); From 1c24d653390df2af178c296d281b1e494e8cacf5 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 27 Sep 2026 14:15:35 +0100 Subject: [PATCH 3/4] End prediction when it is turned off with the keyboard closed Review follow-up: a disabled setting now kills the spare worker directly, and the fake worker liveness check matches the image name so a reused process id cannot fail the test. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/prediction/mod.rs | 35 ++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index 1fced8ad..0f69030d 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -479,15 +479,17 @@ fn model_resource( } pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ignored: &[String]) { - let Some(keyboard) = keyboard else { - close(); - return; - }; if !enabled { stop(); - keyboard.predictions(None, false); + if let Some(keyboard) = keyboard { + keyboard.predictions(None, false); + } return; } + let Some(keyboard) = keyboard else { + close(); + return; + }; SERVICE.with(|slot| { let mut s = slot.borrow_mut(); let case = ( @@ -904,16 +906,25 @@ mod tests { expire(&mut slot, now + SPARE_IDLE); assert!(slot.is_none()); } + /// Whether the fake worker with this id is running. The image name is + /// matched too, so a reused id on another program does not count. fn alive(pid: u32) -> bool { #[cfg(target_os = "windows")] - let output = Command::new("tasklist") - .args(["/NH", "/FI", &format!("PID eq {pid}")]) - .output(); + let (output, image) = ( + Command::new("tasklist") + .args(["/NH", "/FI", &format!("PID eq {pid}")]) + .output(), + "powershell", + ); #[cfg(target_os = "macos")] - let output = Command::new("/bin/ps") - .args(["-o", "pid=", "-p", &pid.to_string()]) - .output(); - String::from_utf8_lossy(&output.unwrap().stdout).contains(&pid.to_string()) + let (output, image) = ( + Command::new("/bin/ps") + .args(["-o", "pid=,comm=", "-p", &pid.to_string()]) + .output(), + "sleep", + ); + let listed = String::from_utf8_lossy(&output.unwrap().stdout).to_lowercase(); + listed.contains(image) && listed.split_whitespace().any(|w| w == pid.to_string()) } #[test] fn closing_kills_the_worker_that_held_text_before_starting_a_spare() { From 27bda58b99be1b9de6af68917725cdf3f8d7bdef Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 27 Sep 2026 14:17:23 +0100 Subject: [PATCH 4/4] List the fake worker's id and name as separate ps columns Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/prediction/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index 0f69030d..98a20003 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -919,7 +919,7 @@ mod tests { #[cfg(target_os = "macos")] let (output, image) = ( Command::new("/bin/ps") - .args(["-o", "pid=,comm=", "-p", &pid.to_string()]) + .args(["-o", "pid=", "-o", "comm=", "-p", &pid.to_string()]) .output(), "sleep", );