Skip to main content

ferroui_hot_patch/
restart.rs

1//! Process restart is a two-phase operation: successful build, then acknowledged startup.
2//! Pump this supervisor off the UI thread. Capture application state on the UI thread only
3//! after `poll_build` returns a ticket, and pass that snapshot to `launch`.
4use serde::{Deserialize, Serialize};
5use std::{
6    fs, io,
7    process::{Child, Command},
8    time::{Duration, Instant},
9};
10use tempfile::TempDir;
11
12const STATE_ENV: &str = "FERROUI_RESTART_STATE";
13const READY_ENV: &str = "FERROUI_RESTART_READY";
14const FORMAT_VERSION: u32 = 1;
15
16#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
17#[serde(deny_unknown_fields)]
18pub struct WindowPlacement {
19    pub key: String,
20    pub position: [i32; 2],
21    pub size: [f64; 2],
22    pub maximized: bool,
23}
24/// Application data is explicitly provided and versioned, never inferred from view models.
25#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
26#[serde(deny_unknown_fields)]
27pub struct RestartState {
28    pub format_version: u32,
29    pub application: String,
30    pub application_version: u32,
31    pub windows: Vec<WindowPlacement>,
32    pub navigation: Option<String>,
33    pub application_state: serde_json::Value,
34}
35impl RestartState {
36    pub fn new(
37        application: String,
38        application_version: u32,
39        application_state: serde_json::Value,
40    ) -> Self {
41        Self {
42            format_version: FORMAT_VERSION,
43            application,
44            application_version,
45            windows: Vec::new(),
46            navigation: None,
47            application_state,
48        }
49    }
50    fn validate(&self) -> io::Result<()> {
51        if self.format_version != FORMAT_VERSION || self.application.is_empty() {
52            return Err(io::Error::new(
53                io::ErrorKind::InvalidData,
54                "Unsupported restart state envelope",
55            ));
56        }
57        let mut keys = std::collections::BTreeSet::new();
58        for window in &self.windows {
59            if window.key.is_empty()
60                || !keys.insert(&window.key)
61                || window
62                    .size
63                    .iter()
64                    .any(|value| !value.is_finite() || *value <= 0.0)
65            {
66                return Err(io::Error::new(
67                    io::ErrorKind::InvalidData,
68                    "Invalid or ambiguous window placement",
69                ));
70            }
71        }
72        Ok(())
73    }
74}
75
76/// Startup acknowledgement is explicit: call only after restoring state and initializing the UI.
77pub struct RestartStartup {
78    pub state: RestartState,
79    ready: std::path::PathBuf,
80}
81impl RestartStartup {
82    pub fn from_environment(application: &str, version: u32) -> io::Result<Option<Self>> {
83        let (state, ready) = (std::env::var_os(STATE_ENV), std::env::var_os(READY_ENV));
84        match (state, ready) {
85            (None, None) => Ok(None),
86            (Some(state), Some(ready)) => {
87                let state: RestartState = serde_json::from_slice(&fs::read(state)?)?;
88                state.validate()?;
89                if state.application != application || state.application_version != version {
90                    return Err(io::Error::new(
91                        io::ErrorKind::InvalidData,
92                        "Application restart state version mismatch",
93                    ));
94                }
95                Ok(Some(Self {
96                    state,
97                    ready: ready.into(),
98                }))
99            }
100            _ => Err(io::Error::new(
101                io::ErrorKind::InvalidInput,
102                "Incomplete restart environment",
103            )),
104        }
105    }
106    pub fn acknowledge(self) -> io::Result<()> {
107        let staged = self.ready.with_extension("staged");
108        fs::write(&staged, b"ferroui-ready-v1")?;
109        fs::rename(staged, self.ready)
110    }
111}
112
113struct Build {
114    revision: u64,
115    child: Child,
116}
117struct Candidate {
118    revision: u64,
119    child: Child,
120    directory: TempDir,
121    deadline: Instant,
122}
123/// Opaque proof that this supervisor observed a successful build for the current revision.
124#[derive(Debug)]
125pub struct BuiltRevision {
126    revision: u64,
127    owner: u64,
128}
129#[derive(Debug, PartialEq, Eq)]
130pub enum ReplacementStatus {
131    Waiting,
132    Ready,
133    Failed(String),
134    Superseded,
135}
136
137pub struct RestartSupervisor {
138    current: Child,
139    latest_revision: u64,
140    owner: u64,
141    build: Option<Build>,
142    candidate: Option<Candidate>,
143}
144impl RestartSupervisor {
145    pub fn new(current: Child) -> Self {
146        static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
147        Self {
148            current,
149            latest_revision: 0,
150            owner: NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
151            build: None,
152            candidate: None,
153        }
154    }
155    pub fn current_id(&self) -> u32 {
156        self.current.id()
157    }
158    pub fn current_is_running(&mut self) -> io::Result<bool> {
159        Ok(self.current.try_wait()?.is_none())
160    }
161    /// Marks all earlier build/startup work stale without interrupting the running application.
162    pub fn supersede(&mut self, revision: u64) -> io::Result<()> {
163        if revision <= self.latest_revision {
164            return Err(io::Error::new(
165                io::ErrorKind::InvalidInput,
166                "Revision must increase",
167            ));
168        }
169        self.latest_revision = revision;
170        Ok(())
171    }
172    /// Command must build into an isolated output path; never overwrite the running executable.
173    /// Configure stdout/stderr as files or inherited streams, not unconsumed pipes.
174    pub fn build(&mut self, revision: u64, command: &mut Command) -> io::Result<()> {
175        if self.build.is_some() || self.candidate.is_some() {
176            return Err(io::Error::new(
177                io::ErrorKind::WouldBlock,
178                "Restart work is still pending",
179            ));
180        }
181        self.supersede(revision)?;
182        self.build = Some(Build {
183            revision,
184            child: command.spawn()?,
185        });
186        Ok(())
187    }
188    /// None means the build is still running. Failure and supersession never retire the old process.
189    pub fn poll_build(&mut self) -> io::Result<Option<BuiltRevision>> {
190        let build = self
191            .build
192            .as_mut()
193            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No restart build"))?;
194        let Some(status) = build.child.try_wait()? else {
195            return Ok(None);
196        };
197        let build = self.build.take().unwrap();
198        if build.revision != self.latest_revision {
199            return Err(io::Error::new(
200                io::ErrorKind::Interrupted,
201                "Restart build superseded",
202            ));
203        }
204        if !status.success() {
205            return Err(io::Error::other(format!(
206                "Replacement build failed: {status}"
207            )));
208        }
209        Ok(Some(BuiltRevision {
210            revision: build.revision,
211            owner: self.owner,
212        }))
213    }
214    /// The supplied command must run the artifact produced by the ticket's build.
215    /// State is captured after building, so edits made while the compiler runs are retained.
216    pub fn launch(
217        &mut self,
218        ticket: BuiltRevision,
219        command: &mut Command,
220        state: &RestartState,
221        timeout: Duration,
222    ) -> io::Result<()> {
223        if ticket.owner != self.owner || ticket.revision != self.latest_revision {
224            return Err(io::Error::new(
225                io::ErrorKind::Interrupted,
226                "Stale or foreign restart build",
227            ));
228        }
229        if self.candidate.is_some() {
230            return Err(io::Error::new(
231                io::ErrorKind::WouldBlock,
232                "Replacement already starting",
233            ));
234        }
235        state.validate()?;
236        let directory = tempfile::tempdir()?;
237        let state_file = directory.path().join("state.json");
238        fs::write(&state_file, serde_json::to_vec(state)?)?;
239        let child = command
240            .env(STATE_ENV, state_file)
241            .env(READY_ENV, directory.path().join("ready"))
242            .spawn()?;
243        self.candidate = Some(Candidate {
244            revision: ticket.revision,
245            child,
246            directory,
247            deadline: Instant::now() + timeout,
248        });
249        Ok(())
250    }
251    pub fn poll_replacement(&mut self) -> io::Result<ReplacementStatus> {
252        let candidate = self
253            .candidate
254            .as_mut()
255            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No replacement starting"))?;
256        let status = if candidate.revision != self.latest_revision {
257            ReplacementStatus::Superseded
258        } else if let Some(status) = candidate.child.try_wait()? {
259            ReplacementStatus::Failed(format!("Replacement exited during startup: {status}"))
260        } else if fs::read(candidate.directory.path().join("ready"))
261            .is_ok_and(|bytes| bytes == b"ferroui-ready-v1")
262        {
263            return Ok(ReplacementStatus::Ready);
264        } else if Instant::now() >= candidate.deadline {
265            ReplacementStatus::Failed("Replacement startup timed out".into())
266        } else {
267            return Ok(ReplacementStatus::Waiting);
268        };
269        let mut candidate = self.candidate.take().unwrap();
270        stop(&mut candidate.child)?;
271        Ok(status)
272    }
273    /// Retires the old process only after an explicit successful startup acknowledgement.
274    pub fn commit(&mut self) -> io::Result<()> {
275        if self.poll_replacement()? != ReplacementStatus::Ready {
276            return Err(io::Error::new(
277                io::ErrorKind::WouldBlock,
278                "Replacement is not ready",
279            ));
280        }
281        stop(&mut self.current)?;
282        self.current = self.candidate.take().unwrap().child;
283        Ok(())
284    }
285}
286fn stop(child: &mut Child) -> io::Result<()> {
287    if child.try_wait()?.is_none() {
288        child.kill()?;
289    }
290    child.wait()?;
291    Ok(())
292}
293impl Drop for RestartSupervisor {
294    fn drop(&mut self) {
295        if let Some(build) = &mut self.build {
296            let _ = stop(&mut build.child);
297        }
298        if let Some(candidate) = &mut self.candidate {
299            let _ = stop(&mut candidate.child);
300        }
301        let _ = stop(&mut self.current);
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use std::process::Stdio;
309    fn command(mode: &str) -> Command {
310        let mut command = Command::new(std::env::current_exe().unwrap());
311        command
312            .args(["--exact", "restart::tests::child_fixture", "--nocapture"])
313            .env("FERROUI_RESTART_TEST_CHILD", mode)
314            .env_remove(STATE_ENV)
315            .env_remove(READY_ENV)
316            .stdout(Stdio::null())
317            .stderr(Stdio::null());
318        command
319    }
320    #[test]
321    fn child_fixture() {
322        let Ok(mode) = std::env::var("FERROUI_RESTART_TEST_CHILD") else {
323            return;
324        };
325        match mode.as_str() {
326            "build-ok" => return,
327            "build-failed" => std::process::exit(7),
328            "restore" => {
329                let startup = RestartStartup::from_environment("fixture", 2)
330                    .unwrap()
331                    .unwrap();
332                assert_eq!(startup.state.application_state["counter"], 41);
333                assert_eq!(startup.state.navigation.as_deref(), Some("settings"));
334                assert_eq!(startup.state.windows[0].position, [40, 50]);
335                startup.acknowledge().unwrap();
336            }
337            "wrong-version" => {
338                assert!(RestartStartup::from_environment("fixture", 3).is_err());
339                std::process::exit(9);
340            }
341            "wait" => (),
342            _ => panic!("unknown child mode"),
343        }
344        loop {
345            std::thread::sleep(Duration::from_millis(50));
346        }
347    }
348    fn supervisor() -> RestartSupervisor {
349        RestartSupervisor::new(command("wait").spawn().unwrap())
350    }
351    fn state() -> RestartState {
352        let mut state =
353            RestartState::new("fixture".into(), 2, serde_json::json!({ "counter": 41 }));
354        state.navigation = Some("settings".into());
355        state.windows.push(WindowPlacement {
356            key: "main".into(),
357            position: [40, 50],
358            size: [640.0, 480.0],
359            maximized: false,
360        });
361        state
362    }
363    fn built(supervisor: &mut RestartSupervisor) -> io::Result<BuiltRevision> {
364        let deadline = Instant::now() + Duration::from_secs(10);
365        loop {
366            if let Some(ticket) = supervisor.poll_build()? {
367                return Ok(ticket);
368            }
369            assert!(Instant::now() < deadline, "build timed out");
370            std::thread::sleep(Duration::from_millis(10));
371        }
372    }
373    fn settled(supervisor: &mut RestartSupervisor) -> ReplacementStatus {
374        let deadline = Instant::now() + Duration::from_secs(10);
375        loop {
376            let status = supervisor.poll_replacement().unwrap();
377            if status != ReplacementStatus::Waiting {
378                return status;
379            }
380            assert!(Instant::now() < deadline, "startup timed out");
381            std::thread::sleep(Duration::from_millis(10));
382        }
383    }
384    #[test]
385    fn failed_build_retains_original_process() {
386        let mut supervisor = supervisor();
387        let old = supervisor.current_id();
388        supervisor.build(1, &mut command("build-failed")).unwrap();
389        assert!(built(&mut supervisor).is_err());
390        assert_eq!(supervisor.current_id(), old);
391        assert!(supervisor.current_is_running().unwrap());
392    }
393    #[test]
394    fn acknowledged_state_restore_precedes_retiring_old_process() {
395        let mut supervisor = supervisor();
396        let old = supervisor.current_id();
397        supervisor.build(1, &mut command("build-ok")).unwrap();
398        let ticket = built(&mut supervisor).unwrap();
399        supervisor
400            .launch(
401                ticket,
402                &mut command("restore"),
403                &state(),
404                Duration::from_secs(5),
405            )
406            .unwrap();
407        assert!(supervisor.current_is_running().unwrap());
408        assert_eq!(settled(&mut supervisor), ReplacementStatus::Ready);
409        assert_eq!(supervisor.current_id(), old);
410        supervisor.commit().unwrap();
411        assert_ne!(supervisor.current_id(), old);
412        assert!(supervisor.current_is_running().unwrap());
413    }
414    #[test]
415    fn incompatible_restore_and_startup_timeout_retain_old_process() {
416        let mut supervisor = supervisor();
417        let old = supervisor.current_id();
418        for (revision, mode) in [(1, "wrong-version"), (2, "wait")] {
419            supervisor
420                .build(revision, &mut command("build-ok"))
421                .unwrap();
422            let ticket = built(&mut supervisor).unwrap();
423            supervisor
424                .launch(
425                    ticket,
426                    &mut command(mode),
427                    &state(),
428                    Duration::from_millis(200),
429                )
430                .unwrap();
431            assert!(matches!(
432                settled(&mut supervisor),
433                ReplacementStatus::Failed(_)
434            ));
435            assert_eq!(supervisor.current_id(), old);
436            assert!(supervisor.current_is_running().unwrap());
437        }
438    }
439    #[test]
440    fn superseded_build_and_startup_cannot_replace_current_process() {
441        let mut supervisor = supervisor();
442        supervisor.build(1, &mut command("build-ok")).unwrap();
443        supervisor.supersede(2).unwrap();
444        assert_eq!(
445            built(&mut supervisor).unwrap_err().kind(),
446            io::ErrorKind::Interrupted
447        );
448        supervisor.build(3, &mut command("build-ok")).unwrap();
449        let ticket = built(&mut supervisor).unwrap();
450        supervisor
451            .launch(
452                ticket,
453                &mut command("restore"),
454                &state(),
455                Duration::from_secs(5),
456            )
457            .unwrap();
458        supervisor.supersede(4).unwrap();
459        assert_eq!(settled(&mut supervisor), ReplacementStatus::Superseded);
460        assert!(supervisor.current_is_running().unwrap());
461    }
462}