main
Drew Owens 0.11.0 source 1b7115e 6d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
//! The com-host CHILD PROCESS, seen from the server side.
//!
//! The server spawns its own exe again with `--com-host` and speaks JSON-lines
//! over that child's stdio:
//!
//! ```text
//! ->  {"id":1,"cmd":"open","args":{"path":"C:\\a.sldprt"}}
//! <-  {"event":"ready","pid":4242}                 (once, at boot)
//! <-  {"id":1,"ok":true,"result":{...}}
//! <-  {"id":1,"ok":false,"error":"...","detail":"..."}
//! ```
//!
//! stdout carries protocol lines ONLY; the child's diagnostics go to stderr,
//! which this module drains, mirrors to our own stderr (ab captures it) and
//! keeps the tail of, so a failure can hand the caller `detail`.
//!
//! One request is in flight at a time — the COM apartment is single-threaded
//! and SOLIDWORKS is one app, so callers queue on a mutex. A request that
//! times out (a modal dialog, a rebuild that never returns) or a child that
//! dies takes the child down with it; the NEXT call respawns. That is the
//! whole reason the COM object lives in a child instead of a thread.

pub mod created_store;
pub mod expr;
pub mod host;
pub mod mock;
pub mod mock_csg;
pub mod mock_model;

#[cfg(windows)]
pub mod com;
#[cfg(windows)]
pub mod variant;

use serde_json::{json, Value};
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

/// Default per-request budget: below ab's default 120s verb timeout, so a stuck
/// COM call comes back as an honest bridge error instead of ab's 504.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(110);
/// How long the child gets to print its `ready` line.
const BOOT_TIMEOUT: Duration = Duration::from_secs(30);
const STDERR_TAIL: usize = 200;

/// A failure that came from (or happened while talking to) the com host.
#[derive(Debug, Clone)]
pub struct SidecarError {
    pub message: String,
    pub detail: Option<String>,
    /// The host's branchable code, if it gave one.
    pub code: Option<String>,
    /// DID THIS CALL EXCEED ITS BUDGET? Carried explicitly, because it is one
    /// half of the `host_app_hung` verdict and callers were deriving it by
    /// searching this struct's `message` for the words "did not answer" — a
    /// copy of the sentence built below, in two other files, that would have
    /// gone quietly false the day this wording changed.
    pub timed_out: bool,
    /// THE STRUCTURED REMAINS OF THE INNER FAILURE, when the host had any — the
    /// HRESULT and EXCEPINFO of the COM call that actually broke. Prose is what a
    /// person reads; this is what a person DIAGNOSES from.
    pub data: Option<serde_json::Value>,
    /// A DOCUMENT THE CALL CREATED BEFORE IT FAILED (the host's
    /// `createdBeforeFailing`): a script whose new_part ran and whose later step
    /// failed still made that part, and the server must own it too (0.10.2).
    pub created: Option<serde_json::Value>,
}

impl SidecarError {
    fn new(message: impl Into<String>, detail: Option<String>) -> Self {
        Self { message: message.into(), detail, code: None, timed_out: false, data: None, created: None }
    }

    fn timeout(message: impl Into<String>, detail: Option<String>) -> Self {
        Self { message: message.into(), detail, code: None, timed_out: true, data: None, created: None }
    }

    fn coded(message: impl Into<String>, detail: Option<String>, code: Option<String>) -> Self {
        Self { message: message.into(), detail, code, timed_out: false, data: None, created: None }
    }
}

impl std::fmt::Display for SidecarError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.message)
    }
}

struct Live {
    child: Child,
    stdin: ChildStdin,
    lines: Receiver<String>,
    tail: Arc<Mutex<VecDeque<String>>>,
    pid: u32,
}

/// A read of the com host that never blocks on the busy mutex. `pid` comes from
/// an atomic; `busy` and `inFlight` come from a try_lock, so `swx_status` (the
/// declared statusVerb, which ab tells callers to poll) can answer in
/// milliseconds even while a 580s Pack and Go holds the request lock.
#[derive(Clone, Debug)]
pub struct Snapshot {
    pub pid: Option<u32>,
    pub busy: bool,
    pub in_flight: Option<String>,
}

/// Owns the com-host child: lazy spawn, request/response, restart after a
/// crash or hang.
pub struct ComHost {
    live: Mutex<Option<Live>>,
    seq: AtomicU64,
    /// The current child's pid, readable WITHOUT the `live` lock. 0 = none.
    pid: std::sync::atomic::AtomicU32,
    /// The verb currently in flight (short lock, never held across the COM
    /// wait), so a status poll can name what is keeping the host busy.
    in_flight: Mutex<Option<String>>,
}

impl Default for ComHost {
    fn default() -> Self {
        Self::new()
    }
}

impl ComHost {
    pub fn new() -> Self {
        Self {
            live: Mutex::new(None),
            seq: AtomicU64::new(0),
            pid: std::sync::atomic::AtomicU32::new(0),
            in_flight: Mutex::new(None),
        }
    }

    /// pid of the running child, or None when nothing is spawned. From the
    /// atomic, so it never blocks. Surfaced in `swx_status.com.hostPid` so a
    /// human (or a test) can see WHICH process holds the COM object, and watch
    /// it change after a respawn.
    pub fn pid(&self) -> Option<u32> {
        match self.pid.load(Ordering::SeqCst) {
            0 => None,
            n => Some(n),
        }
    }

    /// A non-blocking read of the host's state for `swx_status`.
    ///
    /// `busy` must mean "a verb holds the request lock", NOT "the lock is
    /// poisoned" — this is the statusVerb ab polls, so it must never lie about
    /// being busy. So a poisoned lock (a panic left it that way) is treated as
    /// contention-free: report not-busy and, for in_flight, recover the value
    /// through the poison rather than dropping to `None`.
    pub fn snapshot(&self) -> Snapshot {
        use std::sync::TryLockError;
        let busy = match self.live.try_lock() {
            Ok(_) => false,                       // uncontended
            Err(TryLockError::WouldBlock) => true, // a verb genuinely holds it
            Err(TryLockError::Poisoned(_)) => false, // a past panic, not busy now
        };
        let in_flight = match self.in_flight.try_lock() {
            Ok(g) => g.clone(),
            Err(TryLockError::WouldBlock) => None,
            Err(TryLockError::Poisoned(g)) => g.into_inner().clone(),
        };
        Snapshot { pid: self.pid(), busy, in_flight }
    }

    /// Run one command against the com host. Spawns the child if needed, and
    /// transparently replaces one that died BETWEEN requests.
    ///
    /// The retry is deliberately narrow: it happens only when the child was
    /// already gone before this request was written, so nothing it asked for can
    /// have half-happened. A child that dies MID-request is reported, never
    /// silently re-run — re-running an export or a Pack and Go behind the
    /// caller's back would be worse than the error.
    pub fn call(&self, cmd: &str, args: Value, timeout: Duration) -> Result<Value, SidecarError> {
        let mut guard = self.live.lock().unwrap_or_else(|e| e.into_inner());

        for attempt in 0..2 {
            // A child that has exited (crashed, or was killed out from under us)
            // is replaced before we write anything into a dead pipe.
            if let Some(live) = guard.as_mut() {
                if matches!(live.child.try_wait(), Ok(Some(_))) {
                    println!("[swx] com host (pid {}) is gone; respawning", live.pid);
                    kill(guard.take());
                }
            }
            if guard.is_none() {
                *guard = Some(spawn_child()?);
            }
            self.sync_pid(&guard);
            let id = self.seq.fetch_add(1, Ordering::SeqCst) + 1;
            let req = json!({"id": id, "cmd": cmd, "args": args});

            {
                let live = guard.as_mut().expect("spawned above");
                let mut line = serde_json::to_string(&req).unwrap_or_default();
                line.push('\n');
                if let Err(e) =
                    live.stdin.write_all(line.as_bytes()).and_then(|_| live.stdin.flush())
                {
                    let tail = tail_text(&live.tail);
                    kill(guard.take());
                    self.sync_pid(&guard);
                    if attempt == 0 {
                        println!("[swx] com host write failed ({e}); respawning and retrying");
                        continue;
                    }
                    return Err(SidecarError::new(
                        format!("com host write failed ({e}); it will respawn on the next call"),
                        tail,
                    ));
                }
            }

            // Record the in-flight verb (short lock, released immediately) so a
            // concurrent status poll can name what is keeping the host busy.
            *self.in_flight.lock().unwrap_or_else(|e| e.into_inner()) = Some(cmd.to_string());
            let out = self.await_response(&mut guard, id, cmd, timeout);
            *self.in_flight.lock().unwrap_or_else(|e| e.into_inner()) = None;
            self.sync_pid(&guard);
            return out;
        }
        unreachable!("the loop returns or continues exactly once")
    }

    /// Keep the lock-free pid atomic in step with the live child.
    fn sync_pid(&self, guard: &Option<Live>) {
        self.pid.store(guard.as_ref().map(|l| l.pid).unwrap_or(0), Ordering::SeqCst);
    }

    fn await_response(
        &self,
        guard: &mut std::sync::MutexGuard<'_, Option<Live>>,
        id: u64,
        cmd: &str,
        timeout: Duration,
    ) -> Result<Value, SidecarError> {
        let deadline = Instant::now() + timeout;
        loop {
            let remaining = deadline.saturating_duration_since(Instant::now());
            let live = guard.as_mut().expect("still live");
            let recv = if remaining.is_zero() {
                Err(RecvTimeoutError::Timeout)
            } else {
                live.lines.recv_timeout(remaining)
            };
            match recv {
                Ok(line) => {
                    // Anything that is not a well-formed response for THIS request
                    // is skipped, never fatal: a stray print from the host app, an
                    // event line, or a late answer to a request that already timed
                    // out. The deadline still governs, so we cannot spin forever.
                    let resp: Value = match serde_json::from_str(&line) {
                        Ok(v) => v,
                        Err(_) => continue,
                    };
                    if resp.get("event").is_some() {
                        continue;
                    }
                    if resp.get("id").and_then(|v| v.as_u64()) != Some(id) {
                        continue;
                    }
                    if resp.get("ok").and_then(|v| v.as_bool()) != Some(true) {
                        let message = resp
                            .get("error")
                            .and_then(|v| v.as_str())
                            .unwrap_or("com host error")
                            .to_string();
                        let detail =
                            resp.get("detail").and_then(|v| v.as_str()).map(|s| s.to_string());
                        let code =
                            resp.get("errorCode").and_then(|v| v.as_str()).map(|s| s.to_string());
                        let mut e = SidecarError::coded(message, detail, code);
                        e.data = resp.get("errorData").filter(|v| !v.is_null()).cloned();
                        e.created = resp.get("createdBeforeFailing").filter(|v| !v.is_null()).cloned();
                        // The HOST may have given up on the call itself.
                        e.timed_out = resp.get("timedOut").and_then(|v| v.as_bool()).unwrap_or(false);
                        return Err(e);
                    }
                    return Ok(resp.get("result").cloned().unwrap_or_else(|| json!({})));
                }
                Err(RecvTimeoutError::Timeout) => {
                    let tail = tail_text(&live.tail);
                    kill(guard.take());
                    return Err(SidecarError::timeout(
                        format!(
                            "com host did not answer '{cmd}' within {}s (SOLIDWORKS may be busy \
                             with a modal dialog or a huge rebuild); it was killed and respawns \
                             on the next call",
                            timeout.as_secs()
                        ),
                        tail,
                    ));
                }
                Err(RecvTimeoutError::Disconnected) => {
                    let tail = tail_text(&live.tail);
                    let code = live.child.try_wait().ok().flatten().map(|s| s.to_string());
                    kill(guard.take());
                    return Err(SidecarError::new(
                        format!(
                            "com host exited ({}) while serving '{cmd}'; it respawns on the next call",
                            code.unwrap_or_else(|| "unknown status".into())
                        ),
                        tail,
                    ));
                }
            }
        }
    }

    /// RESPAWN THE CHILD, AND NOTHING ELSE (swx_com_respawn, 0.10.6): the
    /// child exits through its own loop (`__exit__`), so its backend is DROPPED
    /// and every COM reference it held is released by its own Release calls —
    /// not left to the ~6-minute COM ping rundown a killed process leaves.
    /// SOLIDWORKS is never called. A verb holding the host refuses (`Err`):
    /// pulling the host out from under a COM call is a different act.
    pub fn respawn(&self) -> Result<RespawnReport, String> {
        use std::sync::TryLockError;
        let mut guard = match self.live.try_lock() {
            Ok(g) => g,
            Err(TryLockError::WouldBlock) => return Err("a verb is in flight on the com host".into()),
            Err(TryLockError::Poisoned(g)) => g.into_inner(),
        };
        let old_pid = guard.as_ref().map(|l| l.pid);
        let mut graceful = old_pid.is_none();
        if let Some(live) = guard.as_mut() {
            let _ = live.stdin.write_all(b"{\"id\":0,\"cmd\":\"__exit__\"}\n");
            let _ = live.stdin.flush();
            let deadline = Instant::now() + Duration::from_secs(5);
            while Instant::now() < deadline {
                if matches!(live.child.try_wait(), Ok(Some(_))) {
                    graceful = true;
                    break;
                }
                std::thread::sleep(Duration::from_millis(50));
            }
        }
        kill(guard.take());
        self.sync_pid(&guard);
        *guard = Some(spawn_child().map_err(|e| e.message)?);
        self.sync_pid(&guard);
        Ok(RespawnReport { old_pid, new_pid: guard.as_ref().map(|l| l.pid), graceful_exit: graceful })
    }

    /// Ask the child to exit, then make sure of it. Idempotent, and it must NOT
    /// block on the request lock — ab's uninstall cascade has a 120s budget, but
    /// a 580s Pack and Go could be holding that lock.
    ///
    /// So: try_lock for the graceful path; if it is contended, kill the child by
    /// pid (via the OS) instead. Killing the child makes its wedged call return
    /// an error, which releases the lock — the cascade proceeds, and no orphaned
    /// process is left holding a COM object.
    pub fn shutdown(&self) {
        if let Ok(mut guard) = self.live.try_lock() {
            if let Some(live) = guard.as_mut() {
                let _ = live.stdin.write_all(b"{\"id\":0,\"cmd\":\"__exit__\"}\n");
                let _ = live.stdin.flush();
                std::thread::sleep(Duration::from_millis(200));
            }
            kill(guard.take());
            self.pid.store(0, Ordering::SeqCst);
            return;
        }
        // Contended by an in-flight verb. Kill by pid so we neither block nor
        // wait on a lock a long COM call owns. This pid can only be a child the
        // contending call is actively holding (the atomic tracks the LIVE child
        // and is zeroed on every take), so there is no stale-pid window here —
        // the verb holding the lock is what keeps the child, and the pid, alive.
        if let Some(pid) = self.pid() {
            println!("[swx] com host busy at shutdown; killing pid {pid} to unblock the cascade");
            kill_by_pid(pid);
        }
    }
}

/// What a respawn did.
#[derive(Clone, Debug)]
pub struct RespawnReport {
    pub old_pid: Option<u32>,
    pub new_pid: Option<u32>,
    /// The old child left through its own loop (references released by Drop)
    /// rather than being killed.
    pub graceful_exit: bool,
}

/// OS-level kill of the child by pid, used only when the request lock is held
/// by a long COM call and we cannot reach the `Child` handle behind it.
fn kill_by_pid(pid: u32) {
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        let _ = std::process::Command::new("taskkill")
            .args(["/F", "/T", "/PID", &pid.to_string()])
            .creation_flags(0x0800_0000) // CREATE_NO_WINDOW
            .output();
    }
    #[cfg(unix)]
    {
        let _ = std::process::Command::new("kill").args(["-9", &pid.to_string()]).output();
    }
}

fn kill(live: Option<Live>) {
    if let Some(mut live) = live {
        let _ = live.child.kill();
        let _ = live.child.wait();
    }
}

fn tail_text(tail: &Arc<Mutex<VecDeque<String>>>) -> Option<String> {
    let t = tail.lock().unwrap_or_else(|e| e.into_inner());
    if t.is_empty() {
        None
    } else {
        Some(t.iter().cloned().collect::<Vec<_>>().join("\n"))
    }
}

fn spawn_child() -> Result<Live, SidecarError> {
    let exe = std::env::current_exe()
        .map_err(|e| SidecarError::new(format!("cannot locate our own exe: {e}"), None))?;
    let mut cmd = Command::new(&exe);
    cmd.arg("--com-host")
        // The child's watchdog polls this so it exits even if the server is
        // hard-killed while a COM call is wedged (no orphan holding a COM object).
        .env("SWX_PARENT_PID", std::process::id().to_string())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    #[cfg(windows)]
    {
        // CREATE_NO_WINDOW — a bridge child must never blip a console window on
        // the user's desktop.
        use std::os::windows::process::CommandExt;
        cmd.creation_flags(0x0800_0000);
    }
    let mut child = cmd
        .spawn()
        .map_err(|e| SidecarError::new(format!("failed to spawn com host: {e}"), None))?;

    let pid = child.id();
    let stdin = child.stdin.take().expect("piped");
    let stdout = child.stdout.take().expect("piped");
    let stderr = child.stderr.take().expect("piped");

    // stdout reader thread: turns blocking line reads into something we can put
    // a deadline on. Its sender dropping = the child's stdout closed.
    let (tx, rx) = std::sync::mpsc::channel::<String>();
    std::thread::Builder::new()
        .name("swx-comhost-stdout".into())
        .spawn(move || {
            for line in BufReader::new(stdout).lines() {
                match line {
                    Ok(l) => {
                        if tx.send(l).is_err() {
                            break;
                        }
                    }
                    Err(_) => break,
                }
            }
        })
        .map_err(|e| SidecarError::new(format!("reader thread: {e}"), None))?;

    // stderr drain: mirrored to our stderr (ab captures it) and kept as a tail
    // so a failed verb can carry `detail`.
    let tail = Arc::new(Mutex::new(VecDeque::<String>::new()));
    let tail2 = Arc::clone(&tail);
    std::thread::Builder::new()
        .name("swx-comhost-stderr".into())
        .spawn(move || {
            for line in BufReader::new(stderr).lines().map_while(Result::ok) {
                let line = line.trim_end().to_string();
                if line.is_empty() {
                    continue;
                }
                eprintln!("[swx-com] {line}");
                let mut t = tail2.lock().unwrap_or_else(|e| e.into_inner());
                t.push_back(line);
                while t.len() > STDERR_TAIL {
                    t.pop_front();
                }
            }
        })
        .map_err(|e| SidecarError::new(format!("stderr thread: {e}"), None))?;

    let live = Live { child, stdin, lines: rx, tail, pid };

    // Wait for the ready line so the first request never races the boot.
    let deadline = Instant::now() + BOOT_TIMEOUT;
    loop {
        let remaining = deadline.saturating_duration_since(Instant::now());
        match live.lines.recv_timeout(remaining) {
            Ok(line) => {
                if let Ok(v) = serde_json::from_str::<Value>(&line) {
                    if v.get("event").and_then(|e| e.as_str()) == Some("ready") {
                        // PROTOCOL SKEW IS AN ERROR, NOT A SHRUG. A host older
                        // than this server does not send every field this server
                        // reads, and an absent field is indistinguishable from a
                        // `false` one unless somebody checks. The version is the
                        // one fact that makes the difference visible.
                        let their = v.get("version").and_then(|x| x.as_str());
                        match their {
                            Some(v) if v == crate::BRIDGE_VERSION => {}
                            Some(v) => {
                                let detail = format!(
                                    "com host reports version {v}, this server is {}. They are \
                                     separate binaries and a mismatched pair drops fields \
                                     silently — including the ones that say a close could not be \
                                     verified. Reinstall so both come from the same release.",
                                    crate::BRIDGE_VERSION
                                );
                                kill(Some(live));
                                return Err(SidecarError::new("com host version mismatch", Some(detail)));
                            }
                            None => {
                                let detail = format!(
                                    "the com host's ready line carries no version, so it is older \
                                     than {} and this server cannot tell which of its fields that \
                                     host never sends. Reinstall so both come from the same \
                                     release.",
                                    crate::BRIDGE_VERSION
                                );
                                kill(Some(live));
                                return Err(SidecarError::new("com host version unknown", Some(detail)));
                            }
                        }
                        println!("[swx] com host up (pid {pid}, mock={})", crate::is_mock());
                        return Ok(live);
                    }
                }
                // Not the ready line — keep waiting (a noisy host is not a dead one).
            }
            Err(_) => {
                let detail = tail_text(&live.tail);
                kill(Some(live));
                return Err(SidecarError::new("com host failed to boot", detail));
            }
        }
    }
}