1use std::io::Write;
63use std::path::{Path, PathBuf};
64use std::process::{Child, ExitStatus};
65use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, Ordering};
66use std::sync::{mpsc, Once};
67use std::time::{Duration, Instant};
68
69use crate::child_launch::{self, AfterReadiness, ProcessEnv, ProcessLaunch, StartedProcess};
70use crate::delegation::SPAWNER_ENV;
71use crate::door_auth::DoorToken;
72use crate::formation::{FormationId, FormationPeer, FORMATION_PEERS_ENV};
73use crate::formation_credentials::{
74 render_address_line, FormationAuthority, FORMATION_CHANNEL_ENV,
75};
76use crate::lifeline::SPAWNER_LIFELINE_ENV;
77use crate::roster::AdmittedRoster;
78use crate::running::ProcessIdentity;
79use crate::session_status::{SessionDeadline, INHERITED_DEADLINE_FLAG};
80use serde::{Deserialize, Serialize};
81
82pub const MEMBER_READY_TIMEOUT: Duration = Duration::from_secs(180);
88
89pub const MEMBER_STOP_GRACE: Duration = Duration::from_secs(25);
95
96const READY_PROBE_INTERVAL: Duration = Duration::from_millis(100);
98
99const POLL_INTERVAL: Duration = Duration::from_millis(50);
102
103const EXIT_AFTER_EOF_WAIT: Duration = Duration::from_secs(2);
105
106pub const PEER_BIND_ADDR: &str = "127.0.0.1";
108
109pub const CAPSULE_SHA256_FLAG: &str = "--capsule-sha256";
111
112pub const STORE_ROOT_FLAG: &str = "--store-root";
115
116pub const FORMATIONS_DIR: &str = "formations";
118
119pub const FORMATION_MARKER_FILE: &str = "formation.json";
122
123#[derive(Debug, Clone)]
125pub struct FormationLaunchOptions {
126 pub formation_id: FormationId,
128 pub project_dir: PathBuf,
131 pub task: Option<String>,
133 pub json: bool,
135 pub verbose: bool,
137 pub no_env_file: bool,
139 pub containment: Option<String>,
141 pub ready_timeout: Duration,
144 pub stop_grace: Duration,
147 pub deadline: Option<SessionDeadline>,
151 pub(crate) formations_dir: Option<PathBuf>,
154}
155
156impl FormationLaunchOptions {
157 pub fn new(formation_id: FormationId, project_dir: PathBuf) -> Self {
159 Self {
160 formation_id,
161 project_dir,
162 task: None,
163 json: false,
164 verbose: false,
165 no_env_file: false,
166 containment: None,
167 ready_timeout: MEMBER_READY_TIMEOUT,
168 stop_grace: MEMBER_STOP_GRACE,
169 deadline: None,
170 formations_dir: None,
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177pub(crate) struct PlannedMember {
178 pub(crate) name: String,
179 pub(crate) capsule: String,
180 pub(crate) version: String,
181 pub(crate) sha256: String,
183}
184
185#[derive(Debug, Clone)]
188pub(crate) struct FormationPlan {
189 pub(crate) peers: Vec<PlannedMember>,
190 pub(crate) entry: PlannedMember,
191 pub(crate) callees: Vec<(String, Vec<String>)>,
194}
195
196impl FormationPlan {
197 pub(crate) fn from_roster(roster: &AdmittedRoster) -> Self {
198 let planned = |member: &crate::roster::AdmittedMember| PlannedMember {
199 name: member.name.clone(),
200 capsule: member.capsule.clone(),
201 version: member.version.clone(),
202 sha256: member.sha256.clone(),
203 };
204 let entry = roster.entry();
205 Self {
206 peers: roster
207 .members()
208 .iter()
209 .filter(|member| !member.entry)
210 .map(planned)
211 .collect(),
212 entry: planned(entry),
213 callees: roster
214 .members()
215 .iter()
216 .map(|member| {
217 let callees: Vec<String> = roster
218 .callees(&member.name)
219 .map(|callee| callee.name.clone())
220 .collect();
221 (member.name.clone(), callees)
222 })
223 .filter(|(_, callees)| !callees.is_empty())
224 .collect(),
225 }
226 }
227
228 pub(crate) fn callees_of(&self, member: &str) -> &[String] {
230 self.callees
231 .iter()
232 .find(|(name, _)| name == member)
233 .map(|(_, callees)| callees.as_slice())
234 .unwrap_or_default()
235 }
236
237 fn first_line(
240 &self,
241 authority: &FormationAuthority,
242 launcher: Option<&ProcessIdentity>,
243 member: &str,
244 ) -> String {
245 let callees: Vec<&str> = self.callees_of(member).iter().map(String::as_str).collect();
246 let mut bundle = authority.member_bundle(member, &callees);
247 bundle.launcher = launcher.cloned();
248 bundle.entry = member == self.entry.name;
249 bundle.render_line()
250 }
251}
252
253pub struct ReadyPeer {
255 pub name: String,
256 pub capsule: String,
257 pub version: String,
258 pub pid: u32,
259 pub session_id: String,
260 pub url: String,
262 pub workdir: PathBuf,
265 door_token: Option<DoorToken>,
268}
269
270impl ReadyPeer {
271 pub fn door_token(&self) -> Option<&DoorToken> {
273 self.door_token.as_ref()
274 }
275}
276
277impl std::fmt::Debug for ReadyPeer {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 f.debug_struct("ReadyPeer")
280 .field("name", &self.name)
281 .field("capsule", &self.capsule)
282 .field("version", &self.version)
283 .field("pid", &self.pid)
284 .field("session_id", &self.session_id)
285 .field("url", &self.url)
286 .field("workdir", &self.workdir)
287 .field(
288 "door_token",
289 &self.door_token.as_ref().map(|_| "<redacted>"),
290 )
291 .finish()
292 }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
297pub enum MemberFailureReason {
298 NotStarted { error: String },
300 ExitedBeforeReporting {
302 status: String,
303 stderr_tail: Vec<String>,
304 },
305 ExitedBeforeDoorAnswered {
307 status: String,
308 stderr_tail: Vec<String>,
309 },
310 DidNotReport { deadline: Duration },
312 UnreadableReport { why: String },
314 ReportedNoDoor,
316 WrongFormation {
318 reported: Option<String>,
319 expected: String,
320 },
321 DoorDidNotAnswer {
323 url: String,
324 session_id: String,
325 deadline: Duration,
326 last_error: String,
327 },
328}
329
330impl std::fmt::Display for MemberFailureReason {
331 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332 let tail = |f: &mut std::fmt::Formatter<'_>, lines: &[String]| -> std::fmt::Result {
333 if lines.is_empty() {
334 return f.write_str("; it wrote nothing to stderr");
335 }
336 f.write_str("; its last stderr lines were:")?;
337 for line in lines {
338 write!(f, "\n {line}")?;
339 }
340 Ok(())
341 };
342 match self {
343 Self::NotStarted { error } => write!(f, "its process could not be started: {error}"),
344 Self::ExitedBeforeReporting {
345 status,
346 stderr_tail,
347 } => {
348 write!(f, "its process exited with {status} before reporting")?;
349 tail(f, stderr_tail)
350 }
351 Self::ExitedBeforeDoorAnswered {
352 status,
353 stderr_tail,
354 } => {
355 write!(
356 f,
357 "its process reported and then exited with {status} before its door answered"
358 )?;
359 tail(f, stderr_tail)
360 }
361 Self::DidNotReport { deadline } => {
362 write!(f, "it did not report within {}s", deadline.as_secs_f32())
363 }
364 Self::UnreadableReport { why } => {
365 write!(f, "its first line is not a readiness line: {why}")
366 }
367 Self::ReportedNoDoor => f.write_str("it reported no door"),
368 Self::WrongFormation { reported, expected } => match reported {
369 Some(reported) => {
370 write!(f, "it reported formation {reported} instead of {expected}")
371 }
372 None => write!(f, "it reported no formation instead of {expected}"),
373 },
374 Self::DoorDidNotAnswer {
375 url,
376 session_id,
377 deadline,
378 last_error,
379 } => write!(
380 f,
381 "its door at {url} did not answer as session {session_id} within {}s; the last \
382 probe said: {last_error}",
383 deadline.as_secs_f32()
384 ),
385 }
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct MemberFailure {
392 pub name: String,
393 pub capsule: String,
394 pub version: String,
395 pub reason: MemberFailureReason,
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct StoppedMember {
401 pub name: String,
402 pub pid: u32,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq)]
407pub struct MemberLaunchFailure {
408 pub failed: Vec<MemberFailure>,
411 pub stopped: Vec<StoppedMember>,
413 pub interrupted_by: Option<i32>,
416 pub deadline_reached: bool,
419}
420
421impl std::fmt::Display for MemberLaunchFailure {
422 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423 match self.interrupted_by {
424 Some(signal) if self.failed.is_empty() => write!(
425 f,
426 "the formation launch was interrupted by {} before every member was ready",
427 signal_name(signal)
428 )?,
429 None if self.deadline_reached && self.failed.is_empty() => {
430 f.write_str("the formation reached its deadline before its entry member started")?
431 }
432 _ => f.write_str("the formation could not be launched")?,
433 }
434 for failure in &self.failed {
435 write!(
436 f,
437 "\n '{}' ({}@{}): {}",
438 failure.name, failure.capsule, failure.version, failure.reason
439 )?;
440 }
441 write!(f, "\n stopped: {}", render_stopped(&self.stopped))
442 }
443}
444
445pub fn render_stopped(stopped: &[StoppedMember]) -> String {
447 if stopped.is_empty() {
448 return "none".to_string();
449 }
450 stopped
451 .iter()
452 .map(|member| format!("{} (pid {})", member.name, member.pid))
453 .collect::<Vec<_>>()
454 .join(", ")
455}
456
457pub fn signal_name(signal: i32) -> String {
459 match signal {
460 libc::SIGINT => "SIGINT".to_string(),
461 libc::SIGTERM => "SIGTERM".to_string(),
462 libc::SIGHUP => "SIGHUP".to_string(),
463 other => format!("signal {other}"),
464 }
465}
466
467#[derive(Debug, Clone, PartialEq, Eq)]
469pub enum FormationEnding {
470 EntryExited(ExitStatus),
472 Signalled(i32),
474}
475
476#[derive(Debug, Clone)]
478pub struct FormationExit {
479 pub ending: FormationEnding,
480 pub stopped: Vec<StoppedMember>,
482}
483
484impl FormationExit {
485 pub fn exit_code(&self) -> i32 {
488 match &self.ending {
489 FormationEnding::EntryExited(status) => crate::shell::exit_code_of(status),
490 FormationEnding::Signalled(signal) => 128 + signal,
491 }
492 }
493}
494
495fn describe_status(status: ExitStatus) -> String {
497 if let Some(code) = status.code() {
498 return format!("status {code}");
499 }
500 #[cfg(unix)]
501 {
502 use std::os::unix::process::ExitStatusExt;
503 if let Some(signal) = status.signal() {
504 return format!("signal {signal}");
505 }
506 }
507 status.to_string()
508}
509
510static CAUGHT_SIGNAL: AtomicI32 = AtomicI32::new(0);
514
515#[cfg(unix)]
516extern "C" fn record_signal(signal: libc::c_int) {
517 CAUGHT_SIGNAL.store(signal, Ordering::SeqCst);
519}
520
521#[cfg(unix)]
527#[allow(unsafe_code)]
528pub fn catch_launcher_signals() -> std::io::Result<()> {
529 for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] {
530 let installed = unsafe {
534 let mut action: libc::sigaction = std::mem::zeroed();
535 action.sa_sigaction = record_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
536 action.sa_flags = libc::SA_RESTART;
537 libc::sigemptyset(&mut action.sa_mask);
538 libc::sigaction(signal, &action, std::ptr::null_mut())
539 };
540 if installed != 0 {
541 return Err(std::io::Error::last_os_error());
542 }
543 }
544 Ok(())
545}
546
547#[cfg(not(unix))]
548pub fn catch_launcher_signals() -> std::io::Result<()> {
549 Ok(())
550}
551
552pub fn caught_signal() -> Option<i32> {
554 match CAUGHT_SIGNAL.load(Ordering::SeqCst) {
555 0 => None,
556 signal => Some(signal),
557 }
558}
559
560const REGISTRY_SLOTS: usize = 1024;
565
566static MEMBER_REGISTRY: [AtomicI64; REGISTRY_SLOTS] = [const { AtomicI64::new(0) }; REGISTRY_SLOTS];
570
571struct RegistrySlot(Option<usize>);
573
574impl RegistrySlot {
575 fn register(pid: u32, own_group: bool) -> Self {
576 let pid = i64::from(pid);
577 let value = if own_group { pid } else { -pid };
578 let slot = MEMBER_REGISTRY.iter().position(|slot| {
579 slot.compare_exchange(0, value, Ordering::SeqCst, Ordering::SeqCst)
580 .is_ok()
581 });
582 Self(slot)
583 }
584
585 fn release(&mut self) {
586 if let Some(index) = self.0.take() {
587 MEMBER_REGISTRY[index].store(0, Ordering::SeqCst);
588 }
589 }
590}
591
592impl Drop for RegistrySlot {
593 fn drop(&mut self) {
594 self.release();
595 }
596}
597
598pub(crate) fn kill_registered_members() {
601 for slot in &MEMBER_REGISTRY {
602 match slot.load(Ordering::SeqCst) {
603 0 => {}
604 pid if pid > 0 => {
605 signal_group(pid, libc::SIGKILL);
606 }
607 pid => signal_pid(-pid, libc::SIGKILL),
608 }
609 }
610}
611
612fn install_panic_hook() {
616 static INSTALLED: Once = Once::new();
617 INSTALLED.call_once(|| {
618 let previous = std::panic::take_hook();
619 std::panic::set_hook(Box::new(move |info| {
620 previous(info);
621 if cfg!(panic = "abort") {
622 kill_registered_members();
623 }
624 }));
625 });
626}
627
628#[cfg(unix)]
629#[allow(unsafe_code)]
630fn signal_pid(pid: i64, signal: libc::c_int) {
631 let Ok(pid) = libc::pid_t::try_from(pid) else {
632 return;
633 };
634 if pid <= 0 {
635 return;
636 }
637 unsafe {
640 libc::kill(pid, signal);
641 }
642}
643
644#[cfg(unix)]
647#[allow(unsafe_code)]
648pub(crate) fn signal_group(pgid: i64, signal: libc::c_int) -> bool {
649 let Ok(pgid) = libc::pid_t::try_from(pgid) else {
650 return false;
651 };
652 if pgid <= 1 {
653 return false;
654 }
655 unsafe { libc::killpg(pgid, signal) == 0 }
658}
659
660#[cfg(not(unix))]
661fn signal_pid(_pid: i64, _signal: i32) {}
662
663#[cfg(not(unix))]
664pub(crate) fn signal_group(_pgid: i64, _signal: i32) -> bool {
665 false
666}
667
668struct MemberProcess {
672 name: String,
673 pid: u32,
674 child: Child,
675 own_group: bool,
676 #[cfg(unix)]
679 lifeline: Option<crate::lifeline::MemberLifeline>,
680 status: Option<ExitStatus>,
683 slot: RegistrySlot,
684 grace: Duration,
685 channel: Option<std::io::PipeWriter>,
688}
689
690impl MemberProcess {
691 fn new(
692 name: &str,
693 child: Child,
694 own_group: bool,
695 grace: Duration,
696 channel: std::io::PipeWriter,
697 #[cfg(unix)] lifeline: Option<crate::lifeline::MemberLifeline>,
698 ) -> Self {
699 let pid = child.id();
700 Self {
701 name: name.to_string(),
702 pid,
703 child,
704 own_group,
705 #[cfg(unix)]
706 lifeline,
707 status: None,
708 slot: RegistrySlot::register(pid, own_group),
709 grace,
710 channel: Some(channel),
711 }
712 }
713
714 fn send_line(&mut self, line: &str) {
717 if let Some(channel) = self.channel.as_mut() {
718 let _ = channel.write_all(format!("{line}\n").as_bytes());
719 }
720 }
721
722 fn has_exited(&self) -> bool {
724 self.status.is_some() || !crate::running::pid_is_alive(self.pid)
725 }
726
727 fn exit_status_now(&mut self) -> Option<ExitStatus> {
732 if self.status.is_none() && self.has_exited() {
733 self.reap();
734 }
735 self.status
736 }
737
738 fn reap(&mut self) {
739 if self.status.is_some() {
740 return;
741 }
742 if self.own_group {
743 signal_group(i64::from(self.pid), libc::SIGKILL);
744 } else if !self.has_exited() {
745 signal_pid(i64::from(self.pid), libc::SIGKILL);
746 }
747 self.status = self.child.wait().ok();
748 if self.status.is_none() {
749 self.status = Some(ExitStatus::default());
751 }
752 self.slot.release();
753 self.channel = None;
754 }
755
756 fn close_lifeline(&mut self) {
758 #[cfg(unix)]
759 if let Some(lifeline) = &mut self.lifeline {
760 lifeline.close();
761 }
762 }
763
764 fn is_running(&self) -> bool {
766 self.status.is_none() && !self.has_exited()
767 }
768
769 fn stop(&mut self) -> Option<StoppedMember> {
772 let was_running = self.is_running();
773 self.stop_from(was_running)
774 }
775
776 fn stop_from(&mut self, was_running: bool) -> Option<StoppedMember> {
779 if self.status.is_some() {
780 return None;
781 }
782 self.close_lifeline();
783 if was_running {
784 let deadline = Instant::now() + self.grace;
785 while !self.has_exited() && Instant::now() < deadline {
786 std::thread::sleep(POLL_INTERVAL);
787 }
788 }
789 self.reap();
790 was_running.then(|| StoppedMember {
791 name: self.name.clone(),
792 pid: self.pid,
793 })
794 }
795}
796
797impl Drop for MemberProcess {
798 fn drop(&mut self) {
799 let _ = self.stop();
800 }
801}
802
803struct MemberSet(Vec<MemberProcess>);
805
806impl MemberSet {
807 fn stop_all(&mut self) -> Vec<StoppedMember> {
810 let running: Vec<bool> = self.0.iter().map(MemberProcess::is_running).collect();
813 for member in &mut self.0 {
814 member.close_lifeline();
815 }
816 std::thread::scope(|scope| {
817 let stopping: Vec<_> = self
818 .0
819 .iter_mut()
820 .zip(running)
821 .map(|(member, was_running)| scope.spawn(move || member.stop_from(was_running)))
822 .collect();
823 stopping
824 .into_iter()
825 .filter_map(|handle| handle.join().ok().flatten())
826 .collect()
827 })
828 }
829}
830
831impl Drop for MemberSet {
832 fn drop(&mut self) {
833 let _ = self.stop_all();
834 }
835}
836
837fn member_args(member: &PlannedMember, workdir: &Path) -> Vec<String> {
841 vec![
842 "run".to_string(),
843 "--capsule".to_string(),
844 member.capsule.clone(),
845 "--capsule-version".to_string(),
846 member.version.clone(),
847 CAPSULE_SHA256_FLAG.to_string(),
848 member.sha256.clone(),
849 "--workdir".to_string(),
850 workdir.display().to_string(),
851 ]
852}
853
854fn pass_through(args: &mut Vec<String>, options: &FormationLaunchOptions) {
857 if options.no_env_file {
858 args.push("--no-env-file".to_string());
859 }
860 if let Some(class) = &options.containment {
861 args.push("--containment".to_string());
862 args.push(class.clone());
863 }
864 if let Some(deadline) = &options.deadline {
865 args.push(INHERITED_DEADLINE_FLAG.to_string());
866 args.push(deadline.at_unix_millis().to_string());
867 }
868}
869
870pub(crate) fn peer_args(
874 member: &PlannedMember,
875 options: &FormationLaunchOptions,
876 workdir: &Path,
877) -> Vec<String> {
878 let mut args = member_args(member, workdir);
879 args.extend([
880 STORE_ROOT_FLAG.to_string(),
881 options.project_dir.display().to_string(),
882 "--json".to_string(),
883 child_launch::READINESS_TOKENS_FLAG.to_string(),
884 "--bind".to_string(),
885 PEER_BIND_ADDR.to_string(),
886 ]);
887 pass_through(&mut args, options);
888 args
889}
890
891pub(crate) fn entry_args(member: &PlannedMember, options: &FormationLaunchOptions) -> Vec<String> {
900 let mut args = member_args(member, &options.project_dir);
901 let (task_acceptance, after_task) = match options.task {
902 Some(_) => ("single", "exit"),
903 None => ("queue", "sleep"),
904 };
905 args.extend([
906 "--lifecycle-task-acceptance".to_string(),
907 task_acceptance.to_string(),
908 "--lifecycle-after-task".to_string(),
909 after_task.to_string(),
910 ]);
911 if let Some(task) = &options.task {
912 args.push("--task".to_string());
913 args.push(task.clone());
914 }
915 if options.json {
916 args.push("--json".to_string());
917 }
918 if options.verbose {
919 args.push("--verbose".to_string());
920 }
921 pass_through(&mut args, options);
922 args
923}
924
925fn formations_dir() -> Result<PathBuf, String> {
927 Ok(crate::state_store::murmur_home_dir()?.join(FORMATIONS_DIR))
928}
929
930pub fn formation_member_roots(formation_id: &FormationId) -> Vec<PathBuf> {
937 match formations_dir() {
938 Ok(dir) => formation_member_roots_in(&dir, formation_id),
939 Err(_) => Vec::new(),
940 }
941}
942
943pub fn formation_member_roots_in(
946 formations_dir: &Path,
947 formation_id: &FormationId,
948) -> Vec<PathBuf> {
949 let dir = formations_dir.join(formation_id.as_str());
950 let entries = match std::fs::read_dir(&dir) {
951 Ok(entries) => entries,
952 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
953 Err(_) => return vec![dir],
954 };
955 let mut roots: Vec<PathBuf> = entries
956 .filter_map(Result::ok)
957 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
958 .filter_map(|entry| crate::records_dir::records_group(&entry.path()).ok())
959 .filter(|root| root.exists())
960 .collect();
961 roots.sort();
962 roots
963}
964
965#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971pub struct FormationMarker {
972 pub project_dir: PathBuf,
974 pub launcher_pid: u32,
976 pub launcher_start: String,
979}
980
981impl FormationMarker {
982 pub fn for_this_launcher(project_dir: &Path) -> Self {
984 let pid = std::process::id();
985 Self {
986 project_dir: canonical_project_dir(project_dir),
987 launcher_pid: pid,
988 launcher_start: crate::running::process_start_token(pid).unwrap_or_default(),
989 }
990 }
991
992 pub fn read(formation_dir: &Path) -> Option<Self> {
994 let raw = std::fs::read_to_string(formation_dir.join(FORMATION_MARKER_FILE)).ok()?;
995 serde_json::from_str(&raw).ok()
996 }
997}
998
999pub(crate) fn canonical_project_dir(project_dir: &Path) -> PathBuf {
1002 std::fs::canonicalize(project_dir).unwrap_or_else(|_| project_dir.to_path_buf())
1003}
1004
1005fn write_formation_marker(options: &FormationLaunchOptions) -> Result<(), String> {
1008 let formations_dir = launch_formations_dir(options)?;
1009 let formation_dir = formations_dir.join(options.formation_id.as_str());
1010 for dir in [formations_dir.as_path(), formation_dir.as_path()] {
1011 crate::state_store::ensure_private_dir(dir, crate::murmur_home::MURMUR_HOME_DIR_MODE)
1012 .map_err(|reason| format!("{}: {reason}", dir.display()))?;
1013 }
1014 let marker = FormationMarker::for_this_launcher(&options.project_dir);
1015 let json = serde_json::to_string(&marker).map_err(|error| error.to_string())?;
1016 crate::murmur_home::write_private_file(
1017 &formation_dir.join(FORMATION_MARKER_FILE),
1018 format!("{json}\n").as_bytes(),
1019 )
1020}
1021
1022pub fn prune_ended_formations(
1029 current: &FormationId,
1030 project_dir: &Path,
1031 policy: &murmur_artifact::TraceRetainConfig,
1032) -> Vec<crate::retention::PrunedFormation> {
1033 let (Ok(formations_dir), Ok(running_dir)) =
1034 (formations_dir(), crate::running::running_dir_location())
1035 else {
1036 return Vec::new();
1037 };
1038 prune_ended_formations_in(&formations_dir, &running_dir, current, project_dir, policy)
1039}
1040
1041pub fn prune_ended_formations_in(
1044 formations_dir: &Path,
1045 running_dir: &Path,
1046 current: &FormationId,
1047 project_dir: &Path,
1048 policy: &murmur_artifact::TraceRetainConfig,
1049) -> Vec<crate::retention::PrunedFormation> {
1050 let records = crate::running::scan(running_dir)
1052 .ok()
1053 .map(|scan| scan.records);
1054 crate::retention::prune_formations(
1055 formations_dir,
1056 current,
1057 project_dir,
1058 policy,
1059 crate::retention::now_ms(),
1060 |id, marker| formation_is_live(id, marker, records.as_deref()),
1061 )
1062}
1063
1064fn formation_is_live(
1071 id: &FormationId,
1072 marker: &FormationMarker,
1073 records: Option<&[crate::running::RunningRecord]>,
1074) -> bool {
1075 let Some(records) = records else {
1076 return true;
1077 };
1078 crate::running::process_state_of(marker.launcher_pid, &marker.launcher_start).is_running()
1079 || records.iter().any(|record| {
1080 record.formation_id.as_ref() == Some(id)
1081 && crate::running::process_state(record).is_running()
1082 })
1083}
1084
1085fn make_member_workdir(
1092 formations_dir: &Path,
1093 formation_id: &FormationId,
1094 member: &str,
1095) -> Result<PathBuf, String> {
1096 use std::os::unix::fs::DirBuilderExt;
1097
1098 let formation_dir = formations_dir.join(formation_id.as_str());
1099 let workdir = formation_dir.join(member);
1100 let failed = |path: &Path, reason: String| {
1101 format!(
1102 "its directory {} could not be made: {}: {reason}",
1103 workdir.display(),
1104 path.display()
1105 )
1106 };
1107 for dir in [formations_dir, formation_dir.as_path()] {
1108 crate::state_store::ensure_private_dir(dir, crate::murmur_home::MURMUR_HOME_DIR_MODE)
1109 .map_err(|reason| failed(dir, reason))?;
1110 }
1111 std::fs::DirBuilder::new()
1112 .mode(crate::murmur_home::MURMUR_HOME_DIR_MODE)
1113 .create(&workdir)
1114 .map_err(|error| failed(&workdir, error.to_string()))?;
1115 Ok(workdir)
1117}
1118
1119fn launch_formations_dir(options: &FormationLaunchOptions) -> Result<PathBuf, String> {
1122 match &options.formations_dir {
1123 Some(dir) => Ok(dir.clone()),
1124 None => {
1125 crate::murmur_home::ensure_murmur_home()?;
1126 formations_dir()
1127 }
1128 }
1129}
1130
1131enum PeerOutcome {
1133 Ready(ReadyPeer),
1134 Failed(MemberFailureReason),
1135 Abandoned,
1137 DeadlineReached,
1139}
1140
1141struct Channel {
1144 writer: std::io::PipeWriter,
1145 reader: std::io::PipeReader,
1146}
1147
1148impl Channel {
1149 fn open(first_line: &str) -> Result<Self, MemberFailureReason> {
1152 let (reader, mut writer) = std::io::pipe().map_err(|error| {
1153 not_started(&format!(
1154 "its formation channel could not be created: {error}"
1155 ))
1156 })?;
1157 writer
1158 .write_all(format!("{first_line}\n").as_bytes())
1159 .map_err(|error| {
1160 not_started(&format!(
1161 "its formation channel could not be written: {error}"
1162 ))
1163 })?;
1164 Ok(Self { writer, reader })
1165 }
1166
1167 fn fd(&self) -> i32 {
1169 use std::os::fd::AsRawFd;
1170 self.reader.as_raw_fd()
1171 }
1172
1173 fn member_env(&self, formation_id: &FormationId) -> Vec<(String, String)> {
1177 let (id_name, id_value) = formation_id.env_pair();
1178 vec![
1179 (id_name.to_string(), id_value),
1180 (FORMATION_CHANNEL_ENV.to_string(), self.fd().to_string()),
1181 crate::host_warnings::reported_env_pair(),
1182 ]
1183 }
1184}
1185
1186fn start_peer(
1191 binary: &Path,
1192 member: &PlannedMember,
1193 first_line: &str,
1194 options: &FormationLaunchOptions,
1195 abort: &AtomicBool,
1196 #[cfg(unix)] mut lifeline: crate::lifeline::MemberLifeline,
1197) -> (Option<MemberProcess>, PeerOutcome) {
1198 let workdir = match launch_formations_dir(options)
1199 .and_then(|dir| make_member_workdir(&dir, &options.formation_id, &member.name))
1200 {
1201 Ok(workdir) => workdir,
1202 Err(error) => return (None, PeerOutcome::Failed(not_started(&error))),
1203 };
1204 let args = peer_args(member, options, &workdir);
1205 let prefix = format!("[{}] ", member.name);
1206 let channel = match Channel::open(first_line) {
1207 Ok(channel) => channel,
1208 Err(reason) => return (None, PeerOutcome::Failed(reason)),
1209 };
1210 let set = channel.member_env(&options.formation_id);
1211 let spawned_at = Instant::now();
1212 let deadline = options
1216 .deadline
1217 .map_or(spawned_at + options.ready_timeout, |formation| {
1218 (spawned_at + options.ready_timeout).min(formation.instant())
1219 });
1220 let deadline_passed = || {
1221 options
1222 .deadline
1223 .is_some_and(|formation| formation.left(Instant::now()).is_zero())
1224 };
1225 let started = child_launch::start_process(&ProcessLaunch {
1226 binary,
1227 args: &args,
1228 cwd: None,
1229 env: ProcessEnv::Inherited {
1230 set: &set,
1231 remove: &[FORMATION_PEERS_ENV, SPAWNER_ENV, SPAWNER_LIFELINE_ENV],
1232 },
1233 stdin_line: None,
1234 stderr_prefix: Some(&prefix),
1235 inherit_output: false,
1236 own_process_group: true,
1237 inherit_fd: Some(channel.fd()),
1238 #[cfg(unix)]
1239 lifeline: Some(crate::child_launch::Lifeline::Member(&lifeline)),
1240 });
1241 let Channel { writer, reader } = channel;
1243 drop(reader);
1244 let StartedProcess {
1245 mut child,
1246 stderr_tail,
1247 ..
1248 } = match started {
1249 Ok(started) => started,
1250 Err(error) => {
1251 return (
1252 None,
1253 PeerOutcome::Failed(MemberFailureReason::NotStarted {
1254 error: format!("{}: {error}", binary.display()),
1255 }),
1256 )
1257 }
1258 };
1259 #[cfg(unix)]
1260 lifeline.spawned();
1261 let stdout = child.stdout.take();
1262 let mut process = MemberProcess::new(
1263 &member.name,
1264 child,
1265 true,
1266 options.stop_grace,
1267 writer,
1268 #[cfg(unix)]
1269 Some(lifeline),
1270 );
1271 let Some(stdout) = stdout else {
1272 return (
1273 Some(process),
1274 PeerOutcome::Failed(MemberFailureReason::NotStarted {
1275 error: "the process exposed no standard output".to_string(),
1276 }),
1277 );
1278 };
1279 let lines = child_launch::relay_readiness_line(stdout, AfterReadiness::PrefixedStderr(prefix));
1280
1281 let line = loop {
1283 if abort.load(Ordering::SeqCst) {
1284 return (Some(process), PeerOutcome::Abandoned);
1285 }
1286 let now = Instant::now();
1287 if now >= deadline && deadline_passed() {
1288 return (Some(process), PeerOutcome::DeadlineReached);
1289 }
1290 if now >= deadline {
1291 return (
1292 Some(process),
1293 PeerOutcome::Failed(MemberFailureReason::DidNotReport {
1294 deadline: options.ready_timeout,
1295 }),
1296 );
1297 }
1298 match lines.recv_timeout(POLL_INTERVAL.min(deadline - now)) {
1299 Ok(Some(line)) => break line,
1300 Ok(None) | Err(mpsc::RecvTimeoutError::Disconnected) => {
1301 let closed_at = Instant::now();
1304 let status = loop {
1305 if let Some(status) = process.exit_status_now() {
1306 break status;
1307 }
1308 if closed_at.elapsed() >= EXIT_AFTER_EOF_WAIT {
1309 process.reap();
1310 break process.status.unwrap_or_default();
1311 }
1312 std::thread::sleep(POLL_INTERVAL);
1313 };
1314 if deadline_passed() {
1315 return (Some(process), PeerOutcome::DeadlineReached);
1316 }
1317 return (
1318 Some(process),
1319 PeerOutcome::Failed(MemberFailureReason::ExitedBeforeReporting {
1320 status: describe_status(status),
1321 stderr_tail: stderr_tail.lines_at_end(),
1322 }),
1323 );
1324 }
1325 Err(mpsc::RecvTimeoutError::Timeout) => {}
1326 }
1327 };
1328
1329 let report = match read_report(&line, &options.formation_id) {
1330 Ok(report) => report,
1331 Err(reason) => return (Some(process), PeerOutcome::Failed(reason)),
1332 };
1333
1334 let mut last_error;
1336 loop {
1337 if abort.load(Ordering::SeqCst) {
1338 return (Some(process), PeerOutcome::Abandoned);
1339 }
1340 if let Some(status) = process.exit_status_now() {
1341 if deadline_passed() {
1342 return (Some(process), PeerOutcome::DeadlineReached);
1343 }
1344 return (
1345 Some(process),
1346 PeerOutcome::Failed(MemberFailureReason::ExitedBeforeDoorAnswered {
1347 status: describe_status(status),
1348 stderr_tail: stderr_tail.lines_at_end(),
1349 }),
1350 );
1351 }
1352 match door_answers_as(
1353 crate::running::probe_door_session_id(&report.url, report.door_token.as_ref()),
1354 &report.session_id,
1355 ) {
1356 Ok(()) => {
1357 let pid = process.pid;
1358 return (
1359 Some(process),
1360 PeerOutcome::Ready(ReadyPeer {
1361 name: member.name.clone(),
1362 capsule: member.capsule.clone(),
1363 version: member.version.clone(),
1364 pid,
1365 session_id: report.session_id,
1366 url: report.url,
1367 workdir,
1368 door_token: report.door_token,
1369 }),
1370 );
1371 }
1372 Err(why) => last_error = why,
1373 }
1374 let now = Instant::now();
1375 if now >= deadline && deadline_passed() {
1376 return (Some(process), PeerOutcome::DeadlineReached);
1377 }
1378 if now >= deadline {
1379 return (
1380 Some(process),
1381 PeerOutcome::Failed(MemberFailureReason::DoorDidNotAnswer {
1382 url: report.url,
1383 session_id: report.session_id,
1384 deadline: options.ready_timeout,
1385 last_error,
1386 }),
1387 );
1388 }
1389 std::thread::sleep(READY_PROBE_INTERVAL.min(deadline - now));
1390 }
1391}
1392
1393fn door_answers_as(probed: Result<String, String>, reported: &str) -> Result<(), String> {
1397 match probed {
1398 Ok(session_id) if session_id == reported => Ok(()),
1399 Ok(other) => Err(format!("the door answers as session {other}")),
1400 Err(error) => Err(error),
1401 }
1402}
1403
1404struct Report {
1406 session_id: String,
1407 url: String,
1409 door_token: Option<DoorToken>,
1410}
1411
1412fn read_report(line: &str, expected: &FormationId) -> Result<Report, MemberFailureReason> {
1414 let report: serde_json::Value =
1415 serde_json::from_str(line).map_err(|error| MemberFailureReason::UnreadableReport {
1416 why: format!("it does not parse as JSON ({error})"),
1417 })?;
1418 let reported = report
1419 .get("formation_id")
1420 .and_then(serde_json::Value::as_str);
1421 if reported != Some(expected.as_str()) {
1422 return Err(MemberFailureReason::WrongFormation {
1423 reported: reported.map(str::to_string),
1424 expected: expected.as_str().to_string(),
1425 });
1426 }
1427 let session_id = report
1428 .get("session_id")
1429 .and_then(serde_json::Value::as_str)
1430 .filter(|session_id| !session_id.is_empty())
1431 .ok_or_else(|| MemberFailureReason::UnreadableReport {
1432 why: "it names no session_id".to_string(),
1433 })?;
1434 let url = report
1435 .get("url")
1436 .and_then(serde_json::Value::as_str)
1437 .unwrap_or_default();
1438 if url.is_empty() {
1439 return Err(MemberFailureReason::ReportedNoDoor);
1440 }
1441 Ok(Report {
1442 session_id: session_id.to_string(),
1443 url: format!("http://{}", url.trim_start_matches("http://")),
1444 door_token: child_launch::readiness_door_token(&report),
1445 })
1446}
1447
1448pub fn launch_formation(
1456 roster: &AdmittedRoster,
1457 options: FormationLaunchOptions,
1458) -> Result<RunningFormation, MemberLaunchFailure> {
1459 launch_plan(FormationPlan::from_roster(roster), options)
1460}
1461
1462pub(crate) fn launch_plan(
1463 plan: FormationPlan,
1464 options: FormationLaunchOptions,
1465) -> Result<RunningFormation, MemberLaunchFailure> {
1466 install_panic_hook();
1467 let binary = match child_launch::mur_binary() {
1468 Ok(binary) => binary,
1469 Err(error) => {
1470 return Err(MemberLaunchFailure {
1471 failed: plan
1472 .peers
1473 .iter()
1474 .chain([&plan.entry])
1475 .map(|member| failure(member, not_started(&error.to_string())))
1476 .collect(),
1477 stopped: Vec::new(),
1478 interrupted_by: None,
1479 deadline_reached: false,
1480 })
1481 }
1482 };
1483
1484 let authority = match FormationAuthority::generate(&options.formation_id) {
1485 Ok(authority) => authority,
1486 Err(error) => {
1487 return Err(MemberLaunchFailure {
1488 failed: plan
1489 .peers
1490 .iter()
1491 .chain([&plan.entry])
1492 .map(|member| failure(member, not_started(&error)))
1493 .collect(),
1494 stopped: Vec::new(),
1495 interrupted_by: None,
1496 deadline_reached: false,
1497 })
1498 }
1499 };
1500 #[cfg(unix)]
1504 let (peer_lifelines, entry_lifeline) = match member_lifelines(plan.peers.len() + 1) {
1505 Ok(mut lifelines) => {
1506 let entry = lifelines.pop();
1507 (lifelines, entry)
1508 }
1509 Err(error) => {
1510 return Err(MemberLaunchFailure {
1511 failed: plan
1512 .peers
1513 .iter()
1514 .chain([&plan.entry])
1515 .map(|member| {
1516 failure(
1517 member,
1518 not_started(&format!("its lifeline could not be created: {error}")),
1519 )
1520 })
1521 .collect(),
1522 stopped: Vec::new(),
1523 interrupted_by: None,
1524 deadline_reached: false,
1525 })
1526 }
1527 };
1528 let launcher = ProcessIdentity::of_this_process();
1532 let first_lines: Vec<String> = plan
1533 .peers
1534 .iter()
1535 .map(|member| plan.first_line(&authority, launcher.as_ref(), &member.name))
1536 .collect();
1537 if !plan.peers.is_empty() {
1540 if let Err(reason) = write_formation_marker(&options) {
1541 crate::runtime_err!(
1542 "[mur run] warning: formation {} has no ownership marker ({reason}); its \
1543 directory will never be removed automatically",
1544 options.formation_id
1545 );
1546 }
1547 }
1548
1549 let abort = AtomicBool::new(false);
1550 let mut outcomes: Vec<Option<(Option<MemberProcess>, PeerOutcome)>> =
1551 plan.peers.iter().map(|_| None).collect();
1552 let mut interrupted_by = caught_signal();
1553 if interrupted_by.is_some() {
1554 abort.store(true, Ordering::SeqCst);
1555 }
1556 std::thread::scope(|scope| {
1557 let (tx, rx) = mpsc::channel();
1558 #[cfg(unix)]
1559 let mut peer_lifelines = peer_lifelines.into_iter();
1560 for (index, member) in plan.peers.iter().enumerate() {
1561 let tx = tx.clone();
1562 let (binary, options, abort) = (&binary, &options, &abort);
1563 let first_line = first_lines[index].as_str();
1564 #[cfg(unix)]
1565 let lifeline = peer_lifelines
1566 .next()
1567 .expect("member_lifelines made one lifeline per peer");
1568 scope.spawn(move || {
1569 let started = start_peer(
1570 binary,
1571 member,
1572 first_line,
1573 options,
1574 abort,
1575 #[cfg(unix)]
1576 lifeline,
1577 );
1578 let _ = tx.send((index, started));
1579 });
1580 }
1581 drop(tx);
1582 loop {
1583 match rx.recv_timeout(POLL_INTERVAL) {
1584 Ok((index, outcome)) => {
1585 if matches!(
1586 outcome.1,
1587 PeerOutcome::Failed(_) | PeerOutcome::DeadlineReached
1588 ) {
1589 abort.store(true, Ordering::SeqCst);
1590 }
1591 outcomes[index] = Some(outcome);
1592 }
1593 Err(mpsc::RecvTimeoutError::Timeout) => {}
1594 Err(mpsc::RecvTimeoutError::Disconnected) => break,
1595 }
1596 if interrupted_by.is_none() {
1597 if let Some(signal) = caught_signal() {
1598 interrupted_by = Some(signal);
1599 abort.store(true, Ordering::SeqCst);
1600 }
1601 }
1602 }
1603 });
1604
1605 let mut members = MemberSet(Vec::new());
1606 let mut peers = Vec::new();
1607 let mut failed = Vec::new();
1608 let mut deadline_reached = false;
1609 for (member, outcome) in plan.peers.iter().zip(outcomes) {
1610 let Some((process, outcome)) = outcome else {
1611 continue;
1612 };
1613 members.0.extend(process);
1614 match outcome {
1615 PeerOutcome::Ready(peer) => peers.push(peer),
1616 PeerOutcome::Failed(reason) => failed.push(failure(member, reason)),
1617 PeerOutcome::Abandoned => {}
1618 PeerOutcome::DeadlineReached => deadline_reached = true,
1619 }
1620 }
1621
1622 if !failed.is_empty()
1623 || interrupted_by.is_some()
1624 || deadline_reached
1625 || peers.len() != plan.peers.len()
1626 {
1627 let stopped = members.stop_all();
1628 return Err(MemberLaunchFailure {
1629 failed,
1630 stopped,
1631 interrupted_by,
1632 deadline_reached,
1633 });
1634 }
1635
1636 for member in &mut members.0 {
1639 let addresses = addresses_of(&peers, plan.callees_of(&member.name));
1640 if !addresses.is_empty() {
1641 member.send_line(&render_address_line(&addresses));
1642 }
1643 }
1644
1645 Ok(RunningFormation {
1646 formation_id: options.formation_id.clone(),
1647 entry: plan.entry.clone(),
1648 plan,
1649 peers,
1650 members,
1651 entry_index: None,
1652 #[cfg(unix)]
1653 entry_lifeline,
1654 binary,
1655 options,
1656 authority,
1657 launcher,
1658 })
1659}
1660
1661fn addresses_of(ready: &[ReadyPeer], callees: &[String]) -> Vec<FormationPeer> {
1663 ready
1664 .iter()
1665 .filter(|peer| callees.contains(&peer.name))
1666 .map(|peer| FormationPeer {
1667 name: peer.name.clone(),
1668 url: peer.url.clone(),
1669 })
1670 .collect()
1671}
1672
1673#[cfg(unix)]
1675fn member_lifelines(count: usize) -> std::io::Result<Vec<crate::lifeline::MemberLifeline>> {
1676 (0..count)
1677 .map(|_| crate::lifeline::MemberLifeline::new())
1678 .collect()
1679}
1680
1681fn failure(member: &PlannedMember, reason: MemberFailureReason) -> MemberFailure {
1682 MemberFailure {
1683 name: member.name.clone(),
1684 capsule: member.capsule.clone(),
1685 version: member.version.clone(),
1686 reason,
1687 }
1688}
1689
1690fn not_started(error: &str) -> MemberFailureReason {
1691 MemberFailureReason::NotStarted {
1692 error: error.to_string(),
1693 }
1694}
1695
1696pub struct RunningFormation {
1702 formation_id: FormationId,
1703 entry: PlannedMember,
1704 plan: FormationPlan,
1705 peers: Vec<ReadyPeer>,
1706 members: MemberSet,
1709 entry_index: Option<usize>,
1711 #[cfg(unix)]
1714 entry_lifeline: Option<crate::lifeline::MemberLifeline>,
1715 binary: PathBuf,
1716 options: FormationLaunchOptions,
1717 authority: FormationAuthority,
1718 launcher: Option<ProcessIdentity>,
1720}
1721
1722impl std::fmt::Debug for RunningFormation {
1723 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1724 f.debug_struct("RunningFormation")
1725 .field("formation_id", &self.formation_id)
1726 .field("entry", &self.entry.name)
1727 .field("peers", &self.peers)
1728 .finish()
1729 }
1730}
1731
1732impl RunningFormation {
1733 pub fn formation_id(&self) -> &FormationId {
1734 &self.formation_id
1735 }
1736
1737 pub fn entry_name(&self) -> &str {
1739 &self.entry.name
1740 }
1741
1742 pub fn peers(&self) -> &[ReadyPeer] {
1744 &self.peers
1745 }
1746
1747 pub fn callees_of(&self, member: &str) -> &[String] {
1750 self.plan.callees_of(member)
1751 }
1752
1753 pub fn entry_pid(&self) -> Option<u32> {
1755 self.entry_index.map(|index| self.members.0[index].pid)
1756 }
1757
1758 pub fn start_entry(&mut self) -> Result<u32, MemberLaunchFailure> {
1762 if self
1764 .options
1765 .deadline
1766 .is_some_and(|deadline| deadline.left(Instant::now()).is_zero())
1767 {
1768 let stopped = self.members.stop_all();
1769 return Err(MemberLaunchFailure {
1770 failed: Vec::new(),
1771 stopped,
1772 interrupted_by: None,
1773 deadline_reached: true,
1774 });
1775 }
1776 let args = entry_args(&self.entry, &self.options);
1777 let channel = Channel::open(&self.plan.first_line(
1779 &self.authority,
1780 self.launcher.as_ref(),
1781 &self.entry.name,
1782 ))
1783 .and_then(|mut channel| {
1784 let addresses = addresses_of(&self.peers, self.plan.callees_of(&self.entry.name));
1785 if !addresses.is_empty() {
1786 channel
1787 .writer
1788 .write_all(format!("{}\n", render_address_line(&addresses)).as_bytes())
1789 .map_err(|error| {
1790 not_started(&format!(
1791 "its formation channel could not be written: {error}"
1792 ))
1793 })?;
1794 }
1795 Ok(channel)
1796 });
1797 let channel = match channel {
1798 Ok(channel) => channel,
1799 Err(reason) => {
1800 let stopped = self.members.stop_all();
1801 return Err(MemberLaunchFailure {
1802 failed: vec![failure(&self.entry, reason)],
1803 stopped,
1804 interrupted_by: None,
1805 deadline_reached: false,
1806 });
1807 }
1808 };
1809 let set = channel.member_env(&self.formation_id);
1810 let started = child_launch::start_process(&ProcessLaunch {
1811 binary: &self.binary,
1812 args: &args,
1813 cwd: None,
1814 env: ProcessEnv::Inherited {
1815 set: &set,
1816 remove: &[SPAWNER_ENV, FORMATION_PEERS_ENV, SPAWNER_LIFELINE_ENV],
1817 },
1818 stdin_line: None,
1819 stderr_prefix: None,
1820 inherit_output: true,
1821 own_process_group: false,
1822 inherit_fd: Some(channel.fd()),
1823 #[cfg(unix)]
1824 lifeline: self
1825 .entry_lifeline
1826 .as_ref()
1827 .map(crate::child_launch::Lifeline::Member),
1828 });
1829 let Channel { writer, reader } = channel;
1830 drop(reader);
1831 match started {
1832 Ok(started) => {
1833 #[cfg(unix)]
1834 let lifeline = self.entry_lifeline.take().map(|mut lifeline| {
1835 lifeline.spawned();
1836 lifeline
1837 });
1838 let process = MemberProcess::new(
1839 &self.entry.name,
1840 started.child,
1841 false,
1842 self.options.stop_grace,
1843 writer,
1844 #[cfg(unix)]
1845 lifeline,
1846 );
1847 let pid = process.pid;
1848 self.members.0.push(process);
1849 self.entry_index = Some(self.members.0.len() - 1);
1850 Ok(pid)
1851 }
1852 Err(error) => {
1853 let stopped = self.members.stop_all();
1854 Err(MemberLaunchFailure {
1855 failed: vec![failure(
1856 &self.entry,
1857 not_started(&format!("{}: {error}", self.binary.display())),
1858 )],
1859 stopped,
1860 interrupted_by: None,
1861 deadline_reached: false,
1862 })
1863 }
1864 }
1865 }
1866
1867 pub fn wait(mut self) -> FormationExit {
1870 let ending = match self.entry_index {
1871 None => caught_signal()
1872 .map(FormationEnding::Signalled)
1873 .unwrap_or(FormationEnding::EntryExited(ExitStatus::default())),
1874 Some(index) => loop {
1875 if let Some(status) = self.members.0[index].exit_status_now() {
1876 break FormationEnding::EntryExited(status);
1877 }
1878 if let Some(signal) = caught_signal() {
1879 break FormationEnding::Signalled(signal);
1880 }
1881 std::thread::sleep(POLL_INTERVAL);
1882 },
1883 };
1884 let stopped = self.members.stop_all();
1885 FormationExit { ending, stopped }
1886 }
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891 use super::*;
1892 use std::io::{Read, Write};
1893 use std::net::TcpListener;
1894 use std::sync::atomic::AtomicUsize;
1895 use std::sync::Arc;
1896
1897 const LIVENESS_BOUND: Duration = Duration::from_secs(120);
1901
1902 fn load_average() -> String {
1904 std::fs::read_to_string("/proc/loadavg")
1905 .unwrap_or_default()
1906 .split(' ')
1907 .take(3)
1908 .collect::<Vec<_>>()
1909 .join(" ")
1910 }
1911
1912 #[test]
1915 fn the_production_bounds_are_180s_and_a_grace_past_the_runtime_teardown() {
1916 assert_eq!(MEMBER_READY_TIMEOUT, Duration::from_secs(180));
1917 assert_eq!(MEMBER_STOP_GRACE, Duration::from_secs(25));
1918 assert!(MEMBER_STOP_GRACE > crate::runtime::TERMINATE_TEARDOWN_DEADLINE);
1919 }
1920
1921 fn member(name: &str) -> PlannedMember {
1922 PlannedMember {
1923 name: name.to_string(),
1924 capsule: name.to_string(),
1925 version: "0.1.0".to_string(),
1926 sha256: "ab".repeat(32),
1927 }
1928 }
1929
1930 #[test]
1933 fn every_member_is_told_host_warnings_were_reported() {
1934 let channel = Channel::open("first").expect("a channel opens");
1935 let env = channel.member_env(&FormationId::mint());
1936 let values: Vec<&str> = env
1937 .iter()
1938 .filter(|(key, _)| key == crate::host_warnings::HOST_WARNINGS_REPORTED_ENV)
1939 .map(|(_, value)| value.as_str())
1940 .collect();
1941 assert_eq!(values, vec!["1"], "{env:?}");
1942 }
1943
1944 fn options(deadline: Duration) -> FormationLaunchOptions {
1945 let mut options =
1946 FormationLaunchOptions::new(FormationId::mint(), PathBuf::from("/tmp/project"));
1947 options.ready_timeout = deadline;
1948 options.stop_grace = Duration::from_secs(2);
1949 options.formations_dir = Some(tempfile::tempdir().unwrap().keep());
1951 options
1952 }
1953
1954 #[test]
1955 fn member_argv_is_exactly_the_documented_shape() {
1956 let mut options = options(MEMBER_READY_TIMEOUT);
1957 let coder = member("coder");
1958 let workdir = PathBuf::from("/home/u/.murmur/formations/frm_x/coder");
1959 assert_eq!(
1960 peer_args(&coder, &options, &workdir),
1961 [
1962 "run",
1963 "--capsule",
1964 "coder",
1965 "--capsule-version",
1966 "0.1.0",
1967 "--capsule-sha256",
1968 &"ab".repeat(32),
1969 "--workdir",
1970 "/home/u/.murmur/formations/frm_x/coder",
1971 "--store-root",
1972 "/tmp/project",
1973 "--json",
1974 "--readiness-tokens",
1975 "--bind",
1976 "127.0.0.1",
1977 ]
1978 );
1979 options.task = Some("probe".to_string());
1980 options.json = true;
1981 options.verbose = true;
1982 options.no_env_file = true;
1983 options.containment = Some("scoped".to_string());
1984 let entry = entry_args(&member("planner"), &options);
1985 assert_eq!(&entry[7..9], ["--workdir", "/tmp/project"]);
1986 assert_eq!(
1987 &entry[9..],
1988 [
1989 "--lifecycle-task-acceptance",
1990 "single",
1991 "--lifecycle-after-task",
1992 "exit",
1993 "--task",
1994 "probe",
1995 "--json",
1996 "--verbose",
1997 "--no-env-file",
1998 "--containment",
1999 "scoped",
2000 ]
2001 );
2002 assert!(
2003 !entry.iter().any(|arg| arg == "--bind"
2004 || arg == STORE_ROOT_FLAG
2005 || arg == child_launch::READINESS_TOKENS_FLAG),
2006 "{entry:?}"
2007 );
2008 let peer = peer_args(&coder, &options, &workdir);
2009 assert!(peer.ends_with(&[
2010 "--no-env-file".to_string(),
2011 "--containment".to_string(),
2012 "scoped".to_string()
2013 ]));
2014 assert!(!peer.iter().any(|arg| arg == "--task" || arg == "--verbose"));
2015 assert!(
2016 !peer
2017 .iter()
2018 .chain(&entry)
2019 .any(|arg| arg == INHERITED_DEADLINE_FLAG),
2020 "no deadline, no flag: {peer:?} {entry:?}"
2021 );
2022
2023 options.task = None;
2026 let untasked = entry_args(&member("planner"), &options);
2027 assert_eq!(
2028 &untasked[9..],
2029 [
2030 "--lifecycle-task-acceptance",
2031 "queue",
2032 "--lifecycle-after-task",
2033 "sleep",
2034 "--json",
2035 "--verbose",
2036 "--no-env-file",
2037 "--containment",
2038 "scoped",
2039 ]
2040 );
2041 assert_eq!(peer_args(&coder, &options, &workdir), peer);
2042 options.task = Some("probe".to_string());
2043
2044 let deadline = SessionDeadline::after(
2046 Instant::now(),
2047 std::time::SystemTime::UNIX_EPOCH + Duration::from_millis(1_792_497_600_250),
2048 60,
2049 );
2050 options.deadline = Some(deadline);
2051 let flag = [
2052 INHERITED_DEADLINE_FLAG.to_string(),
2053 "1792497660250".to_string(),
2054 ];
2055 let peer = peer_args(&coder, &options, &workdir);
2056 assert!(peer.ends_with(&flag), "{peer:?}");
2057 let entry = entry_args(&member("planner"), &options);
2058 assert!(entry.ends_with(&flag), "{entry:?}");
2059 assert_eq!(
2060 entry
2061 .iter()
2062 .filter(|arg| *arg == INHERITED_DEADLINE_FLAG)
2063 .count(),
2064 1
2065 );
2066 }
2067
2068 fn binary_guard() -> std::sync::MutexGuard<'static, ()> {
2071 crate::formation::FORMATION_ENV_LOCK
2072 .lock()
2073 .unwrap_or_else(|poisoned| poisoned.into_inner())
2074 }
2075
2076 fn stand_in(dir: &Path, line: Option<&str>) -> PathBuf {
2079 let script = dir.join("mur");
2080 let print = match line {
2081 Some(line) => {
2082 std::fs::write(dir.join("line"), format!("{line}\n")).unwrap();
2083 format!("cat '{}'\n", dir.join("line").display())
2084 }
2085 None => String::new(),
2086 };
2087 let body = format!(
2090 "#!/bin/sh\ntrap 'echo TERM >> \"{dir}/sigterm\"' TERM\n{print}\
2091 cat \"/dev/fd/$MURMUR_FORMATION_LIFELINE\" >/dev/null\n",
2092 dir = dir.display()
2093 );
2094 std::fs::write(&script, body).unwrap();
2095 use std::os::unix::fs::PermissionsExt;
2096 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
2097 script
2098 }
2099
2100 fn plan_of(peer: &str) -> FormationPlan {
2101 FormationPlan {
2102 peers: vec![member(peer)],
2103 entry: member("planner"),
2104 callees: vec![("planner".to_string(), vec![peer.to_string()])],
2105 }
2106 }
2107
2108 fn refused(line: Option<&str>, deadline: Duration) -> (MemberLaunchFailure, FormationId) {
2111 let _guard = binary_guard();
2112 let dir = tempfile::tempdir().unwrap();
2113 let options = options(deadline);
2114 let id = options.formation_id.clone();
2115 let line = line.map(|line| line.replace("{formation}", id.as_str()));
2116 std::env::set_var(
2117 child_launch::MUR_BINARY_ENV,
2118 stand_in(dir.path(), line.as_deref()),
2119 );
2120 let result = launch_plan(plan_of("coder"), options);
2121 std::env::remove_var(child_launch::MUR_BINARY_ENV);
2122 match result {
2123 Ok(_) => panic!("the launch was expected to be refused"),
2124 Err(failure) => (failure, id),
2125 }
2126 }
2127
2128 fn assert_reaped(failure: &MemberLaunchFailure) {
2129 assert_eq!(failure.stopped.len(), 1, "{failure:?}");
2130 let pid = failure.stopped[0].pid;
2131 assert_eq!(failure.stopped[0].name, "coder");
2132 assert!(
2133 !crate::running::pid_is_alive(pid),
2134 "the stand-in {pid} is still running"
2135 );
2136 assert!(
2137 !Path::new(&format!("/proc/{pid}")).exists() || cfg!(not(target_os = "linux")),
2138 "the stand-in {pid} was not reaped"
2139 );
2140 }
2141
2142 fn only_reason(failure: &MemberLaunchFailure) -> &MemberFailureReason {
2143 assert_eq!(failure.failed.len(), 1, "{failure:?}");
2144 assert_eq!(failure.failed[0].name, "coder");
2145 &failure.failed[0].reason
2146 }
2147
2148 fn silent_port() -> u16 {
2150 crate::pinned_port::claim()
2151 }
2152
2153 #[test]
2154 fn a_door_nothing_answers_on_is_refused_at_the_deadline() {
2155 let deadline = Duration::from_secs(10);
2156 let port = silent_port();
2157 let started = Instant::now();
2158 let (failure, _) = refused(
2159 Some(&format!(
2160 r#"{{"formation_id":"{{formation}}","session_id":"ses_standin","url":"localhost:{port}"}}"#
2161 )),
2162 deadline,
2163 );
2164 assert!(started.elapsed() >= deadline);
2165 match only_reason(&failure) {
2166 MemberFailureReason::DoorDidNotAnswer {
2167 url, session_id, ..
2168 } => {
2169 assert_eq!(url, &format!("http://localhost:{port}"));
2170 assert_eq!(session_id, "ses_standin");
2171 }
2172 MemberFailureReason::DidNotReport { .. } => panic!(
2175 "the stand-in's readiness line did not arrive within {deadline:?} (load average {}); \
2176 host load is the likely cause, since the stand-in prints it at once: {failure:?}",
2177 load_average()
2178 ),
2179 other => panic!("{other:?}"),
2180 }
2181 let message = failure.to_string();
2182 assert!(
2183 message.contains("did not answer as session ses_standin"),
2184 "{message}"
2185 );
2186 assert!(message.contains("'coder' (coder@0.1.0)"), "{message}");
2187 assert_reaped(&failure);
2188 }
2189
2190 #[test]
2194 fn a_formation_deadline_before_the_entry_member_ends_the_launch_at_the_deadline() {
2195 let _guard = binary_guard();
2196 let dir = tempfile::tempdir().unwrap();
2197 let mut options = options(Duration::from_secs(60));
2198 let started = Instant::now();
2199 options.deadline = Some(SessionDeadline::after(
2200 started,
2201 std::time::SystemTime::now(),
2202 2,
2203 ));
2204 std::env::set_var(child_launch::MUR_BINARY_ENV, stand_in(dir.path(), None));
2205 let result = launch_plan(plan_of("coder"), options);
2206 std::env::remove_var(child_launch::MUR_BINARY_ENV);
2207 let Err(failure) = result else {
2208 panic!("the launch was expected to end at the deadline");
2209 };
2210 let took = started.elapsed();
2211 assert!(took >= Duration::from_secs(2), "{took:?}");
2212 assert!(took < Duration::from_secs(20), "{took:?}: {failure:?}");
2213 assert!(failure.deadline_reached, "{failure:?}");
2214 assert!(failure.failed.is_empty(), "{failure:?}");
2215 assert_eq!(failure.interrupted_by, None);
2216 assert_reaped(&failure);
2217 assert_eq!(
2218 failure.to_string(),
2219 format!(
2220 "the formation reached its deadline before its entry member started\n \
2221 stopped: coder (pid {})",
2222 failure.stopped[0].pid
2223 )
2224 );
2225 }
2226
2227 #[test]
2229 fn start_entry_after_the_formation_deadline_starts_nothing() {
2230 let _guard = binary_guard();
2231 let dir = tempfile::tempdir().unwrap();
2232 let mut options = options(Duration::from_secs(60));
2233 let started = Instant::now();
2234 options.deadline = Some(SessionDeadline::after(
2235 started,
2236 std::time::SystemTime::now(),
2237 1,
2238 ));
2239 std::env::set_var(child_launch::MUR_BINARY_ENV, stand_in(dir.path(), None));
2240 let plan = FormationPlan {
2241 peers: Vec::new(),
2242 entry: member("planner"),
2243 callees: Vec::new(),
2244 };
2245 let launched = launch_plan(plan, options);
2246 std::env::remove_var(child_launch::MUR_BINARY_ENV);
2247 let mut formation = launched.expect("a formation with no peers launches at once");
2248 std::thread::sleep(Duration::from_millis(1_100));
2249 let failure = formation
2250 .start_entry()
2251 .expect_err("the deadline has fallen");
2252 assert!(failure.deadline_reached, "{failure:?}");
2253 assert!(
2254 failure.failed.is_empty() && failure.stopped.is_empty(),
2255 "{failure:?}"
2256 );
2257 assert_eq!(formation.entry_pid(), None);
2258 }
2259
2260 fn card_door(mut answer: impl FnMut() -> Option<&'static str> + Send + 'static) -> u16 {
2264 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2265 let port = listener.local_addr().unwrap().port();
2266 std::thread::spawn(move || {
2267 for stream in listener.incoming().flatten() {
2268 let mut stream = stream;
2269 let mut buffer = [0u8; 4096];
2270 let _ = stream.read(&mut buffer);
2271 let body = match answer() {
2272 Some(session_id) => serde_json::json!({
2273 "name": "coder",
2274 "capabilities": {"extensions": [{
2275 "uri": crate::identity::CAPSULE_EXTENSION_URI,
2276 "params": {"sessionId": session_id},
2277 }]},
2278 })
2279 .to_string(),
2280 None => "{}".to_string(),
2281 };
2282 let _ = write!(
2283 stream,
2284 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\
2285 connection: close\r\n\r\n{body}",
2286 body.len()
2287 );
2288 }
2289 });
2290 port
2291 }
2292
2293 fn door(session_id: &'static str, answer_after: Duration) -> u16 {
2296 let opened = Instant::now();
2297 card_door(move || (opened.elapsed() >= answer_after).then_some(session_id))
2298 }
2299
2300 const WRONG_ANSWERS: usize = 3;
2302
2303 fn door_naming_another_session_first() -> (u16, Arc<AtomicUsize>) {
2307 let served = Arc::new(AtomicUsize::new(0));
2308 let counted = Arc::clone(&served);
2309 let port = card_door(move || {
2310 if counted.fetch_add(1, Ordering::SeqCst) < WRONG_ANSWERS {
2311 Some("ses_somebody_else")
2312 } else {
2313 Some("ses_standin")
2314 }
2315 });
2316 (port, served)
2317 }
2318
2319 #[test]
2322 fn a_door_naming_another_session_is_not_ready() {
2323 let _guard = binary_guard();
2324 let (port, served) = door_naming_another_session_first();
2325 let dir = tempfile::tempdir().unwrap();
2326 let options = options(LIVENESS_BOUND);
2327 let line = format!(
2328 r#"{{"formation_id":"{}","session_id":"ses_standin","url":"localhost:{port}"}}"#,
2329 options.formation_id
2330 );
2331 std::env::set_var(
2332 child_launch::MUR_BINARY_ENV,
2333 stand_in(dir.path(), Some(&line)),
2334 );
2335 let result = launch_plan(plan_of("coder"), options);
2336 std::env::remove_var(child_launch::MUR_BINARY_ENV);
2337 let formation = match result {
2338 Ok(formation) => formation,
2339 Err(failure) => match only_reason(&failure) {
2340 MemberFailureReason::DidNotReport { .. } => panic!(
2341 "the stand-in's readiness line did not arrive within {LIVENESS_BOUND:?} \
2342 (load average {}); host load is the likely cause: {failure:?}",
2343 load_average()
2344 ),
2345 _ => panic!(
2346 "the launch was refused although the door answered as ses_standin from its \
2347 request {} on: {failure:?}",
2348 WRONG_ANSWERS + 1
2349 ),
2350 },
2351 };
2352 let peer = &formation.peers()[0];
2353 assert_eq!(peer.session_id, "ses_standin");
2354 let served = served.load(Ordering::SeqCst);
2355 assert!(
2356 served > WRONG_ANSWERS,
2357 "an answer naming another session was counted as ready: the door served {served} \
2358 request(s), and its first {WRONG_ANSWERS} named ses_somebody_else"
2359 );
2360 let pid = peer.pid;
2361 drop(formation);
2362 assert!(
2363 !crate::running::pid_is_alive(pid),
2364 "dropping the owner stops the peer"
2365 );
2366 }
2367
2368 #[test]
2369 fn a_door_counts_as_ready_only_when_it_answers_as_the_reported_session() {
2370 let port = door("ses_somebody_else", Duration::ZERO);
2371 let probed = crate::running::probe_door_session_id(&format!("localhost:{port}"), None);
2372 match door_answers_as(probed, "ses_standin") {
2373 Err(why) => assert!(
2374 why.contains("the door answers as session ses_somebody_else"),
2375 "{why}"
2376 ),
2377 Ok(()) => panic!("a door answering as ses_somebody_else counted as ses_standin"),
2378 }
2379 assert_eq!(
2380 door_answers_as(Ok("ses_standin".to_string()), "ses_standin"),
2381 Ok(())
2382 );
2383 assert_eq!(
2384 door_answers_as(Err("connection refused".to_string()), "ses_standin"),
2385 Err("connection refused".to_string())
2386 );
2387 }
2388
2389 #[test]
2390 fn an_empty_url_is_refused_as_no_door() {
2391 let (failure, _) = refused(
2392 Some(r#"{"formation_id":"{formation}","session_id":"ses_standin","url":""}"#),
2393 Duration::from_secs(10),
2394 );
2395 assert_eq!(only_reason(&failure), &MemberFailureReason::ReportedNoDoor);
2396 assert!(failure.to_string().contains("it reported no door"));
2397 assert_reaped(&failure);
2398 }
2399
2400 #[test]
2401 fn a_wrong_formation_or_none_is_refused_naming_what_was_reported() {
2402 let other = FormationId::mint();
2403 let (failure, id) = refused(
2404 Some(&format!(
2405 r#"{{"formation_id":"{}","session_id":"ses_standin","url":"localhost:1"}}"#,
2406 other.as_str()
2407 )),
2408 Duration::from_secs(10),
2409 );
2410 assert_eq!(
2411 only_reason(&failure),
2412 &MemberFailureReason::WrongFormation {
2413 reported: Some(other.as_str().to_string()),
2414 expected: id.as_str().to_string(),
2415 }
2416 );
2417 assert!(failure
2418 .to_string()
2419 .contains(&format!("it reported formation {other} instead of {id}")));
2420 assert_reaped(&failure);
2421
2422 let (failure, id) = refused(
2423 Some(r#"{"session_id":"ses_standin","url":"localhost:1"}"#),
2424 Duration::from_secs(10),
2425 );
2426 assert!(failure
2427 .to_string()
2428 .contains(&format!("it reported no formation instead of {id}")));
2429 assert_reaped(&failure);
2430 }
2431
2432 #[test]
2433 fn no_line_at_all_is_refused_at_the_deadline() {
2434 let started = Instant::now();
2435 let (failure, _) = refused(None, Duration::from_millis(600));
2436 assert!(started.elapsed() >= Duration::from_millis(600));
2437 assert_eq!(
2438 only_reason(&failure),
2439 &MemberFailureReason::DidNotReport {
2440 deadline: Duration::from_millis(600)
2441 }
2442 );
2443 assert_reaped(&failure);
2444 }
2445
2446 #[test]
2447 fn a_door_that_starts_answering_before_the_deadline_is_ready() {
2448 let _guard = binary_guard();
2449 let port = door("ses_standin", Duration::from_millis(400));
2450 let dir = tempfile::tempdir().unwrap();
2451 let options = options(Duration::from_secs(20));
2452 let line = format!(
2453 r#"{{"formation_id":"{}","session_id":"ses_standin","url":"localhost:{port}"}}"#,
2454 options.formation_id
2455 );
2456 std::env::set_var(
2457 child_launch::MUR_BINARY_ENV,
2458 stand_in(dir.path(), Some(&line)),
2459 );
2460 let result = launch_plan(plan_of("coder"), options);
2461 std::env::remove_var(child_launch::MUR_BINARY_ENV);
2462 let formation = result.expect("the door answered before the deadline");
2463 assert_eq!(formation.peers().len(), 1);
2464 let peer = &formation.peers()[0];
2465 assert_eq!(peer.session_id, "ses_standin");
2466 assert_eq!(peer.url, format!("http://localhost:{port}"));
2467 assert_eq!(formation.callees_of("planner"), ["coder"]);
2468 assert!(formation.callees_of("coder").is_empty());
2469 let pid = peer.pid;
2470 assert!(crate::running::pid_is_alive(pid));
2471 let dropped = Instant::now();
2472 drop(formation);
2473 assert!(
2474 !crate::running::pid_is_alive(pid),
2475 "dropping the owner stops the peer"
2476 );
2477 assert!(
2478 dropped.elapsed() < Duration::from_secs(2),
2479 "the peer ended on its lifeline's EOF, inside the grace: {:?}",
2480 dropped.elapsed()
2481 );
2482 assert!(
2483 !dir.path().join("sigterm").exists(),
2484 "the launcher sent the peer SIGTERM"
2485 );
2486 }
2487
2488 #[test]
2490 fn a_member_deaf_to_its_lifeline_is_killed_after_the_grace() {
2491 let _guard = binary_guard();
2492 let port = door("ses_standin", Duration::ZERO);
2493 let dir = tempfile::tempdir().unwrap();
2494 let mut options = options(Duration::from_secs(20));
2495 options.stop_grace = Duration::from_millis(600);
2496 let line = format!(
2497 r#"{{"formation_id":"{}","session_id":"ses_standin","url":"localhost:{port}"}}"#,
2498 options.formation_id
2499 );
2500 let script = dir.path().join("mur");
2501 std::fs::write(dir.path().join("line"), format!("{line}\n")).unwrap();
2502 std::fs::write(
2503 &script,
2504 format!(
2505 "#!/bin/sh\ncat '{}'\nexec sleep 600\n",
2506 dir.path().join("line").display()
2507 ),
2508 )
2509 .unwrap();
2510 use std::os::unix::fs::PermissionsExt;
2511 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
2512 std::env::set_var(child_launch::MUR_BINARY_ENV, &script);
2513 let result = launch_plan(plan_of("coder"), options);
2514 std::env::remove_var(child_launch::MUR_BINARY_ENV);
2515 let formation = result.expect("the door answers");
2516 let pid = formation.peers()[0].pid;
2517 let dropped = Instant::now();
2518 drop(formation);
2519 assert!(dropped.elapsed() >= Duration::from_millis(600));
2520 assert!(!crate::running::pid_is_alive(pid));
2521 }
2522
2523 #[test]
2528 fn each_member_reads_only_its_own_credentials_and_callees_on_its_channel() {
2529 use crate::formation_credentials::{parse_address_line, FormationBundle};
2530 let _guard = binary_guard();
2531 let dir = tempfile::tempdir().unwrap();
2532 let coder_port = door("ses_coder", Duration::ZERO);
2533 let reviewer_port = door("ses_reviewer", Duration::ZERO);
2534 let options = options(Duration::from_secs(20));
2535 let id = options.formation_id.clone();
2536 let line = |session: &str, port: u16| {
2537 format!(
2538 r#"{{"formation_id":"{id}","session_id":"{session}","url":"localhost:{port}"}}"#
2539 )
2540 };
2541 let out = dir.path().display().to_string();
2542 let script = dir.path().join("mur");
2543 std::fs::write(
2544 &script,
2545 format!(
2546 "#!/bin/sh\n\
2547 case \" $* \" in\n\
2548 *' --capsule coder '*) name=coder; line='{coder}';;\n\
2549 *' --capsule reviewer '*) name=reviewer; line='{reviewer}';;\n\
2550 *) name=planner; line='';;\n\
2551 esac\n\
2552 env > '{out}/'$name.env\n\
2553 printf '%s\\n' \"$*\" > '{out}/'$name.argv\n\
2554 cat /dev/fd/$MURMUR_FORMATION_CHANNEL >> '{out}/'$name.channel &\n\
2555 [ -n \"$line\" ] && printf '%s\\n' \"$line\"\n\
2556 exec sleep 600\n",
2557 coder = line("ses_coder", coder_port),
2558 reviewer = line("ses_reviewer", reviewer_port),
2559 ),
2560 )
2561 .unwrap();
2562 use std::os::unix::fs::PermissionsExt;
2563 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
2564 std::env::set_var(child_launch::MUR_BINARY_ENV, &script);
2565 let plan = FormationPlan {
2566 peers: vec![member("coder"), member("reviewer")],
2567 entry: member("planner"),
2568 callees: vec![
2571 ("planner".to_string(), vec!["coder".to_string()]),
2572 ("coder".to_string(), vec!["reviewer".to_string()]),
2573 ],
2574 };
2575 let result = launch_plan(plan, options);
2576 let mut formation = result.expect("both stand-ins came up");
2577 formation.start_entry().expect("the entry stand-in starts");
2578 std::env::remove_var(child_launch::MUR_BINARY_ENV);
2579
2580 let read_lines = |name: &str, count: usize| -> Vec<String> {
2581 let path = dir.path().join(format!("{name}.channel"));
2582 let deadline = Instant::now() + Duration::from_secs(10);
2583 loop {
2584 let lines: Vec<String> = std::fs::read_to_string(&path)
2585 .unwrap_or_default()
2586 .lines()
2587 .map(str::to_string)
2588 .collect();
2589 if lines.len() >= count || Instant::now() >= deadline {
2590 return lines;
2591 }
2592 std::thread::sleep(POLL_INTERVAL);
2593 }
2594 };
2595 let coder = read_lines("coder", 2);
2596 let planner = read_lines("planner", 2);
2597 let reviewer = read_lines("reviewer", 1);
2598 std::thread::sleep(Duration::from_millis(300));
2599 assert_eq!(read_lines("reviewer", 1).len(), 1, "reviewer calls nobody");
2600
2601 let bundle = |line: &str| FormationBundle::parse_line(line).expect("a member's bundle");
2602 let names = |bundle: &FormationBundle| -> Vec<String> {
2603 bundle.calls.iter().map(|(name, _)| name.clone()).collect()
2604 };
2605 let (coder_bundle, planner_bundle, reviewer_bundle) =
2606 (bundle(&coder[0]), bundle(&planner[0]), bundle(&reviewer[0]));
2607 assert_eq!(coder_bundle.member, "coder");
2608 assert_eq!(names(&coder_bundle), ["reviewer"]);
2609 assert_eq!(names(&planner_bundle), ["coder"]);
2610 assert!(names(&reviewer_bundle).is_empty());
2611 let launcher = crate::running::ProcessIdentity::of_this_process();
2614 assert!(launcher.is_some());
2615 for bundle in [&coder_bundle, &planner_bundle, &reviewer_bundle] {
2616 assert_eq!(bundle.formation_id, id);
2617 assert_eq!(bundle.verify_key, coder_bundle.verify_key);
2618 assert_eq!(bundle.launcher, launcher, "{}", bundle.member);
2619 }
2620 assert!(planner_bundle.entry);
2622 assert!(!coder_bundle.entry && !reviewer_bundle.entry);
2623 assert!(planner[0].contains("\"entry\":true"), "{planner:?}");
2624 assert!(
2625 !coder[0].contains("\"entry\"") && !reviewer[0].contains("\"entry\""),
2626 "{coder:?} {reviewer:?}"
2627 );
2628 assert!(!planner.join("\n").contains("reviewer"), "{planner:?}");
2629 let addresses = |line: &str| -> Vec<(String, String)> {
2630 parse_address_line(line)
2631 .unwrap()
2632 .into_iter()
2633 .map(|peer| (peer.name, peer.url))
2634 .collect()
2635 };
2636 assert_eq!(
2637 addresses(&coder[1]),
2638 [(
2639 "reviewer".to_string(),
2640 format!("http://localhost:{reviewer_port}")
2641 )]
2642 );
2643 assert_eq!(
2644 addresses(&planner[1]),
2645 [(
2646 "coder".to_string(),
2647 format!("http://localhost:{coder_port}")
2648 )]
2649 );
2650
2651 for name in ["coder", "reviewer", "planner"] {
2652 let env = std::fs::read_to_string(dir.path().join(format!("{name}.env"))).unwrap();
2653 let argv = std::fs::read_to_string(dir.path().join(format!("{name}.argv"))).unwrap();
2654 assert!(!env.contains("mft1.") && !argv.contains("mft1."), "{name}");
2655 assert!(!env.contains(FORMATION_PEERS_ENV), "{name}: {env}");
2656 assert!(
2657 env.contains(&format!("{FORMATION_CHANNEL_ENV}=")),
2658 "{name}: {env}"
2659 );
2660 }
2661 let pids: Vec<u32> = formation.peers().iter().map(|peer| peer.pid).collect();
2662 drop(formation);
2663 for pid in pids {
2664 assert!(!crate::running::pid_is_alive(pid), "{pid}");
2665 }
2666 }
2667
2668 #[test]
2669 fn a_registered_member_is_killed_by_the_panic_hooks_sweep() {
2670 use std::os::unix::process::CommandExt;
2671 let _guard = binary_guard();
2673 let mut child = std::process::Command::new("sleep")
2674 .arg("600")
2675 .process_group(0)
2676 .spawn()
2677 .unwrap();
2678 let slot = RegistrySlot::register(child.id(), true);
2679 assert!(slot.0.is_some());
2680 kill_registered_members();
2681 let status = child.wait().unwrap();
2682 use std::os::unix::process::ExitStatusExt;
2683 assert_eq!(status.signal(), Some(libc::SIGKILL));
2684 drop(slot);
2685 assert!(MEMBER_REGISTRY
2686 .iter()
2687 .all(|slot| slot.load(Ordering::SeqCst) != i64::from(child.id())));
2688 }
2689
2690 #[test]
2691 fn a_launch_failure_names_each_member_and_every_stopped_one() {
2692 let failure = MemberLaunchFailure {
2693 failed: vec![failure(
2694 &member("broken"),
2695 MemberFailureReason::ExitedBeforeReporting {
2696 status: "status 1".to_string(),
2697 stderr_tail: vec!["error[E-RUN-008]: missing".to_string()],
2698 },
2699 )],
2700 stopped: vec![
2701 StoppedMember {
2702 name: "coder".to_string(),
2703 pid: 7,
2704 },
2705 StoppedMember {
2706 name: "reviewer".to_string(),
2707 pid: 8,
2708 },
2709 ],
2710 interrupted_by: None,
2711 deadline_reached: false,
2712 };
2713 assert_eq!(
2714 failure.to_string(),
2715 "the formation could not be launched\n 'broken' (broken@0.1.0): its process exited \
2716 with status 1 before reporting; its last stderr lines were:\n \
2717 error[E-RUN-008]: missing\n stopped: coder (pid 7), reviewer (pid 8)"
2718 );
2719 }
2720
2721 #[test]
2722 fn ready_peer_debug_prints_no_door_token() {
2723 let peer = ReadyPeer {
2724 name: "coder".to_string(),
2725 capsule: "coder".to_string(),
2726 version: "1.2.0".to_string(),
2727 pid: 1,
2728 session_id: "ses_x".to_string(),
2729 url: "http://localhost:1".to_string(),
2730 workdir: PathBuf::from("/home/u/.murmur/formations/frm_x/coder"),
2731 door_token: Some(DoorToken::new("mdt1.secretpayload.mac".to_string())),
2732 };
2733 let debug = format!("{peer:?}");
2734 assert!(!debug.contains("secretpayload"), "{debug}");
2735 assert!(debug.contains("<redacted>"), "{debug}");
2736 }
2737
2738 #[test]
2740 fn a_member_directory_is_made_owner_only_under_its_formation() {
2741 use std::os::unix::fs::PermissionsExt;
2742
2743 let home = tempfile::tempdir().unwrap();
2744 let formations = home.path().join(FORMATIONS_DIR);
2745 let id = FormationId::mint();
2746 let workdir = make_member_workdir(&formations, &id, "worker").unwrap();
2747 assert_eq!(workdir, formations.join(id.as_str()).join("worker"));
2748 for dir in [&formations, &formations.join(id.as_str()), &workdir] {
2749 let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
2750 assert_eq!(mode, 0o700, "{}", dir.display());
2751 }
2752 }
2753
2754 #[test]
2757 fn an_existing_member_path_is_refused_and_named() {
2758 let home = tempfile::tempdir().unwrap();
2759 let formations = home.path().join(FORMATIONS_DIR);
2760 let id = FormationId::mint();
2761 make_member_workdir(&formations, &id, "worker").unwrap();
2762 let again = make_member_workdir(&formations, &id, "worker").unwrap_err();
2763 let path = formations.join(id.as_str()).join("worker");
2764 assert!(again.contains(&path.display().to_string()), "{again}");
2765 std::os::unix::fs::symlink(home.path(), formations.join(id.as_str()).join("linked"))
2766 .unwrap();
2767 assert!(make_member_workdir(&formations, &id, "linked").is_err());
2768 }
2769
2770 #[test]
2772 fn a_peer_whose_directory_cannot_be_made_refuses_the_launch() {
2773 let _guard = binary_guard();
2774 let dir = tempfile::tempdir().unwrap();
2775 let options = options(Duration::from_secs(5));
2776 let formations = options.formations_dir.clone().unwrap();
2777 let taken = formations.join(options.formation_id.as_str()).join("coder");
2778 std::fs::create_dir_all(&taken).unwrap();
2779 std::env::set_var(child_launch::MUR_BINARY_ENV, stand_in(dir.path(), None));
2780 let result = launch_plan(plan_of("coder"), options);
2781 std::env::remove_var(child_launch::MUR_BINARY_ENV);
2782 let Err(failure) = result else {
2783 panic!("the launch was expected to be refused");
2784 };
2785 assert!(failure.stopped.is_empty(), "{failure:?}");
2786 match only_reason(&failure) {
2787 MemberFailureReason::NotStarted { error } => {
2788 assert!(error.contains(&taken.display().to_string()), "{error}")
2789 }
2790 other => panic!("expected NotStarted, got {other:?}"),
2791 }
2792 }
2793
2794 #[test]
2798 fn member_roots_are_the_records_groups_of_member_directories() {
2799 let home = tempfile::tempdir().unwrap();
2800 crate::murmur_home::run_with_home(
2801 "formation_launch::tests::member_roots_inner",
2802 home.path(),
2803 );
2804 }
2805
2806 #[test]
2807 #[ignore = "run by member_roots_are_the_records_groups_of_member_directories"]
2808 fn member_roots_inner() {
2809 if !crate::murmur_home::in_scratch_home() {
2810 return;
2811 }
2812 assert!(formation_member_roots(&FormationId::mint()).is_empty());
2813
2814 let id = FormationId::mint();
2815 let home = PathBuf::from(std::env::var_os("HOME").unwrap());
2816 let formations = home.join(".murmur").join(FORMATIONS_DIR);
2817 for member in ["worker", "reviewer", "idle"] {
2818 assert_eq!(
2819 make_member_workdir(&formations, &id, member).unwrap(),
2820 formations.join(id.as_str()).join(member)
2821 );
2822 }
2823 let group = |member: &str| {
2824 crate::records_dir::records_group(&formations.join(id.as_str()).join(member)).unwrap()
2825 };
2826 for member in ["worker", "reviewer"] {
2827 std::fs::create_dir_all(group(member)).unwrap();
2828 }
2829 assert_eq!(
2830 formation_member_roots_in(&formations, &id),
2831 [group("reviewer"), group("worker")]
2832 );
2833 assert!(group("worker").starts_with(home.join(".murmur/records")));
2834 }
2835
2836 #[test]
2838 fn formation_marker_round_trips_as_three_keys() {
2839 let marker = FormationMarker {
2840 project_dir: PathBuf::from("/srv/project"),
2841 launcher_pid: 4242,
2842 launcher_start: "12345".to_string(),
2843 };
2844 let value = serde_json::to_value(&marker).unwrap();
2845 assert_eq!(
2846 value,
2847 serde_json::json!({
2848 "project_dir": "/srv/project",
2849 "launcher_pid": 4242,
2850 "launcher_start": "12345",
2851 })
2852 );
2853 let dir = tempfile::tempdir().unwrap();
2854 std::fs::write(dir.path().join(FORMATION_MARKER_FILE), value.to_string()).unwrap();
2855 assert_eq!(FormationMarker::read(dir.path()), Some(marker));
2856 std::fs::write(dir.path().join(FORMATION_MARKER_FILE), "{").unwrap();
2857 assert_eq!(FormationMarker::read(dir.path()), None);
2858 assert_eq!(FormationMarker::read(&dir.path().join("missing")), None);
2859 }
2860
2861 #[test]
2865 fn formation_marker_is_written_owner_only_before_the_first_peer_directory() {
2866 use std::os::unix::fs::PermissionsExt;
2867
2868 let _guard = binary_guard();
2869 let dir = tempfile::tempdir().unwrap();
2870 let project = tempfile::tempdir().unwrap();
2871 let mut options = options(Duration::from_secs(1));
2872 options.project_dir = project.path().join(".");
2873 let formation_dir = options
2874 .formations_dir
2875 .clone()
2876 .unwrap()
2877 .join(options.formation_id.as_str());
2878 std::env::set_var(child_launch::MUR_BINARY_ENV, stand_in(dir.path(), None));
2879 let result = launch_plan(plan_of("coder"), options);
2880 std::env::remove_var(child_launch::MUR_BINARY_ENV);
2881 assert!(result.is_err(), "the stand-in never reports itself");
2882
2883 let path = formation_dir.join(FORMATION_MARKER_FILE);
2884 let metadata = std::fs::metadata(&path).unwrap();
2885 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
2886 let marker = FormationMarker::read(&formation_dir).unwrap();
2887 let pid = std::process::id();
2888 assert_eq!(
2889 marker,
2890 FormationMarker {
2891 project_dir: std::fs::canonicalize(project.path()).unwrap(),
2892 launcher_pid: pid,
2893 launcher_start: crate::running::process_start_token(pid).unwrap_or_default(),
2894 }
2895 );
2896 let peer_dir = std::fs::metadata(formation_dir.join("coder")).unwrap();
2897 assert!(
2898 metadata.modified().unwrap() <= peer_dir.modified().unwrap(),
2899 "the marker was written after the peer's directory was made"
2900 );
2901 }
2902
2903 #[test]
2905 fn formation_marker_is_never_written_without_peers() {
2906 let _guard = binary_guard();
2907 let dir = tempfile::tempdir().unwrap();
2908 let options = options(Duration::from_secs(5));
2909 let formations = options.formations_dir.clone().unwrap();
2910 std::env::set_var(child_launch::MUR_BINARY_ENV, stand_in(dir.path(), None));
2911 let plan = FormationPlan {
2912 peers: Vec::new(),
2913 entry: member("planner"),
2914 callees: Vec::new(),
2915 };
2916 let result = launch_plan(plan, options);
2917 std::env::remove_var(child_launch::MUR_BINARY_ENV);
2918 let formation = result.expect("a formation with no peers has nothing to wait for");
2919 drop(formation);
2920 assert!(
2921 std::fs::read_dir(&formations).map_or(true, |mut entries| entries.next().is_none()),
2922 "{}",
2923 formations.display()
2924 );
2925 }
2926
2927 fn marker_of(pid: u32, start: &str) -> FormationMarker {
2928 FormationMarker {
2929 project_dir: PathBuf::from("/srv/project"),
2930 launcher_pid: pid,
2931 launcher_start: start.to_string(),
2932 }
2933 }
2934
2935 fn reaped_pid() -> u32 {
2937 let mut child = std::process::Command::new("true").spawn().unwrap();
2938 let pid = child.id();
2939 child.wait().unwrap();
2940 pid
2941 }
2942
2943 fn running_record(pid: u32, formation: Option<&FormationId>) -> crate::running::RunningRecord {
2944 crate::running::RunningRecord {
2945 session_id: "ses_0199c4e2f1b7712a9d3e4f5061728394".to_string(),
2946 url: "127.0.0.1:1".to_string(),
2947 pid,
2948 process_start: crate::running::process_start_token(pid).unwrap_or_default(),
2949 capsule_name: "worker".to_string(),
2950 capsule_version: "0.1.0".to_string(),
2951 workdir: PathBuf::from("/tmp/worker"),
2952 records_dir: PathBuf::from("/tmp/worker").join("records"),
2953 outlives_launcher: false,
2954 started_at: "2026-01-01T00:00:00Z".to_string(),
2955 door_token: None,
2956 credentials: Default::default(),
2957 formation_id: formation.cloned(),
2958 formation_member: None,
2959 formation_entry: false,
2960 formation_lifeline: false,
2961 formation_launcher: None,
2962 spawned_by: None,
2963 }
2964 }
2965
2966 #[test]
2969 fn prune_formations_liveness_reads_the_launcher_from_the_marker() {
2970 let id = FormationId::mint();
2971 let own = std::process::id();
2972 let token = crate::running::process_start_token(own).unwrap();
2973 assert!(formation_is_live(&id, &marker_of(own, &token), Some(&[])));
2974 assert!(!formation_is_live(
2975 &id,
2976 &marker_of(reaped_pid(), &token),
2977 Some(&[])
2978 ));
2979 }
2980
2981 #[test]
2985 fn prune_formations_liveness_reads_running_records_that_name_the_formation() {
2986 let id = FormationId::mint();
2987 let dead = marker_of(reaped_pid(), "1");
2988 let mut sleeper = std::process::Command::new("sleep")
2989 .arg("30")
2990 .spawn()
2991 .unwrap();
2992 let live = running_record(sleeper.id(), Some(&id));
2993 let other = running_record(sleeper.id(), Some(&FormationId::mint()));
2994 let unnamed = running_record(sleeper.id(), None);
2995 let gone = running_record(reaped_pid(), Some(&id));
2996
2997 assert!(formation_is_live(&id, &dead, Some(&[live])));
2998 assert!(!formation_is_live(
2999 &id,
3000 &dead,
3001 Some(&[other, unnamed, gone])
3002 ));
3003 let _ = sleeper.kill();
3004 let _ = sleeper.wait();
3005 }
3006
3007 #[test]
3010 fn prune_formations_liveness_treats_an_unlistable_running_directory_as_all_live() {
3011 let id = FormationId::mint();
3012 assert!(formation_is_live(&id, &marker_of(reaped_pid(), "1"), None));
3013
3014 let home = tempfile::tempdir().unwrap();
3015 let project = tempfile::tempdir().unwrap();
3016 let formations = home.path().join(FORMATIONS_DIR);
3017 let old = FormationId::parse(&format!("frm_{:012x}{:020x}", 1_000_000u64, 1)).unwrap();
3018 let old_dir = formations.join(old.as_str());
3019 std::fs::create_dir_all(old_dir.join("worker")).unwrap();
3020 let marker = FormationMarker {
3021 project_dir: std::fs::canonicalize(project.path()).unwrap(),
3022 ..marker_of(reaped_pid(), "1")
3023 };
3024 std::fs::write(
3025 old_dir.join(FORMATION_MARKER_FILE),
3026 serde_json::to_string(&marker).unwrap(),
3027 )
3028 .unwrap();
3029 let policy = murmur_artifact::TraceRetainConfig {
3030 max_sessions: Some(1),
3031 max_age_secs: Some(1),
3032 };
3033
3034 let running = home.path().join("running");
3036 std::fs::write(&running, "").unwrap();
3037 let current = FormationId::mint();
3038 assert!(prune_ended_formations_in(
3039 &formations,
3040 &running,
3041 ¤t,
3042 project.path(),
3043 &policy
3044 )
3045 .is_empty());
3046 assert!(old_dir.is_dir());
3047
3048 std::fs::remove_file(&running).unwrap();
3050 let pruned =
3051 prune_ended_formations_in(&formations, &running, ¤t, project.path(), &policy);
3052 assert_eq!(pruned.len(), 1, "{pruned:?}");
3053 assert_eq!(pruned[0].formation_id, old);
3054 assert_eq!(pruned[0].reason, crate::trace::RETENTION_REASON_MAX_AGE);
3055 assert!(!old_dir.exists());
3056 }
3057}