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::sync::Arc;
520    use tokio::io::{AsyncReadExt, AsyncWriteExt};
521
522    fn options(address: SocketAddr) -> ServerOptions {
523        ServerOptions::new(
524            address,
525            watch::channel(ViewerSnapshot::default()).1,
526            watch::channel(mj_core::snapshot_map::SnapshotMap::new()).1,
527            ServerRequests {
528                action_tx: mpsc::channel(1).0,
529                bundle_tx: mpsc::channel(1).0,
530                receipt_tx: mpsc::channel(1).0,
531                preflight_tx: mpsc::channel(1).0,
532                move_preparation_tx: mpsc::channel(1).0,
533                client_state_tx: mpsc::channel(1).0,
534            },
535        )
536        .unwrap()
537    }
538
539    fn ready(address: SocketAddr) -> WebViewerAccess {
540        WebViewerAccess::Ready {
541            viewer_url: format!("http://{address}"),
542            viewer_code: "123456".into(),
543            qr_login_url: None,
544            fallback_reason: None,
545            certificate_sha256: None,
546        }
547    }
548
549    async fn wait_access(
550        control: &ViewerControl,
551        predicate: impl Fn(&WebViewerAccess) -> bool,
552    ) -> WebViewerAccess {
553        let mut updates = control.access.subscribe();
554        tokio::time::timeout(Duration::from_secs(10), async {
555            loop {
556                let access = updates.borrow_and_update().clone();
557                if predicate(&access) {
558                    return access;
559                }
560                updates.changed().await.unwrap();
561            }
562        })
563        .await
564        .expect("viewer state did not arrive")
565    }
566
567    async fn http_response(address: SocketAddr) -> String {
568        tokio::time::timeout(Duration::from_secs(3), async {
569            let mut stream = tokio::net::TcpStream::connect(address).await.unwrap();
570            stream
571                .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
572                .await
573                .unwrap();
574            let mut response = String::new();
575            stream.read_to_string(&mut response).await.unwrap();
576            response
577        })
578        .await
579        .expect("viewer did not serve HTTP")
580    }
581
582    fn spawn_viewer(
583        options: ServerOptions,
584        control: Arc<ViewerControl>,
585    ) -> tokio::task::JoinHandle<Result<()>> {
586        tokio::spawn(async move {
587            let ready = ready(options.bind);
588            serve(options, ready, &control, |access| control.publish(access)).await
589        })
590    }
591
592    /// Read one API route over a fresh connection, the way a CLI client and
593    /// `probe_api` do. A stalled listener shows up here as a timeout, because
594    /// a new connection has to be accepted before anything can answer.
595    async fn api_response(address: SocketAddr, deadline: Duration) -> String {
596        tokio::time::timeout(deadline, async {
597            let mut stream = tokio::net::TcpStream::connect(address).await.unwrap();
598            stream
599                .write_all(
600                    b"GET /api/v1/sessions HTTP/1.1\r\nHost: localhost\r\n\
601                      Authorization: Bearer test-api-token\r\nConnection: close\r\n\r\n",
602                )
603                .await
604                .unwrap();
605            let mut response = String::new();
606            stream.read_to_string(&mut response).await.unwrap();
607            response
608        })
609        .await
610        .expect("the API did not answer while the control loop was busy")
611    }
612
613    /// The phone server runs its control loop beside this listener. The loop
614    /// takes long turns — it builds snapshots, waits on the daemon's locks,
615    /// and follows every session — and while it does, a cheap read must still
616    /// be answered. `run_server` therefore puts the listener on its own task
617    /// through `ViewerServer` instead of polling it in the loop's `select!`,
618    /// which is what left `GET /api/v1/sessions` unanswered past the client's
619    /// ten-second timeout during a remote provisioning run (issue 1061).
620    // Hard-won: #1061: a sessions API request timed out during remote provisioning.
621    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
622    async fn the_api_answers_while_the_control_loop_takes_a_long_turn() {
623        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
624        let address = listener.local_addr().unwrap();
625        let mut options = options(address);
626        options.set_api_token("test-api-token".to_owned());
627        let cancel = options.shutdown.clone();
628        let mut server = crate::server_runtime::ViewerServer::spawn(
629            crate::server::run_server_on_listener(options, listener),
630        );
631
632        let (busy_tx, busy_rx) = tokio::sync::oneshot::channel();
633        // A turn that occupies its task for far longer than the client waits.
634        let control = async move {
635            let _ = busy_tx.send(());
636            std::thread::sleep(Duration::from_secs(3));
637            Ok::<(), anyhow::Error>(())
638        };
639        let request = tokio::spawn(async move {
640            busy_rx.await.unwrap();
641            api_response(address, Duration::from_secs(1)).await
642        });
643
644        // The same composition `run_server` uses: whatever the control loop is
645        // doing, the server keeps its own schedule.
646        tokio::select! {
647            result = server.stopped() => panic!("the viewer stopped early: {result:?}"),
648            result = control => result.unwrap(),
649        }
650        let response = request.await.unwrap();
651        assert!(
652            response.starts_with("HTTP/1.1 200"),
653            "the sessions list must answer during a long control-loop turn: {response}"
654        );
655        assert!(response.contains("\"sessions\""), "{response}");
656        cancel.cancel();
657    }
658
659    #[tokio::test]
660    async fn occupied_port_can_recover_on_another_reserved_port_and_serve_http() {
661        let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
662        let address = occupied.local_addr().unwrap();
663        let options = options(address);
664        let cancel = options.shutdown.clone();
665        let control = Arc::new(ViewerControl::new());
666        let task = spawn_viewer(options, control.clone());
667        let failure = wait_access(&control, |access| {
668            matches!(access, WebViewerAccess::Failed { .. })
669        })
670        .await;
671        assert!(
672            matches!(&failure, WebViewerAccess::Failed { address: failed, port_conflict: true, .. } if *failed == address)
673        );
674        let WebViewerAccess::Failed { message, .. } = &failure else {
675            unreachable!()
676        };
677        assert!(
678            message.contains("under `[phone]`") && message.contains("bind = \"127.0.0.1:"),
679            "a port conflict must name the config line that resolves it: {message}"
680        );
681        control.recover(WebViewerRecovery::AnotherPort).unwrap();
682        assert!(
683            control.recover(WebViewerRecovery::AnotherPort).is_err(),
684            "concurrent recovery must be rejected"
685        );
686        let access = wait_access(&control, |access| {
687            matches!(access, WebViewerAccess::Ready { .. })
688        })
689        .await;
690        let WebViewerAccess::Ready { viewer_url, .. } = access else {
691            unreachable!()
692        };
693        let url = url::Url::parse(&viewer_url).unwrap();
694        let actual = SocketAddr::new(address.ip(), url.port().unwrap());
695        assert_ne!(actual.port(), address.port());
696        assert_ne!(actual.port(), 0);
697        assert!(http_response(actual).await.starts_with("HTTP/1.1 200"));
698        assert!(
699            TcpListener::bind(address).await.is_err(),
700            "recovery must leave the existing listener alone"
701        );
702        assert!(
703            control.recover(WebViewerRecovery::Retry).is_err(),
704            "a healthy viewer must not be restarted"
705        );
706        cancel.cancel();
707        tokio::time::timeout(Duration::from_secs(3), task)
708            .await
709            .unwrap()
710            .unwrap()
711            .unwrap();
712    }
713
714    #[tokio::test]
715    async fn retry_uses_the_original_port_after_its_owner_releases_it() {
716        let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
717        let address = occupied.local_addr().unwrap();
718        let options = options(address);
719        let cancel = options.shutdown.clone();
720        let control = Arc::new(ViewerControl::new());
721        let task = spawn_viewer(options, control.clone());
722        wait_access(&control, |access| {
723            matches!(access, WebViewerAccess::Failed { .. })
724        })
725        .await;
726        drop(occupied);
727        // Concurrent process-spawning tests can inherit the listening socket
728        // between fork and exec. Dropping our descriptor alone does not prove
729        // the OS has released it. Wait for that precondition before Retry.
730        // Bind without listening so the probe cannot become another inherited
731        // listener; SO_REUSEADDR matches the server's bind behavior.
732        tokio::time::timeout(Duration::from_secs(3), async {
733            loop {
734                let probe = tokio::net::TcpSocket::new_v4().unwrap();
735                probe.set_reuseaddr(true).unwrap();
736                match probe.bind(address) {
737                    Ok(()) => break,
738                    Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => {
739                        tokio::time::sleep(Duration::from_millis(10)).await;
740                    }
741                    Err(error) => panic!("cannot check listener release: {error}"),
742                }
743            }
744        })
745        .await
746        .expect("original listener was not released");
747        control.recover(WebViewerRecovery::Retry).unwrap();
748        let access = wait_access(&control, |access| {
749            matches!(
750                access,
751                WebViewerAccess::Ready { .. } | WebViewerAccess::Failed { .. }
752            )
753        })
754        .await;
755        assert!(
756            matches!(&access, WebViewerAccess::Ready { viewer_url, .. } if viewer_url == &format!("http://{address}/")),
757            "retry did not restore the original listener: {access:?}"
758        );
759        assert!(http_response(address).await.starts_with("HTTP/1.1 200"));
760        cancel.cancel();
761        tokio::time::timeout(Duration::from_secs(3), task)
762            .await
763            .unwrap()
764            .unwrap()
765            .unwrap();
766    }
767
768    #[tokio::test]
769    async fn shutdown_does_not_wait_for_a_port_conflict_to_be_resolved() {
770        let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
771        let options = options(occupied.local_addr().unwrap());
772        let cancel = options.shutdown.clone();
773        let control = Arc::new(ViewerControl::new());
774        let task = spawn_viewer(options, control.clone());
775        wait_access(&control, |access| {
776            matches!(access, WebViewerAccess::Failed { .. })
777        })
778        .await;
779        cancel.cancel();
780        tokio::time::timeout(Duration::from_millis(500), task)
781            .await
782            .unwrap()
783            .unwrap()
784            .unwrap();
785        assert!(control.recover(WebViewerRecovery::Retry).is_err());
786    }
787
788    #[test]
789    fn changing_ports_preserves_https_hostname_and_login_credentials() {
790        let ready = WebViewerAccess::Ready {
791            viewer_url: "https://host.tailnet.ts.net:37650/".into(),
792            viewer_code: "123456".into(),
793            qr_login_url: Some("https://host.tailnet.ts.net:37650/auth/login?token=secret".into()),
794            fallback_reason: None,
795            certificate_sha256: Some("pinned".into()),
796        };
797        let changed = ready_at(&ready, 49152).unwrap();
798        assert!(
799            matches!(changed, WebViewerAccess::Ready { viewer_url, viewer_code, qr_login_url: Some(login), certificate_sha256: Some(pin), .. }
800            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")
801        );
802        let ipv6 = ready_at(&self::ready("[::1]:0".parse().unwrap()), 49152).unwrap();
803        assert!(
804            matches!(ipv6, WebViewerAccess::Ready { viewer_url, .. } if viewer_url == "http://[::1]:49152/")
805        );
806    }
807
808    #[cfg(target_os = "linux")]
809    #[tokio::test]
810    async fn inspection_finds_real_listener_and_refuses_to_stop_the_current_daemon() {
811        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
812        let address = listener.local_addr().unwrap();
813        let pids = listener_pids(address).unwrap();
814        assert!(
815            pids.contains(&std::process::id()),
816            "socket {address} owners: {pids:?}; expected {}",
817            std::process::id()
818        );
819        let processes = tokio::task::spawn_blocking(move || inspect_listener(address))
820            .await
821            .unwrap()
822            .unwrap();
823        let own = processes
824            .into_iter()
825            .find(|process| process.pid == std::process::id())
826            .expect("listener owner must be found");
827        assert!(
828            own.stop_disabled_reason
829                .as_ref()
830                .unwrap()
831                .contains("current daemon")
832        );
833        let error = stop_listener(address, own, CancellationToken::new())
834            .await
835            .unwrap_err();
836        assert!(error.to_string().contains("current daemon"));
837        assert!(TcpListener::bind(address).await.is_err());
838    }
839
840    #[cfg(target_os = "linux")]
841    #[tokio::test]
842    async fn stopping_rejects_a_stale_inspected_identity() {
843        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
844        let address = listener.local_addr().unwrap();
845        let mut own = tokio::task::spawn_blocking(move || inspect_listener(address))
846            .await
847            .unwrap()
848            .unwrap()
849            .into_iter()
850            .find(|process| process.pid == std::process::id())
851            .unwrap();
852        own.started_at = own.started_at.saturating_sub(1);
853        let error = stop_listener(address, own, CancellationToken::new())
854            .await
855            .unwrap_err();
856        assert!(error.to_string().contains("identity changed"));
857    }
858}