flashkraft_gui/core/flash_runner.rs
1//! Flash Subscription - Real-time progress streaming
2//!
3//! ## Architecture
4//!
5//! ```text
6//! Iced async runtime (ThreadPool) blocking OS thread
7//! ──────────────────────────────── ──────────────────
8//! flash_progress() std::thread::spawn
9//! │ │
10//! │ futures::channel::mpsc │
11//! │ ◄─────────────────────────── bridge thread
12//! │ (forwards from std_rx) │
13//! │ run_pipeline(std_tx)
14//! event = rx.next().await │
15//! │ (yields to executor) writes image / verifies
16//! │
17//! FlashProgress → Message → Iced repaint
18//! ```
19//!
20//! ## Why blocking `recv()` was wrong
21//!
22//! The previous implementation called `std::sync::mpsc::Receiver::recv()`
23//! directly inside the `async` stream block. `recv()` is a **blocking**
24//! syscall — it parks the OS thread until a message arrives. Because Iced
25//! drives subscriptions on a `futures::executor::ThreadPool` (not tokio),
26//! blocking that thread starved every other future on the same worker,
27//! including Iced's repaint loop. Progress events were queued correctly but
28//! the UI never re-rendered until the entire pipeline had finished.
29//!
30//! ## Fix
31//!
32//! We now use a **three-actor design**:
33//!
34//! 1. **Pipeline thread** — calls `run_pipeline` with a `std::sync::mpsc::Sender`.
35//! 2. **Bridge thread** — calls `std_rx.recv()` (blocking is fine here because
36//! this thread owns nothing except forwarding) and calls
37//! `futures_tx.try_send()` into a `futures::channel::mpsc` channel.
38//! A tiny `thread::sleep(1 ms)` between iterations keeps CPU usage near zero
39//! while the pipeline is idle between blocks.
40//! 3. **Async stream** — calls `rx.next().await` on the `futures::channel::mpsc`
41//! receiver, which is a proper async future that yields between every message
42//! and lets the Iced executor schedule repaints freely.
43//!
44//! ## Cancellation
45//!
46//! An `Arc<AtomicBool>` cancel token is shared with the pipeline thread.
47//! The pipeline checks it on every 4 MiB write block.
48
49use crate::flash_debug;
50use flashkraft_core::flash_helper::{run_pipeline, FlashEvent};
51use flashkraft_core::FlashUpdate;
52use futures::channel::mpsc as futures_mpsc;
53use futures::StreamExt;
54use iced::stream;
55use iced::Subscription;
56use std::hash::Hash;
57use std::path::PathBuf;
58use std::sync::{
59 atomic::{AtomicBool, Ordering},
60 Arc,
61};
62
63// ---------------------------------------------------------------------------
64// Public types
65// ---------------------------------------------------------------------------
66
67/// Progress event emitted by the flash subscription to the Iced runtime.
68///
69/// This is a type alias for [`flashkraft_core::FlashUpdate`] — the unified
70/// frontend event defined in core so both the GUI and TUI share the same
71/// representation without duplicating the type.
72pub use flashkraft_core::FlashUpdate as FlashProgress;
73
74// ---------------------------------------------------------------------------
75// Subscription data
76// ---------------------------------------------------------------------------
77
78/// All data needed by the flash subscription stream.
79///
80/// Implements [`Hash`] manually so that only the deterministic fields
81/// (`image_path`, `device_path`, `run_id`) contribute to the subscription
82/// identity. `cancel_token` is an `Arc<AtomicBool>` which is intentionally
83/// excluded — its pointer value changes on every allocation and would defeat
84/// subscription deduplication.
85#[derive(Clone)]
86struct FlashSubData {
87 image_path: PathBuf,
88 device_path: PathBuf,
89 cancel_token: Arc<AtomicBool>,
90 run_id: u64,
91}
92
93impl Hash for FlashSubData {
94 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
95 self.image_path.hash(state);
96 self.device_path.hash(state);
97 self.run_id.hash(state);
98 }
99}
100
101// ---------------------------------------------------------------------------
102// Public API
103// ---------------------------------------------------------------------------
104
105/// Create a subscription that streams [`FlashProgress`] events while the
106/// flash operation runs.
107///
108/// `run_id` must be incremented on every new flash attempt so that flashing
109/// the same image to the same device twice always produces a distinct
110/// subscription ID and Iced creates a fresh stream.
111pub fn flash_progress(
112 image_path: PathBuf,
113 device_path: PathBuf,
114 cancel_token: Arc<AtomicBool>,
115 run_id: u64,
116) -> Subscription<FlashProgress> {
117 let data = FlashSubData {
118 image_path,
119 device_path,
120 cancel_token,
121 run_id,
122 };
123
124 Subscription::run_with(data, build_flash_stream)
125}
126
127/// Builder function passed to [`Subscription::run_with`].
128///
129/// Receives a reference to the subscription data, clones what it needs,
130/// and returns an async stream that drives the flash pipeline.
131fn build_flash_stream(
132 data: &FlashSubData,
133) -> impl futures::Stream<Item = FlashProgress> + Send + 'static {
134 let image_path = data.image_path.clone();
135 let device_path = data.device_path.clone();
136 let cancel_token = data.cancel_token.clone();
137
138 stream::channel(64, async move |mut output| {
139 use futures::SinkExt as _;
140
141 // ── Validate inputs ───────────────────────────────────────────────
142 let image_size = match image_path.metadata() {
143 Ok(m) if m.len() == 0 => {
144 let _ = output
145 .send(FlashProgress::Failed("Image file is empty".into()))
146 .await;
147 return std::future::pending().await;
148 }
149 Ok(m) => m.len(),
150 Err(e) => {
151 let _ = output
152 .send(FlashProgress::Failed(format!(
153 "Cannot read image file: {e}"
154 )))
155 .await;
156 return std::future::pending().await;
157 }
158 };
159
160 flash_debug!("flash_progress: image={image_path:?} dev={device_path:?} size={image_size}");
161
162 // ── Channel setup ─────────────────────────────────────────────────
163 //
164 // std channel → bridge thread (blocking recv) → futures channel
165 // ↓
166 // rx.next().await
167 // (yields to executor)
168 let (std_tx, std_rx) = std::sync::mpsc::channel::<FlashEvent>();
169
170 // futures::channel::mpsc is executor-agnostic — next() is a real
171 // async future that yields between every message.
172 let (mut futures_tx, mut futures_rx) = futures_mpsc::channel::<FlashEvent>(64);
173
174 // ── Pipeline thread ───────────────────────────────────────────────
175 let img_str = image_path.to_string_lossy().into_owned();
176 let dev_str = device_path.to_string_lossy().into_owned();
177 let cancel_pipeline = cancel_token.clone();
178
179 std::thread::Builder::new()
180 .name("flashkraft-pipeline".into())
181 .spawn(move || {
182 flash_debug!("flash thread: starting pipeline");
183 run_pipeline(&img_str, &dev_str, std_tx, cancel_pipeline);
184 flash_debug!("flash thread: pipeline returned");
185 })
186 .expect("failed to spawn flash pipeline thread");
187
188 // ── Bridge thread ─────────────────────────────────────────────────
189 //
190 // Sits in a blocking recv() loop — safe because this is its own
191 // dedicated OS thread and it owns no async resources. When a
192 // message arrives it forwards it into the futures channel via
193 // try_send (non-blocking from this thread's perspective).
194 std::thread::Builder::new()
195 .name("flashkraft-bridge".into())
196 .spawn(move || {
197 while let Ok(event) = std_rx.recv() {
198 // try_send returns Err if the receiver was
199 // dropped (subscription cancelled) — exit cleanly.
200 if futures_tx.try_send(event).is_err() {
201 break;
202 }
203 }
204 })
205 .expect("failed to spawn flash bridge thread");
206
207 // ── Async event loop ──────────────────────────────────────────────
208 //
209 // futures_rx.next().await is a genuine async yield point.
210 // The Iced ThreadPool executor is free to run other futures
211 // (repaints, animation ticks, etc.) between every message.
212 loop {
213 match futures_rx.next().await {
214 Some(FlashEvent::Done) => {
215 flash_debug!("flash thread: Done");
216 let _ = output.send(FlashUpdate::Completed).await;
217 break;
218 }
219
220 Some(FlashEvent::Error(e)) => {
221 flash_debug!("flash thread: Error: {e}");
222 let _ = output.send(FlashUpdate::Failed(e)).await;
223 break;
224 }
225
226 Some(core_event) => {
227 let update = FlashUpdate::from(core_event);
228 flash_debug!("flash event: {update:?}");
229 let _ = output.send(update).await;
230 }
231
232 // Channel closed — bridge thread exited (pipeline done or cancelled).
233 None => {
234 flash_debug!("flash channel closed unexpectedly");
235 if cancel_token.load(Ordering::SeqCst) {
236 let _ = output
237 .send(FlashUpdate::Failed(
238 "Flash operation cancelled by user".into(),
239 ))
240 .await;
241 } else {
242 let _ = output
243 .send(FlashUpdate::Failed(
244 "Flash thread terminated unexpectedly".into(),
245 ))
246 .await;
247 }
248 break;
249 }
250 }
251 }
252
253 // Park forever — Iced requires the stream future to never return.
254 std::future::pending().await
255 })
256}
257
258// ---------------------------------------------------------------------------
259// Tests
260// ---------------------------------------------------------------------------
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use std::collections::hash_map::DefaultHasher;
266 use std::hash::Hasher;
267
268 #[test]
269 fn test_flash_progress_clone() {
270 let progress = FlashProgress::Progress {
271 progress: 0.5,
272 bytes_written: 1024,
273 speed_mb_s: 10.0,
274 };
275 let cloned = progress.clone();
276 match cloned {
277 FlashProgress::Progress {
278 progress,
279 bytes_written,
280 speed_mb_s,
281 } => {
282 assert!((progress - 0.5).abs() < f32::EPSILON);
283 assert_eq!(bytes_written, 1024);
284 assert!((speed_mb_s - 10.0).abs() < f32::EPSILON);
285 }
286 _ => panic!("Expected Progress variant"),
287 }
288 }
289
290 #[test]
291 fn test_flash_progress_debug() {
292 let progress = FlashProgress::Completed;
293 let debug_str = format!("{:?}", progress);
294 assert!(!debug_str.is_empty());
295 }
296
297 #[test]
298 fn test_subscription_id_is_deterministic() {
299 fn compute_id(image: &str, device: &str, run_id: u64) -> u64 {
300 let data = FlashSubData {
301 image_path: PathBuf::from(image),
302 device_path: PathBuf::from(device),
303 cancel_token: Arc::new(AtomicBool::new(false)),
304 run_id,
305 };
306 let mut hasher = DefaultHasher::new();
307 data.hash(&mut hasher);
308 hasher.finish()
309 }
310 assert_eq!(
311 compute_id("/tmp/a.img", "/dev/sdb", 1),
312 compute_id("/tmp/a.img", "/dev/sdb", 1),
313 );
314 }
315
316 #[test]
317 fn test_subscription_id_differs_for_different_devices() {
318 fn compute_id(image: &str, device: &str, run_id: u64) -> u64 {
319 let data = FlashSubData {
320 image_path: PathBuf::from(image),
321 device_path: PathBuf::from(device),
322 cancel_token: Arc::new(AtomicBool::new(false)),
323 run_id,
324 };
325 let mut hasher = DefaultHasher::new();
326 data.hash(&mut hasher);
327 hasher.finish()
328 }
329 assert_ne!(
330 compute_id("/tmp/a.img", "/dev/sdb", 1),
331 compute_id("/tmp/a.img", "/dev/sdc", 1),
332 );
333 }
334
335 #[test]
336 fn test_subscription_id_differs_for_different_run_ids() {
337 fn compute_id(image: &str, device: &str, run_id: u64) -> u64 {
338 let data = FlashSubData {
339 image_path: PathBuf::from(image),
340 device_path: PathBuf::from(device),
341 cancel_token: Arc::new(AtomicBool::new(false)),
342 run_id,
343 };
344 let mut hasher = DefaultHasher::new();
345 data.hash(&mut hasher);
346 hasher.finish()
347 }
348 let id_a = compute_id("/tmp/a.img", "/dev/sdb", 1);
349 let id_b = compute_id("/tmp/a.img", "/dev/sdb", 2);
350 assert_ne!(id_a, id_b);
351 }
352
353 #[test]
354 fn test_verify_progress_overall_image_phase() {
355 let p = FlashProgress::VerifyProgress {
356 phase: "image",
357 overall: 0.25,
358 bytes_read: 100,
359 total_bytes: 400,
360 speed_mb_s: 50.0,
361 };
362 if let FlashProgress::VerifyProgress { overall, .. } = p {
363 assert!((overall - 0.25).abs() < f32::EPSILON);
364 }
365 }
366
367 #[test]
368 fn test_verify_progress_overall_device_phase() {
369 let p = FlashProgress::VerifyProgress {
370 phase: "device",
371 overall: 0.75,
372 bytes_read: 300,
373 total_bytes: 400,
374 speed_mb_s: 50.0,
375 };
376 if let FlashProgress::VerifyProgress { overall, .. } = p {
377 assert!((overall - 0.75).abs() < f32::EPSILON);
378 }
379 }
380
381 #[test]
382 fn test_cancelled_maps_to_failed() {
383 let token = Arc::new(AtomicBool::new(true));
384 assert!(token.load(Ordering::SeqCst));
385 let msg = FlashProgress::Failed("Flash operation cancelled by user".into());
386 match msg {
387 FlashProgress::Failed(e) => {
388 assert!(e.contains("cancelled"));
389 }
390 _ => panic!("Expected Failed variant"),
391 }
392 }
393
394 #[test]
395 fn test_bridge_exits_when_receiver_dropped() {
396 // Simulate bridge thread behavior: if the futures channel receiver
397 // is dropped (subscription cancelled), try_send returns Err and the
398 // bridge exits cleanly.
399 let (std_tx, std_rx) = std::sync::mpsc::channel::<FlashEvent>();
400 let (futures_tx, _futures_rx) = futures_mpsc::channel::<FlashEvent>(4);
401
402 // Drop the receiver immediately
403 drop(_futures_rx);
404
405 // Send an event through std channel
406 let _ = std_tx.send(FlashEvent::Done);
407
408 // Bridge logic: try_send should fail
409 let mut ftx = futures_tx;
410 if let Ok(event) = std_rx.recv() {
411 let result = ftx.try_send(event);
412 assert!(
413 result.is_err(),
414 "try_send should fail when receiver is dropped"
415 );
416 }
417 }
418
419 #[test]
420 fn test_flash_event_mapping_smoke() {
421 // Test that FlashEvent → FlashUpdate mapping works for common variants
422 let events = vec![
423 FlashEvent::Done,
424 FlashEvent::Error("test error".into()),
425 FlashEvent::Progress {
426 bytes_written: 512,
427 total_bytes: 1024,
428 speed_mb_s: 10.0,
429 },
430 ];
431
432 for event in events {
433 match event {
434 FlashEvent::Done => {
435 let update = FlashUpdate::Completed;
436 assert!(matches!(update, FlashUpdate::Completed));
437 }
438 FlashEvent::Error(e) => {
439 let update = FlashUpdate::Failed(e);
440 assert!(matches!(update, FlashUpdate::Failed(_)));
441 }
442 other => {
443 let update = FlashUpdate::from(other);
444 // Should not panic
445 let _ = format!("{:?}", update);
446 }
447 }
448 }
449 }
450
451 #[test]
452 fn test_cancel_token_not_part_of_hash() {
453 // Two FlashSubData with different cancel tokens but same paths/run_id
454 // should hash identically.
455 let data1 = FlashSubData {
456 image_path: PathBuf::from("/tmp/test.img"),
457 device_path: PathBuf::from("/dev/sdb"),
458 cancel_token: Arc::new(AtomicBool::new(false)),
459 run_id: 42,
460 };
461 let data2 = FlashSubData {
462 image_path: PathBuf::from("/tmp/test.img"),
463 device_path: PathBuf::from("/dev/sdb"),
464 cancel_token: Arc::new(AtomicBool::new(true)),
465 run_id: 42,
466 };
467 let mut h1 = DefaultHasher::new();
468 data1.hash(&mut h1);
469 let mut h2 = DefaultHasher::new();
470 data2.hash(&mut h2);
471 assert_eq!(h1.finish(), h2.finish());
472 }
473}