diff --git a/app/ScanStudio/engine/src/bin/mock_bridge.rs b/app/ScanStudio/engine/src/bin/mock_bridge.rs index 73a00f6..1821905 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,26 @@ 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(), + // A malformed occurrence is a test-authoring bug; failing + // loudly beats silently reinterpreting it as ":1". + n.parse::() + .ok() + .filter(|n| *n >= 1) + .unwrap_or_else(|| panic!("malformed MOCK_BRIDGE_CRASH_ON occurrence: {n:?}")), + ), + 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 +375,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 +408,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 +424,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 +607,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 +622,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..d1542b0 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 @@ -468,20 +479,38 @@ impl BridgeClient { // The handshake is just a normal correlated request — reuse // `call`. Propagate its Err verbatim: a version-mismatch or - // timeout during the handshake must never be swallowed. - let result_value = client.call("bridge.hello", hello_request_params())?; - let result: BridgeHelloResult = serde_json::from_value(result_value) - .map_err(|err| BridgeCallError::Io(format!("malformed bridge.hello result: {err}")))?; - if result.protocol_version != 1 { - return Err(BridgeCallError::BridgeError { - code: "INVALID_PARAMS".to_string(), - message: format!( - "bridge reported protocolVersion {}, expected 1", - result.protocol_version - ), - recoverable: false, - }); - } + // timeout during the handshake must never be swallowed. A child + // that never completed this FIRST handshake cannot have opened the + // scanner, so on any handshake failure it is terminated outright + // (restart()'s own terminate_uninitialized_child policy) instead of + // receiving Drop's established-session leave-alive courtesy -- + // otherwise every failed scanner.rescan during a slow bridge boot + // would orphan another child contending for the same physical + // scanner (WV round 2, second review). + let handshake = (|| -> Result { + let result_value = client.call("bridge.hello", hello_request_params())?; + let result: BridgeHelloResult = serde_json::from_value(result_value).map_err(|err| { + BridgeCallError::Io(format!("malformed bridge.hello result: {err}")) + })?; + if result.protocol_version != 1 { + return Err(BridgeCallError::BridgeError { + code: "INVALID_PARAMS".to_string(), + message: format!( + "bridge reported protocolVersion {}, expected 1", + result.protocol_version + ), + recoverable: false, + }); + } + Ok(result) + })(); + let result = match handshake { + Ok(result) => result, + Err(error) => { + client.terminate_uninitialized_child(); + return Err(error); + } + }; *client.hello_info.lock().unwrap() = result; Ok(client) } @@ -2552,6 +2581,14 @@ impl RealLs5000 { Self::new_with_env(bridge_cmd, request_timeout, &[]) } + /// Whether this backend's bridge child is currently believed alive. + /// `scanner.rescan` consults this so a real backend whose bridge died + /// (WSL restart, bridge crash) can be replaced instead of staying + /// listed-but-unconnectable forever (WV round 2, second review). + pub fn bridge_is_healthy(&self) -> bool { + self.bridge.is_healthy() + } + /// Like [`new`](Self::new), but additionally sets `bridge_env` on the /// spawned bridge subprocess — scoped to that child (and re-applied to /// every child respawned after a proven predecessor exit) via @@ -3298,12 +3335,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, @@ -3835,6 +3893,46 @@ 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_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, + 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..4a7c027 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,73 @@ 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 + .as_ref() + .is_some_and(|real| !real.bridge_is_healthy()) + { + // A real backend whose bridge died would otherwise stay listed + // forever (this field is set-once) while every connect fails; a + // rescan is the operator's explicit ask to re-establish it. + // Dropping the dead client is safe: unhealthy means its child + // provably exited. + self.real = None; + } + 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 +1034,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 +2115,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 +2216,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 +2272,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 +2294,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 +2362,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 +2406,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 +2426,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 +2468,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 +2527,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 +2597,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 +2679,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 +2723,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 +2779,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 +2812,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 +2855,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 +2875,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 +2912,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 +2966,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 +3021,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 +3071,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 +3168,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 +3210,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 +3235,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 +3349,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 +3486,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 +3555,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 +3611,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 +3675,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 +3735,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 +3802,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 +3911,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 +4057,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 +4214,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 +4244,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 +4345,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 +4420,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 +4478,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 +4535,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 +4571,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 +4762,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 +4805,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 +4904,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..28e9d4e 100644 --- a/ports/tauri/app/src-tauri/src/engine.rs +++ b/ports/tauri/app/src-tauri/src/engine.rs @@ -269,6 +269,59 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box spawn_engine(&app.handle().clone(), command) } +/// 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; + let epoch_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + // One preformatted buffer, one write_all: writeln!'s per-fragment + // writes are not atomic under O_APPEND, and overlapping app + // instances (the launcher's own documented relaunch race) would + // interleave MID-line. Rotation uses the projected size so a large + // single entry cannot overshoot the cap by more than itself. + let entry = format!("[{epoch_seconds}] {line}\n"); + if let Ok(metadata) = std::fs::metadata(&self.path) { + if metadata.len() + entry.len() as u64 > ENGINE_LOG_MAX_BYTES { + let _ = std::fs::rename(&self.path, self.path.with_extension("log.1")); + } + } + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + { + let _ = file.write_all(entry.as_bytes()); + } + } +} + /// 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 @@ -290,7 +343,27 @@ pub fn spawn_engine( if app.try_state::().is_none() { app.manage(crate::preview::PreviewAccess::default()); } - let (mut rx, child) = command.spawn()?; + // 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 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)), @@ -317,13 +390,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 +577,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..38beb9c 100644 --- a/ports/tauri/app/src-tauri/src/lib.rs +++ b/ports/tauri/app/src-tauri/src/lib.rs @@ -7,11 +7,30 @@ 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 { +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())) + { + candidates.push(dir); + } + let payload_identity = wsl::checker::windows_payload_identity(&candidates); 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..4c4d25a 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,18 +95,76 @@ pub fn run_all_probes( executor: &dyn CommandExecutor, is_windows: bool, entrypoint: &str, + windows_payload: Option<&BridgePayloadIdentity>, ) -> 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 ONLY place `Unknown` is produced: a non-Windows host cannot run these -/// probes, and the honest answer is "this check does not apply here". +/// 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 BridgePayloadIdentity { + pub bundle_sha256: String, +} + +/// 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}; + 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(BridgePayloadIdentity { + bundle_sha256: format!("{:x}", hasher.finalize()), + }) + }) +} + +/// 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!( + "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, @@ -115,6 +174,79 @@ fn windows_only(id: &'static str) -> ProbeResult { } } +/// 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 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<&BridgePayloadIdentity>, +) -> 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::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: None, + }; + }; + 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: 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_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 the deployed driver identity: {}", + out.stdout.trim() + ), + fix_command: redeploy_fix, + }; + } + if deployed_bundle_sha != payload.bundle_sha256 { + return ProbeResult { + id: "bridge-identity", + status: ProbeStatus::Fail, + detail: "deployed WSL bridge driver differs from the installed payload (bundle.py pin table mismatch)" + .to_string(), + fix_command: redeploy_fix, + }; + } + ProbeResult { + id: "bridge-identity", + 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, + } +} + fn probe_wsl_status(executor: &dyn CommandExecutor, is_windows: bool) -> ProbeResult { if !is_windows { return windows_only("wsl-status"); @@ -427,8 +559,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,12 +796,105 @@ 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() -> BridgePayloadIdentity { + BridgePayloadIdentity { + bundle_sha256: "aa".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_selfcheck_pass_and_matching_pin_table_is_ok() { + let identity = identity_fixture(); + 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:#?}"); + assert!(result.fix_command.is_none()); + } + + #[test] + fn bridge_identity_pin_table_mismatch_offers_redeploy() { + let identity = identity_fixture(); + 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("pin table mismatch"), "{result:#?}"); + assert!(result.fix_command.as_deref().unwrap_or("").contains("install-bridge-wsl.sh --force")); + } + + #[test] + 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("worker.py SHA-256 mismatch"), "{result:#?}"); + assert!(result.fix_command.as_deref().unwrap_or("").contains("install-bridge-wsl.sh --force")); + } + + #[test] + 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::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_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(&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!( + 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 4cf5a4d..20db955 100644 --- a/ports/tauri/app/src/session/store/session.ts +++ b/ports/tauri/app/src/session/store/session.ts @@ -855,6 +855,35 @@ export class SessionStore { }; } + /** 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. 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[] }> { + 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. */ 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..7b39212 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,39 @@ 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); + // 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 { + 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/install_pinned_tauri_tools.py b/ports/tauri/packaging/install_pinned_tauri_tools.py index d98d681..316e66d 100644 --- a/ports/tauri/packaging/install_pinned_tauri_tools.py +++ b/ports/tauri/packaging/install_pinned_tauri_tools.py @@ -115,7 +115,11 @@ "sha256": "5ba143b5db4a87d32d6e7802e033330aae56cbceabe0d1e3ba41948385ad4709", } -WEBVIEW2_GUID = "e4dd9b83-b7e3-4d17-8d7c-e14cdd7c3a51" +# 2026-08-14: Microsoft rotated the fwlink 2124701 delivery GUID mid-day +# (previous: e4dd9b83-b7e3-4d17-8d7c-e14cdd7c3a51); the pin gate failed +# closed on the drift exactly as designed. New artifact re-verified via +# the official fwlink redirect before re-pinning. +WEBVIEW2_GUID = "eb04ea38-69c8-4b86-b65b-fd4c8469ae59" WEBVIEW2_ASSET = { "name": "MicrosoftEdgeWebView2RuntimeInstallerX64.exe", "url": ( @@ -123,8 +127,8 @@ "filestreamingservice/files/" f"{WEBVIEW2_GUID}/MicrosoftEdgeWebView2RuntimeInstallerX64.exe" ), - "size": 209_653_456, - "sha256": "f8d4ab074c22a0cd136434f37c6b34dfb64ebf8a32ce42e03bd8f2a6b51a3892", + "size": 212_668_624, + "sha256": "6ac57a21414742ac1a6a03bf9516a048897317cef04a49967b283093e29c31b7", } WINDOWS_RESERVED_NAMES = { 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/packaging/windows/build-and-verify.ps1 b/ports/tauri/packaging/windows/build-and-verify.ps1 index 2db9a1f..be94a8e 100644 --- a/ports/tauri/packaging/windows/build-and-verify.ps1 +++ b/ports/tauri/packaging/windows/build-and-verify.ps1 @@ -28,9 +28,9 @@ $windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\ $pinnedToolsInstaller = Join-Path $portRoot 'packaging\install_pinned_tauri_tools.py' $cargoTarget = Join-Path $appRoot 'src-tauri\target' $tauriToolsRoot = Join-Path $cargoTarget '.tauri' -$webViewGuid = 'e4dd9b83-b7e3-4d17-8d7c-e14cdd7c3a51' +$webViewGuid = 'eb04ea38-69c8-4b86-b65b-fd4c8469ae59' $webViewFileName = 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe' -$webViewSha256 = 'f8d4ab074c22a0cd136434f37c6b34dfb64ebf8a32ce42e03bd8f2a6b51a3892' +$webViewSha256 = '6ac57a21414742ac1a6a03bf9516a048897317cef04a49967b283093e29c31b7' $nsisPluginSha256 = '5ba143b5db4a87d32d6e7802e033330aae56cbceabe0d1e3ba41948385ad4709' $pinnedWebView = Join-Path $tauriToolsRoot "x64\$webViewGuid\$webViewFileName" $pinnedMakensis = Join-Path $tauriToolsRoot 'NSIS\makensis.exe' diff --git a/ports/tauri/runbooks/WINDOWS-LIVE-VALIDATION.md b/ports/tauri/runbooks/WINDOWS-LIVE-VALIDATION.md index 1116dcb..e2ec596 100644 --- a/ports/tauri/runbooks/WINDOWS-LIVE-VALIDATION.md +++ b/ports/tauri/runbooks/WINDOWS-LIVE-VALIDATION.md @@ -28,7 +28,7 @@ detach --busid `. No driver is swapped or replaced by the WSL lane. ### Checker pre-flight Confirm each row of the app's setup checker is green before continuing. The -five ids below are the literal id strings the checker rows carry, pinned in +six ids below are the literal id strings the checker rows carry, pinned in `app/src-tauri/src/wsl/checker.rs`: 1. confirm the checker row `wsl-status` is green — it verifies @@ -37,15 +37,34 @@ 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 — the deployed + bridge's own interpreter runs the driver's capture-bundle + self-check over every pinned component of the copy it actually imports, + and the imported pin table (`bundle.py`) must hash byte-identically to + the installed payload's CorrespondingSource copy. 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`. A dev or portable run without the + packaged payload reports Unknown, not red. +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.) + +Engine log: the app tees engine diagnostics to +`scanstudio-engine.log` (one rotation to `scanstudio-engine.log.1`) in the +platform application-log directory — on Windows, +`%LOCALAPPDATA%\com.scanstudio.desktop\logs`. When a Start-menu launch +misbehaves with nothing on screen, read that file first. 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..1821905 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,26 @@ 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(), + // A malformed occurrence is a test-authoring bug; failing + // loudly beats silently reinterpreting it as ":1". + n.parse::() + .ok() + .filter(|n| *n >= 1) + .unwrap_or_else(|| panic!("malformed MOCK_BRIDGE_CRASH_ON occurrence: {n:?}")), + ), + 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 +375,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 +408,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 +424,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 +607,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 +622,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..7d6517b 100644 --- a/ports/tauri/vendor/engine/src/real_backend.rs +++ b/ports/tauri/vendor/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 @@ -468,20 +479,38 @@ impl BridgeClient { // The handshake is just a normal correlated request — reuse // `call`. Propagate its Err verbatim: a version-mismatch or - // timeout during the handshake must never be swallowed. - let result_value = client.call("bridge.hello", hello_request_params())?; - let result: BridgeHelloResult = serde_json::from_value(result_value) - .map_err(|err| BridgeCallError::Io(format!("malformed bridge.hello result: {err}")))?; - if result.protocol_version != 1 { - return Err(BridgeCallError::BridgeError { - code: "INVALID_PARAMS".to_string(), - message: format!( - "bridge reported protocolVersion {}, expected 1", - result.protocol_version - ), - recoverable: false, - }); - } + // timeout during the handshake must never be swallowed. A child + // that never completed this FIRST handshake cannot have opened the + // scanner, so on any handshake failure it is terminated outright + // (restart()'s own terminate_uninitialized_child policy) instead of + // receiving Drop's established-session leave-alive courtesy -- + // otherwise every failed scanner.rescan during a slow bridge boot + // would orphan another child contending for the same physical + // scanner (WV round 2, second review). + let handshake = (|| -> Result { + let result_value = client.call("bridge.hello", hello_request_params())?; + let result: BridgeHelloResult = serde_json::from_value(result_value).map_err(|err| { + BridgeCallError::Io(format!("malformed bridge.hello result: {err}")) + })?; + if result.protocol_version != 1 { + return Err(BridgeCallError::BridgeError { + code: "INVALID_PARAMS".to_string(), + message: format!( + "bridge reported protocolVersion {}, expected 1", + result.protocol_version + ), + recoverable: false, + }); + } + Ok(result) + })(); + let result = match handshake { + Ok(result) => result, + Err(error) => { + client.terminate_uninitialized_child(); + return Err(error); + } + }; *client.hello_info.lock().unwrap() = result; Ok(client) } @@ -2623,6 +2652,14 @@ impl RealLs5000 { Self::new_with_env(bridge_cmd, request_timeout, &[]) } + /// Whether this backend's bridge child is currently believed alive. + /// `scanner.rescan` consults this so a real backend whose bridge died + /// (WSL restart, bridge crash) can be replaced instead of staying + /// listed-but-unconnectable forever (WV round 2, second review). + pub fn bridge_is_healthy(&self) -> bool { + self.bridge.is_healthy() + } + /// Like [`new`](Self::new), but additionally sets `bridge_env` on the /// spawned bridge subprocess — scoped to that child (and re-applied to /// every child respawned after a proven predecessor exit) via @@ -3378,12 +3415,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, @@ -3915,6 +3973,46 @@ 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_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, + 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..4a7c027 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,73 @@ 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 + .as_ref() + .is_some_and(|real| !real.bridge_is_healthy()) + { + // A real backend whose bridge died would otherwise stay listed + // forever (this field is set-once) while every connect fails; a + // rescan is the operator's explicit ask to re-establish it. + // Dropping the dead client is safe: unhealthy means its child + // provably exited. + self.real = None; + } + 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 +1034,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 +2115,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 +2216,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 +2272,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 +2294,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 +2362,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 +2406,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 +2426,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 +2468,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 +2527,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 +2597,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 +2679,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 +2723,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 +2779,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 +2812,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 +2855,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 +2875,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 +2912,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 +2966,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 +3021,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 +3071,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 +3168,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 +3210,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 +3235,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 +3349,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 +3486,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 +3555,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 +3611,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 +3675,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 +3735,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 +3802,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 +3911,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 +4057,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 +4214,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 +4244,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 +4345,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 +4420,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 +4478,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 +4535,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 +4571,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 +4762,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 +4805,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 +4904,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..39f5944 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" "18c1dfa51f1e2b0923b3224ad5d4132769e08518639ec0f9954d45846b6784ac" <<'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