master
John Lauer v2.0.16: Module 2.4.0: the panel gutter check now measures against the containing block 92284b3 29d ago
//! `adom-parts-search serve [--print-url]`: the Dock Bar contract.
//!
//! 1. SELF-BACKGROUNDS: spawns the server detached, waits for it to
//!    answer, prints the DIRECT url (http://127.0.0.1:<port>/), exits 0.
//! 2. IDEMPOTENT + VERSION-AWARE: an instance already up with OUR version
//!    is reused (print its url, exit 0). An instance of another version is
//!    stopped (matched by the pid it published AND its exact argv) and
//!    replaced, so a reinstall never leaves users pinned to a stale server.
//! 3. WEBVIEW OWNERSHIP: when the dock set ADOM_DOCK_WEBVIEW, the tab it
//!    pre-opened is navigated to our url via `adom-cli hydrogen webview
//!    open-or-refresh`; skipped when unset (a hand-run serve).

use serde_json::{json, Value};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

pub const VERSION: &str = env!("CARGO_PKG_VERSION");

pub fn state_path() -> std::path::PathBuf { crate::settings::adom_dir().join("parts-search-server.json") }

pub fn direct_url(port: u16) -> String { format!("http://127.0.0.1:{}/", port) }

/// Written by the running server so serve / show can find and version it.
pub fn publish_state(port: u16) {
    let _ = std::fs::create_dir_all(crate::settings::adom_dir());
    let v = json!({
        "port": port, "pid": std::process::id(), "version": VERSION, "url": direct_url(port),
        "argv": expected_argv(port),
        "dockWebview": std::env::var("ADOM_DOCK_WEBVIEW").ok(),
        "dockApp": std::env::var("ADOM_DOCK_APP").ok(),
    });
    let _ = std::fs::write(state_path(), serde_json::to_string_pretty(&v).unwrap_or_default());
}

fn expected_argv(port: u16) -> Vec<String> {
    let exe = std::env::current_exe().map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| "adom-parts-search".into());
    vec![exe, "app".into(), "--port".into(), port.to_string(), "--no-tab".into()]
}

/// GET /api/status on the port; Some(version) when something of ours answers.
pub fn instance_version(port: u16) -> Option<String> {
    let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(1200)).build();
    let r = agent.get(&format!("http://127.0.0.1:{}/api/status", port)).call().ok()?;
    let v: Value = r.into_json().ok()?;
    if v.get("app").and_then(|a| a.as_str()) != Some("adom-parts-search") { return None; }
    Some(v.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string())
}

pub fn alive(port: u16) -> bool { instance_version(port).is_some() }

/// Retire the instance that actually OWNS the port. Argv is a claim, not a
/// fact: the authority is that the process answers our /api/status on this
/// port with our app name, so we check that first and only then match the
/// published pid, and never on a substring.
fn stop_published_instance(port: u16) {
    // Only ever retire a process that is genuinely serving THIS app on THIS
    // port; if something else holds it, we must not kill it.
    if instance_version(port).is_none() { return; }
    let Ok(raw) = std::fs::read_to_string(state_path()) else { return };
    let Ok(st) = serde_json::from_str::<Value>(&raw) else { return };
    let Some(pid) = st.get("pid").and_then(|p| p.as_u64()) else { return };
    let Ok(cmd) = std::fs::read(format!("/proc/{}/cmdline", pid)) else { return };
    let argv: Vec<String> = cmd.split(|b| *b == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).to_string()).collect();
    let want = expected_argv(port);
    // argv[0] may be the bare name or a path; compare the tail exactly.
    let same = argv.len() == want.len()
        && argv[1..] == want[1..]
        && (argv[0] == want[0] || argv[0].rsplit('/').next() == want[0].rsplit('/').next());
    if !same { return; }
    let _ = Command::new("kill").arg(pid.to_string()).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status();
    let _ = std::fs::remove_file(state_path());
    let t0 = Instant::now();
    while alive(port) && t0.elapsed() < Duration::from_secs(3) { std::thread::sleep(Duration::from_millis(100)); }
}

fn shutdown_via_api(port: u16) {
    let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(3000)).build();
    let _ = agent.post(&format!("http://127.0.0.1:{}/shutdown", port)).call();
    let t0 = Instant::now();
    while alive(port) && t0.elapsed() < Duration::from_secs(5) { std::thread::sleep(Duration::from_millis(100)); }
}

pub fn take_over_dock_webview(url: &str) {
    let Ok(name) = std::env::var("ADOM_DOCK_WEBVIEW") else { return };
    if name.trim().is_empty() { return; }
    let _ = Command::new("adom-cli")
        .args(["hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", url])
        .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
        .spawn();
}

pub fn spawn_detached(port: u16) -> Result<(), String> {
    use std::os::unix::process::CommandExt;
    let exe = std::env::current_exe().map_err(|e| format!("current_exe: {}", e))?;
    // setsid when available: a new session survives the parent exiting, which
    // is what the dock's exec does the moment serve prints its URL.
    let exe_str = exe.to_string_lossy().to_string();
    let spawned = Command::new("setsid")
        .args(["--fork", &exe_str, "app", "--port", &port.to_string(), "--no-tab"])
        .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
        .spawn();
    match spawned {
        Ok(_) => Ok(()),
        Err(_) => Command::new(exe)          // no setsid on this box: own process group
            .args(["app", "--port", &port.to_string(), "--no-tab"])
            .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
            .process_group(0)
            .spawn()
            .map(|_| ())
            .map_err(|e| format!("spawn: {}", e)),
    }
}

/// Ensure a server of OUR version is up on `port`. Returns its direct url.
pub fn ensure(port: u16) -> Result<(String, bool), String> {
    if let Some(v) = instance_version(port) {
        if v == VERSION { return Ok((direct_url(port), false)); }
        crate::warn(format!("running instance is {}, this is {}; restarting", if v.is_empty() { "unversioned" } else { &v }, VERSION));
        stop_published_instance(port);
        // THE PID FILE IS A HINT, THE PORT IS THE FACT. The state file is one
        // per box, not per port, so a second copy on another port (a test, a
        // dev build) overwrites it and the pid route finds nothing to kill.
        // Every version since 1.1.0 answers POST /shutdown, and instance_version
        // above already proved this port is serving Parts Search, so asking it
        // to exit is safe. Found 2026-09-22: 2.0.15 could not replace 2.0.14.
        if alive(port) { shutdown_via_api(port); }
        if alive(port) {
            return Err(format!("port {} is held by a parts-search server I could not stop (pid not published by this version). Hint: POST /shutdown to http://127.0.0.1:{}/ or free the port, then retry.", port, port));
        }
    }
    spawn_detached(port)?;
    let t0 = Instant::now();
    while t0.elapsed() < Duration::from_secs(15) {
        if alive(port) { return Ok((direct_url(port), true)); }
        std::thread::sleep(Duration::from_millis(200));
    }
    Err(format!("the server did not answer on port {} within 15s. Hint: run `adom-parts-search app --no-tab --port {}` in the foreground to see why.", port, port))
}

pub fn run(port: u16, print_url: bool) -> Result<(), String> {
    let (url, started) = ensure(port)?;
    take_over_dock_webview(&url);
    if print_url {
        println!("{}", url);
    } else {
        crate::ok(format!("{} on {} (v{})", if started { "started" } else { "already running" }, url, VERSION));
        if std::env::var("ADOM_DOCK_WEBVIEW").is_ok() { crate::note!("  took over dock webview \"{}\"", std::env::var("ADOM_DOCK_WEBVIEW").unwrap_or_default()); }
    }
    Ok(())
}