-
Notifications
You must be signed in to change notification settings - Fork 0
technical design.cancel inflight
Status: Approved 2026-04-21 — goals, design, and 4 open questions resolved (see "Decisions" section). Implementation can begin.
Shift+Click kicks off an OCR pipeline that can take 15+ s on the free-remote tier.
While a request is in flight, subsequent Shift+Clicks are silently dropped
(main.rs:703 gates on !s.is_loading). The user wants rapid iteration:
"I waited too long — give up, capture this other region now."
Current mitigations are inadequate:
- Lowering
remote_timeout_secsonly shortens the window; the user still waits the full timeout. - The worker is
std::thread::spawn+reqwest::blocking::Client. There is no handle, no cancel signal, and no way to interrupt a blocking socket read from outside the thread.
- A new Shift+Click (or Ctrl+Shift+Click) while an OCR is in-flight cancels the in-flight request and starts the new one immediately — no waiting for the old timeout.
- "Cancel" means the in-flight HTTP socket is closed so the remote backend stops billing/computing, not just "discard the response when it eventually arrives."
- No stale result ever paints the UI — if an old request completes after a new one has started, its result is dropped on the floor.
- Latest-wins semantics: only one OCR pipeline is active at a time. We don't fan out parallel requests.
- Cancel via keyboard (ESC stays on quit for now — can be layered later).
- Parallel/concurrent OCR requests.
- Cancelling local CPU-bound inference (jp_detect + manga-ocr-rs) mid-compute. Sub-2 s path — acceptable to let it finish and discard result.
- Cancelling the Electron HUD from the client side.
-
lenzu/src/main.rs:791spawns a rawstd::thread::spawnper Shift+Click with a giant closure. -
lenzu/src/client.rs:3usesreqwest::blocking::Client; every backend call (local ollama, free remote, paid remote) is a blocking HTTP round-trip. - Results flow back via
async-channelintoglib::MainContext::default().spawn_local(main.rs:536) — this is not a tokio runtime; it's glib's executor running on the GTK main thread. -
is_loading: boolin AppState (main.rs:119) serves as a mutex: new clicks are rejected while true. -
tokiois only adev-dependenciesentry (Cargo.toml:43) — no runtime exists in the main binary.
Two mechanisms in combination:
- Add
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] }to[dependencies]. - Create a single
tokio::runtime::Runtimeat app startup; stash itsHandleinAppState(or aOnceLock<Handle>global). - Migrate
OcrClientfromreqwest::blocking::Client→reqwest::Client. Everysend_and_parsebecomesasync. - Sync CPU work (DBNet detect, manga-ocr-rs recognize, grayscale conversion, image crop) is wrapped in
tokio::task::spawn_blockingso it doesn't stall the runtime. - Replace the
std::thread::spawnworker withruntime.spawn(async move { ... })returning aJoinHandle<()>.JoinHandle::abort()closes the underlying TCP socket when called mid-reqwest, which is the unlock. - Store the handle:
in_flight: Option<tokio::task::JoinHandle<()>>in AppState. - On new Shift+Click:
if let Some(h) = state.in_flight.take() { h.abort(); }before spawning the new task.
abort() kills future work on the old task, but previews already sent through
the async-channel before the abort was called will still be in the buffer
and will still paint the UI. To guarantee no stale frame ever appears:
-
AppStategetscurrent_generation: u64, incremented on every Shift+Click. - The worker task captures its generation at spawn time and stamps every
tx.send(...)with it:tx.send((gen_id, Ok(result))).await. - The receiver (glib
spawn_localloop at main.rs:537) compares the incoming stamp tostate.current_generationand drops mismatches silently.
Cheap, airtight. Doesn't replace abort() — complements it.
The existing check at main.rs:703 blocks stacking. Under the new semantics
we want stacking — each new click cancels the previous. Remove the
!s.is_loading clause from should_capture. Keep the 1 s debounce (prevents
accidental double-clicks from firing two captures too close together, which is
a different concern from "user deliberately clicks again because they're tired
of waiting").
is_loading still drives the spinner animation (main.rs:614–625). Keep it
as a visual state, but have it track in_flight.is_some() rather than gate
input. When a new request starts, the spinner resets; when it completes or
is aborted, it clears.
Each step compiles and runs in isolation — no big-bang rewrite.
-
Add tokio runtime, no behaviour change. Create
Runtimeat startup, stash handle, don't use it yet. Verifies dep graph compiles. -
Port
client.rsfrom blocking → async. Big mechanical change: everyfnthat callsself.client.post(...).send()becomesasync fnwith.await. All timeout handling moves totokio::time::timeout(dur, fut).reqwest::blocking::Response::text()→reqwest::Response::text().await. SSE streaming (client.rs:350) needs the async equivalent —reqwest::Response::bytes_stream()+futures::StreamExt. Tests usewiremock(already a dev-dep) — should port cleanly. -
Wrap sync CPU work in
spawn_blocking.jp_detect.detect(...),manga-ocr-rs.recognize(...), grayscale, crop. Each call site becomestokio::task::spawn_blocking(move || { ... }).await?. -
Replace
std::thread::spawnwithruntime.spawn(async move { ... }). The giant closure in main.rs:791 becomes an async block.send_blockingontx_clonebecomestx_clone.send(...).await. -
Add
in_flight: Option<JoinHandle<()>>+ abort-on-new-click. On Shift+Click: take old handle, abort it, spawn new task, store handle. -
Add generation id stamp + receiver filter.
AtomicU64on AppState; each click bumps; worker captures at spawn; every send stamps; receiver drops mismatches. -
Drop
is_loadingas a gate, keep it as spinner state.
Order matters: steps 1–4 land without behaviour change (pipeline runs on tokio but nothing cancels). Steps 5–7 introduce the user-visible behaviour. This means each commit is reviewable and revertable.
-
Unit (wiremock): set up a mock endpoint with a 10 s delay; start request;
call
JoinHandle::abort(); assert the future resolves withJoinError::is_cancelled() == truein < 100 ms. Proves TCP socket actually closes on abort. - Unit (generation id): spawn two requests with gens 1 and 2; gen-1 sends a result after gen-2 completes; assert UI receiver drops gen-1.
- Integration (manual): Shift+Click, wait 3 s, Shift+Click elsewhere. Observe in ollama/OpenRouter logs that the first request was cut. Observe the HUD never flashes the first region's result.
- Regression: existing OCR accuracy tests run against the new async client unchanged (only the wrapper shape changes; the wire protocol doesn't).
| Risk | Mitigation |
|---|---|
| Async migration touches every call site in client.rs (~1000 lines) | Migrate in one PR; wiremock unit tests catch regressions |
| SSE streaming path is the trickiest — block-reading bytes currently |
reqwest::Response::bytes_stream() + tokio::select! with abort signal; existing wiremock tests cover parse logic |
spawn_blocking for local OCR means abort doesn't kill CPU inference mid-flight |
Accepted per non-goal #3. Local OCR is ~0.8–2 s; user pressing Shift+Click during that window waits ≤ 2 s max |
Increased binary size from tokio + tokio-rt-multi-thread
|
~1–2 MB added — negligible for a desktop app |
Runtime lifetime on app shutdown: in-flight tasks may outlive GTK main_quit()
|
Runtime::shutdown_timeout(Duration::from_secs(2)) in the ESC/quit path |
Panic in async task ≠ panic in thread — existing catch_unwind wrapper needs re-review |
tokio::task::JoinHandle::await returns Result<_, JoinError>; JoinError::is_panic() is observable. Worker wraps its body in AssertUnwindSafe / tokio equivalent |
-
Cancel trigger scope: any shift-modified user command + plain ESC. Shift+Click, Ctrl+Shift+Click, Shift+Tab (toggle direction), and Shift+H (help dialog) all cancel in-flight OCR. Plain ESC is a dedicated cancel key (no-op if nothing is in flight). All share the same
in_flightslot. Rationale: a shift-modified action is deliberate intent to move on, and a bare ESC is the universal "cancel current thing" key — users should not have to wait or reach for a modifier to stop a long request. -
Cancelled request logging:
[cancelled]line in debug log, no history entry. Confirmed. -
Quit moves to Shift+ESC. Plain ESC was historically the quit key; it's now the cancel key. Quit is now Shift+ESC so a user can cancel a slow OCR without accidentally closing the app.
-
Runtime ownership: owned by
main,Handlecloned into AppState. No static state, noOnceLock. Clone the handle wherever it's needed;tokio::runtime::Handleis explicitly cheap to clone and clone-safe. Runtime is dropped on app exit, flushing in-flight work viaRuntime::shutdown_timeout(Duration::from_secs(2)).
The abort trigger is no longer just Shift+Click / Ctrl+Shift+Click. Factor the cancel logic into a single helper on AppState:
impl AppState {
fn cancel_inflight(&mut self, reason: &'static str) {
if let Some(h) = self.in_flight.take() {
eprintln!("[OCR] cancel: {reason}");
h.abort();
}
self.current_generation = self.current_generation.wrapping_add(1);
}
}Call sites:
-
Shift+Click/Ctrl+Shift+Click—cancel_inflight("new-capture")then spawn. -
Shift+Tab(toggle direction) —cancel_inflight("direction-toggle"). -
Shift+H(help dialog) —cancel_inflight("help-dialog"). - Plain
ESC—cancel_inflight("user-cancel"), no-op if nothing in flight. No quit. -
Shift+ESC— quit path; kills runtime via shutdown and callsgtk::main_quit().
- Design doc + review: done when this file is approved.
- Step 1 (tokio dep + runtime): ~30 min.
- Step 2 (client.rs blocking → async): largest chunk — 3–5 h. Most of the PR.
- Steps 3–4 (worker to async): 1–2 h.
- Steps 5–6 (abort + generation id): 1 h.
- Step 7 (is_loading cleanup): 15 min.
- Test pass + manual verification: 1 h.
Total: ~1 working day for a clean implementation with tests.
- Current worker:
lenzu/src/main.rs:697–1237 - Current client:
lenzu/src/client.rs(entire file) - Relevant config:
lenzu/src/config.rs:120–133(timeout fields) - Related:
docs/technical-design.md— overall architecture
CodeMonkeyNinja/lenzu · MIT
- technical-design
- technical-design.lens-window
- GTK-Migrations
- technical-design.OCR
- technical-design.manga-ocr
- technical-design.sarashina
- technical-design.phase4-predetect
- technical-design.cancel-inflight