app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
Drew Owens
0.6.9 source: launch resilience, honest counts, ownership persistence, drain, quit ladder — 21 adversary passes, 303 te…
cf16621
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
//! THE SERVER'S OWNERSHIP REGISTRY, ON DISK — bug #5.
//!
//! The bridge refuses to write to any document it did not create. That refusal
//! is only as good as its memory of what it created, and until now that memory
//! lived in one process's RAM. Two things emptied it, both observed while
//! building a real crate on 2026-09-03:
//!
//! * **A bridge restart.** `swx_close_doc` refused all 23 open crate
//! documents — *"which this bridge did not create"* — because the server had
//! been restarted mid-build. The documents were the bridge's own.
//! * **A com-host respawn during a long call.** `app_state` came back as an
//! ERROR, the caller read that error as "no session, no documents open",
//! and the prune that follows dropped every entry. Ninety seconds after
//! creating `Assem27`, the bridge could not save it.
//!
//! Both are the same defect in two places: an unanswerable question answered
//! definitely. So this file does two things. It PERSISTS the registry, keyed by
//! the SOLIDWORKS session (its process id) and, where the OS can tell us, that
//! process's START TIME — so a recycled pid can never inherit another session's
//! claims. And [`crate::verbs::Ctx::prune_scratch`] is now a no-op when the
//! session is unknown, because "I could not read the session" must not mean
//! "forget everything I own".
//!
//! Written and read by the SERVER only. The com host keeps its own independent
//! record in `com-host-created.json` — two vetoes, two files, neither derived
//! from the other. `swx_uninstall` removes both.
use crate::verbs::ScratchDoc;
use serde_json::{json, Value};
pub fn path() -> std::path::PathBuf {
crate::scratch::root().join("owned-docs.json")
}
/// A stored entry: what we own, in which session, and when that session's
/// process started.
#[derive(Clone, Debug, PartialEq)]
pub struct Owned {
pub doc: ScratchDoc,
/// The SOLIDWORKS process's creation time, when the OS could tell us.
/// `None` on a host that cannot answer (the mock, or a locked-down box),
/// where the pid alone has to do — which is what the in-memory version
/// always relied on anyway.
pub session_started_at: Option<u64>,
}
/// How long a looked-up start time is reused. A process's creation time never
/// changes, so this is only about pid REUSE — and a pid coming round again
/// within ten seconds of a SOLIDWORKS process ending is not a thing that
/// happens. Without the cache, every document-addressing verb would take a full
/// process census (the registry is rewritten on every prune), which on a busy
/// build is thousands of enumerations for an answer that cannot change.
const START_TIME_TTL_SECS: u64 = 10;
static START_CACHE: std::sync::Mutex<Option<(i64, Option<u64>, u64)>> = std::sync::Mutex::new(None);
/// The SOLIDWORKS process's start time, so a recycled pid cannot inherit our
/// claims. `None` when the host cannot say — never a fabricated zero.
pub fn session_started_at(session: Option<i64>) -> Option<u64> {
let pid_i64 = session?;
let pid = u32::try_from(pid_i64).ok()?;
let now = crate::launchos::epoch_secs();
{
let cache = START_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((cached_pid, value, at)) = *cache {
if cached_pid == pid_i64 && now.saturating_sub(at) < START_TIME_TTL_SECS {
return value;
}
}
}
let found = crate::launchos::os()
.processes()
.ok()
.and_then(|c| c.into_iter().find(|p| p.pid == pid && p.is("SLDWORKS.exe")))
.and_then(|p| p.created_at);
*START_CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Some((pid_i64, found, now));
found
}
/// What reading the registry produced. ABSENT and UNPARSEABLE are different
/// facts and must not share an answer: the first is an ordinary first run, the
/// second is the bridge silently losing every claim it holds — bug #5's end
/// state reached by a different road.
#[derive(Debug)]
pub enum Registry {
/// No file. A first run, or a session that ended cleanly.
Absent,
Loaded(Vec<Owned>),
/// The file is there and could not be read. The bridge owns nothing, and
/// SAYS SO — loudly, once, at startup.
Unreadable(String),
}
impl Registry {
pub fn docs(self) -> Vec<Owned> {
match self {
Registry::Loaded(v) => v,
_ => Vec::new(),
}
}
}
/// Read the registry back.
pub fn load() -> Registry {
load_from(&path())
}
pub fn load_from(p: &std::path::Path) -> Registry {
let text = match std::fs::read_to_string(p) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Registry::Absent,
Err(e) => return Registry::Unreadable(format!("{p:?} could not be read: {e}")),
};
let v: Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => {
// Keep the evidence. A truncated registry is the visible end of a
// write that was interrupted, and the next question is always
// "interrupted by what?".
let _ = std::fs::copy(p, p.with_extension("json.bad"));
return Registry::Unreadable(format!(
"{} is not valid JSON ({e}) — most likely a write that was interrupted. It has \
been kept as owned-docs.json.bad.",
p.display()
));
}
};
Registry::Loaded(load_docs(&v))
}
fn load_docs(v: &Value) -> Vec<Owned> {
v.get("documents")
.and_then(|d| d.as_array())
.map(|rows| {
rows.iter()
.filter_map(|r| {
let title = r.get("title")?.as_str()?.to_string();
if title.is_empty() {
return None;
}
Some(Owned {
doc: ScratchDoc {
title,
session: r.get("session").and_then(|s| s.as_i64()),
path: r
.get("path")
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
},
session_started_at: r.get("sessionStartedAt").and_then(|s| s.as_u64()),
})
})
.collect()
})
.unwrap_or_default()
}
/// Read, reconcile, and SAY what happened. The one entry point the server uses.
pub fn restore() -> Vec<ScratchDoc> {
match load() {
Registry::Absent => Vec::new(),
Registry::Unreadable(why) => {
// FALL BACK TO THE PREVIOUS GENERATION. `write_atomic` keeps one,
// and it is a strictly better answer than owning nothing: the
// .bak is a registry this bridge itself wrote, one write ago, so at
// worst it is missing the most recent claim — while owning nothing
// refuses every document the bridge created before the restart.
eprintln!(
"[swx] OWNERSHIP REGISTRY UNREADABLE — {why} Falling back to the previous \
generation (owned-docs.bak)."
);
let bak = path().with_extension("bak");
match load_from(&bak) {
Registry::Loaded(rows) if !rows.is_empty() => {
let kept: Vec<ScratchDoc> =
reconcile(rows, |pid| session_started_at(Some(pid))).into_iter().map(|o| o.doc).collect();
println!(
"[swx] ownership registry recovered from owned-docs.bak: {} document(s) — \
the most recent claim may be missing",
kept.len()
);
kept
}
_ => {
eprintln!(
"[swx] ...and there is no usable backup either. This bridge is starting \
up owning NOTHING and will refuse to write to documents it created \
before the restart."
);
println!("[swx] ownership registry could not be read; starting with no claims");
Vec::new()
}
}
}
Registry::Loaded(rows) => {
let kept: Vec<ScratchDoc> =
reconcile(rows, |pid| session_started_at(Some(pid))).into_iter().map(|o| o.doc).collect();
if !kept.is_empty() {
println!("[swx] ownership registry restored: {} document(s)", kept.len());
}
kept
}
}
}
/// Drop entries whose SOLIDWORKS session cannot still be the one we recorded.
///
/// The pid alone is not an identity — Windows reissues them. When BOTH the
/// stored start time and the live one are known and they differ, that pid is a
/// different process now and its documents are somebody else's. When either is
/// unknown we keep the entry and let [`crate::verbs::Ctx::owns_doc`]'s
/// session check decide, exactly as before: this guard can only ever REMOVE a
/// claim, never add one.
pub fn reconcile(stored: Vec<Owned>, live: impl Fn(i64) -> Option<u64>) -> Vec<Owned> {
stored
.into_iter()
.filter(|o| match (o.doc.session, o.session_started_at) {
(Some(pid), Some(was)) => match live(pid) {
Some(now) => now == was,
None => true,
},
_ => true,
})
.collect()
}
/// Persist the whole registry. Best effort — a bridge must never fail a real
/// verb because it could not write its own notes — but the write is a full
/// replacement, so a crash mid-write cannot leave a half-registry that grants
/// ownership of something we do not own.
pub fn save(docs: &[ScratchDoc], started_at: impl Fn(Option<i64>) -> Option<u64>) {
save_to(&path(), docs, started_at)
}
pub fn save_to(
p: &std::path::Path,
docs: &[ScratchDoc],
started_at: impl Fn(Option<i64>) -> Option<u64>,
) {
let rows: Vec<Value> = docs
.iter()
.map(|d| {
json!({
"title": d.title,
"session": d.session,
"path": d.path,
"sessionStartedAt": started_at(d.session),
})
})
.collect();
// ATOMIC. A plain `fs::write` truncates the file and then fills it, and this
// is written on every remember, forget, rename and prune — hundreds of times
// in one assembly build. A kill in that window leaves a truncated registry,
// and the bridge then starts up owning nothing.
if let Err(e) = crate::launch::write_atomic(p, &json!({"documents": rows}).to_string()) {
eprintln!(
"[swx] OWNERSHIP REGISTRY NOT PERSISTED ({e}). The in-memory record is intact for \
this process, but a restart would start owning nothing."
);
}
}
/// The SOLIDWORKS session ended: its untitled numbering restarts, so no
/// remembered title can be trusted and the file goes.
pub fn clear() {
let _ = std::fs::remove_file(path());
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(title: &str, session: Option<i64>, path: Option<&str>) -> ScratchDoc {
ScratchDoc { title: title.into(), session, path: path.map(String::from) }
}
#[test]
fn the_registry_survives_a_round_trip_through_the_file() {
let dir = std::env::temp_dir().join(format!("swx-owned-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("owned-docs.json");
let docs = vec![doc("Assem27", Some(88556), None), doc("R4.SLDPRT", Some(88556), Some("C:\\p\\R4.sldprt"))];
save_to(&p, &docs, |_| Some(1_757_000_000));
let back = load_from(&p).docs();
assert_eq!(back.len(), 2);
assert_eq!(back[0].doc, docs[0], "an unsaved document keeps its title AND its session");
assert_eq!(back[1].doc.path.as_deref(), Some("C:\\p\\R4.sldprt"));
assert_eq!(back[1].session_started_at, Some(1_757_000_000));
// An empty title is not a document, and a corrupt file is an empty
// registry rather than a panic or a claim.
std::fs::write(&p, r#"{"documents":[{"title":"","session":1}]}"#).unwrap();
assert!(load_from(&p).docs().is_empty(), "an empty title is not a document");
// A CORRUPT file is not an empty registry — it is a reported failure.
std::fs::write(&p, "not json at all").unwrap();
assert!(
matches!(load_from(&p), Registry::Unreadable(_)),
"a truncated registry must be distinguishable from a first run"
);
assert!(p.with_extension("json.bad").exists(), "the evidence is kept");
std::fs::remove_file(&p).unwrap();
assert!(matches!(load_from(&p), Registry::Absent), "...and both from an absent one");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_recycled_pid_does_not_inherit_the_previous_sessions_documents() {
let stored = vec![
Owned { doc: doc("Assem27", Some(88556), None), session_started_at: Some(1000) },
Owned { doc: doc("Part9", Some(88556), None), session_started_at: None },
];
// SAME pid, DIFFERENT process: the first entry goes, and it must.
let kept = reconcile(stored.clone(), |_| Some(2000));
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].doc.title, "Part9", "an entry with no recorded start time is left to owns_doc");
// Same process: everything stays.
assert_eq!(reconcile(stored.clone(), |_| Some(1000)).len(), 2);
// The host cannot answer: nothing is dropped on a guess.
assert_eq!(reconcile(stored, |_| None).len(), 2);
}
}