Skip to content

Commit ffcb7af

Browse files
committed
fix restart behavior to match upstream OTP behavior
1 parent 717fb8c commit ffcb7af

3 files changed

Lines changed: 91 additions & 8 deletions

File tree

.vale/styles/config/vocabularies/technical/accept.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,6 @@ launchd
235235
Wireshark
236236
testsupport
237237
callee
238+
Erlang
239+
OTP
240+
respawn(s|ed|ing)?

lib/saluki-core/src/runtime/restart.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,18 +92,16 @@ pub enum RestartType {
9292
/// The child is restarted only if it exits abnormally.
9393
///
9494
/// An abnormal exit is an error, panic, or forced abort. A normal exit (the child's future resolves with `Ok(())`)
95-
/// is treated as intentional, and the child is not restarted.
95+
/// is treated as intentional, and the child is not restarted. This governs the child's _own_ exit; a transient
96+
/// child is still restarted when a sibling triggers a [`RestartMode::OneForAll`] group restart, matching
97+
/// Erlang/OTP.
9698
Transient,
9799

98100
/// The child is never restarted, regardless of how it exits.
99101
///
100102
/// This suits short-lived, on-demand children -- for example, one task per network connection -- whose termination
101-
/// is a normal part of operation.
102-
///
103-
/// > **Note:** Mixing `Temporary` children into a non-dynamic supervisor that uses [`RestartMode::OneForAll`]
104-
/// > is not yet fully supported: a one-for-all restart triggered by a sibling will currently restart temporary
105-
/// > children as well. Temporary children are intended for one-for-one supervision (including the dynamic
106-
/// > supervisor).
103+
/// is a normal part of operation. A temporary child is never restarted even when a sibling triggers a
104+
/// [`RestartMode::OneForAll`] group restart: it is shut down with the group but not brought back.
107105
Temporary,
108106
}
109107

lib/saluki-core/src/runtime/supervisor.rs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,24 @@ impl Supervisor {
525525
Ok(())
526526
}
527527

528+
/// Respawns children after a one-for-all restart, honoring each child's [`RestartType`].
529+
///
530+
/// Every child except [`RestartType::Temporary`] is restarted, matching Erlang/OTP: a group restart restarts all
531+
/// permanent and transient children -- regardless of how they last exited, including a transient child that had
532+
/// already exited cleanly -- but never temporary children, which are shut down with the group and not brought back.
533+
/// A transient child's "restart only on abnormal exit" rule governs its _own_ termination, not a group restart
534+
/// driven by a sibling.
535+
fn respawn_children_one_for_all(&self, worker_state: &mut WorkerState) -> Result<(), SupervisorError> {
536+
debug!(supervisor_id = %self.supervisor_id, "Restarting all eligible static child processes.");
537+
for child_spec_idx in 0..self.child_specs.len() {
538+
if self.get_restart_type(child_spec_idx) != RestartType::Temporary {
539+
self.spawn_child(child_spec_idx, worker_state)?;
540+
}
541+
}
542+
543+
Ok(())
544+
}
545+
528546
async fn run_inner(&self, process: Process, process_shutdown: ShutdownHandle) -> Result<(), SupervisorError> {
529547
if self.child_specs.is_empty() {
530548
return Err(SupervisorError::NoChildren);
@@ -598,7 +616,7 @@ impl Supervisor {
598616
RestartMode::OneForAll => {
599617
warn!(supervisor_id = %self.supervisor_id, worker_name = child_spec.name(), ?worker_result, "Child process terminated, restarting all processes.");
600618
worker_state.shutdown_workers().await;
601-
self.spawn_all_children(&mut worker_state)?;
619+
self.respawn_children_one_for_all(&mut worker_state)?;
602620
}
603621
},
604622
RestartAction::Shutdown => {
@@ -1162,6 +1180,70 @@ mod tests {
11621180
);
11631181
}
11641182

1183+
#[tokio::test]
1184+
async fn one_for_all_does_not_restart_temporary_children() {
1185+
// A permanent worker that fails repeatedly drives one-for-all restarts; a temporary sibling is shut down with
1186+
// the group on each cycle but, per OTP semantics, must never be brought back.
1187+
let failing = MockWorker::failing("failing-worker", Duration::from_millis(50));
1188+
let failing_count = failing.start_count();
1189+
1190+
let temp = MockWorker::long_running("temp-worker");
1191+
let temp_count = temp.start_count();
1192+
1193+
let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
1194+
RestartStrategy::one_for_all().with_intensity_and_period(20, Duration::from_secs(10)),
1195+
);
1196+
sup.add_worker(ChildSpecification::worker(temp).with_restart_type(RestartType::Temporary));
1197+
sup.add_worker(failing);
1198+
1199+
let (tx, handle) = run_supervisor_with_trigger(sup).await;
1200+
1201+
// Let several one-for-all cycles occur.
1202+
sleep(Duration::from_millis(300)).await;
1203+
let _ = tx.send(());
1204+
1205+
let result = timeout(Duration::from_secs(2), handle).await.unwrap().unwrap();
1206+
assert!(result.is_ok());
1207+
assert!(
1208+
failing_count.load(Ordering::SeqCst) >= 2,
1209+
"permanent worker should have been restarted by one-for-all"
1210+
);
1211+
assert_eq!(
1212+
temp_count.load(Ordering::SeqCst),
1213+
1,
1214+
"temporary child must not be restarted by a one-for-all group restart"
1215+
);
1216+
}
1217+
1218+
#[tokio::test]
1219+
async fn one_for_all_restarts_transient_children() {
1220+
// A transient child that exits cleanly is not restarted on its own, but a one-for-all restart triggered by a
1221+
// sibling restarts it anyway -- matching OTP, where only temporary children are exempt from group restarts.
1222+
let transient = MockWorker::completing("transient-worker", Duration::from_millis(30));
1223+
let transient_count = transient.start_count();
1224+
1225+
// Fails after the transient has already exited cleanly, so the group restart is what brings the transient back.
1226+
let failing = MockWorker::failing("failing-worker", Duration::from_millis(80));
1227+
1228+
let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
1229+
RestartStrategy::one_for_all().with_intensity_and_period(20, Duration::from_secs(10)),
1230+
);
1231+
sup.add_worker(ChildSpecification::worker(transient).with_restart_type(RestartType::Transient));
1232+
sup.add_worker(failing);
1233+
1234+
let (tx, handle) = run_supervisor_with_trigger(sup).await;
1235+
1236+
sleep(Duration::from_millis(300)).await;
1237+
let _ = tx.send(());
1238+
1239+
let result = timeout(Duration::from_secs(2), handle).await.unwrap().unwrap();
1240+
assert!(result.is_ok());
1241+
assert!(
1242+
transient_count.load(Ordering::SeqCst) >= 2,
1243+
"transient child must be restarted by a one-for-all group restart, even after a clean exit"
1244+
);
1245+
}
1246+
11651247
#[tokio::test]
11661248
async fn restart_limit_exceeded_shuts_down_supervisor() {
11671249
let mut sup = Supervisor::new("test-sup")

0 commit comments

Comments
 (0)