1use std::{
4 collections::HashMap,
5 fmt::Debug,
6 path::{Path, PathBuf},
7 pin::Pin,
8};
9
10use smol::{
11 channel::{Receiver, Sender, unbounded},
12 stream::Stream,
13};
14
15use crate::toolchain::Host;
16
17#[cfg(target_os = "macos")]
18use std::collections::BTreeSet;
19#[cfg(target_os = "macos")]
20use std::time::{Duration, Instant};
21
22const LOG_LEVEL_ENV: &str = "WATERUI_LOG";
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
30pub enum LogLevel {
31 Error,
33 Warn,
35 #[default]
37 Info,
38 Debug,
40 Verbose,
42}
43
44impl LogLevel {
45 #[must_use]
47 pub const fn to_android_priority(self) -> char {
48 match self {
49 Self::Error => 'E',
50 Self::Warn => 'W',
51 Self::Info => 'I',
52 Self::Debug => 'D',
53 Self::Verbose => 'V',
54 }
55 }
56
57 #[must_use]
66 pub const fn to_apple_level(self) -> &'static str {
67 match self {
68 Self::Error | Self::Warn | Self::Info => "default",
69 Self::Debug | Self::Verbose => "debug",
70 }
71 }
72
73 #[must_use]
79 pub const fn to_tracing_level(self) -> &'static str {
80 match self {
81 Self::Error => "error",
82 Self::Warn => "warn",
83 Self::Info => "info",
84 Self::Debug => "debug",
85 Self::Verbose => "trace",
86 }
87 }
88}
89
90#[derive(Debug, Clone, Default)]
92pub struct RunOptions {
93 env_vars: HashMap<String, String>,
101
102 log_level: Option<LogLevel>,
104
105 native_logs: bool,
108
109 replace_existing_macos_app_instances: bool,
113
114 forward_tcp_ports: Vec<u16>,
123}
124
125impl RunOptions {
126 #[must_use]
128 pub fn new() -> Self {
129 Self {
130 env_vars: HashMap::new(),
131 log_level: None,
132 native_logs: false,
133 replace_existing_macos_app_instances: true,
134 forward_tcp_ports: Vec::new(),
135 }
136 }
137
138 pub fn insert_env_var(&mut self, key: String, value: String) {
140 self.env_vars.insert(key, value);
141 }
142
143 pub fn describe_project(&mut self, project: &crate::project::Project) {
151 self.insert_env_var(
152 String::from("WATERUI_PROJECT_DIR"),
153 project.root().display().to_string(),
154 );
155 let name = project.manifest().package.name.clone();
156 self.insert_env_var(String::from("WATERUI_APP_NAME"), name);
157 }
158
159 pub fn env_vars(&self) -> impl Iterator<Item = (&str, &str)> {
161 self.env_vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))
162 }
163
164 pub fn set_log_level(&mut self, level: LogLevel) {
169 self.log_level = Some(level);
170 self.insert_env_var(
171 String::from(LOG_LEVEL_ENV),
172 String::from(level.to_tracing_level()),
173 );
174 }
175
176 #[must_use]
178 pub const fn log_level(&self) -> Option<LogLevel> {
179 self.log_level
180 }
181
182 pub const fn set_native_logs(&mut self, native_logs: bool) {
184 self.native_logs = native_logs;
185 }
186
187 #[must_use]
189 pub const fn native_logs(&self) -> bool {
190 self.native_logs
191 }
192
193 pub const fn set_replace_existing_macos_app_instances(&mut self, replace: bool) {
196 self.replace_existing_macos_app_instances = replace;
197 }
198
199 #[must_use]
201 pub const fn replace_existing_macos_app_instances(&self) -> bool {
202 self.replace_existing_macos_app_instances
203 }
204
205 pub fn set_forward_tcp_ports(&mut self, ports: impl IntoIterator<Item = u16>) {
208 self.forward_tcp_ports = ports.into_iter().collect();
209 }
210
211 #[must_use]
213 pub fn forward_tcp_ports(&self) -> &[u16] {
214 &self.forward_tcp_ports
215 }
216}
217
218#[derive(Debug)]
220pub struct Artifact {
221 bundle_id: String,
222 path: PathBuf,
223}
224
225impl Artifact {
226 #[must_use]
228 pub fn new(bundle_id: impl Into<String>, path: PathBuf) -> Self {
229 Self {
230 bundle_id: bundle_id.into(),
231 path,
232 }
233 }
234
235 #[must_use]
237 pub const fn bundle_id(&self) -> &str {
238 self.bundle_id.as_str()
239 }
240
241 #[must_use]
243 pub fn path(&self) -> &Path {
244 &self.path
245 }
246}
247
248pub trait Device: Sized + Send {
257 fn name(&self) -> &str;
259
260 fn launch(&self, host: &Host) -> impl Future<Output = eyre::Result<()>> + Send;
264
265 fn run(
267 &self,
268 host: &Host,
269 artifact: Artifact,
270 options: RunOptions,
271 ) -> impl Future<Output = Result<Running, FailToRun>> + Send;
272
273 fn scan(host: &Host) -> impl Future<Output = eyre::Result<Vec<Self>>> + Send;
280}
281
282pub struct Running {
286 sender: Sender<DeviceEvent>,
287 receiver: Receiver<DeviceEvent>,
288 on_drop: Vec<Box<dyn FnOnce() + Send>>,
289}
290
291impl Debug for Running {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 f.debug_struct("Running").finish_non_exhaustive()
294 }
295}
296
297impl Running {
298 #[allow(clippy::missing_panics_doc)]
300 pub fn new(on_drop: impl FnOnce() + Send + 'static) -> (Self, Sender<DeviceEvent>) {
301 let (sender, receiver) = unbounded();
302 sender.try_send(DeviceEvent::Started).unwrap(); (
304 Self {
305 sender: sender.clone(),
306 receiver,
307 on_drop: vec![Box::new(on_drop)],
308 },
309 sender,
310 )
311 }
312
313 pub fn retain<T: Send + 'static>(&mut self, value: T) {
315 self.on_drop.push(Box::new(move || {
316 drop(value);
317 }));
318 }
319
320 pub fn detach(self: Pin<&mut Self>) {
325 let this = unsafe { self.get_unchecked_mut() };
328 for hook in this.on_drop.drain(..) {
333 std::mem::forget(hook);
334 }
335 }
336}
337
338impl Stream for Running {
339 type Item = DeviceEvent;
340
341 fn poll_next(
342 self: std::pin::Pin<&mut Self>,
343 cx: &mut std::task::Context<'_>,
344 ) -> std::task::Poll<Option<Self::Item>> {
345 let receiver = unsafe { &mut self.get_unchecked_mut().receiver };
348 unsafe { std::pin::Pin::new_unchecked(receiver) }.poll_next(cx)
351 }
352}
353
354impl Drop for Running {
355 fn drop(&mut self) {
356 let _ = self.sender.try_send(DeviceEvent::Stopped);
357 for f in self.on_drop.drain(..) {
358 f();
359 }
360 }
361}
362
363#[derive(Debug, thiserror::Error)]
365pub enum FailToRun {
366 #[error("Invalid artifact")]
368 InvalidArtifact,
369
370 #[error("Failed to install application on device: {0}")]
372 Install(eyre::Report),
373
374 #[error("Failed to launch device: {0}")]
376 Launch(eyre::Report),
377 #[error("Failed to run application on device: {0}")]
379 Run(eyre::Report),
380
381 #[error("Failed to package the artifacts: {0}")]
383 Package(eyre::Report),
384
385 #[error("Failed to build the project: {0}")]
387 Build(eyre::Report),
388
389 #[error("Application crashed: {0}")]
391 Crashed(String),
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub struct ApplicationExit {
397 reason: ApplicationExitReason,
398}
399
400impl ApplicationExit {
401 #[must_use]
403 pub const fn completed() -> Self {
404 Self {
405 reason: ApplicationExitReason::Completed,
406 }
407 }
408
409 #[must_use]
411 pub const fn user_closed() -> Self {
412 Self {
413 reason: ApplicationExitReason::UserClosed,
414 }
415 }
416
417 #[must_use]
419 pub const fn terminal_message(self) -> &'static str {
420 match self.reason {
421 ApplicationExitReason::Completed => "Application exited",
422 ApplicationExitReason::UserClosed => "Application closed",
423 }
424 }
425
426 #[must_use]
428 pub const fn reason(self) -> ApplicationExitReason {
429 self.reason
430 }
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub enum ApplicationExitReason {
436 Completed,
438 UserClosed,
440}
441
442#[derive(Debug)]
444pub enum DeviceEvent {
445 Started,
447 Stopped,
449 Stdout {
451 message: String,
453 },
454
455 Stderr {
457 message: String,
459 },
460 Log {
462 level: tracing::Level,
464 message: String,
466 },
467
468 Exited(ApplicationExit),
470
471 Crashed(String),
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477pub enum DeviceKind {
478 Simulator,
480 Physical,
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub enum DeviceState {
487 Booted,
489 Shutdown,
491 Disconnected,
493}
494
495#[cfg(target_os = "macos")]
500use smol::{
501 Timer,
502 io::{AsyncBufReadExt, BufReader},
503 process::{Command, Stdio},
504 spawn,
505 stream::StreamExt,
506};
507
508#[cfg(target_os = "macos")]
510#[derive(Debug, Clone)]
511pub struct PanicInfo {
512 pub payload: String,
514 pub location: Option<String>,
516}
517
518#[cfg(target_os = "macos")]
519struct MacosLogStream {
520 task: smol::Task<()>,
521 panic_rx: Receiver<String>,
522}
523
524#[cfg(target_os = "macos")]
536fn start_log_stream(
537 host: &Host,
538 sender: Sender<DeviceEvent>,
539 log_level: Option<LogLevel>,
540 pid: u32,
541) -> Result<(MacosLogStream, smol::process::Child), FailToRun> {
542 let (panic_tx, panic_rx) = smol::channel::bounded::<String>(1);
544
545 let stream_level = log_level.map_or("default", |l| l.to_apple_level());
547
548 let predicate = format!("processID == {pid} AND subsystem == \"dev.waterui\"");
549
550 let mut log_cmd = host.command("log");
551 log_cmd
552 .arg("stream")
553 .arg("--predicate")
554 .arg(&predicate)
555 .arg("--level")
556 .arg(stream_level)
557 .arg("--style")
558 .arg("compact")
559 .stdout(Stdio::piped())
560 .stderr(Stdio::null())
561 .kill_on_drop(true);
562
563 let mut log_child = log_cmd.spawn().map_err(|error| {
564 FailToRun::Launch(eyre::eyre!("Failed to start macOS log stream: {error}"))
565 })?;
566 let stdout = log_child
567 .stdout
568 .take()
569 .expect("stdout is piped for the macOS log stream");
570
571 replay_log_history(
577 host.clone(),
578 predicate,
579 sender.clone(),
580 panic_tx.clone(),
581 log_level,
582 );
583
584 let task = spawn(async move {
585 let mut lines = BufReader::new(stdout).lines();
586 while let Some(Ok(line)) = lines.next().await {
587 if line.starts_with("Filtering") || line.starts_with("Timestamp") {
588 continue;
589 }
590
591 if line.contains("panic.payload=")
592 && let Some(info) = extract_panic_info_from_log(&line)
593 {
594 let _ = panic_tx.try_send(format_panic_message(
595 &info.payload,
596 info.location.as_deref(),
597 ));
598 }
599
600 if log_level.is_some() {
601 let level = if line.contains(" F ") || line.contains(" E ") {
602 tracing::Level::ERROR
603 } else if line.contains(" W ") {
604 tracing::Level::WARN
605 } else if line.contains(" D ") {
606 tracing::Level::DEBUG
607 } else {
608 tracing::Level::INFO
609 };
610
611 if sender
612 .try_send(DeviceEvent::Log {
613 level,
614 message: line,
615 })
616 .is_err()
617 {
618 break;
619 }
620 }
621 }
622 });
623
624 Ok((MacosLogStream { task, panic_rx }, log_child))
625}
626
627#[cfg(target_os = "macos")]
631fn replay_log_history(
632 host: Host,
633 predicate: String,
634 sender: Sender<DeviceEvent>,
635 panic_tx: Sender<String>,
636 log_level: Option<LogLevel>,
637) {
638 spawn(async move {
639 Timer::after(Duration::from_secs(4)).await;
640 let Ok(output) = host
641 .command("log")
642 .args(["show", "--last", "2m", "--predicate", &predicate])
643 .args(["--style", "compact"])
644 .output()
645 .await
646 else {
647 return;
648 };
649 for line in String::from_utf8_lossy(&output.stdout).lines() {
650 if line.starts_with("Filtering") || line.starts_with("Timestamp") {
651 continue;
652 }
653 if line.contains("panic.payload=")
654 && let Some(info) = extract_panic_info_from_log(line)
655 {
656 let _ = panic_tx.try_send(format_panic_message(
657 &info.payload,
658 info.location.as_deref(),
659 ));
660 }
661 if log_level.is_some() {
662 let level = if line.contains(" F ") || line.contains(" E ") {
663 tracing::Level::ERROR
664 } else if line.contains(" W ") {
665 tracing::Level::WARN
666 } else if line.contains(" D ") {
667 tracing::Level::DEBUG
668 } else {
669 tracing::Level::INFO
670 };
671 let _ = sender.try_send(DeviceEvent::Log {
672 level,
673 message: line.to_string(),
674 });
675 }
676 }
677 })
678 .detach();
679}
680
681#[cfg(target_os = "macos")]
683fn extract_panic_info_from_log(line: &str) -> Option<PanicInfo> {
684 let mut payload = None;
685 let mut location = None;
686
687 if let Some(start) = line.find("panic.payload=\"") {
689 let start = start + 15;
690 if let Some(end) = line[start..].find('"') {
691 payload = Some(line[start..start + end].to_string());
692 }
693 }
694
695 if let Some(start) = line.find("panic.location=\"") {
697 let start = start + 16;
698 if let Some(end) = line[start..].find('"') {
699 location = Some(line[start..start + end].to_string());
700 }
701 }
702
703 payload.map(|p| PanicInfo {
704 payload: p,
705 location,
706 })
707}
708
709#[cfg(target_os = "macos")]
714async fn fetch_recent_panic_logs(
715 host: &Host,
716 started_at: Instant,
717 pid: Option<u32>,
718) -> Option<String> {
719 let last = started_at.elapsed() + Duration::from_secs(2);
720 let last_arg = format!("{}s", last.as_secs().max(5));
721
722 let predicate = pid.map_or_else(
723 || "subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\"".to_string(),
724 |pid| {
725 format!(
726 "processID == {pid} AND subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\""
727 )
728 },
729 );
730
731 let output = host
732 .output(
733 "log",
734 [
735 "show",
736 "--predicate",
737 predicate.as_str(),
738 "--style",
739 "compact",
740 "--last",
741 last_arg.as_str(),
742 ],
743 )
744 .await
745 .ok()?;
746
747 let stdout = String::from_utf8(output.stdout).ok()?;
748
749 for line in stdout.lines() {
750 if line.starts_with("Filtering") || line.starts_with("Timestamp") || line.is_empty() {
751 continue;
752 }
753
754 let mut location = None;
755 let mut payload = None;
756
757 if let Some(loc_start) = line.find("panic.location=\"") {
758 let start = loc_start + 16;
759 if let Some(end) = line[start..].find('"') {
760 location = Some(&line[start..start + end]);
761 }
762 }
763
764 if let Some(pay_start) = line.find("panic.payload=\"") {
765 let start = pay_start + 15;
766 if let Some(end) = line[start..].find('"') {
767 payload = Some(&line[start..start + end]);
768 }
769 }
770
771 if payload.is_some() || location.is_some() {
772 let mut msg = String::from("Panic:");
773 if let Some(p) = payload {
774 msg = format!("{msg} {p}");
775 }
776 if let Some(l) = location {
777 msg = format!("{msg}\n at {l}");
778 }
779 return Some(msg);
780 }
781 }
782
783 None
784}
785
786#[derive(Debug, Clone, Copy, Default)]
798pub struct Local;
799
800impl Device for Local {
801 fn name(&self) -> &'static str {
802 "Local Machine"
803 }
804
805 fn launch(&self, _host: &Host) -> impl Future<Output = eyre::Result<()>> + Send {
806 std::future::ready(Ok(()))
808 }
809
810 async fn run(
811 &self,
812 host: &Host,
813 artifact: Artifact,
814 options: RunOptions,
815 ) -> Result<Running, FailToRun> {
816 let artifact_path = artifact.path();
817
818 match artifact_path.extension().and_then(|e| e.to_str()) {
820 Some("app") => {
821 run_macos_app(host, artifact, options).await
823 }
824 _ => {
825 run_binary(host, &artifact, &options)
827 }
828 }
829 }
830
831 fn scan(_host: &Host) -> impl Future<Output = eyre::Result<Vec<Self>>> + Send {
832 std::future::ready(Ok(vec![Self]))
834 }
835}
836
837#[cfg(target_os = "macos")]
838#[derive(Debug)]
839struct MacosProcess {
840 pid: u32,
841 command: String,
842}
843
844#[cfg(target_os = "macos")]
845async fn list_macos_processes(host: &Host) -> Result<Vec<MacosProcess>, FailToRun> {
846 let output = host
847 .output("ps", ["-axo", "pid=,command="])
848 .await
849 .map_err(|e| FailToRun::Launch(eyre::eyre!("Failed to list local processes: {e}")))?;
850
851 if !output.status.success() {
852 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
853 return Err(FailToRun::Launch(eyre::eyre!(
854 "Failed to list local processes with ps: {stderr}"
855 )));
856 }
857
858 let stdout = String::from_utf8_lossy(&output.stdout);
859 let mut processes = Vec::new();
860 for line in stdout.lines() {
861 let trimmed = line.trim_start();
862 if trimmed.is_empty() {
863 continue;
864 }
865
866 let mut fields = trimmed.splitn(2, char::is_whitespace);
867 let Some(pid_str) = fields.next() else {
868 continue;
869 };
870 let Some(command) = fields.next() else {
871 continue;
872 };
873
874 let pid = pid_str.parse::<u32>().map_err(|e| {
875 FailToRun::Launch(eyre::eyre!(
876 "Failed to parse process id '{pid_str}' from ps output: {e}"
877 ))
878 })?;
879 processes.push(MacosProcess {
880 pid,
881 command: command.trim_start().to_string(),
882 });
883 }
884
885 Ok(processes)
886}
887
888#[cfg(target_os = "macos")]
889fn command_runs_executable(command: &str, executable_path: &Path) -> bool {
890 let executable = executable_path.to_string_lossy();
891 command == executable || command.starts_with(&format!("{executable} "))
892}
893
894#[cfg(target_os = "macos")]
895async fn read_macos_bundle_identifier(app_path: &Path) -> Result<String, FailToRun> {
896 let plist_path = app_path.join("Contents").join("Info.plist");
897 smol::unblock({
898 let plist_path = plist_path.clone();
899 move || -> eyre::Result<String> {
900 let plist = plist::Value::from_file(&plist_path).map_err(|error| {
901 eyre::eyre!(
902 "Failed to read bundle Info.plist at '{}': {error}",
903 plist_path.display()
904 )
905 })?;
906 let dictionary = plist.into_dictionary().ok_or_else(|| {
907 eyre::eyre!(
908 "Bundle Info.plist at '{}' must contain a dictionary root",
909 plist_path.display()
910 )
911 })?;
912 dictionary
913 .get("CFBundleIdentifier")
914 .and_then(plist::Value::as_string)
915 .map(ToOwned::to_owned)
916 .ok_or_else(|| {
917 eyre::eyre!(
918 "Bundle Info.plist at '{}' is missing CFBundleIdentifier",
919 plist_path.display()
920 )
921 })
922 }
923 })
924 .await
925 .map_err(FailToRun::Launch)
926}
927
928#[cfg(target_os = "macos")]
929fn command_app_bundle_path_for_executable(command: &str, executable_name: &str) -> Option<PathBuf> {
930 const BUNDLE_SUFFIX: &str = ".app";
931 const EXECUTABLE_MARKER: &str = ".app/Contents/MacOS/";
932
933 let command = command.trim_start();
934 if !command.starts_with('/') {
935 return None;
936 }
937 let marker_start = command.find(EXECUTABLE_MARKER)?;
938 let executable_start = marker_start + EXECUTABLE_MARKER.len();
939 let executable_end = executable_start.checked_add(executable_name.len())?;
940 if !command[executable_start..].starts_with(executable_name) {
941 return None;
942 }
943 if command
944 .as_bytes()
945 .get(executable_end)
946 .is_some_and(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r'))
947 {
948 return None;
949 }
950
951 let app_end = marker_start + BUNDLE_SUFFIX.len();
952 Some(PathBuf::from(&command[..app_end]))
953}
954
955#[cfg(target_os = "macos")]
956async fn list_conflicting_macos_app_pids(
957 host: &Host,
958 launch: &MacosBundleLaunchContext,
959) -> Result<Vec<u32>, FailToRun> {
960 let executable_name = launch
961 .executable_path
962 .file_name()
963 .and_then(|name| name.to_str())
964 .ok_or_else(|| {
965 FailToRun::Launch(eyre::eyre!(
966 "Failed to determine executable name for '{}'",
967 launch.executable_path.display()
968 ))
969 })?;
970
971 let mut pids = BTreeSet::new();
972 for process in list_macos_processes(host).await? {
973 if command_runs_executable(&process.command, &launch.executable_path) {
974 pids.insert(process.pid);
975 continue;
976 }
977
978 let Some(app_path) =
979 command_app_bundle_path_for_executable(&process.command, executable_name)
980 else {
981 continue;
982 };
983 match read_macos_bundle_identifier(&app_path).await {
987 Ok(bundle_id) if bundle_id == launch.bundle_id => {
988 pids.insert(process.pid);
989 }
990 Ok(_) => {}
991 Err(error) => {
992 tracing::debug!(
993 pid = process.pid,
994 path = %app_path.display(),
995 "Skipping running app with unreadable bundle: {error:?}"
996 );
997 }
998 }
999 }
1000
1001 Ok(pids.into_iter().collect())
1002}
1003
1004#[cfg(target_os = "macos")]
1005fn quiet_kill_command(host: &Host, signal: &str, pid: &str) -> Command {
1006 let mut command = host.command("kill");
1007 command
1008 .arg(signal)
1009 .arg(pid)
1010 .stdout(Stdio::null())
1011 .stderr(Stdio::null());
1012 command
1013}
1014
1015#[cfg(target_os = "macos")]
1016async fn is_pid_alive(host: &Host, pid: u32) -> bool {
1017 let pid = pid.to_string();
1018 quiet_kill_command(host, "-0", &pid)
1019 .status()
1020 .await
1021 .is_ok_and(|status| status.success())
1022}
1023
1024#[cfg(target_os = "macos")]
1025async fn terminate_pids(host: &Host, pids: &[u32]) -> Result<(), FailToRun> {
1026 if pids.is_empty() {
1027 return Ok(());
1028 }
1029
1030 for &pid in pids {
1031 let pid = pid.to_string();
1032 let status = quiet_kill_command(host, "-TERM", &pid)
1033 .status()
1034 .await
1035 .map_err(|e| {
1036 FailToRun::Launch(eyre::eyre!(
1037 "Failed to terminate existing app process {pid}: {e}"
1038 ))
1039 })?;
1040 if !status.success() {
1041 return Err(FailToRun::Launch(eyre::eyre!(
1042 "Failed to terminate existing app process {pid} before relaunch"
1043 )));
1044 }
1045 }
1046
1047 let deadline = Instant::now() + Duration::from_secs(5);
1048 while Instant::now() < deadline {
1049 let mut alive = false;
1050 for &pid in pids {
1051 if is_pid_alive(host, pid).await {
1052 alive = true;
1053 break;
1054 }
1055 }
1056 if !alive {
1057 return Ok(());
1058 }
1059 Timer::after(Duration::from_millis(80)).await;
1060 }
1061
1062 Err(FailToRun::Launch(eyre::eyre!(
1063 "Timed out waiting for previous app instance(s) to terminate before relaunch"
1064 )))
1065}
1066
1067#[cfg(target_os = "macos")]
1068pub(crate) async fn resolve_macos_bundle_executable_path(
1069 artifact_path: &Path,
1070) -> Result<PathBuf, FailToRun> {
1071 let plist_path = artifact_path.join("Contents").join("Info.plist");
1072 let executable_name = smol::unblock({
1073 let plist_path = plist_path.clone();
1074 move || -> eyre::Result<String> {
1075 let plist = plist::Value::from_file(&plist_path).map_err(|error| {
1076 eyre::eyre!(
1077 "Failed to read bundle Info.plist at '{}': {error}",
1078 plist_path.display()
1079 )
1080 })?;
1081 let dictionary = plist.into_dictionary().ok_or_else(|| {
1082 eyre::eyre!(
1083 "Bundle Info.plist at '{}' must contain a dictionary root",
1084 plist_path.display()
1085 )
1086 })?;
1087 let executable = dictionary
1088 .get("CFBundleExecutable")
1089 .and_then(plist::Value::as_string)
1090 .ok_or_else(|| {
1091 eyre::eyre!(
1092 "Bundle Info.plist at '{}' is missing CFBundleExecutable",
1093 plist_path.display()
1094 )
1095 })?;
1096 Ok(executable.to_string())
1097 }
1098 })
1099 .await
1100 .map_err(FailToRun::Launch)?;
1101
1102 Ok(artifact_path
1103 .join("Contents")
1104 .join("MacOS")
1105 .join(executable_name))
1106}
1107
1108#[cfg(target_os = "macos")]
1109struct MacosBundleLaunchContext {
1110 bundle_id: String,
1111 artifact_path: PathBuf,
1112 executable_path: PathBuf,
1113}
1114
1115pub(crate) fn format_panic_message(payload: &str, location: Option<&str>) -> String {
1116 let mut msg = format!("Panic: {payload}");
1117 if let Some(location) = location {
1118 msg.push('\n');
1119 msg.push_str(" at ");
1120 msg.push_str(location);
1121 }
1122 msg
1123}
1124
1125#[cfg(target_os = "macos")]
1126async fn prepare_macos_bundle_launch(
1127 artifact: Artifact,
1128) -> Result<MacosBundleLaunchContext, FailToRun> {
1129 let artifact_path = artifact.path().to_path_buf();
1130 let executable_path = resolve_macos_bundle_executable_path(&artifact_path).await?;
1131
1132 Ok(MacosBundleLaunchContext {
1133 bundle_id: artifact.bundle_id().to_string(),
1134 artifact_path,
1135 executable_path,
1136 })
1137}
1138
1139#[cfg(target_os = "macos")]
1145const MACOS_LAUNCH_BACKSTOP: Duration = Duration::from_secs(120);
1146
1147#[cfg(target_os = "macos")]
1148async fn launch_macos_bundle_process(
1149 host: &Host,
1150 launch: &MacosBundleLaunchContext,
1151 options: &RunOptions,
1152) -> Result<(smol::process::Child, u32), FailToRun> {
1153 use tracing::info;
1154
1155 if options.replace_existing_macos_app_instances() {
1156 let existing_pids = list_conflicting_macos_app_pids(host, launch).await?;
1157 terminate_pids(host, &existing_pids).await?;
1158 }
1159
1160 let existing_pids = list_conflicting_macos_app_pids(host, launch)
1161 .await?
1162 .into_iter()
1163 .collect::<BTreeSet<_>>();
1164 info!("Launching app on macOS: {}", launch.artifact_path.display());
1165 let mut command = host.command("open");
1166 command.arg("-W").arg("-n");
1167 for (key, value) in options.env_vars() {
1168 command.arg("--env").arg(format!("{key}={value}"));
1169 }
1170 command
1171 .arg(&launch.artifact_path)
1172 .stdout(Stdio::piped())
1173 .stderr(Stdio::piped())
1174 .kill_on_drop(true);
1175 let mut child = command.spawn().map_err(|error| {
1176 FailToRun::Launch(eyre::eyre!(
1177 "Failed to launch macOS app bundle '{}': {error}",
1178 launch.artifact_path.display()
1179 ))
1180 })?;
1181
1182 let deadline = Instant::now() + MACOS_LAUNCH_BACKSTOP;
1188 while Instant::now() < deadline {
1189 let new_pid = list_conflicting_macos_app_pids(host, launch)
1190 .await?
1191 .into_iter()
1192 .find(|pid| !existing_pids.contains(pid));
1193 if let Some(app_pid) = new_pid {
1194 return Ok((child, app_pid));
1195 }
1196
1197 match child.try_status() {
1201 Ok(Some(status)) => {
1202 let mut stderr_text = String::new();
1203 if let Some(stderr) = child.stderr.as_mut() {
1204 use smol::io::AsyncReadExt as _;
1205 let _ = stderr.read_to_string(&mut stderr_text).await;
1206 }
1207 let stderr_text = stderr_text.trim();
1208 return Err(FailToRun::Launch(eyre::eyre!(
1209 "LaunchServices failed to start '{}': `open` exited with {status}{}{}",
1210 launch.artifact_path.display(),
1211 if stderr_text.is_empty() { "" } else { ": " },
1212 stderr_text,
1213 )));
1214 }
1215 Ok(None) => {}
1216 Err(error) => {
1217 return Err(FailToRun::Launch(eyre::eyre!(
1218 "Failed to supervise the `open` process for '{}': {error}",
1219 launch.artifact_path.display()
1220 )));
1221 }
1222 }
1223
1224 Timer::after(Duration::from_millis(80)).await;
1225 }
1226
1227 let _ = child.kill();
1228 let _ = child.status().await;
1229 Err(FailToRun::Launch(eyre::eyre!(
1230 "LaunchServices neither started '{}' nor failed within {MACOS_LAUNCH_BACKSTOP:?}; \
1231 `open` is still running with no matching app process",
1232 launch.artifact_path.display()
1233 )))
1234}
1235
1236#[cfg(target_os = "macos")]
1242async fn run_macos_app(
1243 host: &Host,
1244 artifact: Artifact,
1245 options: RunOptions,
1246) -> Result<Running, FailToRun> {
1247 let launch = prepare_macos_bundle_launch(artifact).await?;
1248 let started_at = Instant::now();
1249 let (child, app_pid) = launch_macos_bundle_process(host, &launch, &options).await?;
1250 let (cancel_tx, cancel_rx) = smol::channel::bounded(1);
1251 let (mut running, sender) = Running::new(move || {
1252 let pid = nix::unistd::Pid::from_raw(
1253 i32::try_from(app_pid).expect("macOS process identifiers fit in i32"),
1254 );
1255 let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM);
1256 let _ = cancel_tx.try_send(());
1257 });
1258 let (log_stream, log_child) =
1259 start_log_stream(host, sender.clone(), options.log_level(), app_pid)?;
1260 running.retain(log_child);
1261 let monitor = ChildMonitor::new(child, sender.clone(), cancel_rx);
1262 spawn_macos_app_exit_monitor(host, monitor, log_stream, sender, started_at, app_pid);
1263
1264 Ok(running)
1265}
1266
1267#[cfg(not(target_os = "macos"))]
1269fn run_macos_app(
1270 _host: &Host,
1271 _artifact: Artifact,
1272 _options: RunOptions,
1273) -> impl std::future::Future<Output = Result<Running, FailToRun>> {
1274 std::future::ready(Err(FailToRun::InvalidArtifact)) }
1276
1277fn run_binary(
1281 host: &Host,
1282 artifact: &Artifact,
1283 options: &RunOptions,
1284) -> Result<Running, FailToRun> {
1285 let binary_path = artifact.path();
1286 if !binary_path.exists() {
1287 return Err(FailToRun::InvalidArtifact);
1288 }
1289
1290 let child = spawn_local_child(host, binary_path, options)?;
1291 let (cancel_tx, cancel_rx) = smol::channel::bounded(1);
1292 let (running, sender) = Running::new(move || {
1293 let _ = cancel_tx.try_send(());
1294 });
1295 let monitor = ChildMonitor::new(child, sender.clone(), cancel_rx);
1296 spawn_binary_exit_monitor(monitor, sender);
1297
1298 Ok(running)
1299}
1300
1301fn spawn_local_child(
1302 host: &Host,
1303 executable_path: &Path,
1304 options: &RunOptions,
1305) -> Result<smol::process::Child, FailToRun> {
1306 use smol::process::Stdio;
1307
1308 let mut cmd = host.command(executable_path);
1309 for (key, value) in options.env_vars() {
1310 cmd.env(key, value);
1311 }
1312
1313 cmd.stdout(Stdio::piped());
1314 cmd.stderr(Stdio::piped());
1315 cmd.kill_on_drop(true);
1316 cmd.spawn().map_err(|error| {
1317 FailToRun::Launch(eyre::eyre!(
1318 "Failed to launch '{}': {error}",
1319 executable_path.display()
1320 ))
1321 })
1322}
1323
1324fn spawn_stdout_forwarder(
1325 stdout: smol::process::ChildStdout,
1326 sender: Sender<DeviceEvent>,
1327) -> smol::Task<()> {
1328 use smol::io::{AsyncBufReadExt, BufReader};
1329 use smol::spawn;
1330 use smol::stream::StreamExt;
1331
1332 spawn(async move {
1333 let reader = BufReader::new(stdout);
1334 let mut lines = reader.lines();
1335 while let Some(result) = lines.next().await {
1336 let Ok(line) = result else { break };
1337 if sender
1338 .try_send(DeviceEvent::Log {
1339 level: parse_log_level(&line),
1340 message: line,
1341 })
1342 .is_err()
1343 {
1344 break;
1345 }
1346 }
1347 })
1348}
1349
1350fn spawn_stderr_forwarder(
1351 stderr: smol::process::ChildStderr,
1352 sender: Sender<DeviceEvent>,
1353 panic_tx: Sender<String>,
1354) -> smol::Task<()> {
1355 use smol::io::{AsyncBufReadExt, BufReader};
1356 use smol::spawn;
1357 use smol::stream::StreamExt;
1358
1359 spawn(async move {
1360 let reader = BufReader::new(stderr);
1361 let mut lines = reader.lines();
1362 let mut panic_lines = Vec::new();
1363 let mut capturing_panic = false;
1364
1365 while let Some(result) = lines.next().await {
1366 let Ok(line) = result else { break };
1367
1368 if starts_panic_capture(&line) {
1369 capturing_panic = true;
1370 panic_lines.clear();
1371 }
1372
1373 if capturing_panic {
1374 panic_lines.push(line.clone());
1375 if should_flush_panic_capture(&panic_lines, &line) {
1376 capturing_panic = false;
1377 try_send_panic_message(&panic_tx, &panic_lines);
1378 }
1379 }
1380
1381 if sender
1382 .try_send(DeviceEvent::Stderr { message: line })
1383 .is_err()
1384 {
1385 break;
1386 }
1387 }
1388
1389 if capturing_panic && !panic_lines.is_empty() {
1390 try_send_panic_message(&panic_tx, &panic_lines);
1391 }
1392 })
1393}
1394
1395fn starts_panic_capture(line: &str) -> bool {
1396 line.contains("panicked at") || line.starts_with("thread '") && line.contains("panic")
1397}
1398
1399fn should_flush_panic_capture(panic_lines: &[String], line: &str) -> bool {
1400 panic_lines.len() > 10 || panic_lines.len() > 2 && line.trim().is_empty()
1401}
1402
1403fn try_send_panic_message(panic_tx: &Sender<String>, panic_lines: &[String]) {
1404 if let Some(message) = extract_panic_message(panic_lines) {
1405 let _ = panic_tx.try_send(message);
1406 }
1407}
1408
1409struct ChildMonitor {
1410 child: smol::process::Child,
1411 stdout_task: Option<smol::Task<()>>,
1412 stderr_task: Option<smol::Task<()>>,
1413 panic_rx: Receiver<String>,
1414 cancel_rx: Receiver<()>,
1415}
1416
1417impl ChildMonitor {
1418 fn new(
1419 mut child: smol::process::Child,
1420 sender: Sender<DeviceEvent>,
1421 cancel_rx: Receiver<()>,
1422 ) -> Self {
1423 let (panic_tx, panic_rx) = smol::channel::unbounded::<String>();
1424 let stdout_task = child
1425 .stdout
1426 .take()
1427 .map(|stdout| spawn_stdout_forwarder(stdout, sender.clone()));
1428 let stderr_task = child
1429 .stderr
1430 .take()
1431 .map(|stderr| spawn_stderr_forwarder(stderr, sender, panic_tx));
1432
1433 Self {
1434 child,
1435 stdout_task,
1436 stderr_task,
1437 panic_rx,
1438 cancel_rx,
1439 }
1440 }
1441
1442 async fn wait(mut self) -> Option<ChildExit> {
1443 let status = {
1444 let wait = self.child.status();
1445 let cancel = self.cancel_rx.recv();
1446 let wait = std::pin::pin!(wait);
1447 let cancel = std::pin::pin!(cancel);
1448
1449 match futures_util::future::select(wait, cancel).await {
1450 futures_util::future::Either::Left((status, _)) => Some(status),
1451 futures_util::future::Either::Right(_) => None,
1452 }
1453 };
1454
1455 if status.is_none() {
1456 let _ = self.child.kill();
1457 let _ = self.child.status().await;
1458 }
1459
1460 if let Some(task) = self.stdout_task {
1461 task.await;
1462 }
1463 if let Some(task) = self.stderr_task {
1464 task.await;
1465 }
1466
1467 status.map(|status| ChildExit {
1468 status,
1469 panic_message: latest_panic_message(&self.panic_rx),
1470 })
1471 }
1472}
1473
1474struct ChildExit {
1475 status: std::io::Result<std::process::ExitStatus>,
1476 panic_message: Option<String>,
1477}
1478
1479fn spawn_binary_exit_monitor(monitor: ChildMonitor, sender: Sender<DeviceEvent>) {
1480 use smol::spawn;
1481
1482 spawn(async move {
1483 let Some(exit) = monitor.wait().await else {
1484 return;
1485 };
1486 emit_process_exit_event(
1487 &sender,
1488 exit.status,
1489 exit.panic_message,
1490 ApplicationExit::completed(),
1491 );
1492 })
1493 .detach();
1494}
1495
1496#[cfg(target_os = "macos")]
1497fn spawn_macos_app_exit_monitor(
1498 host: &Host,
1499 monitor: ChildMonitor,
1500 log_stream: MacosLogStream,
1501 sender: Sender<DeviceEvent>,
1502 started_at: Instant,
1503 pid: u32,
1504) {
1505 let host = host.clone();
1506 spawn(async move {
1507 let Some(exit) = monitor.wait().await else {
1508 return;
1509 };
1510
1511 let mut panic_message = exit
1512 .panic_message
1513 .or_else(|| latest_panic_message(&log_stream.panic_rx));
1514 drop(log_stream.task);
1515
1516 if panic_message.is_none()
1517 && matches!(&exit.status, Ok(exit_status) if !exit_status.success())
1518 {
1519 panic_message = fetch_recent_panic_logs(&host, started_at, Some(pid)).await;
1520 }
1521
1522 emit_process_exit_event(
1523 &sender,
1524 exit.status,
1525 panic_message,
1526 ApplicationExit::user_closed(),
1527 );
1528 })
1529 .detach();
1530}
1531
1532fn latest_panic_message(panic_rx: &Receiver<String>) -> Option<String> {
1533 let mut panic_message = None;
1534 while let Ok(message) = panic_rx.try_recv() {
1535 panic_message = Some(message);
1536 }
1537 panic_message
1538}
1539
1540fn emit_process_exit_event(
1541 sender: &Sender<DeviceEvent>,
1542 status: std::io::Result<std::process::ExitStatus>,
1543 panic_message: Option<String>,
1544 successful_exit: ApplicationExit,
1545) {
1546 match status {
1547 Ok(exit_status) if exit_status.success() => {
1548 let _ = sender.try_send(DeviceEvent::Exited(successful_exit));
1549 }
1550 Ok(exit_status) => {
1551 let _ = sender.try_send(DeviceEvent::Crashed(process_crash_message(
1552 exit_status,
1553 panic_message,
1554 )));
1555 }
1556 Err(error) => {
1557 let _ = sender.try_send(DeviceEvent::Crashed(format!("Process error: {error}")));
1558 }
1559 }
1560}
1561
1562fn process_crash_message(
1563 exit_status: std::process::ExitStatus,
1564 panic_message: Option<String>,
1565) -> String {
1566 #[cfg(unix)]
1567 {
1568 use std::os::unix::process::ExitStatusExt;
1569
1570 if let Some(signal) = exit_status.signal() {
1571 let signal_name = match signal {
1572 6 => "SIGABRT",
1573 11 => "SIGSEGV",
1574 _ => "",
1575 };
1576
1577 let termination = if signal_name.is_empty() {
1578 format!("signal {signal}")
1579 } else {
1580 format!("signal {signal} ({signal_name})")
1581 };
1582
1583 return panic_message.map_or_else(
1584 || {
1585 if signal_name.is_empty() {
1586 format!("Terminated by signal {signal}")
1587 } else {
1588 format!("Process crashed ({signal_name})")
1589 }
1590 },
1591 |panic| panic_process_message(&panic, &termination),
1592 );
1593 }
1594 }
1595
1596 let code = exit_status.code().unwrap_or(-1);
1597 panic_message.map_or_else(
1598 || format!("Exit code: {code}"),
1599 |panic| panic_process_message(&panic, &format!("exit code {code}")),
1600 )
1601}
1602
1603fn panic_process_message(panic: &str, termination: &str) -> String {
1604 let panic = panic.strip_prefix("Panic:").map_or(panic, str::trim_start);
1605 format!("Panic: {panic}\n process terminated with {termination}")
1606}
1607
1608fn extract_panic_message(lines: &[String]) -> Option<String> {
1610 for line in lines {
1611 if let Some(idx) = line.find("panicked at") {
1614 let after = &line[idx + 11..].trim_start();
1615
1616 if after.starts_with('\'')
1618 && let Some(end) = after[1..].find('\'')
1619 {
1620 let message = &after[1..=end];
1621 let location = after[end + 2..].trim_start_matches(", ").trim();
1623 if location.is_empty() {
1624 return Some(message.to_string());
1625 }
1626 return Some(format!("{message}\n at {location}"));
1627 }
1628
1629 if after.ends_with(':') {
1632 let location = after.trim_end_matches(':');
1633 for next_line in lines.iter().skip(1) {
1635 let msg = next_line.trim();
1636 if !msg.is_empty()
1637 && !msg.starts_with("note:")
1638 && !msg.starts_with("stack backtrace:")
1639 {
1640 return Some(format!("{msg}\n at {location}"));
1641 }
1642 }
1643 return Some(format!("panic at {location}"));
1644 }
1645
1646 return Some(after.to_string());
1648 }
1649 }
1650 None
1651}
1652
1653fn parse_log_level(line: &str) -> tracing::Level {
1655 let line_lower = line.to_lowercase();
1656 if line_lower.contains("error") || line_lower.contains("fatal") || line_lower.contains("panic")
1657 {
1658 tracing::Level::ERROR
1659 } else if line_lower.contains("warn") {
1660 tracing::Level::WARN
1661 } else if line_lower.contains("debug") {
1662 tracing::Level::DEBUG
1663 } else if line_lower.contains("trace") {
1664 tracing::Level::TRACE
1665 } else {
1666 tracing::Level::INFO
1667 }
1668}
1669
1670#[cfg(test)]
1671mod tests {
1672 use std::process::ExitStatus;
1673 use std::sync::Arc;
1674 use std::sync::atomic::{AtomicBool, Ordering};
1675
1676 use smol::channel::unbounded;
1677
1678 use super::{
1679 ApplicationExit, ApplicationExitReason, DeviceEvent, Running, emit_process_exit_event,
1680 parse_log_level,
1681 };
1682 #[cfg(target_os = "macos")]
1683 use super::{command_app_bundle_path_for_executable, command_runs_executable};
1684
1685 #[cfg(unix)]
1686 fn successful_exit_status() -> ExitStatus {
1687 use std::os::unix::process::ExitStatusExt;
1688
1689 ExitStatus::from_raw(0)
1690 }
1691
1692 #[cfg(windows)]
1693 fn successful_exit_status() -> ExitStatus {
1694 use std::os::windows::process::ExitStatusExt;
1695
1696 ExitStatus::from_raw(0)
1697 }
1698
1699 #[cfg(unix)]
1700 fn failing_exit_status(code: i32) -> ExitStatus {
1701 use std::os::unix::process::ExitStatusExt;
1702
1703 ExitStatus::from_raw(code << 8)
1704 }
1705
1706 #[cfg(windows)]
1707 fn failing_exit_status(code: u32) -> ExitStatus {
1708 use std::os::windows::process::ExitStatusExt;
1709
1710 ExitStatus::from_raw(code)
1711 }
1712
1713 #[test]
1714 fn application_exit_messages_are_reason_specific() {
1715 assert_eq!(
1716 ApplicationExit::completed().reason(),
1717 ApplicationExitReason::Completed
1718 );
1719 assert_eq!(
1720 ApplicationExit::completed().terminal_message(),
1721 "Application exited"
1722 );
1723 assert_eq!(
1724 ApplicationExit::user_closed().reason(),
1725 ApplicationExitReason::UserClosed
1726 );
1727 assert_eq!(
1728 ApplicationExit::user_closed().terminal_message(),
1729 "Application closed"
1730 );
1731 }
1732
1733 #[test]
1734 fn successful_binary_status_emits_completed_exit() {
1735 let (sender, receiver) = unbounded();
1736 emit_process_exit_event(
1737 &sender,
1738 Ok(successful_exit_status()),
1739 None,
1740 ApplicationExit::completed(),
1741 );
1742
1743 let event = receiver
1744 .try_recv()
1745 .expect("successful status should emit an event");
1746 let DeviceEvent::Exited(exit) = event else {
1747 panic!("successful status should emit a clean exit");
1748 };
1749 assert_eq!(exit.reason(), ApplicationExitReason::Completed);
1750 }
1751
1752 #[test]
1753 fn successful_gui_status_emits_user_closed_exit() {
1754 let (sender, receiver) = unbounded();
1755 emit_process_exit_event(
1756 &sender,
1757 Ok(successful_exit_status()),
1758 None,
1759 ApplicationExit::user_closed(),
1760 );
1761
1762 let event = receiver
1763 .try_recv()
1764 .expect("successful status should emit an event");
1765 let DeviceEvent::Exited(exit) = event else {
1766 panic!("successful status should emit a clean exit");
1767 };
1768 assert_eq!(exit.reason(), ApplicationExitReason::UserClosed);
1769 }
1770
1771 #[test]
1772 fn failing_binary_status_emits_crash_message() {
1773 let (sender, receiver) = unbounded();
1774 emit_process_exit_event(
1775 &sender,
1776 Ok(failing_exit_status(7)),
1777 Some("backend panic".to_string()),
1778 ApplicationExit::completed(),
1779 );
1780
1781 let event = receiver
1782 .try_recv()
1783 .expect("failing status should emit an event");
1784 let DeviceEvent::Crashed(message) = event else {
1785 panic!("failing status should emit a crash event");
1786 };
1787 assert!(message.starts_with("Panic:"));
1788 assert!(message.contains("backend panic"));
1789 assert!(message.contains('7'));
1790 }
1791
1792 #[test]
1793 fn parse_log_level_detects_panic_as_error() {
1794 assert_eq!(
1795 parse_log_level("thread panicked at app.rs"),
1796 tracing::Level::ERROR
1797 );
1798 }
1799
1800 #[cfg(target_os = "macos")]
1801 #[test]
1802 fn macos_process_command_extracts_app_path_with_spaces() {
1803 let app_path = command_app_bundle_path_for_executable(
1804 "/tmp/water build/My App.app/Contents/MacOS/my-app --flag",
1805 "my-app",
1806 )
1807 .expect("app path should be extracted");
1808 assert_eq!(
1809 app_path,
1810 std::path::PathBuf::from("/tmp/water build/My App.app")
1811 );
1812 }
1813
1814 #[cfg(target_os = "macos")]
1815 #[test]
1816 fn macos_process_command_rejects_nonmatching_executable_prefix() {
1817 assert!(
1818 command_app_bundle_path_for_executable(
1819 "/tmp/My App.app/Contents/MacOS/my-app-helper",
1820 "my-app",
1821 )
1822 .is_none()
1823 );
1824 }
1825
1826 #[cfg(target_os = "macos")]
1827 #[test]
1828 fn macos_process_command_matches_exact_executable_path() {
1829 let executable = std::path::Path::new("/tmp/My App.app/Contents/MacOS/my-app");
1830 assert!(command_runs_executable(
1831 "/tmp/My App.app/Contents/MacOS/my-app --flag",
1832 executable,
1833 ));
1834 }
1835
1836 struct DropProbe(Arc<AtomicBool>);
1837
1838 impl Drop for DropProbe {
1839 fn drop(&mut self) {
1840 self.0.store(true, Ordering::SeqCst);
1841 }
1842 }
1843
1844 #[test]
1845 fn dropping_a_running_fires_retained_guards() {
1846 let fired = Arc::new(AtomicBool::new(false));
1847 let (mut running, _sender) = Running::new(|| {});
1848 running.retain(DropProbe(fired.clone()));
1849 drop(running);
1850 assert!(fired.load(Ordering::SeqCst));
1851 }
1852
1853 #[test]
1854 fn detach_keeps_retained_guards_from_firing() {
1855 let fired = Arc::new(AtomicBool::new(false));
1859 let (mut running, _sender) = Running::new(|| {});
1860 running.retain(DropProbe(fired.clone()));
1861 let mut running = Box::pin(running);
1862 running.as_mut().detach();
1863 drop(running);
1864 assert!(!fired.load(Ordering::SeqCst));
1865 }
1866}