1use crate::pb::lifecycle::{DriverRequest, DriverResponse};
45use anyhow::{Context, Result};
46use robonix_atlas::client::AtlasClient;
47use robonix_atlas::pb as atlas_pb;
48use robonix_scribe::{info, warn};
49use serde::{Deserialize, Serialize};
50use std::collections::HashMap;
51use std::path::PathBuf;
52use std::process::Stdio;
53use std::time::{Duration, Instant};
54use tokio::process::Command;
55use tonic::Request;
56use tonic::transport::Endpoint;
57
58pub const CMD_INIT: u32 = 0;
61pub const CMD_ACTIVATE: u32 = 1;
62#[allow(dead_code)]
63pub const CMD_DEACTIVATE: u32 = 2;
64#[allow(dead_code)]
65pub const CMD_SHUTDOWN: u32 = 3;
66pub const DRIVER_REGISTER_TIMEOUT: Duration = Duration::from_secs(60);
69
70pub const DRIVER_INIT_TIMEOUT: Duration = Duration::from_secs(90);
76
77pub const BOOT_CONSUMER_ID: &str = "rbnx-cli/deploy";
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct PackageRuntimeRecord {
92 pub name: String,
93 pub kind: String,
94 pub pid: u32,
95 pub pgid: u32,
96 #[serde(default)]
97 pub provider_id: Option<String>,
98 #[serde(default)]
99 pub driver_contract: Option<String>,
100 #[serde(default)]
101 pub config_json: Option<String>,
102 #[serde(default)]
103 pub package_dir: Option<String>,
104 #[serde(default)]
105 pub stop: Option<String>,
106}
107
108pub async fn shutdown_package_runtime(
111 atlas_endpoint: Option<&str>,
112 record: &PackageRuntimeRecord,
113 term_wait: Duration,
114) {
115 let _ = shutdown_package_runtime_checked(atlas_endpoint, record, term_wait, None).await;
116}
117
118pub async fn shutdown_package_runtime_checked(
123 atlas_endpoint: Option<&str>,
124 record: &PackageRuntimeRecord,
125 term_wait: Duration,
126 boot_id: Option<&str>,
127) -> bool {
128 if let Some(boot_id) = boot_id {
129 if !process_group_has_members(record.pgid).await {
130 return true;
131 }
132 if !process_group_has_boot_id(record.pgid, boot_id).await {
133 warn!(
134 "refusing to stop {} pgid {}: no process carries RBNX_BOOT_ID={}",
135 record.name, record.pgid, boot_id
136 );
137 return false;
138 }
139 }
140
141 if let (Some(endpoint), Some(provider_id), Some(driver_contract)) = (
142 atlas_endpoint,
143 record.provider_id.as_deref(),
144 record.driver_contract.as_deref(),
145 ) {
146 let normalized = if endpoint.starts_with("http") {
147 endpoint.to_string()
148 } else {
149 format!("http://{endpoint}")
150 };
151 match AtlasClient::connect(&normalized).await {
152 Ok(mut atlas) => {
153 let config_json = record.config_json.clone().unwrap_or_else(|| "{}".into());
154 match call_driver_cmd(
155 &mut atlas,
156 provider_id,
157 driver_contract,
158 CMD_SHUTDOWN,
159 config_json,
160 &record.name,
161 )
162 .await
163 {
164 Ok(state) => info!(
165 "shutdown {} via Driver(CMD_SHUTDOWN): {}",
166 record.name, state
167 ),
168 Err(e) => warn!(
169 "shutdown {} Driver(CMD_SHUTDOWN) failed: {e:#}",
170 record.name
171 ),
172 }
173 }
174 Err(e) => warn!(
175 "shutdown {} could not connect atlas at {}: {e:#}",
176 record.name, normalized
177 ),
178 }
179 }
180
181 if let Some(stop) = record
182 .stop
183 .as_deref()
184 .map(str::trim)
185 .filter(|s| !s.is_empty())
186 {
187 run_package_stop_hook(record, stop, atlas_endpoint).await;
188 }
189
190 if !terminate_process_group_guarded(record.pgid, term_wait, boot_id).await {
191 warn!(
192 "refusing to finish shutdown of {} pgid {}: boot ownership changed before signaling",
193 record.name, record.pgid
194 );
195 return false;
196 }
197 !process_group_has_members(record.pgid).await
198}
199
200async fn process_group_has_boot_id(pgid: u32, boot_id: &str) -> bool {
204 let output = match Command::new("pgrep")
205 .arg("-g")
206 .arg(pgid.to_string())
207 .stdout(Stdio::piped())
208 .stderr(Stdio::null())
209 .output()
210 .await
211 {
212 Ok(output) if output.status.success() => output,
213 _ => return false,
214 };
215 String::from_utf8_lossy(&output.stdout)
216 .lines()
217 .filter_map(|line| line.trim().parse::<u32>().ok())
218 .any(|pid| process_has_boot_id(pid, boot_id))
219}
220
221pub fn process_has_boot_id(pid: u32, boot_id: &str) -> bool {
226 if boot_id.is_empty() {
227 return false;
228 }
229 let expected = format!("RBNX_BOOT_ID={boot_id}");
230 process_has_environment_entry(pid, expected.as_bytes())
231}
232
233#[cfg(target_os = "linux")]
237fn process_has_environment_entry(pid: u32, expected: &[u8]) -> bool {
238 std::fs::read(format!("/proc/{pid}/environ"))
239 .ok()
240 .is_some_and(|env| env.split(|byte| *byte == 0).any(|entry| entry == expected))
241}
242
243#[cfg(any(target_os = "macos", test))]
247fn macos_procargs_has_environment_entry(buffer: &[u8], expected: &[u8]) -> Option<bool> {
248 let argc_bytes: [u8; std::mem::size_of::<i32>()] =
249 buffer.get(..std::mem::size_of::<i32>())?.try_into().ok()?;
250 let argc = usize::try_from(i32::from_ne_bytes(argc_bytes)).ok()?;
251 let mut cursor = std::mem::size_of::<i32>();
252
253 cursor += buffer.get(cursor..)?.iter().position(|byte| *byte == 0)? + 1;
254 while buffer.get(cursor) == Some(&0) {
255 cursor += 1;
256 }
257 for _ in 0..argc {
258 cursor += buffer.get(cursor..)?.iter().position(|byte| *byte == 0)? + 1;
259 }
260
261 while cursor < buffer.len() {
262 while buffer.get(cursor) == Some(&0) {
263 cursor += 1;
264 }
265 if cursor == buffer.len() {
266 break;
267 }
268 let tail = buffer.get(cursor..)?;
269 let end = tail.iter().position(|byte| *byte == 0)?;
270 if &tail[..end] == expected {
271 return Some(true);
272 }
273 cursor += end + 1;
274 }
275 Some(false)
276}
277
278#[cfg(target_os = "macos")]
282fn process_has_environment_entry(pid: u32, expected: &[u8]) -> bool {
283 use std::ptr::null_mut;
284
285 let Ok(pid) = i32::try_from(pid) else {
286 return false;
287 };
288 let mut mib = [nix::libc::CTL_KERN, nix::libc::KERN_PROCARGS2, pid];
289 let mut buffer_size = 0;
290 if unsafe {
293 nix::libc::sysctl(
294 mib.as_mut_ptr(),
295 mib.len() as u32,
296 null_mut(),
297 &mut buffer_size,
298 null_mut(),
299 0,
300 )
301 } != 0
302 || buffer_size == 0
303 {
304 return false;
305 }
306
307 let mut buffer = vec![0_u8; buffer_size];
308 if unsafe {
311 nix::libc::sysctl(
312 mib.as_mut_ptr(),
313 mib.len() as u32,
314 buffer.as_mut_ptr().cast(),
315 &mut buffer_size,
316 null_mut(),
317 0,
318 )
319 } != 0
320 || buffer_size > buffer.len()
321 {
322 return false;
323 }
324 buffer.truncate(buffer_size);
325 macos_procargs_has_environment_entry(&buffer, expected) == Some(true)
326}
327
328#[cfg(not(any(target_os = "linux", target_os = "macos")))]
331fn process_has_environment_entry(_pid: u32, _expected: &[u8]) -> bool {
332 false
333}
334
335#[cfg(target_os = "linux")]
339pub fn proc_start_time_ticks(pid: u32) -> Option<u64> {
340 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
341 let after_comm = stat.get(stat.rfind(')')? + 1..)?.trim_start();
342 after_comm.split_whitespace().nth(19)?.parse().ok()
343}
344
345#[cfg(target_os = "macos")]
349pub fn proc_start_time_ticks(pid: u32) -> Option<u64> {
350 let pid = i32::try_from(pid).ok()?;
351 let mut info = std::mem::MaybeUninit::<nix::libc::proc_bsdinfo>::zeroed();
352 let info_size = std::mem::size_of::<nix::libc::proc_bsdinfo>();
353 let written = unsafe {
356 nix::libc::proc_pidinfo(
357 pid,
358 nix::libc::PROC_PIDTBSDINFO,
359 0,
360 info.as_mut_ptr().cast(),
361 i32::try_from(info_size).ok()?,
362 )
363 };
364 if usize::try_from(written).ok()? != info_size {
365 return None;
366 }
367 let info = unsafe { info.assume_init() };
369 info.pbi_start_tvsec
370 .checked_mul(1_000_000)?
371 .checked_add(info.pbi_start_tvusec)
372}
373
374#[cfg(not(any(target_os = "linux", target_os = "macos")))]
375pub fn proc_start_time_ticks(_pid: u32) -> Option<u64> {
376 None
377}
378
379async fn run_package_stop_hook(
380 record: &PackageRuntimeRecord,
381 stop: &str,
382 atlas_endpoint: Option<&str>,
383) {
384 let Some(package_dir) = record.package_dir.as_deref() else {
385 warn!("shutdown {} has stop hook but no package_dir", record.name);
386 return;
387 };
388 let mut cmd = Command::new("bash");
389 cmd.arg("-c")
390 .arg(stop)
391 .current_dir(PathBuf::from(package_dir))
392 .stdin(Stdio::null())
393 .stdout(Stdio::null())
394 .stderr(Stdio::null());
395 if let Some(endpoint) = atlas_endpoint {
396 cmd.env("ROBONIX_ATLAS", endpoint);
397 }
398 match tokio::time::timeout(Duration::from_secs(20), cmd.status()).await {
399 Ok(Ok(status)) if status.success() => {
400 info!("shutdown {} manifest stop hook completed", record.name);
401 }
402 Ok(Ok(status)) => warn!(
403 "shutdown {} manifest stop hook exited with {}",
404 record.name, status
405 ),
406 Ok(Err(e)) => warn!(
407 "shutdown {} manifest stop hook failed to start: {e:#}",
408 record.name
409 ),
410 Err(_) => warn!(
411 "shutdown {} manifest stop hook timed out after 20s",
412 record.name
413 ),
414 }
415}
416
417pub async fn terminate_process_group(pgid: u32, term_wait: Duration) {
419 let _ = terminate_process_group_guarded(pgid, term_wait, None).await;
420}
421
422async fn terminate_process_group_guarded(
426 pgid: u32,
427 term_wait: Duration,
428 boot_id: Option<&str>,
429) -> bool {
430 use nix::sys::signal::{Signal, killpg};
431 use nix::unistd::Pid;
432 if let Some(boot_id) = boot_id {
433 if !process_group_has_members(pgid).await {
434 return true;
435 }
436 if !process_group_has_boot_id(pgid, boot_id).await {
437 return !process_group_has_members(pgid).await;
441 }
442 }
443
444 let pgid_raw = pgid;
445 let pgid = Pid::from_raw(pgid as i32);
446 match killpg(pgid, Signal::SIGTERM) {
447 Ok(()) => {}
448 Err(nix::errno::Errno::ESRCH) => return true,
449 Err(e) => warn!("SIGTERM pgid {} failed: {e}", pgid),
450 }
451
452 let deadline = Instant::now() + term_wait;
453 while Instant::now() < deadline {
454 if !process_group_has_members(pgid_raw).await {
455 return true;
456 }
457 tokio::time::sleep(Duration::from_millis(200)).await;
458 }
459
460 if let Some(boot_id) = boot_id {
461 if !process_group_has_members(pgid_raw).await {
462 return true;
463 }
464 if !process_group_has_boot_id(pgid_raw, boot_id).await {
465 return !process_group_has_members(pgid_raw).await;
466 }
467 }
468 let _ = killpg(pgid, Signal::SIGKILL);
469 true
470}
471
472async fn process_group_has_members(pgid: u32) -> bool {
476 let output = match Command::new("pgrep")
477 .arg("-g")
478 .arg(pgid.to_string())
479 .stdout(Stdio::piped())
480 .stderr(Stdio::null())
481 .output()
482 .await
483 {
484 Ok(output) => output,
485 Err(_) => return true,
486 };
487 if !output.status.success() {
488 return output.status.code() != Some(1);
492 }
493
494 let stdout = String::from_utf8_lossy(&output.stdout);
495 for line in stdout.lines() {
496 let Ok(pid) = line.trim().parse::<u32>() else {
497 return true;
498 };
499 if process_is_not_zombie(pid).await {
500 return true;
501 }
502 }
503 false
504}
505
506#[cfg(target_os = "linux")]
510async fn process_is_not_zombie(pid: u32) -> bool {
511 let status_path = format!("/proc/{pid}/status");
512 let Ok(status) = std::fs::read_to_string(status_path) else {
513 return true;
514 };
515 linux_proc_status_is_not_zombie(&status)
516}
517
518#[cfg(any(target_os = "linux", test))]
519fn linux_proc_status_is_not_zombie(status: &str) -> bool {
521 !status.lines().any(|line| {
522 line.strip_prefix("State:")
523 .is_some_and(|state| state.trim_start().starts_with('Z'))
524 })
525}
526
527#[cfg(any(target_os = "macos", test))]
530fn macos_ps_state_is_not_zombie(status: &str) -> Option<bool> {
531 status
532 .lines()
533 .find_map(|line| line.trim().chars().next())
534 .map(|state| state != 'Z')
535}
536
537#[cfg(target_os = "macos")]
541async fn process_is_not_zombie(pid: u32) -> bool {
542 let output = match Command::new("ps")
543 .arg("-o")
544 .arg("state=")
545 .arg("-p")
546 .arg(pid.to_string())
547 .stdout(Stdio::piped())
548 .stderr(Stdio::null())
549 .output()
550 .await
551 {
552 Ok(output) => output,
553 Err(_) => return true,
554 };
555 if output.status.success() {
556 return macos_ps_state_is_not_zombie(&String::from_utf8_lossy(&output.stdout))
557 .unwrap_or(true);
558 }
559 true
560}
561
562#[cfg(not(any(target_os = "linux", target_os = "macos")))]
566async fn process_is_not_zombie(_pid: u32) -> bool {
567 true
568}
569
570pub fn contract_id_to_service_name(id: &str) -> String {
580 id.split('/')
581 .filter(|x| !x.is_empty())
582 .map(|seg| {
583 seg.split('_')
584 .filter(|p| !p.is_empty())
585 .map(|p| {
586 let mut c = p.chars();
587 match c.next() {
588 None => String::new(),
589 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
590 }
591 })
592 .collect::<String>()
593 })
594 .collect::<String>()
595}
596
597pub type ProviderRegistrationSnapshot = HashMap<String, String>;
602
603pub async fn snapshot_provider_ids(
604 atlas: &mut AtlasClient,
605) -> Result<ProviderRegistrationSnapshot> {
606 Ok(atlas
607 .query_capabilities("", "", atlas_pb::Transport::Unspecified)
608 .await
609 .context("pre-spawn atlas snapshot")?
610 .into_iter()
611 .map(|provider| (provider.id, provider.registration_id))
612 .collect())
613}
614
615pub fn is_new_provider_registration(
618 provider: &atlas_pb::CapabilityProvider,
619 before: &ProviderRegistrationSnapshot,
620) -> bool {
621 before
622 .get(&provider.id)
623 .is_none_or(|registration_id| registration_id != &provider.registration_id)
624}
625
626pub fn is_expected_provider_registration(
631 provider: &atlas_pb::CapabilityProvider,
632 before: &ProviderRegistrationSnapshot,
633 expected_provider_id: &str,
634) -> bool {
635 provider.id == expected_provider_id
636 && !before.contains_key(expected_provider_id)
637 && is_new_provider_registration(provider, before)
638}
639
640#[derive(Debug, Clone)]
650pub struct RegistrationOutcome {
651 pub provider_id: String,
652 pub provider_kind: i32,
653 pub provider_namespace: String,
654 pub registration_id: String,
655 pub driver_contracts: Vec<String>,
656}
657
658pub fn resolve_runtime_driver_contract(
666 provider_id: &str,
667 provider_namespace: &str,
668 expected: &str,
669 observed: &[String],
670 allow_shared_driver_upgrade: bool,
671) -> Result<String> {
672 const SHARED: &str = "robonix/lifecycle/driver";
673 let namespace = provider_namespace.trim_matches('/');
674 let exact_legacy = if namespace.is_empty() {
675 (expected != SHARED && expected.ends_with("/driver")).then(|| expected.to_string())
676 } else {
677 Some(format!("{namespace}/driver"))
678 };
679 let expected_is_supported = expected == SHARED || exact_legacy.as_deref() == Some(expected);
680 if !expected_is_supported {
681 anyhow::bail!(
682 "provider '{provider_id}' selected unsupported lifecycle Driver '{expected}'; expected '{SHARED}' or exact namespace Driver '{}'",
683 exact_legacy
684 .as_deref()
685 .unwrap_or("<provider namespace>/driver")
686 );
687 }
688 if observed.len() > 1 {
689 anyhow::bail!(
690 "provider '{provider_id}' registered multiple lifecycle Drivers: {}; expected exactly '{expected}'",
691 observed.join(", ")
692 );
693 }
694 if observed.is_empty() {
695 anyhow::bail!(
696 "provider '{provider_id}' registered without a lifecycle Driver; expected exactly one Driver: '{expected}'{}; rebuild the package with `rbnx build` to refresh generated stubs or migrate the manifest",
697 if allow_shared_driver_upgrade && exact_legacy.as_deref() == Some(expected) {
698 format!(" or upgraded shared '{SHARED}'")
699 } else {
700 String::new()
701 }
702 );
703 }
704 if observed[0] == expected {
705 return Ok(observed[0].clone());
706 }
707 if allow_shared_driver_upgrade
708 && exact_legacy.as_deref() == Some(expected)
709 && observed[0] == SHARED
710 {
711 return Ok(observed[0].clone());
712 }
713 anyhow::bail!(
714 "provider '{provider_id}' registered lifecycle Driver '{}', expected '{expected}' from the selected package manifest",
715 observed[0]
716 )
717}
718
719pub fn validate_runtime_driver_contracts(
723 provider_id: &str,
724 expected: &str,
725 observed: &[String],
726) -> Result<String> {
727 resolve_runtime_driver_contract(provider_id, "", expected, observed, false)
728}
729
730fn runtime_driver_contracts(provider: &atlas_pb::CapabilityProvider) -> Vec<String> {
731 let mut contracts = provider
732 .capabilities
733 .iter()
734 .filter(|capability| {
735 capability.transport == atlas_pb::Transport::Grpc as i32
736 && capability.contract_id.ends_with("/driver")
737 })
738 .map(|capability| capability.contract_id.clone())
739 .collect::<Vec<_>>();
740 contracts.sort();
741 contracts.dedup();
742 contracts
743}
744
745pub async fn wait_for_registration_core(
761 atlas: &mut AtlasClient,
762 before: &ProviderRegistrationSnapshot,
763 expected_provider_id: &str,
764 who: &str,
765) -> Result<RegistrationOutcome> {
766 if before.contains_key(expected_provider_id) {
767 anyhow::bail!(
768 "[{who}] deployment instance '{expected_provider_id}' was already registered before spawn"
769 );
770 }
771
772 const POLL_INTERVAL: Duration = Duration::from_millis(200);
776 let started = Instant::now();
777 let deadline = started + DRIVER_REGISTER_TIMEOUT;
778 loop {
779 let providers = atlas
780 .query_capabilities("", "", atlas_pb::Transport::Unspecified)
781 .await
782 .with_context(|| format!("[{who}] poll atlas"))?;
783 let matched = providers.iter().find(|provider| {
784 is_expected_provider_registration(provider, before, expected_provider_id)
785 });
786 if let Some(first) = matched {
787 let provider_id = first.id.clone();
788 let registration_id = first.registration_id.clone();
789 let settle_until = Instant::now()
796 .checked_add(Duration::from_millis(1000))
797 .map(|t| t.min(deadline))
798 .unwrap_or(deadline);
799 let mut current: atlas_pb::CapabilityProvider = (*first).clone();
800 loop {
805 if Instant::now() >= settle_until {
806 break;
807 }
808 tokio::time::sleep(Duration::from_millis(100)).await;
809 let providers = atlas
810 .query_capabilities(&provider_id, "", atlas_pb::Transport::Unspecified)
811 .await
812 .with_context(|| format!("[{who}] re-poll for driver"))?;
813 match providers.into_iter().find(|p| p.id == provider_id) {
814 Some(p) if p.registration_id == registration_id => current = p,
815 Some(p) => {
816 anyhow::bail!(
817 "[{who}] provider '{provider_id}' registration changed during settle ('{registration_id}' -> '{}')",
818 p.registration_id,
819 );
820 }
821 None => {
822 anyhow::bail!(
828 "[{who}] provider '{provider_id}' unregistered during settle window",
829 );
830 }
831 }
832 }
833 let driver_contracts = runtime_driver_contracts(¤t);
834 return Ok(RegistrationOutcome {
835 provider_id,
836 provider_kind: current.kind,
837 provider_namespace: current.namespace,
838 registration_id: current.registration_id,
839 driver_contracts,
840 });
841 }
842 if Instant::now() >= deadline {
843 anyhow::bail!(
844 "[{who}] timed out after {DRIVER_REGISTER_TIMEOUT:?} — package never \
845 registered expected deployment instance '{expected_provider_id}' with atlas",
846 );
847 }
848 tokio::time::sleep(POLL_INTERVAL).await;
849 }
850}
851
852pub async fn call_driver_cmd(
861 atlas: &mut AtlasClient,
862 provider_id: &str,
863 driver_contract: &str,
864 cmd: u32,
865 config_json: String,
866 who: &str,
867) -> Result<String> {
868 let cmd_name = match cmd {
869 CMD_INIT => "INIT",
870 CMD_ACTIVATE => "ACTIVATE",
871 CMD_DEACTIVATE => "DEACTIVATE",
872 CMD_SHUTDOWN => "SHUTDOWN",
873 _ => "?",
874 };
875 let (channel_id, endpoint, _params) = atlas
876 .connect_capability(
877 BOOT_CONSUMER_ID,
878 provider_id,
879 driver_contract,
880 atlas_pb::Transport::Grpc,
881 )
882 .await
883 .with_context(|| format!("[{who}] ConnectCapability for {driver_contract}"))?;
884 let normalized = if endpoint.starts_with("http") {
885 endpoint
886 } else {
887 format!("http://{endpoint}")
888 };
889 let result = async {
890 let channel = Endpoint::new(normalized.clone())
891 .with_context(|| format!("invalid driver endpoint '{normalized}'"))?
892 .connect()
893 .await
894 .with_context(|| format!("dial driver at '{normalized}'"))?;
895 let svc_name = contract_id_to_service_name(driver_contract);
896 let path: tonic::codegen::http::uri::PathAndQuery =
897 format!("/robonix.contracts.{svc_name}/Driver")
898 .parse()
899 .with_context(|| format!("build gRPC path for '{driver_contract}'"))?;
900 let mut grpc = tonic::client::Grpc::new(channel);
901 grpc.ready().await.with_context(|| "gRPC ready")?;
902 let codec: tonic_prost::ProstCodec<DriverRequest, DriverResponse> = Default::default();
903 let resp = tokio::time::timeout(
904 DRIVER_INIT_TIMEOUT,
905 grpc.unary(
906 Request::new(DriverRequest {
907 command: cmd,
908 config_json,
909 }),
910 path,
911 codec,
912 ),
913 )
914 .await
915 .map_err(|_| {
916 anyhow::anyhow!(
917 "Driver(CMD_{cmd_name}) timed out after {:?}",
918 DRIVER_INIT_TIMEOUT
919 )
920 })?
921 .with_context(|| format!("Driver(CMD_{cmd_name}) RPC failed"))?;
922 Ok::<_, anyhow::Error>(resp.into_inner())
923 }
924 .await;
925 let _ = atlas.disconnect_capability(&channel_id).await;
928 let r = result.map_err(|e| anyhow::anyhow!("[{who}] Driver(CMD_{cmd_name}): {e:#}"))?;
929 if !r.ok {
930 anyhow::bail!(
931 "[{who}] Driver(CMD_{cmd_name}) returned ok=false (state={}, error={})",
932 r.state,
933 r.error
934 );
935 }
936 Ok(r.state)
937}
938
939#[cfg(test)]
940mod tests {
941 use super::{
942 PackageRuntimeRecord, atlas_pb, is_expected_provider_registration,
943 is_new_provider_registration, linux_proc_status_is_not_zombie,
944 macos_procargs_has_environment_entry, macos_ps_state_is_not_zombie, proc_start_time_ticks,
945 process_group_has_members, resolve_runtime_driver_contract,
946 shutdown_package_runtime_checked, terminate_process_group_guarded,
947 validate_runtime_driver_contracts,
948 };
949
950 const SHARED: &str = "robonix/lifecycle/driver";
951 const LEGACY: &str = "robonix/primitive/camera/driver";
952
953 #[cfg(any(target_os = "linux", target_os = "macos"))]
954 #[test]
955 fn live_process_start_identity_is_available_and_stable() {
956 let first = proc_start_time_ticks(std::process::id())
957 .expect("supported host must expose a live process start identity");
958 std::thread::sleep(std::time::Duration::from_millis(10));
959 assert_eq!(proc_start_time_ticks(std::process::id()), Some(first));
960 }
961
962 #[test]
963 fn runtime_driver_must_exactly_match_shared_or_legacy_selection() {
964 assert_eq!(
965 validate_runtime_driver_contracts("camera", SHARED, &[SHARED.to_string()]).unwrap(),
966 SHARED
967 );
968 assert_eq!(
969 validate_runtime_driver_contracts("camera", LEGACY, &[LEGACY.to_string()]).unwrap(),
970 LEGACY
971 );
972 }
973
974 #[test]
975 fn runtime_driver_rejects_missing_mismatched_and_dual_declarations() {
976 let missing = validate_runtime_driver_contracts("camera", SHARED, &[])
977 .unwrap_err()
978 .to_string();
979 assert!(missing.contains("without a lifecycle Driver"));
980 assert!(missing.contains("rbnx build"));
981
982 let mismatch = validate_runtime_driver_contracts("camera", SHARED, &[LEGACY.to_string()])
983 .unwrap_err()
984 .to_string();
985 assert!(mismatch.contains("expected 'robonix/lifecycle/driver'"));
986
987 let dual = validate_runtime_driver_contracts(
988 "camera",
989 SHARED,
990 &[SHARED.to_string(), LEGACY.to_string()],
991 )
992 .unwrap_err()
993 .to_string();
994 assert!(dual.contains("multiple lifecycle Drivers"));
995 }
996
997 #[test]
998 fn only_exact_legacy_manifest_may_upgrade_to_shared_runtime() {
999 assert_eq!(
1000 resolve_runtime_driver_contract(
1001 "camera",
1002 "robonix/primitive/camera",
1003 SHARED,
1004 &[SHARED.to_string()],
1005 false,
1006 )
1007 .unwrap(),
1008 SHARED
1009 );
1010 let unmarked_upgrade = resolve_runtime_driver_contract(
1011 "camera",
1012 "robonix/primitive/camera",
1013 LEGACY,
1014 &[SHARED.to_string()],
1015 false,
1016 )
1017 .unwrap_err()
1018 .to_string();
1019 assert!(unmarked_upgrade.contains("expected 'robonix/primitive/camera/driver'"));
1020 assert_eq!(
1021 resolve_runtime_driver_contract(
1022 "camera",
1023 "robonix/primitive/camera",
1024 LEGACY,
1025 &[SHARED.to_string()],
1026 true,
1027 )
1028 .unwrap(),
1029 SHARED
1030 );
1031 assert_eq!(
1032 resolve_runtime_driver_contract(
1033 "camera",
1034 "robonix/primitive/camera",
1035 LEGACY,
1036 &[LEGACY.to_string()],
1037 true,
1038 )
1039 .unwrap(),
1040 LEGACY
1041 );
1042 let missing = resolve_runtime_driver_contract(
1043 "camera",
1044 "robonix/primitive/camera",
1045 LEGACY,
1046 &[],
1047 true,
1048 )
1049 .unwrap_err()
1050 .to_string();
1051 assert!(missing.contains("without a lifecycle Driver"));
1052 assert!(missing.contains("robonix/primitive/camera/driver"));
1053 assert!(missing.contains("robonix/lifecycle/driver"));
1054 assert!(missing.contains("rbnx build"));
1055
1056 let reverse = resolve_runtime_driver_contract(
1057 "camera",
1058 "robonix/primitive/camera",
1059 SHARED,
1060 &[LEGACY.to_string()],
1061 true,
1062 )
1063 .unwrap_err()
1064 .to_string();
1065 assert!(reverse.contains("expected 'robonix/lifecycle/driver'"));
1066
1067 assert!(
1068 resolve_runtime_driver_contract(
1069 "camera",
1070 "robonix/primitive/camera",
1071 LEGACY,
1072 &["robonix/primitive/lidar/driver".to_string()],
1073 true,
1074 )
1075 .unwrap_err()
1076 .to_string()
1077 .contains("expected 'robonix/primitive/camera/driver'")
1078 );
1079 assert!(
1080 resolve_runtime_driver_contract(
1081 "camera",
1082 "robonix/primitive/camera",
1083 "robonix/primitive/lidar/driver",
1084 &["robonix/primitive/lidar/driver".to_string()],
1085 true,
1086 )
1087 .unwrap_err()
1088 .to_string()
1089 .contains("unsupported lifecycle Driver")
1090 );
1091 }
1092
1093 #[test]
1094 fn registration_snapshot_detects_same_id_takeover_but_not_same_generation() {
1095 let before =
1096 std::collections::HashMap::from([("camera".to_string(), "registration-a".to_string())]);
1097 let provider = |id: &str, registration_id: &str| atlas_pb::CapabilityProvider {
1098 id: id.to_string(),
1099 registration_id: registration_id.to_string(),
1100 ..Default::default()
1101 };
1102
1103 assert!(!is_new_provider_registration(
1104 &provider("camera", "registration-a"),
1105 &before,
1106 ));
1107 assert!(is_new_provider_registration(
1108 &provider("camera", "registration-b"),
1109 &before,
1110 ));
1111 assert!(is_new_provider_registration(
1112 &provider("lidar", "registration-a"),
1113 &before,
1114 ));
1115 }
1116
1117 #[test]
1118 fn expected_registration_ignores_unrelated_concurrent_provider() {
1119 let before =
1120 std::collections::HashMap::from([("existing_camera".to_string(), "old".to_string())]);
1121 let provider = |id: &str, registration_id: &str| atlas_pb::CapabilityProvider {
1122 id: id.to_string(),
1123 registration_id: registration_id.to_string(),
1124 ..Default::default()
1125 };
1126
1127 assert!(!is_expected_provider_registration(
1128 &provider("unrelated_lidar", "new"),
1129 &before,
1130 "camera",
1131 ));
1132 assert!(is_expected_provider_registration(
1133 &provider("camera", "new"),
1134 &before,
1135 "camera",
1136 ));
1137 }
1138
1139 #[test]
1140 fn expected_registration_does_not_accept_takeover_of_existing_id() {
1141 let before = std::collections::HashMap::from([("camera".to_string(), "old".to_string())]);
1142 let provider = atlas_pb::CapabilityProvider {
1143 id: "camera".to_string(),
1144 registration_id: "new".to_string(),
1145 ..Default::default()
1146 };
1147
1148 assert!(!is_expected_provider_registration(
1149 &provider, &before, "camera",
1150 ));
1151 }
1152
1153 #[test]
1154 fn linux_proc_status_zombie_detection_is_conservative() {
1155 assert!(!linux_proc_status_is_not_zombie(
1156 "Name:\tworker\nState:\tZ (zombie)\n"
1157 ));
1158 assert!(linux_proc_status_is_not_zombie(
1159 "Name:\tworker\nState:\tS (sleeping)\n"
1160 ));
1161 assert!(linux_proc_status_is_not_zombie("Name:\tworker\n"));
1162 }
1163
1164 #[test]
1165 fn macos_procargs_parser_matches_exact_environment_entry() {
1166 let mut procargs = 2_i32.to_ne_bytes().to_vec();
1167 procargs.extend_from_slice(
1168 b"/usr/local/bin/worker\0\0worker\0--serve\0MODE=test\0RBNX_BOOT_ID=boot-123\0\0",
1169 );
1170
1171 assert_eq!(
1172 macos_procargs_has_environment_entry(&procargs, b"RBNX_BOOT_ID=boot-123"),
1173 Some(true)
1174 );
1175 assert_eq!(
1176 macos_procargs_has_environment_entry(&procargs, b"RBNX_BOOT_ID=boot"),
1177 Some(false)
1178 );
1179 assert_eq!(
1180 macos_procargs_has_environment_entry(&2_i32.to_ne_bytes(), b"MODE=test"),
1181 None
1182 );
1183
1184 let mut truncated = procargs;
1185 truncated.pop();
1186 truncated.pop();
1187 assert_eq!(
1188 macos_procargs_has_environment_entry(&truncated, b"RBNX_BOOT_ID=boot-123"),
1189 None
1190 );
1191 }
1192
1193 #[test]
1194 fn macos_ps_state_parser_only_excludes_zombies() {
1195 assert_eq!(macos_ps_state_is_not_zombie("Z+ \n"), Some(false));
1196 assert_eq!(macos_ps_state_is_not_zombie("S \n"), Some(true));
1197 assert_eq!(macos_ps_state_is_not_zombie(" \n"), None);
1198 }
1199
1200 #[tokio::test]
1201 async fn live_process_group_is_not_reported_empty() {
1202 use std::os::unix::process::CommandExt;
1203
1204 let mut child = std::process::Command::new("sleep")
1205 .arg("30")
1206 .process_group(0)
1207 .spawn()
1208 .unwrap();
1209 let pgid = child.id();
1210 let detected = process_group_has_members(pgid).await;
1211 let _ = nix::sys::signal::killpg(
1212 nix::unistd::Pid::from_raw(pgid as i32),
1213 nix::sys::signal::Signal::SIGKILL,
1214 );
1215 let _ = child.wait();
1216
1217 assert!(detected);
1218 }
1219
1220 #[tokio::test]
1221 async fn checked_shutdown_accepts_matching_boot_group_and_terminates_it() {
1222 use std::os::unix::process::CommandExt;
1223
1224 let boot_id = format!("shutdown-test-{}", std::process::id());
1225 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1226 .args([
1227 "--exact",
1228 "launch::tests::boot_identity_fixture_process",
1229 "--ignored",
1230 "--test-threads=1",
1231 ])
1232 .env("RBNX_BOOT_ID", &boot_id)
1233 .process_group(0)
1234 .spawn()
1235 .unwrap();
1236 let pgid = child.id();
1237 let record = PackageRuntimeRecord {
1238 name: "shutdown-test".into(),
1239 pid: pgid,
1240 pgid,
1241 ..PackageRuntimeRecord::default()
1242 };
1243
1244 let complete = shutdown_package_runtime_checked(
1245 None,
1246 &record,
1247 std::time::Duration::from_secs(2),
1248 Some(&boot_id),
1249 )
1250 .await;
1251 let still_running = child.try_wait().unwrap().is_none();
1252 if still_running {
1253 let _ = nix::sys::signal::killpg(
1254 nix::unistd::Pid::from_raw(pgid as i32),
1255 nix::sys::signal::Signal::SIGKILL,
1256 );
1257 }
1258 let _ = child.wait();
1259
1260 assert!(complete);
1261 assert!(!still_running);
1262 }
1263
1264 #[tokio::test]
1265 async fn mismatched_boot_group_is_rejected_without_signaling() {
1266 use std::os::unix::process::CommandExt;
1267
1268 let boot_id = format!("mismatch-test-{}", std::process::id());
1269 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1270 .args([
1271 "--exact",
1272 "launch::tests::boot_identity_fixture_process",
1273 "--ignored",
1274 "--test-threads=1",
1275 ])
1276 .env("RBNX_BOOT_ID", &boot_id)
1277 .process_group(0)
1278 .spawn()
1279 .unwrap();
1280 let pgid = child.id();
1281
1282 let accepted = terminate_process_group_guarded(
1283 pgid,
1284 std::time::Duration::from_millis(10),
1285 Some("different-boot-id"),
1286 )
1287 .await;
1288 let still_running = child.try_wait().unwrap().is_none();
1289 let _ = nix::sys::signal::killpg(
1290 nix::unistd::Pid::from_raw(pgid as i32),
1291 nix::sys::signal::Signal::SIGKILL,
1292 );
1293 let _ = child.wait();
1294
1295 assert!(!accepted);
1296 assert!(still_running);
1297 }
1298
1299 #[tokio::test]
1300 async fn zombie_only_process_group_is_reported_empty() {
1301 use super::process_is_not_zombie;
1302 use std::os::unix::process::CommandExt;
1303
1304 let mut child = std::process::Command::new("sh")
1305 .arg("-c")
1306 .arg("exit 0")
1307 .process_group(0)
1308 .spawn()
1309 .unwrap();
1310 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1311 while process_is_not_zombie(child.id()).await && std::time::Instant::now() < deadline {
1312 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1313 }
1314 let zombie_detected = !process_is_not_zombie(child.id()).await;
1315 let group_has_members = process_group_has_members(child.id()).await;
1316 let _ = child.wait();
1317
1318 assert!(zombie_detected);
1319 assert!(!group_has_members);
1320 }
1321
1322 #[test]
1323 #[ignore = "spawned by checked_shutdown_accepts_matching_boot_group_and_terminates_it"]
1324 fn boot_identity_fixture_process() {
1325 std::thread::sleep(std::time::Duration::from_secs(30));
1326 }
1327}