1use 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#[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
76pub 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#[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 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 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 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 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 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}