app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
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));
}
}
}
}