Skip to main content

mj_controller/
web_viewer.rs

1//! Listener recovery lives beside the daemon, independently of session control.
2
3use 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        // Serialize checking and queueing so two attached clients cannot start two recoveries.
44        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
76/// Retain the controller and authentication while the listener waits for recovery.
77pub 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
175/// Explain a taken viewer port and give the exact `[phone] bind` line that
176/// moves this instance off it. The suggested port is one the OS reports free
177/// right now; writing it into the config keeps it stable across restarts.
178async 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        // Acquire a stable process handle before re-inspecting, so PID reuse cannot redirect a signal.
284        #[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            // Inspection is bounded; observe its result even when shutdown wins.
318            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    // SAFETY: the owned pidfd names the inspected process, never a recycled PID.
352    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    // SAFETY: pidfd_open has no pointer arguments; success yields an owned descriptor.
369    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    // SAFETY: the new descriptor is owned by this call.
376    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    /// Read one API route over a fresh connection, the way a CLI client and
595    /// `probe_api` do. A stalled listener shows up here as a timeout, because
596    /// a new connection has to be accepted before anything can answer.
597    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    /// The phone server runs its control loop beside this listener. The loop
616    /// takes long turns — it builds snapshots, waits on the daemon's locks,
617    /// and follows every session — and while it does, a cheap read must still
618    /// be answered. `run_server` therefore puts the listener on its own task
619    /// through `ViewerServer` instead of polling it in the loop's `select!`,
620    /// which is what left `GET /api/v1/sessions` unanswered past the client's
621    /// ten-second timeout during a remote provisioning run (issue 1061).
622    #[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        // A turn that occupies its task for far longer than the client waits.
635        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        // The same composition `run_server` uses: whatever the control loop is
646        // doing, the server keeps its own schedule.
647        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        // Concurrent process-spawning tests can inherit the listening socket
729        // between fork and exec. Dropping our descriptor alone does not prove
730        // the OS has released it. Wait for that precondition before Retry.
731        // Bind without listening so the probe cannot become another inherited
732        // listener; SO_REUSEADDR matches the server's bind behavior.
733        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}