main
Drew Owens 0.11.2 source 784acbf 5d ago
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."
    );
}