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 port_conflict_message(address).await
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
175async fn port_conflict_message(address: SocketAddr) -> String {
179 let taken = format!(
180 "Port {} is already in use, possibly by another Mjolnir instance.",
181 address.port()
182 );
183 let Ok(free) = TcpListener::bind(SocketAddr::new(address.ip(), 0)).await else {
184 return taken;
185 };
186 let Ok(free) = free.local_addr() else {
187 return taken;
188 };
189 format!(
190 "{taken} To use another port, add `bind = \"{free}\"` under `[phone]` in {}.",
191 mj_core::config::config_path().display()
192 )
193}
194
195fn ready_at(ready: &WebViewerAccess, port: u16) -> Result<WebViewerAccess> {
196 let WebViewerAccess::Ready {
197 viewer_url,
198 viewer_code,
199 qr_login_url,
200 fallback_reason,
201 certificate_sha256,
202 } = ready
203 else {
204 bail!("viewer startup is missing its access details");
205 };
206 fn with_port(value: &str, port: u16) -> Result<String> {
207 let mut url = url::Url::parse(value).context("parse viewer URL")?;
208 url.set_port(Some(port))
209 .map_err(|()| anyhow::anyhow!("viewer URL cannot have a port"))?;
210 Ok(url.into())
211 }
212 Ok(WebViewerAccess::Ready {
213 viewer_url: with_port(viewer_url, port)?,
214 viewer_code: viewer_code.clone(),
215 qr_login_url: qr_login_url
216 .as_deref()
217 .map(|url| with_port(url, port))
218 .transpose()?,
219 fallback_reason: fallback_reason.clone(),
220 certificate_sha256: certificate_sha256.clone(),
221 })
222}
223
224pub fn inspect_listener(address: SocketAddr) -> Result<Vec<WebListenerProcess>> {
225 let pids = listener_pids(address)?;
226 let mut system = sysinfo::System::new();
227 let own_pid = sysinfo::Pid::from_u32(std::process::id());
228 let mut requested = pids
229 .iter()
230 .map(|pid| sysinfo::Pid::from_u32(*pid))
231 .collect::<Vec<_>>();
232 if !requested.contains(&own_pid) {
233 requested.push(own_pid);
234 }
235 system.refresh_processes_specifics(
236 sysinfo::ProcessesToUpdate::Some(&requested),
237 true,
238 sysinfo::ProcessRefreshKind::new()
239 .with_user(sysinfo::UpdateKind::Always)
240 .with_cmd(sysinfo::UpdateKind::Always)
241 .with_exe(sysinfo::UpdateKind::Always),
242 );
243 let own_user = system
244 .process(own_pid)
245 .and_then(|process| process.user_id());
246 Ok(pids
247 .into_iter()
248 .filter_map(|pid| {
249 let process = system.process(sysinfo::Pid::from_u32(pid))?;
250 let executable = process.exe().map(std::path::Path::to_path_buf).unwrap_or_default();
251 let is_mj = executable.file_stem().is_some_and(|name| name == "mj")
252 && process.cmd().get(1).is_some_and(|arg| arg == "daemon-run");
253 let reason = if pid == std::process::id() {
254 Some("This is the current daemon; stopping it would disconnect this dashboard.")
255 } else if own_user.is_none() || process.user_id() != own_user {
256 Some("This process belongs to another user or its owner cannot be verified.")
257 } else if !is_mj {
258 Some("This is not an identified Mjolnir server. Stop it in its own application.")
259 } else if !cfg!(target_os = "linux") {
260 Some("Safe stopping is unavailable on this platform. Stop this server in its application or use another port.")
261 } else {
262 None
263 };
264 Some(WebListenerProcess {
265 pid,
266 name: process.name().to_string_lossy().into_owned(),
267 executable,
268 started_at: process.start_time(),
269 stop_disabled_reason: reason.map(str::to_owned),
270 })
271 })
272 .collect())
273}
274
275async fn stop_listener(
276 address: SocketAddr,
277 expected: WebListenerProcess,
278 cancel: CancellationToken,
279) -> Result<()> {
280 let pid = expected.pid;
281 let signal_cancel = cancel.clone();
282 let mut task = tokio::task::spawn_blocking(move || {
283 #[cfg(target_os = "linux")]
285 let process_handle = open_process_handle(pid)?;
286 let current = inspect_listener(address)?
287 .into_iter()
288 .find(|process| process.pid == pid)
289 .context("That process no longer owns this listener. Inspect the port again.")?;
290 ensure!(
291 current == expected,
292 "The listener's identity changed. Inspect the port again."
293 );
294 ensure!(
295 current.stop_disabled_reason.is_none(),
296 "{}",
297 current.stop_disabled_reason.unwrap_or_default()
298 );
299 ensure!(
300 !signal_cancel.is_cancelled(),
301 "Viewer shutdown cancelled the stop request"
302 );
303 #[cfg(target_os = "linux")]
304 {
305 signal_process(&process_handle)
306 }
307 #[cfg(not(target_os = "linux"))]
308 {
309 bail!(
310 "Safe process termination is unavailable on this platform. Use another port or stop the identified server in its application."
311 )
312 }
313 });
314 tokio::select! {
315 result = &mut task => result.context("listener stop task failed")??,
316 _ = cancel.cancelled() => {
317 match task.await {
319 Ok(Ok(())) => {},
320 Ok(Err(error)) => tracing::warn!(%error, "listener stop failed during shutdown"),
321 Err(error) => tracing::warn!(%error, "listener stop task failed during shutdown"),
322 }
323 bail!("Viewer shutdown interrupted recovery");
324 }
325 }
326 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
327 loop {
328 tokio::select! {
329 _ = cancel.cancelled() => bail!("Viewer shutdown interrupted recovery"),
330 _ = tokio::time::sleep(Duration::from_millis(100)) => {}
331 }
332 match TcpListener::bind(address).await {
333 Ok(listener) => {
334 drop(listener);
335 return Ok(());
336 }
337 Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => {}
338 Err(error) => return Err(error).context("check listener after stopping the server"),
339 }
340 ensure!(
341 tokio::time::Instant::now() < deadline,
342 "Port {} is still occupied after waiting 10 seconds. No force kill was sent. Use another port or inspect again.",
343 address.port()
344 );
345 }
346}
347
348#[cfg(target_os = "linux")]
349fn signal_process(handle: &std::os::fd::OwnedFd) -> Result<()> {
350 use std::os::fd::AsRawFd;
351 let result = unsafe {
353 libc::syscall(
354 libc::SYS_pidfd_send_signal,
355 handle.as_raw_fd(),
356 libc::SIGTERM,
357 std::ptr::null::<libc::siginfo_t>(),
358 0,
359 )
360 };
361 ensure!(result == 0, "{}", std::io::Error::last_os_error());
362 Ok(())
363}
364
365#[cfg(target_os = "linux")]
366fn open_process_handle(pid: u32) -> Result<std::os::fd::OwnedFd> {
367 use std::os::fd::FromRawFd;
368 let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
370 ensure!(
371 fd >= 0,
372 "Cannot safely open process {pid}: {}",
373 std::io::Error::last_os_error()
374 );
375 Ok(unsafe { std::os::fd::OwnedFd::from_raw_fd(fd as i32) })
377}
378
379#[cfg(target_os = "linux")]
380fn listener_pids(address: SocketAddr) -> Result<Vec<u32>> {
381 use std::collections::BTreeSet;
382 use std::fs;
383 use std::path::Path;
384 let mut inodes = BTreeSet::new();
385 for (path, ipv6) in [("/proc/net/tcp", false), ("/proc/net/tcp6", true)] {
386 let contents = match fs::read_to_string(path) {
387 Ok(contents) => contents,
388 Err(error) if error.kind() == std::io::ErrorKind::NotFound && ipv6 => continue,
389 Err(error) => return Err(error).with_context(|| format!("read {path}")),
390 };
391 for line in contents.lines().skip(1) {
392 let fields = line.split_whitespace().collect::<Vec<_>>();
393 ensure!(fields.len() >= 10, "Invalid listener information in {path}");
394 if fields[3] != "0A" {
395 continue;
396 }
397 let candidate = proc_address(fields[1], ipv6)?;
398 if addresses_overlap(address, candidate) {
399 inodes.insert(format!("socket:[{}]", fields[9]));
400 }
401 }
402 }
403 if inodes.is_empty() {
404 return Ok(Vec::new());
405 }
406 let mut pids = BTreeSet::new();
407 for entry in fs::read_dir("/proc").context("inspect running processes")? {
408 let entry = entry?;
409 let Some(pid) = entry
410 .file_name()
411 .to_str()
412 .and_then(|name| name.parse::<u32>().ok())
413 else {
414 continue;
415 };
416 let descriptors = match fs::read_dir(entry.path().join("fd")) {
417 Ok(entries) => entries,
418 Err(error)
419 if matches!(
420 error.kind(),
421 std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::NotFound
422 ) =>
423 {
424 continue;
425 }
426 Err(error) => return Err(error).context("inspect listener process descriptors"),
427 };
428 for descriptor in descriptors {
429 let descriptor = descriptor?;
430 let target = match fs::read_link(descriptor.path()) {
431 Ok(target) => target,
432 Err(error)
433 if matches!(
434 error.kind(),
435 std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::NotFound
436 ) =>
437 {
438 continue;
439 }
440 Err(error) => return Err(error).context("inspect listener socket ownership"),
441 };
442 if inodes.iter().any(|inode| target == Path::new(inode)) {
443 pids.insert(pid);
444 break;
445 }
446 }
447 }
448 Ok(pids.into_iter().collect())
449}
450
451#[cfg(target_os = "linux")]
452fn proc_address(value: &str, ipv6: bool) -> Result<SocketAddr> {
453 let (host, port) = value.split_once(':').context("invalid listener address")?;
454 let port = u16::from_str_radix(port, 16).context("invalid listener port")?;
455 if ipv6 {
456 ensure!(host.len() == 32 && host.is_ascii(), "invalid IPv6 listener");
457 let mut bytes = [0; 16];
458 for (index, bytes) in bytes.chunks_mut(4).enumerate() {
459 bytes.copy_from_slice(
460 &u32::from_str_radix(&host[index * 8..index * 8 + 8], 16)?.to_ne_bytes(),
461 );
462 }
463 Ok(SocketAddr::new(
464 std::net::Ipv6Addr::from(bytes).into(),
465 port,
466 ))
467 } else {
468 Ok(SocketAddr::new(
469 std::net::Ipv4Addr::from(u32::from_str_radix(host, 16)?.to_ne_bytes()).into(),
470 port,
471 ))
472 }
473}
474
475#[cfg(target_os = "linux")]
476fn addresses_overlap(a: SocketAddr, b: SocketAddr) -> bool {
477 a.port() == b.port()
478 && (a.ip().is_unspecified()
479 || b.ip().is_unspecified()
480 || a.ip() == b.ip()
481 || a.ip().to_canonical() == b.ip().to_canonical())
482}
483
484#[cfg(not(target_os = "linux"))]
485fn listener_pids(address: SocketAddr) -> Result<Vec<u32>> {
486 use crate::targets::{CancellableProcessExecutor, CommandExecutor, CommandSpec};
487 let executor = CancellableProcessExecutor::new(std::sync::Arc::new(
488 std::sync::atomic::AtomicBool::new(false),
489 ))
490 .with_deadline(Duration::from_secs(5));
491 let output = executor
492 .execute(&CommandSpec::new(
493 "lsof",
494 [
495 "-nP".to_owned(),
496 "-a".into(),
497 format!("-iTCP:{}", address.port()),
498 "-sTCP:LISTEN".into(),
499 "-Fp".into(),
500 ],
501 ))
502 .context("Could not inspect this port; lsof must be installed")?;
503 ensure!(
504 output.status == 0 || (output.status == 1 && output.stderr.is_empty()),
505 "Could not inspect this port: {}",
506 String::from_utf8_lossy(&output.stderr)
507 );
508 String::from_utf8(output.stdout)?
509 .lines()
510 .filter_map(|line| line.strip_prefix('p'))
511 .map(|pid| pid.parse().context("invalid listener PID"))
512 .collect()
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use crate::server::{ServerRequests, ViewerSnapshot};
519 use std::collections::BTreeMap;
520 use std::sync::Arc;
521 use tokio::io::{AsyncReadExt, AsyncWriteExt};
522
523 fn options(address: SocketAddr) -> ServerOptions {
524 ServerOptions::new(
525 address,
526 watch::channel(ViewerSnapshot::default()).1,
527 watch::channel(BTreeMap::new()).1,
528 ServerRequests {
529 action_tx: mpsc::channel(1).0,
530 bundle_tx: mpsc::channel(1).0,
531 receipt_tx: mpsc::channel(1).0,
532 preflight_tx: mpsc::channel(1).0,
533 move_preparation_tx: mpsc::channel(1).0,
534 client_state_tx: mpsc::channel(1).0,
535 dictation_tx: mpsc::channel(1).0,
536 },
537 )
538 .unwrap()
539 }
540
541 fn ready(address: SocketAddr) -> WebViewerAccess {
542 WebViewerAccess::Ready {
543 viewer_url: format!("http://{address}"),
544 viewer_code: "123456".into(),
545 qr_login_url: None,
546 fallback_reason: None,
547 certificate_sha256: None,
548 }
549 }
550
551 async fn wait_access(
552 control: &ViewerControl,
553 predicate: impl Fn(&WebViewerAccess) -> bool,
554 ) -> WebViewerAccess {
555 let mut updates = control.access.subscribe();
556 tokio::time::timeout(Duration::from_secs(10), async {
557 loop {
558 let access = updates.borrow_and_update().clone();
559 if predicate(&access) {
560 return access;
561 }
562 updates.changed().await.unwrap();
563 }
564 })
565 .await
566 .expect("viewer state did not arrive")
567 }
568
569 async fn http_response(address: SocketAddr) -> String {
570 tokio::time::timeout(Duration::from_secs(3), async {
571 let mut stream = tokio::net::TcpStream::connect(address).await.unwrap();
572 stream
573 .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
574 .await
575 .unwrap();
576 let mut response = String::new();
577 stream.read_to_string(&mut response).await.unwrap();
578 response
579 })
580 .await
581 .expect("viewer did not serve HTTP")
582 }
583
584 fn spawn_viewer(
585 options: ServerOptions,
586 control: Arc<ViewerControl>,
587 ) -> tokio::task::JoinHandle<Result<()>> {
588 tokio::spawn(async move {
589 let ready = ready(options.bind);
590 serve(options, ready, &control, |access| control.publish(access)).await
591 })
592 }
593
594 async fn api_response(address: SocketAddr, deadline: Duration) -> String {
598 tokio::time::timeout(deadline, async {
599 let mut stream = tokio::net::TcpStream::connect(address).await.unwrap();
600 stream
601 .write_all(
602 b"GET /api/v1/sessions HTTP/1.1\r\nHost: localhost\r\n\
603 Authorization: Bearer test-api-token\r\nConnection: close\r\n\r\n",
604 )
605 .await
606 .unwrap();
607 let mut response = String::new();
608 stream.read_to_string(&mut response).await.unwrap();
609 response
610 })
611 .await
612 .expect("the API did not answer while the control loop was busy")
613 }
614
615 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
623 async fn the_api_answers_while_the_control_loop_takes_a_long_turn() {
624 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
625 let address = listener.local_addr().unwrap();
626 let mut options = options(address);
627 options.set_api_token("test-api-token".to_owned());
628 let cancel = options.shutdown.clone();
629 let mut server = crate::server_runtime::ViewerServer::spawn(
630 crate::server::run_server_on_listener(options, listener),
631 );
632
633 let (busy_tx, busy_rx) = tokio::sync::oneshot::channel();
634 let control = async move {
636 let _ = busy_tx.send(());
637 std::thread::sleep(Duration::from_secs(3));
638 Ok::<(), anyhow::Error>(())
639 };
640 let request = tokio::spawn(async move {
641 busy_rx.await.unwrap();
642 api_response(address, Duration::from_secs(1)).await
643 });
644
645 tokio::select! {
648 result = server.stopped() => panic!("the viewer stopped early: {result:?}"),
649 result = control => result.unwrap(),
650 }
651 let response = request.await.unwrap();
652 assert!(
653 response.starts_with("HTTP/1.1 200"),
654 "the sessions list must answer during a long control-loop turn: {response}"
655 );
656 assert!(response.contains("\"sessions\""), "{response}");
657 cancel.cancel();
658 }
659
660 #[tokio::test]
661 async fn occupied_port_can_recover_on_another_reserved_port_and_serve_http() {
662 let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
663 let address = occupied.local_addr().unwrap();
664 let options = options(address);
665 let cancel = options.shutdown.clone();
666 let control = Arc::new(ViewerControl::new());
667 let task = spawn_viewer(options, control.clone());
668 let failure = wait_access(&control, |access| {
669 matches!(access, WebViewerAccess::Failed { .. })
670 })
671 .await;
672 assert!(
673 matches!(&failure, WebViewerAccess::Failed { address: failed, port_conflict: true, .. } if *failed == address)
674 );
675 let WebViewerAccess::Failed { message, .. } = &failure else {
676 unreachable!()
677 };
678 assert!(
679 message.contains("under `[phone]`") && message.contains("bind = \"127.0.0.1:"),
680 "a port conflict must name the config line that resolves it: {message}"
681 );
682 control.recover(WebViewerRecovery::AnotherPort).unwrap();
683 assert!(
684 control.recover(WebViewerRecovery::AnotherPort).is_err(),
685 "concurrent recovery must be rejected"
686 );
687 let access = wait_access(&control, |access| {
688 matches!(access, WebViewerAccess::Ready { .. })
689 })
690 .await;
691 let WebViewerAccess::Ready { viewer_url, .. } = access else {
692 unreachable!()
693 };
694 let url = url::Url::parse(&viewer_url).unwrap();
695 let actual = SocketAddr::new(address.ip(), url.port().unwrap());
696 assert_ne!(actual.port(), address.port());
697 assert_ne!(actual.port(), 0);
698 assert!(http_response(actual).await.starts_with("HTTP/1.1 200"));
699 assert!(
700 TcpListener::bind(address).await.is_err(),
701 "recovery must leave the existing listener alone"
702 );
703 assert!(
704 control.recover(WebViewerRecovery::Retry).is_err(),
705 "a healthy viewer must not be restarted"
706 );
707 cancel.cancel();
708 tokio::time::timeout(Duration::from_secs(3), task)
709 .await
710 .unwrap()
711 .unwrap()
712 .unwrap();
713 }
714
715 #[tokio::test]
716 async fn retry_uses_the_original_port_after_its_owner_releases_it() {
717 let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
718 let address = occupied.local_addr().unwrap();
719 let options = options(address);
720 let cancel = options.shutdown.clone();
721 let control = Arc::new(ViewerControl::new());
722 let task = spawn_viewer(options, control.clone());
723 wait_access(&control, |access| {
724 matches!(access, WebViewerAccess::Failed { .. })
725 })
726 .await;
727 drop(occupied);
728 tokio::time::timeout(Duration::from_secs(3), async {
734 loop {
735 let probe = tokio::net::TcpSocket::new_v4().unwrap();
736 probe.set_reuseaddr(true).unwrap();
737 match probe.bind(address) {
738 Ok(()) => break,
739 Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => {
740 tokio::time::sleep(Duration::from_millis(10)).await;
741 }
742 Err(error) => panic!("cannot check listener release: {error}"),
743 }
744 }
745 })
746 .await
747 .expect("original listener was not released");
748 control.recover(WebViewerRecovery::Retry).unwrap();
749 let access = wait_access(&control, |access| {
750 matches!(
751 access,
752 WebViewerAccess::Ready { .. } | WebViewerAccess::Failed { .. }
753 )
754 })
755 .await;
756 assert!(
757 matches!(&access, WebViewerAccess::Ready { viewer_url, .. } if viewer_url == &format!("http://{address}/")),
758 "retry did not restore the original listener: {access:?}"
759 );
760 assert!(http_response(address).await.starts_with("HTTP/1.1 200"));
761 cancel.cancel();
762 tokio::time::timeout(Duration::from_secs(3), task)
763 .await
764 .unwrap()
765 .unwrap()
766 .unwrap();
767 }
768
769 #[tokio::test]
770 async fn shutdown_does_not_wait_for_a_port_conflict_to_be_resolved() {
771 let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
772 let options = options(occupied.local_addr().unwrap());
773 let cancel = options.shutdown.clone();
774 let control = Arc::new(ViewerControl::new());
775 let task = spawn_viewer(options, control.clone());
776 wait_access(&control, |access| {
777 matches!(access, WebViewerAccess::Failed { .. })
778 })
779 .await;
780 cancel.cancel();
781 tokio::time::timeout(Duration::from_millis(500), task)
782 .await
783 .unwrap()
784 .unwrap()
785 .unwrap();
786 assert!(control.recover(WebViewerRecovery::Retry).is_err());
787 }
788
789 #[test]
790 fn changing_ports_preserves_https_hostname_and_login_credentials() {
791 let ready = WebViewerAccess::Ready {
792 viewer_url: "https://host.tailnet.ts.net:37650/".into(),
793 viewer_code: "123456".into(),
794 qr_login_url: Some("https://host.tailnet.ts.net:37650/auth/login?token=secret".into()),
795 fallback_reason: None,
796 certificate_sha256: Some("pinned".into()),
797 };
798 let changed = ready_at(&ready, 49152).unwrap();
799 assert!(
800 matches!(changed, WebViewerAccess::Ready { viewer_url, viewer_code, qr_login_url: Some(login), certificate_sha256: Some(pin), .. }
801 if pin == "pinned" && viewer_url == "https://host.tailnet.ts.net:49152/" && viewer_code == "123456" && login == "https://host.tailnet.ts.net:49152/auth/login?token=secret")
802 );
803 let ipv6 = ready_at(&self::ready("[::1]:0".parse().unwrap()), 49152).unwrap();
804 assert!(
805 matches!(ipv6, WebViewerAccess::Ready { viewer_url, .. } if viewer_url == "http://[::1]:49152/")
806 );
807 }
808
809 #[cfg(target_os = "linux")]
810 #[tokio::test]
811 async fn inspection_finds_real_listener_and_refuses_to_stop_the_current_daemon() {
812 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
813 let address = listener.local_addr().unwrap();
814 let pids = listener_pids(address).unwrap();
815 assert!(
816 pids.contains(&std::process::id()),
817 "socket {address} owners: {pids:?}; expected {}",
818 std::process::id()
819 );
820 let processes = tokio::task::spawn_blocking(move || inspect_listener(address))
821 .await
822 .unwrap()
823 .unwrap();
824 let own = processes
825 .into_iter()
826 .find(|process| process.pid == std::process::id())
827 .expect("listener owner must be found");
828 assert!(
829 own.stop_disabled_reason
830 .as_ref()
831 .unwrap()
832 .contains("current daemon")
833 );
834 let error = stop_listener(address, own, CancellationToken::new())
835 .await
836 .unwrap_err();
837 assert!(error.to_string().contains("current daemon"));
838 assert!(TcpListener::bind(address).await.is_err());
839 }
840
841 #[cfg(target_os = "linux")]
842 #[tokio::test]
843 async fn stopping_rejects_a_stale_inspected_identity() {
844 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
845 let address = listener.local_addr().unwrap();
846 let mut own = tokio::task::spawn_blocking(move || inspect_listener(address))
847 .await
848 .unwrap()
849 .unwrap()
850 .into_iter()
851 .find(|process| process.pid == std::process::id())
852 .unwrap();
853 own.started_at = own.started_at.saturating_sub(1);
854 let error = stop_listener(address, own, CancellationToken::new())
855 .await
856 .unwrap_err();
857 assert!(error.to_string().contains("identity changed"));
858 }
859}