Skip to main content

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}