From c53801528f1037cd0daef07159188bd8a945c064 Mon Sep 17 00:00:00 2001 From: Rohan Pandula Date: Fri, 14 Aug 2026 09:22:21 -0700 Subject: [PATCH 1/4] Windows live-validation findings round: rescan, engine log, bridge identity, film gate, launcher hint Every change here traces to a finding from the first live Windows hardware validation (WV-2 through WV-5 plus the relaunch race; WV-1 shipped in beta.10): - WV-2: device discovery ran only at engine startup, so a WSL bridge stack that became healthy afterwards left the real scanner invisible until a full app restart. New `scanner.rescan` method: one deliberate re-attempt of the real-backend startup, idempotent, degrading to the sim-only list exactly like startup does, and refused while connected so an active session's backend can never be swapped underneath it. The device bar gains a Rescan button (disabled while connected/busy). PROTOCOL.md documents the method in both copies. - WV-3: a Start-menu launch discards this process's stderr, so every engine diagnostic was lost -- the live forensics had to run on framebuffer screenshots. Engine stderr/error/termination lines now tee to a bounded log (app log dir, 1 MiB, one rotation) that can never break the engine loop. - WV-4: a stale WSL bridge (inherited through a VM clone from a commit window whose driver tree was internally inconsistent) passed bridge-which and bridge-version all session, then refused its first real capture on bundle identity. New `bridge-identity` checker probe: the deployed bridge's two capture-bundle identity files (bundle.py, usb_backend.py) must hash byte-identically to the installed payload's CorrespondingSource copies; red offers the install-bridge-wsl.sh --force redeploy as its fix text. The runbook documents the row (and the runbook-consistency test enforces that documentation). - WV-5: a preview on an empty transport spent minutes in motion-adjacent work and completed with zero frames and no explanation. The engine now probes a fresh status -- the same live path scanner.status uses, never a cached snapshot, so a just-fed roll cannot be falsely refused -- after the preview approval window opens and refuses typed (NO_MEDIA) before roll.preview when film is definitively absent; an undetermined probe proceeds. Placed after the window opens so a rejected overlapping preview still makes zero bridge calls; a refusal retires the window token exactly like a refused roll.preview. - Relaunch race: starting the Hardware Session within seconds of closing the app arms and instantly disarms with no hint (the previous WebView2 teardown still holds its profile; the existing pre-launch process check cannot see it because the old app process is already gone). The launcher now names that likely cause whenever the app exits within ten seconds of starting. Deliberately deferred: WV-6 (the Mac app's manual-placement CTA on REFEED_REQUIRED has no Tauri counterpart) is a parity feature through the motion/binding path, not a defect fix, and stays on the findings ledger. Test changes: mock_bridge's MOCK_BRIDGE_CRASH_ON gains an optional :N occurrence suffix, and its WHILE_PREVIEW_PENDING status hang now tracks a real requested-and-not-established pending flag -- both because the film gate legitimately issues one pre-preview device.status that the old first-occurrence triggers would have eaten. Call-log expectations in the overlap/quarantine/restart tests gained that same leading device.status; their subjects (zero successor bridge calls, fenced reconnects) are unchanged and still asserted. New coverage: film-absent e2e refusal (mutation-proven: disabling the gate fails it with roll.preview reaching the mock), three rescan unit tests, five bridge-identity probe tests, log rotation, and two DeviceBar rescan tests. Suites: engine 25 green suites in each copy (primary and mirror); Tauri crate 85; frontend 61 files, 440 passed, 6 skipped; vendor-sync gate green with the engine-pair fingerprint re-pinned for the identical two-sided edits. --- app/ScanStudio/engine/src/bin/mock_bridge.rs | 35 ++- app/ScanStudio/engine/src/real_backend.rs | 33 +++ app/ScanStudio/engine/src/server.rs | 149 ++++++++++- .../engine/tests/end_to_end_real_backend.rs | 66 ++++- .../engine/tests/real_backend_mapping.rs | 8 +- app/ScanStudio/protocol/PROTOCOL.md | 16 ++ ports/tauri/app/src-tauri/src/engine.rs | 96 +++++++- ports/tauri/app/src-tauri/src/lib.rs | 9 + ports/tauri/app/src-tauri/src/wsl/checker.rs | 233 +++++++++++++++++- ports/tauri/app/src/session/store/session.ts | 12 + ports/tauri/app/src/views/DeviceBar.tsx | 28 +++ .../src/views/__tests__/DeviceBar.test.tsx | 38 +++ .../Start-ScanStudio-Hardware-Session.ps1 | 11 + .../tauri/runbooks/WINDOWS-LIVE-VALIDATION.md | 20 +- .../vendor/engine/src/bin/mock_bridge.rs | 35 ++- ports/tauri/vendor/engine/src/real_backend.rs | 33 +++ ports/tauri/vendor/engine/src/server.rs | 149 ++++++++++- .../engine/tests/end_to_end_real_backend.rs | 66 ++++- .../engine/tests/real_backend_mapping.rs | 8 +- ports/tauri/vendor/protocol/PROTOCOL.md | 16 ++ scripts/check_ports_vendor_sync.sh | 2 +- 21 files changed, 1001 insertions(+), 62 deletions(-) diff --git a/app/ScanStudio/engine/src/bin/mock_bridge.rs b/app/ScanStudio/engine/src/bin/mock_bridge.rs index 73a00f6..a5f48bc 100644 --- a/app/ScanStudio/engine/src/bin/mock_bridge.rs +++ b/app/ScanStudio/engine/src/bin/mock_bridge.rs @@ -70,6 +70,13 @@ struct MockState { /// its terminal success event. These atomics are shared with that worker /// so a pending preview cannot masquerade as completed. preview_established: Arc, + /// True from `roll.preview` acceptance until `device.close` -- the + /// "a preview has been requested this session" half of the + /// WHILE_PREVIEW_PENDING hang seam (pending = requested and not yet + /// established). The engine's pre-preview film probe issues a + /// legitimate `device.status` BEFORE any preview; that probe must + /// never fall into the pending-status hang. + preview_requested: Arc, preview_slot_count: Arc, } @@ -93,9 +100,21 @@ fn main() { let version_mismatch = std::env::var("MOCK_BRIDGE_VERSION_MISMATCH") .map(|v| !v.is_empty()) .unwrap_or(false); - let crash_on = std::env::var("MOCK_BRIDGE_CRASH_ON") + // `method` or `method:N` -- crash on the Nth occurrence of that method + // (default 1). The engine's pre-preview film probe added a legitimate + // early `device.status`, so tests that target a LATER status read (the + // post-preview terminal refresh) name the occurrence explicitly. + let crash_on: Option<(String, u32)> = std::env::var("MOCK_BRIDGE_CRASH_ON") .ok() - .filter(|v| !v.is_empty()); + .filter(|v| !v.is_empty()) + .map(|v| match v.split_once(':') { + Some((method, n)) => ( + method.to_string(), + n.parse::().ok().filter(|n| *n >= 1).unwrap_or(1), + ), + None => (v, 1), + }); + let crash_on_seen = AtomicU32::new(0); // Scan-path silence-watchdog test double: when set, scan.start still accepts // normally (the bridge process and its main dispatch loop stay fully // alive and responsive to every other request) but the job's worker @@ -351,6 +370,7 @@ fn main() { status_hang_active: false, call_log_path, preview_established: Arc::new(AtomicBool::new(false)), + preview_requested: Arc::new(AtomicBool::new(false)), preview_slot_count: Arc::new(AtomicU32::new(0)), }; let mut hello_received = false; @@ -383,8 +403,12 @@ fn main() { // Simulated hard crash: checked first, before any other handling // of this request (including the hello gate below), so it fires // regardless of which method triggers it. - if crash_on.as_deref() == Some(request.method.as_str()) { - std::process::exit(1); + if let Some((crash_method, crash_occurrence)) = &crash_on { + if crash_method == request.method.as_str() + && crash_on_seen.fetch_add(1, Ordering::SeqCst) + 1 == *crash_occurrence + { + std::process::exit(1); + } } // 10-06: once armed (inside the "scan.start" arm below), @@ -395,6 +419,7 @@ fn main() { if request.method == "device.status" && (state.status_hang_active || (hang_status_while_preview_pending + && state.preview_requested.load(Ordering::Acquire) && !state.preview_established.load(Ordering::Acquire))) { if let Some(trigger_path) = exit_trigger_on_hung_status.clone() { @@ -577,6 +602,7 @@ fn handle_request( require_open(state)?; state.device_open = false; state.preview_established.store(false, Ordering::Release); + state.preview_requested.store(false, Ordering::Release); state.preview_slot_count.store(0, Ordering::Release); let status = current_status(state, false); emit_event(tx, "device.status", BridgeDeviceStatusPayload { status }); @@ -591,6 +617,7 @@ fn handle_request( )); } let _params: BridgeRollPreviewParams = parse_params(&request.params)?; + state.preview_requested.store(true, Ordering::Release); state.preview_established.store(false, Ordering::Release); state.preview_slot_count.store(0, Ordering::Release); spawn_roll_preview_worker( diff --git a/app/ScanStudio/engine/src/real_backend.rs b/app/ScanStudio/engine/src/real_backend.rs index 96d7b5c..7e8f04e 100644 --- a/app/ScanStudio/engine/src/real_backend.rs +++ b/app/ScanStudio/engine/src/real_backend.rs @@ -3835,6 +3835,39 @@ impl ScannerBackend for RealLs5000 { let (session_epoch, bridge_generation) = backend.active_session_identity()?; let preview_token = backend.begin_preview_approval_window(session_epoch, bridge_generation)?; + // WV-5 (first live Windows validation): a preview requested on an + // empty transport spent minutes in motion-adjacent work and then + // completed with zero frames and no explanation anywhere. Probe the + // transport fresh -- the same live status path `scanner.status` + // uses, never a cached snapshot, so a just-fed roll can never be + // falsely refused -- and refuse typed before any motion when film + // is definitively absent. An undetermined probe (None) proceeds: + // preview is exactly how presence becomes known on transports that + // cannot report it. Deliberately AFTER the approval window opens so + // a rejected overlapping preview still makes zero bridge calls; a + // refusal here retires the token exactly like a refused + // roll.preview below. + let fresh = backend + .fresh_status_for_session(session_epoch, bridge_generation) + .map_err(|error| { + backend.retire_preview_approval_window( + preview_token, + session_epoch, + bridge_generation, + ); + error + })?; + if fresh.film_present == Some(false) { + backend.retire_preview_approval_window( + preview_token, + session_epoch, + bridge_generation, + ); + return Err(EngineError::new( + ErrorCode::NoMedia, + "no film is loaded (the scanner reports film not present); feed the roll or strip, then acquire a fresh preview", + )); + } // "Reject before accepting": validate/round-trip synchronously, // exactly like every other ScannerBackend method; the actual // preview stream is reported purely through events afterward. diff --git a/app/ScanStudio/engine/src/server.rs b/app/ScanStudio/engine/src/server.rs index 5d38e17..e5cac6c 100644 --- a/app/ScanStudio/engine/src/server.rs +++ b/app/ScanStudio/engine/src/server.rs @@ -163,6 +163,10 @@ struct Backends { sim: Arc, real: Option>, active: Option, + /// The configured bridge command, retained so `scanner.rescan` can + /// re-attempt the real-backend startup that `from_env` performs exactly + /// once. None when `SCANSTUDIO_BRIDGE_CMD` is unset/empty. + bridge_cmd: Option, } impl Backends { @@ -175,25 +179,61 @@ impl Backends { /// (T-09-11). fn from_env() -> Self { let sim = Arc::new(SimulatedLs5000::new()); - let real = match std::env::var("SCANSTUDIO_BRIDGE_CMD") { - Ok(cmd) if !cmd.trim().is_empty() => { - match RealLs5000::new(&cmd, DEFAULT_BRIDGE_TIMEOUT) { - Ok(backend) => Some(Arc::new(backend)), - Err(err) => { - eprintln!( - "scanstudio-engine: SCANSTUDIO_BRIDGE_CMD configured ('{cmd}') but the real backend could not start ({err}); falling back to simulator-only scanner.list" - ); - None - } - } - } + let bridge_cmd = match std::env::var("SCANSTUDIO_BRIDGE_CMD") { + Ok(cmd) if !cmd.trim().is_empty() => Some(cmd), _ => None, }; + let real = match &bridge_cmd { + Some(cmd) => match RealLs5000::new(cmd, DEFAULT_BRIDGE_TIMEOUT) { + Ok(backend) => Some(Arc::new(backend)), + Err(err) => { + eprintln!( + "scanstudio-engine: SCANSTUDIO_BRIDGE_CMD configured ('{cmd}') but the real backend could not start ({err}); falling back to simulator-only scanner.list" + ); + None + } + }, + None => None, + }; Backends { sim, real, active: None, + bridge_cmd, + } + } + + /// `scanner.rescan`: one deliberate re-attempt of the real-backend + /// startup `from_env` performs exactly once. Live-motivated (first + /// Windows hardware validation, WV-2): the engine starts while the WSL + /// bridge stack is still coming up, the single startup attempt times + /// out, and the real device stays invisible until a full app restart. + /// Idempotent by construction -- an already-running real backend and an + /// unconfigured `SCANSTUDIO_BRIDGE_CMD` both return the current list + /// unchanged -- and a failed re-attempt degrades to the same sim-only + /// list as `from_env` (T-09-11), never an error. Refused while any + /// device is connected so an active session's backend can never be + /// replaced underneath it (same invariant as T-09-12). + fn rescan(&mut self) -> Result, EngineError> { + if self.active.is_some() { + return Err(EngineError::new( + ErrorCode::AlreadyConnected, + "disconnect the active device before rescanning for devices", + )); + } + if self.real.is_none() { + if let Some(cmd) = &self.bridge_cmd { + match RealLs5000::new(cmd, DEFAULT_BRIDGE_TIMEOUT) { + Ok(backend) => self.real = Some(Arc::new(backend)), + Err(err) => { + eprintln!( + "scanstudio-engine: scanner.rescan could not start the real backend ({err}); the device list stays simulator-only" + ); + } + } + } } + Ok(self.list_devices()) } /// `scanner.list`: the simulator always, plus the real device only if @@ -982,6 +1022,9 @@ fn handle_request( "scanner.list" => to_json(&protocol::ScannerListResult { devices: backends.list_devices(), }), + "scanner.rescan" => to_json(&protocol::ScannerListResult { + devices: backends.rescan()?, + }), "scanner.connect" => { let params: protocol::ConnectParams = parse_params(&request.params)?; let options = params.options.unwrap_or_default(); @@ -2060,12 +2103,53 @@ mod tests { assert_eq!(parsed.frames, None); } + #[test] + fn rescan_without_a_configured_bridge_is_an_idempotent_no_op() { + let mut backends = Backends { + sim: Arc::new(SimulatedLs5000::new()), + real: None, + active: None, + bridge_cmd: None, + }; + let devices = backends.rescan().expect("rescan without a bridge cmd is a no-op"); + assert_eq!(devices.len(), 1, "sim-only list stays sim-only: {devices:#?}"); + assert!(backends.real.is_none()); + } + + #[test] + fn rescan_with_a_broken_bridge_cmd_degrades_to_sim_only_like_startup() { + let mut backends = Backends { + sim: Arc::new(SimulatedLs5000::new()), + real: None, + active: None, + bridge_cmd: Some("/nonexistent-wv2-rescan-bridge-cmd".to_string()), + }; + let devices = backends + .rescan() + .expect("a broken bridge cmd must degrade exactly like from_env, never error"); + assert_eq!(devices.len(), 1, "{devices:#?}"); + assert!(backends.real.is_none()); + } + + #[test] + fn rescan_refuses_while_a_device_is_connected() { + let mut backends = Backends { + sim: Arc::new(SimulatedLs5000::new()), + real: None, + active: Some(ActiveDevice::Sim), + bridge_cmd: None, + }; + let error = backends.rescan().expect_err("rescan must refuse while connected"); + assert_eq!(error.code, ErrorCode::AlreadyConnected); + } + #[test] fn acquire_thumbnails_uses_a_matching_active_project_film_process() { let mut backends = Backends { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2120,6 +2204,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2175,6 +2260,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let request = Request { @@ -2196,6 +2282,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2263,6 +2350,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2306,6 +2394,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2325,6 +2414,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2366,6 +2456,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2424,6 +2515,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2493,6 +2585,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2574,6 +2667,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2617,6 +2711,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2672,6 +2767,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2704,6 +2800,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2746,6 +2843,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); // no project ever opened @@ -2765,6 +2863,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2801,6 +2900,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2854,6 +2954,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2908,6 +3009,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2957,6 +3059,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3053,6 +3156,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3094,6 +3198,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); // no project.create call @@ -3118,6 +3223,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3231,6 +3337,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3367,6 +3474,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3435,6 +3543,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3490,6 +3599,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3553,6 +3663,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3612,6 +3723,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3678,6 +3790,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3786,6 +3899,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3931,6 +4045,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4087,6 +4202,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4116,6 +4232,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4216,6 +4333,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4290,6 +4408,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4347,6 +4466,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let directory = temp_test_dir("all-off-effective-override"); @@ -4403,6 +4523,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4438,6 +4559,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4628,6 +4750,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4670,6 +4793,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4768,6 +4892,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); diff --git a/app/ScanStudio/engine/tests/end_to_end_real_backend.rs b/app/ScanStudio/engine/tests/end_to_end_real_backend.rs index 557ad1c..4fed7d9 100644 --- a/app/ScanStudio/engine/tests/end_to_end_real_backend.rs +++ b/app/ScanStudio/engine/tests/end_to_end_real_backend.rs @@ -338,6 +338,58 @@ fn preview_motion_not_armed_surfaces_a_typed_public_error() { let _ = reader_handle.join(); } +/// WV-5 (first live Windows validation, 2026-08-13): a preview requested on +/// an empty transport spent minutes in motion-adjacent work and completed +/// with zero frames and no explanation anywhere in the UI. The engine now +/// probes a fresh status before opening the preview lane and refuses typed +/// (`NO_MEDIA`) when film is definitively absent; the mock's call log proves +/// `roll.preview` was never sent. A `null` (undetermined) probe still +/// proceeds -- preview is exactly how presence becomes known on transports +/// that cannot report it -- so only the definitive `false` is gated. +#[test] +fn preview_with_film_definitively_absent_refuses_typed_before_any_motion() { + let log_directory = unique_output_destination("film-absent-preview-call-log"); + let log_path = log_directory.join("bridge-calls.log"); + std::fs::write(&log_path, "").expect("create mock bridge call log"); + let log_path_string = log_path.display().to_string(); + let (mut child, mut stdin, rx, reader_handle) = spawn_connected_engine_with_bridge_env( + "e2e-film-absent-preview", + &[ + ("MOCK_BRIDGE_FILM_PRESENT", "false"), + ("MOCK_BRIDGE_CALL_LOG", log_path_string.as_str()), + ], + ); + + std::fs::write(&log_path, "").expect("reset mock bridge call log"); + send( + &mut stdin, + 3, + "scanner.acquireThumbnails", + json!({"operationId": "film-absent-preview"}), + ); + let preview = recv_response_for(&rx, 3, |_| {}); + assert_eq!( + preview["error"]["code"], "NO_MEDIA", + "a definitively empty transport must refuse the preview typed: {preview:#?}" + ); + let calls = read_mock_bridge_calls(&log_path); + assert!( + !calls.iter().any(|call| call == "roll.preview"), + "the refusal must happen before any motion-capable bridge call: {calls:#?}" + ); + assert!( + calls.iter().any(|call| call == "device.status"), + "the gate must have probed a fresh status rather than a cached snapshot: {calls:#?}" + ); + + send(&mut stdin, 4, "engine.shutdown", json!({})); + assert!(recv_response_for(&rx, 4, |_| {}).get("error").is_none()); + let exit = wait_for_exit_bounded(&mut child, Duration::from_secs(10)); + assert!(exit.success(), "engine did not exit 0: {exit:?}"); + let _ = reader_handle.join(); + let _ = std::fs::remove_dir_all(log_directory); +} + /// `roll.approve` is a public, real-device-only acknowledgement of an /// existing preview warning. It must make exactly one non-motion bridge call: /// it does not refresh status, acquire another preview, or start capture. @@ -1058,8 +1110,11 @@ fn overlapping_preview_is_rejected_before_bridge_and_cannot_authorize_approval() ); assert_eq!( read_mock_bridge_calls(&log_path), - vec!["roll.preview"], - "the rejected successor must not issue a second roll.preview bridge call" + // The accepted first preview probes film presence (one + // device.status) and then opens its stream; the rejected successor + // must contribute NOTHING to this log -- not a probe, not a preview. + vec!["device.status", "roll.preview"], + "the rejected successor must not issue any bridge call" ); send( @@ -1075,7 +1130,7 @@ fn overlapping_preview_is_rejected_before_bridge_and_cannot_authorize_approval() ); assert_eq!( read_mock_bridge_calls(&log_path), - vec!["roll.preview"], + vec!["device.status", "roll.preview"], "neither the successor preview nor its approval may reach the bridge" ); @@ -1466,7 +1521,10 @@ fn terminal_scan_status_process_exit_invalidates_once_after_completion() { fn terminal_preview_status_process_exit_emits_correlated_disconnect() { let (mut child, mut stdin, rx, reader_handle) = spawn_connected_engine_with_bridge_env( "e2e-terminal-preview-status-session-loss", - &[("MOCK_BRIDGE_CRASH_ON", "device.status")], + // :2 -- the first device.status is the pre-preview film-presence + // gate; this test's subject is losing the POST-preview terminal + // status refresh. + &[("MOCK_BRIDGE_CRASH_ON", "device.status:2")], ); let operation_id = "terminal-preview-status-session-loss-op"; diff --git a/app/ScanStudio/engine/tests/real_backend_mapping.rs b/app/ScanStudio/engine/tests/real_backend_mapping.rs index e5dba08..a2dafcb 100644 --- a/app/ScanStudio/engine/tests/real_backend_mapping.rs +++ b/app/ScanStudio/engine/tests/real_backend_mapping.rs @@ -527,8 +527,8 @@ fn healthy_preview_timeout_quarantines_successors_until_disconnect_and_reconnect .expect("read mock bridge call log after rejected successor"); assert_eq!( calls_after_successor.lines().collect::>(), - vec!["roll.preview"], - "the quarantined successor must not issue a second roll.preview call" + vec!["device.status", "roll.preview"], + "the quarantined successor must not issue any bridge call" ); let (scan_tx, _scan_rx) = mpsc::channel(); @@ -558,7 +558,7 @@ fn healthy_preview_timeout_quarantines_successors_until_disconnect_and_reconnect .expect("read call log after locally rejected session changes") .lines() .collect::>(), - vec!["roll.preview"], + vec!["device.status", "roll.preview"], "undrained quarantine must reject session changes before bridge traffic" ); @@ -757,7 +757,7 @@ fn restart_during_preview_keeps_old_reader_attached_until_it_detaches() { .expect("read calls while the original bridge still owns the process fence") .lines() .collect::>(), - vec!["roll.preview", "device.status"], + vec!["device.status", "roll.preview", "device.status"], "failed-closed reconnect attempts must not reach the live hung bridge" ); diff --git a/app/ScanStudio/protocol/PROTOCOL.md b/app/ScanStudio/protocol/PROTOCOL.md index 973e019..f15431a 100644 --- a/app/ScanStudio/protocol/PROTOCOL.md +++ b/app/ScanStudio/protocol/PROTOCOL.md @@ -35,6 +35,16 @@ params `{}` → result `{}`; then the engine cancels outstanding simulated work, ### `scanner.list` `{}` → `{devices: [DeviceInfo]}`. Always exactly one simulated device in M1. +### `scanner.rescan` +`{}` → `{devices: [DeviceInfo]}`. One deliberate re-attempt of the real +backend's startup for the case where the engine started before the bridge +stack was ready (the real device then stays invisible to `scanner.list` +until rescan). Idempotent: an already-running real backend and an +unconfigured bridge both return the current list unchanged, and a failed +re-attempt degrades to the simulator-only list rather than erroring. +Error: `ALREADY_CONNECTED` (rescan never replaces a connected session's +backend). + ### `scanner.connect` `{deviceId: string, options?: {timeScale?: number, faultInjection?: "none"|"demo"}}` → `{device: DeviceInfo, status: ScannerStatus}` and emits a `scanner.status` event. `timeScale` (default `1.0`) multiplies every simulated delay — tests use ~`0.01`. Errors: `UNKNOWN_DEVICE`, `ALREADY_CONNECTED`. @@ -50,6 +60,12 @@ params `{}` → result `{}`; then the engine cancels outstanding simulated work, ### `scanner.acquireThumbnails` `{frames?: [u32], filmProcess?: "positive"|"c41ColorNegative"|"bwNegative"|"kodachrome", operationId?: string}` (omitted `frames` = all loaded frames) → immediate ack `{accepted: true, frames: [u32]}`, then one `scanner.thumbnail` event per frame (~80 ms × timeScale apart), then `scanner.thumbnailsComplete`, then a post-preview `scanner.status`. Before a project exists, `filmProcess` selects the material used for preview (omission uses the deterministic C-41 default). With an active project, its persisted `filmProcess` is authoritative: omission or an equal supplied value is accepted, while a different supplied value is rejected with `INVALID_PARAMS`. +On a real device, a definitively empty transport refuses the request typed +(`NO_MEDIA`) before any motion: the engine probes a fresh status first, and +only `filmPresent: false` gates -- an undetermined probe proceeds, since +preview is exactly how presence becomes known on transports that cannot +report it. + `operationId` is an additive asynchronous-correlation token. New ScanStudio clients send a fresh value for each accepted preview request. When present, the engine echoes it unchanged on every event produced by that preview worker: `scanner.thumbnail`, `scanner.thumbnailsFailed` (when applicable), `scanner.thumbnailsComplete`, and the post-preview `scanner.status`. The bridge protocol remains unchanged; the engine adds the token to bridge-derived events. Older callers may omit it, in which case those event payloads omit it too. While a preview is active, clients must fail closed on missing or mismatched tokens: such events cannot add thumbnails, report failure, complete the preview, clear its busy state, or authorize a second request. Generic untagged status events never terminate an active preview. Errors: `NOT_CONNECTED`, `NO_MEDIA`, `SCANNER_BUSY`, `INVALID_PARAMS` (active-project material conflict). diff --git a/ports/tauri/app/src-tauri/src/engine.rs b/ports/tauri/app/src-tauri/src/engine.rs index 2e7974d..30cece5 100644 --- a/ports/tauri/app/src-tauri/src/engine.rs +++ b/ports/tauri/app/src-tauri/src/engine.rs @@ -280,6 +280,53 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box /// `CommandChild`/`CommandEvent` types it returns do not depend on the /// runtime at all, only `Manager::manage`/`AppHandle::state` do, and both /// work identically under a mocked runtime. +/// WV-3 (first live Windows validation, 2026-08-13): the app is normally +/// launched from a Start-menu shortcut, where this process's stderr goes +/// nowhere -- every engine diagnostic that would have root-caused that +/// session's failures in minutes was simply lost. Tee engine +/// stderr/error/termination lines to a bounded log file under the platform +/// app-log directory. Logging must never break the engine loop: every +/// failure in here degrades to the pre-existing eprintln-only behavior. +struct EngineLogSink { + path: std::path::PathBuf, +} + +/// One rotation at 1 MiB (current file renamed to `.log.1`, replacing any +/// previous rotation) bounds worst-case disk use at ~2 MiB. +const ENGINE_LOG_MAX_BYTES: u64 = 1024 * 1024; + +impl EngineLogSink { + fn new(app: &AppHandle) -> Option { + let dir = app.path().app_log_dir().ok()?; + std::fs::create_dir_all(&dir).ok()?; + Some(Self::at(dir.join("scanstudio-engine.log"))) + } + + fn at(path: std::path::PathBuf) -> Self { + EngineLogSink { path } + } + + fn append(&self, line: &str) { + use std::io::Write; + if let Ok(metadata) = std::fs::metadata(&self.path) { + if metadata.len() > ENGINE_LOG_MAX_BYTES { + let _ = std::fs::rename(&self.path, self.path.with_extension("log.1")); + } + } + let epoch_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + { + let _ = writeln!(file, "[{epoch_seconds}] {line}"); + } + } +} + pub fn spawn_engine( app: &AppHandle, command: Command, @@ -298,6 +345,14 @@ pub fn spawn_engine( pending: Mutex::new(HashMap::new()), handshake: handshake_tx, }); + let log_sink = EngineLogSink::new(app); + if let Some(sink) = &log_sink { + sink.append(concat!( + "engine spawned (app v", + env!("CARGO_PKG_VERSION"), + ")" + )); + } let app_handle = app.clone(); tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { @@ -317,13 +372,25 @@ pub fn spawn_engine( ); } CommandEvent::Stderr(bytes) => { - eprintln!("[engine stderr] {}", String::from_utf8_lossy(&bytes)); + let line = format!("[engine stderr] {}", String::from_utf8_lossy(&bytes)); + eprintln!("{line}"); + if let Some(sink) = &log_sink { + sink.append(&line); + } } CommandEvent::Error(err) => { - eprintln!("[engine error] {err}"); + let line = format!("[engine error] {err}"); + eprintln!("{line}"); + if let Some(sink) = &log_sink { + sink.append(&line); + } } CommandEvent::Terminated(payload) => { - eprintln!("[engine terminated] {payload:?}"); + let line = format!("[engine terminated] {payload:?}"); + eprintln!("{line}"); + if let Some(sink) = &log_sink { + sink.append(&line); + } let state = app_handle.state::(); fail_pending_requests(&state); } @@ -492,6 +559,29 @@ pub fn handle_run_event(app_handle: &AppHandle, event: &tauri::RunEvent) { mod tests { use super::*; + #[test] + fn engine_log_sink_appends_and_rotates_once_at_the_size_cap() { + let dir = std::env::temp_dir().join(format!("engine-log-sink-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("scanstudio-engine.log"); + let sink = EngineLogSink::at(path.clone()); + sink.append("first line"); + let first = std::fs::read_to_string(&path).unwrap(); + assert!(first.contains("first line"), "{first:?}"); + assert!(first.trim_start().starts_with('['), "epoch prefix expected: {first:?}"); + + // Push the file over the cap, then append: the oversized file must + // rotate to .log.1 and the fresh file must hold only the new line. + std::fs::write(&path, vec![b'x'; (ENGINE_LOG_MAX_BYTES + 1) as usize]).unwrap(); + sink.append("post-rotation line"); + let rotated = std::fs::read(path.with_extension("log.1")).unwrap(); + assert_eq!(rotated.len() as u64, ENGINE_LOG_MAX_BYTES + 1); + let fresh = std::fs::read_to_string(&path).unwrap(); + assert!(fresh.contains("post-rotation line"), "{fresh:?}"); + assert!(!fresh.contains('x'), "rotation must start a fresh file: {fresh:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn production_engine_spawn_is_unarmed_by_default_on_windows() { assert_eq!( diff --git a/ports/tauri/app/src-tauri/src/lib.rs b/ports/tauri/app/src-tauri/src/lib.rs index 544261a..e10612d 100644 --- a/ports/tauri/app/src-tauri/src/lib.rs +++ b/ports/tauri/app/src-tauri/src/lib.rs @@ -8,10 +8,19 @@ mod wsl; /// `fix_command` strings are display-only copy-paste text. #[tauri::command] fn wsl_run_checks() -> Vec { + // The installed payload's driver identity lives next to the executable + // (CorrespondingSource/...); a resolution failure reports as the + // bridge-identity probe's distinct "installed payload incomplete" Fail + // rather than being silently skipped. + let payload_identity = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|dir| dir.to_path_buf())) + .and_then(|dir| wsl::checker::windows_payload_identity(&dir)); wsl::checker::run_all_probes( &wsl::checker::RealCommandExecutor, cfg!(target_os = "windows"), wsl::bridge_cmd::BRIDGE_ENTRYPOINT, + payload_identity.as_ref(), ) } diff --git a/ports/tauri/app/src-tauri/src/wsl/checker.rs b/ports/tauri/app/src-tauri/src/wsl/checker.rs index 5b67eab..7d0bf32 100644 --- a/ports/tauri/app/src-tauri/src/wsl/checker.rs +++ b/ports/tauri/app/src-tauri/src/wsl/checker.rs @@ -82,10 +82,11 @@ impl CommandExecutor for RealCommandExecutor { } } -pub const PROBE_IDS: [&str; 5] = [ +pub const PROBE_IDS: [&str; 6] = [ "wsl_status", "bridge_which", "bridge_version", + "bridge_identity", "usbipd_attach", "webview2", ]; @@ -94,16 +95,145 @@ pub fn run_all_probes( executor: &dyn CommandExecutor, is_windows: bool, entrypoint: &str, + windows_payload: Option<&BridgeIdentityFiles>, ) -> Vec { vec![ probe_wsl_status(executor, is_windows), probe_bridge_which(executor, is_windows, entrypoint), probe_bridge_version(executor, is_windows, entrypoint), + probe_bridge_identity(executor, is_windows, windows_payload), probe_usbipd_attach(executor, is_windows), probe_webview2(executor, is_windows), ] } +/// The installed payload's driver-identity hashes, resolved by the caller +/// from its own install directory (the two files live in +/// `CorrespondingSource/coolscanpy/.../ls5000_single_pass/`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeIdentityFiles { + pub bundle_sha256: String, + pub usb_backend_sha256: String, +} + +/// Resolves the installed payload's driver-identity hashes, or None when the +/// install directory does not carry them (packaging damage or a dev run). +pub fn windows_payload_identity(install_dir: &std::path::Path) -> Option { + use sha2::{Digest, Sha256}; + let base = install_dir + .join("CorrespondingSource") + .join("coolscanpy") + .join("src") + .join("coolscanpy") + .join("protocol") + .join("ls5000_single_pass"); + let hash_file = |name: &str| -> Option { + let bytes = std::fs::read(base.join(name)).ok()?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + Some(format!("{:x}", hasher.finalize())) + }; + Some(BridgeIdentityFiles { + bundle_sha256: hash_file("bundle.py")?, + usb_backend_sha256: hash_file("usb_backend.py")?, + }) +} + +/// Shell fragment listing the deployed bridge's two driver-identity files. +/// `$HOME` because install-bridge-wsl.sh deploys per-user; quoting keeps the +/// path literal apart from that one expansion. +const DEPLOYED_IDENTITY_SH: &str = concat!( + "sha256sum ", + "\"$HOME/.local/share/scanstudio/wsl-bridge/sources/coolscanpy/src/coolscanpy/protocol/ls5000_single_pass/bundle.py\" ", + "\"$HOME/.local/share/scanstudio/wsl-bridge/sources/coolscanpy/src/coolscanpy/protocol/ls5000_single_pass/usb_backend.py\"" +); + +/// WV-4 (first live Windows validation, 2026-08-13): a WSL bridge deployed +/// days earlier -- inherited through a VM clone, from a commit window whose +/// driver tree was internally inconsistent -- passed `bridge-which` and +/// `bridge-version` all session and then refused the first real capture +/// with a bundle-identity error. Nothing bound the deployed bridge to the +/// installed app's payload. This probe closes that gap: the deployed +/// sources' two capture-bundle identity files must hash byte-identically to +/// the installed CorrespondingSource copies. +fn probe_bridge_identity( + executor: &dyn CommandExecutor, + is_windows: bool, + windows_payload: Option<&BridgeIdentityFiles>, +) -> ProbeResult { + if !is_windows { + return windows_only("bridge-identity"); + } + let redeploy_fix = Some( + "Re-run install-bridge-wsl.sh --force from the ScanStudio install directory (the deployed WSL bridge is not the one this ScanStudio version shipped)" + .to_string(), + ); + let Some(payload) = windows_payload else { + return ProbeResult { + id: "bridge-identity", + status: ProbeStatus::Fail, + detail: "the installed payload is missing its CorrespondingSource driver identity files" + .to_string(), + fix_command: Some("Reinstall ScanStudio (the install directory is incomplete)".to_string()), + }; + }; + let out = executor.run("wsl.exe", &["-d", WSL_DISTRO, "-e", "sh", "-c", DEPLOYED_IDENTITY_SH]); + if !out.success { + return ProbeResult { + id: "bridge-identity", + status: ProbeStatus::Fail, + detail: "deployed bridge sources not found inside WSL".to_string(), + fix_command: redeploy_fix, + }; + } + let deployed_hash_for = |file_name: &str| -> Option { + out.stdout.lines().find_map(|line| { + let mut parts = line.split_whitespace(); + let hash = parts.next()?; + let path = parts.next()?; + path.ends_with(file_name).then(|| hash.to_string()) + }) + }; + let (Some(deployed_bundle), Some(deployed_usb)) = + (deployed_hash_for("/bundle.py"), deployed_hash_for("/usb_backend.py")) + else { + return ProbeResult { + id: "bridge-identity", + status: ProbeStatus::Fail, + detail: format!( + "could not read deployed driver identity from WSL: {}", + out.stdout.trim() + ), + fix_command: redeploy_fix, + }; + }; + let mut mismatched = Vec::new(); + if deployed_bundle != payload.bundle_sha256 { + mismatched.push("bundle.py"); + } + if deployed_usb != payload.usb_backend_sha256 { + mismatched.push("usb_backend.py"); + } + if mismatched.is_empty() { + return ProbeResult { + id: "bridge-identity", + status: ProbeStatus::Ok, + detail: "deployed WSL bridge driver matches the installed payload (bundle.py + usb_backend.py sha256)" + .to_string(), + fix_command: None, + }; + } + ProbeResult { + id: "bridge-identity", + status: ProbeStatus::Fail, + detail: format!( + "deployed WSL bridge driver differs from the installed payload ({})", + mismatched.join(", ") + ), + fix_command: redeploy_fix, + } +} + /// The ONLY place `Unknown` is produced: a non-Windows host cannot run these /// probes, and the honest answer is "this check does not apply here". fn windows_only(id: &'static str) -> ProbeResult { @@ -427,8 +557,8 @@ mod tests { #[test] fn every_probe_is_unknown_windows_only_on_non_windows() { let fake = FakeExecutor::new(HashMap::new()); - let results = run_all_probes(&fake, false, super::super::bridge_cmd::BRIDGE_ENTRYPOINT); - assert_eq!(results.len(), 5); + let results = run_all_probes(&fake, false, super::super::bridge_cmd::BRIDGE_ENTRYPOINT, None); + assert_eq!(results.len(), 6); for r in &results { assert_eq!(r.status, ProbeStatus::Unknown); assert_eq!(r.detail, "windows only"); @@ -664,14 +794,107 @@ mod tests { #[test] fn run_all_probes_returns_probe_ids_in_order() { let fake = FakeExecutor::new(HashMap::new()); - let results = run_all_probes(&fake, true, super::super::bridge_cmd::BRIDGE_ENTRYPOINT); + let results = run_all_probes(&fake, true, super::super::bridge_cmd::BRIDGE_ENTRYPOINT, None); let ids: Vec<&str> = results.iter().map(|r| r.id).collect(); assert_eq!( ids, - vec!["wsl-status", "bridge-which", "bridge-version", "usbipd-attach", "webview2"] + vec![ + "wsl-status", + "bridge-which", + "bridge-version", + "bridge-identity", + "usbipd-attach", + "webview2" + ] ); } + fn identity_fixture() -> BridgeIdentityFiles { + BridgeIdentityFiles { + bundle_sha256: "aa".repeat(32), + usb_backend_sha256: "bb".repeat(32), + } + } + + fn deployed_identity_key() -> (String, Vec) { + key( + "wsl.exe", + &["-d", super::super::bridge_cmd::WSL_DISTRO, "-e", "sh", "-c", DEPLOYED_IDENTITY_SH], + ) + } + + #[test] + fn bridge_identity_matching_hashes_is_ok() { + let identity = identity_fixture(); + let stdout = format!( + "{} /home/u/.local/share/scanstudio/wsl-bridge/sources/coolscanpy/src/coolscanpy/protocol/ls5000_single_pass/bundle.py\n{} /home/u/.local/share/scanstudio/wsl-bridge/sources/coolscanpy/src/coolscanpy/protocol/ls5000_single_pass/usb_backend.py\n", + identity.bundle_sha256, identity.usb_backend_sha256 + ); + let fake = FakeExecutor::new(HashMap::from([(deployed_identity_key(), success_out(&stdout))])); + let result = probe_bridge_identity(&fake, true, Some(&identity)); + assert_eq!(result.status, ProbeStatus::Ok, "{result:#?}"); + assert!(result.fix_command.is_none()); + } + + #[test] + fn bridge_identity_mismatch_names_the_diverging_file_and_offers_redeploy() { + let identity = identity_fixture(); + let stdout = format!( + "{} /home/u/x/bundle.py\n{} /home/u/x/usb_backend.py\n", + identity.bundle_sha256, + "cc".repeat(32) + ); + let fake = FakeExecutor::new(HashMap::from([(deployed_identity_key(), success_out(&stdout))])); + let result = probe_bridge_identity(&fake, true, Some(&identity)); + assert_eq!(result.status, ProbeStatus::Fail); + assert!(result.detail.contains("usb_backend.py"), "{result:#?}"); + assert!(!result.detail.contains("bundle.py, "), "{result:#?}"); + assert!(result.fix_command.as_deref().unwrap_or("").contains("install-bridge-wsl.sh --force")); + } + + #[test] + fn bridge_identity_missing_deployment_fails_with_redeploy_fix() { + let fake = FakeExecutor::new(HashMap::new()); + let identity = identity_fixture(); + let result = probe_bridge_identity(&fake, true, Some(&identity)); + assert_eq!(result.status, ProbeStatus::Fail); + assert!(result.detail.contains("not found inside WSL"), "{result:#?}"); + } + + #[test] + fn bridge_identity_missing_installed_payload_is_a_distinct_failure() { + let fake = FakeExecutor::new(HashMap::new()); + let result = probe_bridge_identity(&fake, true, None); + assert_eq!(result.status, ProbeStatus::Fail); + assert!(result.detail.contains("installed payload"), "{result:#?}"); + assert_eq!(fake.called_args().len(), 0, "must not probe WSL when the payload itself is unreadable"); + } + + #[test] + fn windows_payload_identity_hashes_the_two_driver_files() { + let dir = std::env::temp_dir().join(format!( + "checker-identity-{}", + std::process::id() + )); + let base = dir + .join("CorrespondingSource") + .join("coolscanpy") + .join("src") + .join("coolscanpy") + .join("protocol") + .join("ls5000_single_pass"); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join("bundle.py"), b"bundle-bytes").unwrap(); + std::fs::write(base.join("usb_backend.py"), b"usb-bytes").unwrap(); + let identity = windows_payload_identity(&dir).expect("both files present"); + // sha256 of the exact bytes written above, precomputed. + assert_eq!(identity.bundle_sha256.len(), 64); + assert_ne!(identity.bundle_sha256, identity.usb_backend_sha256); + std::fs::remove_file(base.join("usb_backend.py")).unwrap(); + assert!(windows_payload_identity(&dir).is_none(), "a missing file must resolve to None"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn max_single_read_from_telemetry_all_input_shapes() { assert_eq!( diff --git a/ports/tauri/app/src/session/store/session.ts b/ports/tauri/app/src/session/store/session.ts index 4cf5a4d..8795148 100644 --- a/ports/tauri/app/src/session/store/session.ts +++ b/ports/tauri/app/src/session/store/session.ts @@ -855,6 +855,18 @@ export class SessionStore { }; } + /** Thin forward to scanner.rescan: one deliberate re-attempt of the real + * backend's startup for the case the first live Windows validation hit + * (WV-2) -- the engine started while the WSL bridge stack was still + * coming up, so the real device stayed invisible until a full app + * restart. Returns the refreshed device list; the engine refuses while a + * device is connected. */ + async rescanDevices(): Promise<{ devices: DeviceInfo[] }> { + return (await this.transport.sendRequest("scanner.rescan", {})) as { + devices: DeviceInfo[]; + }; + } + /** Thin forward to scanner.status; refreshes connection.status. */ async refreshStatus(): Promise { const recoveryCandidate = diff --git a/ports/tauri/app/src/views/DeviceBar.tsx b/ports/tauri/app/src/views/DeviceBar.tsx index 526b3ad..6664ac0 100644 --- a/ports/tauri/app/src/views/DeviceBar.tsx +++ b/ports/tauri/app/src/views/DeviceBar.tsx @@ -65,6 +65,7 @@ export default function DeviceBar() { const [connectionPending, setConnectionPending] = useState(null); const connectionPendingRef = useRef(false); const [connectionError, setConnectionError] = useState(null); + const [rescanPending, setRescanPending] = useState(false); useEffect(() => { let cancelled = false; @@ -127,9 +128,36 @@ export default function DeviceBar() { // simulated/real backend discriminator. const isRealBackend = device?.kind === "real"; + // WV-2 (first live Windows validation): device discovery ran only at app + // launch, so a WSL bridge stack that became healthy afterwards left the + // real scanner invisible until a full app restart. Rescan asks the engine + // for one deliberate re-attempt; the engine refuses it while connected, + // so the button is disabled then rather than surfacing that refusal. + const rescan = async (): Promise => { + if (rescanPending || connected || operationBusy) return; + setRescanPending(true); + try { + const result = await sessionStore.rescanDevices(); + setDevices(result.devices); + setConnectionError(null); + } catch (error) { + setConnectionError(connectionErrorOf(error)); + } finally { + setRescanPending(false); + } + }; + return (

Devices

+
    {(devices ?? []).map((listedDevice) => { const isActive = diff --git a/ports/tauri/app/src/views/__tests__/DeviceBar.test.tsx b/ports/tauri/app/src/views/__tests__/DeviceBar.test.tsx index 0e827ee..27635e8 100644 --- a/ports/tauri/app/src/views/__tests__/DeviceBar.test.tsx +++ b/ports/tauri/app/src/views/__tests__/DeviceBar.test.tsx @@ -66,6 +66,44 @@ function scriptedFixture(devices: DeviceInfo[], status?: ScannerStatus): Session } describe("DeviceBar", () => { + it("rescan asks the engine for a fresh device list and renders what arrives (WV-2)", async () => { + // Live Windows finding: discovery ran only at launch, so a WSL bridge + // stack that turned healthy afterwards left the real scanner invisible + // until a full app restart. Rescan replaces the restart. + const handle = createScriptedTransport({ + onRequest: (method) => { + if (method === "scanner.list") return { result: { devices: [SIM_DEVICE] } }; + if (method === "scanner.rescan") { + return { result: { devices: [SIM_DEVICE, REAL_DEVICE] } }; + } + return { result: undefined }; + }, + }); + mocks.sessionStore = new SessionStore(handle.transport); + const user = userEvent.setup(); + render(); + expect(await screen.findByText(SIM_DEVICE.model)).toBeInTheDocument(); + expect(screen.queryByText(REAL_DEVICE.model)).toBeNull(); + + await act(async () => { + await user.click(screen.getByTestId("rescan-devices")); + }); + expect(await screen.findByText(REAL_DEVICE.model)).toBeInTheDocument(); + }); + + it("rescan is disabled while a device is connected (the engine refuses it then)", async () => { + const store = scriptedFixture([SIM_DEVICE], CONNECTED_STATUS); + mocks.sessionStore = store; + const user = userEvent.setup(); + render(); + const connectButton = await screen.findByRole("button", { name: "Connect" }); + expect(screen.getByTestId("rescan-devices")).toBeEnabled(); + await act(async () => { + await user.click(connectButton); + }); + expect(screen.getByTestId("rescan-devices")).toBeDisabled(); + }); + it("renders the model and a badge whose text is traceable to the device's kind field", async () => { mocks.sessionStore = scriptedFixture([SIM_DEVICE]); render(); diff --git a/ports/tauri/packaging/windows/Start-ScanStudio-Hardware-Session.ps1 b/ports/tauri/packaging/windows/Start-ScanStudio-Hardware-Session.ps1 index f89d4cc..f8478f0 100644 --- a/ports/tauri/packaging/windows/Start-ScanStudio-Hardware-Session.ps1 +++ b/ports/tauri/packaging/windows/Start-ScanStudio-Hardware-Session.ps1 @@ -605,11 +605,22 @@ try { Write-Host "ScanStudio hardware session started (PID $($process.Id))." Write-Host 'Keep this window open. The owned WSL latch will be removed when that app process exits.' + $sessionStartedAtUtc = [DateTime]::UtcNow $process.WaitForExit() $sessionExitCode = $process.ExitCode if ($sessionExitCode -ne 0) { Write-SessionError "$MainExecutableName exited with code $sessionExitCode." } + # First live Windows validation: relaunching within a few seconds of + # closing the app made the fresh instance exit immediately (the previous + # instance's WebView2 teardown still holds its profile), and this console + # then showed a fully armed-and-disarmed session with no hint anything + # went wrong. Assert-NoExistingScanStudioProcesses cannot see that state + # -- the old app process is already gone -- so name the likely cause when + # the app lives for only a moment. + if (([DateTime]::UtcNow - $sessionStartedAtUtc).TotalSeconds -lt 10) { + Write-Host 'ScanStudio exited within seconds of starting. If you just closed a previous ScanStudio, its browser runtime may still have been shutting down; wait a few seconds and run this launcher again.' + } } catch { Write-SessionError $_.Exception.Message diff --git a/ports/tauri/runbooks/WINDOWS-LIVE-VALIDATION.md b/ports/tauri/runbooks/WINDOWS-LIVE-VALIDATION.md index 1116dcb..ab7bb09 100644 --- a/ports/tauri/runbooks/WINDOWS-LIVE-VALIDATION.md +++ b/ports/tauri/runbooks/WINDOWS-LIVE-VALIDATION.md @@ -37,15 +37,25 @@ five ids below are the literal id strings the checker rows carry, pinned in entrypoint resolves on PATH inside WSL. 3. confirm the checker row `bridge-version` is green — it verifies the bridge entrypoint launches and exits cleanly (bridge presence/version). -4. confirm the checker row `usbipd-attach` is green — it verifies `usbipd +4. confirm the checker row `bridge-identity` is green — it verifies the + deployed WSL bridge's driver identity files (`bundle.py`, + `usb_backend.py`) hash byte-identically to the installed payload's + CorrespondingSource copies. The first live Windows validation found a + stale bridge (inherited through a VM clone) that stayed green on the + presence/startup probes for an entire session and then refused its first + real capture on bundle identity; this row binds the deployed bridge to + the installed ScanStudio version so that state is visible before any + capture. Red here means: re-run `install-bridge-wsl.sh --force`. +5. confirm the checker row `usbipd-attach` is green — it verifies `usbipd list` shows the LS-5000 (VID:PID `04b0:4002`) attached to WSL. -5. confirm the checker row `webview2` is green — it verifies the WebView2 +6. confirm the checker row `webview2` is green — it verifies the WebView2 runtime is present via the registry probe. -(The same five probe ids are declared in `app/src-tauri/src/wsl/checker.rs` +(The same six probe ids are declared in `app/src-tauri/src/wsl/checker.rs` as the `PROBE_IDS` constant with underscore spellings — `wsl_status`, -`bridge_which`, `bridge_version`, `usbipd_attach`, `webview2` — while the -checker rows themselves carry the hyphenated id strings enumerated above.) +`bridge_which`, `bridge_version`, `bridge_identity`, `usbipd_attach`, +`webview2` — while the checker rows themselves carry the hyphenated id +strings enumerated above.) Pre-flight re-attach check: `usbipd attach` does not survive a reboot, a replug, or a usbipd service restart — only the one-time `usbipd bind` is diff --git a/ports/tauri/vendor/engine/src/bin/mock_bridge.rs b/ports/tauri/vendor/engine/src/bin/mock_bridge.rs index 73a00f6..a5f48bc 100644 --- a/ports/tauri/vendor/engine/src/bin/mock_bridge.rs +++ b/ports/tauri/vendor/engine/src/bin/mock_bridge.rs @@ -70,6 +70,13 @@ struct MockState { /// its terminal success event. These atomics are shared with that worker /// so a pending preview cannot masquerade as completed. preview_established: Arc, + /// True from `roll.preview` acceptance until `device.close` -- the + /// "a preview has been requested this session" half of the + /// WHILE_PREVIEW_PENDING hang seam (pending = requested and not yet + /// established). The engine's pre-preview film probe issues a + /// legitimate `device.status` BEFORE any preview; that probe must + /// never fall into the pending-status hang. + preview_requested: Arc, preview_slot_count: Arc, } @@ -93,9 +100,21 @@ fn main() { let version_mismatch = std::env::var("MOCK_BRIDGE_VERSION_MISMATCH") .map(|v| !v.is_empty()) .unwrap_or(false); - let crash_on = std::env::var("MOCK_BRIDGE_CRASH_ON") + // `method` or `method:N` -- crash on the Nth occurrence of that method + // (default 1). The engine's pre-preview film probe added a legitimate + // early `device.status`, so tests that target a LATER status read (the + // post-preview terminal refresh) name the occurrence explicitly. + let crash_on: Option<(String, u32)> = std::env::var("MOCK_BRIDGE_CRASH_ON") .ok() - .filter(|v| !v.is_empty()); + .filter(|v| !v.is_empty()) + .map(|v| match v.split_once(':') { + Some((method, n)) => ( + method.to_string(), + n.parse::().ok().filter(|n| *n >= 1).unwrap_or(1), + ), + None => (v, 1), + }); + let crash_on_seen = AtomicU32::new(0); // Scan-path silence-watchdog test double: when set, scan.start still accepts // normally (the bridge process and its main dispatch loop stay fully // alive and responsive to every other request) but the job's worker @@ -351,6 +370,7 @@ fn main() { status_hang_active: false, call_log_path, preview_established: Arc::new(AtomicBool::new(false)), + preview_requested: Arc::new(AtomicBool::new(false)), preview_slot_count: Arc::new(AtomicU32::new(0)), }; let mut hello_received = false; @@ -383,8 +403,12 @@ fn main() { // Simulated hard crash: checked first, before any other handling // of this request (including the hello gate below), so it fires // regardless of which method triggers it. - if crash_on.as_deref() == Some(request.method.as_str()) { - std::process::exit(1); + if let Some((crash_method, crash_occurrence)) = &crash_on { + if crash_method == request.method.as_str() + && crash_on_seen.fetch_add(1, Ordering::SeqCst) + 1 == *crash_occurrence + { + std::process::exit(1); + } } // 10-06: once armed (inside the "scan.start" arm below), @@ -395,6 +419,7 @@ fn main() { if request.method == "device.status" && (state.status_hang_active || (hang_status_while_preview_pending + && state.preview_requested.load(Ordering::Acquire) && !state.preview_established.load(Ordering::Acquire))) { if let Some(trigger_path) = exit_trigger_on_hung_status.clone() { @@ -577,6 +602,7 @@ fn handle_request( require_open(state)?; state.device_open = false; state.preview_established.store(false, Ordering::Release); + state.preview_requested.store(false, Ordering::Release); state.preview_slot_count.store(0, Ordering::Release); let status = current_status(state, false); emit_event(tx, "device.status", BridgeDeviceStatusPayload { status }); @@ -591,6 +617,7 @@ fn handle_request( )); } let _params: BridgeRollPreviewParams = parse_params(&request.params)?; + state.preview_requested.store(true, Ordering::Release); state.preview_established.store(false, Ordering::Release); state.preview_slot_count.store(0, Ordering::Release); spawn_roll_preview_worker( diff --git a/ports/tauri/vendor/engine/src/real_backend.rs b/ports/tauri/vendor/engine/src/real_backend.rs index bbae9f4..2601580 100644 --- a/ports/tauri/vendor/engine/src/real_backend.rs +++ b/ports/tauri/vendor/engine/src/real_backend.rs @@ -3915,6 +3915,39 @@ impl ScannerBackend for RealLs5000 { let (session_epoch, bridge_generation) = backend.active_session_identity()?; let preview_token = backend.begin_preview_approval_window(session_epoch, bridge_generation)?; + // WV-5 (first live Windows validation): a preview requested on an + // empty transport spent minutes in motion-adjacent work and then + // completed with zero frames and no explanation anywhere. Probe the + // transport fresh -- the same live status path `scanner.status` + // uses, never a cached snapshot, so a just-fed roll can never be + // falsely refused -- and refuse typed before any motion when film + // is definitively absent. An undetermined probe (None) proceeds: + // preview is exactly how presence becomes known on transports that + // cannot report it. Deliberately AFTER the approval window opens so + // a rejected overlapping preview still makes zero bridge calls; a + // refusal here retires the token exactly like a refused + // roll.preview below. + let fresh = backend + .fresh_status_for_session(session_epoch, bridge_generation) + .map_err(|error| { + backend.retire_preview_approval_window( + preview_token, + session_epoch, + bridge_generation, + ); + error + })?; + if fresh.film_present == Some(false) { + backend.retire_preview_approval_window( + preview_token, + session_epoch, + bridge_generation, + ); + return Err(EngineError::new( + ErrorCode::NoMedia, + "no film is loaded (the scanner reports film not present); feed the roll or strip, then acquire a fresh preview", + )); + } // "Reject before accepting": validate/round-trip synchronously, // exactly like every other ScannerBackend method; the actual // preview stream is reported purely through events afterward. diff --git a/ports/tauri/vendor/engine/src/server.rs b/ports/tauri/vendor/engine/src/server.rs index 5d38e17..e5cac6c 100644 --- a/ports/tauri/vendor/engine/src/server.rs +++ b/ports/tauri/vendor/engine/src/server.rs @@ -163,6 +163,10 @@ struct Backends { sim: Arc, real: Option>, active: Option, + /// The configured bridge command, retained so `scanner.rescan` can + /// re-attempt the real-backend startup that `from_env` performs exactly + /// once. None when `SCANSTUDIO_BRIDGE_CMD` is unset/empty. + bridge_cmd: Option, } impl Backends { @@ -175,25 +179,61 @@ impl Backends { /// (T-09-11). fn from_env() -> Self { let sim = Arc::new(SimulatedLs5000::new()); - let real = match std::env::var("SCANSTUDIO_BRIDGE_CMD") { - Ok(cmd) if !cmd.trim().is_empty() => { - match RealLs5000::new(&cmd, DEFAULT_BRIDGE_TIMEOUT) { - Ok(backend) => Some(Arc::new(backend)), - Err(err) => { - eprintln!( - "scanstudio-engine: SCANSTUDIO_BRIDGE_CMD configured ('{cmd}') but the real backend could not start ({err}); falling back to simulator-only scanner.list" - ); - None - } - } - } + let bridge_cmd = match std::env::var("SCANSTUDIO_BRIDGE_CMD") { + Ok(cmd) if !cmd.trim().is_empty() => Some(cmd), _ => None, }; + let real = match &bridge_cmd { + Some(cmd) => match RealLs5000::new(cmd, DEFAULT_BRIDGE_TIMEOUT) { + Ok(backend) => Some(Arc::new(backend)), + Err(err) => { + eprintln!( + "scanstudio-engine: SCANSTUDIO_BRIDGE_CMD configured ('{cmd}') but the real backend could not start ({err}); falling back to simulator-only scanner.list" + ); + None + } + }, + None => None, + }; Backends { sim, real, active: None, + bridge_cmd, + } + } + + /// `scanner.rescan`: one deliberate re-attempt of the real-backend + /// startup `from_env` performs exactly once. Live-motivated (first + /// Windows hardware validation, WV-2): the engine starts while the WSL + /// bridge stack is still coming up, the single startup attempt times + /// out, and the real device stays invisible until a full app restart. + /// Idempotent by construction -- an already-running real backend and an + /// unconfigured `SCANSTUDIO_BRIDGE_CMD` both return the current list + /// unchanged -- and a failed re-attempt degrades to the same sim-only + /// list as `from_env` (T-09-11), never an error. Refused while any + /// device is connected so an active session's backend can never be + /// replaced underneath it (same invariant as T-09-12). + fn rescan(&mut self) -> Result, EngineError> { + if self.active.is_some() { + return Err(EngineError::new( + ErrorCode::AlreadyConnected, + "disconnect the active device before rescanning for devices", + )); + } + if self.real.is_none() { + if let Some(cmd) = &self.bridge_cmd { + match RealLs5000::new(cmd, DEFAULT_BRIDGE_TIMEOUT) { + Ok(backend) => self.real = Some(Arc::new(backend)), + Err(err) => { + eprintln!( + "scanstudio-engine: scanner.rescan could not start the real backend ({err}); the device list stays simulator-only" + ); + } + } + } } + Ok(self.list_devices()) } /// `scanner.list`: the simulator always, plus the real device only if @@ -982,6 +1022,9 @@ fn handle_request( "scanner.list" => to_json(&protocol::ScannerListResult { devices: backends.list_devices(), }), + "scanner.rescan" => to_json(&protocol::ScannerListResult { + devices: backends.rescan()?, + }), "scanner.connect" => { let params: protocol::ConnectParams = parse_params(&request.params)?; let options = params.options.unwrap_or_default(); @@ -2060,12 +2103,53 @@ mod tests { assert_eq!(parsed.frames, None); } + #[test] + fn rescan_without_a_configured_bridge_is_an_idempotent_no_op() { + let mut backends = Backends { + sim: Arc::new(SimulatedLs5000::new()), + real: None, + active: None, + bridge_cmd: None, + }; + let devices = backends.rescan().expect("rescan without a bridge cmd is a no-op"); + assert_eq!(devices.len(), 1, "sim-only list stays sim-only: {devices:#?}"); + assert!(backends.real.is_none()); + } + + #[test] + fn rescan_with_a_broken_bridge_cmd_degrades_to_sim_only_like_startup() { + let mut backends = Backends { + sim: Arc::new(SimulatedLs5000::new()), + real: None, + active: None, + bridge_cmd: Some("/nonexistent-wv2-rescan-bridge-cmd".to_string()), + }; + let devices = backends + .rescan() + .expect("a broken bridge cmd must degrade exactly like from_env, never error"); + assert_eq!(devices.len(), 1, "{devices:#?}"); + assert!(backends.real.is_none()); + } + + #[test] + fn rescan_refuses_while_a_device_is_connected() { + let mut backends = Backends { + sim: Arc::new(SimulatedLs5000::new()), + real: None, + active: Some(ActiveDevice::Sim), + bridge_cmd: None, + }; + let error = backends.rescan().expect_err("rescan must refuse while connected"); + assert_eq!(error.code, ErrorCode::AlreadyConnected); + } + #[test] fn acquire_thumbnails_uses_a_matching_active_project_film_process() { let mut backends = Backends { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2120,6 +2204,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2175,6 +2260,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let request = Request { @@ -2196,6 +2282,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2263,6 +2350,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2306,6 +2394,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2325,6 +2414,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2366,6 +2456,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2424,6 +2515,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2493,6 +2585,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2574,6 +2667,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2617,6 +2711,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2672,6 +2767,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2704,6 +2800,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2746,6 +2843,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); // no project ever opened @@ -2765,6 +2863,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2801,6 +2900,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2854,6 +2954,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2908,6 +3009,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -2957,6 +3059,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3053,6 +3156,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3094,6 +3198,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); // no project.create call @@ -3118,6 +3223,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3231,6 +3337,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3367,6 +3474,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3435,6 +3543,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3490,6 +3599,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3553,6 +3663,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3612,6 +3723,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3678,6 +3790,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3786,6 +3899,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -3931,6 +4045,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4087,6 +4202,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4116,6 +4232,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4216,6 +4333,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4290,6 +4408,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4347,6 +4466,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let directory = temp_test_dir("all-off-effective-override"); @@ -4403,6 +4523,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4438,6 +4559,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4628,6 +4750,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4670,6 +4793,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); @@ -4768,6 +4892,7 @@ mod tests { sim: Arc::new(SimulatedLs5000::new()), real: None, active: None, + bridge_cmd: None, }; let (tx, _rx) = mpsc::channel(); let mut project_state = ProjectState::default(); diff --git a/ports/tauri/vendor/engine/tests/end_to_end_real_backend.rs b/ports/tauri/vendor/engine/tests/end_to_end_real_backend.rs index 557ad1c..4fed7d9 100644 --- a/ports/tauri/vendor/engine/tests/end_to_end_real_backend.rs +++ b/ports/tauri/vendor/engine/tests/end_to_end_real_backend.rs @@ -338,6 +338,58 @@ fn preview_motion_not_armed_surfaces_a_typed_public_error() { let _ = reader_handle.join(); } +/// WV-5 (first live Windows validation, 2026-08-13): a preview requested on +/// an empty transport spent minutes in motion-adjacent work and completed +/// with zero frames and no explanation anywhere in the UI. The engine now +/// probes a fresh status before opening the preview lane and refuses typed +/// (`NO_MEDIA`) when film is definitively absent; the mock's call log proves +/// `roll.preview` was never sent. A `null` (undetermined) probe still +/// proceeds -- preview is exactly how presence becomes known on transports +/// that cannot report it -- so only the definitive `false` is gated. +#[test] +fn preview_with_film_definitively_absent_refuses_typed_before_any_motion() { + let log_directory = unique_output_destination("film-absent-preview-call-log"); + let log_path = log_directory.join("bridge-calls.log"); + std::fs::write(&log_path, "").expect("create mock bridge call log"); + let log_path_string = log_path.display().to_string(); + let (mut child, mut stdin, rx, reader_handle) = spawn_connected_engine_with_bridge_env( + "e2e-film-absent-preview", + &[ + ("MOCK_BRIDGE_FILM_PRESENT", "false"), + ("MOCK_BRIDGE_CALL_LOG", log_path_string.as_str()), + ], + ); + + std::fs::write(&log_path, "").expect("reset mock bridge call log"); + send( + &mut stdin, + 3, + "scanner.acquireThumbnails", + json!({"operationId": "film-absent-preview"}), + ); + let preview = recv_response_for(&rx, 3, |_| {}); + assert_eq!( + preview["error"]["code"], "NO_MEDIA", + "a definitively empty transport must refuse the preview typed: {preview:#?}" + ); + let calls = read_mock_bridge_calls(&log_path); + assert!( + !calls.iter().any(|call| call == "roll.preview"), + "the refusal must happen before any motion-capable bridge call: {calls:#?}" + ); + assert!( + calls.iter().any(|call| call == "device.status"), + "the gate must have probed a fresh status rather than a cached snapshot: {calls:#?}" + ); + + send(&mut stdin, 4, "engine.shutdown", json!({})); + assert!(recv_response_for(&rx, 4, |_| {}).get("error").is_none()); + let exit = wait_for_exit_bounded(&mut child, Duration::from_secs(10)); + assert!(exit.success(), "engine did not exit 0: {exit:?}"); + let _ = reader_handle.join(); + let _ = std::fs::remove_dir_all(log_directory); +} + /// `roll.approve` is a public, real-device-only acknowledgement of an /// existing preview warning. It must make exactly one non-motion bridge call: /// it does not refresh status, acquire another preview, or start capture. @@ -1058,8 +1110,11 @@ fn overlapping_preview_is_rejected_before_bridge_and_cannot_authorize_approval() ); assert_eq!( read_mock_bridge_calls(&log_path), - vec!["roll.preview"], - "the rejected successor must not issue a second roll.preview bridge call" + // The accepted first preview probes film presence (one + // device.status) and then opens its stream; the rejected successor + // must contribute NOTHING to this log -- not a probe, not a preview. + vec!["device.status", "roll.preview"], + "the rejected successor must not issue any bridge call" ); send( @@ -1075,7 +1130,7 @@ fn overlapping_preview_is_rejected_before_bridge_and_cannot_authorize_approval() ); assert_eq!( read_mock_bridge_calls(&log_path), - vec!["roll.preview"], + vec!["device.status", "roll.preview"], "neither the successor preview nor its approval may reach the bridge" ); @@ -1466,7 +1521,10 @@ fn terminal_scan_status_process_exit_invalidates_once_after_completion() { fn terminal_preview_status_process_exit_emits_correlated_disconnect() { let (mut child, mut stdin, rx, reader_handle) = spawn_connected_engine_with_bridge_env( "e2e-terminal-preview-status-session-loss", - &[("MOCK_BRIDGE_CRASH_ON", "device.status")], + // :2 -- the first device.status is the pre-preview film-presence + // gate; this test's subject is losing the POST-preview terminal + // status refresh. + &[("MOCK_BRIDGE_CRASH_ON", "device.status:2")], ); let operation_id = "terminal-preview-status-session-loss-op"; diff --git a/ports/tauri/vendor/engine/tests/real_backend_mapping.rs b/ports/tauri/vendor/engine/tests/real_backend_mapping.rs index e5dba08..a2dafcb 100644 --- a/ports/tauri/vendor/engine/tests/real_backend_mapping.rs +++ b/ports/tauri/vendor/engine/tests/real_backend_mapping.rs @@ -527,8 +527,8 @@ fn healthy_preview_timeout_quarantines_successors_until_disconnect_and_reconnect .expect("read mock bridge call log after rejected successor"); assert_eq!( calls_after_successor.lines().collect::>(), - vec!["roll.preview"], - "the quarantined successor must not issue a second roll.preview call" + vec!["device.status", "roll.preview"], + "the quarantined successor must not issue any bridge call" ); let (scan_tx, _scan_rx) = mpsc::channel(); @@ -558,7 +558,7 @@ fn healthy_preview_timeout_quarantines_successors_until_disconnect_and_reconnect .expect("read call log after locally rejected session changes") .lines() .collect::>(), - vec!["roll.preview"], + vec!["device.status", "roll.preview"], "undrained quarantine must reject session changes before bridge traffic" ); @@ -757,7 +757,7 @@ fn restart_during_preview_keeps_old_reader_attached_until_it_detaches() { .expect("read calls while the original bridge still owns the process fence") .lines() .collect::>(), - vec!["roll.preview", "device.status"], + vec!["device.status", "roll.preview", "device.status"], "failed-closed reconnect attempts must not reach the live hung bridge" ); diff --git a/ports/tauri/vendor/protocol/PROTOCOL.md b/ports/tauri/vendor/protocol/PROTOCOL.md index 973e019..f15431a 100644 --- a/ports/tauri/vendor/protocol/PROTOCOL.md +++ b/ports/tauri/vendor/protocol/PROTOCOL.md @@ -35,6 +35,16 @@ params `{}` → result `{}`; then the engine cancels outstanding simulated work, ### `scanner.list` `{}` → `{devices: [DeviceInfo]}`. Always exactly one simulated device in M1. +### `scanner.rescan` +`{}` → `{devices: [DeviceInfo]}`. One deliberate re-attempt of the real +backend's startup for the case where the engine started before the bridge +stack was ready (the real device then stays invisible to `scanner.list` +until rescan). Idempotent: an already-running real backend and an +unconfigured bridge both return the current list unchanged, and a failed +re-attempt degrades to the simulator-only list rather than erroring. +Error: `ALREADY_CONNECTED` (rescan never replaces a connected session's +backend). + ### `scanner.connect` `{deviceId: string, options?: {timeScale?: number, faultInjection?: "none"|"demo"}}` → `{device: DeviceInfo, status: ScannerStatus}` and emits a `scanner.status` event. `timeScale` (default `1.0`) multiplies every simulated delay — tests use ~`0.01`. Errors: `UNKNOWN_DEVICE`, `ALREADY_CONNECTED`. @@ -50,6 +60,12 @@ params `{}` → result `{}`; then the engine cancels outstanding simulated work, ### `scanner.acquireThumbnails` `{frames?: [u32], filmProcess?: "positive"|"c41ColorNegative"|"bwNegative"|"kodachrome", operationId?: string}` (omitted `frames` = all loaded frames) → immediate ack `{accepted: true, frames: [u32]}`, then one `scanner.thumbnail` event per frame (~80 ms × timeScale apart), then `scanner.thumbnailsComplete`, then a post-preview `scanner.status`. Before a project exists, `filmProcess` selects the material used for preview (omission uses the deterministic C-41 default). With an active project, its persisted `filmProcess` is authoritative: omission or an equal supplied value is accepted, while a different supplied value is rejected with `INVALID_PARAMS`. +On a real device, a definitively empty transport refuses the request typed +(`NO_MEDIA`) before any motion: the engine probes a fresh status first, and +only `filmPresent: false` gates -- an undetermined probe proceeds, since +preview is exactly how presence becomes known on transports that cannot +report it. + `operationId` is an additive asynchronous-correlation token. New ScanStudio clients send a fresh value for each accepted preview request. When present, the engine echoes it unchanged on every event produced by that preview worker: `scanner.thumbnail`, `scanner.thumbnailsFailed` (when applicable), `scanner.thumbnailsComplete`, and the post-preview `scanner.status`. The bridge protocol remains unchanged; the engine adds the token to bridge-derived events. Older callers may omit it, in which case those event payloads omit it too. While a preview is active, clients must fail closed on missing or mismatched tokens: such events cannot add thumbnails, report failure, complete the preview, clear its busy state, or authorize a second request. Generic untagged status events never terminate an active preview. Errors: `NOT_CONNECTED`, `NO_MEDIA`, `SCANNER_BUSY`, `INVALID_PARAMS` (active-project material conflict). diff --git a/scripts/check_ports_vendor_sync.sh b/scripts/check_ports_vendor_sync.sh index fc4b018..1b6edb8 100755 --- a/scripts/check_ports_vendor_sync.sh +++ b/scripts/check_ports_vendor_sync.sh @@ -296,7 +296,7 @@ EOF check_pair "protocol" "app/ScanStudio/protocol" "ports/tauri/vendor/protocol" "55577442d8b6a23ddcd3cc191ebf41a8258004047bc075511a241fa72adb0b65" <<'EOF' EOF -check_pair "engine" "app/ScanStudio/engine" "ports/tauri/vendor/engine" "e2f134fb6885cf94d3f972732367e234f0c6373d15d813d49fac85c77cf7e816" <<'EOF' +check_pair "engine" "app/ScanStudio/engine" "ports/tauri/vendor/engine" "82fad320653bed2120d7ea296d7352a47b1c52f197d0cd6588d1aa20e42e285a" <<'EOF' Files app/ScanStudio/engine/Cargo.lock and ports/tauri/vendor/engine/Cargo.lock differ Files app/ScanStudio/engine/Cargo.toml and ports/tauri/vendor/engine/Cargo.toml differ Files app/ScanStudio/engine/src/evidence_package.rs and ports/tauri/vendor/engine/src/evidence_package.rs differ From 462897d1b38d1a5ca27f667fba0236874fac299d Mon Sep 17 00:00:00 2001 From: Rohan Pandula Date: Fri, 14 Aug 2026 09:51:03 -0700 Subject: [PATCH 2/4] WV round 2: probe deadline, complete bridge-identity check, rescan interlock, sink-before-spawn Adversarial review of the first commit surfaced one blocker and six required fixes; all are addressed here: - The film-presence probe now runs under its own 30-second deadline (PREVIEW_FILM_PROBE_DEADLINE, the eject-deadline idiom): the probe's status read legitimately waits on the driver's adapter-status settle -- up to ~10s draining a post-feed medium-change attention -- while the generic control-plane timeout is 10s and its expiry restarts the bridge and destroys the session. An operator who feeds film and immediately asks to preview must never lose the session to the gate that exists to help them. fresh_status_for_session gained a with-options variant; the zero-argument wrapper keeps the generic bound for every other caller. - The bridge-identity probe now proves the whole driver, not a sample: the deployed interpreter runs the driver's own verify_capture_bundle(require_python_sources=True) self-check over the copy it actually imports (site-packages, not the staged sources), and the imported pin table (bundle.py) must hash byte-identically to the installed payload's copy -- pin-table equality plus self-consistency binds every pinned component. The deploy path honors XDG_DATA_HOME exactly like install-bridge-wsl.sh does, the payload resolves through the Tauri resource directory with the executable's directory as fallback, and a build without the packaged payload reports an honest Unknown instead of a red "reinstall" instruction. - rescanDevices now runs under connectionChangePending, so the whole session store sees a rescan as busy for its full duration (a rescan can hold the engine's single dispatch thread across a cold bridge start), and the device bar no longer clears an unread connection error when an unrelated rescan succeeds. - The engine log sink exists before the spawn attempt and records a sidecar spawn failure -- the most likely "app dies instantly with no diagnostics" case the log exists for -- and the runbook now names the log file's location. spawn_engine's original doc comment is restored to its function (the sink's doc had displaced it). - The Rescan button carries the shared control styling. Suites re-run green end to end: engine both copies, Tauri crate 85, frontend 61 files / 440 passed / 6 skipped, vendor gate with the final engine-pair fingerprint. --- app/ScanStudio/engine/src/real_backend.rs | 49 +++- ports/tauri/app/src-tauri/src/engine.rs | 52 ++-- ports/tauri/app/src-tauri/src/lib.rs | 24 +- ports/tauri/app/src-tauri/src/wsl/checker.rs | 230 +++++++++--------- ports/tauri/app/src/session/store/session.ts | 27 +- ports/tauri/app/src/views/DeviceBar.tsx | 5 +- .../tauri/runbooks/WINDOWS-LIVE-VALIDATION.md | 27 +- ports/tauri/vendor/engine/src/real_backend.rs | 49 +++- scripts/check_ports_vendor_sync.sh | 2 +- 9 files changed, 298 insertions(+), 167 deletions(-) diff --git a/app/ScanStudio/engine/src/real_backend.rs b/app/ScanStudio/engine/src/real_backend.rs index 7e8f04e..8d6b05a 100644 --- a/app/ScanStudio/engine/src/real_backend.rs +++ b/app/ScanStudio/engine/src/real_backend.rs @@ -84,6 +84,17 @@ const STREAM_SILENCE_DEADLINE: Duration = Duration::from_secs(600); /// existing quarantine and session-ownership teardown are preserved /// unchanged (see `eject`). const EJECT_CALL_DEADLINE: Duration = Duration::from_secs(300); + +/// Deadline for the pre-preview film-presence probe (WV-5 review round 2): +/// that `device.status` waits on mechanics -- the driver's adapter-status +/// settle loop alone may spend ~10s draining a post-feed medium-change +/// attention, plus an adapter-identity read -- while the generic +/// control-plane timeout is 10s and its expiry RESTARTS the bridge and +/// destroys the session (see `should_reject_concurrent_motion`'s warning +/// about exactly this hazard on this method). An operator who feeds film +/// and immediately asks to preview must never lose the session to the +/// probe; a genuinely dead transport still surfaces, just on this bound. +const PREVIEW_FILM_PROBE_DEADLINE: Duration = Duration::from_secs(30); /// Appended to the session-ownership-lost detail when a `device.eject` /// request crossed a broken bridge boundary. The physical fact an operator /// needs is the one the generic transport-failure text cannot carry: the @@ -3298,12 +3309,33 @@ impl RealLs5000 { session_epoch: u64, bridge_generation: u64, ) -> Result { - let status_value = self.call_session_scoped( + self.fresh_status_for_session_with_options( session_epoch, bridge_generation, - "device.status", - serde_json::json!({}), - )?; + SessionCallOptions::default(), + ) + } + + /// `fresh_status_for_session` with a caller-supplied call bound. The + /// pre-preview film probe passes [`PREVIEW_FILM_PROBE_DEADLINE`] because + /// its status read can legitimately wait on the driver's settle loop; + /// everything else keeps the generic control-plane bound via the + /// zero-argument wrapper above. + fn fresh_status_for_session_with_options( + &self, + session_epoch: u64, + bridge_generation: u64, + options: SessionCallOptions<'_>, + ) -> Result { + let status_value = self + .call_session_scoped_detailed( + session_epoch, + bridge_generation, + "device.status", + serde_json::json!({}), + options, + ) + .map_err(SessionCallError::into_engine_error)?; let status: BridgeDeviceStatus = serde_json::from_value(status_value).map_err(|err| { EngineError::new( ErrorCode::Internal, @@ -3848,7 +3880,14 @@ impl ScannerBackend for RealLs5000 { // refusal here retires the token exactly like a refused // roll.preview below. let fresh = backend - .fresh_status_for_session(session_epoch, bridge_generation) + .fresh_status_for_session_with_options( + session_epoch, + bridge_generation, + SessionCallOptions { + deadline: Some(PREVIEW_FILM_PROBE_DEADLINE), + transport_failure_guidance: None, + }, + ) .map_err(|error| { backend.retire_preview_approval_window( preview_token, diff --git a/ports/tauri/app/src-tauri/src/engine.rs b/ports/tauri/app/src-tauri/src/engine.rs index 30cece5..5b9904b 100644 --- a/ports/tauri/app/src-tauri/src/engine.rs +++ b/ports/tauri/app/src-tauri/src/engine.rs @@ -269,17 +269,6 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box spawn_engine(&app.handle().clone(), command) } -/// Spawns `command`, wires its stdout into the shared pending/event dispatch, -/// and starts the `engine.hello` handshake task -- the exact sequence a real -/// connection to the engine needs, regardless of which `Command` produced it -/// (the bundled sidecar in production, or a plain path to a locally built -/// binary in tests). Generic over `R` (rather than pinned to the production -/// `Wry` runtime) so the integration test can drive this identical code path -/// against `tauri::test::mock_builder`'s `MockRuntime` instead of -/// re-implementing spawn/wire/handshake by hand -- `Command::spawn` and the -/// `CommandChild`/`CommandEvent` types it returns do not depend on the -/// runtime at all, only `Manager::manage`/`AppHandle::state` do, and both -/// work identically under a mocked runtime. /// WV-3 (first live Windows validation, 2026-08-13): the app is normally /// launched from a Start-menu shortcut, where this process's stderr goes /// nowhere -- every engine diagnostic that would have root-caused that @@ -327,6 +316,17 @@ impl EngineLogSink { } } +/// Spawns `command`, wires its stdout into the shared pending/event dispatch, +/// and starts the `engine.hello` handshake task -- the exact sequence a real +/// connection to the engine needs, regardless of which `Command` produced it +/// (the bundled sidecar in production, or a plain path to a locally built +/// binary in tests). Generic over `R` (rather than pinned to the production +/// `Wry` runtime) so the integration test can drive this identical code path +/// against `tauri::test::mock_builder`'s `MockRuntime` instead of +/// re-implementing spawn/wire/handshake by hand -- `Command::spawn` and the +/// `CommandChild`/`CommandEvent` types it returns do not depend on the +/// runtime at all, only `Manager::manage`/`AppHandle::state` do, and both +/// work identically under a mocked runtime. pub fn spawn_engine( app: &AppHandle, command: Command, @@ -337,22 +337,34 @@ pub fn spawn_engine( if app.try_state::().is_none() { app.manage(crate::preview::PreviewAccess::default()); } - let (mut rx, child) = command.spawn()?; - let (handshake_tx, _handshake_rx) = watch::channel(HandshakeState::Pending); - app.manage(EngineHandle { - child: Mutex::new(Some(child)), - next_id: AtomicU64::new(1), - pending: Mutex::new(HashMap::new()), - handshake: handshake_tx, - }); + // The sink exists BEFORE the spawn attempt (review round 2): a sidecar + // that cannot start at all is the most likely "app dies instantly with + // no diagnostics" case WV-3 is about, and its error must reach the file + // too, not just the discarded stderr. let log_sink = EngineLogSink::new(app); if let Some(sink) = &log_sink { sink.append(concat!( - "engine spawned (app v", + "engine spawning (app v", env!("CARGO_PKG_VERSION"), ")" )); } + let (mut rx, child) = match command.spawn() { + Ok(spawned) => spawned, + Err(error) => { + if let Some(sink) = &log_sink { + sink.append(&format!("[engine spawn failed] {error}")); + } + return Err(error.into()); + } + }; + let (handshake_tx, _handshake_rx) = watch::channel(HandshakeState::Pending); + app.manage(EngineHandle { + child: Mutex::new(Some(child)), + next_id: AtomicU64::new(1), + pending: Mutex::new(HashMap::new()), + handshake: handshake_tx, + }); let app_handle = app.clone(); tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { diff --git a/ports/tauri/app/src-tauri/src/lib.rs b/ports/tauri/app/src-tauri/src/lib.rs index e10612d..38beb9c 100644 --- a/ports/tauri/app/src-tauri/src/lib.rs +++ b/ports/tauri/app/src-tauri/src/lib.rs @@ -7,15 +7,25 @@ mod wsl; /// read-only setup probes. Never automated install/elevate — the returned /// `fix_command` strings are display-only copy-paste text. #[tauri::command] -fn wsl_run_checks() -> Vec { - // The installed payload's driver identity lives next to the executable - // (CorrespondingSource/...); a resolution failure reports as the - // bridge-identity probe's distinct "installed payload incomplete" Fail - // rather than being silently skipped. - let payload_identity = std::env::current_exe() +fn wsl_run_checks(app: tauri::AppHandle) -> Vec { + // The installed payload's driver identity: the NSIS install places + // CorrespondingSource/ next to the executable, and Tauri's resource + // directory is the layout-portable answer -- try both, first hit wins. + // A build without the payload (dev, portable) resolves to None and the + // bridge-identity probe reports honest Unknown, never a red + // "reinstall" instruction. + use tauri::Manager; + let mut candidates: Vec = Vec::new(); + if let Ok(dir) = app.path().resource_dir() { + candidates.push(dir); + } + if let Some(dir) = std::env::current_exe() .ok() .and_then(|exe| exe.parent().map(|dir| dir.to_path_buf())) - .and_then(|dir| wsl::checker::windows_payload_identity(&dir)); + { + candidates.push(dir); + } + let payload_identity = wsl::checker::windows_payload_identity(&candidates); wsl::checker::run_all_probes( &wsl::checker::RealCommandExecutor, cfg!(target_os = "windows"), diff --git a/ports/tauri/app/src-tauri/src/wsl/checker.rs b/ports/tauri/app/src-tauri/src/wsl/checker.rs index 7d0bf32..4c4d25a 100644 --- a/ports/tauri/app/src-tauri/src/wsl/checker.rs +++ b/ports/tauri/app/src-tauri/src/wsl/checker.rs @@ -95,7 +95,7 @@ pub fn run_all_probes( executor: &dyn CommandExecutor, is_windows: bool, entrypoint: &str, - windows_payload: Option<&BridgeIdentityFiles>, + windows_payload: Option<&BridgePayloadIdentity>, ) -> Vec { vec![ probe_wsl_status(executor, is_windows), @@ -107,59 +107,87 @@ pub fn run_all_probes( ] } -/// The installed payload's driver-identity hashes, resolved by the caller -/// from its own install directory (the two files live in -/// `CorrespondingSource/coolscanpy/.../ls5000_single_pass/`). +/// The installed payload's driver identity: the sha256 of its +/// `CorrespondingSource` copy of the driver's `bundle.py`. That one file +/// carries the pin table for every capture component, so equality of +/// `bundle.py` plus the deployed side's own `verify_capture_bundle` +/// self-check together bind the entire deployed driver tree to the +/// installed payload -- not just a sample of files. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct BridgeIdentityFiles { +pub struct BridgePayloadIdentity { pub bundle_sha256: String, - pub usb_backend_sha256: String, } -/// Resolves the installed payload's driver-identity hashes, or None when the -/// install directory does not carry them (packaging damage or a dev run). -pub fn windows_payload_identity(install_dir: &std::path::Path) -> Option { +/// Resolves the installed payload's driver identity from the first candidate +/// directory that carries it, or None when no candidate does (a dev or +/// portable run without the packaged payload -- reported as Unknown, never +/// as a red instruction to reinstall). +pub fn windows_payload_identity( + candidate_install_dirs: &[std::path::PathBuf], +) -> Option { use sha2::{Digest, Sha256}; - let base = install_dir - .join("CorrespondingSource") - .join("coolscanpy") - .join("src") - .join("coolscanpy") - .join("protocol") - .join("ls5000_single_pass"); - let hash_file = |name: &str| -> Option { - let bytes = std::fs::read(base.join(name)).ok()?; + candidate_install_dirs.iter().find_map(|dir| { + let bundle_path = dir + .join("CorrespondingSource") + .join("coolscanpy") + .join("src") + .join("coolscanpy") + .join("protocol") + .join("ls5000_single_pass") + .join("bundle.py"); + let bytes = std::fs::read(bundle_path).ok()?; let mut hasher = Sha256::new(); hasher.update(&bytes); - Some(format!("{:x}", hasher.finalize())) - }; - Some(BridgeIdentityFiles { - bundle_sha256: hash_file("bundle.py")?, - usb_backend_sha256: hash_file("usb_backend.py")?, + Some(BridgePayloadIdentity { + bundle_sha256: format!("{:x}", hasher.finalize()), + }) }) } -/// Shell fragment listing the deployed bridge's two driver-identity files. -/// `$HOME` because install-bridge-wsl.sh deploys per-user; quoting keeps the -/// path literal apart from that one expansion. +/// Runs the DEPLOYED bridge interpreter itself (honoring `XDG_DATA_HOME`, +/// exactly like install-bridge-wsl.sh's own layout choice): line 1 prints +/// the sha256 of the `bundle.py` the interpreter actually imports (the +/// site-packages copy -- the code that runs, not the staged sources), and +/// line 2 runs `verify_capture_bundle(require_python_sources=True)`, the +/// installer's own all-components self-check, which raises (non-zero exit, +/// reason on stderr) on any pinned-component mismatch. const DEPLOYED_IDENTITY_SH: &str = concat!( - "sha256sum ", - "\"$HOME/.local/share/scanstudio/wsl-bridge/sources/coolscanpy/src/coolscanpy/protocol/ls5000_single_pass/bundle.py\" ", - "\"$HOME/.local/share/scanstudio/wsl-bridge/sources/coolscanpy/src/coolscanpy/protocol/ls5000_single_pass/usb_backend.py\"" + "PY=\"${XDG_DATA_HOME:-$HOME/.local/share}/scanstudio/wsl-bridge/python/bin/python3.13\"; ", + "\"$PY\" -I -c \"", + "import hashlib; ", + "from coolscanpy.protocol.ls5000_single_pass import bundle; ", + "print(hashlib.sha256(open(bundle.__file__,'rb').read()).hexdigest()); ", + "print(bundle.verify_capture_bundle(require_python_sources=True))", + "\"" ); +/// The ONLY place `Unknown` is produced for host reasons: a non-Windows +/// host cannot run these probes, and the honest answer is "this check does +/// not apply here". (`bridge-identity` also reports Unknown for a build +/// without the packaged payload -- same honesty, different cause.) +fn windows_only(id: &'static str) -> ProbeResult { + ProbeResult { + id, + status: ProbeStatus::Unknown, + detail: "windows only".to_string(), + fix_command: None, + } +} + /// WV-4 (first live Windows validation, 2026-08-13): a WSL bridge deployed /// days earlier -- inherited through a VM clone, from a commit window whose /// driver tree was internally inconsistent -- passed `bridge-which` and /// `bridge-version` all session and then refused the first real capture /// with a bundle-identity error. Nothing bound the deployed bridge to the -/// installed app's payload. This probe closes that gap: the deployed -/// sources' two capture-bundle identity files must hash byte-identically to -/// the installed CorrespondingSource copies. +/// installed app's payload. This probe closes that gap completely (review +/// round 2): the deployed interpreter runs the driver's own +/// `verify_capture_bundle` self-check over every pinned component of the +/// copy it actually imports, and the imported `bundle.py` (the pin table +/// itself) must hash byte-identically to the installed payload's copy. fn probe_bridge_identity( executor: &dyn CommandExecutor, is_windows: bool, - windows_payload: Option<&BridgeIdentityFiles>, + windows_payload: Option<&BridgePayloadIdentity>, ) -> ProbeResult { if !is_windows { return windows_only("bridge-identity"); @@ -171,10 +199,10 @@ fn probe_bridge_identity( let Some(payload) = windows_payload else { return ProbeResult { id: "bridge-identity", - status: ProbeStatus::Fail, - detail: "the installed payload is missing its CorrespondingSource driver identity files" + status: ProbeStatus::Unknown, + detail: "this build does not carry the packaged CorrespondingSource payload (dev or portable run); the deployed bridge cannot be bound to it" .to_string(), - fix_command: Some("Reinstall ScanStudio (the install directory is incomplete)".to_string()), + fix_command: None, }; }; let out = executor.run("wsl.exe", &["-d", WSL_DISTRO, "-e", "sh", "-c", DEPLOYED_IDENTITY_SH]); @@ -182,65 +210,39 @@ fn probe_bridge_identity( return ProbeResult { id: "bridge-identity", status: ProbeStatus::Fail, - detail: "deployed bridge sources not found inside WSL".to_string(), + detail: format!( + "deployed bridge failed its capture-bundle self-check or is not deployed: {}", + out.stderr.trim().lines().last().unwrap_or("").trim() + ), fix_command: redeploy_fix, }; } - let deployed_hash_for = |file_name: &str| -> Option { - out.stdout.lines().find_map(|line| { - let mut parts = line.split_whitespace(); - let hash = parts.next()?; - let path = parts.next()?; - path.ends_with(file_name).then(|| hash.to_string()) - }) - }; - let (Some(deployed_bundle), Some(deployed_usb)) = - (deployed_hash_for("/bundle.py"), deployed_hash_for("/usb_backend.py")) - else { + let deployed_bundle_sha = out.stdout.lines().next().map(str::trim).unwrap_or(""); + if deployed_bundle_sha.len() != 64 { return ProbeResult { id: "bridge-identity", status: ProbeStatus::Fail, detail: format!( - "could not read deployed driver identity from WSL: {}", + "could not read the deployed driver identity: {}", out.stdout.trim() ), fix_command: redeploy_fix, }; - }; - let mut mismatched = Vec::new(); - if deployed_bundle != payload.bundle_sha256 { - mismatched.push("bundle.py"); - } - if deployed_usb != payload.usb_backend_sha256 { - mismatched.push("usb_backend.py"); } - if mismatched.is_empty() { + if deployed_bundle_sha != payload.bundle_sha256 { return ProbeResult { id: "bridge-identity", - status: ProbeStatus::Ok, - detail: "deployed WSL bridge driver matches the installed payload (bundle.py + usb_backend.py sha256)" + status: ProbeStatus::Fail, + detail: "deployed WSL bridge driver differs from the installed payload (bundle.py pin table mismatch)" .to_string(), - fix_command: None, + fix_command: redeploy_fix, }; } ProbeResult { id: "bridge-identity", - status: ProbeStatus::Fail, - detail: format!( - "deployed WSL bridge driver differs from the installed payload ({})", - mismatched.join(", ") - ), - fix_command: redeploy_fix, - } -} - -/// The ONLY place `Unknown` is produced: a non-Windows host cannot run these -/// probes, and the honest answer is "this check does not apply here". -fn windows_only(id: &'static str) -> ProbeResult { - ProbeResult { - id, - status: ProbeStatus::Unknown, - detail: "windows only".to_string(), + status: ProbeStatus::Ok, + detail: "deployed WSL bridge passes its capture-bundle self-check and matches the installed payload (all pinned components)" + .to_string(), fix_command: None, } } @@ -809,10 +811,9 @@ mod tests { ); } - fn identity_fixture() -> BridgeIdentityFiles { - BridgeIdentityFiles { + fn identity_fixture() -> BridgePayloadIdentity { + BridgePayloadIdentity { bundle_sha256: "aa".repeat(32), - usb_backend_sha256: "bb".repeat(32), } } @@ -824,12 +825,9 @@ mod tests { } #[test] - fn bridge_identity_matching_hashes_is_ok() { + fn bridge_identity_selfcheck_pass_and_matching_pin_table_is_ok() { let identity = identity_fixture(); - let stdout = format!( - "{} /home/u/.local/share/scanstudio/wsl-bridge/sources/coolscanpy/src/coolscanpy/protocol/ls5000_single_pass/bundle.py\n{} /home/u/.local/share/scanstudio/wsl-bridge/sources/coolscanpy/src/coolscanpy/protocol/ls5000_single_pass/usb_backend.py\n", - identity.bundle_sha256, identity.usb_backend_sha256 - ); + let stdout = format!("{}\nfff{}\n", identity.bundle_sha256, "0".repeat(61)); let fake = FakeExecutor::new(HashMap::from([(deployed_identity_key(), success_out(&stdout))])); let result = probe_bridge_identity(&fake, true, Some(&identity)); assert_eq!(result.status, ProbeStatus::Ok, "{result:#?}"); @@ -837,62 +835,66 @@ mod tests { } #[test] - fn bridge_identity_mismatch_names_the_diverging_file_and_offers_redeploy() { + fn bridge_identity_pin_table_mismatch_offers_redeploy() { let identity = identity_fixture(); - let stdout = format!( - "{} /home/u/x/bundle.py\n{} /home/u/x/usb_backend.py\n", - identity.bundle_sha256, - "cc".repeat(32) - ); + let stdout = format!("{}\nirrelevant\n", "bb".repeat(32)); let fake = FakeExecutor::new(HashMap::from([(deployed_identity_key(), success_out(&stdout))])); let result = probe_bridge_identity(&fake, true, Some(&identity)); assert_eq!(result.status, ProbeStatus::Fail); - assert!(result.detail.contains("usb_backend.py"), "{result:#?}"); - assert!(!result.detail.contains("bundle.py, "), "{result:#?}"); + assert!(result.detail.contains("pin table mismatch"), "{result:#?}"); assert!(result.fix_command.as_deref().unwrap_or("").contains("install-bridge-wsl.sh --force")); } #[test] - fn bridge_identity_missing_deployment_fails_with_redeploy_fix() { - let fake = FakeExecutor::new(HashMap::new()); + fn bridge_identity_failed_selfcheck_surfaces_the_reason_and_redeploy() { let identity = identity_fixture(); + let fake = FakeExecutor::new(HashMap::from([( + deployed_identity_key(), + CommandOutput { + success: false, + stdout: String::new(), + stderr: "coolscanpy...CaptureBundleIntegrityError: capture component worker.py SHA-256 mismatch: expected aa, got bb".to_string(), + }, + )])); let result = probe_bridge_identity(&fake, true, Some(&identity)); assert_eq!(result.status, ProbeStatus::Fail); - assert!(result.detail.contains("not found inside WSL"), "{result:#?}"); + assert!(result.detail.contains("worker.py SHA-256 mismatch"), "{result:#?}"); + assert!(result.fix_command.as_deref().unwrap_or("").contains("install-bridge-wsl.sh --force")); } #[test] - fn bridge_identity_missing_installed_payload_is_a_distinct_failure() { + fn bridge_identity_without_a_packaged_payload_is_unknown_not_red() { let fake = FakeExecutor::new(HashMap::new()); let result = probe_bridge_identity(&fake, true, None); - assert_eq!(result.status, ProbeStatus::Fail); - assert!(result.detail.contains("installed payload"), "{result:#?}"); - assert_eq!(fake.called_args().len(), 0, "must not probe WSL when the payload itself is unreadable"); + assert_eq!(result.status, ProbeStatus::Unknown); + assert!(result.detail.contains("dev or portable"), "{result:#?}"); + assert!(result.fix_command.is_none()); + assert_eq!(fake.called_args().len(), 0, "must not probe WSL without a payload to bind to"); } #[test] - fn windows_payload_identity_hashes_the_two_driver_files() { - let dir = std::env::temp_dir().join(format!( - "checker-identity-{}", - std::process::id() - )); - let base = dir + fn windows_payload_identity_uses_the_first_candidate_that_carries_the_payload() { + let base_dir = std::env::temp_dir().join(format!("checker-identity-{}", std::process::id())); + let empty = base_dir.join("empty"); + let real = base_dir.join("real"); + let bundle_dir = real .join("CorrespondingSource") .join("coolscanpy") .join("src") .join("coolscanpy") .join("protocol") .join("ls5000_single_pass"); - std::fs::create_dir_all(&base).unwrap(); - std::fs::write(base.join("bundle.py"), b"bundle-bytes").unwrap(); - std::fs::write(base.join("usb_backend.py"), b"usb-bytes").unwrap(); - let identity = windows_payload_identity(&dir).expect("both files present"); - // sha256 of the exact bytes written above, precomputed. + std::fs::create_dir_all(&empty).unwrap(); + std::fs::create_dir_all(&bundle_dir).unwrap(); + std::fs::write(bundle_dir.join("bundle.py"), b"pin-table-bytes").unwrap(); + let identity = windows_payload_identity(&[empty.clone(), real.clone()]) + .expect("second candidate carries the payload"); assert_eq!(identity.bundle_sha256.len(), 64); - assert_ne!(identity.bundle_sha256, identity.usb_backend_sha256); - std::fs::remove_file(base.join("usb_backend.py")).unwrap(); - assert!(windows_payload_identity(&dir).is_none(), "a missing file must resolve to None"); - let _ = std::fs::remove_dir_all(&dir); + assert!( + windows_payload_identity(&[empty]).is_none(), + "no candidate with the payload must resolve to None" + ); + let _ = std::fs::remove_dir_all(&base_dir); } #[test] diff --git a/ports/tauri/app/src/session/store/session.ts b/ports/tauri/app/src/session/store/session.ts index 8795148..20db955 100644 --- a/ports/tauri/app/src/session/store/session.ts +++ b/ports/tauri/app/src/session/store/session.ts @@ -855,16 +855,33 @@ export class SessionStore { }; } - /** Thin forward to scanner.rescan: one deliberate re-attempt of the real + /** Forward to scanner.rescan: one deliberate re-attempt of the real * backend's startup for the case the first live Windows validation hit * (WV-2) -- the engine started while the WSL bridge stack was still * coming up, so the real device stayed invisible until a full app * restart. Returns the refreshed device list; the engine refuses while a - * device is connected. */ + * device is connected. Runs under `connectionChangePending` (review + * round 2): a rescan can hold the engine's single dispatch thread for a + * cold bridge start, so every other session mutation must see the store + * as busy for its whole duration, exactly like connect/disconnect. */ async rescanDevices(): Promise<{ devices: DeviceInfo[] }> { - return (await this.transport.sendRequest("scanner.rescan", {})) as { - devices: DeviceInfo[]; - }; + if (sessionOperationBusy(this.#state)) { + throw { + code: "SCANNER_BUSY", + message: "wait for the active operation to finish before rescanning for devices", + recoverable: false, + } satisfies EngineError; + } + this.#state.connectionChangePending = true; + this.#notify(); + try { + return (await this.transport.sendRequest("scanner.rescan", {})) as { + devices: DeviceInfo[]; + }; + } finally { + this.#state.connectionChangePending = false; + this.#notify(); + } } /** Thin forward to scanner.status; refreshes connection.status. */ diff --git a/ports/tauri/app/src/views/DeviceBar.tsx b/ports/tauri/app/src/views/DeviceBar.tsx index 6664ac0..7b39212 100644 --- a/ports/tauri/app/src/views/DeviceBar.tsx +++ b/ports/tauri/app/src/views/DeviceBar.tsx @@ -139,7 +139,9 @@ export default function DeviceBar() { try { const result = await sessionStore.rescanDevices(); setDevices(result.devices); - setConnectionError(null); + // Deliberately does NOT clear connectionError: a rescan succeeding + // says nothing about an earlier connect failure the operator has not + // yet read (review round 2). } catch (error) { setConnectionError(connectionErrorOf(error)); } finally { @@ -152,6 +154,7 @@ export default function DeviceBar() {

    Devices