app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
//! ADVERSARIAL REVIEW OF 0.4.0 — every test here is a FAILING test written
//! against the shipped commit (42c36fe). Each one states a property the release
//! CLAIMS in `docs/LAUNCH-RESILIENCE.md` or in its own doc comments, and then
//! shows the code does not have it.
//!
//! None of these were run against a live SOLIDWORKS. Every one of them runs on
//! the simulated machine (`SWX_LAUNCH_FAKE_DIR`) or against the pure rules, so
//! a fix can be verified without touching Drew's desktop.
//!
//! Read a failure here as "the claim in the docs is not the behaviour of the
//! code", not as "the suite is broken".
mod common;
use common::{AbStub, Bridge, TempDir};
use serde_json::{json, Value};
use std::time::{Duration, Instant};
use swx_bridge::launch;
fn s(v: &Value) -> String {
serde_json::to_string_pretty(v).unwrap_or_default()
}
/// A simulated machine, like `tests/launch.rs::Machine` but with the
/// pre-existing process list under the test's control — because the processes
/// the bridge must NOT touch are the whole subject of half of these tests.
struct Sim {
dir: TempDir,
}
impl Sim {
fn new(tag: &str, routes: Value, preexisting: Value) -> Sim {
let dir = TempDir::new(tag);
std::fs::write(dir.join("routes.json"), routes.to_string()).unwrap();
std::fs::write(dir.join("preexisting.json"), preexisting.to_string()).unwrap();
Sim { dir }
}
/// The Platform's daemons and a chain the user started a minute ago — the
/// baseline every launch test shares.
fn daemons() -> Vec<Value> {
vec![
json!({"pid": 6076, "image": "3DEXPERIENCELauncher.exe", "ageSeconds": 30000}),
json!({"pid": 90552, "image": "3DEXPERIENCELauncherSysTray.exe", "ageSeconds": 30000}),
json!({"pid": 90553, "image": "3DEXPERIENCELauncherService.exe", "ageSeconds": 30000}),
]
}
fn sldworks(self, state: Value) -> Sim {
std::fs::write(self.dir.join("sldworks.json"), state.to_string()).unwrap();
self
}
fn census_fails(self) -> Sim {
std::fs::write(self.dir.join("census-fails"), "").unwrap();
self
}
fn lines(&self, file: &str) -> Vec<Value> {
std::fs::read_to_string(self.dir.join(file))
.unwrap_or_default()
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
fn invocations(&self) -> Vec<String> {
self.lines("invocations.jsonl")
.iter()
.filter_map(|v| v.get("route").and_then(|r| r.as_str()).map(String::from))
.collect()
}
fn terminated(&self) -> Vec<(u64, String)> {
self.lines("terminated.jsonl")
.iter()
.filter_map(|v| Some((v.get("pid")?.as_u64()?, v.get("image")?.as_str()?.to_string())))
.collect()
}
fn boot(&self, scratch: TempDir, extra: &[(&str, &str)]) -> Bridge {
let lnk = format!("{}/SOLIDWORKS Design.lnk", self.dir.path());
std::fs::write(&lnk, "not really a shortcut").unwrap();
let mut env: Vec<(&str, &str)> = vec![
("SWX_SOLIDWORKS_SHORTCUT", Box::leak(lnk.into_boxed_str())),
("SWX_LAUNCH_FAKE_DIR", Box::leak(self.dir.path().into_boxed_str())),
("SWX_LAUNCH_RUN_BEGIN_SECS", "2"),
("SWX_LAUNCH_SLDWORKS_SECS", "4"),
("SWX_LAUNCH_ATTACH_SECS", "8"),
("SWX_LAUNCH_POLL_MS", "100"),
("SWX_LAUNCH_RETRY_SETTLE_MS", "400"),
("SWX_LAUNCH_ATTACH_POLL_MS", "150"),
("SWX_LAUNCH_INFLIGHT_SECS", "120"),
];
env.extend_from_slice(extra);
Bridge::start_in(scratch, &env)
}
}
fn wait_for_launch(b: &Bridge, secs: u64) -> Value {
let deadline = Instant::now() + Duration::from_secs(secs);
loop {
let last = b.call("swx_status", json!({}));
if last["launch"]["finished"] == true || Instant::now() > deadline {
return last;
}
std::thread::sleep(Duration::from_millis(150));
}
}
// ════════════════════════════════════════════════════════════════════════════
// 1. THE KILL ALLOWLIST
// ════════════════════════════════════════════════════════════════════════════
/// `docs/LAUNCH-RESILIENCE.md` §3, condition 4: *"it was created at or after our
/// own invoke (±1s of clock slack), so a chain the user started before we asked
/// for anything is never in the list."*
///
/// The second half of that sentence is not true of the first half. The slack is
/// applied to the PROCESS's age, not to ours, so a chain created up to a full
/// second BEFORE the invoke is terminable — and one seconds' worth of
/// Start-Menu click is exactly the race this bridge is most likely to lose,
/// because an agent calls `swx_start` at the same moment it tells the user
/// "starting SOLIDWORKS".
#[test]
fn a_chain_the_user_started_before_our_invoke_is_never_terminable() {
let invoked = 1_000_000u64;
assert!(
!launch::may_terminate("CATSTART.exe", Some(invoked - 1), invoked),
"a CATSTART created one second BEFORE our invoke is the user's, not ours: \
the ±1s slack must widen OUR window backwards in time, not adopt THEIR process"
);
}
/// §3: *"The processes one stall recovery may end: our own chain and nothing
/// else."* The kill list is scoped by TIME ONLY — every `CATSTART.exe` /
/// `SWXDesktopLauncher.exe` on the machine that is younger than our invoke is
/// terminated, whoever started it. `Attempt.chain` records our real pids and
/// `clean_up_chain` then ignores them and re-derives the list from the clock.
///
/// The field scenario: the bridge invokes a route; twenty seconds is a long
/// time to watch a screen do nothing, so the user starts SOLIDWORKS themselves
/// from the Start Menu; at the `Run Begin` deadline the bridge ends BOTH chains
/// and launches a third.
#[test]
fn a_user_launch_that_appears_during_ours_is_never_terminated() {
// ageSeconds 0 => the simulator reports this chain as created "just now",
// i.e. AFTER our invoke: a launch the user started while ours was in flight.
let mut pre = Sim::daemons();
pre.push(json!({"pid": 70003, "image": "CATSTART.exe", "ageSeconds": 0}));
pre.push(json!({"pid": 70004, "image": "SWXDesktopLauncher.exe", "ageSeconds": 0}));
let m = Sim::new(
"adv-userchain",
json!({
"explorer": {"chain": true},
"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 100, "attachAfterMs": 300}
}),
Value::Array(pre),
);
let b = m.boot(TempDir::new("adv-scratch"), &[]);
b.call("swx_start", json!({"reason": "modelling a bracket", "waitSeconds": 0}));
wait_for_launch(&b, 25);
let killed = m.terminated();
for theirs in [70003u64, 70004] {
assert!(
!killed.iter().any(|(p, _)| *p == theirs),
"the bridge terminated pid {theirs}, a launcher chain it did not start. \
The kill list is scoped by CREATION TIME alone, so any chain younger than our \
invoke is swept up — including the user's own Start-Menu launch.\n\
terminated: {killed:?}\n--- server log ---\n{}",
b.log()
);
}
}
/// §3: *"Every terminate is `OpenProcess(PROCESS_TERMINATE)` + `TerminateProcess`
/// by pid, with the image name re-checked against a live census first (pids are
/// reused)."* — and §8: the exhausted test proves the machine *"leaves nothing
/// of ours running"*.
///
/// When the census cannot be taken, `clean_up_chain` returns an EMPTY killed
/// list and the state machine moves to the next route. Four routes later the
/// attempt reports `exhausted` with four `routesTried` entries whose
/// `killed: []` is indistinguishable from "there was nothing to kill", while up
/// to eight launcher processes are still on the machine. The one place it is
/// recorded is a ledger event no caller reads.
#[test]
fn a_stall_the_bridge_could_not_clean_up_is_not_reported_as_a_clean_one() {
let m = Sim::new("adv-blindkill", json!({}), Value::Array(Sim::daemons())).census_fails();
let b = m.boot(TempDir::new("adv-scratch"), &[("SWX_LAUNCH_RUN_BEGIN_SECS", "1")]);
b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));
let st = wait_for_launch(&b, 40);
assert_eq!(
st["launch"]["outcome"], "exhausted",
"precondition: every route stalls on this machine\n{}",
s(&st["launch"])
);
let report = s(&st["launch"]);
assert!(
report.contains("census") || report.to_lowercase().contains("could not end")
|| report.to_lowercase().contains("still running"),
"the launch report says four routes stalled and shows `killed: []` for each, which is \
exactly what a CLEAN recovery looks like — but the census failed, so nothing was ended \
and up to eight launcher processes are still on the machine. A caller cannot tell the \
two apart from this report.\n{report}\n--- server log ---\n{}",
b.log()
);
}
// ════════════════════════════════════════════════════════════════════════════
// 2. THE STATE MACHINE
// ════════════════════════════════════════════════════════════════════════════
/// `watch_route`'s stall note is written from the CLOCK, not from the evidence:
///
/// ```text
/// "route '{route}' reached SecurityContext but never logged 'Run Begin' within {n}s"
/// ```
///
/// It says `reached SecurityContext` whether or not a launcher log was ever
/// found. A route that never started anything at all — `explorer.exe` refusing
/// the link, PowerShell blocked by execution policy, a `.lnk` that no longer
/// exists — produces the same sentence, which sends the reader looking for a
/// stalled launcher that does not exist. (None of the four routes checks that
/// its invocation actually did anything: `spawn()` succeeding only means the
/// EXE started.)
#[test]
fn a_route_that_never_started_a_chain_is_not_described_as_stalled_at_securitycontext() {
let m = Sim::new(
"adv-nochain",
// chain:false — the invoke produced no launcher process and no log.
json!({"explorer": {"chain": false}, "shellExecute": {"chain": false},
"powershell": {"chain": false}, "cmdStart": {"chain": false}}),
Value::Array(Sim::daemons()),
);
let b = m.boot(TempDir::new("adv-scratch"), &[("SWX_LAUNCH_RUN_BEGIN_SECS", "1")]);
b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));
let st = wait_for_launch(&b, 40);
assert_eq!(m.invocations().len(), 4, "precondition: all four routes were invoked");
let note = st["launch"]["routesTried"][0]["note"].as_str().unwrap_or("").to_string();
assert!(
!note.contains("reached SecurityContext"),
"no launcher log ever appeared for this route — nothing reached SecurityContext, because \
nothing started. The note is fabricated from the deadline rather than read from the \
evidence, and `launcherLog` is null right beside it.\nnote: {note}\n{}",
s(&st["launch"])
);
}
/// §4: *"The third condition is what keeps this honest rather than a timer: a
/// state file left behind by a bridge that was killed mid-launch does not block
/// the next launch forever."*
///
/// It does block it, for as long as SOLIDWORKS happens to be running — because
/// `in_flight`'s third condition accepts ANY `sldworks.exe` as evidence that
/// OUR attempt is alive. A bridge killed mid-launch, restarted while the user's
/// SOLIDWORKS is up but not COM-attachable (a sign-in window: the single most
/// likely reason the bridge was restarted), answers `swx_start` with
/// "an attempt this bridge started is still running" and tells the caller to
/// poll a supervisor that does not exist. Nothing ever marks the persisted
/// attempt finished, so `swx_status` keeps advertising its stage and advice.
#[test]
fn a_stale_attempt_nobody_is_supervising_is_not_reported_as_in_flight() {
let scratch = TempDir::new("adv-scratch");
let m = Sim::new(
"adv-stale",
json!({"explorer": {"chain": true}}),
Value::Array({
let mut p = Sim::daemons();
p.push(json!({"pid": 88556, "image": "SLDWORKS.exe", "ageSeconds": 7200}));
p
}),
)
.sldworks(json!({"responding": true, "hwnd": 4242, "privateMB": 2400, "handles": 9000}));
// The state file a bridge that was killed mid-launch leaves behind.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
std::fs::write(
scratch.join("launch-state.json"),
json!({
"preferredRoute": Value::Null,
"attempt": {
"id": 7, "startedAt": now - 30, "shortcut": "C:\\x\\SOLIDWORKS Design.lnk",
"reason": "modelling", "caller": "SolidWorks Bridge",
"routePlan": ["explorer", "shellExecute", "powershell", "cmdStart"],
"route": "explorer", "invokedAt": now - 30,
"stage": "runBegin", "stageAt": now - 28, "chainPids": [],
"launcherLog": "C:\\logs\\20260904.log", "crashRecoveryClicked": false,
"finished": false, "outcome": Value::Null, "error": Value::Null
},
"history": []
})
.to_string(),
)
.unwrap();
let b = m.boot(scratch, &[]);
let r = b.call("swx_start", json!({"reason": "trying again after the bridge restarted"}));
assert!(
r["stillLaunching"] != true,
"no thread in this process is supervising attempt 7 — it belongs to a bridge that is \
gone. Reporting it as in flight makes the caller poll forever and hides the SOLIDWORKS \
that IS on the user's screen and unreachable.\n{}\n--- server log ---\n{}",
s(&r),
b.log()
);
}
// ════════════════════════════════════════════════════════════════════════════
// 3. THE CRASH REPORTER
// ════════════════════════════════════════════════════════════════════════════
/// §6: *"it then asks ab, read-only, whether that window carries a control named
/// 'Restart SOLIDWORKS'. Only if ab says yes does it send ONE background click."*
///
/// `mentions_button` does not ask whether ab said yes. It stringifies the WHOLE
/// response and looks for the button name anywhere in it — so any reply that
/// echoes the query it was given (`{"contains": "Restart SOLIDWORKS"}`, which is
/// exactly what the bridge sent) passes the gate with zero controls found.
/// `found:false` is the only shape that is refused, and §9.5 admits the real
/// response shape has never been seen.
#[test]
fn a_control_lookup_that_only_echoes_the_button_name_is_not_a_find() {
// A perfectly ordinary "I looked and found nothing" reply that happens to
// quote the query back.
let echo = json!({
"success": true,
"query": {"hwnd": 111, "contains": "Restart SOLIDWORKS", "role": "button"},
"controls": [],
"count": 0
});
assert!(
!launch::mentions_button(&echo),
"a reply that found NO controls passed the click gate because the request it echoes \
contains the button name: {echo}"
);
}
/// The same hole, end to end: ab answers "no controls", the bridge clicks
/// anyway.
#[test]
fn nothing_is_clicked_when_ab_reports_no_controls() {
let ab = AbStub::start(json!([
{"hwnd": 111, "title": "SOLIDWORKS Design has stopped working", "className": "#32770", "rect": {"left": 0}}
]));
ab.set_response(
"desktop_find_control",
json!({"success": true, "controls": [], "count": 0,
"query": {"contains": "Restart SOLIDWORKS", "role": "button"}}),
);
ab.set_response("desktop_ui_click", json!({"success": true, "invoked": true}));
let m = Sim::new(
"adv-echo",
json!({"explorer": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 100, "attachAfterMs": 1200}}),
Value::Array(Sim::daemons()),
);
let b = m.boot(
TempDir::new("adv-scratch"),
&[("ADOM_DIRECT_API_URL", &ab.url), ("ADOM_BRIDGE_TOKEN", "test-token")],
);
b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));
wait_for_launch(&b, 25);
let clicks = ab.calls_to("desktop_ui_click");
assert!(
clicks.is_empty(),
"ab reported no matching control and the bridge clicked anyway: {clicks:?}\n\
--- server log ---\n{}",
b.log()
);
}
/// §6: *"the BUTTON is the identification, not the window title"* — true, but
/// candidacy still decides which windows get a UIA probe and, once the gate
/// above is passed, which window is CLICKED. `is_crash_reporter_candidate` is
/// `title contains "solidworks" AND title contains one of seven words`, one of
/// which is the bare word `restart`. Every top-level window on the desktop is
/// searched, so a browser reading about a SOLIDWORKS crash, a Windows Update
/// prompt, and the Platform's own "restart to finish installing" notice are all
/// candidates.
#[test]
fn an_ordinary_window_that_merely_mentions_solidworks_is_not_a_crash_reporter() {
for title in [
"SOLIDWORKS crash on save — how to fix it - Google Chrome",
"Restart SOLIDWORKS to finish installing the update",
"SOLIDWORKS Installation Manager — restart required",
"Re: SOLIDWORKS keeps crashing - Outlook",
] {
assert!(
!launch::is_crash_reporter_candidate(title),
"'{title}' is a candidate crash reporter, so the bridge sends a UIA control probe at \
it — and clicks it if the probe's answer merely quotes the button name"
);
}
}
// ════════════════════════════════════════════════════════════════════════════
// 4. HUNG / EXHAUSTED
// ════════════════════════════════════════════════════════════════════════════
/// §5: `host_app_exhausted` is raised on *"private memory > 12 GB, or > 40
/// documents open"*. Measured on the same machine, the same week: a HEALTHY,
/// working SOLIDWORKS session at 13.1 GB with 33 documents open. The threshold
/// was read off one wedged session (17.4 GB / 52 docs) and set 4 GB below it.
#[test]
fn a_healthy_thirteen_gigabyte_session_is_not_called_exhausted() {
assert!(
launch::exhaustion(Some(13_100), Some(33), false).is_none(),
"13.1 GB / 33 documents is the measured footprint of a HEALTHY session on this machine, \
and the bridge calls it out of room: {:?}",
launch::exhaustion(Some(13_100), Some(33), false)
);
}
/// The consequence of the threshold, on the verb a caller actually reaches for.
/// A healthy 13.1 GB session that is momentarily un-attachable (a sign-in, a
/// modal) is reported as `host_app_exhausted` — "close your documents, then ask
/// the user to restart" — instead of `host_app_com_unreachable`, which is what
/// is actually true. `swx_status` carries the same verdict permanently.
#[test]
fn a_healthy_big_session_is_not_reported_as_out_of_room_by_the_verbs() {
let m = Sim::new(
"adv-bigsession",
json!({}),
Value::Array({
let mut p = Sim::daemons();
p.push(json!({"pid": 88556, "image": "SLDWORKS.exe", "ageSeconds": 7200}));
p
}),
)
.sldworks(json!({"responding": true, "hwnd": 4242, "privateMB": 13100, "handles": 14000}));
let b = m.boot(TempDir::new("adv-scratch"), &[]);
let st = b.call("swx_status", json!({}));
assert!(
st["solidworks"]["exhausted"].is_null(),
"swx_status flags a healthy 13.1 GB session as out of room: {}",
s(&st["solidworks"])
);
let r = b.call("swx_start", json!({"reason": "modelling"}));
assert!(
r["errorCode"] != "host_app_exhausted",
"swx_start tells the caller a healthy session has no room left and to have the user \
restart it. The honest code here is host_app_com_unreachable.\n{}",
s(&r)
);
}
/// §5: *"BOTH signals are required ... a not-responding window mid-rebuild is
/// not hung."* The second signal is not "a COM call timed out" — `swx_status`
/// computes it as `com.error is present OR the com host is BUSY`:
///
/// ```rust
/// let com_timed_out = com.get("error").is_some() || snap.busy;
/// ```
///
/// The com host is busy exactly when THIS BRIDGE is running a long verb, which
/// is exactly when SOLIDWORKS stops pumping its message loop. So the two
/// "independent" signals fire together, from one cause, during every long
/// operation the bridge itself starts — and `swx_status` reports the user's
/// working CAD session as hung.
#[test]
fn the_bridges_own_long_verb_is_never_reported_as_a_hung_solidworks() {
let m = Sim::new(
"adv-busyhung",
json!({}),
Value::Array({
let mut p = Sim::daemons();
p.push(json!({"pid": 88556, "image": "SLDWORKS.exe", "ageSeconds": 7200}));
p
}),
)
// Not responding, because it is deep in the rebuild the bridge asked for.
.sldworks(json!({"responding": false, "hwnd": 4242, "privateMB": 2400, "handles": 9000}));
let lnk = format!("{}/none.lnk", m.dir.path());
let b = std::sync::Arc::new(Bridge::start_in(
TempDir::new("adv-scratch"),
&[
("SWX_LAUNCH_FAKE_DIR", &m.dir.path()),
// No SWX_SOLIDWORKS_SHORTCUT: swx_start takes the plain COM-launch
// path, which the mock makes take real time.
("SWX_MOCK_SLOW_MS", "4000"),
],
));
let _ = lnk;
let worker = {
let b = std::sync::Arc::clone(&b);
std::thread::spawn(move || b.call("swx_start", json!({"reason": "a long operation"})))
};
std::thread::sleep(Duration::from_millis(600));
let st = b.call("swx_status", json!({}));
let busy = st["com"]["busy"] == true;
let hung = st["solidworks"]["hung"].clone();
let _ = worker.join();
assert!(busy, "precondition: the com host is held by the bridge's own verb: {}", s(&st["com"]));
assert!(
hung != json!(true),
"the bridge reports SOLIDWORKS as HUNG because ITS OWN verb is holding the com host and \
the window is not pumping messages while it works. `hung` is supposed to need two \
independent signals; here one cause produced both.\nsolidworks: {}\ncom: {}",
s(&st["solidworks"]),
s(&st["com"])
);
}
/// `host_app_health` takes the FIRST `SLDWORKS.exe` in the census and reports
/// its memory, handles, hwnd and responsiveness as though it were the only one.
/// A zombie left by a crashed session sits in the census beside the live one —
/// and toolhelp order is roughly creation order, so the numbers a caller reads,
/// and the `hung` verdict drawn from them, describe the DEAD process.
#[test]
fn two_solidworks_processes_are_not_silently_collapsed_into_one_verdict() {
let m = Sim::new(
"adv-twosw",
json!({}),
Value::Array({
let mut p = Sim::daemons();
// The wedged leftover, first in the census.
p.push(json!({"pid": 88556, "image": "SLDWORKS.exe", "ageSeconds": 30000}));
// The session the user is actually working in.
p.push(json!({"pid": 99001, "image": "SLDWORKS.exe", "ageSeconds": 120}));
p
}),
)
.sldworks(json!({"responding": true, "hwnd": 4242, "privateMB": 2400, "handles": 9000}));
let b = m.boot(TempDir::new("adv-scratch"), &[]);
let st = b.call("swx_status", json!({}));
let sw = &st["solidworks"];
assert!(
sw.get("processes").is_some() || sw.get("processCount").is_some(),
"two SOLIDWORKS processes are running and swx_status reports exactly one set of numbers \
(pid {}), with nothing to say the other exists. Every verdict downstream — hung, \
exhausted, 'do not launch a second copy' — is about whichever process the census \
happened to list first.\n{}",
sw["processId"],
s(sw)
);
}
// ════════════════════════════════════════════════════════════════════════════
// 5. OWNERSHIP PERSISTENCE
// ════════════════════════════════════════════════════════════════════════════
/// §7 / `src/owned.rs`: *"A missing or unreadable file is an EMPTY registry
/// (which fails closed — the bridge simply owns nothing)."*
///
/// That is the same fail-closed that produced bug #5: 23 open crate documents
/// the bridge had created, all refused. `owned::save_to` is a plain
/// `fs::write` — not a temp-file-and-rename — so a kill, a full disk or a power
/// cut in the middle of a rewrite (which happens on EVERY remember, forget,
/// rename and prune, i.e. hundreds of times in one build) leaves a truncated
/// file. The bridge then starts up owning nothing and says nothing about it,
/// which is indistinguishable from a first run.
#[test]
fn a_registry_file_that_cannot_be_parsed_is_not_silently_an_empty_one() {
let scratch = TempDir::new("adv-scratch");
// A rewrite that was interrupted mid-write.
std::fs::write(
scratch.join("owned-docs.json"),
r#"{"documents":[{"title":"Assem27","session":88556,"path":null,"sessionSt"#,
)
.unwrap();
let b = Bridge::start_in(scratch, &[]);
let st = b.call("swx_status", json!({}));
assert!(st["success"] == true, "{}", s(&st));
let log = b.log().to_lowercase();
assert!(
log.contains("registry") || log.contains("owned-docs"),
"the ownership registry file exists and is corrupt, and the bridge started up silently \
owning nothing — the exact end state of bug #5, reached by a different road. Nothing in \
the log, the status or the response says the file could not be read.\n\
--- server log ---\n{}",
b.log()
);
}
/// `Ctx::prune_scratch` compares stored paths to open paths with `==` on the
/// raw strings, while `Ctx::owns_doc` compares them with `same_path`, which
/// normalises separators and case. Windows hands the same file back in
/// different casings from different APIs (`GetPathName` vs a path the caller
/// passed in), so a document can be pruned out of the registry it would
/// otherwise have matched — and the bridge disowns its own document.
///
/// This is the pure half of the claim: the two comparisons must agree.
#[test]
fn ownership_and_pruning_compare_paths_the_same_way() {
let stored = "C:\\Users\\drew\\Desktop\\crate\\R4.SLDPRT";
let reported = "C:/Users/drew/Desktop/crate/R4.sldprt";
assert!(
swx_bridge::verbs::same_path(stored, reported),
"precondition: owns_doc treats these as the same file"
);
// prune_scratch's test, called through the function it now shares with
// owns_doc (`verbs::doc_matches`) rather than transcribed from it — a copy
// of the comparison is exactly what let the two drift apart.
let prune_keeps = swx_bridge::verbs::doc_matches("R4.SLDPRT", Some(stored), "R4.sldprt", Some(reported));
assert!(
prune_keeps,
"prune_scratch compares paths byte-for-byte while owns_doc compares them with \
same_path(). A document SOLIDWORKS reports under a different casing or separator is \
pruned out of the registry, and the next write to it is refused as 'this bridge did not \
create it' — bug #5's user-visible symptom, from a different cause."
);
}