1use std::net::SocketAddr;
4use std::sync::{Mutex, PoisonError};
5use std::time::Duration;
6
7use crate::server::{
8 ServerOptions, WebListenerProcess, WebViewerAccess, WebViewerRecovery, run_server_on_listener,
9};
10use anyhow::{Context, Result, bail, ensure};
11use tokio::net::TcpListener;
12use tokio::sync::{mpsc, watch};
13use tokio_util::sync::CancellationToken;
14
15pub struct ViewerControl {
16 access: watch::Sender<WebViewerAccess>,
17 commands: Mutex<Option<mpsc::Sender<WebViewerRecovery>>>,
18}
19
20impl Default for ViewerControl {
21 fn default() -> Self {
22 Self::new()
23 }
24}
25
26impl ViewerControl {
27 pub fn new() -> Self {
28 Self {
29 access: watch::channel(WebViewerAccess::Starting).0,
30 commands: Mutex::new(None),
31 }
32 }
33
34 pub fn access(&self) -> WebViewerAccess {
35 self.access.borrow().clone()
36 }
37
38 pub fn publish(&self, access: WebViewerAccess) {
39 self.access.send_replace(access);
40 }
41
42 pub fn recover(&self, action: WebViewerRecovery) -> Result<()> {
43 let commands = self.commands.lock().unwrap_or_else(PoisonError::into_inner);
45 ensure!(
46 matches!(self.access(), WebViewerAccess::Failed { .. }),
47 "The viewer is already running or starting. Refresh its status before retrying."
48 );
49 let sender = commands
50 .as_ref()
51 .context("Viewer recovery is unavailable")?;
52 let previous = self.access();
53 if matches!(&action, WebViewerRecovery::StopAndRetry(_)) {
54 self.conflict_address()?;
55 }
56 self.publish(WebViewerAccess::Starting);
57 if let Err(error) = sender.try_send(action) {
58 self.publish(previous);
59 return Err(error).context("A viewer recovery is already in progress");
60 }
61 Ok(())
62 }
63
64 pub fn conflict_address(&self) -> Result<SocketAddr> {
65 match self.access() {
66 WebViewerAccess::Failed {
67 address,
68 port_conflict: true,
69 ..
70 } => Ok(address),
71 _ => bail!("The viewer no longer has a port conflict. Refresh its status."),
72 }
73 }
74}
75
76pub async fn serve(
78 options: ServerOptions,
79 ready: WebViewerAccess,
80 control: &ViewerControl,
81 report: impl Fn(WebViewerAccess),
82) -> Result<()> {
83 let (commands, mut requests) = mpsc::channel(1);
84 *control
85 .commands
86 .lock()
87 .unwrap_or_else(PoisonError::into_inner) = Some(commands);
88 let result = serve_inner(options, ready, &mut requests, report).await;
89 *control
90 .commands
91 .lock()
92 .unwrap_or_else(PoisonError::into_inner) = None;
93 result
94}
95
96async fn serve_inner(
97 options: ServerOptions,
98 ready: WebViewerAccess,
99 requests: &mut mpsc::Receiver<WebViewerRecovery>,
100 report: impl Fn(WebViewerAccess),
101) -> Result<()> {
102 let mut address = options.bind;
103 let mut recovery_failure = None;
104 loop {
105 let failure = if let Some(message) = recovery_failure.take() {
106 WebViewerAccess::Failed {
107 address,
108 message,
109 port_conflict: true,
110 }
111 } else {
112 report(WebViewerAccess::Starting);
113 let listener = tokio::select! {
114 _ = options.shutdown.cancelled() => return Ok(()),
115 result = TcpListener::bind(address) => result,
116 };
117 match listener {
118 Ok(listener) => {
119 address = listener.local_addr().context("read reserved viewer port")?;
120 report(ready_at(&ready, address.port())?);
121 let result = run_server_on_listener(options.clone(), listener).await;
122 if options.shutdown.is_cancelled() {
123 return result;
124 }
125 let message = match result {
126 Ok(()) => {
127 "The web viewer stopped unexpectedly. Try starting it again.".to_owned()
128 }
129 Err(error) => {
130 tracing::warn!(error = format!("{error:#}"), %address, "web viewer stopped");
131 format!("The web viewer stopped: {error}")
132 }
133 };
134 WebViewerAccess::Failed {
135 address,
136 message,
137 port_conflict: false,
138 }
139 }
140 Err(error) => {
141 let port_conflict = error.kind() == std::io::ErrorKind::AddrInUse;
142 let message = if port_conflict {
143 format!("Port {} is already in use.", address.port())
144 } else {
145 format!("Could not listen on {address}: {error}")
146 };
147 WebViewerAccess::Failed {
148 address,
149 message,
150 port_conflict,
151 }
152 }
153 }
154 };
155 report(failure);
156 let request = tokio::select! {
157 _ = options.shutdown.cancelled() => return Ok(()),
158 request = requests.recv() => request.context("viewer recovery channel closed")?,
159 };
160 report(WebViewerAccess::Starting);
161 match request {
162 WebViewerRecovery::Retry => {}
163 WebViewerRecovery::AnotherPort => address.set_port(0),
164 WebViewerRecovery::StopAndRetry(process) => {
165 let cancellation = options.shutdown.clone();
166 let result = stop_listener(address, process, cancellation).await;
167 if let Err(error) = result {
168 recovery_failure = Some(format!("Could not stop the server: {error:#}"));
169 }
170 }
171 }
172 }
173}
174
175fn ready_at(ready: &WebViewerAccess, port: u16) -> Result<WebViewerAccess> {
176 let WebViewerAccess::Ready {
177 viewer_url,
178 viewer_code,
179 qr_login_url,
180 fallback_reason,
181 } = ready
182 else {
183 bail!("viewer startup is missing its access details");
184 };
185 fn with_port(value: &str, port: u16) -> Result<String> {
186 let mut url = url::Url::parse(value).context("parse viewer URL")?;
187 url.set_port(Some(port))
188 .map_err(|()| anyhow::anyhow!("viewer URL cannot have a port"))?;
189 Ok(url.into())
190 }
191 Ok(WebViewerAccess::Ready {
192 viewer_url: with_port(viewer_url, port)?,
193 viewer_code: viewer_code.clone(),
194 qr_login_url: qr_login_url
195 .as_deref()
196 .map(|url| with_port(url, port))
197 .transpose()?,
198 fallback_reason: fallback_reason.clone(),
199 })
200}
201
202pub fn inspect_listener(address: SocketAddr) -> Result<Vec<WebListenerProcess>> {
203 let pids = listener_pids(address)?;
204 let mut system = sysinfo::System::new();
205 let own_pid = sysinfo::Pid::from_u32(std::process::id());
206 let mut requested = pids
207 .iter()
208 .map(|pid| sysinfo::Pid::from_u32(*pid))
209 .collect::<Vec<_>>();
210 if !requested.contains(&own_pid) {
211 requested.push(own_pid);
212 }
213 system.refresh_processes_specifics(
214 sysinfo::ProcessesToUpdate::Some(&requested),
215 true,
216 sysinfo::ProcessRefreshKind::new()
217 .with_user(sysinfo::UpdateKind::Always)
218 .with_cmd(sysinfo::UpdateKind::Always)
219 .with_exe(sysinfo::UpdateKind::Always),
220 );
221 let own_user = system
222 .process(own_pid)
223 .and_then(|process| process.user_id());
224 Ok(pids
225 .into_iter()
226 .filter_map(|pid| {
227 let process = system.process(sysinfo::Pid::from_u32(pid))?;
228 let executable = process.exe().map(std::path::Path::to_path_buf).unwrap_or_default();
229 let is_mj = executable.file_stem().is_some_and(|name| name == "mj")
230 && process.cmd().get(1).is_some_and(|arg| arg == "daemon-run");
231 let reason = if pid == std::process::id() {
232 Some("This is the current daemon; stopping it would disconnect this dashboard.")
233 } else if own_user.is_none() || process.user_id() != own_user {
234 Some("This process belongs to another user or its owner cannot be verified.")
235 } else if !is_mj {
236 Some("This is not an identified Mjolnir server. Stop it in its own application.")
237 } else if !cfg!(target_os = "linux") {
238 Some("Safe stopping is unavailable on this platform. Stop this server in its application or use another port.")
239 } else {
240 None
241 };
242 Some(WebListenerProcess {
243 pid,
244 name: process.name().to_string_lossy().into_owned(),
245 executable,
246 started_at: process.start_time(),
247 stop_disabled_reason: reason.map(str::to_owned),
248 })
249 })
250 .collect())
251}
252
253async fn stop_listener(
254 address: SocketAddr,
255 expected: WebListenerProcess,
256 cancel: CancellationToken,
257) -> Result<()> {
258 let pid = expected.pid;
259 let signal_cancel = cancel.clone();
260 let mut task = tokio::task::spawn_blocking(move || {
261 #[cfg(target_os = "linux")]
263 let process_handle = open_process_handle(pid)?;
264 let current = inspect_listener(address)?
265 .into_iter()
266 .find(|process| process.pid == pid)
267 .context("That process no longer owns this listener. Inspect the port again.")?;
268 ensure!(
269 current == expected,
270 "The listener's identity changed. Inspect the port again."
271 );
272 ensure!(
273 current.stop_disabled_reason.is_none(),
274 "{}",
275 current.stop_disabled_reason.unwrap_or_default()
276 );
277 ensure!(
278 !signal_cancel.is_cancelled(),
279 "Viewer shutdown cancelled the stop request"
280 );
281 #[cfg(target_os = "linux")]
282 {
283 signal_process(&process_handle)
284 }
285 #[cfg(not(target_os = "linux"))]
286 {
287 bail!(
288 "Safe process termination is unavailable on this platform. Use another port or stop the identified server in its application."
289 )
290 }
291 });
292 tokio::select! {
293 result = &mut task => result.context("listener stop task failed")??,
294 _ = cancel.cancelled() => {
295 match task.await {
297 Ok(Ok(())) => {},
298 Ok(Err(error)) => tracing::warn!(%error, "listener stop failed during shutdown"),
299 Err(error) => tracing::warn!(%error, "listener stop task failed during shutdown"),
300 }
301 bail!("Viewer shutdown interrupted recovery");
302 }
303 }
304 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
305 loop {
306 tokio::select! {
307 _ = cancel.cancelled() => bail!("Viewer shutdown interrupted recovery"),
308 _ = tokio::time::sleep(Duration::from_millis(100)) => {}
309 }
310 match TcpListener::bind(address).await {
311 Ok(listener) => {
312 drop(listener);
313 return Ok(());
314 }
315 Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => {}
316 Err(error) => return Err(error).context("check listener after stopping the server"),
317 }
318 ensure!(
319 tokio::time::Instant::now() < deadline,
320 "Port {} is still occupied after waiting 10 seconds. No force kill was sent. Use another port or inspect again.",
321 address.port()
322 );
323 }
324}
325
326#[cfg(target_os = "linux")]
327fn signal_process(handle: &std::os::fd::OwnedFd) -> Result<()> {
328 use std::os::fd::AsRawFd;
329 let result = unsafe {
331 libc::syscall(
332 libc::SYS_pidfd_send_signal,
333 handle.as_raw_fd(),
334 libc::SIGTERM,
335 std::ptr::null::<libc::siginfo_t>(),
336 0,
337 )
338 };
339 ensure!(result == 0, "{}", std::io::Error::last_os_error());
340 Ok(())
341}
342
343#[cfg(target_os = "linux")]
344fn open_process_handle(pid: u32) -> Result<std::os::fd::OwnedFd> {
345 use std::os::fd::FromRawFd;
346 let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
348 ensure!(
349 fd >= 0,
350 "Cannot safely open process {pid}: {}",
351 std::io::Error::last_os_error()
352 );
353 Ok(unsafe { std::os::fd::OwnedFd::from_raw_fd(fd as i32) })
355}
356
357#[cfg(target_os = "linux")]
358fn listener_pids(address: SocketAddr) -> Result<Vec<u32>> {
359 use std::collections::BTreeSet;
360 use std::fs;
361 use std::path::Path;
362 let mut inodes = BTreeSet::new();
363 for (path, ipv6) in [("/proc/net/tcp", false), ("/proc/net/tcp6", true)] {
364 let contents = match fs::read_to_string(path) {
365 Ok(contents) => contents,
366 Err(error) if error.kind() == std::io::ErrorKind::NotFound && ipv6 => continue,
367 Err(error) => return Err(error).with_context(|| format!("read {path}")),
368 };
369 for line in contents.lines().skip(1) {
370 let fields = line.split_whitespace().collect::<Vec<_>>();
371 ensure!(fields.len() >= 10, "Invalid listener information in {path}");
372 if fields[3] != "0A" {
373 continue;
374 }
375 let candidate = proc_address(fields[1], ipv6)?;
376 if addresses_overlap(address, candidate) {
377 inodes.insert(format!("socket:[{}]", fields[9]));
378 }
379 }
380 }
381 if inodes.is_empty() {
382 return Ok(Vec::new());
383 }
384 let mut pids = BTreeSet::new();
385 for entry in fs::read_dir("/proc").context("inspect running processes")? {
386 let entry = entry?;
387 let Some(pid) = entry
388 .file_name()
389 .to_str()
390 .and_then(|name| name.parse::<u32>().ok())
391 else {
392 continue;
393 };
394 let descriptors = match fs::read_dir(entry.path().join("fd")) {
395 Ok(entries) => entries,
396 Err(error)
397 if matches!(
398 error.kind(),
399 std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::NotFound
400 ) =>
401 {
402 continue;
403 }
404 Err(error) => return Err(error).context("inspect listener process descriptors"),
405 };
406 for descriptor in descriptors {
407 let descriptor = descriptor?;
408 let target = match fs::read_link(descriptor.path()) {
409 Ok(target) => target,
410 Err(error)
411 if matches!(
412 error.kind(),
413 std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::NotFound
414 ) =>
415 {
416 continue;
417 }
418 Err(error) => return Err(error).context("inspect listener socket ownership"),
419 };
420 if inodes.iter().any(|inode| target == Path::new(inode)) {
421 pids.insert(pid);
422 break;
423 }
424 }
425 }
426 Ok(pids.into_iter().collect())
427}
428
429#[cfg(target_os = "linux")]
430fn proc_address(value: &str, ipv6: bool) -> Result<SocketAddr> {
431 let (host, port) = value.split_once(':').context("invalid listener address")?;
432 let port = u16::from_str_radix(port, 16).context("invalid listener port")?;
433 if ipv6 {
434 ensure!(host.len() == 32 && host.is_ascii(), "invalid IPv6 listener");
435 let mut bytes = [0; 16];
436 for (index, bytes) in bytes.chunks_mut(4).enumerate() {
437 bytes.copy_from_slice(
438 &u32::from_str_radix(&host[index * 8..index * 8 + 8], 16)?.to_ne_bytes(),
439 );
440 }
441 Ok(SocketAddr::new(
442 std::net::Ipv6Addr::from(bytes).into(),
443 port,
444 ))
445 } else {
446 Ok(SocketAddr::new(
447 std::net::Ipv4Addr::from(u32::from_str_radix(host, 16)?.to_ne_bytes()).into(),
448 port,
449 ))
450 }
451}
452
453#[cfg(target_os = "linux")]
454fn addresses_overlap(a: SocketAddr, b: SocketAddr) -> bool {
455 a.port() == b.port()
456 && (a.ip().is_unspecified()
457 || b.ip().is_unspecified()
458 || a.ip() == b.ip()
459 || a.ip().to_canonical() == b.ip().to_canonical())
460}
461
462#[cfg(not(target_os = "linux"))]
463fn listener_pids(address: SocketAddr) -> Result<Vec<u32>> {
464 use crate::targets::{CancellableProcessExecutor, CommandExecutor, CommandSpec};
465 let executor = CancellableProcessExecutor::new(std::sync::Arc::new(
466 std::sync::atomic::AtomicBool::new(false),
467 ))
468 .with_deadline(Duration::from_secs(5));
469 let output = executor
470 .execute(&CommandSpec::new(
471 "lsof",
472 [
473 "-nP".to_owned(),
474 "-a".into(),
475 format!("-iTCP:{}", address.port()),
476 "-sTCP:LISTEN".into(),
477 "-Fp".into(),
478 ],
479 ))
480 .context("Could not inspect this port; lsof must be installed")?;
481 ensure!(
482 output.status == 0 || (output.status == 1 && output.stderr.is_empty()),
483 "Could not inspect this port: {}",
484 String::from_utf8_lossy(&output.stderr)
485 );
486 String::from_utf8(output.stdout)?
487 .lines()
488 .filter_map(|line| line.strip_prefix('p'))
489 .map(|pid| pid.parse().context("invalid listener PID"))
490 .collect()
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use crate::server::{ServerRequests, ViewerSnapshot};
497 use std::collections::BTreeMap;
498 use std::sync::Arc;
499 use tokio::io::{AsyncReadExt, AsyncWriteExt};
500
501 fn options(address: SocketAddr) -> ServerOptions {
502 ServerOptions::new(
503 address,
504 watch::channel(ViewerSnapshot::default()).1,
505 watch::channel(BTreeMap::new()).1,
506 ServerRequests {
507 action_tx: mpsc::channel(1).0,
508 bundle_tx: mpsc::channel(1).0,
509 receipt_tx: mpsc::channel(1).0,
510 preflight_tx: mpsc::channel(1).0,
511 move_preparation_tx: mpsc::channel(1).0,
512 client_state_tx: mpsc::channel(1).0,
513 dictation_tx: mpsc::channel(1).0,
514 },
515 )
516 .unwrap()
517 }
518
519 fn ready(address: SocketAddr) -> WebViewerAccess {
520 WebViewerAccess::Ready {
521 viewer_url: format!("http://{address}"),
522 viewer_code: "123456".into(),
523 qr_login_url: None,
524 fallback_reason: None,
525 }
526 }
527
528 async fn wait_access(
529 control: &ViewerControl,
530 predicate: impl Fn(&WebViewerAccess) -> bool,
531 ) -> WebViewerAccess {
532 let mut updates = control.access.subscribe();
533 tokio::time::timeout(Duration::from_secs(10), async {
534 loop {
535 let access = updates.borrow_and_update().clone();
536 if predicate(&access) {
537 return access;
538 }
539 updates.changed().await.unwrap();
540 }
541 })
542 .await
543 .expect("viewer state did not arrive")
544 }
545
546 async fn http_response(address: SocketAddr) -> String {
547 tokio::time::timeout(Duration::from_secs(3), async {
548 let mut stream = tokio::net::TcpStream::connect(address).await.unwrap();
549 stream
550 .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
551 .await
552 .unwrap();
553 let mut response = String::new();
554 stream.read_to_string(&mut response).await.unwrap();
555 response
556 })
557 .await
558 .expect("viewer did not serve HTTP")
559 }
560
561 fn spawn_viewer(
562 options: ServerOptions,
563 control: Arc<ViewerControl>,
564 ) -> tokio::task::JoinHandle<Result<()>> {
565 tokio::spawn(async move {
566 let ready = ready(options.bind);
567 serve(options, ready, &control, |access| control.publish(access)).await
568 })
569 }
570
571 #[tokio::test]
572 async fn occupied_port_can_recover_on_another_reserved_port_and_serve_http() {
573 let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
574 let address = occupied.local_addr().unwrap();
575 let options = options(address);
576 let cancel = options.shutdown.clone();
577 let control = Arc::new(ViewerControl::new());
578 let task = spawn_viewer(options, control.clone());
579 let failure = wait_access(&control, |access| {
580 matches!(access, WebViewerAccess::Failed { .. })
581 })
582 .await;
583 assert!(
584 matches!(failure, WebViewerAccess::Failed { address: failed, port_conflict: true, .. } if failed == address)
585 );
586 control.recover(WebViewerRecovery::AnotherPort).unwrap();
587 assert!(
588 control.recover(WebViewerRecovery::AnotherPort).is_err(),
589 "concurrent recovery must be rejected"
590 );
591 let access = wait_access(&control, |access| {
592 matches!(access, WebViewerAccess::Ready { .. })
593 })
594 .await;
595 let WebViewerAccess::Ready { viewer_url, .. } = access else {
596 unreachable!()
597 };
598 let url = url::Url::parse(&viewer_url).unwrap();
599 let actual = SocketAddr::new(address.ip(), url.port().unwrap());
600 assert_ne!(actual.port(), address.port());
601 assert_ne!(actual.port(), 0);
602 assert!(http_response(actual).await.starts_with("HTTP/1.1 200"));
603 assert!(
604 TcpListener::bind(address).await.is_err(),
605 "recovery must leave the existing listener alone"
606 );
607 assert!(
608 control.recover(WebViewerRecovery::Retry).is_err(),
609 "a healthy viewer must not be restarted"
610 );
611 cancel.cancel();
612 tokio::time::timeout(Duration::from_secs(3), task)
613 .await
614 .unwrap()
615 .unwrap()
616 .unwrap();
617 }
618
619 #[tokio::test]
620 async fn retry_uses_the_original_port_after_its_owner_releases_it() {
621 let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
622 let address = occupied.local_addr().unwrap();
623 let options = options(address);
624 let cancel = options.shutdown.clone();
625 let control = Arc::new(ViewerControl::new());
626 let task = spawn_viewer(options, control.clone());
627 wait_access(&control, |access| {
628 matches!(access, WebViewerAccess::Failed { .. })
629 })
630 .await;
631 drop(occupied);
632 tokio::time::timeout(Duration::from_secs(3), async {
638 loop {
639 let probe = tokio::net::TcpSocket::new_v4().unwrap();
640 probe.set_reuseaddr(true).unwrap();
641 match probe.bind(address) {
642 Ok(()) => break,
643 Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => {
644 tokio::time::sleep(Duration::from_millis(10)).await;
645 }
646 Err(error) => panic!("cannot check listener release: {error}"),
647 }
648 }
649 })
650 .await
651 .expect("original listener was not released");
652 control.recover(WebViewerRecovery::Retry).unwrap();
653 let access = wait_access(&control, |access| {
654 matches!(
655 access,
656 WebViewerAccess::Ready { .. } | WebViewerAccess::Failed { .. }
657 )
658 })
659 .await;
660 assert!(
661 matches!(&access, WebViewerAccess::Ready { viewer_url, .. } if viewer_url == &format!("http://{address}/")),
662 "retry did not restore the original listener: {access:?}"
663 );
664 assert!(http_response(address).await.starts_with("HTTP/1.1 200"));
665 cancel.cancel();
666 tokio::time::timeout(Duration::from_secs(3), task)
667 .await
668 .unwrap()
669 .unwrap()
670 .unwrap();
671 }
672
673 #[tokio::test]
674 async fn shutdown_does_not_wait_for_a_port_conflict_to_be_resolved() {
675 let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
676 let options = options(occupied.local_addr().unwrap());
677 let cancel = options.shutdown.clone();
678 let control = Arc::new(ViewerControl::new());
679 let task = spawn_viewer(options, control.clone());
680 wait_access(&control, |access| {
681 matches!(access, WebViewerAccess::Failed { .. })
682 })
683 .await;
684 cancel.cancel();
685 tokio::time::timeout(Duration::from_millis(500), task)
686 .await
687 .unwrap()
688 .unwrap()
689 .unwrap();
690 assert!(control.recover(WebViewerRecovery::Retry).is_err());
691 }
692
693 #[test]
694 fn changing_ports_preserves_https_hostname_and_login_credentials() {
695 let ready = WebViewerAccess::Ready {
696 viewer_url: "https://host.tailnet.ts.net:37650/".into(),
697 viewer_code: "123456".into(),
698 qr_login_url: Some("https://host.tailnet.ts.net:37650/auth/login?token=secret".into()),
699 fallback_reason: None,
700 };
701 let changed = ready_at(&ready, 49152).unwrap();
702 assert!(
703 matches!(changed, WebViewerAccess::Ready { viewer_url, viewer_code, qr_login_url: Some(login), .. }
704 if viewer_url == "https://host.tailnet.ts.net:49152/" && viewer_code == "123456" && login == "https://host.tailnet.ts.net:49152/auth/login?token=secret")
705 );
706 let ipv6 = ready_at(&self::ready("[::1]:0".parse().unwrap()), 49152).unwrap();
707 assert!(
708 matches!(ipv6, WebViewerAccess::Ready { viewer_url, .. } if viewer_url == "http://[::1]:49152/")
709 );
710 }
711
712 #[cfg(target_os = "linux")]
713 #[tokio::test]
714 async fn inspection_finds_real_listener_and_refuses_to_stop_the_current_daemon() {
715 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
716 let address = listener.local_addr().unwrap();
717 let pids = listener_pids(address).unwrap();
718 assert!(
719 pids.contains(&std::process::id()),
720 "socket {address} owners: {pids:?}; expected {}",
721 std::process::id()
722 );
723 let processes = tokio::task::spawn_blocking(move || inspect_listener(address))
724 .await
725 .unwrap()
726 .unwrap();
727 let own = processes
728 .into_iter()
729 .find(|process| process.pid == std::process::id())
730 .expect("listener owner must be found");
731 assert!(
732 own.stop_disabled_reason
733 .as_ref()
734 .unwrap()
735 .contains("current daemon")
736 );
737 let error = stop_listener(address, own, CancellationToken::new())
738 .await
739 .unwrap_err();
740 assert!(error.to_string().contains("current daemon"));
741 assert!(TcpListener::bind(address).await.is_err());
742 }
743
744 #[cfg(target_os = "linux")]
745 #[tokio::test]
746 async fn stopping_rejects_a_stale_inspected_identity() {
747 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
748 let address = listener.local_addr().unwrap();
749 let mut own = tokio::task::spawn_blocking(move || inspect_listener(address))
750 .await
751 .unwrap()
752 .unwrap()
753 .into_iter()
754 .find(|process| process.pid == std::process::id())
755 .unwrap();
756 own.started_at = own.started_at.saturating_sub(1);
757 let error = stop_listener(address, own, CancellationToken::new())
758 .await
759 .unwrap_err();
760 assert!(error.to_string().contains("identity changed"));
761 }
762}