app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
← Commit history
0.6.9 source: launch resilience, honest counts, ownership persistence, drain, quit ladder — 21 adversary passes, 303 tests
51 files changed
+19724−422
diff truncated
.gitignore+1BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+25−1README.md+14−1adom-bridge-solidworks-manifest.json+2−2bridge.json+2−1package.json+1−1page.json+1−7src/abwin.rs+11−1src/comhost/com.rs+1148−91src/comhost/created_store.rs+6−3src/comhost/host.rs+510−8src/comhost/mock.rs+1094−32src/comhost/mod.rs+48−4src/comhost/variant.rs+7src/launch.rs+3793src/launchos.rs+1616src/ledger.rs+6−2src/lib.rs+4src/owned.rs+324src/quit.rs+1149src/scratch.rs+54−2src/verbs.rs+1047−185src/verbs3.rs+1132−43src/writes.rs+69−3tests/adversary.rs+616tests/adversary10.rs+152tests/adversary11.rs+131tests/adversary12.rs+139tests/adversary13.rs+210tests/adversary14.rs+188tests/adversary15.rs+103tests/adversary16.rs+121tests/adversary17.rs+137tests/adversary18.rs+190tests/adversary2.rs+411tests/adversary20.rs+135tests/adversary3.rs+256tests/adversary4.rs+202tests/adversary5.rs+152tests/adversary6.rs+346tests/adversary7.rs+185tests/adversary8.rs+109tests/adversary9.rs+188tests/common/mod.rs+60−14tests/e2e.rs+289−18tests/import_lifecycle.rs+949tests/launch.rs+1739tests/protocol.rs+143−1tests/quit.rs+507.gitignore+1@@ -5,3 +5,4 @@ # filenames in the repo root. Never commit them. C:* /dist/+__pycache__/
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.3.3+0.6.9
Cargo.lock+1−1@@ -107,7 +107,7 @@ dependencies = [ [[package]] name = "swx-bridge"-version = "0.3.3"+version = "0.6.9" dependencies = [ "serde", "serde_json",
Cargo.toml+25−1@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.3.3"+version = "0.6.9" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"@@ -41,7 +41,31 @@ windows = { version = "0.62", features = [ "Win32_System_Variant", # Threading: the com-host watchdog opens the parent process to check it is # still alive, so a hard-killed server leaves no orphan holding a COM object.+ # Also GetProcessTimes (the CREATION TIME the launch kill list is scoped by),+ # GetProcessHandleCount and TerminateProcess — every terminate this bridge+ # makes is by pid, never by image name. "Win32_System_Threading",+ # ToolHelp: the launcher-chain process census (src/launchos.rs).+ "Win32_System_Diagnostics_ToolHelp",+ # ProcessStatus: private memory, for the exhaustion verdict.+ "Win32_System_ProcessStatus",+ # WindowsAndMessaging: IsHungAppWindow + EnumWindows — how this bridge+ # answers "is SOLIDWORKS responding?" WITHOUT COM, which is the only time+ # that question matters.+ "Win32_UI_WindowsAndMessaging",+ # Shell: ShellExecuteExW, the launch route that runs the .lnk with an+ # explicit SW_SHOWNORMAL. SHELLEXECUTEINFOW carries an HKEY, so the Registry+ # feature is what makes the struct exist at all.+ "Win32_UI_Shell",+ "Win32_System_Registry",+ # SystemInformation: GetSystemTimeAsFileTime — the bridge reads its own+ # clock through the SAME call the process creation times come from, so+ # "created at or after our invoke" needs no slack to absorb a conversion.+ "Win32_System_SystemInformation",+ # RestartManager: the LAST quit rung — RmStartSession / RmRegisterResources /+ # RmShutdown, the OS's own polite shutdown. `RmForceShutdown` is never passed+ # (a literal 0 at the call site); see src/quit.rs::win.+ "Win32_System_RestartManager", ] } # winreg: App Paths lookup + HKLM\SOFTWARE\SolidWorks version enumeration. # Safe wrapper over the registry API — cheaper than hand-rolling RegOpenKeyExW
README.md+14−1@@ -347,7 +347,20 @@ not. ## Roadmap -- v0.6.8 (this): **unit-tested is not wired.** `identity_matches_attribution`+- v0.6.9 (this): **two things the suite could not see.** The quit ladder+ re-measures every precondition before every rung, and the launch-claim check is+ part of that sweep — but nothing could demonstrate it, because the simulator's+ attached pid was fixed for a whole run, so the FRONT GATE would always be what+ caught a mismatch and the per-rung call site could have been deleted in silence.+ `mock-com-pid` now hands out a different pid per read, and a test makes the+ session valid at rung (a) and different by rung (b); neutering ONLY the per-rung+ call site fails that test and nothing else. And `tests/wiring.rs` proved "has a+ caller", not "is called where the bridge decides something" — callers must now+ be in a named set of decision-making files, with the simulator deliberately not+ among them, so a guard whose only caller is the mock reads as unwired. The file+ also now states what it does NOT prove: that a call exists is not that it is+ reachable, in the right place, or passed the right arguments. 303 tests.+- v0.6.8: **unit-tested is not wired.** `identity_matches_attribution` shipped in 0.6.7 with ZERO production callers — the fifth guard in this project to do that, and the third about attribution. The commit message said it was wired into `swx_stop`; the edit that would have wired it was in a script that
adom-bridge-solidworks-manifest.json+2−2@@ -3,8 +3,8 @@ "name": "solidworks", "version": "0.6.9", "url": "https://wiki.adom.inc/download/adom/solidworks-bridge/0.6.9/swx-bridge-v0.6.9.zip",- "sha256": "24281bb4c21b5a7c17e168919a002241fbd735447af3c136e44d9fa4fc666603",- "size": 869758,+ "sha256": "9079afeb08e6c1cba89bce48ff2793d44acb4091bc61be0dcb473840048fc9d7",+ "size": 782543, "verbPrefixes": [ "swx_" ],
bridge.json+2−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "solidworks", "displayName": "SOLIDWORKS",- "version": "0.3.3",+ "version": "0.6.9", "description": "Bridge for SOLIDWORKS desktop CAD \u2014 create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.", "author": "Adom Inc.", "license": "MIT",@@ -51,6 +51,7 @@ "swx_activate_document", "swx_close_all_documents", "swx_reset_workspace",+ "swx_close_import_temps", "swx_kill", "swx_window_info", "swx_dismiss_blocking_dialogs",
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "solidworks-bridge",- "version": "0.3.3",+ "version": "0.6.9", "type": "app", "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip — a single native swx-bridge.exe; Adom Bridge loads that.", "tags": [
page.json+1−7@@ -22,11 +22,5 @@ "label": "Change a dimension", "prompt": "Set the extrusion depth on my SolidWorks part to 25mm" }- ],- "hero": {- "type": "billboard",- "auto": true,- "headline": "solidworks-bridge",- "subhead": "On the Adom Wiki."- }+ ] }
src/abwin.rs+11−1@@ -93,7 +93,17 @@ pub fn windows_from(resp: &Value) -> Option<Vec<Value>> { pub fn classify(title: &str) -> (&'static str, bool, bool) { let t = title.to_lowercase(); // (kind, blocking, neverAutoAnswer)- if t.contains("save") && (t.contains("change") || t.contains("?")) {+ // The per-document prompt ("Save changes to Part1?") AND the multi-document+ // one, which on a Connected edition is titled "Save Modified Documents" and+ // offers Save all to This PC / Save all to 3DEXPERIENCE / Don't Save /+ // Cancel. That second title contains neither "change" nor "?", so it used to+ // classify as `unknown` — still never auto-answered, but the quit ladder+ // would have reported it as a generic dialog instead of the one finding that+ // matters: a save prompt at a VERIFIED zero open documents means the+ // document count was wrong (docs/QUIT-INVESTIGATION.md §5.5).+ if t.contains("save")+ && (t.contains("change") || t.contains("?") || t.contains("modified") || t.contains("document"))+ { return ("save_changes", true, true); } if t.contains("recover") {
src/comhost/com.rs+1148−91@@ -58,6 +58,61 @@ fn doc_type_name(id: i32) -> String { } } +/// The `_hint` `swx_import` carries, keyed on the NET document count. An import+/// leaves ONE open, unsaved, bridge-created document — exactly like+/// `swx_new_document` — and it stays open (and costs memory) until it is closed+/// or saved. The 1342-temp leak was callers importing and never closing.+/// Appended when the document handle had to be recovered from `ActiveDoc`.+const RECOVERED_HANDLE_CAVEAT: &str =+ " NOTE: the document handle could not be taken from the import call and was recovered from ActiveDoc. The net count proves a document arrived; if a SECOND import was running concurrently (another caller, or the user importing by hand), the title and path reported here may belong to that one. Confirm with swx_docs before acting on the title.";++fn import_open_hint(net: i32) -> String {+ match net {+ 1 => "Imported. This left ONE new open, unsaved document (like swx_new_document) — it \+ stays open and holds memory until you save it (swx_save_as) or close it \+ (swx_close_doc). Importing repeatedly without closing is what leaks documents; \+ swx_close_import_temps drains any that were left behind."+ .to_string(),+ 0 => "The open-document count did NOT rise, so nothing was actually imported even though \+ a call returned — do not treat this as a new document. Check swx_docs."+ .to_string(),+ n => format!(+ "The open-document count rose by {n}, not 1 — more than the single document an import \+ should add. Check swx_docs; you may have a duplicate open. swx_close_import_temps \+ drains leaked import temporaries."+ ),+ }+}++/// WHY A DOCUMENT WALK STOPPED. Reaching the end of the session and being cut+/// short are different facts, and only one of them means "that is all there was".+#[derive(Clone, Copy, PartialEq, Debug)]+pub enum WalkStop {+ /// `GetNext` returned nothing: the session really ended here.+ ReachedEnd,+ /// We had seen as many documents as we asked for.+ HitMax,+ /// The time box stopped us.+ TimedOut,+ /// `GetNext` FAILED — the iteration was invalidated (a document closed under+ /// us). Not the end of the session, and never to be reported as one.+ IterationFailed,+}++impl WalkStop {+ pub fn complete(self) -> bool {+ self == WalkStop::ReachedEnd+ }+ pub fn name(self) -> &'static str {+ match self {+ WalkStop::ReachedEnd => "reachedEnd",+ WalkStop::HitMax => "hitMax",+ WalkStop::TimedOut => "timedOut",+ WalkStop::IterationFailed => "iterationFailed",+ }+ }+}+ pub struct ComBackend { sw: Option<Disp>, was_launched: bool,@@ -321,7 +376,13 @@ impl ComBackend { fn cmd_status(&mut self) -> Result<Value, HostError> { let mut running = false; let mut version = Value::Null;- let mut open_docs = 0usize;+ // WHICH sldworks.exe COM is attached to. `host_app_health` needs it to+ // report about the right process when two are running.+ let mut process_id = Value::Null;+ // `None` = we could not count. Reported as JSON null, never as 0: the+ // difference between "no documents are open" and "I could not ask"+ // is the difference the whole 0.3.3 enumeration fix was about.+ let mut open_docs: Option<usize> = None; if let Ok(clsid) = Self::clsid() { unsafe { let mut unk = None;@@ -333,7 +394,15 @@ impl ComBackend { .string("RevisionNumber", &[]) .map(Value::from) .unwrap_or(Value::Null);- open_docs = sw.call("GetDocuments", &[]).map(|d| d.as_array().len()).unwrap_or(0);+ // GetDocumentCount is the AUTHORITY here. On 2026-09-03+ // it kept saying 51 while GetDocuments returned an empty+ // VARIANT, so counting the array meant reporting zero+ // open documents with 51 on screen. An unreadable count+ // is null — never a comfortable 0.+ process_id = sw.int("GetProcessID", &[]).map(Value::from).unwrap_or(Value::Null);+ open_docs = sw+ .int("GetDocumentCount", &[])+ .map(|c| c as usize); self.sw = Some(sw); } }@@ -343,10 +412,634 @@ impl ComBackend { "running": running, "version": version, "openDocs": open_docs,+ "processId": process_id, "attached": self.sw.is_some(), })) } + /// Did this call fail because the COM SERVER WENT AWAY? For a quit rung+ /// that is the SUCCESS case, not an error: any rung that works tears the+ /// server down underneath the very call that asked for it.+ ///+ /// ONLY the specific HRESULTs that mean "the object we were calling has gone+ /// away". An earlier version matched any message containing "server" or+ /// "rpc", which would report a quit for a completely unrelated failure with+ /// SOLIDWORKS still on screen.+ fn is_rpc_disconnect(e: &HostError) -> bool {+ let d = e.detail.as_deref().unwrap_or("");+ [+ "0x800706BA", // RPC_S_SERVER_UNAVAILABLE+ "0x80010108", // RPC_E_DISCONNECTED+ "0x800706BE", // RPC_S_CALL_FAILED+ "0x800706BF", // RPC_S_CALL_FAILED_DNE+ "0x80010012", // CO_E_OBJNOTCONNECTED+ "0x80010105", // RPC_E_SERVERFAULT+ ]+ .iter()+ .any(|code| d.contains(code))+ }++ /// `GetDocumentCount` — ONE call, always answers. `None` only when the+ /// call itself failed.+ fn document_count(sw: &Disp) -> Option<i32> {+ sw.int("GetDocumentCount", &[])+ }++ /// `swx_docs`, and it MUST NOT WEDGE on a huge count.+ ///+ /// The authoritative count is read first. Under the cap the full list is+ /// enumerated as before; over it, only the first `DOC_SAMPLE` handles are+ /// turned into `doc_info` and the rest are reported by number — because a+ /// session with 1342 leaked import temps cannot be marshalled in the+ /// budget, and blocking the whole 30s is what got the com host killed.+ fn cmd_docs(&mut self) -> Result<Value, HostError> {+ let sw = self.app(false, true)?;+ let active =+ sw.obj("ActiveDoc", &[])?.map(|d| Self::doc_info(&d)).unwrap_or(Value::Null);+ let count = Self::document_count(&sw);+ // AN UNREADABLE COUNT IS UNKNOWN, AND UNKNOWN MUST NOT ENUMERATE. The+ // first version fell through to the full marshal when the count came back+ // `None` — which is exactly the 30s wedge this cap exists to prevent, and+ // most likely precisely when the machine is under the memory pressure+ // that made the count unreadable in the first place.+ if count.is_none() {+ return Ok(json!({+ "docs": [],+ "count": Value::Null,+ "documentCount": Value::Null,+ "truncated": true,+ "sample": [],+ "active": active,+ "_hint": "SOLIDWORKS would not say how many documents are open, so this bridge did NOT try to enumerate them — an unbounded marshal is how the com host gets killed, and an unreadable count usually means the session is under pressure. `count:null` means UNKNOWN, not zero. Retry; if it keeps failing, swx_close_import_temps drains leaked import temporaries and swx_status reports the process's memory.",+ }));+ }+ if let Some(c) = count {+ if c as usize > super::host::doc_enum_cap() {+ // NEVER `GetDocuments` HERE. Walk exactly DOC_SAMPLE documents,+ // one at a time, time-boxed — the cost of this branch must be a+ // function of the SAMPLE, never of how flooded the session is.+ let (walked, stop) = Self::walk_documents(+ &sw,+ super::host::DOC_SAMPLE,+ std::time::Duration::from_secs(super::host::DOC_SAMPLE_BUDGET_SECS),+ )?;+ let timed_out = stop == WalkStop::TimedOut;+ let sample: Vec<Value> = walked.iter().map(Self::doc_info).collect();+ // COUNTED OVER THE SAMPLE ONLY, which is what the field has always+ // been called. Counting it over all 2144 was 2144 `GetPathName`+ // round trips for a number nobody could act on.+ // Roots resolved ONCE, not per document: on Windows resolving+ // them reads the registry, and this loop is the one place that+ // would have paid for it ten times over.+ let roots = super::host::temp_roots();+ let temps = walked+ .iter()+ .filter(|d| {+ d.string("GetPathName", &[])+ .map(|p| super::host::is_import_temp_under_any(&p, &roots))+ .unwrap_or(false)+ })+ .count();+ return Ok(json!({+ "docs": [],+ "count": c,+ "documentCount": c,+ "truncated": true,+ "sample": sample,+ "active": active,+ "importTempsInSample": temps,+ // ...OUT OF HOW MANY. A bare count against an unstated+ // denominator reads as a count of the session.+ "importTempsInSampleOf": sample.len(),+ "sampleTruncatedByTime": timed_out,+ "sampleWalkStopped": stop.name(),+ "_hint": format!(+ "{c} documents are open — too many to enumerate in the budget, so this is the count plus the first {} as a sample, NOT the whole list (and NOT a timeout). A count this large is almost always leaked STEP/IGES import temporaries in the bridge\'s own temp dir (…\\swx<pid>\\IC~~\\…); run \+ swx_close_import_temps to drain them. It never touches a saved user document.",+ sample.len()+ ),+ }));+ }+ }+ let docs: Vec<Value> = Self::documents(&sw)?.iter().map(Self::doc_info).collect();+ Ok(json!({+ "docs": docs,+ "count": count.map(Value::from).unwrap_or_else(|| Value::from(docs.len())),+ "truncated": false,+ "active": active,+ }))+ }++ /// Drain leaked STEP/IGES-import temporaries — and NOTHING else.+ ///+ /// A document qualifies ONLY by [`super::host::is_import_temp`] on its own+ /// `GetPathName`: a path under the bridge's temp dir with an `IC~~`+ /// conversion segment. No user document can match that, so this is safe in+ /// the DEFAULT posture — the path is the ownership proof, and a stronger one+ /// than the registry (it survives the pid change that made the 1342 leaked+ /// temps un-ownable). `CloseDoc` discards without a save prompt, which is+ /// correct: these were never saved to a real location.+ ///+ /// BOUNDED per call: it closes at most `max` documents or runs for at most+ /// ~40s, then reports `done:false` with `remaining` so the server can call+ /// it again. Closing 1342 documents in one unbounded call is exactly the+ /// kind of thing that gets the com host killed mid-work.+ fn cmd_close_import_temps(&mut self, args: &Value) -> Result<Value, HostError> {+ let sw = self.app(false, true)?;+ let before = Self::document_count(&sw);+ let max = args.get("max").and_then(|v| v.as_u64()).unwrap_or(200).max(1) as usize;+ // THE ANCHOR IS THE WHOLE LOCATION PROOF, so without a resolved `%TEMP%`+ // nothing can be identified as a temp. Say so — `closed:0, done:true`+ // would read as "there was nothing to drain" on a session holding+ // hundreds of them.+ let temp_roots = super::host::temp_roots();+ if temp_roots.is_empty() {+ return Ok(json!({+ "closed": 0,+ "closedDocs": [],+ "documentCountBefore": before,+ "documentCountAfter": before,+ "done": false,+ "batchLimit": max,+ "tempRoot": Value::Null,+ "tempRoots": [],+ }));+ }+ // WALK, DO NOT MARSHAL. `GetDocuments` returns every handle in one call+ // and did not answer within 30s on Drew's 2144-document session — the+ // exact session this verb exists to drain, so the drain would have wedged+ // the com host on the one machine state it was written for.+ //+ // Two phases, deliberately: COLLECT up to `max` victims by walking one+ // document at a time (reading each path as we pass it), then CLOSE them.+ // Closing while walking would invalidate the very iteration we are in the+ // middle of.+ let (walked, walk_stop) = Self::walk_documents(+ &sw,+ // Enough to find `max` temps on a session where they are not all+ // temps, without becoming an unbounded scan.+ max.saturating_mul(4).max(max + 50),+ std::time::Duration::from_secs(super::host::DRAIN_WALK_BUDGET_SECS),+ )?;+ let walk_timed_out = walk_stop == WalkStop::TimedOut;+ let scanned = walked.len() as u64;+ let mut victims: Vec<(String, String)> = Vec::new();+ // EVERY document the walk passed, by (title, path). The title is read for+ // all of them, not just the victims, because it is what the collision+ // check below needs — and one extra call per scanned document inside a+ // bounded window is the price of not closing the wrong file.+ let mut seen: Vec<(String, String)> = Vec::new();+ let mut hit_limit = walk_timed_out;+ let mut elsewhere = 0u64;+ let mut elsewhere_sample: Option<String> = None;+ for doc in &walked {+ let Some(path) = doc.string("GetPathName", &[]) else { continue };+ let title = doc.string("GetTitle", &[]).unwrap_or_default();+ seen.push((title.clone(), path.clone()));+ // NOTE: the scan does NOT stop at `max` — only the victim list does.+ // Stopping the scan would blind the collision check to a document+ // later in the window that shares a victim's title.+ if !super::host::is_import_temp_under_any(&path, &temp_roots) {+ // A NEAR MISS is worth counting: the launcher's own+ // `swx<digits>\IC~~\<file>` tail, under a directory that is not+ // one of the temp roots this process could resolve. Almost+ // certainly a leaked temp whose `%TEMP%` we do not know — and the+ // one thing the report must not do is call that "nothing to+ // drain".+ if super::host::has_import_temp_tail(&path) {+ elsewhere += 1;+ if elsewhere_sample.is_none() {+ elsewhere_sample = Some(path.clone());+ }+ }+ continue;+ }+ if victims.len() >= max {+ hit_limit = true;+ continue;+ }+ victims.push((title, path));+ }++ // ── A TITLE IS NOT AN IDENTITY, and this is the one place that rule was+ // missing. `ISldWorks::CloseDoc` resolves a document BY NAME and does+ // not prompt. The victim is chosen by PATH — the whole location proof+ // — and was then closed by TITLE, so on Drew's live session (leaked+ // temps titled `First.step-1.SLDPRT` beside his SAVED+ // `…\validation\First.step-1.SLDPRT`) SOLIDWORKS would have chosen+ // which one died, and this response would have recorded the temp's+ // path as closed either way.+ //+ // Two guards, because one of them is about a call whose by-path+ // behaviour this bridge has not yet proven on a live machine:+ // 1. CLOSE BY THE PATH THE VICTIM WAS CHOSEN BY.+ // 2. REFUSE any victim whose title another open document shares —+ // and say which paths, so the user can act on it.+ let mut collisions: Vec<Value> = Vec::new();+ let victims: Vec<(String, String)> = victims+ .into_iter()+ .filter(|(title, path)| {+ let others: Vec<String> = seen+ .iter()+ .filter(|(t, p)| t == title && !crate::verbs::same_path(p, path))+ .map(|(_, p)| p.clone())+ .collect();+ if others.is_empty() {+ return true;+ }+ let mut paths = vec![path.clone()];+ paths.extend(others);+ collisions.push(json!({"title": title, "paths": paths}));+ false+ })+ .collect();++ // ...THEN CLOSE, by path, verifying each one.+ let mut closed: Vec<Value> = Vec::new();+ let mut wrong_document_closed = false;+ let mut close_ignored_path = false;+ let mut close_outcome_unknown = false;+ let mut close_failed: Vec<Value> = Vec::new();+ for (title, path) in victims {+ let asked = sw.call("CloseDoc", &[&Var::bstr(&path)]).is_ok();+ // VERIFY, by the same identity we asked with. `GetOpenDocumentByName`+ // is one call.+ let still_open = Self::open_document_by_name(&sw, &path).is_some();+ if asked && still_open {+ // ── TWO VERY DIFFERENT FACTS, and only a COUNT separates them.+ //+ // The documented signature of `CloseDoc` is `CloseDoc(name)` and+ // the help text says only "name of the document to close" — that+ // a FULL PATH is accepted is this bridge's premise, and it has+ // not been proven on a live machine. So:+ //+ // * the count did NOT move -> the call did nothing at all.+ // That is a CAPABILITY problem (the path form is not+ // understood), not a wrong close, and it is not a reason to+ // retry with a title: that is exactly the ambiguity this+ // round removed. Refuse, and say which premise failed.+ // * the count DROPPED while our victim is still open -> some+ // OTHER document was closed. That must be unreachable, and+ // this is the check that proves it rather than hoping.+ //+ // One extra count, only in the anomaly case.+ let expected = before.map(|b| b - closed.len() as i32);+ let now = Self::document_count(&sw);+ let nothing_happened = match (expected, now) {+ (Some(e), Some(n)) => n >= e,+ // ── THE THIRD CASE, and it is not the benign one. An+ // unreadable count cannot distinguish "nothing happened"+ // from "something else was closed" — and the count can+ // become unreadable BECAUSE something large just changed:+ // closing a big assembly is exactly the kind of event that+ // makes GetDocumentCount stop answering. Taking the+ // reassuring arm here would report a capability problem and+ // accuse nobody while a user's document may have gone.+ // Everywhere else in this bridge an unknown refuses AND+ // SAYS UNKNOWN; so does this.+ _ => {+ close_outcome_unknown = true;+ close_failed.push(json!({+ "title": title,+ "path": path,+ "outcome": "undetermined",+ "documentCountBeforeClose": expected,+ "why": "CloseDoc returned success, the document is still open, and the \+ open-document count could not be read afterwards — so it \+ cannot be told whether NOTHING closed or ANOTHER document did. \+ A human must check whether this path, or some other document, \+ closed. The drain stopped here.",+ }));+ break;+ }+ };+ if nothing_happened {+ close_ignored_path = true;+ close_failed.push(json!({+ "title": title,+ "path": path,+ "why": "CloseDoc(path) returned success, the document is still open, and \+ the open-document count did not move — the call did nothing. This \+ bridge will NOT retry by title: a title is not an identity. The \+ drain stopped here.",+ }));+ break;+ }+ wrong_document_closed = true;+ close_failed.push(json!({+ "title": title,+ "path": path,+ "why": "CloseDoc reported success, the document is still open, and the count \+ DROPPED — a DIFFERENT document was closed. The drain stopped here.",+ }));+ break;+ }+ if !asked || still_open {+ close_failed.push(json!({+ "title": title,+ "path": path,+ "why": "SOLIDWORKS declined to close it; nothing else was touched",+ }));+ continue;+ }+ // It is no longer ours to remember, on either veto.+ self.created.retain(|(t, p)| {+ t != &title && p.as_deref() != Some(path.as_str())+ });+ closed.push(json!({"title": title, "path": path}));+ }+ let remaining_after = Self::document_count(&sw);+ // DID THE WALK SEE EVERY OPEN DOCUMENT? The two numbers were already in+ // the response and nothing compared them.+ let scanned_all = before.map(|b| scanned >= b.max(0) as u64).unwrap_or(false);+ // More may remain either because we hit the batch limit, or because the+ // count still shows import-image documents we did not reach this pass.+ let done = !hit_limit;+ Ok(json!({+ "closed": closed.len(),+ "closedDocs": closed,+ "documentCountBefore": before,+ "documentCountAfter": remaining_after,+ // NOT DONE while documents carry the launcher's tail and we closed+ // nothing, nor when the walk did not reach the end of the session,+ // nor when a victim was skipped: "done" would be a claim about the+ // user's session that the evidence does not support.+ // DONE MEANS THE SESSION IS DRAINED, and every one of these is part+ // of that claim. `hit_limit` alone was seeded from the TIME box only,+ // so a walk that stopped at its scan cap (hitMax) or on an+ // invalidated iteration (iterationFailed) reported a finished drain+ // with temporaries still open past the window.+ "done": done+ && walk_stop.complete()+ && scanned_all+ && collisions.is_empty()+ && close_failed.is_empty()+ && !(elsewhere > 0 && closed.is_empty()),+ "batchLimit": max,+ "tempRoot": temp_roots.first(),+ "tempRoots": temp_roots,+ "importTempsElsewhere": elsewhere,+ "importTempsElsewhereSample": elsewhere_sample,+ // HOW FAR THE WALK GOT. `importTempsElsewhere` is a count over the+ // documents SCANNED, not over the session — on a flooded session this+ // verb deliberately looks at a bounded window per call.+ "documentsScanned": scanned,+ "walkTruncatedByTime": walk_timed_out,+ // DID THE WALK REACH THE END OF THE SESSION? A `GetNext` that fails+ // mid-walk (a document closed under us, invalidating the iteration)+ // is not the end of the session and must never be reported as one.+ "walkComplete": walk_stop.complete(),+ // reachedEnd | hitMax | timedOut | iterationFailed. Published because+ // `titleCollisions: []` and `done` cannot be interpreted without it: a+ // collision check over 55 of 60 documents is not the same claim as one+ // over the session.+ "walkStop": walk_stop.name(),+ "titleCollisions": collisions,+ // THE COLLISION CHECK'S REACH. It compares each victim's title against+ // the documents the walk actually PASSED, so a same-titled document+ // outside that window is invisible to it. Harmless for the close (the+ // path is the key) and load-bearing for the report.+ "titleCollisionsCheckedOver": scanned,+ "closeFailed": close_failed,+ "wrongDocumentClosed": wrong_document_closed,+ "closeIgnoredPath": close_ignored_path,+ "closeOutcomeUnknown": close_outcome_unknown,+ }))+ }++ /// ONE RUNG of the quit ladder (docs/QUIT-INVESTIGATION.md §5.3).+ ///+ /// Every rung ENDS with every COM reference released, and rungs (b)-(f)+ /// therefore START from a released state — the com host re-attaches only to+ /// act, never to measure. A `WM_CLOSE` sent while we still hold an+ /// `IDispatch` is the most direct way to manufacture the half-closed+ /// `SLDWORKS.exe -Embedding` orphan this design exists to avoid.+ ///+ /// The result is what the rung DID, not whether it worked: the server+ /// decides that from a process census. A rung that tears the COM server down+ /// underneath us comes back as an RPC disconnect, which is the SUCCESS case+ /// and must not be reported as an error.+ /// IS THIS STILL THE WINDOW WE MEASURED? Asked immediately before rung (c)+ /// or (d) sends anything, because the handle was captured before rung (a) ran+ /// and rung (a)'s whole purpose is to make that window go away.+ ///+ /// Both facts have to answer, and an unreadable one is a NO: the owning pid+ /// (`GetWindowThreadProcessId`) and that pid's creation time (the census). A+ /// pid alone would pass a recycled pid.+ #[cfg(windows)]+ fn hwnd_still_ours(hwnd: i64, pid: u32, created: Option<u64>, detail: &mut Vec<String>) -> bool {+ let Some(created) = created else {+ detail.push("no creation time was supplied for the target process".into());+ return false;+ };+ let actual = crate::quit::win::window_pid(hwnd);+ let actual_created = crate::launchos::os()+ .processes()+ .ok()+ .and_then(|c| actual.and_then(|p| c.iter().find(|r| r.pid == p).and_then(|r| r.created_at)));+ let id = crate::quit::Identity { pid, created_at: created, main_hwnd: Some(hwnd) };+ let ok = crate::quit::hwnd_still_owned(actual, actual_created, &id);+ detail.push(format!(+ "hwnd {hwnd} re-verified before sending: owner pid {actual:?} (created \+ {actual_created:?}) vs expected {pid} (created {created}) -> {}",+ if ok { "same window" } else { "NOT ours, nothing sent" }+ ));+ ok+ }++ #[cfg(not(windows))]+ fn hwnd_still_ours(_hwnd: i64, _pid: u32, _created: Option<u64>, detail: &mut Vec<String>) -> bool {+ detail.push("window ownership cannot be re-verified off Windows".into());+ false+ }++ fn cmd_quit_rung(&mut self, args: &Value) -> Result<Value, HostError> {+ let rung = args.get("rung").and_then(|v| v.as_str()).unwrap_or("");+ if !crate::quit::is_rung(rung) {+ return Err(HostError::coded(+ "unknown_rung",+ format!("'{rung}' is not a rung of the quit ladder"),+ ));+ }+ let hwnd = args.get("mainHwnd").and_then(|v| v.as_i64()).unwrap_or(0);+ let pid = args.get("processId").and_then(|v| v.as_u64()).unwrap_or(0) as u32;+ let created = args.get("processCreatedAt").and_then(|v| v.as_u64());+ let mut detail: Vec<String> = Vec::new();+ let mut result = "declined";++ match rung {+ "a" => {+ let sw = self.app(false, true)?;+ // UserControl is THE flag that decides whether SOLIDWORKS+ // outlives its automation client. Remember it so a decline can+ // be undone.+ let was = sw.flag("UserControl", &[]);+ detail.push(format!("UserControl before = {was:?}"));+ sw.try_put("UserControl", &Var::boolean(false));+ detail.push("UserControl=false attempted".into());+ let exit = sw.call("ExitApp", &[]);+ match &exit {+ Ok(_) => detail.push("ExitApp returned".into()),+ Err(e) => {+ // The disconnect HRESULTs mean the server went away —+ // which IS the success case for this call.+ if Self::is_rpc_disconnect(e) {+ detail.push(format!("ExitApp: RPC disconnect ({}) — the COM server went away", e.message));+ result = "exited";+ } else {+ detail.push(format!("ExitApp failed: {}", e.message));+ }+ }+ }+ // RELEASE EVERYTHING, unconditionally, before returning.+ drop(sw);+ self.sw = None;+ detail.push("released every COM reference".into());+ // Restore UserControl only if it plainly declined AND we can+ // still reach the app — never leave the user's session with a+ // flag we changed.+ if result != "exited" {+ if let (Some(prev), Ok(sw2)) = (was, self.app(false, true)) {+ if prev {+ sw2.try_put("UserControl", &Var::boolean(true));+ detail.push("UserControl restored to true".into());+ }+ drop(sw2);+ self.sw = None;+ }+ }+ }+ "b" => {+ let sw = self.app(false, true)?;+ let called = sw.call(+ "RunCommand",+ &[&Var::i4(crate::quit::SW_COMMANDS_APP_EXIT), &Var::bstr("")],+ );+ match &called {+ Ok(_) => detail.push(format!("RunCommand({}) returned", crate::quit::SW_COMMANDS_APP_EXIT)),+ Err(e) => {+ if Self::is_rpc_disconnect(e) {+ detail.push("RunCommand: RPC disconnect — the COM server went away".into());+ result = "exited";+ } else {+ detail.push(format!("RunCommand failed: {}", e.message));+ }+ }+ }+ drop(sw);+ self.sw = None;+ detail.push("released every COM reference".into());+ }+ "c" | "d" => {+ // MESSAGE RUNGS: no COM at all, and they must not run while we+ // hold a reference.+ self.sw = None;+ if hwnd == 0 {+ detail.push("no main-frame hwnd was supplied".into());+ result = "skipped";+ } else if !Self::hwnd_still_ours(hwnd, pid, created, &mut detail) {+ // NOT OUR WINDOW ANY MORE. Nothing is sent — a WM_CLOSE to a+ // recycled handle is a close nobody asked for.+ result = "skipped";+ } else {+ #[cfg(windows)]+ {+ let (sent, blocked, note) = if rung == "c" {+ crate::quit::win::send_wm_close(hwnd)+ } else {+ crate::quit::win::post_sc_close(hwnd)+ };+ detail.push(note);+ if blocked {+ result = "uipiBlocked";+ } else if !sent {+ result = "declined";+ }+ }+ #[cfg(not(windows))]+ {+ detail.push("window messages are Windows-only".into());+ result = "skipped";+ }+ }+ }+ "e" => {+ // The in-process exit. ONLY the shipped, content-hashed macro.+ match crate::quit::exit_macro_path() {+ None => {+ detail.push(+ "the exit macro is not shipped in resources/macros/ (a .swp is a \+ binary VBA project and cannot be authored without SOLIDWORKS), so \+ this rung is unavailable"+ .into(),+ );+ result = "skipped";+ }+ Some(path) => {+ let sw = self.app(false, true)?;+ let mut err_code = 0i32;+ let called = sw.call(+ "RunMacro2",+ &[+ &Var::bstr(&path),+ &Var::bstr("main"),+ &Var::bstr("main"),+ &Var::i4(0),+ &Var::byref_i4(&mut err_code as *mut i32),+ ],+ );+ match &called {+ Ok(_) => detail.push(format!("RunMacro2({path}) returned, err={err_code}")),+ Err(e) => {+ if Self::is_rpc_disconnect(e) {+ detail.push("RunMacro2: RPC disconnect — the COM server went away".into());+ result = "exited";+ } else {+ detail.push(format!("RunMacro2 failed: {}", e.message));+ }+ }+ }+ drop(sw);+ self.sw = None;+ detail.push("released every COM reference".into());+ }+ }+ }+ "f" => {+ self.sw = None;+ match created {+ None => {+ detail.push("no process creation time — the Restart Manager needs the pid AND its creation time".into());+ result = "skipped";+ }+ Some(ct) => {+ #[cfg(windows)]+ {+ let (ok, note) = crate::quit::win::restart_manager_shutdown(pid, ct);+ detail.push(note);+ if !ok {+ result = "declined";+ }+ }+ #[cfg(not(windows))]+ {+ let _ = (pid, ct);+ detail.push("the Restart Manager is Windows-only".into());+ result = "skipped";+ }+ }+ }+ }+ _ => unreachable!("guarded by is_rung"),+ }+ Ok(json!({+ "rung": rung,+ "result": result,+ "detail": detail.join("; "),+ "comReferencesOutstanding": usize::from(self.sw.is_some()),+ }))+ }+ fn cmd_open(&mut self, args: &Value) -> Result<Value, HostError> { let path = args .get("path")@@ -626,7 +1319,11 @@ impl ComBackend { mgr.try_put("MakeInterferingPartsTransparent", &Var::boolean(false)); let found = mgr.call("GetInterferences", &[])?;- let count = mgr.int("GetInterferenceCount", &[]).unwrap_or(0);+ // `null`, not 0. "Zero interferences" is a CLEARANCE REPORT somebody+ // builds a physical thing from; manufacturing an all-clear out of a+ // count SOLIDWORKS declined to give is the most expensive empty-value+ // collapse in this file.+ let count = mgr.int("GetInterferenceCount", &[]); let mut out = Vec::new(); for item in found.as_array() { let item = match item.as_dispatch().map(Disp) {@@ -646,7 +1343,26 @@ impl ComBackend { .unwrap_or_default(); out.push(json!({"components": names, "volumeM3": item.float("Volume", &[])})); }- Ok(json!({"doc": Self::doc_info(&doc), "count": count, "interferences": out}))+ // A count that disagrees with the list is not an answer either.+ let listed = out.len();+ if let Some(c) = count {+ if c as usize != listed && listed == 0 {+ return Err(HostError::coded(+ "interference_enumeration_failed",+ format!(+ "SOLIDWORKS reports {c} interference(s) but returned none of them. This \+ is NOT a clean result — nothing was concluded from it; retry the check."+ ),+ ));+ }+ }+ Ok(json!({+ "doc": Self::doc_info(&doc),+ "count": count,+ "countKnown": count.is_some(),+ "interferences": out,+ "listed": listed,+ })) } fn cmd_rebuild_check(&mut self, args: &Value) -> Result<Value, HostError> {@@ -930,15 +1646,32 @@ impl ComBackend { /// from the ENVIRONMENT it inherited rather than from anything the server /// sent. So the server cannot fabricate the escape: `allowUserWrites` in /// the request is necessary but never sufficient.- /// Run a `.lnk` through the shell, detached.+ /// Run the Platform's `.lnk` — by ONE named route.+ ///+ /// THE MEASUREMENT THIS EXISTS FOR (2026-09-04, Drew's machine): five of+ /// five launches through `cmdStart` produced a `SWXDesktopLauncher` log that+ /// stopped at `SecurityContext:` and never logged `Run Begin`. Every launch+ /// of the same shortcut with the same arguments from an INTERACTIVE context+ /// reached `Run Begin` ~2s later and started SOLIDWORKS. The suspected+ /// difference is the startup context — `DETACHED_PROCESS |+ /// CREATE_NO_WINDOW` and the show state that rides with it — so the three+ /// routes above `cmdStart` all hand the link to the SHELL with a normal show+ /// state, by three independent mechanisms. The bridge does not have to know+ /// which one is right: `crate::launch` tries them in order and keeps the one+ /// that works. ///- /// `raw_arg` rather than `arg`: `cmd.exe` does its own command-line parsing,- /// and Rust's argument escaper is written for the CRT convention, so a path- /// with a space in it (which every one of these has) arrives at `start`- /// mis-split. DETACHED_PROCESS | CREATE_NO_WINDOW so no console flashes on- /// the user's desktop — this bridge never puts a window on screen.+ /// `raw_arg` on the `cmd` route rather than `arg`: `cmd.exe` does its own+ /// command-line parsing, and Rust's escaper is written for the CRT+ /// convention, so a path with a space in it (which every one of these has)+ /// arrives at `start` mis-split.+ /// What an invoke was able to say about what it started.+ ///+ /// `Some((pid, created_at))` is AUTHORITY TO TERMINATE, and the only thing+ /// that is. `None` means the route ran but cannot say — which is a first-+ /// class answer: the bridge then never adopts a chain from that route and+ /// never kills anything on its behalf. See `launch::AttributionRule`. #[cfg(windows)]- fn spawn_shortcut(shortcut: &str) -> Result<(), HostError> {+ fn spawn_shortcut(shortcut: &str, route: &str) -> Result<Option<(u32, Option<u64>)>, HostError> { use std::os::windows::process::CommandExt; const DETACHED_PROCESS: u32 = 0x0000_0008; const CREATE_NO_WINDOW: u32 = 0x0800_0000;@@ -948,17 +1681,55 @@ impl ComBackend { "the shortcut path contains a quote character and will not be run", )); }- std::process::Command::new("cmd.exe")- .raw_arg(format!("/c start \"\" \"{shortcut}\""))- .creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)- .spawn()- .map(|_| ())- .map_err(|e| {- HostError::coded(- "host_app_launch_blocked",- format!("could not invoke the shortcut: {e}"),- )- })+ if !crate::launch::is_route(route) {+ return Err(HostError::coded(+ "host_app_launch_blocked",+ format!("'{route}' is not a launch route this bridge knows"),+ ));+ }+ let blocked = |e: std::io::Error| {+ HostError::coded(+ "host_app_launch_blocked",+ format!("could not invoke the shortcut via '{route}': {e}"),+ )+ };+ match route {+ // ShellExecuteExW with an EXPLICIT SW_SHOWNORMAL, and+ // SEE_MASK_NOCLOSEPROCESS so the shell hands back the process it+ // created. Runs here, on the com host's thread, because the shell's+ // link resolver needs an STA apartment and this is the only thread+ // in the bridge that has one.+ "shellExecute" => crate::launchos::shell_execute_lnk(shortcut).map_err(|e| {+ HostError::coded("host_app_launch_blocked", format!("route 'shellExecute': {e}"))+ }),+ // Start-Process -PassThru PRINTS the pid, and the PowerShell child+ // is waited on, so a policy block or a bad path is reported as+ // "this route started nothing" instead of being mistaken later for+ // a stall in a launcher that never ran.+ "powershell" => crate::launchos::powershell_start_lnk(shortcut).map_err(|e| {+ HostError::coded("host_app_launch_blocked", format!("route 'powershell': {e}"))+ }),+ // NOT ATTRIBUTABLE. The shell forwards the link to an existing+ // explorer process and returns nothing, so there is no handle and+ // no pid — and therefore, later, no kill.+ "explorer" => std::process::Command::new("explorer.exe")+ .arg(shortcut)+ .creation_flags(DETACHED_PROCESS)+ .spawn()+ .map(|_| None)+ .map_err(blocked),+ // NOT ATTRIBUTABLE, and the route that stalled five times out of+ // five. Kept last rather than deleted: it is the one route proven+ // to at least START the chain on this machine. `raw_arg` because+ // cmd.exe does its own parsing and Rust's escaper is written for+ // the CRT convention, so a path with a space arrives mis-split.+ _ => std::process::Command::new("cmd.exe")+ .raw_arg(format!("/c start \"\" \"{shortcut}\""))+ .creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)+ .spawn()+ .map(|_| None)+ .map_err(blocked),+ } } fn authorize(@@ -981,9 +1752,22 @@ impl ComBackend { destination: Option<&str>, always: bool, ) -> Result<(), HostError> {- let clobbers = destination- .map(|d| !d.is_empty() && std::path::Path::new(d).exists())- .unwrap_or(false);+ // THE SAME THREE-STATE RULE THE SERVER USES, from the same function —+ // this is the INDEPENDENT veto, and two vetoes that disagree about what+ // "the destination is free" means are one veto with extra steps. An+ // unverifiable destination refuses here too, and the com host is the layer+ // most likely to hit it: it is a child of ab, and it may not hold the+ // user's network token at all.+ let clobbers = match crate::writes::destination_state(destination) {+ crate::writes::Destination::Occupied => true,+ crate::writes::Destination::Free => false,+ crate::writes::Destination::Unverifiable(io) => {+ return Err(HostError::coded(+ "destination_unverifiable",+ crate::writes::unverifiable_message(destination.unwrap_or(""), &io),+ ));+ }+ }; let foreign = doc.map(|d| !self.host_owns(d)).unwrap_or(false); if !clobbers && !foreign && !always { return Ok(());@@ -1155,12 +1939,38 @@ impl Backend for ComBackend { "version": sw.string("RevisionNumber", &[]), })); }- Self::spawn_shortcut(shortcut)?;+ // The SERVER chooses the route (it owns the state machine and+ // the memory of what worked). An absent route means the caller+ // is an older server: fall back to the first route rather than+ // refusing, so a version skew cannot make the bridge unable to+ // start SOLIDWORKS at all.+ let route = args+ .get("route")+ .and_then(|v| v.as_str())+ .filter(|r| crate::launch::is_route(r))+ .unwrap_or(crate::launch::ROUTES[0]);+ let root = Self::spawn_shortcut(shortcut, route)?; // One brief look, so a warm start reports attached immediately // instead of making the caller poll for something already true. let attached = self.app(false, true).is_ok(); Ok(json!({ "invoked": true,+ "route": route,+ // THE ATTRIBUTION, or an honest absence of one.+ "rootPid": root.map(|(p, _)| p),+ "rootCreatedAt": root.and_then(|(_, c)| c),+ "attributionSource": match route {+ "shellExecute" => "shellExecuteHandle",+ "powershell" => "startProcessPassThru",+ _ => "none",+ },+ "attributionNote": if root.is_some() {+ Value::Null+ } else {+ Value::from(format!(+ "route '{route}' cannot say what it started, so this bridge will not terminate anything on its behalf"+ ))+ }, "attached": attached, "shortcut": shortcut, "version": if attached {@@ -1226,14 +2036,7 @@ impl Backend for ComBackend { })) } - "docs" => {- let sw = self.app(false, true)?;- let docs: Vec<Value> =- Self::documents(&sw)?.iter().map(Self::doc_info).collect();- let active =- sw.obj("ActiveDoc", &[])?.map(|d| Self::doc_info(&d)).unwrap_or(Value::Null);- Ok(json!({"docs": docs, "active": active}))- }+ "docs" => self.cmd_docs(), "open" => self.cmd_open(args), @@ -1254,7 +2057,14 @@ impl Backend for ComBackend { ], )?; }- sw.call("CloseDoc", &[&Var::bstr(&title)])?;+ // BY PATH when the document has one — a title is not an+ // identity, and `CloseDoc` resolves by name without prompting.+ // `resolve` has already refused an ambiguous title+ // (`ambiguous_target`), so this is the second guard rather than+ // the only one.+ let path = doc.string("GetPathName", &[]).unwrap_or_default();+ let key = if path.trim().is_empty() { title.clone() } else { path.clone() };+ sw.call("CloseDoc", &[&Var::bstr(&key)])?; // A closed document is no longer ours: the server prunes its // registry the same way, and the two vetoes must not disagree // about who owns what.@@ -1346,6 +2156,32 @@ impl Backend for ComBackend { "set_parameter" => self.cmd_set_parameter(args), "inspect_bodies" => self.cmd_inspect_bodies(args), "import" => self.cmd_import(args),++ "close_import_temps" => self.cmd_close_import_temps(args),++ // THE QUIT LADDER. One rung per call; the SERVER orchestrates and+ // measures, because the measurement must not be a COM call.+ "quit_rung" => self.cmd_quit_rung(args),++ // Drop every IDispatch this process holds. Called before any rung+ // and before any measurement — see `crate::quit`'s refcount note.+ "release_com" => {+ let had = self.sw.is_some();+ self.sw = None;+ Ok(json!({+ "released": had,+ // `Disp` is a refcounted IDispatch (Clone = AddRef, Drop =+ // Release) and `self.sw` is the only reference this host+ // caches, so with it dropped and no local in scope our+ // outstanding count is zero.+ "outstanding": 0,+ "note": "every IDispatch this com host held has been released",+ }))+ }++ // Read-only: how many references we are holding right now. The+ // ladder asserts zero before it measures.+ "com_refs" => Ok(json!({"outstanding": usize::from(self.sw.is_some())})), "save_as" => self.cmd_save_as(args), "run_script" => self.cmd_run_script(args), "add_component" => self.cmd_add_component(args),@@ -1497,8 +2333,95 @@ impl ComBackend { /// did not create it", for an assembly created seconds earlier. /// /// An empty list is only believed when the count agrees it is empty.+ /// WALK the open documents one at a time, stopping at `max` or at `budget`.+ ///+ /// `GetDocuments` marshals EVERY handle in one call, and that is what killed+ /// the com host on Drew's 2144-document session: `GetDocumentCount` answered+ /// in 0.7 s, `GetDocuments` did not answer in 30 s, and the code above the cap+ /// then called `GetPathName` on all 2144 of them — 2144 more round trips — to+ /// fill in a field named `importTempsInSample`. The mock's `GetDocuments` was+ /// instant, so the whole degrade path was mock-proven and false live.+ ///+ /// `GetFirstDocument()` / `GetNext()` is the per-document road, and it was+ /// measured live on the 1342-document session: `$sw.GetFirstDocument()+ /// .GetNext().GetTitle()` answered immediately. So the cost is now O(what we+ /// actually look at) instead of O(everything open).+ ///+ /// Returns the documents visited and WHY the walk stopped — because "we+ /// stopped early" must never be reported as "that is all there was".+ ///+ /// The reason matters: a `GetNext` that fails mid-walk (the user closed+ /// something, and the iteration is invalidated) used to be indistinguishable+ /// from reaching the end, so a walk cut short after four of 2144 documents+ /// was reported as a completed drain.+ fn walk_documents(+ sw: &Disp,+ max: usize,+ budget: std::time::Duration,+ ) -> Result<(Vec<Disp>, WalkStop), HostError> {+ let deadline = std::time::Instant::now() + budget;+ let mut out: Vec<Disp> = Vec::new();+ let mut cur = sw.obj("GetFirstDocument", &[])?;+ let mut stop = WalkStop::ReachedEnd;+ while let Some(doc) = cur {+ if out.len() >= max {+ stop = WalkStop::HitMax;+ break;+ }+ if std::time::Instant::now() >= deadline {+ stop = WalkStop::TimedOut;+ break;+ }+ let next = doc.obj("GetNext", &[]);+ let failed = next.is_err();+ let next = next.ok().flatten();+ out.push(doc);+ if failed {+ // The ITERATION broke, which is not the same as the session+ // ending. Say so.+ stop = WalkStop::IterationFailed;+ break;+ }+ cur = next;+ }+ Ok((out, stop))+ }++ /// Is a document with this exact name still open? `GetOpenDocumentByName`+ /// takes the full path for a document that has one, and it is ONE call — the+ /// cheap, exact way to verify a close did what it was asked to.+ fn open_document_by_name(sw: &Disp, name: &str) -> Option<Disp> {+ sw.obj("GetOpenDocumentByName", &[&Var::bstr(name)]).ok().flatten()+ }+ fn documents(sw: &Disp) -> Result<Vec<Disp>, HostError> {- let mut count = -1i32;+ // NEVER MARSHAL A FLOODED SESSION. Above the cap this call cannot be+ // completed in any budget a caller would wait for, and its failure mode is+ // a 30s wedge and a killed com host — so it refuses, in ONE place, for+ // every caller that would otherwise have discovered it the hard way.+ // Callers that can work from a count and a sample do that instead.+ if let Some(c) = Self::document_count(sw) {+ if c as usize > super::host::doc_enum_cap() {+ return Err(HostError::coded(+ "docs_enumeration_capped",+ format!(+ "{c} documents are open — more than this bridge will marshal in one call \+ ({}). GetDocuments returns every handle at once and did not answer within \+ 30s on a 2144-document session, which kills the com host; the count and a \+ sample come back from swx_docs instead. This many open documents is \+ almost always leaked STEP/IGES import temporaries: run \+ swx_close_import_temps to drain them.",+ super::host::doc_enum_cap()+ ),+ ));+ }+ }+ + // THE COUNT IS AN Option, and that is the whole point of this round's+ // fix. It used to be `unwrap_or(-1)`, and `-1 <= 0` made an UNREADABLE+ // count believe an empty enumeration — the exact road to bug #5 that+ // this function was written to close, left open inside the closure.+ let mut count: Option<i32> = None; for attempt in 0..4u64 { let docs: Vec<Disp> = sw .call("GetDocuments", &[])?@@ -1506,19 +2429,30 @@ impl ComBackend { .iter() .filter_map(|v| v.as_dispatch().map(Disp)) .collect();- count = sw.int("GetDocumentCount", &[]).unwrap_or(-1);- if !docs.is_empty() || count <= 0 {+ if !docs.is_empty() {+ return Ok(docs);+ }+ count = sw.int("GetDocumentCount", &[]);+ // An empty list is believed ONLY when the count AGREES it is empty.+ // A count we could not read agrees with nothing.+ if count == Some(0) { return Ok(docs); } std::thread::sleep(std::time::Duration::from_millis(150 * (attempt + 1))); } Err(HostError::coded( "docs_enumeration_failed",- format!(- "SOLIDWORKS reports {count} open document(s) but returned none — the enumeration \- failed, most likely because it was busy. NOTHING was concluded from this read; \- retry the call."- ),+ match count {+ Some(c) => format!(+ "SOLIDWORKS reports {c} open document(s) but returned none — the enumeration \+ failed, most likely because it was busy. NOTHING was concluded from this \+ read; retry the call."+ ),+ None => "SOLIDWORKS returned no documents AND would not say how many are open, so \+ this bridge cannot tell an empty session from a failed enumeration. \+ NOTHING was concluded from this read; retry the call."+ .to_string(),+ }, )) } @@ -1526,13 +2460,23 @@ impl ComBackend { /// only report what was open before and what survived. fn cmd_close_all_documents(&mut self, args: &Value) -> Result<Value, HostError> { let sw = self.app(false, true)?;- let before: Vec<Value> = sw- .call("GetDocuments", &[])?- .as_array()- .iter()- .filter_map(|v| v.as_dispatch().map(Disp))- .map(|d| Self::doc_info(&d))- .collect();+ // ABOVE THE CAP the lists are not affordable — `GetDocuments` on a+ // 2144-document session does not answer in 30s — but the ACT is one COM+ // call and the user may well want it. So the flooded session gets counts+ // instead of lists, and says so; it never gets a list that is silently a+ // sample, and it never gets a wedge.+ let count_before = Self::document_count(&sw);+ let capped = count_before+ .map(|c| c as usize > super::host::doc_enum_cap())+ .unwrap_or(false);+ // `documents()`, so a contradicted empty enumeration cannot make a+ // DESTRUCTIVE verb report that it closed nothing when it closed+ // everything the user had open.+ let before: Vec<Value> = if capped {+ Vec::new()+ } else {+ Self::documents(&sw)?.iter().map(Self::doc_info).collect()+ }; let include_unsaved = args.get("closeUnsaved").and_then(|v| v.as_bool()).unwrap_or(false); let would_prompt: Vec<Value> = before@@ -1544,18 +2488,26 @@ impl ComBackend { .call("CloseAllDocuments", &[&Var::boolean(include_unsaved)])? .as_bool() .unwrap_or(false);- let remaining: Vec<Value> = sw- .call("GetDocuments", &[])?- .as_array()- .iter()- .filter_map(|v| v.as_dispatch().map(Disp))- .map(|d| Self::doc_info(&d))- .collect();+ // Empty here is EXPECTED and believable — `documents()` accepts an+ // empty list the count agrees with, and only refuses one it contradicts.+ let count_after = Self::document_count(&sw);+ let still_capped =+ count_after.map(|c| c as usize > super::host::doc_enum_cap()).unwrap_or(false);+ let remaining: Option<Vec<Value>> = if still_capped {+ None+ } else {+ Some(Self::documents(&sw)?.iter().map(Self::doc_info).collect())+ }; Ok(json!({ "closedAll": ok,- "closed": before,- "remaining": remaining,- "wouldPrompt": would_prompt,+ // NULL, NOT AN EMPTY LIST, when the session was too flooded to+ // enumerate. An empty list here would read as "nothing was open".+ "closed": if capped { Value::Null } else { json!(before) },+ "remaining": remaining.as_ref().map(|r| json!(r)).unwrap_or(Value::Null),+ "documentCountBefore": count_before,+ "documentCountAfter": count_after,+ "listsOmittedTooManyDocuments": capped || still_capped,+ "wouldPrompt": if capped { Value::Null } else { json!(would_prompt) }, "closeUnsaved": include_unsaved, })) }@@ -1986,14 +2938,18 @@ impl ComBackend { // FAIL CLOSED. If we cannot count the open documents we cannot tell // whether LoadFile4 already imported the file, and guessing wrong runs // the import a second time — two documents, one of them untracked.- let before = match sw.call("GetDocuments", &[]) {- Ok(d) => d.as_array().len(),- Err(e) => {+ // The COUNT, not the enumeration: `GetDocumentCount` is one call and+ // answers even in a session holding hundreds of leaked temps, whereas+ // marshalling that many document handles is what times out. Import must+ // not become impossible just because earlier imports were not drained.+ let before = match Self::document_count(&sw) {+ Some(c) => c,+ None => { return Err(HostError::detailed(- "cannot import: SOLIDWORKS would not report its open documents, so there is \- no way to tell afterwards whether the import already happened"+ "cannot import: SOLIDWORKS would not report how many documents are open, so \+ there is no way to tell afterwards whether the import already happened" .to_string(),- e.message,+ "GetDocumentCount returned nothing".to_string(), )) } };@@ -2008,37 +2964,69 @@ impl ComBackend { ); let load4_err = load4.as_ref().err().map(|e| e.message.clone()); let imported = load4.ok().and_then(|v| v.as_dispatch()).map(Disp);- let after = match sw.call("GetDocuments", &[]) {- Ok(d) => d.as_array().len(),- Err(e) => {- return Err(HostError::detailed(- "the import may or may not have completed: SOLIDWORKS stopped reporting its \- open documents. Check swx_docs before retrying — retrying blind could import \- the file twice."+ // OWN IT THE MOMENT IT EXISTS. Registration used to happen in the+ // dispatcher, on the way out — so any failure AFTER the document was+ // created (a count that stopped answering, a timeout, a kill) left a+ // document open that this host would then refuse to close, because its+ // own record said it had never made one. `register_created` persists, so+ // this survives a respawn as well as a failure.+ let born = imported.as_ref().map(Self::doc_info);+ if let Some(doc) = born.clone() {+ self.register_created(&doc);+ }+ let after = match Self::document_count(&sw) {+ Some(c) => c,+ None => {+ let mut e = HostError::detailed(+ "the import may or may not have completed: SOLIDWORKS stopped reporting how \+ many documents are open. Check swx_docs before retrying — retrying blind \+ could import the file twice." .to_string(),- e.message,- ))+ "GetDocumentCount returned nothing".to_string(),+ );+ e.created = born;+ return Err(e); } };+ // THE NET-COUNT ASSERTION. A clean import raises the open-document count+ // by exactly one; anything else is reported so a caller is never told+ // "imported" for a no-op or a double.+ let net = after - before; if let Some(doc) = imported { return Ok(json!({ "imported": Self::doc_info(&doc), "via": "LoadFile4", "errors": errors,- "documentsOpened": after.saturating_sub(before),+ "documentsOpened": net,+ "netCountOk": net == 1,+ "documentCount": after,+ "_hint": import_open_hint(net), })); } if after > before { // It DID import; we just could not take the handle. Use the active // document rather than importing it again.+ //+ // CAVEAT, and it is reported rather than silently assumed: under a+ // CONCURRENT second import (another caller, or a user importing by+ // hand at the same moment) `ActiveDoc` is whichever document became+ // active last, which may be the OTHER import. The net count still+ // proves that A document arrived; the title/path we report may+ // belong to the other one. The bridge serialises its own COM calls,+ // so this needs a second actor outside the bridge — and the honest+ // move is to say so in the hint, not to pretend the handle was ours. let doc = sw .obj("ActiveDoc", &[])? .ok_or_else(|| HostError::msg("import added a document but none became active"))?; return Ok(json!({ "imported": Self::doc_info(&doc), "via": "LoadFile4 (handle recovered from ActiveDoc)",+ "titleMayBeAnotherConcurrentImport": true, "errors": errors,- "documentsOpened": after - before,+ "documentsOpened": net,+ "netCountOk": net == 1,+ "documentCount": after,+ "_hint": format!("{}{}", import_open_hint(net), RECOVERED_HANDLE_CAVEAT), })); } let ok = sw.call("LoadFile2", &[&Var::bstr(path), &Var::bstr("")])?.as_bool().unwrap_or(false);@@ -2145,13 +3133,22 @@ impl ComBackend { })) } };- let docs: Vec<Value> = sw- .call("GetDocuments", &[])?- .as_array()- .iter()- .filter_map(|v| v.as_dispatch().map(Disp))- .map(|d| Self::doc_info(&d))- .collect();+ // DEGRADE ABOVE THE CAP, exactly as `cmd_docs` does — this is called by+ // `ownership_context` on nearly every verb, so a 1342-document session+ // would wedge the whole bridge here, not just `swx_docs`. Over the cap+ // we report the COUNT and set `documentsTruncated`, and the SERVER must+ // then NOT prune its ownership registry against an empty list (an empty+ // list here means "too many to enumerate", not "nothing is open").+ let count = Self::document_count(&sw);+ // `None` (unreadable) is UNKNOWN-and-truncated, never "small enough to+ // enumerate": the server must not prune ownership against it and must not+ // pay the unbounded marshal. See cmd_docs.+ let truncated = count.map(|c| c as usize > super::host::doc_enum_cap()).unwrap_or(true);+ let docs: Vec<Value> = if truncated {+ Vec::new()+ } else {+ Self::documents(&sw)?.iter().map(Self::doc_info).collect()+ }; let active = sw.obj("ActiveDoc", &[])?.map(|d| Self::doc_info(&d)); let workspace = active .as_ref()@@ -2168,7 +3165,8 @@ impl ComBackend { "activeDocument": active.clone().map(|a| a.get("title").cloned().unwrap_or(Value::Null)).unwrap_or(Value::Null), "activeDoc": active, "documents": docs,- "documentCount": sw.int("GetDocumentCount", &[]),+ "documentsTruncated": truncated,+ "documentCount": count, "unsavedDocuments": unsaved, "workspace": workspace, "busy": sw.flag("CommandInProgress", &[]),@@ -2195,7 +3193,14 @@ impl ComBackend { /// half to the server's window census. fn cmd_check_recovery(&mut self) -> Result<Value, HostError> { let sw = self.app(false, true)?;- let docs = sw.call("GetDocuments", &[])?.as_array().len();+ // The COUNT, for the same reason cmd_status uses it: a contradicted+ // empty enumeration would report "no documents open" to a human asking+ // whether SOLIDWORKS is showing a recovery pane.+ // `null`, not 0, when SOLIDWORKS will not say. A human asking whether+ // the Document Recovery pane is up is asking about a session that has+ // just come back from a crash; "0 documents open" is the most+ // misleading possible answer to get wrong there.+ let docs = sw.int("GetDocumentCount", &[]).map(|c| c as usize); Ok(json!({ "openDocuments": docs, "comApiHasRecoveryCheck": false,@@ -2406,9 +3411,33 @@ impl ComBackend { let before = Self::component_count(&asm); for method in Self::insert_ladder(self.insert_method.as_deref()) { let called = Self::invoke_insert(&asm, method, r);- if Self::component_count(&asm) > before {- used = Some(method);- break 'passes;+ let after = Self::component_count(&asm);+ match (before, after) {+ (Some(b), Some(a)) if a > b => {+ used = Some(method);+ break 'passes;+ }+ // COULD NOT COUNT. Trying the next method would insert+ // the component a second time if the first one worked,+ // so stop and say so rather than guess.+ (None, _) | (_, None) => {+ return Err(HostError::coded(+ "component_count_failed",+ format!(+ "SOLIDWORKS would not report how many components '{}' has, so \+ this bridge cannot tell whether the insert of {} succeeded — \+ and trying the next insert method could add it TWICE. \+ Nothing further was attempted; check the assembly with \+ swx_design_info and retry.",+ Self::doc_info(&asm)+ .get("title")+ .and_then(|t| t.as_str())+ .unwrap_or("the assembly"),+ r.file+ ),+ ));+ }+ _ => {} } attempts.push(json!({ "method": method,@@ -2566,8 +3595,24 @@ impl ComBackend { /// THE SUCCESS TEST. Not the return value of the insert call — see /// `cmd_add_component`.- fn component_count(asm: &Disp) -> usize {- asm.call("GetComponents", &[&Var::boolean(true)]).map(|v| v.as_array().len()).unwrap_or(0)+ ///+ /// `None` = COULD NOT COUNT. The `unwrap_or(0)` this replaces made a failed+ /// enumeration look like an empty assembly, so `count > before` was false+ /// after an insert that had actually WORKED — and the method ladder moved on+ /// and inserted the SAME component again. Same shape as bug #11, with a+ /// duplicated component in the user's assembly for a consequence.+ fn component_count(asm: &Disp) -> Option<usize> {+ let comps = asm.call("GetComponents", &[&Var::boolean(true)]).ok()?;+ let listed = comps.as_array().len();+ if listed > 0 {+ return Some(listed);+ }+ // An empty list is believed only when the assembly agrees it is empty.+ match asm.int("GetComponentCount", &[]) {+ Some(0) => Some(0),+ Some(_) => None, // it says it has components and did not list them+ None => comps.is_null().then_some(0),+ } } /// The most recently added top-level component, best effort — used only for@@ -3001,6 +4046,18 @@ impl ComBackend { // v0.2 rename bug wearing a different hat, and a script has no // obvious place for the caller to have consented to it. // swx_save_as {copy:false} remains the deliberate rename.+ // THE SAME THREE-STATE RULE as the write gate: "could not+ // look" is not "nothing is there". This is a pre-check and+ // `save_as` re-checks below, but the defect class does not get to+ // live on in a second copy of the question.+ if let crate::writes::Destination::Unverifiable(io) =+ crate::writes::destination_state(Some(path))+ {+ return Err(HostError::coded(+ "destination_unverifiable",+ crate::writes::unverifiable_message(path, &io),+ ));+ } if std::path::Path::new(path).exists() { return Err(HostError::msg(format!( "refusing to overwrite an existing file: {path}. A modelling script that \
src/comhost/created_store.rs+6−3@@ -99,9 +99,12 @@ pub fn save_session(session: Option<i64>, created: &[(String, Option<String>)]) if let Some(parent) = p.parent() { let _ = std::fs::create_dir_all(parent); }- let _ = std::fs::write(- p,- serde_json::json!({"session": session, "documents": Value::Array(rows)}).to_string(),+ // ATOMIC, like the server's half of the same record: this is the com+ // host's independent veto, and a truncated file makes it refuse writes to+ // the bridge's own documents — bug #5, from the host's side.+ let _ = crate::launch::write_atomic(+ &p,+ &serde_json::json!({"session": session, "documents": Value::Array(rows)}).to_string(), ); }
src/comhost/host.rs+510−8@@ -26,18 +26,44 @@ pub struct HostError { /// carrying `errorCode: None` and prose only — a caller cannot branch on /// "document not open: Part2". pub code: Option<String>,+ /// The HOST gave up on this call — its own inner deadline, not the+ /// server's. Carried over the wire so the server's `hung` verdict never has+ /// to guess a timeout from the wording of a message.+ pub timed_out: bool,+ /// A DOCUMENT THIS CALL CREATED BEFORE IT FAILED.+ ///+ /// Live, 2026-09-11: an import whose com host timed out AFTER `LoadFile4`+ /// had already succeeded left a document open that neither veto knew about —+ /// the server could not close it (not in its registry) and the host refused+ /// to (not in its `created` set), so it could only be closed by hand. A+ /// backend that made something during a call that then failed still MADE it,+ /// and that is the host's own first-hand evidence, not a claim the server+ /// asked it to believe.+ pub created: Option<Value>, } impl HostError {+ /// Attach the document this failing call had already created. The dispatcher+ /// registers it, so the host owns what it made even when it could not say so.+ pub fn with_created(mut self, doc: Value) -> Self {+ self.created = Some(doc);+ self+ }+ pub fn msg(message: impl Into<String>) -> Self {- Self { message: message.into(), detail: None, code: None }+ Self { message: message.into(), detail: None, code: None, timed_out: false, created: None }+ }++ /// A call the HOST abandoned. One half of `host_app_hung`.+ pub fn timeout(message: impl Into<String>) -> Self {+ Self { message: message.into(), detail: None, code: None, timed_out: true, created: None } } pub fn detailed(message: impl Into<String>, detail: impl Into<String>) -> Self {- Self { message: message.into(), detail: Some(detail.into()), code: None }+ Self { message: message.into(), detail: Some(detail.into()), code: None, timed_out: false, created: None } } /// A failure a caller can branch on. pub fn coded(code: &str, message: impl Into<String>) -> Self {- Self { message: message.into(), detail: None, code: Some(code.to_string()) }+ Self { message: message.into(), detail: None, code: Some(code.to_string()), timed_out: false, created: None } } } @@ -118,6 +144,34 @@ pub const READ_ONLY_COMMANDS: &[&str] = &[ // closing, and on a Connected edition stopping was — // until this route existed — not recoverable at all. "launch_shortcut",+ // `close_import_temps` CLOSES DOCUMENTS, and is exempt for one reason:+ // the only documents it can reach are STEP/IGES+ // conversion temporaries the BRIDGE ITSELF caused,+ // proven by the launcher's own contiguous path layout+ // (`is_import_temp` — a `Temp\swx<pid>\…\IC~~\`+ // chain no user document can have). That path IS the+ // ownership proof, and a stronger one than the+ // registry: it survives the pid change that made 1342+ // leaked temps un-ownable by a dead bridge process,+ // which is exactly the state the drain exists to+ // clear. Requiring the write opt-in would make the+ // leak undrainable in the DEFAULT posture — the bug+ // this verb was written to fix.+ //+ // BECAUSE the host's veto does not apply here, the+ // path proof is the ONLY guard, which is why it is a+ // segment walk and not two substring tests: a loose+ // version closed a user's saved file with `CloseDoc`+ // (no save prompt). See `is_import_temp`.+ "close_import_temps",+ // `release_com` / `com_refs` Drop or count the IDispatch pointers THIS+ // HOST holds. They change nothing in SOLIDWORKS — a+ // client releasing its own reference is not a write —+ // and the quit ladder's central invariant is that no+ // rung runs while we hold one, so they must be+ // callable without an opt-in.+ "release_com",+ "com_refs", ]; /// Does this command need authorization? Anything not known to be side-effect@@ -171,6 +225,347 @@ pub trait Backend { /// every document it ever looked at. pub const CREATED_KEYS: &[&str] = &["created", "imported"]; +/// Above this many open documents, an enumeration is not attempted — the count+/// is reported with a sample instead.+///+/// A live session was found holding 1342 open documents (8 GB), all leaked+/// STEP-import temporaries, and `swx_docs` blocked the full 30s marshalling+/// them and got the com host killed. Enumerating N documents is N handles times+/// several COM round-trips each; past a few hundred that cannot finish in any+/// budget. `GetDocumentCount` is ONE call and always answers, so the honest+/// report is "N open, here are the first few" — never a timeout, never a+/// fabricated empty list. See docs/IMPORT-LEAK.md.+pub const DOC_ENUM_CAP_DEFAULT: usize = 200;+/// The cap, overridable by `SWX_DOC_ENUM_CAP` for tests only (so the degrade+/// and drain paths can be exercised with a handful of documents rather than the+/// 200+ it would otherwise take to reach them). Never set in production.+pub fn doc_enum_cap() -> usize {+ std::env::var("SWX_DOC_ENUM_CAP")+ .ok()+ .and_then(|v| v.parse().ok())+ .unwrap_or(DOC_ENUM_CAP_DEFAULT)+}+/// How many titles/paths to sample when the count is over the cap.+pub const DOC_SAMPLE: usize = 10;++/// How long the SAMPLE WALK may take. A time box, not a doc count, because the+/// thing that went wrong live was cost per document under memory pressure: the+/// walk stops and says `sampleTruncatedByTime:true` rather than becoming the+/// 30-second wedge the cap exists to prevent.+pub const DOC_SAMPLE_BUDGET_SECS: u64 = 5;++/// How long the DRAIN may spend walking to find its batch. The drain is a bounded+/// batch by design (`max`), and this bounds the SEARCH for that batch too — on a+/// session where most documents are temps it finds them immediately, and on one+/// where they are rare it stops and reports what it scanned.+pub const DRAIN_WALK_BUDGET_SECS: u64 = 20;++/// The resolved `%TEMP%` this process would use, lowercased with `\` separators+/// and no trailing slash. `None` when the environment does not say.+///+/// The com host runs as the SAME USER as the SOLIDWORKS it is attached to (it is+/// a child of the bridge in that user's interactive session), so its own `%TEMP%`+/// IS the directory the launcher writes conversion temps into. That is what makes+/// this an anchor and not a guess.+/// THE TEMP DIRECTORIES A LEAKED IMPORT TEMP MAY LEGITIMATELY SIT UNDER, in+/// precedence order, deduplicated, lowercased and separator-normalised.+///+/// This is a SET and not a single directory because the drain compares a path+/// SOLIDWORKS reported against a directory THIS process resolved, and the two can+/// legitimately differ:+///+/// * the com host inherits Adom Bridge's environment, and ab may run under a+/// service profile, a redirected `TEMP`, or a different account from the+/// interactive session SOLIDWORKS lives in;+/// * the temps this verb exists for were written by a DIFFERENT, DEAD bridge+/// process (the design deliberately does not check the pid) — and 0.5.3+/// nonetheless assumed that process's `%TEMP%` was ours.+///+/// PRECEDENCE+///+/// 1. `SWX_TEMP_ROOT`, when set, is the WHOLE set. It is the explicit override+/// the suite and the gate use to say "this is the resolved root", and an+/// operator can use it on a live machine once they know which directory+/// SOLIDWORKS is really writing under.+/// 2. Otherwise: this process's `%TEMP%` (`TEMP`, then `TMP`, then `TMPDIR`),+/// AND the interactive user's own `HKCU\Environment\TEMP` expanded. Each is+/// a candidate only if it is readable and non-empty, and a document under+/// EITHER qualifies. Reading the user's registry value is the least invasive+/// way to learn the temp directory of the session SOLIDWORKS is in: its own+/// `GetTempPath` cannot be called from another process, and reading its PEB+/// to get at its environment block is invasive, version-fragile and needs+/// rights this bridge should not want.+///+/// An EMPTY set is not a permissive one: the drain refuses+/// (`temp_root_unknown`), because the resolved directory is the whole location+/// proof and the close it authorises does not prompt.+pub fn temp_roots() -> Vec<String> {+ let norm = |v: &str| -> Option<String> {+ let v = v.trim();+ if v.is_empty() {+ return None;+ }+ let n = v.replace('/', "\\").trim_end_matches('\\').to_ascii_lowercase();+ if n.is_empty() { None } else { Some(n) }+ };+ // MOCK MODE'S SIMULATED %TEMP%. The simulated launcher writes Windows paths+ // under `C:\Users\drew\AppData\Local\Temp`, and the real environment here+ // says `/tmp` — so without this the anchor would reject the mock's own temps+ // and the whole drain would be untested. The explicit override still wins, so+ // a test can model a MISMATCH (which is exactly what adversary8 does) or an+ // unknown root.+ if let Ok(v) = std::env::var("SWX_TEMP_ROOT") {+ return norm(&v).into_iter().collect();+ }+ if crate::is_mock() {+ return norm(crate::comhost::mock::MOCK_TEMP_ROOT).into_iter().collect();+ }+ let mut out: Vec<String> = Vec::new();+ let mut push = |v: Option<String>| {+ if let Some(v) = v {+ if !out.contains(&v) {+ out.push(v);+ }+ }+ };+ for key in ["TEMP", "TMP", "TMPDIR"] {+ if let Ok(v) = std::env::var(key) {+ push(norm(&v));+ break;+ }+ }+ push(user_registry_temp().and_then(|v| norm(&v)));+ out+}++/// The FIRST resolved temp root, for reporting and for the single-root callers.+/// `None` means no root could be resolved at all, which refuses.+pub fn temp_root() -> Option<String> {+ temp_roots().into_iter().next()+}++/// `HKCU\Environment\TEMP`, with `%USERPROFILE%`-style references expanded.+///+/// This is the interactive user's own setting — the one SOLIDWORKS inherits when+/// the user launches it — which is precisely the value this process's environment+/// may NOT have. Read-only, one value, no write of any kind.+#[cfg(windows)]+fn user_registry_temp() -> Option<String> {+ use winreg::enums::HKEY_CURRENT_USER;+ use winreg::RegKey;+ let key = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment").ok()?;+ let raw: String = key.get_value("TEMP").or_else(|_| key.get_value("TMP")).ok()?;+ Some(expand_env_refs(&raw))+}++#[cfg(not(windows))]+fn user_registry_temp() -> Option<String> {+ None+}++/// `%USERPROFILE%\AppData\Local\Temp` -> the expanded path. An unresolvable+/// reference leaves the text alone, which then simply fails to match any path —+/// a miss, never a wildcard.+#[cfg(windows)]+fn expand_env_refs(raw: &str) -> String {+ let mut out = String::new();+ let mut rest = raw;+ while let Some(i) = rest.find('%') {+ out.push_str(&rest[..i]);+ let after = &rest[i + 1..];+ match after.find('%') {+ Some(j) => {+ let name = &after[..j];+ match std::env::var(name) {+ Ok(v) => out.push_str(&v),+ Err(_) => {+ out.push('%');+ out.push_str(name);+ out.push('%');+ }+ }+ rest = &after[j + 1..];+ }+ None => {+ out.push('%');+ out.push_str(after);+ rest = "";+ }+ }+ }+ out.push_str(rest);+ out+}++/// Is this the path of a STEP/IGES-import TEMPORARY — a document SOLIDWORKS+/// created under the bridge's own temp directory while converting an imported+/// file, never saved to a real location and unambiguously transient?+///+/// THREE things must all hold, and each closed a data-loss path:+///+/// 1. **LOCATION.** The path must sit under THIS USER'S RESOLVED `%TEMP%`+/// ([`temp_root`]), and that is the WHOLE location test. Proving the layout+/// alone was not enough: `D:\Temp\swx107688\IC~~\First.step.SLDPRT` has the+/// launcher's exact shape on a volume the launcher never writes to, and the+/// drain closes what qualifies with `CloseDoc`, which does not prompt. An+/// earlier version OR-ed in a shape test (`parent` ends+/// `appdata\local\temp`), which made the anchor advisory: a restored profile+/// backup at `E:\Backups\drew\AppData\Local\Temp\swx…\IC~~\…`, or another+/// user's profile, qualified — and when `%TEMP%` was unreadable the shape+/// alone decided. **No root, no drain**: `temp_root() == None` matches+/// nothing, and the verb refuses with `temp_root_unknown` rather than+/// reporting a silent zero.+/// 2. **THE PID FOLDER.** Immediately under `%TEMP%`, a `swx<digits>` segment+/// (the 8.3 short form `SWX107~1` counts). The pid VALUE is deliberately not+/// checked: the 1342 leaked temps belonged to a DEAD bridge process, and a+/// dead pid's directory is still under the same `%TEMP%`.+/// 3. **`IC~~` DIRECTLY UNDER IT**, then the file. Live evidence is exactly+/// `…\Temp\swx107688\IC~~\First.step.SLDPRT`; anything deeper is not a shape+/// the launcher produces. A miss costs memory, a false match costs data, and+/// those are not the same price.+pub fn is_import_temp(path: &str) -> bool {+ is_import_temp_under_any(path, &temp_roots())+}++/// THE LAUNCHER'S TAIL, wherever it lives: `…\swx<digits>\ic~~\<file>`.+///+/// This is deliberately NOT the drain's test — it is the NEAR MISS. A document+/// whose path carries this tail but sits under no resolved temp root is almost+/// certainly a leaked import temp whose `%TEMP%` this process does not know, and+/// the drain has to be able to SAY that: 0.5.3 matched nothing in that case and+/// reported "every open STEP/IGES import temporary was closed" with three of them+/// open. A miss is the safe direction for the close; it is not the safe direction+/// for the report.+pub fn has_import_temp_tail(path: &str) -> bool {+ tail_parts(path).is_some()+}++/// `(parent segments, pid segment)` when the path ends `…\swx<digits>\ic~~\<file>`.+/// Lowercased, separators normalised.+fn tail_parts(path: &str) -> Option<(Vec<String>, String)> {+ let lower = path.replace('/', "\\").to_ascii_lowercase();+ let segs: Vec<&str> = lower.split('\\').filter(|s| !s.is_empty()).collect();+ if segs.len() < 4 {+ return None;+ }+ let file_at = segs.len() - 1;+ let ic_at = file_at - 1;+ let pid_at = ic_at - 1;+ if segs[ic_at] != "ic~~" || !is_swx_pid_dir(segs[pid_at]) {+ return None;+ }+ Some((segs[..pid_at].iter().map(|s| s.to_string()).collect(), segs[pid_at].to_string()))+}++/// The same rule with the temp root made explicit, so every branch is testable+/// without mutating the process environment.+pub fn is_import_temp_under(path: &str, temp_root: Option<&str>) -> bool {+ let roots: Vec<String> = temp_root.into_iter().map(String::from).collect();+ is_import_temp_under_any(path, &roots)+}++/// The rule against a SET of resolved roots: the document qualifies if its pid+/// folder's parent IS one of them. An EMPTY set matches nothing — no root, no+/// drain.+pub fn is_import_temp_under_any(path: &str, roots: &[String]) -> bool {+ if roots.is_empty() {+ return false;+ }+ let Some((parent, _pid)) = tail_parts(path) else { return false };+ roots.iter().any(|root| {+ let root = root.replace('/', "\\").trim_end_matches('\\').to_ascii_lowercase();+ let want: Vec<&str> = root.split('\\').filter(|s| !s.is_empty()).collect();+ !want.is_empty()+ && parent.len() == want.len()+ && parent.iter().zip(want.iter()).all(|(a, b)| segment_equivalent(a, b))+ })+}++/// Two path segments that name the SAME directory: equal, or one is the other's+/// 8.3 short form.+///+/// Both sides have to be resolved, not just compared, because the two forms of+/// the same directory are both legitimate: `GetPathName` can hand back+/// `C:\Users\DREW~1\AppData\Local\Temp\…` for the very directory `%TEMP%`+/// reports as `C:\Users\drew\AppData\Local\Temp`. `GetLongPathName` would be+/// the Windows way to do it and is deliberately NOT used: it answers only for a+/// path that still exists on disk, and this rule is asked about documents whose+/// temp file may already have been deleted while the document is still open in+/// SOLIDWORKS. A rule whose answer depends on whether a file happens to exist is+/// not a rule you can reason about before a close.+fn segment_equivalent(a: &str, b: &str) -> bool {+ a == b || is_short_form_of(a, b) || is_short_form_of(b, a)+}++/// Is `short` the 8.3 short form of `long`? Both lowercased.+///+/// Windows builds it from the first six characters of the name with spaces and+/// periods removed, plus `~<n>`. The six-character head is required to be+/// EXACTLY that long (or the whole name, when the name is shorter), so `a~1`+/// cannot stand in for `adversary` — a loose prefix rule would let a short+/// segment match a great many long ones, which is the opposite of an anchor.+fn is_short_form_of(short: &str, long: &str) -> bool {+ let Some((head, tail)) = short.split_once('~') else { return false };+ if head.is_empty() || tail.is_empty() || !tail.bytes().all(|b| b.is_ascii_digit()) {+ return false;+ }+ let stripped: String = long.chars().filter(|c| *c != ' ' && *c != '.').collect();+ // WHEN DOES WINDOWS ACTUALLY GENERATE A TILDE NAME? Two cases, and they are+ // different shapes:+ //+ // 1. MANGLING. The long name does not fit 8.3 — over 8 characters, a space,+ // a second period, an extension over 3. The short name is then the first+ // SIX characters plus `~n`: `adversary` -> `ADVERS~1`. This is the case+ // adversary pass 8 asked for, and it correctly rejects `a~1` standing in+ // for `adversary`.+ // 2. COLLISION. The long name fits 8.3 perfectly and STILL gets a tilde,+ // because another entry in the same directory claims the same short name.+ // The short name is then the WHOLE name plus `~n`: `DREW~1` for `drew`.+ // This is not hypothetical and it is not rare — it is the standard+ // profile-directory case on a domain-joined machine, where `C:\Users\drew`+ // sits beside `C:\Users\drew.ADOM` and the short name of the first IS+ // `DREW~1`. Pass 8 read this acceptance as a pure widening; it is the+ // collision form, and rejecting it would make the drain MISS real leaked+ // temps on exactly the machine class this bridge ships to. A miss is the+ // safe direction for a close and the wrong direction for a drain that+ // exists because 1342 temps went unclosed.+ //+ // So: the mangled form needs the six-character head, and the collision form+ // needs the whole name. Neither admits a loose prefix, which was the actual+ // looseness worth removing.+ let long_len = long.chars().count();+ let needs_mangling = long_len > 8+ || long.contains(' ')+ || long.matches('.').count() > 1+ || long.split('.').nth(1).map(|ext| ext.chars().count() > 3).unwrap_or(false);+ let head_len = head.chars().count();+ if needs_mangling {+ head_len == stripped.chars().count().min(6) && stripped.starts_with(head)+ } else {+ head == stripped+ }+}++/// `swx107688`, or its 8.3 short form `swx107~1`. Lowercased input.+///+/// `swx` followed by DIGITS — not `swx-archive`, not `swxnotes`. The 8.3 form is+/// accepted because a path read through a short-name API legitimately carries it.+fn is_swx_pid_dir(seg: &str) -> bool {+ let Some(rest) = seg.strip_prefix("swx") else { return false };+ if rest.is_empty() {+ return false;+ }+ match rest.split_once('~') {+ Some((head, tail)) => {+ !head.is_empty()+ && !tail.is_empty()+ && head.bytes().all(|b| b.is_ascii_digit())+ && tail.bytes().all(|b| b.is_ascii_digit())+ }+ None => rest.bytes().all(|b| b.is_ascii_digit()),+ }+}+ /// The document a result says it created, if any. /// /// IT CAN BE NESTED. `run_script` reports the `new_document` result it ran as@@ -222,6 +617,12 @@ pub const COMMANDS: &[&str] = &[ "set_parameter", "inspect_bodies", "import",+ "close_import_temps",+ // The quit ladder: one rung per call, plus the two reference-discipline+ // commands the ladder's invariant depends on.+ "quit_rung",+ "release_com",+ "com_refs", "save_as", "run_script", "run_macro",@@ -333,7 +734,13 @@ pub fn run() { spawn_parent_watchdog(); let mut backend = make_backend(); - send(&json!({"event": "ready", "pid": pid}));+ // THE VERSION IS PART OF THE HANDSHAKE. The com host is a separate process+ // and can be an OLDER BINARY than the server that spawned it (a half-applied+ // update, a stale exe left in a cache dir). A field this server expects and+ // that host never sends then reads as `false` — and one of those fields is+ // `closeOutcomeUnknown`, where a silently dropped `true` becomes "nothing+ // happened". A skew must be an explicit error, not a quietly missing field.+ send(&json!({"event": "ready", "pid": pid, "version": crate::BRIDGE_VERSION})); // Test-only knob: emit junk around every response so the SERVER's // skip-what-you-cannot-parse path is exercised over the real wire@@ -397,7 +804,7 @@ pub fn run() { match verdict { Ok(Ok(())) => {} Ok(Err(e)) => {- send(&json!({"id": id, "ok": false, "error": e.message, "detail": e.detail, "errorCode": e.code}));+ send(&json!({"id": id, "ok": false, "error": e.message, "detail": e.detail, "errorCode": e.code, "timedOut": e.timed_out})); continue; } Err(_) => {@@ -431,9 +838,19 @@ pub fn run() { } send(&json!({"id": id, "ok": true, "result": value})) }- Ok(Err(e)) => send(- &json!({"id": id, "ok": false, "error": e.message, "detail": e.detail, "errorCode": e.code}),- ),+ Ok(Err(e)) => {+ // A CALL THAT FAILED AFTER IT CREATED SOMETHING still created it.+ if let Some(doc) = e.created.clone() {+ let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {+ backend.register_created(&doc)+ }));+ }+ send(+ &json!({"id": id, "ok": false, "error": e.message, "detail": e.detail,+ "errorCode": e.code, "timedOut": e.timed_out,+ "createdBeforeFailing": e.created}),+ )+ } Err(panic) => { let what = panic .downcast_ref::<&str>()@@ -457,6 +874,91 @@ mod tests { use super::*; use serde_json::json; + #[test]+ fn the_temp_anchor_is_the_whole_location_test() {+ let live = r"C:\Users\drew\AppData\Local\Temp";+ // UNDER the resolved root, in every form GetPathName can hand back.+ for p in [+ r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT",+ "C:/Users/drew/AppData/Local/Temp/swx107688/IC~~/First.step.SLDPRT",+ r"C:\USERS\DREW\APPDATA\LOCAL\TEMP\SWX107~1\IC~~\FIRST.STEP.SLDPRT",+ // the 8.3 PROFILE form of the same directory+ r"C:\Users\DREW~1\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT",+ ] {+ assert!(is_import_temp_under(p, Some(live)), "must still drain: {p}");+ }+ // NOT under it, however exactly the tail matches: a restored profile+ // backup on another volume, and another user's profile. The shape test+ // that used to be OR-ed in qualified both.+ for p in [+ r"E:\Backups\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT",+ r"C:\Users\someoneelse\AppData\Local\Temp\swx107688\IC~~\x.SLDPRT",+ r"D:\Temp\swx107688\IC~~\First.step.SLDPRT",+ ] {+ assert!(!is_import_temp_under(p, Some(live)), "not under the resolved %TEMP%: {p}");+ }+ // NO ROOT, NO DRAIN — an unreadable %TEMP% matches nothing at all.+ assert!(!is_import_temp_under(+ r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT",+ None+ ));+ }++ #[test]+ fn a_document_under_either_resolved_root_drains_and_a_near_miss_is_recognisable() {+ // TWO candidate roots, because the com host's own %TEMP% and the+ // interactive user's HKCU\Environment\TEMP can legitimately differ and a+ // leaked temp may sit under either.+ let process_root = r"C:\Windows\Temp".to_string();+ let user_root = r"C:\Users\drew\AppData\Local\Temp".to_string();+ let roots = vec![process_root.clone(), user_root.clone()];+ let under_user = r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT";+ let under_process = r"C:\Windows\Temp\swx4242\IC~~\Second.step.SLDPRT";+ let under_neither = r"E:\Backups\drew\AppData\Local\Temp\swx1\IC~~\Third.SLDPRT";+ for p in [under_user, under_process] {+ assert!(is_import_temp_under_any(p, &roots), "under one of the roots: {p}");+ }+ assert!(!is_import_temp_under_any(under_neither, &roots));+ // ...and with only ONE root known, only that one qualifies.+ assert!(is_import_temp_under_any(under_user, &[user_root]));+ assert!(!is_import_temp_under_any(under_user, &[process_root]));+ // NO ROOT, NO DRAIN — an empty set matches nothing at all.+ assert!(!is_import_temp_under_any(under_user, &[]));+ // THE NEAR MISS is the launcher's tail regardless of where it sits: it is+ // what lets the drain say "3 documents look like temps but are not under+ // the directory I compared against" instead of "nothing to do".+ for p in [under_user, under_process, under_neither] {+ assert!(has_import_temp_tail(p), "the launcher's tail: {p}");+ }+ // ...and it is not satisfied by the things round five and six closed.+ for p in [+ r"C:\Users\drew\AppData\Local\Temp\swxnotes\IC~~\keep.sldprt",+ r"C:\work\IC~~\drafts\Temp\swx_export\customer-master.sldprt",+ r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~",+ ] {+ assert!(!has_import_temp_tail(p), "not the launcher's tail: {p}");+ }+ }++ #[test]+ fn an_8_3_short_name_is_matched_by_the_real_rule_not_a_loose_prefix() {+ // The MANGLED form takes the six-character head; the COLLISION form takes+ // the whole name (`DREW~1` for `drew`, which is what a domain-joined+ // machine really produces when `drew.ADOM` sits beside `drew`). A loose+ // prefix — the looseness actually worth removing — is rejected in both.+ assert!(segment_equivalent("advers~1", "adversary"), "mangled: six-char head");+ assert!(!segment_equivalent("a~1", "adversary"), "a loose prefix is not a short name");+ assert!(segment_equivalent("advers~1", "advers"), "collision form of a 6-char name");+ assert!(!segment_equivalent("advers~1", "adverse"), "fits 8.3, and the head is not the name");+ assert!(segment_equivalent("drew~1", "drew"), "collision form of a name that fits 8.3");+ assert!(!segment_equivalent("drew~1", "drewson"), "neither form: 7 chars, head is not it");+ assert!(segment_equivalent("myfold~2", "my folder"), "a space forces mangling");+ // ...and the symmetry and the malformed cases.+ assert!(segment_equivalent("drew", "drew~1"));+ assert!(!segment_equivalent("drew~x", "drew"), "the tail must be digits");+ assert!(!segment_equivalent("drew", "someone"));+ }+ #[test] fn a_created_document_is_found_flat_and_nested() { let flat = json!({"created": {"title": "Part1", "path": ""}});
src/comhost/mock.rs+1094−32@@ -89,9 +89,74 @@ struct MockDoc { /// BOM aggregates THESE when there are any, so a test can insert components /// and then read a BOM built from what it inserted. components: Vec<(String, String)>,+ /// Modelled STEP/IGES-import temporary: a document SOLIDWORKS created under+ /// the bridge's temp dir with an `IC~~` conversion path. The real backend+ /// recognises these by that path; the mock carries the fact as a flag and a+ /// synthetic temp path, so `close_import_temps` and the enumeration cap can+ /// be tested without a live SOLIDWORKS.+ import_temp: bool,+ temp_path: Option<String>, } impl MockDoc {+ /// The mock's documents live in the com-host CHILD, which the server kills+ /// and respawns by design — so without this they vanished on every respawn,+ /// and the mock quietly modelled a SOLIDWORKS that closes everything when+ /// the bridge's sidecar restarts. It does not. That fiction made bug #5+ /// (ownership lost across a respawn) untestable: the documents were gone,+ /// so no verb could ever reach the ownership check.+ fn to_json(&self) -> Value {+ json!({+ "path": self.path,+ "title": self.title,+ "saved": self.saved,+ "docType": self.doc_type,+ "config": self.config,+ "dirty": self.dirty,+ "props": Value::Object(self.props.clone()),+ "material": self.material,+ "dimensions": Value::Object(self.dimensions.clone()),+ "bodies": self.bodies,+ "components": self.components.iter().map(|(f, c)| json!([f, c])).collect::<Vec<_>>(),+ "importTemp": self.import_temp,+ "tempPath": self.temp_path,+ })+ }++ fn from_json(v: &Value) -> Option<MockDoc> {+ let doc_type = match v.get("docType")?.as_str()? {+ "part" => "part",+ "assembly" => "assembly",+ "drawing" => "drawing",+ _ => return None,+ };+ Some(MockDoc {+ path: v.get("path")?.as_str()?.to_string(),+ title: v.get("title")?.as_str()?.to_string(),+ saved: v.get("saved").and_then(|x| x.as_bool()).unwrap_or(true),+ doc_type,+ config: v.get("config").and_then(|x| x.as_str()).unwrap_or("Default").to_string(),+ dirty: v.get("dirty").and_then(|x| x.as_bool()).unwrap_or(false),+ props: v.get("props").and_then(|x| x.as_object()).cloned().unwrap_or_default(),+ material: v.get("material").and_then(|x| x.as_str()).map(String::from),+ dimensions: v.get("dimensions").and_then(|x| x.as_object()).cloned().unwrap_or_default(),+ bodies: v.get("bodies").and_then(|x| x.as_array()).cloned().unwrap_or_default(),+ components: v+ .get("components")+ .and_then(|x| x.as_array())+ .map(|rows| {+ rows.iter()+ .filter_map(|r| {+ Some((r.get(0)?.as_str()?.to_string(), r.get(1)?.as_str()?.to_string()))+ })+ .collect()+ })+ .unwrap_or_default(),+ import_temp: v.get("importTemp").and_then(|x| x.as_bool()).unwrap_or(false),+ temp_path: v.get("tempPath").and_then(|x| x.as_str()).map(String::from),+ })+ }+ fn opened(path: String, doc_type: &'static str, config: String) -> MockDoc { let title = base_name(&path).to_string(); MockDoc {@@ -106,6 +171,8 @@ impl MockDoc { dimensions: Map::new(), bodies: Vec::new(), components: Vec::new(),+ import_temp: false,+ temp_path: None, } } }@@ -141,10 +208,136 @@ pub struct MockBackend { /// properties from — which is exactly the situation a BOM is in when its /// components are files on disk rather than windows on screen. files: std::collections::BTreeMap<String, Map<String, Value>>,+ /// A rung reported success and the simulated process is STILL GOING — it+ /// exits at this instant (epoch ms). A real `ExitApp` returns while+ /// SOLIDWORKS is still tearing itself down, so this is the divergence the+ /// mock has to be able to express: the census must see the process for a+ /// while after the rung returned.+ pending_exit_at_ms: Option<u64>,+}++/// The simulated machine, when a test has configured one. With a fake dir set,+/// the SIMULATOR is the single source of truth for whether a launch reached a+/// COM-attachable SOLIDWORKS — so a route that stalls never becomes attachable,+/// however long anybody polls, exactly as on the real machine.+fn fake_dir() -> Option<std::path::PathBuf> {+ std::env::var(crate::launchos::ENV_FAKE_DIR)+ .ok()+ .filter(|d| !d.is_empty())+ .map(std::path::PathBuf::from)+}++/// A runtime fault switch: a file in the scratch root, so a test can turn a+/// failure on and off BETWEEN calls without restarting the bridge. Test-only —+/// nothing in production ever creates these.+fn fault(name: &str) -> bool {+ crate::scratch::root().join(name).exists() } const MOCK_VERSION: &str = "mock 33.0.0 (2025 SP0)"; +/// The `%TEMP%` the SIMULATED launcher writes under.+///+/// The real backend anchors the drain to the `%TEMP%` the com-host process+/// resolves, which on this Linux host would be `/tmp` — nothing like the Windows+/// path SOLIDWORKS reports. So the mock carries its own RESOLVED root and passes+/// it explicitly, which keeps the drain going through the same+/// `is_import_temp_under` rule the real backend uses rather than a mock-only+/// flag. Modelling this faithfully is what exposed the loose anchor twice.+pub const MOCK_TEMP_ROOT: &str = r"C:\Users\drew\AppData\Local\Temp";++/// The simulated resolved root, or `None` when the test asked for an UNREADABLE+/// `%TEMP%` (`mock-temp-root-unknown`) — the branch where the drain must refuse+/// instead of reporting that it found nothing.+/// PER-DOCUMENT COST, simulated. `SWX_MOCK_DOC_LATENCY_MS` (or the fault file+/// `mock-doc-latency-ms` holding a number) is the milliseconds a single document+/// costs to touch.+///+/// THIS EXISTS BECAUSE THE MOCK'S SPEED HID A LIVE WEDGE. `GetDocuments`+/// marshals every handle in one call; on Drew's 2144-document session it did not+/// answer within 30s and the com host was killed, while `GetDocumentCount`+/// answered in 0.7s. The mock's enumeration was instant, so the whole "degrades+/// instead of timing out" claim was mock-proven and false live. A mock that+/// cannot be slow cannot prove anything about a budget.+fn doc_latency_ms() -> u64 {+ if let Ok(v) = std::env::var("SWX_MOCK_DOC_LATENCY_MS") {+ if let Ok(n) = v.trim().parse::<u64>() {+ return n;+ }+ }+ std::fs::read_to_string(crate::scratch::root().join("mock-doc-latency-ms"))+ .ok()+ .and_then(|t| t.trim().parse::<u64>().ok())+ .unwrap_or(0)+}++/// Charge for touching `n` documents. Called wherever the real backend pays a+/// COM round trip per document — and, crucially, ONCE PER DOCUMENT IN THE WHOLE+/// SESSION wherever it would call `GetDocuments`, because that call's cost is a+/// function of everything open and not of what the caller wanted.+fn charge_docs(n: usize) {+ let ms = doc_latency_ms();+ if ms > 0 && n > 0 {+ std::thread::sleep(std::time::Duration::from_millis(ms.saturating_mul(n as u64)));+ }+}++/// THE OPEN-DOCUMENT COUNT, through the same seam that can be made UNREADABLE.+///+/// The mock used to read `self.docs.len()` directly, so `mock-doccount-none`+/// could not reach the drain — and the drain's discriminator has an arm that+/// fires only when the count cannot be read, which is the arm that decides+/// between "the call did nothing" and "a user's document may have closed". It+/// was dead code as far as the suite was concerned.+/// A PER-CALL SEQUENCE OF COM PROCESS IDS, so the attached session can CHANGE+/// between two reads inside one verb.+///+/// The quit ladder re-measures every precondition before every rung, and the+/// claim check is part of that sweep — but nothing could demonstrate it, because+/// the simulator's attached pid was fixed for the life of a run and the FRONT+/// GATE would always be the thing that caught a mismatch. `mock-com-pid` holds+/// whitespace-separated pids and hands out the next one on each read, so a test+/// can make the session identity valid at rung (a) and different by rung (b) —+/// which is the situation the per-rung sweep exists for.+fn mock_com_pid() -> Option<i64> {+ let path = crate::scratch::root().join("mock-com-pid");+ let text = std::fs::read_to_string(&path).ok()?;+ let pids: Vec<i64> = text.split_whitespace().filter_map(|t| t.parse().ok()).collect();+ if pids.is_empty() {+ return None;+ }+ // The cursor lives beside it, so the sequence survives the com host being+ // respawned mid-verb — which this bridge does on purpose.+ let cursor_path = crate::scratch::root().join("mock-com-pid.cursor");+ let i: usize = std::fs::read_to_string(&cursor_path)+ .ok()+ .and_then(|t| t.trim().parse().ok())+ .unwrap_or(0);+ let _ = std::fs::write(&cursor_path, (i + 1).to_string());+ // The LAST value stands once the sequence is exhausted: a test says what+ // changes, not how many times it will be asked.+ Some(*pids.get(i).unwrap_or(pids.last().expect("non-empty")))+}++fn mock_doc_count(n: usize) -> Option<i32> {+ if fault("mock-doccount-none") {+ None+ } else {+ Some(n as i32)+ }+}++fn mock_temp_roots() -> Vec<String> {+ if fault("mock-temp-root-unknown") {+ return Vec::new();+ }+ // The SAME resolver the real backend uses — which in mock mode answers with+ // MOCK_TEMP_ROOT unless SWX_TEMP_ROOT overrides it. Deferring to it means the+ // mock cannot drift into proving something about a root the real rule would+ // never accept, and it is what lets a test set a MISMATCHED root.+ super::host::temp_roots()+}+ /// The mock's preference table — the same NAMES the real backend exposes, so a /// caller cannot discover a preference here that SOLIDWORKS would reject. const MOCK_PREFERENCES: &[(&str, i32)] = &[("lengthUnitRaw", 53), ("unitSystemRaw", 48)];@@ -155,7 +348,7 @@ fn prop(value: &str) -> Value { impl MockBackend { pub fn new() -> Self {- Self {+ let mut me = Self { running: false, created: crate::comhost::created_store::load_session().1, created_session: crate::comhost::created_store::load_session().0,@@ -166,11 +359,160 @@ impl MockBackend { active: None, files: std::collections::BTreeMap::new(), launching: None,+ pending_exit_at_ms: None,+ };+ me.restore();+ me+ }++ /// A backend with NO restored session — for the in-process unit tests,+ /// which all share one scratch root and would otherwise inherit each+ /// other's open documents. The integration suite gets a real restore+ /// because each bridge there has its own `SWX_SCRATCH_DIR`.+ #[cfg(test)]+ pub fn fresh() -> Self {+ Self {+ running: false,+ created: Vec::new(),+ created_session: None,+ generation: 0,+ untitled: 0,+ preferences: Map::new(),+ docs: Vec::new(),+ active: None,+ files: std::collections::BTreeMap::new(),+ launching: None,+ pending_exit_at_ms: None,+ }+ }++ /// Where the simulated SOLIDWORKS keeps its session between com-host+ /// processes. This is not bridge state: it stands in for the application's+ /// OWN memory, which a com-host respawn does not touch.+ fn session_file() -> std::path::PathBuf {+ crate::scratch::root().join("mock-solidworks-open-docs.json")+ }++ /// `ISldWorks::CloseDoc(name)` — BY NAME, which is what makes the close key+ /// matter: a full path identifies one document, a title identifies whichever+ /// SOLIDWORKS finds first.+ ///+ /// Modelled faithfully as of 0.5.6. Before that the mock removed by its own+ /// unique internal key, so it closed exactly the right document every time+ /// and 254 green tests said nothing whatsoever about the close key — while+ /// the real backend was passing a TITLE that Drew's session had two of.+ fn find_by_name(&self, name: &str) -> Option<usize> {+ // A full path wins, and matches only itself.+ if let Some(i) = self+ .docs+ .iter()+ .position(|d| {+ let p = Self::reported_path(d);+ !p.is_empty() && crate::verbs::same_path(p, name)+ })+ {+ return Some(i);+ }+ // Otherwise it is a TITLE, and SOLIDWORKS resolves it to the FIRST open+ // document that carries it.+ self.docs.iter().position(|d| d.title.eq_ignore_ascii_case(name))+ }++ fn close_doc_by_name(&mut self, name: &str) -> bool {+ // AN UNPROVEN PREMISE, MODELLED. `CloseDoc`'s help says only "name of the+ // document to close"; that a FULL PATH is accepted is this bridge's+ // assumption and has never been run live. With this fault set, the path+ // form returns SUCCESS and closes nothing — which is what the count-based+ // discriminator exists to catch, and what would otherwise look like a+ // drain that quietly did nothing.+ if fault("mock-closedoc-ignores-path") && name.contains('\\') {+ return true;+ }+ let Some(pos) = self.find_by_name(name) else { return false };+ let d = self.docs.remove(pos);+ self.created.retain(|(t, _)| t != &d.title);+ if self.active.as_deref() == Some(d.path.as_str()) {+ self.active = self.docs.first().map(|x| x.path.clone()); }+ true } + /// FLOOD THE SESSION, for the tests that are about scale.+ ///+ /// `SWX_MOCK_OPEN_DOCS=2144` opens that many import temporaries at start, so+ /// the suite can hold the exact session the live defect was found on: 2144+ /// documents, a cap of 200, and a `swx_docs` that must still answer in+ /// seconds. Creating them through `import` one at a time would take longer+ /// than the test it enables.+ fn flood(&mut self) {+ let Ok(n) = std::env::var("SWX_MOCK_OPEN_DOCS") else { return };+ let Ok(n) = n.trim().parse::<usize>() else { return };+ for i in 0..n {+ let key = format!("untitled:flood:{i}:Widget{i}.SLDPRT");+ let mut d = MockDoc::opened(key.clone(), "part", "Default".into());+ d.title = format!("Widget{i}.SLDPRT");+ d.saved = false;+ d.import_temp = true;+ d.temp_path = Some(format!("{MOCK_TEMP_ROOT}\\swx4242\\IC~~\\Widget{i}.SLDPRT"));+ self.docs.push(d);+ }+ self.active = self.docs.first().map(|d| d.path.clone());+ }++ /// Reload the simulated application's open documents. Called once, at host+ /// boot — so a respawned host attaches to the SAME session it left, exactly+ /// as `GetActiveObject` does against a SOLIDWORKS that never went away.+ fn restore(&mut self) {+ let Ok(text) = std::fs::read_to_string(Self::session_file()) else { return };+ let Ok(v) = serde_json::from_str::<Value>(&text) else { return };+ self.running = v.get("running").and_then(|x| x.as_bool()).unwrap_or(false);+ self.untitled = v.get("untitled").and_then(|x| x.as_u64()).unwrap_or(0) as u32;+ self.active = v.get("active").and_then(|x| x.as_str()).map(String::from);+ self.docs = v+ .get("docs")+ .and_then(|x| x.as_array())+ .map(|rows| rows.iter().filter_map(MockDoc::from_json).collect())+ .unwrap_or_default();+ if let Some(files) = v.get("files").and_then(|x| x.as_object()) {+ for (k, val) in files {+ if let Some(props) = val.as_object() {+ self.files.insert(k.clone(), props.clone());+ }+ }+ }+ }++ /// Persist the simulated application's session. Called after EVERY command+ /// from the dispatch entry point, not from the individual commands, for the+ /// same reason `register_created` is: a command that forgets cannot exist.+ fn persist(&self) {+ let files: Map<String, Value> = self+ .files+ .iter()+ .map(|(k, v)| (k.clone(), Value::Object(v.clone())))+ .collect();+ let body = json!({+ "running": self.running,+ "untitled": self.untitled,+ "active": self.active,+ "docs": self.docs.iter().map(|d| d.to_json()).collect::<Vec<_>>(),+ "files": Value::Object(files),+ });+ let p = Self::session_file();+ if let Some(parent) = p.parent() {+ let _ = std::fs::create_dir_all(parent);+ }+ let _ = std::fs::write(p, body.to_string());+ }++ /// Find an open document by its key. CASE-INSENSITIVE, via the same+ /// `same_path` the server uses: SOLIDWORKS normalises what it reports (ask+ /// it to save `mine.sldprt` and it will tell you the document lives at+ /// `mine.SLDPRT`), so a mock that compared strings exactly would answer+ /// "document not open" for a document that is open — which is precisely+ /// what it did to a respawned host holding a restored session. fn find(&self, path: &str) -> Option<&MockDoc> {- self.docs.iter().find(|d| d.path == path)+ self.docs.iter().find(|d| d.path == path || crate::verbs::same_path(&d.path, path)) } fn doc_info(&self, path: &str) -> Value {@@ -178,9 +520,19 @@ impl MockBackend { None => Value::Null, Some(d) => json!({ "title": d.title,- // Never saved -> no path. This is the ONE signal that separates- // a scratch document from a real file, and both backends give it.- "path": if d.saved { d.path.clone() } else { String::new() },+ // Never saved -> no path... EXCEPT an import temporary, whose+ // `GetPathName` on the real backend returns its+ // `…\Temp\swx<pid>\IC~~\…` conversion path even though it was+ // never saved anywhere real. The mock used to report "" for+ // those, so `is_import_temp` was FALSE against the mock's own+ // doc_info and every path-keyed consumer (the drain match, the+ // write gate's exists-check, save_as, ownership-by-path) was+ // exercised on a value the real backend never produces.+ "path": match (&d.temp_path, d.saved) {+ (Some(t), _) => t.clone(),+ (None, true) => d.path.clone(),+ (None, false) => String::new(),+ }, "type": d.doc_type, "config": d.config, // SOLIDWORKS DOES NOT ALWAYS ANSWER THIS. `GetSaveFlag` is@@ -203,11 +555,15 @@ impl MockBackend { /// The mock's internal key (`untitled:Part1`) is not a path and must never /// be matchable as one — treating it as one is what made every mock test /// pass while the first real unsaved document was unaddressable.+ /// What `GetPathName` would return: a saved document's file, an import+ /// temporary's conversion path, and "" for a document that has neither.+ /// Must agree with `doc_info`, or resolve-by-path cannot find a document the+ /// listing just advertised. fn reported_path(d: &MockDoc) -> &str {- if d.saved {- &d.path- } else {- ""+ match (&d.temp_path, d.saved) {+ (Some(t), _) => t,+ (None, true) => &d.path,+ (None, false) => "", } } @@ -218,7 +574,9 @@ impl MockBackend { if let Some(path) = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) {- return match self.docs.iter().find(|d| Self::reported_path(d) == path) {+ return match self.docs.iter().find(|d| {+ Self::reported_path(d) == path || crate::verbs::same_path(&Self::reported_path(d), path)+ }) { Some(d) => Ok(d.path.clone()), None => Err(HostError::coded( "document_not_open",@@ -298,12 +656,19 @@ impl MockBackend { return false; } let Some(d) = self.find(key) else { return false };- if d.saved {+ // BY THE PATH SOLIDWORKS WOULD REPORT, not by the mock's internal key and+ // not by `saved`. An import temporary is UNSAVED and still has a path+ // (its `IC~~` conversion file), so keying this on `saved` looked for a+ // pathless record while `register_created` had stored the path — and the+ // host refused to close the document the bridge had just imported. The+ // real backend has always compared `GetPathName`; this is that rule.+ let reported = Self::reported_path(d);+ if reported.is_empty() {+ self.created.iter().any(|(t, p)| p.is_none() && *t == d.title)+ } else { self.created .iter()- .any(|(_, p)| p.as_deref().map(|q| crate::verbs::same_path(q, &d.path)).unwrap_or(false))- } else {- self.created.iter().any(|(t, p)| p.is_none() && *t == d.title)+ .any(|(_, p)| p.as_deref().map(|q| crate::verbs::same_path(q, reported)).unwrap_or(false)) } } @@ -328,9 +693,19 @@ impl MockBackend { } fn authorize_inner(&self, args: &Value, what: &str, doc: Option<&str>, destination: Option<&str>, always: bool) -> Result<(), HostError> {- let clobbers = destination- .map(|d| !d.is_empty() && std::path::Path::new(d).exists())- .unwrap_or(false);+ // THE SAME THREE-STATE RULE, through the same function the real backend+ // and the server use. The mock's veto has to be the real veto or the+ // suite proves nothing about the layer that actually stops writes.+ let clobbers = match crate::writes::destination_state(destination) {+ crate::writes::Destination::Occupied => true,+ crate::writes::Destination::Free => false,+ crate::writes::Destination::Unverifiable(io) => {+ return Err(HostError::coded(+ "destination_unverifiable",+ crate::writes::unverifiable_message(destination.unwrap_or(""), &io),+ ));+ }+ }; let foreign = doc.map(|p| !self.host_owns(p)).unwrap_or(false); if !clobbers && !foreign && !always { return Ok(());@@ -393,6 +768,47 @@ impl MockBackend { v } + /// Publish the simulated SOLIDWORKS pid into the fake-OS directory so the+ /// PROCESS CENSUS can see it.+ ///+ /// The quit ladder measures success by census (pid + creation time absent),+ /// never by a COM call — so the census and the mock backend must agree about+ /// which process is SOLIDWORKS. This is the same one-source-of-truth wiring+ /// `quit.jsonl` and launch attachability use: the backend publishes, the+ /// census reads.+ fn publish_session(&self) {+ let Some(dir) = fake_dir() else { return };+ let _ = std::fs::create_dir_all(&dir);+ let f = dir.join("mock-session");+ // ONLY WHEN IT IS ACTUALLY RUNNING. Publishing unconditionally put a+ // SOLIDWORKS into the census of every launch fixture the moment anything+ // called status — and a launch machine that sees SOLIDWORKS already up+ // correctly refuses to start a second copy, so the launch suite went+ // red. The census must describe the machine, not the fact that someone+ // asked about it.+ if self.running {+ let _ = std::fs::write(&f, self.session().to_string());+ } else {+ let _ = std::fs::remove_file(&f);+ }+ }++ /// THE SESSION IS OVER: the simulated process is gone, and the next one is a+ /// NEW SOLIDWORKS — new process id, and the untitled numbering restarts at+ /// Part1, which is the whole reason document ownership is scoped to a+ /// session.+ fn finish_exit(&mut self) {+ self.running = false;+ self.docs.clear();+ self.active = None;+ self.generation += 1;+ self.untitled = 0;+ self.end_session();+ let _ = std::fs::remove_file(Self::session_file());+ crate::comhost::created_store::clear();+ self.publish_session();+ }+ /// The SOLIDWORKS "process" went away: a new one gets a new id. fn end_session(&self) { let _ = std::fs::remove_file(crate::scratch::root().join("mock-solidworks-session"));@@ -487,15 +903,76 @@ impl Backend for MockBackend { } fn call(&mut self, cmd: &str, args: &Value) -> Result<Value, HostError> {+ let out = self.dispatch(cmd, args);+ // The simulated APPLICATION's session outlives the com host, because a+ // real one does. Written after every command so a respawn resumes from+ // exactly where the last one left off.+ self.persist();+ out+ }+}++impl MockBackend {+ fn dispatch(&mut self, cmd: &str, args: &Value) -> Result<Value, HostError> {+ // A SLOW EXIT that has now completed. Applied lazily rather than on a+ // timer so the simulator has no thread of its own and stays exactly as+ // deterministic as the file-backed census that measures it.+ if let Some(at) = self.pending_exit_at_ms {+ if crate::launchos::epoch_ms() as u64 >= at {+ self.pending_exit_at_ms = None;+ self.finish_exit();+ }+ }+ // A COM CALL THAT OVERRAN ITS BUDGET, simulated at the exact boundary+ // the server judges: the message the ComHost produces on a real timeout.+ // Test-only; it is the only way to reach the `host_app_hung` branch,+ // which needs a genuine timeout and not merely a failure.+ if fault("mock-com-timeout") && cmd != "ping" {+ return Err(HostError::timeout(format!(+ "com host did not answer '{cmd}' within 20s (SOLIDWORKS may be busy with a modal \+ dialog or a huge rebuild); it was killed and respawns on the next call"+ )));+ } match cmd { "ping" => Ok(json!({"pong": true, "pid": std::process::id()})), - "status" => Ok(json!({+ "status" => {+ // A REAL `status` CALLS `GetActiveObject`: it does not report a+ // cached flag, it asks the machine. So the mock asks the+ // simulator — otherwise `running` could only ever become true+ // inside `start`, and a session that came up while nobody was+ // calling swx_start (the user signing in, which is exactly the+ // case the sign-in work exists for) was invisible to swx_status.+ if !self.running {+ if let Some(dir) = fake_dir() {+ if crate::launchos::FakeOs::attachable(&dir) {+ self.running = true;+ self.launching = None;+ }+ }+ }+ self.publish_session();+ Ok(json!({ "running": self.running, "version": if self.running { Value::from(MOCK_VERSION) } else { Value::Null }, "openDocs": self.docs.len(), "attached": self.running,- })),+ // THE PID COM WOULD REPORT: the simulated SOLIDWORKS process,+ // not the mock's internal session number. They are the same+ // process, and a scenario where they differ is not one that can+ // occur on a real machine.+ "processId": if self.running {+ Value::from(mock_com_pid().unwrap_or_else(|| {+ fake_dir()+ .and_then(|d| crate::launchos::FakeOs::attached_sldworks_pid(&d))+ .map(|p| p as i64)+ .unwrap_or_else(|| self.session())+ }))+ } else {+ Value::Null+ },+ }))+ } "start" => { // ATTACH vs LAUNCH, modelled faithfully: with launch:false this@@ -508,7 +985,15 @@ impl Backend for MockBackend { // attachable once it has had its time — so an ATTACH (launch: // false) starts failing and then starts succeeding, exactly as // it does while a Connected edition boots.- if let Some(since) = self.launching {+ if let Some(dir) = fake_dir() {+ // The simulator decides. A stalled route reports invoked but+ // never attachable, which is the branch the whole launch+ // state machine exists for.+ if crate::launchos::FakeOs::attachable(&dir) {+ self.running = true;+ self.launching = None;+ }+ } else if let Some(since) = self.launching { let after = std::env::var("SWX_MOCK_SHORTCUT_ATTACHES_AFTER_MS") .ok() .and_then(|v| v.parse::<u64>().ok());@@ -535,6 +1020,9 @@ impl Backend for MockBackend { } let was_launched = !self.running; self.running = true;+ if was_launched && self.docs.is_empty() {+ self.flood();+ } Ok(json!({ "started": true, "version": MOCK_VERSION,@@ -566,6 +1054,57 @@ impl Backend for MockBackend { if self.running { return Ok(json!({"invoked": false, "attached": true, "alreadyRunning": true})); }+ // The SERVER picks the route; the host only carries it out.+ let route = args+ .get("route")+ .and_then(|v| v.as_str())+ .filter(|r| crate::launch::is_route(r))+ .unwrap_or(crate::launch::ROUTES[0])+ .to_string();+ if let Some(dir) = fake_dir() {+ // Running the shortcut, simulated: the invocation is+ // recorded and everything the state machine can observe —+ // the chain, the launcher log, Run Begin, sldworks.exe,+ // attachability — is derived from it by `FakeOs`.+ let seq = crate::launchos::FakeOs::record_invoke(&dir, &route, &shortcut);+ self.launching = Some(std::time::Instant::now());+ // ATTRIBUTION, modelled per route exactly as the real thing+ // can and cannot supply it: the two shell-handle routes hand+ // back the root they started, the other two hand back+ // nothing. And a route that started no process has nothing+ // to attribute whichever route it is.+ let sim = crate::launchos::FakeOs { dir: dir.clone() };+ let inv = sim.invocations().into_iter().find(|i| i.seq == seq);+ // A route attributes only when it CAN in principle AND+ // this particular invocation actually got a handle back.+ let reports_root = sim.route_reports_root(&route);+ let root = match (crate::launch::route_is_attributable(&route), reports_root, inv) {+ (true, true, Some(i)) => Some((i.catstart_pid(), (i.at_ms / 1000) as u64)),+ _ => None,+ };+ return Ok(json!({+ "invoked": true,+ "route": route,+ "rootPid": root.map(|(p, _)| p),+ "rootCreatedAt": root.map(|(_, c)| c),+ "attributionSource": match route.as_str() {+ "shellExecute" => "shellExecuteHandle",+ "powershell" => "startProcessPassThru",+ _ => "none",+ },+ "attributionNote": if root.is_some() {+ Value::Null+ } else {+ Value::from(format!(+ "route '{route}' cannot say what it started, so this bridge will \+ not terminate anything on its behalf"+ ))+ },+ "attached": false,+ "shortcut": shortcut,+ "version": Value::Null,+ }));+ } let attaches = std::env::var("SWX_MOCK_SHORTCUT_ATTACHES").as_deref() == Ok("1"); if attaches { self.running = true;@@ -581,6 +1120,7 @@ impl Backend for MockBackend { } Ok(json!({ "invoked": true,+ "route": route, "attached": attaches, "shortcut": shortcut, "version": if attaches { Value::from(MOCK_VERSION) } else { Value::Null },@@ -599,19 +1139,380 @@ impl Backend for MockBackend { self.generation += 1; self.untitled = 0; self.end_session();+ let _ = std::fs::remove_file(Self::session_file()); crate::comhost::created_store::clear(); Ok(json!({"stopped": true, "stillRunning": false, "rpcDisconnect": Value::Null})) } "docs" => { self.require_running()?;- let docs: Vec<Value> =- self.docs.iter().map(|d| self.doc_info(&d.path)).collect(); let active = match &self.active { Some(p) => self.doc_info(p), None => Value::Null, };- Ok(json!({"docs": docs, "active": active}))+ // SOLIDWORKS DECLINING TO COUNT. Modelled so the+ // unreadable-count branch (which must NOT enumerate) has a test.+ if fault("mock-doccount-none") {+ return Ok(json!({+ "docs": [],+ "count": Value::Null,+ "documentCount": Value::Null,+ "truncated": true,+ "sample": [],+ "active": active,+ "_hint": "SOLIDWORKS would not say how many documents are open, so this bridge did NOT try to enumerate them. `count:null` means UNKNOWN, not zero. Retry.",+ }));+ }+ let count = self.docs.len();+ if count > super::host::doc_enum_cap() {+ // THE WALK, not the marshal: the real backend now visits+ // exactly DOC_SAMPLE documents here, so the mock pays for+ // exactly that many. Before this fix it paid for none, and a+ // path that cost 2144 round trips live looked free.+ charge_docs(self.docs.len().min(super::host::DOC_SAMPLE));+ // Built from the doc's own fields, not via doc_info, to keep+ // the iterator's immutable borrow of self.docs clean — and+ // an import temp shows its synthetic IC~~ path here (as the+ // real backend shows GetPathName) even though it is unsaved.+ let sample: Vec<Value> = self+ .docs+ .iter()+ .take(super::host::DOC_SAMPLE)+ .map(|d| json!({+ "title": d.title,+ "path": d.temp_path.clone().unwrap_or_else(|| if d.saved { d.path.clone() } else { String::new() }),+ "importTemp": d.import_temp,+ }))+ .collect();+ // COUNTED OVER THE SAMPLE ONLY, like the real backend — the+ // session-wide count used to be computed here with one path+ // read per document, which is the 2144-round-trip defect.+ let temps = self+ .docs+ .iter()+ .take(super::host::DOC_SAMPLE)+ .filter(|d| {+ d.temp_path+ .as_deref()+ .map(|p| {+ super::host::is_import_temp_under_any(p, &mock_temp_roots())+ })+ .unwrap_or(false)+ })+ .count();+ return Ok(json!({+ "docs": [],+ "count": count,+ "documentCount": count,+ "truncated": true,+ "sample": sample,+ "active": active,+ "importTempsInSample": temps,+ "importTempsInSampleOf": sample.len(),+ "sampleTruncatedByTime": false,+ "_hint": "too many documents to enumerate in the budget — count plus a sample, not a timeout. Likely leaked import temps; run swx_close_import_temps.",+ }));+ }+ // UNDER the cap the real backend does marshal them all, so the+ // mock pays for all of them — which is what makes the cap itself+ // testable rather than decorative.+ charge_docs(count);+ let docs: Vec<Value> =+ self.docs.iter().map(|d| self.doc_info(&d.path)).collect();+ Ok(json!({"docs": docs, "count": count, "truncated": false, "active": active}))+ }++ // THE QUIT LADDER, per-rung, scripted. `quit-script.json` in the+ // fake dir says what each rung does — declined / exited / dialog /+ // uipiBlocked / skipped — so every branch of the ladder has a test+ // instead of the mock's old "rung (a) always quits", which made+ // rungs (b)-(f) unreachable from any test.+ "quit_rung" => {+ let rung = args.get("rung").and_then(|v| v.as_str()).unwrap_or("").to_string();+ if !crate::quit::is_rung(&rung) {+ return Err(HostError::coded(+ "unknown_rung",+ format!("'{rung}' is not a rung of the quit ladder"),+ ));+ }+ // Every rung releases every reference, exactly as the real one+ // does — the ladder's invariant is that no rung runs, and no+ // measurement is taken, while the host holds an IDispatch.+ // The script entry is either a plain outcome ("exited") or an+ // object: {"result":"exited","exitAfterMs":4000}. The delay is+ // the important one — a real `ExitApp` RETURNS and the process+ // then takes seconds to go, and a single post-rung sample called+ // that "declined" and escalated into an exiting application.+ let entry = fake_dir().and_then(|d| {+ std::fs::read_to_string(d.join("quit-script.json"))+ .ok()+ .and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v.get(&rung).cloned())+ });+ let (outcome, exit_after_ms) = match &entry {+ Some(Value::String(s)) => (s.clone(), 0u64),+ Some(v @ Value::Object(_)) => (+ v.get("result").and_then(|x| x.as_str()).unwrap_or("declined").to_string(),+ v.get("exitAfterMs").and_then(|x| x.as_u64()).unwrap_or(0),+ ),+ // No script at all: with no fake dir there is no census to+ // measure against either, so the simplest useful default is+ // a clean immediate exit.+ _ if fake_dir().is_none() => ("exited".to_string(), 0),+ _ => ("declined".to_string(), 0),+ };+ let mut detail = vec![format!("simulated rung '{rung}': {outcome}")];+ match outcome.as_str() {+ "exited" | "quit" => {+ // ONE source of truth: the census (FakeOs) reads this+ // same file, so the measurement cannot disagree.+ let now_ms = crate::launchos::epoch_ms() as u64;+ let effective = now_ms + exit_after_ms;+ if let Some(d) = fake_dir() {+ let f = d.join("quit.jsonl");+ let mut t = std::fs::read_to_string(&f).unwrap_or_default();+ t.push_str(+ &json!({"rung": rung, "atMs": now_ms, "effectiveAtMs": effective})+ .to_string(),+ );+ t.push('\n');+ let _ = std::fs::write(&f, t);+ }+ // A QUIT ENDS THE SESSION, and the next one is a NEW+ // SOLIDWORKS: new process id, and the untitled numbering+ // restarts at Part1 — which is the whole reason document+ // ownership is scoped to a session. The `stop` command+ // has always modelled this; the ladder must too, or a+ // test could not tell a real quit from a no-op.+ if exit_after_ms == 0 {+ self.finish_exit();+ detail.push(+ "the simulated SOLIDWORKS process exited; the session ended (new \+ process id, untitled numbering restarts)"+ .into(),+ );+ } else {+ // STILL RUNNING for now: the rung returned, the+ // process has not gone yet, and the census says so.+ self.pending_exit_at_ms = Some(effective);+ detail.push(format!(+ "the rung returned; the simulated process exits {exit_after_ms} ms \+ later (it is still in the census until then)"+ ));+ }+ }+ "dialog" | "dialogAppeared" => {+ if let Some(d) = fake_dir() {+ let _ = std::fs::write(+ d.join("dialog-open.json"),+ json!({+ "hwnd": 9001,+ "className": "#32770",+ "title": "Save Modified Documents",+ "visible": true+ })+ .to_string(),+ );+ }+ detail.push("a top-level dialog appeared".into());+ }+ _ => {}+ }+ Ok(json!({+ "rung": rung,+ "result": outcome,+ "detail": detail.join("; "),+ "comReferencesOutstanding": 0,+ }))+ }++ "release_com" => Ok(json!({+ "released": true,+ "outstanding": 0,+ "note": "the mock holds no IDispatch; the count is zero by construction",+ })),++ "com_refs" => Ok(json!({"outstanding": 0})),++ "close_import_temps" => {+ self.require_running()?;+ let max = args.get("max").and_then(|v| v.as_u64()).unwrap_or(200).max(1) as usize;+ let before = mock_doc_count(self.docs.len());+ // AN UNREADABLE %TEMP% IS A REFUSAL, NOT AN EMPTY DRAIN. The+ // anchor is the whole location proof, so without it nothing can+ // be identified as a temp — and reporting `closed:0, done:true`+ // for that would be the silent no-op this bridge keeps being+ // rewritten to stop making.+ let roots = mock_temp_roots();+ if roots.is_empty() {+ return Ok(json!({+ "closed": 0,+ "closedDocs": [],+ "documentCountBefore": before,+ "documentCountAfter": before,+ "done": false,+ "batchLimit": max,+ "tempRoot": Value::Null,+ "tempRoots": [],+ }));+ }+ // BY PATH, through the SAME `is_import_temp` the real backend+ // calls — not by a mock-only flag. The flag would have made the+ // mock prove something about itself while the real drain proved+ // something about paths, which is exactly how F1 (a loose path+ // proof that closed user documents) had no e2e test.+ let is_temp = |d: &MockDoc| -> bool {+ d.temp_path+ .as_deref()+ .map(|p| super::host::is_import_temp_under_any(p, &roots))+ .unwrap_or(false)+ };+ // ── THE WINDOW IS THE WHOLE POINT. The real backend walks a+ // bounded window (about 4x the batch) one document at a time+ // and reasons ONLY over what it passed. The mock used to select+ // victims and check collisions across the entire session, so it+ // closed documents its own reported scan never reached — and+ // every window property of the real backend was untested.+ let window = self.docs.len().min(max.saturating_mul(4).max(max + 50));+ charge_docs(window);+ // The window as (title, reported path, is-a-temp, has-the-tail) —+ // everything the real backend learns by walking, and nothing it+ // could not have learned that way.+ let walked: Vec<(String, String, bool, bool)> = self+ .docs+ .iter()+ .take(window)+ .map(|d| {+ (+ d.title.clone(),+ Self::reported_path(d).to_string(),+ is_temp(d),+ d.temp_path+ .as_deref()+ .map(super::host::has_import_temp_tail)+ .unwrap_or(false),+ )+ })+ .collect();+ let walk_complete = window >= self.docs.len();+ // The NEAR MISSES, over the WINDOW: the launcher's tail under a+ // directory that is not one of the roots this process resolved.+ let elsewhere: Vec<&(String, String, bool, bool)> =+ walked.iter().filter(|(_, _, temp, tail)| !*temp && *tail).collect();+ let elsewhere_count = elsewhere.len() as u64;+ let elsewhere_sample = elsewhere.first().map(|(_, p, _, _)| p.clone());+ let total_temps = walked.iter().filter(|(_, _, temp, _)| *temp).count();+ // THE VICTIM IS (title, reported path) — the same pair the real+ // backend collects, because the close key is the whole point of+ // this round.+ let victims: Vec<(String, String)> = walked+ .iter()+ .filter(|(_, _, temp, _)| *temp)+ .take(max)+ .map(|(t, p, _, _)| (t.clone(), p.clone()))+ .collect();+ let hit_limit = total_temps > victims.len();+ // A TITLE IS NOT AN IDENTITY. Any other open document sharing a+ // victim's title makes `CloseDoc` ambiguous — SOLIDWORKS would+ // pick — so the victim is skipped and the collision reported.+ let mut collisions: Vec<Value> = Vec::new();+ let victims: Vec<(String, String)> = victims+ .into_iter()+ .filter(|(title, path)| {+ // OVER THE WINDOW, exactly as the real backend does — a+ // same-titled document outside it is invisible to this+ // check, which is what `titleCollisionsCheckedOver` says.+ let others: Vec<String> = walked+ .iter()+ .filter(|(t, p, _, _)| t == title && !crate::verbs::same_path(p, path))+ .map(|(_, p, _, _)| p.clone())+ .collect();+ if others.is_empty() {+ return true;+ }+ let mut paths = vec![path.clone()];+ paths.extend(others);+ collisions.push(json!({"title": title, "paths": paths}));+ false+ })+ .collect();+ let mut closed = Vec::new();+ let mut wrong_document_closed = false;+ let mut close_failed: Vec<Value> = Vec::new();+ let mut close_ignored_path = false;+ let mut close_outcome_unknown = false;+ for (title, path) in &victims {+ // BY NAME, exactly as CloseDoc resolves it — see+ // `close_doc_by_name`. Passing the title here would close+ // whichever document SOLIDWORKS found first, which is the+ // defect the previous round existed for.+ let count_before_this = mock_doc_count(self.docs.len());+ let asked = self.close_doc_by_name(path);+ let still_open = self.find_by_name(path).is_some();+ if asked && still_open {+ // The SAME discriminator the real backend uses, including+ // its third case: an UNREADABLE count cannot tell "nothing+ // happened" from "something else closed", and saying so is+ // the only honest answer.+ let now = mock_doc_count(self.docs.len());+ let nothing_happened = match (count_before_this, now) {+ (Some(e), Some(n)) => n >= e,+ _ => {+ close_outcome_unknown = true;+ close_failed.push(json!({+ "title": title, "path": path, "outcome": "undetermined",+ "documentCountBeforeClose": count_before_this,+ "why": "CloseDoc returned success, the document is still open, \+ and the count could not be read afterwards"}));+ break;+ }+ };+ if nothing_happened {+ close_ignored_path = true;+ close_failed.push(json!({"title": title, "path": path,+ "why": "CloseDoc(path) did nothing and the count did not move"}));+ break;+ }+ wrong_document_closed = true;+ close_failed.push(json!({"title": title, "path": path,+ "why": "CloseDoc reported success, the document is still open, and the \+ count DROPPED"}));+ break;+ }+ if !asked || still_open {+ close_failed.push(json!({"title": title, "path": path,+ "why": "SOLIDWORKS declined to close it"}));+ continue;+ }+ closed.push(json!({"title": title, "path": path}));+ }+ let after = mock_doc_count(self.docs.len());+ Ok(json!({+ "closed": closed.len(),+ "closedDocs": closed,+ "documentCountBefore": before,+ "documentCountAfter": after,+ "done": !hit_limit+ && walk_complete+ && collisions.is_empty()+ && close_failed.is_empty()+ && !(elsewhere_count > 0 && closed.is_empty()),+ "batchLimit": max,+ "tempRoot": roots.first(),+ "tempRoots": roots,+ "importTempsElsewhere": elsewhere_count,+ "importTempsElsewhereSample": elsewhere_sample,+ "documentsScanned": window,+ "walkTruncatedByTime": false,+ "walkComplete": walk_complete,+ "walkStop": if walk_complete { "reachedEnd" } else { "hitMax" },+ "titleCollisions": collisions,+ "titleCollisionsCheckedOver": window,+ "closeFailed": close_failed,+ "wrongDocumentClosed": wrong_document_closed,+ "closeIgnoredPath": close_ignored_path,+ "closeOutcomeUnknown": close_outcome_unknown,+ })) } "open" => {@@ -904,9 +1805,26 @@ impl Backend for MockBackend { &path, "interference detection needs an assembly (.sldasm)", )?;+ // SOLIDWORKS DECLINING TO COUNT, modelled — because `count:+ // null` is the most consequential null in this bridge (a+ // clearance report somebody builds a physical part from) and a+ // shape no test could reach is a shape nothing checks.+ if fault("mock-interference-count-unknown") {+ return Ok(json!({+ "doc": self.doc_info(&path),+ "count": Value::Null,+ "countKnown": false,+ "interferences": [+ {"components": ["bracket-1", "shaft-1"], "volumeM3": 2.1e-08}+ ],+ "listed": 1,+ }));+ } Ok(json!({ "doc": self.doc_info(&path), "count": 1,+ "countKnown": true,+ "listed": 1, "interferences": [ {"components": ["bracket-1", "shaft-1"], "volumeM3": 2.1e-08} ],@@ -970,6 +1888,15 @@ impl Backend for MockBackend { "new_document" => { self.running = true; let kind = args.get("type").and_then(|v| v.as_str()).unwrap_or("part");+ // EXHAUSTION, as the real thing reports it: SOLIDWORKS accepts+ // the call and hands back nothing. Observed live on 2026-09-03+ // for BOTH the part and the assembly template, at 17.4 GB and+ // 52 open documents.+ if fault("mock-newdoc-empty") {+ return Err(HostError::msg(+ "SOLIDWORKS created no document from C:\\ProgramData\\SolidWorks\\templates\\Part.prtdot (is the template valid?)",+ ));+ } // The REAL untitled names SOLIDWORKS uses. A mock that invents // its own ("Assembly1") lets a test assert on a title the live // application will never produce.@@ -1212,15 +2139,100 @@ impl Backend for MockBackend { // document titled `smoke.SLDPRT`. The mock used the bare stem, // so it could not express the title collision the live run hit // when the suite imported a STEP beside its own saved part.+ let before = self.docs.len() as i32; let title = format!("{}.SLDPRT", file_stem(&path));- let key = format!("untitled:{title}");+ // A UNIQUE key per import (SOLIDWORKS keeps every leaked temp as+ // its own document), and a synthetic IC~~ temp path so the drain+ // verb and the cap have something faithful to match — this is+ // how the 1342-temp leak is modelled.+ let n = self.untitled;+ let key = format!("untitled:import:{n}:{title}");+ // LIVE, 2026-09-11: a fresh `LoadFile4` import has NO PATH AT+ // ALL. The `IC~~` conversion paths belonged to the older route+ // that produced the 1342 leaked temps. `mock-import-pathless`+ // models the live behaviour so the identity-at-birth step, and+ // the drain's ownership-based rule, have the real shape to work+ // against.+ // BUG C's shape: the document IS created and the call then+ // times out. The server must reconcile on the respawned host+ // rather than leaving a document nobody owns.+ if fault("mock-import-timeout-after-open") {+ // A SECOND document appearing in the same window — the user+ // opening something of their own while our call hangs. The+ // session then gains two, and only one of them is ours.+ if fault("mock-open-extra-doc") {+ let mut theirs = MockDoc::opened(+ "C:\\users\\drew\\theirs.SLDPRT".into(),+ "part",+ "Default".into(),+ );+ theirs.title = "theirs.SLDPRT".into();+ theirs.saved = true;+ self.docs.push(theirs);+ }+ let title = format!("{}.SLDPRT", file_stem(&path));+ let n = self.untitled;+ let key = format!("untitled:import:{n}:{title}");+ let mut doc = MockDoc::opened(key.clone(), "part", "Default".into());+ doc.title = title;+ doc.saved = false;+ doc.import_temp = true;+ doc.temp_path = None;+ self.docs.push(doc);+ self.active = Some(key.clone());+ self.publish_session();+ // IT MADE THE DOCUMENT. The error carries it, so the host+ // owns what it made — exactly as the real backend now does by+ // registering at birth rather than on the way out.+ let info = self.doc_info(&key);+ return Err(HostError::timeout(+ "com host did not answer 'import' within 280s (the file was already loaded when it stopped answering)",+ )+ .with_created(info));+ }+ if fault("mock-import-pathless") {+ let title = format!("{}.SLDPRT", file_stem(&path));+ let n = self.untitled;+ let key = format!("untitled:import:{n}:{title}");+ let mut doc = MockDoc::opened(key.clone(), "part", "Default".into());+ doc.title = title;+ doc.saved = false;+ doc.import_temp = true;+ doc.temp_path = None;+ doc.bodies.push(mock_body("Imported1", 50.0, 30.0, 20.0));+ self.docs.push(doc);+ self.active = Some(key.clone());+ let after = self.docs.len() as i32;+ return Ok(json!({+ "imported": {"title": self.docs.last().map(|d| d.title.clone()),+ "path": "", "type": "part"},+ "documentCountBefore": before,+ "documentCountAfter": after,+ "net": after - before,+ "netCountOk": after - before == 1,+ }));+ }+ let temp_path =+ format!("{MOCK_TEMP_ROOT}\\swx4242\\IC~~\\{}-{n}.SLDPRT", file_stem(&path)); let mut doc = MockDoc::opened(key.clone(), "part", "Default".into()); doc.title = title; doc.saved = false;+ doc.import_temp = true;+ doc.temp_path = Some(temp_path); doc.bodies.push(mock_body("Imported1", 50.0, 30.0, 20.0)); self.docs.push(doc); self.active = Some(key.clone());- Ok(json!({"imported": self.doc_info(&key), "via": "LoadFile4", "errors": 0}))+ let after = self.docs.len() as i32;+ let net = after - before;+ Ok(json!({+ "imported": self.doc_info(&key),+ "via": "LoadFile4",+ "errors": 0,+ "documentsOpened": net,+ "netCountOk": net == 1,+ "documentCount": after,+ "_hint": "Imported. This left ONE new open, unsaved document (like swx_new_document) — it stays open and holds memory until you save or close it. Importing repeatedly without closing is what leaks documents; swx_close_import_temps drains any left behind.",+ })) } "save_as" => {@@ -1299,8 +2311,30 @@ impl Backend for MockBackend { })) } + // NOTE ON COST: `app_state` reads the COUNT first and enumerates only+ // under the cap — so it is charged inside that branch, below. "app_state" => {- let docs: Vec<Value> = self.docs.iter().map(|d| self.doc_info(&d.path)).collect();+ self.publish_session();+ // A com host that cannot answer. The point of the switch is that+ // the SERVER must treat this as "I do not know" and keep its+ // ownership registry — which is bug #5.+ if fault("mock-fail-app-state") {+ return Err(HostError::msg(+ "com host did not answer 'app_state' (simulated: SOLIDWORKS is busy)",+ ));+ }+ // DEGRADE ABOVE THE CAP, exactly as the real backend does: this is+ // read on nearly every verb, so a flooded session must report+ // the count and set documentsTruncated rather than enumerate.+ let count_known = !fault("mock-doccount-none");+ let count = self.docs.len();+ let truncated = !count_known || count > super::host::doc_enum_cap();+ let docs: Vec<Value> = if truncated {+ Vec::new()+ } else {+ charge_docs(count);+ self.docs.iter().map(|d| self.doc_info(&d.path)).collect()+ }; let active = self.active.as_ref().map(|p| self.doc_info(p)); Ok(json!({ "running": self.running,@@ -1308,7 +2342,8 @@ impl Backend for MockBackend { "activeDocument": active.clone().map(|a| a.get("title").cloned().unwrap_or(Value::Null)).unwrap_or(Value::Null), "activeDoc": active, "documents": docs,- "documentCount": self.docs.len(),+ "documentsTruncated": truncated,+ "documentCount": if count_known { Value::from(count) } else { Value::Null }, "unsavedDocuments": self.docs.iter().filter(|d| d.dirty).count(), "workspace": self .active@@ -1318,7 +2353,10 @@ impl Backend for MockBackend { .unwrap_or("none"), "busy": false, "visible": self.running,- "processId": self.session(),+ // THE SAME PER-CALL SEQUENCE `status` uses: this is the read+ // the quit ladder's precondition sweep makes, so it is the+ // one that has to be able to change between rungs.+ "processId": mock_com_pid().unwrap_or_else(|| self.session()), "mainHwnd": if self.running { json!(4242) } else { Value::Null }, })) }@@ -1326,7 +2364,11 @@ impl Backend for MockBackend { "check_recovery" => { self.require_running()?; Ok(json!({- "openDocuments": self.docs.len(),+ "openDocuments": if fault("mock-doccount-unknown") {+ Value::Null+ } else {+ Value::from(self.docs.len())+ }, "comApiHasRecoveryCheck": false, "note": "SOLIDWORKS exposes no COM member for the Document Recovery pane; \ the server pairs this with the window census, which is where a \@@ -1454,6 +2496,18 @@ impl Backend for MockBackend { "add_component" => { self.require_running()?;+ // AN UNCOUNTABLE ASSEMBLY. The real backend refuses to run the+ // insert ladder when it cannot tell whether the last insert+ // worked, because the next method would add the component a+ // SECOND time.+ if fault("mock-component-count-unknown") {+ return Err(HostError::coded(+ "component_count_failed",+ "SOLIDWORKS would not report how many components the assembly has, so \+ this bridge cannot tell whether the insert succeeded — and trying the \+ next insert method could add it TWICE. Nothing further was attempted.",+ ));+ } let path = self.resolve(args)?; self.require_assembly( &path,@@ -1594,6 +2648,14 @@ impl Backend for MockBackend { json!({"property": name, "value": value, "code": 1}) } Op::SaveAs { path } => {+ if let crate::writes::Destination::Unverifiable(io) =+ crate::writes::destination_state(Some(path))+ {+ return Err(HostError::coded(+ "destination_unverifiable",+ crate::writes::unverifiable_message(path, &io),+ ));+ } if std::path::Path::new(path).exists() { return Err(HostError::msg(format!( "refusing to overwrite an existing file: {path}"@@ -1650,7 +2712,7 @@ mod tests { #[test] fn open_then_resolve_active_then_close_falls_back() {- let mut b = MockBackend::new();+ let mut b = MockBackend::fresh(); // launch:true because opening no longer starts SOLIDWORKS on its own — // the SERVER decides that, per edition (see swx_open / may_launch). b.call("open", &json!({"path": "C:\\m\\a.sldasm", "launch": true})).unwrap();@@ -1667,14 +2729,14 @@ mod tests { fn opening_a_document_does_not_start_solidworks_by_itself() { // swx_open used to pass launch:true unconditionally, which made it a // second road to CoCreateInstance that skipped the edition check.- let mut b = MockBackend::new();+ let mut b = MockBackend::fresh(); let e = b.call("open", &json!({"path": "C:\\m\\a.sldprt"})).unwrap_err(); assert!(e.message.contains("not running"), "{}", e.message); } #[test] fn bom_requires_an_assembly() {- let mut b = MockBackend::new();+ let mut b = MockBackend::fresh(); b.call("open", &json!({"path": "C:\\m\\a.sldprt", "launch": true})).unwrap(); let e = b.call("bom", &json!({})).unwrap_err(); assert!(e.message.contains("assembly"), "{}", e.message);
src/comhost/mod.rs+48−4@@ -53,15 +53,25 @@ pub struct SidecarError { 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, } impl SidecarError { fn new(message: impl Into<String>, detail: Option<String>) -> Self {- Self { message: message.into(), detail, code: None }+ Self { message: message.into(), detail, code: None, timed_out: false }+ }++ fn timeout(message: impl Into<String>, detail: Option<String>) -> Self {+ Self { message: message.into(), detail, code: None, timed_out: true } } fn coded(message: impl Into<String>, detail: Option<String>, code: Option<String>) -> Self {- Self { message: message.into(), detail, code }+ Self { message: message.into(), detail, code, timed_out: false } } } @@ -257,14 +267,17 @@ impl ComHost { 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());- return Err(SidecarError::coded(message, detail, code));+ let mut e = SidecarError::coded(message, detail, code);+ // 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::new(+ 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 \@@ -432,6 +445,37 @@ fn spawn_child() -> Result<Live, SidecarError> { 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); }
src/comhost/variant.rs+7@@ -451,6 +451,13 @@ impl Disp { message: format!("SOLIDWORKS was busy and rejected '{name}'"), detail: Some(format!("{e}")), code: Some("com_call_rejected".to_string()),+ // A REJECTION, not a timeout: SOLIDWORKS answered promptly+ // that it was busy. Reporting it as a timeout would supply+ // half of a `host_app_hung` verdict for a message loop that+ // is running perfectly.+ timed_out: false,+ // A rejected call did nothing at all, so it made nothing.+ created: None, }); } }
src/launch.rsadded+3793@@ -0,0 +1,3793 @@+//! LAUNCH RESILIENCE — the state machine that starts SOLIDWORKS and recovers+//! when the 3DEXPERIENCE launcher chain stalls.+//!+//! ## What went wrong, measured+//!+//! On 2026-09-04, five of five launches this bridge made produced a+//! `SWXDesktopLauncher` log that stopped at `SecurityContext:` and went no+//! further: no `Run Begin`, no helper log, no `sldworks.exe`. `CATSTART.exe` and+//! `SWXDesktopLauncher.exe` sat idle at ~50 MB forever. Every launch of the SAME+//! shortcut with the SAME arguments from an INTERACTIVE context — a Start-Menu+//! click, the crash reporter's "Restart SOLIDWORKS Design" button — reached+//! `Run Begin` about two seconds later and started SOLIDWORKS.+//!+//! The difference is the startup context. The bridge ran the shortcut through+//! `cmd.exe /c start` with `DETACHED_PROCESS | CREATE_NO_WINDOW`, and the+//! launcher's Run thread never started. That is a HYPOTHESIS, not a proven+//! cause, and this module is written so it does not need to be right: it tries+//! routes in order, DETECTS the stall from the launcher's own log, cleans up+//! after itself, and moves to the next one — then remembers which route worked+//! so the next launch starts there.+//!+//! ## The four failures this module answers+//!+//! | # | Failure | Answer here |+//! |---|---|---|+//! | A | the chain stalls before `Run Begin` | [`Supervisor`] watches the log, kills ONLY the chain it started, tries the next route |+//! | B | a second `swx_start` fires the launcher again | the attempt is persisted; a call during flight NEVER launches (`stillLaunching`) |+//! | C | SOLIDWORKS crashes on first spawn and the reporter appears | [`CRASH_RESTART_BUTTON`] is clicked ONCE, ledgered, and never anything else |+//! | D | SOLIDWORKS is up but hung or memory-exhausted | [`host_app_hung`] / [`host_app_exhausted`], with the honest "only the user can end it" |+//!+//! ## The kill allowlist — the only processes this bridge may ever terminate+//!+//! [`TERMINABLE_IMAGES`], and only instances CREATED AFTER our own invoke.+//! Everything else is off limits, permanently:+//!+//! * **`3DEXPERIENCELauncher*` daemons are NEVER touched.** They are the+//! Platform's own background services. A successful launch on 2026-09-04+//! had no Backbone running at all, so they are not even required — killing+//! them buys nothing and breaks the user's Platform session. A real user+//! launch rebuilds them; this bridge does not get to decide that.+//! * **`sldworks.exe` is NEVER killed here.** Ending the user's CAD session is+//! `swx_kill`'s job, and that verb is gated behind the process-level write+//! opt-in, a `reason` and a `confirm` for exactly this reason.+//! * **No image-name kill anywhere.** Every terminate is by pid, re-checked+//! against the live census. See [`crate::launchos::LaunchOs::terminate`].+//!+//! ## Round 2 (0.4.1) — what the adversary found in the above+//!+//! The first version scoped the kill list BY CLOCK: every terminable image+//! younger than our invoke. Two consequences, both proved by test:+//!+//! * a chain the USER started while ours was in flight was swept up with ours+//! (pids 70003/70004) — and twenty seconds of a screen doing nothing is+//! exactly when somebody reaches for their own Start Menu;+//! * a one-second "clock slack" was applied to the PROCESS's age rather than+//! to ours, so a chain created a second BEFORE our invoke was terminable.+//!+//! The kill list is now the chain we RECORDED — pids that appeared after our+//! invoke and were absent from the baseline census taken immediately before it,+//! plus their descendants by parent pid — each re-verified as (pid, image,+//! creation time) on the open handle at the moment of the kill. There is no+//! backward slack anywhere, and `swx-bridge` reads its own clock through the+//! same call the creation times come from.++use crate::launchos::{epoch_secs, LaunchOs, ProcInfo};+use serde_json::{json, Value};+use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};+use std::sync::{Arc, Mutex};+use std::time::Duration;++// ── routes ──────────────────────────────────────────────────────────────────++/// How the shortcut is invoked, in the order this bridge tries them.+///+/// **ATTRIBUTABLE ROUTES FIRST.** The order used to be about which mechanism+/// was most likely to make the launcher work; it is now about which one can+/// tell us WHAT IT STARTED, because a route that cannot is a route whose chain+/// this bridge may never clean up:+///+/// | Route | Attribution | Show state |+/// |---|---|---|+/// | `shellExecute` | `SEE_MASK_NOCLOSEPROCESS` -> `hProcess` -> pid + creation time | explicit `SW_SHOWNORMAL` |+/// | `powershell` | `Start-Process -PassThru` prints the pid; the child is waited on | `Start-Process`'s own normal |+/// | `explorer` | **NONE** — the shell forwards the link and returns nothing | the shell's own |+/// | `cmdStart` | **NONE** — `start` returns as soon as it has handed off | detached, no window |+///+/// `explorer` and `cmdStart` are kept because they may be the routes that+/// actually WORK (that is still unmeasured), but a stall on either is reported+/// as `cleanup:"notAttributable"` and nothing is terminated.+pub const ROUTES: &[&str] = &["shellExecute", "powershell", "explorer", "cmdStart"];++/// Can this route say what it started? Routes that cannot are still tried and+/// still watched — they simply never earn the right to end a process.+pub fn route_is_attributable(route: &str) -> bool {+ matches!(route, "shellExecute" | "powershell")+}++/// A route name we will actually invoke. Anything else is refused rather than+/// passed to a process spawner.+pub fn is_route(name: &str) -> bool {+ ROUTES.contains(&name)+}++/// The plan for one attempt: the remembered route first (if it is still a real+/// route), then every other route in the standard order. No duplicates.+pub fn route_plan(preferred: Option<&str>) -> Vec<String> {+ let mut plan: Vec<String> = Vec::new();+ if let Some(p) = preferred.filter(|p| is_route(p)) {+ plan.push(p.to_string());+ }+ for r in ROUTES {+ if !plan.iter().any(|p| p == r) {+ plan.push((*r).to_string());+ }+ }+ plan+}++// ── the kill allowlist ──────────────────────────────────────────────────────++/// The ONLY images this bridge may ever terminate as part of a launch recovery.+pub const TERMINABLE_IMAGES: &[&str] = &["CATSTART.exe", "SWXDesktopLauncher.exe"];++/// Images that are off limits whatever else is true. `3DEXPERIENCELauncher` is+/// matched as a PREFIX so a renamed or versioned daemon cannot slip through a+/// list of exact names.+pub const NEVER_TERMINATE_PREFIXES: &[&str] = &["3DEXPERIENCELauncher"];+/// In the census as PARENT LINKS, and never as targets. The shells are how a+/// chain root is attributed; terminating one would take the user's desktop+/// (explorer.exe) or this bridge's own tooling with it.+pub const NEVER_TERMINATE: &[&str] = &[+ "SLDWORKS.exe",+ "sldworks.exe",+ "swCefSubProc.exe",+ "explorer.exe",+ "cmd.exe",+ "powershell.exe",+ "pwsh.exe",+ "swx-bridge.exe",+];++/// MAY THIS BRIDGE TERMINATE THIS PROCESS? The whole safety property, as one+/// pure function, so every branch of it is testable and none of it depends on+/// what a caller passed in.+///+/// Four conditions, ALL required:+/// 1. the image is on [`TERMINABLE_IMAGES`];+/// 2. the image is not on either never-terminate list;+/// 3. the creation time is KNOWN — an unreadable creation time is "not ours",+/// never "probably ours";+/// 4. it was created **at or after** our own invoke. NO BACKWARD SLACK: the+/// first version subtracted a second here "for clock skew", which made a+/// chain created a second BEFORE our invoke terminable — and a second is+/// exactly how long it takes somebody to click their own Start Menu while+/// an agent is telling them it is starting SOLIDWORKS. Skew is dealt with+/// where it belongs instead: `LaunchOs::now()` reads the SAME clock the+/// creation times come from, through the same conversion.+///+/// **This function is necessary and not sufficient.** It answers "could this+/// class of process ever be ours"; WHICH processes are ours is decided by+/// [`chain_candidates`] from a baseline census, and the kill itself re-verifies+/// identity on the open handle.+pub fn may_terminate(image: &str, created_at: Option<u64>, invoked_at: u64) -> bool {+ if NEVER_TERMINATE.iter().any(|n| n.eq_ignore_ascii_case(image)) {+ return false;+ }+ let lower = image.to_ascii_lowercase();+ if NEVER_TERMINATE_PREFIXES.iter().any(|p| lower.starts_with(&p.to_ascii_lowercase())) {+ return false;+ }+ if !TERMINABLE_IMAGES.iter().any(|t| t.eq_ignore_ascii_case(image)) {+ return false;+ }+ match created_at {+ None => false,+ Some(at) => at >= invoked_at,+ }+}++/// THE ROOT OF OUR CHAIN, as the invoke itself reported it.+///+/// `None` means the route could not say — and that is a first-class answer, not+/// a gap to be filled in by looking around the machine.+#[derive(Clone, Debug, PartialEq)]+pub struct Attribution {+ pub pid: u32,+ pub created_at: Option<u64>,+ /// How we came to believe it: `shellExecuteHandle`, `startProcessPassThru`.+ pub source: String,+}++impl Attribution {+ pub fn to_json(&self) -> Value {+ json!({"pid": self.pid, "createdAt": self.created_at, "source": self.source})+ }+ /// The identity this pins, for `ProcInfo::same_process`.+ pub fn as_proc(&self, image: &str) -> ProcInfo {+ ProcInfo { pid: self.pid, image: image.to_string(), created_at: self.created_at, ppid: None }+ }+}++/// **NO HANDLE, NO KILL.**+///+/// The law this module is built on, stated once. A process may be terminated by+/// this bridge only if its membership of our launcher chain descends, through+/// parent links, from a root the INVOKE ITSELF handed back — a `hProcess` from+/// `ShellExecuteExW`, or a pid printed by `Start-Process -PassThru`. Nothing+/// found by searching the machine afterwards is ever adopted.+///+/// The rule this replaces was "the first CATSTART to appear after our invoke, in+/// census order, is ours". Three things made that a guess and not an inference:+///+/// * census order is roughly pid order and pids do not record who asked;+/// * parentage could not break the tie, because the census contained no+/// shells, so the root's parent link always dangled;+/// * on the `explorer` route our CATSTART and a Start-Menu CATSTART have+/// literally the SAME parent — the user's `explorer.exe`.+///+/// So on a run where our route started nothing at all (which this bridge now+/// detects and names), a launch the user started while waiting was the only+/// candidate — and was adopted, and terminated. That is the finding this rule+/// exists to make structurally impossible.+pub struct AttributionRule;++/// Grow the chain DOWNWARD from pids already proven ours.+///+/// Strictly descendants: a seed pid is never re-adopted as a member by its+/// number. Membership of the seeds themselves is re-confirmed elsewhere with+/// [`ProcInfo::same_process`] against the recorded identities — because CATSTART+/// exits about three seconds into a healthy launch and its number is then free+/// for the rest of a four-minute watch, so anything that takes it and happens to+/// be a launcher image would otherwise be re-adopted by number alone.+///+/// With NO seeds there is nothing to descend from and the answer is EMPTY. That+/// is the whole of the fix: this function can no longer invent a root.+pub fn chain_candidates(+ census: &[ProcInfo],+ baseline_pids: &[u32],+ seed_pids: &[u32],+ invoked_at: u64,+) -> Vec<ProcInfo> {+ // The earliest creation time among the seeds: nothing descending from them+ // can be older than the oldest of them.+ let root_created = census+ .iter()+ .filter(|p| seed_pids.contains(&p.pid))+ .filter_map(|p| p.created_at)+ .min();+ let mut ids: Vec<u32> = seed_pids.to_vec();+ let mut ours: Vec<ProcInfo> = Vec::new();+ let mut changed = true;+ while changed {+ changed = false;+ for p in census {+ if ids.contains(&p.pid) || baseline_pids.contains(&p.pid) {+ continue;+ }+ if !may_terminate(&p.image, p.created_at, invoked_at) {+ continue;+ }+ // DESCENT IS THE ONLY WAY IN. No root rule, no "first one wins".+ // And a child cannot pre-date its parent: a pid whose number+ // happens to point at one of ours, on a process that already+ // existed when ours started, is not our child.+ let after_parent = match (p.created_at, root_created) {+ (Some(child), Some(root)) => child >= root,+ _ => true,+ };+ if after_parent && p.ppid.map(|pp| ids.contains(&pp)).unwrap_or(false) {+ ids.push(p.pid);+ ours.push(p.clone());+ changed = true;+ }+ }+ }+ ours+}++/// The whole chain: the attributed ROOT (re-confirmed as the same process), the+/// members we already confirmed, and everything descending from them.+///+/// Returns `None` when there is no attribution — the caller must then record+/// nothing and, on a stall, report that a chain may remain which it cannot+/// identify.+pub fn attributed_chain(+ census: &[ProcInfo],+ root: Option<&Attribution>,+ recorded: &[ProcInfo],+ baseline_pids: &[u32],+ invoked_at: u64,+) -> Option<Vec<ProcInfo>> {+ let root = root?;+ // A root whose creation time we could not read is not an identity.+ root.created_at?;+ let mut chain: Vec<ProcInfo> = Vec::new();+ // The root is a MEMBER only if the census can see it and it is terminable.+ if let Some(live) = census+ .iter()+ .find(|c| c.pid == root.pid && c.created_at == root.created_at)+ {+ if may_terminate(&live.image, live.created_at, invoked_at) {+ chain.push(live.clone());+ }+ }+ // Members confirmed on an earlier pass, re-confirmed now as the SAME+ // processes — never by number.+ for r in recorded {+ if chain.iter().any(|c| c.pid == r.pid) {+ continue;+ }+ if let Some(live) = census.iter().find(|c| c.same_process(r)) {+ chain.push(live.clone());+ }+ }+ // THE ROOT PID SEEDS THE WALK UNCONDITIONALLY. Being terminable and being+ // proof of lineage are different things, and the first version conflated+ // them: `ShellExecuteExW` hands back whatever process the shell created for+ // the `.lnk`, and if that is anything but the two watched launcher images —+ // a shim, a wrapper, a future Platform build — the root was never seeded,+ // the walk had nothing to descend from, and the real CATSTART it started+ // (whose ppid is right there in the census, pointing at our handle-proven+ // pid) was never attributed. The supervisor then read the empty result as+ // "nothingToClean" and moved on with a chain of ours still running.+ let mut seeds: Vec<u32> = chain.iter().map(|p| p.pid).collect();+ // ...UNLESS THE MACHINE CONTRADICTS IT. A census entry with the root's pid+ // and a DIFFERENT creation time means that number now belongs to something+ // else, so its children are somebody else's children and the lineage is+ // void. A root the census simply cannot SEE (an unwatched image, or one+ // that has already exited) is not contradicted — that is the whole point.+ let contradicted = census+ .iter()+ .any(|c| c.pid == root.pid && c.created_at.is_some() && c.created_at != root.created_at);+ if !contradicted && !seeds.contains(&root.pid) {+ seeds.push(root.pid);+ }+ chain.extend(chain_candidates(census, baseline_pids, &seeds, invoked_at));+ Some(chain)+}++// ── stages ──────────────────────────────────────────────────────────────────++/// How far the launch has got. Reported verbatim to callers as `launch.stage`.+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]+pub enum Stage {+ /// The route was invoked; nothing has been observed yet.+ Invoked,+ /// A NEW `SWXDesktopLauncher` log appeared — the chain is running.+ LauncherStarted,+ /// The launcher's second thread logged `Run Begin`: it is locating and+ /// spawning `sldworks.exe`. This is the transition that never happened.+ RunBegin,+ /// A `SWXDesktopHelper` log appeared: sign-in / licence.+ HelperSignIn,+ /// `sldworks.exe` exists.+ SldworksUp,+ /// `sldworks.exe` exists and we are polling for a COM server.+ Attaching,+ /// COM-attached. Terminal, and the only success.+ Attached,+ /// This route stalled and its chain was cleaned up. Terminal for the route.+ Stalled,+ /// Every route was tried. Terminal for the attempt.+ Exhausted,+}++impl Stage {+ pub fn name(&self) -> &'static str {+ match self {+ Stage::Invoked => "invoked",+ Stage::LauncherStarted => "launcherStarted",+ Stage::RunBegin => "runBegin",+ Stage::HelperSignIn => "helperSignIn",+ Stage::SldworksUp => "sldworksUp",+ Stage::Attaching => "attaching",+ Stage::Attached => "attached",+ Stage::Stalled => "stalled",+ Stage::Exhausted => "exhausted",+ }+ }++ /// What a caller should DO about this stage. Every one of these is written+ /// for an AI reading the response and nothing else.+ pub fn advice(&self) -> &'static str {+ match self {+ Stage::Invoked => "The launcher was invoked moments ago. Poll swx_status.",+ Stage::LauncherStarted =>+ "The launcher chain is running but has not reached 'Run Begin' yet. This is the \+ stage that stalls; the bridge is watching for it and will clean up and try the \+ next route on its own. Do NOT invoke the shortcut any other way.",+ Stage::RunBegin =>+ "The launcher is locating and spawning sldworks.exe. SOLIDWORKS appears within a \+ few seconds; COM attach follows in 1-3 minutes.",+ Stage::HelperSignIn =>+ "SOLIDWORKS is signing in to the 3DEXPERIENCE tenant. This can stop at a sign-in \+ window only the user can answer — if it sits here, ask them to look at their screen.",+ Stage::SldworksUp | Stage::Attaching =>+ "sldworks.exe is up; the COM server is not answering yet. A Connected cold start \+ runs 1-3 minutes. Keep polling swx_status; do not launch again.",+ Stage::Attached => "SOLIDWORKS is up and COM-attached. Carry on.",+ Stage::Stalled =>+ "That route stalled and its chain was cleaned up. The bridge is trying the next route.",+ Stage::Exhausted =>+ "Every launch route was tried and none reached a COM-attachable SOLIDWORKS. ASK \+ THE USER to start SOLIDWORKS from the Start Menu or the 3DEXPERIENCE Platform; \+ an interactive launch works when this one does not, and once it is up every \+ other verb in this bridge behaves normally.",+ }+ }+}++/// THE 3DEXPERIENCE ID SIGN-IN WINDOW.+///+/// Live, 2026-09-11: a cold launch stopped at a NATIVE WebView2 window — class+/// `DSYSysLoginPanelWebView2Class`, title "Login | 3DEXPERIENCE ID | Dassault+/// Systemes", owned by `SWXDesktopLauncher.exe` in the bridge's OWN chain, and+/// with no controls visible to UIA at all. Nothing can fill it, including this+/// bridge; only the user can.+///+/// The launch machine did not know that window existed, so its+/// "sldworks.exe within 120s" deadline fired while Drew was reading the sign-in+/// prompt, and the machine KILLED ITS OWN CHAIN — including the window he was+/// about to type into — escalated to the next route, killed that too, and+/// finished `exhausted` with advice to ask the user. SOLIDWORKS then started+/// anyway, from a surviving window, and the bridge recorded the session as not+/// its own.+///+/// A SIGN-IN WAIT IS NOT A STALL. It is the launch working correctly and waiting+/// for a human, and a human is slower than any budget worth setting.+pub const SIGN_IN_CLASSES: &[&str] = &["DSYSysLoginPanelWebView2Class"];++/// Title fragments, as a fallback for a build whose class name differs. Both+/// must match — a window is a sign-in only if its title carries the platform's+/// own name AND says what it is.+pub fn is_sign_in_window(class_name: &str, title: &str) -> bool {+ if SIGN_IN_CLASSES.iter().any(|c| c.eq_ignore_ascii_case(class_name)) {+ return true;+ }+ let t = title.to_ascii_lowercase();+ (t.contains("3dexperience id") || t.contains("dassault"))+ && (t.contains("login") || t.contains("sign in") || t.contains("sign-in"))+}++/// TELL THE USER, once, that the launch is waiting on them.+///+/// A paused deadline is invisible: without this the bridge would sit silently+/// while a window nobody mentioned waited on a desk somebody had walked away+/// from. Through ab, so it is the same native toast every other bridge uses; a+/// failure here never fails the launch (the wait is still correct), it just+/// means nobody was told.+fn announce_sign_in(title: &str) {+ let r = crate::caller::ad_callback(+ "notify_user",+ json!({+ "title": "SOLIDWORKS needs your 3DEXPERIENCE sign-in",+ "message": format!(+ "The launch is waiting at \"{title}\". Type your password in that window — this \+ bridge cannot (and never stores one). Tick \"Remember me\" so the next launch \+ does not stop here."+ ),+ }),+ Duration::from_secs(10),+ );+ crate::ledger::record(+ "solidworks_launch_sign_in_notified",+ json!({"delivered": r.get("success").and_then(|v| v.as_bool()), "title": title}),+ );+}++/// Is one of OUR chain's processes showing a sign-in window right now?+///+/// Attribution by OWNER PID, never by class name alone: a sign-in window+/// belonging to somebody else's launch is not ours to wait for. `None` means the+/// window census could not be taken for any of our pids — which is NOT "no+/// sign-in": an unreadable census must not resume a deadline that would kill the+/// very window it could not see.+pub fn sign_in_window(os: &dyn LaunchOs, ours: &[u32]) -> Option<Option<(i64, String)>> {+ let mut any_read = false;+ for pid in ours {+ match os.windows_of_pid(*pid) {+ Ok(ws) => {+ any_read = true;+ if let Some(w) = ws.iter().find(|w| is_sign_in_window(&w.class_name, &w.title)) {+ return Some(Some((w.hwnd, w.title.clone())));+ }+ }+ Err(_) => {}+ }+ }+ if any_read {+ Some(None)+ } else {+ None+ }+}++// ── tunables (test knobs; the defaults are the measured machine) ────────────++fn secs_env(name: &str, default: u64) -> u64 {+ std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default)+}++/// `Run Begin` within this many seconds of the invoke, or the route has stalled.+/// Measured: a healthy launch logs it ~2s after `SecurityContext:`.+pub fn run_begin_budget() -> Duration {+ Duration::from_secs(secs_env("SWX_LAUNCH_RUN_BEGIN_SECS", 20))+}+/// `sldworks.exe` within this long of the invoke. Measured: ~3s after Run Begin.+pub fn sldworks_budget() -> Duration {+ Duration::from_secs(secs_env("SWX_LAUNCH_SLDWORKS_SECS", 120))+}+/// COM attach within this long of the invoke. Measured: 1-3 minutes.+pub fn attach_budget() -> Duration {+ Duration::from_secs(secs_env("SWX_LAUNCH_ATTACH_SECS", 240))+}+/// How long an unfinished attempt keeps refusing to launch again.+pub fn inflight_budget() -> Duration {+ Duration::from_secs(secs_env("SWX_LAUNCH_INFLIGHT_SECS", 240))+}+/// How long after a FAILED attempt before another full one may start.+///+/// A retry that immediately sweeps all four routes again is how four launcher+/// chains ended up on somebody's desktop. After a failure the bridge waits, and+/// then tries ONE route rather than the whole plan.+pub fn backoff_budget() -> Duration {+ Duration::from_secs(secs_env("SWX_LAUNCH_BACKOFF_SECS", 60))+}+fn poll_interval() -> Duration {+ Duration::from_millis(secs_env("SWX_LAUNCH_POLL_MS", 1000).max(50))+}+fn attach_poll_interval() -> Duration {+ Duration::from_millis(secs_env("SWX_LAUNCH_ATTACH_POLL_MS", 3000).max(50))+}++// ── the crash reporter ──────────────────────────────────────────────────────++/// The ONE control this bridge may click during a launch. Identification is by+/// the BUTTON'S ACCESSIBLE NAME, not by a window title.+pub const CRASH_RESTART_BUTTON: &str = "Restart SOLIDWORKS";++/// Crash-reporter recovery is OFF unless this is `on`.+///+/// SHIPPED DISABLED. The click is decided from ab's `desktop_find_control`+/// reply, and **no reply from that verb has ever been seen** — the shape it+/// returns on a real desktop is a guess, and a guess about a reply shape is a+/// guess about whether to click something on somebody's screen. The live gate+/// captures the real reply; until it has, the bridge reports the crash reporter+/// and does nothing about it.+pub const ENV_CRASH_RECOVERY: &str = "SWX_CRASH_RECOVERY";++pub fn crash_recovery_enabled() -> bool {+ std::env::var(ENV_CRASH_RECOVERY).as_deref() == Ok("on")+}++/// Phrases that make a window a crash reporter. A crash reporter SAYS a crash+/// happened; it does not merely mention SOLIDWORKS.+const CRASH_PHRASES: &[&str] =+ &["stopped working", "stopped responding", "not responding", "error report", "crash report",+ "has crashed", "document recovery", "unexpectedly"];++/// ...and phrases that prove it is something else. A browser tab about a crash,+/// a mail thread about one, and the Installation Manager asking for a restart+/// all contain the words a naive matcher looks for. The bridge sends a UIA probe+/// at every candidate, so candidacy is not free.+const NOT_A_CRASH_REPORTER: &[&str] = &[+ "chrome", "firefox", "edge", "opera", "safari", "outlook", "teams", "slack", "gmail",+ "installation manager", "install", "update", "upgrade", "setup", "re:", "fw:", "how to",+ "- word", "- excel", "notepad", "visual studio", "explorer",+];++/// Is this window worth a read-only control probe? Pure, so the rule is visible+/// and testable rather than buried in a callback.+///+/// NOTE the asymmetry: a false NO costs one crash recovery the user can do+/// themselves in two clicks; a false YES sends a UIA probe at an arbitrary+/// window on somebody's desktop and, if the reply is read loosely, clicks it.+pub fn is_crash_reporter_candidate(title: &str) -> bool {+ let t = title.to_lowercase();+ if NOT_A_CRASH_REPORTER.iter().any(|bad| t.contains(bad)) {+ return false;+ }+ if let Ok(exact) = std::env::var("SWX_CRASH_REPORTER_TITLE") {+ if !exact.is_empty() && t.contains(&exact.to_lowercase()) {+ return true;+ }+ }+ t.contains("solidworks") && CRASH_PHRASES.iter().any(|w| t.contains(w))+}++/// Did ab's control lookup actually FIND the button?+///+/// Reads NAMED FIELDS ONLY. The first version stringified the whole reply and+/// searched it for the button name — and the bridge sends that name in the+/// request, so any reply that echoed its query passed the gate with zero+/// controls found. A reply is a find when it says so structurally: an explicit+/// `found:true`, or a control/element entry whose own `name`/`automationId`/+/// `text` names the button. An empty list is a NO, and an unrecognised shape is+/// a NO — the bridge does not click on a reply it does not understand.+pub fn mentions_button(resp: &Value) -> bool {+ if resp.get("success").and_then(|v| v.as_bool()) != Some(true) {+ return false;+ }+ if resp.get("found").and_then(|v| v.as_bool()) == Some(false) {+ return false;+ }+ let names_it = |v: &Value| -> bool {+ ["name", "automationId", "automationID", "text", "label"].iter().any(|k| {+ v.get(*k)+ .and_then(|x| x.as_str())+ .map(|s| s.to_lowercase().contains(&CRASH_RESTART_BUTTON.to_lowercase()))+ .unwrap_or(false)+ })+ };+ // A list of controls: at least one entry must name the button itself.+ for key in ["controls", "elements", "matches", "results", "children"] {+ if let Some(arr) = resp.get(key).and_then(|v| v.as_array()) {+ return arr.iter().any(names_it);+ }+ }+ // A single control object, inline or under `control`.+ if let Some(c) = resp.get("control").filter(|c| c.is_object()) {+ return names_it(c);+ }+ if names_it(resp) {+ // A flat reply naming the control — accepted only alongside a positive+ // `found`/`invokable`, so the query echo (which has neither) cannot.+ return resp.get("found").and_then(|v| v.as_bool()) == Some(true)+ || resp.get("invokable").and_then(|v| v.as_bool()) == Some(true);+ }+ false+}++// ── the persisted attempt ───────────────────────────────────────────────────++/// One route's turn inside an attempt, as it is recorded.+#[derive(Clone, Debug)]+pub struct RouteTry {+ pub route: String,+ pub invoked_at: u64,+ pub stage: Stage,+ pub stalled: bool,+ pub killed: Vec<ProcInfo>,+ /// The cleanup could not be PERFORMED — the census failed, or a terminate+ /// was refused. `killed:[]` alone is indistinguishable from "there was+ /// nothing to kill", which is what a clean recovery looks like.+ pub cleanup_failed: bool,+ pub cleanup_error: Option<String>,+ /// Chain processes known to be still running after the cleanup.+ pub chain_left_running: Vec<ProcInfo>,+ /// Did the INVOKE tell us what it started? Without this, nothing may be+ /// terminated, however obvious the chain on the machine looks.+ pub attributed: bool,+ /// `cleaned` | `notAttributable` | `notConfirmed` | `failed` | `nothingToClean`.+ pub cleanup: &'static str,+ /// A launcher chain from this route may still be running and this bridge+ /// cannot name it. Distinct from `chain_left_running`, which lists+ /// processes it CAN name and could not end.+ pub chain_may_remain: bool,+ // ── THIS ROUTE'S OWN EVIDENCE ────────────────────────────────────────+ // These were read off the ATTEMPT by the gate's per-route table, which+ // printed the same value on every row: the last route's log attributed to+ // all of them. A per-route table whose rows are all the same number is not+ // a measurement.+ pub launcher_log: Option<String>,+ pub run_begin_seen: bool,+ pub unattributed_log: bool,+ pub root_pid: Option<u32>,+ pub root_confirmed: bool,+ pub note: Option<String>,+}++impl RouteTry {+ fn to_json(&self) -> Value {+ json!({+ "route": self.route,+ "invokedAt": self.invoked_at,+ "stage": self.stage.name(),+ "stalled": self.stalled,+ "killed": self.killed.iter().map(|p| p.to_json()).collect::<Vec<_>>(),+ "cleanupFailed": self.cleanup_failed,+ "cleanupError": self.cleanup_error,+ "chainLeftRunning": self.chain_left_running.iter().map(|p| p.to_json()).collect::<Vec<_>>(),+ "attributed": self.attributed,+ "cleanup": self.cleanup,+ "chainMayRemain": self.chain_may_remain,+ "launcherLog": self.launcher_log,+ "runBeginSeen": self.run_begin_seen,+ "unattributedLog": self.unattributed_log,+ "rootPid": self.root_pid,+ "rootConfirmed": self.root_confirmed,+ "note": self.note,+ })+ }+}++/// The launch attempt this process is running (or last ran).+#[derive(Clone, Debug)]+pub struct Attempt {+ pub id: u64,+ pub started_at: u64,+ pub shortcut: String,+ pub reason: String,+ pub caller: String,+ pub plan: Vec<String>,+ pub route: String,+ pub invoked_at: u64,+ pub stage: Stage,+ pub stage_at: u64,+ /// The chain WE started, by identity. The kill path uses this and nothing+ /// else.+ pub chain: Vec<ProcInfo>,+ /// Terminable processes that were ALREADY running when we invoked. Never+ /// ours, whatever their age says later.+ pub baseline_pids: Vec<u32>,+ /// Could the baseline census be taken at all? `false` means this bridge+ /// cannot see the machine it is starting processes on.+ pub baseline_known: bool,+ /// What the INVOKE said it started. The only authority to terminate.+ pub attribution: Option<Attribution>,+ pub launcher_log: Option<String>,+ /// Did `Run Begin` appear in a launcher log we could prove was OURS? The+ /// only evidence that the route did anything — and the only thing that may+ /// make a route "preferred".+ pub run_begin_seen: bool,+ /// Was the user asked to sign in to 3DEXPERIENCE during this attempt? It+ /// changes what every timing in this record MEANS — a launch that took eight+ /// minutes because somebody was typing a password is not a slow launch.+ pub sign_in_seen: bool,+ /// LAUNCHER PROCESSES THAT APPEARED AFTER OUR INVOKE AND CANNOT BE PROVED+ /// EITHER WAY. An unattributable route (`explorer`, `cmdStart`) cannot say+ /// what it started, so a launcher that shows up while one is running is+ /// UNATTRIBUTED — possibly ours, possibly not. It is recorded here so the+ /// record is honest about what was on the machine, and it is excluded from+ /// BOTH the foreign set and the kill path: "possibly ours" is a reason not to+ /// accuse and not to terminate, which happen to be the same answer.+ pub possibly_ours: Vec<u32>,+ /// WHEN EACH CHAIN PROCESS WAS LAST SEEN ALIVE, as `(pid, created_at, epoch+ /// seconds)`.+ ///+ /// A dead parent may be accepted as lineage — the launcher chain always exits+ /// once `sldworks.exe` is up, so requiring it alive would make the walk+ /// useless — but "a pid we once recorded" is not enough on its own: once that+ /// process dies the NUMBER is free, and a user's launcher that reuses it+ /// would have its SOLIDWORKS record our dead pid as its parent, with nothing+ /// alive left to contradict it. So the window in which a child may claim that+ /// parent is bounded by when we last watched the parent run.+ pub chain_last_seen: Vec<(u32, u64, u64)>,+ /// THE SOLIDWORKS THIS ATTEMPT'S ATTRIBUTION IS ABOUT: `(pid, creation+ /// time)`, set when a session is re-claimed after a sign-in. `None` until+ /// then. Read by the verbs that END a session, so that an attribution record+ /// can never authorise acting on a DIFFERENT process than the one it was+ /// made about.+ pub attributed_sldworks: Option<(u32, u64)>,+ /// Did we ever see the attributed root, or anything descending from it?+ /// An attributed route whose chain was NEVER confirmed cannot be cleaned+ /// up, and that is a failure to account for what we started — not a clean+ /// "there was nothing to kill".+ pub root_confirmed: bool,+ /// Which route was in flight when COM attached. An ORDERING HINT ONLY —+ /// `attached` is proof that SOLIDWORKS is up, never proof that this route+ /// put it there. Reported with `"proof": false` and never promoted.+ pub last_route_before_attach: Option<String>,+ /// A launcher chain that is not ours was seen alive during this route's+ /// watch. It is what stops "the only new log must be ours" being true.+ pub foreign_chain_seen: bool,+ /// A NEW launcher log appeared while this route was watched, and it could+ /// not be attributed to us. Evidence that something started; no evidence+ /// whose it is.+ pub unattributed_log: bool,+ pub tries: Vec<RouteTry>,+ pub crash_recovery_clicked: bool,+ /// The bridge process supervising this attempt. A LATER bridge reading this+ /// file needs to tell "somebody is watching this" from "this was left+ /// behind by a process that is gone".+ pub supervisor_pid: Option<u32>,+ pub finished: bool,+ pub outcome: Option<String>,+ pub error: Option<String>,+ pub os_kind: &'static str,+}++impl Attempt {+ pub fn age_secs(&self) -> u64 {+ epoch_secs().saturating_sub(self.started_at)+ }++ /// Any chain process this attempt could not end.+ pub fn left_running(&self) -> Vec<Value> {+ self.tries+ .iter()+ .flat_map(|t| t.chain_left_running.iter())+ .map(|p| p.to_json())+ .collect()+ }++ pub fn cleanup_failed(&self) -> bool {+ self.tries.iter().any(|t| t.cleanup_failed)+ }++ pub fn to_json(&self) -> Value {+ json!({+ "id": self.id,+ "startedAt": self.started_at,+ "ageSeconds": self.age_secs(),+ "shortcut": self.shortcut,+ "reason": self.reason,+ "caller": self.caller,+ "routePlan": self.plan,+ "route": self.route,+ "invokedAt": self.invoked_at,+ "stage": self.stage.name(),+ "stageAgeSeconds": epoch_secs().saturating_sub(self.stage_at),+ "chainPids": self.chain.iter().map(|p| p.to_json()).collect::<Vec<_>>(),+ "baselinePids": self.baseline_pids,+ "baselineKnown": self.baseline_known,+ "attribution": self.attribution.as_ref().map(|a| a.to_json()),+ "attributed": self.attribution.is_some(),+ "launcherLog": self.launcher_log,+ "runBeginSeen": self.run_begin_seen,+ "signInSeen": self.sign_in_seen,+ // Launcher processes that appeared while an UNATTRIBUTABLE route was+ // running: not proved ours, not proved anybody else's, and never+ // terminated on either reading.+ "possiblyOurs": self.possibly_ours,+ "attributedSolidworks": self.attributed_sldworks+ .map(|(p, c)| json!({"pid": p, "createdAt": c})),+ "chainLastSeen": self.chain_last_seen+ .iter()+ .map(|(p, c, t)| json!([p, c, t]))+ .collect::<Vec<_>>(),+ "rootConfirmed": self.root_confirmed,+ "unattributedLog": self.unattributed_log,+ "foreignChainSeen": self.foreign_chain_seen,+ "lastRouteBeforeAttach": self.last_route_before_attach.as_ref().map(|r| json!({+ "route": r,+ "proof": false,+ "note": "the route in flight when COM attached. NOT evidence that it did the \+ launching — the user may have started SOLIDWORKS themselves — so it is \+ never remembered as preferredRoute. An ordering hint for a human.",+ })),+ "routesTried": self.tries.iter().map(|t| t.to_json()).collect::<Vec<_>>(),+ "cleanupFailed": self.cleanup_failed(),+ "chainLeftRunning": self.left_running(),+ "crashRecovery": if !crash_recovery_enabled() {+ "off"+ } else if self.crash_recovery_clicked {+ "clicked"+ } else {+ "armed"+ },+ "supervisorPid": self.supervisor_pid,+ "finished": self.finished,+ "outcome": self.outcome,+ "error": self.error,+ "observedBy": self.os_kind,+ })+ }+}++/// Everything the launch machine persists, next to `com-host-created.json` in+/// the bridge's own scratch root.+#[derive(Clone, Debug, Default)]+pub struct LaunchStore {+ pub preferred_route: Option<String>,+ pub attempt: Option<Attempt>,+ pub history: Vec<Value>,+ /// How many attempts in a row have failed. Drives the backoff.+ pub consecutive_failures: u32,+ /// When the last attempt finished without attaching.+ pub last_failure_at: Option<u64>,+}++const HISTORY_MAX: usize = 20;++pub fn state_path() -> std::path::PathBuf {+ crate::scratch::root().join("launch-state.json")+}++fn stage_from_name(s: &str) -> Stage {+ match s {+ "launcherStarted" => Stage::LauncherStarted,+ "runBegin" => Stage::RunBegin,+ "helperSignIn" => Stage::HelperSignIn,+ "sldworksUp" => Stage::SldworksUp,+ "attaching" => Stage::Attaching,+ "attached" => Stage::Attached,+ "stalled" => Stage::Stalled,+ "exhausted" => Stage::Exhausted,+ _ => Stage::Invoked,+ }+}++fn procs_from_json(v: Option<&Value>) -> Vec<ProcInfo> {+ v.and_then(|a| a.as_array())+ .map(|rows| {+ rows.iter()+ .filter_map(|r| {+ Some(ProcInfo {+ pid: r.get("pid")?.as_u64()? as u32,+ image: r.get("image")?.as_str()?.to_string(),+ created_at: r.get("createdAt").and_then(|c| c.as_u64()),+ ppid: r.get("ppid").and_then(|c| c.as_u64()).map(|v| v as u32),+ })+ })+ .collect()+ })+ .unwrap_or_default()+}++/// Read the store from disk. A missing file is an EMPTY store; an UNPARSEABLE+/// one is an empty store AND a loud line, because those are different facts.+pub fn load() -> LaunchStore {+ let path = state_path();+ let text = match std::fs::read_to_string(&path) {+ Ok(t) => t,+ Err(_) => return LaunchStore::default(),+ };+ let v: Value = match serde_json::from_str(&text) {+ Ok(v) => v,+ Err(e) => {+ eprintln!(+ "[swx] launch-state.json could not be parsed ({e}); starting with no launch \+ history. The file is kept as launch-state.json.bad."+ );+ let _ = std::fs::rename(&path, path.with_extension("json.bad"));+ return LaunchStore::default();+ }+ };+ let attempt = v.get("attempt").filter(|a| a.is_object()).map(|a| Attempt {+ id: a.get("id").and_then(|x| x.as_u64()).unwrap_or(0),+ started_at: a.get("startedAt").and_then(|x| x.as_u64()).unwrap_or(0),+ shortcut: a.get("shortcut").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ reason: a.get("reason").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ caller: a.get("caller").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ plan: a+ .get("routePlan")+ .and_then(|x| x.as_array())+ .map(|r| r.iter().filter_map(|s| s.as_str().map(String::from)).collect())+ .unwrap_or_default(),+ route: a.get("route").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ invoked_at: a.get("invokedAt").and_then(|x| x.as_u64()).unwrap_or(0),+ stage: stage_from_name(a.get("stage").and_then(|x| x.as_str()).unwrap_or("")),+ stage_at: a.get("stageAt").and_then(|x| x.as_u64()).unwrap_or(0),+ chain: procs_from_json(a.get("chainPids")),+ baseline_pids: a+ .get("baselinePids")+ .and_then(|x| x.as_array())+ .map(|r| r.iter().filter_map(|p| p.as_u64().map(|v| v as u32)).collect())+ .unwrap_or_default(),+ baseline_known: a.get("baselineKnown").and_then(|x| x.as_bool()).unwrap_or(false),+ attribution: a.get("attribution").filter(|x| x.is_object()).and_then(|x| {+ Some(Attribution {+ pid: x.get("pid")?.as_u64()? as u32,+ created_at: x.get("createdAt").and_then(|c| c.as_u64()),+ source: x.get("source").and_then(|c| c.as_str()).unwrap_or("restored").to_string(),+ })+ }),+ launcher_log: a.get("launcherLog").and_then(|x| x.as_str()).map(String::from),+ run_begin_seen: a.get("runBeginSeen").and_then(|x| x.as_bool()).unwrap_or(false),+ sign_in_seen: a.get("signInSeen").and_then(|x| x.as_bool()).unwrap_or(false),+ possibly_ours: a+ .get("possiblyOurs")+ .and_then(|x| x.as_array())+ .map(|v| v.iter().filter_map(|p| p.as_u64().map(|n| n as u32)).collect())+ .unwrap_or_default(),+ attributed_sldworks: a.get("attributedSolidworks").and_then(|v| {+ Some((+ v.get("pid")?.as_u64()? as u32,+ v.get("createdAt")?.as_u64()?,+ ))+ }),+ chain_last_seen: a+ .get("chainLastSeen")+ .and_then(|x| x.as_array())+ .map(|v| {+ v.iter()+ .filter_map(|r| {+ let a = r.as_array()?;+ Some((+ a.first()?.as_u64()? as u32,+ a.get(1)?.as_u64()?,+ a.get(2)?.as_u64()?,+ ))+ })+ .collect()+ })+ .unwrap_or_default(),+ root_confirmed: a.get("rootConfirmed").and_then(|x| x.as_bool()).unwrap_or(false),+ unattributed_log: a.get("unattributedLog").and_then(|x| x.as_bool()).unwrap_or(false),+ foreign_chain_seen: a.get("foreignChainSeen").and_then(|x| x.as_bool()).unwrap_or(false),+ last_route_before_attach: a+ .get("lastRouteBeforeAttach")+ .and_then(|x| x.get("route"))+ .and_then(|x| x.as_str())+ .map(String::from),+ tries: Vec::new(),+ crash_recovery_clicked: a+ .get("crashRecoveryClicked")+ .and_then(|x| x.as_bool())+ .unwrap_or(false),+ // ABSENT means "written by a bridge that did not record one", which is+ // treated exactly like a pid that is gone: not us, not alive.+ supervisor_pid: a.get("supervisorPid").and_then(|x| x.as_u64()).map(|v| v as u32),+ finished: a.get("finished").and_then(|x| x.as_bool()).unwrap_or(true),+ outcome: a.get("outcome").and_then(|x| x.as_str()).map(String::from),+ error: a.get("error").and_then(|x| x.as_str()).map(String::from),+ os_kind: "restored",+ });+ LaunchStore {+ preferred_route: v.get("preferredRoute").and_then(|x| x.as_str()).map(String::from),+ attempt,+ history: v.get("history").and_then(|x| x.as_array()).cloned().unwrap_or_default(),+ consecutive_failures: v+ .get("consecutiveFailures")+ .and_then(|x| x.as_u64())+ .unwrap_or(0) as u32,+ last_failure_at: v.get("lastFailureAt").and_then(|x| x.as_u64()),+ }+}++/// Persist the store ATOMICALLY: write a temp file, then rename over the real+/// one. A plain `fs::write` truncates first, so a kill (or a full disk) in the+/// middle of one leaves a half-file — and the launch machine would then read+/// "no attempt in flight" from a bridge that has one.+pub fn save(store: &LaunchStore) {+ let mut history = store.history.clone();+ if history.len() > HISTORY_MAX {+ let drop = history.len() - HISTORY_MAX;+ history.drain(0..drop);+ }+ let mut attempt = store.attempt.as_ref().map(|a| a.to_json());+ if let (Some(obj), Some(a)) = (attempt.as_mut().and_then(|x| x.as_object_mut()), &store.attempt) {+ obj.insert("stageAt".into(), json!(a.stage_at));+ obj.insert("crashRecoveryClicked".into(), json!(a.crash_recovery_clicked));+ }+ let body = json!({+ "preferredRoute": store.preferred_route,+ "attempt": attempt,+ "history": history,+ "consecutiveFailures": store.consecutive_failures,+ "lastFailureAt": store.last_failure_at,+ });+ if let Err(e) = write_atomic(&state_path(), &body.to_string()) {+ // Loud, once per failure. An in-flight guard that silently stopped+ // persisting would let the next swx_start start a second launch.+ eprintln!("[swx] LAUNCH STATE NOT PERSISTED: {e}");+ }+}++/// Temp file + rename, with the previous version kept as `.bak`.+///+/// RETRIES THE RENAME. On Windows an antivirus scanner or the search indexer+/// holds a brief exclusive handle on a file it has just seen created, and the+/// rename fails with ACCESS_DENIED for a few milliseconds — on a file this+/// bridge rewrites hundreds of times during one assembly build. A single+/// attempt therefore drops writes at random, and the one it drops might be the+/// one that recorded a document the bridge owns.+///+/// Returns whether the write landed. A failure is REPORTED (the callers log it)+/// rather than swallowed: "the bridge could not write its own notes" is a fact+/// somebody needs, and it is the beginning of every ownership bug in this file.+pub fn write_atomic(path: &std::path::Path, body: &str) -> Result<(), String> {+ if let Some(parent) = path.parent() {+ let _ = std::fs::create_dir_all(parent);+ }+ // UNIQUE PER WRITE. The temp name used to be the pid alone, which is one+ // name shared by every HTTP worker thread in this process — and the+ // ownership registry is rewritten from several of them. Two concurrent+ // writes then raced on one temp file: both wrote it, one renamed it away,+ // and the other's rename failed with "no such file" (or, worse, renamed a+ // half-written file into place).+ static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);+ let tmp = path.with_extension(format!(+ "tmp{}-{}",+ std::process::id(),+ SEQ.fetch_add(1, Ordering::SeqCst)+ ));+ if let Err(e) = std::fs::write(&tmp, body) {+ let _ = std::fs::remove_file(&tmp);+ return Err(format!("could not write {}: {e}", tmp.display()));+ }+ // THE BACKUP IS STAGED TOO. `copy` straight onto `.bak` is not atomic+ // either, so two concurrent writers could leave a torn backup — and the+ // backup is what `owned::restore` falls back to when the live file cannot+ // be parsed. Copy to a unique name, then rename it into place.+ if path.exists() {+ let bak_tmp = path.with_extension(format!("bak.tmp{}", std::process::id()));+ if std::fs::copy(path, &bak_tmp).is_ok() {+ if std::fs::rename(&bak_tmp, path.with_extension("bak")).is_err() {+ let _ = std::fs::remove_file(&bak_tmp);+ }+ } else {+ let _ = std::fs::remove_file(&bak_tmp);+ }+ }+ let mut last = String::new();+ for attempt in 0..3 {+ match std::fs::rename(&tmp, path) {+ Ok(()) => return Ok(()),+ Err(e) => {+ last = e.to_string();+ // ACCESS_DENIED / PermissionDenied is the scanner; anything+ // else will not improve by waiting.+ if e.kind() != std::io::ErrorKind::PermissionDenied {+ break;+ }+ std::thread::sleep(Duration::from_millis(30 * (attempt + 1)));+ }+ }+ }+ let _ = std::fs::remove_file(&tmp);+ Err(format!("could not replace {} after 3 attempts: {last}", path.display()))+}++// ── process-wide state ──────────────────────────────────────────────────────++static STORE: Mutex<Option<LaunchStore>> = Mutex::new(None);+static SUPERVISOR: AtomicBool = AtomicBool::new(false);+/// The sldworks pid whose crash reporter this bridge has already clicked.+/// Per-PROCESS, not per-attempt: a retry must not re-arm the click.+static CLICKED_FOR: Mutex<Option<u32>> = Mutex::new(None);++/// Read the store WITHOUT writing it back.+///+/// The first version saved on every read, so `swx_status` — the verb ab tells+/// callers to POLL — rewrote `launch-state.json` a few times a second, for no+/// change, on the one file whose integrity the in-flight guard depends on.+fn read_store<T>(f: impl FnOnce(&LaunchStore) -> T) -> T {+ let mut guard = STORE.lock().unwrap_or_else(|e| e.into_inner());+ if guard.is_none() {+ *guard = Some(load());+ }+ f(guard.as_ref().expect("initialised above"))+}++/// Change the store and persist it. The ONLY path that writes.+fn mutate_store<T>(f: impl FnOnce(&mut LaunchStore) -> T) -> T {+ let mut guard = STORE.lock().unwrap_or_else(|e| e.into_inner());+ if guard.is_none() {+ *guard = Some(load());+ }+ let out = f(guard.as_mut().expect("initialised above"));+ save(guard.as_ref().expect("initialised above"));+ out+}++/// The route that last worked on this machine, if any.+pub fn preferred_route() -> Option<String> {+ read_store(|s| s.preferred_route.clone())+}++/// The current attempt, if there is one.+pub fn attempt() -> Option<Attempt> {+ read_store(|s| s.attempt.clone())+}++/// Is a launch genuinely in flight RIGHT NOW?+///+/// Three questions, in order, and the last two are what make this honest rather+/// than a timer:+///+/// 1. is THIS PROCESS supervising it? (the common case, and free)+/// 2. is the process that recorded it still alive? An attempt whose+/// `supervisorPid` is absent, is not us, and is not running belongs to a+/// bridge that is GONE — it is marked `abandoned` on the spot, so it stops+/// being reported and stops blocking the next launch.+/// 3. is OUR OWN CHAIN still alive? Note what is NOT here: "is any+/// sldworks.exe running". The first version accepted that, so a bridge+/// restarted while the user's SOLIDWORKS sat at a sign-in reported a+/// launch in flight forever and told the caller to poll a supervisor that+/// did not exist.+pub fn in_flight(os: &dyn LaunchOs) -> Option<Attempt> {+ let a = attempt()?;+ if a.finished {+ return None;+ }+ if SUPERVISOR.load(Ordering::SeqCst) {+ return Some(a);+ }+ if a.age_secs() > inflight_budget().as_secs() {+ abandon("the attempt outlived its in-flight budget with no supervisor in this process");+ return None;+ }+ let ours = a.supervisor_pid == Some(os.self_pid());+ if !ours {+ let alive = a.supervisor_pid.and_then(|p| os.pid_alive(p));+ if alive != Some(true) {+ abandon(match a.supervisor_pid {+ None => "the attempt records no supervising bridge process, so nothing is watching it",+ Some(_) => "the bridge process that started this launch is gone",+ });+ return None;+ }+ }+ // Another live bridge owns it (or ours restarted the supervisor): believe it+ // only while OUR OWN recorded chain is still running.+ let census = os.processes().ok()?;+ let chain_alive = !a.chain.is_empty()+ && a.chain.iter().any(|c| census.iter().any(|p| p.same_process(c)));+ if chain_alive {+ Some(a)+ } else {+ abandon("no process from this attempt's own launcher chain is still running");+ None+ }+}++/// Mark the current attempt as belonging to nobody.+fn abandon(why: &str) {+ mutate_store(|s| {+ if let Some(a) = s.attempt.as_mut() {+ if !a.finished {+ a.finished = true;+ a.outcome = Some("abandoned".into());+ a.error = Some(why.to_string());+ }+ }+ });+ crate::ledger::record("solidworks_launch_abandoned", json!({"reason": why}));+}++/// May a new attempt start, or is the machine in backoff after a failure?+pub fn backoff_remaining() -> u64 {+ read_store(|s| {+ if s.consecutive_failures == 0 {+ return 0;+ }+ let Some(at) = s.last_failure_at else { return 0 };+ let wait = backoff_budget().as_secs() * (s.consecutive_failures.min(4) as u64);+ wait.saturating_sub(epoch_secs().saturating_sub(at))+ })+}++// ── the supervisor ──────────────────────────────────────────────────────────++/// What the supervisor needs: invoke a route, and probe for a COM server. Both+/// go through the com host — the invoke because a `.lnk` needs the shell (and+/// `ShellExecuteExW` needs the host's STA thread), the probe because the com+/// host is the only thing that speaks COM.+pub struct Channel {+ pub com: Arc<crate::comhost::ComHost>,+}++impl Channel {+ fn invoke(&self, shortcut: &str, route: &str) -> Result<Value, String> {+ self.com+ .call(+ "launch_shortcut",+ json!({"shortcut": shortcut, "route": route}),+ Duration::from_secs(60),+ )+ .map_err(|e| e.message)+ }++ fn attached(&self) -> bool {+ self.com+ .call("start", json!({"visible": true, "launch": false}), Duration::from_secs(15))+ .is_ok()+ }+}++/// Why a launch cannot start right now.+pub enum BeginRefusal {+ /// One is already running; report it, launch nothing.+ InFlight(Attempt),+ /// The machine failed recently; a full re-sweep would start four more+ /// chains. `seconds` until it may try again.+ Backoff { seconds: u64, attempt: Option<Attempt> },+}++/// Begin an attempt and hand it to a background supervisor.+pub fn begin(+ channel: Channel,+ shortcut: &str,+ reason: &str,+ caller: &str,+) -> Result<Attempt, BeginRefusal> {+ let wait = backoff_remaining();+ if wait > 0 {+ return Err(BeginRefusal::Backoff { seconds: wait, attempt: attempt() });+ }+ if SUPERVISOR.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {+ return Err(BeginRefusal::InFlight(+ attempt().unwrap_or_else(|| unfinished_placeholder(shortcut)),+ ));+ }+ let os = crate::launchos::os();+ // AFTER A FAILURE, TRY ONE ROUTE — not the whole plan again. A caller that+ // retries in a loop would otherwise start four launcher chains per retry,+ // which is the exact failure this release exists to end.+ let (plan, failures) = read_store(|s| {+ let full = route_plan(s.preferred_route.as_deref());+ if s.consecutive_failures == 0 {+ (full, 0)+ } else {+ let idx = (s.consecutive_failures as usize) % full.len();+ (vec![full[idx].clone()], s.consecutive_failures)+ }+ });+ let started = mutate_store(|s| {+ let id = s.attempt.as_ref().map(|a| a.id + 1).unwrap_or(1);+ if let Some(old) = s.attempt.take() {+ s.history.push(old.to_json());+ }+ let a = Attempt {+ id,+ started_at: epoch_secs(),+ shortcut: shortcut.to_string(),+ reason: reason.to_string(),+ caller: caller.to_string(),+ plan: plan.clone(),+ route: plan.first().cloned().unwrap_or_default(),+ invoked_at: 0,+ stage: Stage::Invoked,+ stage_at: epoch_secs(),+ chain: Vec::new(),+ baseline_pids: Vec::new(),+ baseline_known: false,+ attribution: None,+ launcher_log: None,+ run_begin_seen: false,+ sign_in_seen: false,+ possibly_ours: Vec::new(),+ chain_last_seen: Vec::new(),+ attributed_sldworks: None,+ root_confirmed: false,+ unattributed_log: false,+ foreign_chain_seen: false,+ last_route_before_attach: None,+ tries: Vec::new(),+ crash_recovery_clicked: false,+ supervisor_pid: Some(os.self_pid()),+ finished: false,+ outcome: None,+ error: None,+ os_kind: os.kind(),+ };+ s.attempt = Some(a.clone());+ a+ });+ crate::ledger::record(+ "solidworks_launch_started",+ json!({+ "attempt": started.id,+ "launchRoute": "platformShortcut",+ "routePlan": started.plan,+ "afterFailures": failures,+ "shortcut": shortcut,+ "reason": reason,+ "caller": caller,+ }),+ );+ let spawned = std::thread::Builder::new()+ .name("swx-launch-supervisor".into())+ .spawn(move || {+ run_supervisor(channel, os);+ SUPERVISOR.store(false, Ordering::SeqCst);+ });+ if spawned.is_err() {+ SUPERVISOR.store(false, Ordering::SeqCst);+ finish("failed", Some("could not start the launch supervisor thread".into()));+ }+ Ok(started)+}++fn unfinished_placeholder(shortcut: &str) -> Attempt {+ Attempt {+ id: 0,+ started_at: epoch_secs(),+ shortcut: shortcut.to_string(),+ reason: String::new(),+ caller: String::new(),+ plan: Vec::new(),+ route: String::new(),+ invoked_at: 0,+ stage: Stage::Invoked,+ stage_at: epoch_secs(),+ chain: Vec::new(),+ baseline_pids: Vec::new(),+ baseline_known: false,+ attribution: None,+ launcher_log: None,+ run_begin_seen: false,+ sign_in_seen: false,+ possibly_ours: Vec::new(),+ chain_last_seen: Vec::new(),+ attributed_sldworks: None,+ root_confirmed: false,+ unattributed_log: false,+ foreign_chain_seen: false,+ last_route_before_attach: None,+ tries: Vec::new(),+ crash_recovery_clicked: false,+ supervisor_pid: Some(std::process::id()),+ finished: false,+ outcome: None,+ error: None,+ os_kind: "unknown",+ }+}++fn update(f: impl FnOnce(&mut Attempt)) {+ mutate_store(|s| {+ if let Some(a) = s.attempt.as_mut() {+ f(a);+ }+ });+}++fn set_stage(stage: Stage) {+ let mut changed = false;+ update(|a| {+ if a.stage != stage {+ a.stage = stage;+ a.stage_at = epoch_secs();+ changed = true;+ }+ });+ if changed {+ crate::ledger::record("solidworks_launch_stage", json!({"stage": stage.name()}));+ }+}++fn finish(outcome: &str, error: Option<String>) {+ let mut already = false;+ mutate_store(|s| {+ if let Some(a) = s.attempt.as_mut() {+ // IDEMPOTENT. A second call must not re-decide the outcome, and+ // above all must not bump `consecutive_failures` a second time for+ // one attempt — which would double the backoff for free.+ if a.finished {+ already = true;+ return;+ }+ a.finished = true;+ a.outcome = Some(outcome.to_string());+ a.error = error.clone();+ if outcome == "attached" {+ // MEMORISE THE ROUTE ONLY IF IT DID THE WORK. A user who starts+ // SOLIDWORKS from their Start Menu mid-flight makes the attach+ // succeed; recording the route we happened to be trying as+ // "the one that works" would teach the bridge the wrong lesson+ // from somebody else's click. `Run Begin` in OUR OWN launcher+ // log is the evidence that OUR route started the application.+ if a.run_begin_seen {+ s.preferred_route = Some(a.route.clone());+ }+ s.consecutive_failures = 0;+ s.last_failure_at = None;+ } else {+ s.consecutive_failures = s.consecutive_failures.saturating_add(1);+ s.last_failure_at = Some(epoch_secs());+ }+ }+ });+ if !already {+ crate::ledger::record(+ "solidworks_launch_finished",+ json!({"outcome": outcome, "error": error}),+ );+ }+}++/// Mark the current attempt done because SOLIDWORKS is attached.+pub fn mark_attached() {+ let running = read_store(|s| s.attempt.as_ref().map(|a| !a.finished).unwrap_or(false));+ if running {+ set_stage(Stage::Attached);+ finish("attached", None);+ }+}++/// THE STATE MACHINE. One route at a time; a stall ends the route, never the+/// attempt, and never anything that was running before we started.+fn run_supervisor(channel: Channel, os: Box<dyn LaunchOs>) {+ let plan = attempt().map(|a| a.plan).unwrap_or_default();+ let shortcut = attempt().map(|a| a.shortcut).unwrap_or_default();+ for (index, route) in plan.iter().enumerate() {+ // EVERY PER-ROUTE FACT RESETS. `root_confirmed` and `unattributed_log`+ // did not, with two consequences: `notConfirmed` became unreachable+ // after route 1 (so H2 was restored for routes 2-4), and the log gate's+ // root-confirmed half was pre-satisfied by an EARLIER route's chain.+ update(|a| {+ a.route = route.clone();+ a.chain.clear();+ a.launcher_log = None;+ a.run_begin_seen = false;+ a.root_confirmed = false;+ a.unattributed_log = false;+ a.foreign_chain_seen = false;+ });+ set_stage(Stage::Invoked);++ // THE BASELINE, taken immediately BEFORE the invoke. Everything+ // terminable that is already running is recorded as NOT OURS, whatever+ // its creation time says afterwards.+ let baseline = os.processes();+ let baseline_pids: Vec<u32> = baseline+ .as_ref()+ .map(|c| c.iter().map(|p| p.pid).collect())+ .unwrap_or_default();+ let baseline_known = baseline.is_ok();+ // THE LOG BASELINE, and whether we could take one at all. A FAILED+ // listing is UNKNOWN — `unwrap_or_default()` turned it into "there were+ // no logs", and an absent baseline used to make ANY log ours. Five of+ // the nine logs on the real machine contain `Run Begin`, so that read+ // somebody else's launch from three days ago and jumped the machine+ // straight past stall detection.+ let logs = os.launcher_logs();+ let log_baseline_known = logs.is_ok();+ let (base_stamp, base_created) = newest_log(logs.as_deref().unwrap_or(&[]));+ let invoked_at = os.now();+ update(|a| {+ a.invoked_at = invoked_at;+ a.baseline_pids = baseline_pids.clone();+ a.baseline_known = baseline_known;+ a.attribution = None;+ });++ let invoked = match channel.invoke(&shortcut, route) {+ Err(e) => {+ record_try(+ route,+ invoked_at,+ false,+ Cleanup { kind: "nothingToClean", ..Default::default() },+ Some(format!("the invoke itself failed: {e}")),+ );+ crate::ledger::record(+ "solidworks_launch_route_failed",+ json!({"route": route, "error": e}),+ );+ continue;+ }+ Ok(r) => {+ if r.get("alreadyRunning").and_then(|v| v.as_bool()).unwrap_or(false)+ || r.get("attached").and_then(|v| v.as_bool()).unwrap_or(false)+ {+ set_stage(Stage::Attached);+ finish("attached", None);+ return;+ }+ r+ }+ };++ // WHAT DID THE INVOKE SAY IT STARTED? This, and nothing found by+ // searching the machine afterwards, is authority to terminate.+ let attribution = invoked+ .get("rootPid")+ .and_then(|v| v.as_u64())+ .filter(|p| *p > 0)+ .map(|pid| Attribution {+ pid: pid as u32,+ created_at: invoked.get("rootCreatedAt").and_then(|v| v.as_u64()),+ source: invoked+ .get("attributionSource")+ .and_then(|v| v.as_str())+ .unwrap_or("invoke")+ .to_string(),+ })+ // An attribution with no creation time is not an identity, and this+ // bridge will not terminate a bare number.+ .filter(|a| a.created_at.is_some());+ update(|a| a.attribution = attribution.clone());+ if attribution.is_none() {+ crate::ledger::record(+ "solidworks_launch_not_attributable",+ json!({+ "route": route,+ "why": invoked.get("attributionNote").cloned().unwrap_or(Value::Null),+ "consequence": "no process from this route will be terminated by the bridge",+ }),+ );+ }++ let watched = Watch {+ baseline_pids,+ base_stamp,+ base_created,+ log_baseline_known,+ invoked_at,+ attribution: attribution.clone(),+ };+ match watch_route(&channel, os.as_ref(), route, &watched) {+ RouteOutcome::Attached => {+ // The WINNING route belongs in the per-route table too, or the+ // gate's central measurement is missing its most important row.+ record_try(+ route,+ invoked_at,+ false,+ Cleanup { attributed: attribution.is_some(), kind: "nothingToClean", ..Default::default() },+ Some(format!("route '{route}' ended with SOLIDWORKS COM-attached")),+ );+ // AN ORDERING HINT, NOT A PROOF. Which route was in flight when+ // the attach happened is worth recording — but it is not+ // evidence that this route did it (the user may have started+ // SOLIDWORKS themselves), so it never satisfies `run_begin_seen`+ // and is never promoted to `preferredRoute`.+ let last = route.clone();+ mutate_store(|st| {+ if let Some(a) = st.attempt.as_mut() {+ a.last_route_before_attach = Some(last.clone());+ }+ });+ finish("attached", None);+ return;+ }+ RouteOutcome::Stop => {+ // A STOPPED ROUTE BELONGS IN THE TABLE TOO. `Stop` is the+ // `sldworksUpNotAttached` ending — SOLIDWORKS is on the user's+ // screen and COM has not answered, which on this edition usually+ // means a sign-in only they can clear. It recorded NO row at all,+ // so `routesTried` came back empty for a route that had reached+ // Run Begin and started the application; the attempt-level+ // `runBeginSeen:true` sat beside an empty table, and+ // `reattribute_after_attach` — which looks for the route that+ // proved itself — found nothing to point at. The most important+ // outcome for sign-in recovery was the one with no evidence row.+ record_try(+ route,+ invoked_at,+ false,+ Cleanup {+ attributed: attribution.is_some(),+ kind: "nothingToClean",+ ..Default::default()+ },+ Some(format!(+ "route '{route}' started SOLIDWORKS and stopped: the application is up and \+ COM has not attached. Nothing was cleaned up — the chain did its job."+ )),+ );+ return;+ }+ RouteOutcome::Stalled { note, saw_log, unattributed_log } => {+ // THE FOUR WAYS A ROUTE CAN END, and they are not the same.+ // * attributed AND confirmed: the invoke told us its root and+ // we saw it, so the chain is ours by proof and we end it;+ // * attributed but NEVER CONFIRMED: we were told a root and+ // never once saw it or anything descending from it, so we+ // cannot say what is running;+ // * blind: we could not even take a census when we invoked;+ // * not attributable: the route ran but cannot say what it+ // started, so nothing may be terminated on its behalf.+ let confirmed = attempt().map(|a| a.root_confirmed).unwrap_or(false);+ let cleanup = match (attribution.as_ref(), baseline_known) {+ (Some(_), true) | (Some(_), false) if confirmed => {+ clean_up_chain(os.as_ref(), invoked_at)+ }+ (Some(root), _) => Cleanup::not_confirmed(route, root),+ (None, false) => Cleanup::blind(),+ (None, true) => Cleanup::not_attributable(route, unattributed_log),+ };+ let note = if saw_log {+ note+ } else {+ format!(+ "route '{route}' produced NO launcher log at all within {}s: nothing got \+ as far as writing one, because nothing started. The invocation returned \+ successfully, which for a process spawn only means the EXE ran — it says \+ nothing about whether the shell resolved the .lnk or the launcher chain \+ came up.",+ run_begin_budget().as_secs()+ )+ };+ let failed = cleanup.failed;+ let kind = cleanup.kind;+ let error = cleanup.error.clone();+ let killed: Vec<Value> = cleanup.killed.iter().map(|p| p.to_json()).collect();+ let left = cleanup.left_running.len();+ record_try(route, invoked_at, true, cleanup, Some(note.clone()));+ set_stage(Stage::Stalled);+ crate::ledger::record(+ "solidworks_launch_stalled",+ json!({+ "route": route,+ "note": note,+ "killed": killed,+ "cleanup": kind,+ "cleanupFailed": failed,+ "cleanupError": error,+ "nextRoute": plan.get(index + 1),+ }),+ );+ // MAY THE MACHINE GO ON? Only when nothing was left behind+ // that it cannot account for. A route that started NOTHING (no+ // launcher log ever appeared) leaves nothing behind whether or+ // not it was attributable — that is what makes it safe to try+ // the next one. A route that started something it cannot+ // identify is where the machine stops: another route would put+ // a second chain beside one nobody can find.+ let may_continue = match kind {+ "cleaned" | "nothingToClean" => true,+ // A route that cannot say what it started may be followed+ // by another ONLY if nothing appears to have started: no+ // log of ours (there can be none) and no log we could not+ // attribute either. A new log is evidence that SOMETHING+ // ran, and we cannot say whose it is.+ "notAttributable" => !saw_log && !unattributed_log,+ _ => false,+ };+ if !may_continue {+ set_stage(Stage::Exhausted);+ finish(+ "exhausted",+ Some(format!(+ "stopped after route '{route}' ({kind}): {}. This bridge will not \+ start another launcher chain while processes it cannot account for \+ may still be running. {left} launcher process(es) are known to be \+ still running; ASK THE USER to start SOLIDWORKS from their Start \+ Menu, which works when these routes do not.",+ error.unwrap_or_else(|| "reason unknown".into())+ )),+ );+ return;+ }+ }+ }+ }+ set_stage(Stage::Exhausted);+ finish(+ "exhausted",+ Some("every launch route was tried and none produced a COM-attachable SOLIDWORKS".into()),+ );+}++fn record_try(route: &str, invoked_at: u64, stalled: bool, c: Cleanup, note: Option<String>) {+ update(|a| {+ let stage = a.stage;+ // The evidence is COPIED off the attempt HERE, at the end of the route,+ // while it still describes this route and nothing else — every one of+ // these fields is reset when the next route begins.+ let (log, run_begin, unattributed, root_pid, root_confirmed) = (+ a.launcher_log.clone(),+ a.run_begin_seen,+ a.unattributed_log,+ a.attribution.as_ref().map(|r| r.pid),+ a.root_confirmed,+ );+ a.tries.push(RouteTry {+ route: route.to_string(),+ invoked_at,+ stage,+ stalled,+ killed: c.killed,+ cleanup_failed: c.failed,+ cleanup_error: c.error,+ chain_left_running: c.left_running,+ attributed: c.attributed,+ cleanup: c.kind,+ chain_may_remain: c.may_remain,+ launcher_log: log,+ run_begin_seen: run_begin,+ unattributed_log: unattributed,+ root_pid,+ root_confirmed,+ note,+ });+ });+}++/// WHICH OF THESE LOGS IS OURS? Three tests, in order of how much they prove.+///+/// `root.created_at <= written` — round four's rule — is NECESSARY AND NOT+/// SUFFICIENT: it is true of every log written after our chain started,+/// including one written by a launch the user began while ours was stalling.+/// And `launcher_logs()` is newest-first, so a bare `find` preferentially+/// picked THEIRS. Our own route's log stops at `SecurityContext:`; theirs has+/// `Run Begin` in it; the bridge reported that it had seen `Run Begin`.+///+/// 1. **AUTHORSHIP.** The launcher stamps the writing thread's id on every+/// line. If that thread belongs to a process in OUR OWN chain, the log is+/// ours — positively, not by elimination. (A miss proves nothing: threads+/// exit, and the writing one usually has.)+/// 2. **SOLE CANDIDATE.** Exactly one log post-dates the baseline, our chain+/// has been SEEN and existed before the log was written, and no launcher+/// chain that is not ours was alive in the meantime. Then there is nothing+/// else it could be.+/// 3. **Otherwise: none.** Two candidates and no authorship proof is+/// `unattributedLog`. Never "pick the newest".+fn choose_our_log(+ os: &dyn LaunchOs,+ candidates: &[&crate::launchos::LogFile],+ w: &Watch,+ chain_pids: &[u32],+ foreign_chain_seen: bool,+ root_confirmed: bool,+) -> Option<String> {+ let root = w.attribution.as_ref()?;+ // 1. AUTHORSHIP.+ for l in candidates {+ let Ok(text) = os.read_log(&l.path) else { continue };+ let Some(tid) = crate::launchos::writer_thread_of(&text) else { continue };+ let Some(owner) = os.thread_owner(tid) else { continue };+ if owner == root.pid || chain_pids.contains(&owner) {+ return Some(l.path.clone());+ }+ }+ // 2. SOLE CANDIDATE, bounded from both sides — and only once our own chain+ // has actually been SEEN. A root we were promised and never found is no+ // evidence that anything of ours ran, so it cannot claim a log either.+ if candidates.len() == 1 && !foreign_chain_seen && root_confirmed {+ let l = candidates[0];+ let born_before = match (root.created_at, l.created_at) {+ (Some(born), Some(written)) => born <= written,+ _ => false,+ };+ if born_before {+ return Some(l.path.clone());+ }+ }+ None+}++/// The pids currently recorded as our chain.+fn chain_pids_now() -> Vec<u32> {+ attempt().map(|a| a.chain.iter().map(|p| p.pid).collect()).unwrap_or_default()+}++/// The newest (stamp, created_at) across a set of logs.+fn newest_log(logs: &[crate::launchos::LogFile]) -> (Option<u64>, Option<u64>) {+ (+ logs.iter().filter_map(|l| l.stamp).max(),+ logs.iter().filter_map(|l| l.created_at).max(),+ )+}++struct Watch {+ baseline_pids: Vec<u32>,+ base_stamp: Option<u64>,+ base_created: Option<u64>,+ /// Could the launcher-log directory be listed before we invoked? When it+ /// could not, NO log is read at all — see `LogFile::is_newer_than`.+ log_baseline_known: bool,+ invoked_at: u64,+ attribution: Option<Attribution>,+}++enum RouteOutcome {+ /// COM attached. The only success.+ Attached,+ /// This route stalled; clean up its chain and try the next one. `saw_log`+ /// records whether a launcher log for THIS invoke was ever found, so the+ /// note can say what actually happened instead of assuming.+ Stalled { note: String, saw_log: bool, unattributed_log: bool },+ /// Terminal for the WHOLE attempt, already finished and recorded.+ Stop,+}++/// Watch ONE route from its invoke to attach, a stall, or its budget.+fn watch_route(channel: &Channel, os: &dyn LaunchOs, route: &str, w: &Watch) -> RouteOutcome {+ let started = std::time::Instant::now();+ let mut last_attach_probe = std::time::Instant::now() - attach_poll_interval();+ let mut seen_run_begin = false;+ let mut seen_sldworks = false;+ let mut saw_log = false;+ let mut unattributed_log = false;+ let mut root_confirmed = attempt().map(|a| a.root_confirmed).unwrap_or(false);+ let mut log_baseline_unknown = false;+ // WAS ANOTHER LAUNCH RUNNING? A terminable-image process that is neither in+ // our chain nor in the baseline is somebody else's launcher chain, and its+ // existence is what makes "the only new log must be ours" stop being true.+ let mut foreign_chain_seen = false;+ let mut sldworks_pid: Option<u32> = None;+ // ── THE SIGN-IN PAUSE. Time spent with a 3DEXPERIENCE ID window on screen+ // does not count against any launch deadline: the chain is not stalled,+ // it is waiting for a person. `sign_in_since` is the current wait,+ // `signed_in_paused` the total of the ones that ended, and+ // `sign_in_unreadable` records that we could not even ask — which pauses+ // too, because resuming a deadline on an unreadable census is how the+ // machine killed the window it could not see.+ let mut sign_in_since: Option<std::time::Instant> = None;+ let mut signed_in_paused = Duration::ZERO;+ let mut sign_in_announced = false;+ let mut sign_in_hwnd: Option<i64> = None;+ loop {+ if last_attach_probe.elapsed() >= attach_poll_interval() {+ last_attach_probe = std::time::Instant::now();+ if channel.attached() {+ set_stage(Stage::Attached);+ return RouteOutcome::Attached;+ }+ }++ // THE LAUNCHER'S OWN LOG. Read only when we had a BASELINE to compare+ // against: without one, "is this log ours?" has no answer, and the old+ // code answered "yes, all of them".+ if !w.log_baseline_known {+ log_baseline_unknown = true;+ } else if let Ok(logs) = os.launcher_logs() {+ // A VERIFIABLY EMPTY directory and a FAILED listing both produce no+ // baseline stamps, and they are opposite facts. `is_newer_than`+ // refuses the second by refusing an absent baseline, so the first is+ // recognised HERE — where we know the listing succeeded.+ let baseline_was_empty = w.base_stamp.is_none() && w.base_created.is_none();+ let candidates: Vec<&crate::launchos::LogFile> = logs+ .iter()+ .filter(|l| baseline_was_empty || l.is_newer_than(w.base_stamp, w.base_created))+ .collect();++ // Once a log is OURS it stays ours: re-selecting on every poll is+ // how a log that appears later (a second launch) displaces the one+ // we were reading.+ let already: Option<String> = attempt().and_then(|a| a.launcher_log);+ let chosen: Option<String> = match &already {+ Some(path) if candidates.iter().any(|l| &l.path == path) => Some(path.clone()),+ _ => choose_our_log(+ os,+ &candidates,+ w,+ &chain_pids_now(),+ foreign_chain_seen,+ root_confirmed,+ ),+ };++ match chosen {+ Some(path) => {+ saw_log = true;+ update(|a| a.launcher_log = Some(path.clone()));+ set_stage_at_least(Stage::LauncherStarted);+ if let Ok(text) = os.read_log(&path) {+ if text.contains("Run Begin") {+ seen_run_begin = true;+ update(|a| a.run_begin_seen = true);+ set_stage_at_least(Stage::RunBegin);+ }+ }+ }+ None if !candidates.is_empty() && !unattributed_log => {+ unattributed_log = true;+ update(|a| a.unattributed_log = true);+ crate::ledger::record(+ "solidworks_launch_unattributed_log",+ json!({+ "route": route,+ "candidates": candidates.iter().map(|l| l.path.clone()).collect::<Vec<_>>(),+ "why": if w.attribution.is_none() {+ "this route does not report what it started, so no launcher log \+ can be attributed to it"+ } else if candidates.len() > 1 {+ "more than one launcher log post-dates this invoke and none could \+ be tied to a process in our own chain — picking the newest would \+ be a guess, and on this machine the newest is as likely to be \+ somebody else's launch as ours"+ } else {+ "no process from this route's own chain has been confirmed, or \+ another launcher chain was running, so nothing proves this log \+ is ours"+ },+ "consequence": "it is NOT read as this bridge's own evidence; no stage \+ transition and no route memory comes from it",+ }),+ );+ }+ None => {}+ }+ }++ if seen_run_begin && w.log_baseline_known {+ let baseline_was_empty = w.base_stamp.is_none() && w.base_created.is_none();+ if let Ok(logs) = os.helper_logs() {+ if logs+ .iter()+ .any(|l| baseline_was_empty || l.is_newer_than(w.base_stamp, w.base_created))+ {+ set_stage_at_least(Stage::HelperSignIn);+ }+ }+ }++ // THE PROCESSES. Our chain is recorded BY IDENTITY, grown from the+ // pids we already know are ours — never re-derived from the clock.+ if let Ok(census) = os.processes() {+ // ── RECORD THE CHAIN FIRST, THEN JUDGE WHAT IS FOREIGN. The order+ // was the other way round, and it made a launch call ITSELF+ // foreign: on the first census after an invoke the recorded chain+ // is still EMPTY and only the attribution ROOT (CATSTART) was+ // excluded by pid — so our own `SWXDesktopLauncher`, the root's+ // own child, matched "terminable, not ours, not baseline, not the+ // root" and latched `foreignChainSeen` on every ordinary run.+ //+ // That is not cosmetic. `reattribute_after_attach` bails on+ // `foreign_chain_seen` BEFORE it ever reaches the lineage proof,+ // so sign-in recovery — the whole point of 0.6.0 — was inert. It+ // is the `foreignChainSeen:true` seen live on 2026-09-11.+ //+ // THE CHAIN, ONLY FROM THE ATTRIBUTED ROOT. `attributed_chain`+ // returns None when the invoke could not say what it started, and+ // None means we record nothing — not "look around and guess".+ let recorded: Vec<ProcInfo> = attempt().map(|a| a.chain).unwrap_or_default();+ if let Some(mine) =+ attributed_chain(&census, w.attribution.as_ref(), &recorded, &w.baseline_pids, w.invoked_at)+ {+ if !mine.is_empty() {+ // SEEING THE CHAIN AT LEAST ONCE is what makes an attributed+ // route accountable: it is the difference between "there was+ // nothing left to kill" and "I never found what I started".+ root_confirmed = true;+ // ...and WHEN we saw each of them, which is what bounds the+ // window a later process may claim one of them as its parent.+ let now = crate::launchos::epoch_secs();+ let seen: Vec<(u32, u64, u64)> = mine+ .iter()+ .filter_map(|p| p.created_at.map(|c| (p.pid, c, now)))+ .collect();+ update(|a| {+ a.chain = mine;+ a.root_confirmed = true;+ for (pid, created, at) in &seen {+ match a+ .chain_last_seen+ .iter_mut()+ .find(|(p, c, _)| p == pid && c == created)+ {+ Some(row) => row.2 = *at,+ None => a.chain_last_seen.push((*pid, *created, *at)),+ }+ }+ });+ }+ }+ // ...and only now ask what is somebody else's, against the chain as+ // it stands AFTER this census — plus every descendant of our own+ // root, because the launcher IS our chain whether or not the walk has+ // caught up with it yet.+ let ours: Vec<u32> = chain_pids_now();+ let root_pids: Vec<u32> = w.attribution.as_ref().map(|r| vec![r.pid]).unwrap_or_default();+ let descendants: Vec<u32> = if root_pids.is_empty() {+ Vec::new()+ } else {+ chain_candidates(&census, &w.baseline_pids, &root_pids, w.invoked_at)+ .into_iter()+ .map(|p| p.pid)+ .collect()+ };+ // ── FOREIGNNESS NEEDS POSITIVE EVIDENCE, and an UNATTRIBUTABLE route+ // supplies none either way. `explorer` and `cmdStart` cannot say+ // what they started, so `root_pids` and `descendants` are both+ // empty and their own launcher chains matched every clause of this+ // test — the machine called the processes it had just started+ // somebody else's, on a machine whose only launcher processes were+ // the ones it started. Same shape as the 0.6.3 finding, one route+ // further down the ladder.+ //+ // A launcher process that appeared AFTER our invoke, is not in the+ // baseline, and belongs to a route that cannot name its own root is+ // UNATTRIBUTED — POSSIBLY OURS. It is recorded as such and excluded+ // from the foreign set, and it never enters the kill path: an+ // unattributable route still cleans up nothing, which is the whole+ // point of the attribution law. "Possibly ours" is a reason NOT to+ // kill and NOT to accuse, which are the same answer here.+ let mut possibly_ours: Vec<u32> = Vec::new();+ let foreign: Vec<&ProcInfo> = census+ .iter()+ .filter(|p| {+ TERMINABLE_IMAGES.iter().any(|t| t.eq_ignore_ascii_case(&p.image))+ && !ours.contains(&p.pid)+ && !w.baseline_pids.contains(&p.pid)+ && !root_pids.contains(&p.pid)+ && !descendants.contains(&p.pid)+ })+ .filter(|p| {+ // POSITIVE EVIDENCE that it is not ours: it predates our+ // invoke (so no invoke of ours could have started it). A+ // launcher that appeared after our invoke, while a route of+ // ours was running that cannot name what it started, is not+ // evidence of anybody else.+ let predates = p.created_at.map(|c| c + 1 < w.invoked_at).unwrap_or(false);+ if !predates {+ possibly_ours.push(p.pid);+ }+ predates+ })+ .collect();+ if !possibly_ours.is_empty() {+ let seen = possibly_ours.clone();+ update(|a| {+ for pid in &seen {+ if !a.possibly_ours.contains(pid) {+ a.possibly_ours.push(*pid);+ }+ }+ });+ }+ if !foreign.is_empty() && !foreign_chain_seen {+ let names: Vec<String> =+ foreign.iter().map(|p| format!("{} (pid {})", p.image, p.pid)).collect();+ foreign_chain_seen = true;+ update(|a| a.foreign_chain_seen = true);+ crate::ledger::record(+ "solidworks_launch_foreign_chain",+ json!({+ "processes": names,+ "why": "these launcher processes PREDATE this bridge's invoke, so no route \+ of ours started them — somebody else's launch is running",+ }),+ );+ }+ if let Some(sw) = census.iter().find(|p| p.is("SLDWORKS.exe")) {+ seen_sldworks = true;+ sldworks_pid = Some(sw.pid);+ set_stage_at_least(Stage::SldworksUp);+ set_stage_at_least(Stage::Attaching);+ }+ }++ if seen_run_begin {+ recover_crash_reporter(sldworks_pid);+ }++ // ── IS THE USER BEING ASKED TO SIGN IN? Asked of OUR chain's pids only,+ // and only once the chain exists to ask about.+ {+ let ours = chain_pids_now();+ let found = if ours.is_empty() {+ Some(None)+ } else {+ sign_in_window(os, &ours)+ };+ match found {+ // A window is up, or the census could not be read while our chain+ // is alive. Both pause.+ Some(Some((hwnd, title))) => {+ if sign_in_since.is_none() {+ sign_in_since = Some(std::time::Instant::now());+ sign_in_hwnd = Some(hwnd);+ set_stage_at_least(Stage::HelperSignIn);+ update(|a| a.sign_in_seen = true);+ crate::ledger::record(+ "solidworks_launch_sign_in",+ json!({+ "route": route,+ "hwnd": hwnd,+ "title": title,+ "effect": "every launch deadline is PAUSED while this window is on \+ screen; nothing in this chain will be killed or \+ escalated. Only the user can answer it.",+ }),+ );+ if !sign_in_announced {+ sign_in_announced = true;+ announce_sign_in(&title);+ }+ }+ }+ None => {+ // Census unreadable for every pid we hold. Pause rather than+ // assume the screen is clear.+ if sign_in_since.is_none() {+ sign_in_since = Some(std::time::Instant::now());+ }+ }+ Some(None) => {+ if let Some(t) = sign_in_since.take() {+ signed_in_paused += t.elapsed();+ crate::ledger::record(+ "solidworks_launch_sign_in_done",+ json!({+ "route": route,+ "hwnd": sign_in_hwnd,+ "waitedSeconds": t.elapsed().as_secs(),+ "effect": "the sign-in window is gone; the launch deadlines resume \+ from where they were paused",+ }),+ );+ }+ }+ }+ }+ // EVERY DEADLINE BELOW is measured on this clock, which does not run+ // while a human is being asked for a password.+ let elapsed = started+ .elapsed()+ .saturating_sub(signed_in_paused + sign_in_since.map(|t| t.elapsed()).unwrap_or_default());++ if !seen_run_begin && elapsed > run_begin_budget() {+ if log_baseline_unknown {+ // NO EVIDENCE EITHER WAY. The launcher-log directory could not+ // be listed before the invoke, so no log can be attributed to+ // this launch — and a stall is a statement ABOUT a log.+ return RouteOutcome::Stalled {+ note: format!(+ "route '{route}' could not be judged: the launcher's log directory could \+ not be listed before the invoke, so no log can be attributed to this \+ launch. NOTHING is concluded about whether the chain started, reached \+ SecurityContext or stalled."+ ),+ saw_log: true, // suppress the "nothing started" note: we do not know that+ unattributed_log,+ };+ }+ return RouteOutcome::Stalled {+ note: format!(+ "route '{route}' started a launcher chain that reached SecurityContext but \+ never logged 'Run Begin' within {}s",+ run_begin_budget().as_secs()+ ),+ saw_log,+ unattributed_log,+ };+ }+ if seen_run_begin && !seen_sldworks && elapsed > sldworks_budget() {+ return RouteOutcome::Stalled {+ note: format!(+ "route '{route}' logged 'Run Begin' but no sldworks.exe appeared within {}s",+ sldworks_budget().as_secs()+ ),+ saw_log,+ unattributed_log,+ };+ }+ if elapsed > attach_budget() {+ // SOLIDWORKS IS UP AND NOT ANSWERING COM. Not a stall of the+ // launcher chain, and the chain must NOT be killed for it: the+ // application is on the user's screen, possibly at a sign-in only+ // they can answer. Stop; do not try another route.+ if seen_sldworks {+ set_stage_at_least(Stage::Attaching);+ finish(+ "sldworksUpNotAttached",+ Some(format!(+ "sldworks.exe is running but COM did not attach within {}s — it may be at a \+ 3DEXPERIENCE sign-in only the user can answer",+ attach_budget().as_secs()+ )),+ );+ return RouteOutcome::Stop;+ }+ return RouteOutcome::Stalled {+ note: format!(+ "route '{route}' produced no COM server within {}s",+ attach_budget().as_secs()+ ),+ saw_log,+ unattributed_log,+ };+ }+ std::thread::sleep(poll_interval());+ }+}++fn set_stage_at_least(stage: Stage) {+ let current = attempt().map(|a| a.stage).unwrap_or(Stage::Invoked);+ if current < stage && current < Stage::Attached {+ set_stage(stage);+ }+}++/// What a cleanup did, and what it could not do.+#[derive(Default)]+struct Cleanup {+ killed: Vec<ProcInfo>,+ left_running: Vec<ProcInfo>,+ failed: bool,+ error: Option<String>,+ attributed: bool,+ kind: &'static str,+ may_remain: bool,+}++impl Cleanup {+ /// The route could not say what it started, so nothing may be terminated.+ /// This is NOT a failure of the cleanup — it is a property of the route,+ /// and the difference matters to whoever reads the report.+ fn not_attributable(route: &str, a_log_appeared: bool) -> Cleanup {+ Cleanup {+ kind: "notAttributable",+ may_remain: a_log_appeared,+ error: Some(format!(+ "route '{route}' does not report what it started (the shell forwards the link and \+ returns nothing), so this bridge cannot prove which launcher chain is its own \+ and will not terminate any. {} The user can end any stray chain from Task \+ Manager, and starting SOLIDWORKS from their Start Menu is unaffected by it.",+ if a_log_appeared {+ "A launcher log WAS written while this route was watched, so a \+ CATSTART/SWXDesktopLauncher chain from this attempt MAY still be running."+ } else {+ "No launcher log appeared at all, so most likely nothing started."+ }+ )),+ ..Default::default()+ }+ }++ /// The invoke DID hand back a root, and across the whole watch neither that+ /// process nor anything descending from it was ever seen. We cannot say+ /// whether the chain we started is still running, which is a failure to+ /// account for it — not a clean "there was nothing to kill".+ fn not_confirmed(route: &str, root: &Attribution) -> Cleanup {+ Cleanup {+ kind: "notConfirmed",+ failed: true,+ attributed: true,+ may_remain: true,+ error: Some(format!(+ "route '{route}' reported starting pid {} ({}), and neither that process nor any \+ descendant of it was ever seen in the process census. This bridge cannot tell \+ whether the chain it started is still running, so it stopped rather than \+ starting another.",+ root.pid, root.source+ )),+ ..Default::default()+ }+ }++ /// The baseline census failed, so this bridge cannot see the machine it is+ /// starting processes on.+ fn blind() -> Cleanup {+ Cleanup {+ kind: "failed",+ failed: true,+ error: Some(+ "the process census could not be taken when this route was invoked, so nothing \+ could be attributed and nothing could be cleaned up. Any launcher chain this \+ attempt started is still running and this bridge cannot identify it."+ .to_string(),+ ),+ ..Default::default()+ }+ }+}++/// End OUR OWN launcher chain — the processes recorded on the attempt, and+/// nothing else.+///+/// The list is NOT re-derived from the census here. It was, and that is how a+/// launch the user started during ours ended up in it: everything terminable+/// younger than our invoke was swept in, whoever started it. Now each recorded+/// process is re-confirmed as the SAME process (pid + image + creation time)+/// and then killed through a handle that verifies the same thing again.+fn clean_up_chain(os: &dyn LaunchOs, invoked_at: u64) -> Cleanup {+ let recorded = attempt().map(|a| a.chain).unwrap_or_default();+ if recorded.is_empty() {+ // Nothing was ever attributed to us. If the census is working, that is+ // a genuine "nothing to clean"; if it is not, we cannot say.+ return match os.processes() {+ Ok(_) => Cleanup { attributed: true, kind: "nothingToClean", ..Default::default() },+ Err(e) => Cleanup {+ failed: true,+ attributed: true,+ kind: "failed",+ error: Some(format!(+ "the process census failed ({e}), so this bridge cannot tell whether the \+ launcher chain it started is still running"+ )),+ ..Default::default()+ },+ };+ }+ let census = match os.processes() {+ Ok(c) => c,+ Err(e) => {+ crate::ledger::record(+ "solidworks_launch_cleanup_skipped",+ json!({"reason": format!("the process census failed: {e}")}),+ );+ return Cleanup {+ left_running: recorded,+ failed: true,+ attributed: true,+ kind: "failed",+ error: Some(format!("the process census failed: {e}")),+ ..Default::default()+ };+ }+ };+ let mut killed = Vec::new();+ let mut left = Vec::new();+ let mut errors: Vec<String> = Vec::new();+ for p in recorded {+ // Still the same process? A pid alone is not an identity.+ let Some(live) = census.iter().find(|c| c.same_process(&p)) else {+ continue; // it exited on its own, or the number belongs to somebody else now+ };+ if !may_terminate(&live.image, live.created_at, invoked_at) {+ left.push(live.clone());+ errors.push(format!("pid {} ({}) is not on the kill allowlist", live.pid, live.image));+ continue;+ }+ match os.terminate(live.pid, &live.image, live.created_at) {+ Ok(()) => killed.push(live.clone()),+ Err(e) => {+ left.push(live.clone());+ errors.push(format!("pid {}: {e}", live.pid));+ crate::ledger::record(+ "solidworks_launch_cleanup_failed",+ json!({"pid": live.pid, "image": live.image, "error": e}),+ );+ }+ }+ }+ Cleanup {+ failed: !left.is_empty(),+ kind: if left.is_empty() { "cleaned" } else { "failed" },+ attributed: true,+ may_remain: !left.is_empty(),+ error: (!errors.is_empty()).then(|| errors.join("; ")),+ killed,+ left_running: left,+ }+}++// ── crash-reporter recovery ─────────────────────────────────────────────────++/// If the SOLIDWORKS crash reporter is on screen with a "Restart SOLIDWORKS+/// Design" button, click it ONCE and let the state machine carry on.+///+/// OFF BY DEFAULT (`SWX_CRASH_RECOVERY=on`). The decision to click rests+/// entirely on the shape of ab's `desktop_find_control` reply, and that shape+/// has never been observed on a real desktop — see docs/LAUNCH-RESILIENCE.md+/// §9. Shipping it armed would mean guessing about a click on somebody's screen.+///+/// The rules, when it IS armed:+/// * the window must be OWNED BY the SOLIDWORKS process we are watching —+/// a title is not attribution;+/// * ab must report the button STRUCTURALLY (see `mentions_button`);+/// * once per attempt AND once per bridge process per sldworks pid, so a+/// retry cannot re-arm it;+/// * nothing else is ever clicked.+pub fn recover_crash_reporter(sldworks_pid: Option<u32>) -> Option<Value> {+ if !crash_recovery_enabled() {+ return None;+ }+ if attempt().map(|a| a.crash_recovery_clicked).unwrap_or(true) {+ return None;+ }+ let pid = sldworks_pid?;+ {+ // Once per bridge lifetime for a given SOLIDWORKS process. A new pid+ // means a genuinely new session, and re-arms it.+ let clicked = CLICKED_FOR.lock().unwrap_or_else(|e| e.into_inner());+ if *clicked == Some(pid) {+ return None;+ }+ }+ if !crate::caller::ab_available() {+ return None;+ }+ let windows = crate::abwin::list_windows(Duration::from_secs(10)).ok()?;+ for w in &windows {+ let title = w.get("title").and_then(|t| t.as_str()).unwrap_or("");+ let Some(hwnd) = w.get("hwnd").and_then(|h| h.as_i64()) else { continue };+ if !is_crash_reporter_candidate(title) {+ continue;+ }+ // ATTRIBUTION BEFORE ACTION. The crash reporter belongs to the crashed+ // process (or is its child); a window we cannot attribute to the+ // SOLIDWORKS we are launching is not ours to touch.+ if w.get("pid").and_then(|p| p.as_i64()) != Some(pid as i64) {+ continue;+ }+ let found = crate::caller::ad_callback(+ "desktop_find_control",+ json!({"hwnd": hwnd, "contains": CRASH_RESTART_BUTTON, "role": "button"}),+ Duration::from_secs(20),+ );+ if !mentions_button(&found) {+ continue;+ }+ let reason = format!(+ "SOLIDWORKS crashed while the Adom bridge was starting it; clicking the crash \+ reporter's \"{CRASH_RESTART_BUTTON}\" button once to finish the launch"+ );+ let clicked = crate::caller::ad_callback(+ "desktop_ui_click",+ json!({+ "hwnd": hwnd,+ "contains": CRASH_RESTART_BUTTON,+ "role": "button",+ "reason": reason,+ "label": "Restarting SOLIDWORKS after a crash",+ }),+ Duration::from_secs(30),+ );+ update(|a| a.crash_recovery_clicked = true);+ *CLICKED_FOR.lock().unwrap_or_else(|e| e.into_inner()) = Some(pid);+ crate::ledger::record(+ "solidworks_crash_reporter_restarted",+ json!({+ "hwnd": hwnd,+ "windowTitle": title,+ "solidworksPid": pid,+ "button": CRASH_RESTART_BUTTON,+ "reason": reason,+ "ab": clicked.get("success").cloned().unwrap_or(Value::Null),+ }),+ );+ return Some(clicked);+ }+ None+}++// ── hung / exhausted ────────────────────────────────────────────────────────++/// A memory figure worth MENTIONING. Above the highest reading ever measured on+/// a session that had genuinely run out (17.4 GB), so it cannot fire on a+/// working one — and it is ADVISORY, never a verdict.+pub const MEMORY_ADVISORY_MB: u64 = 17_800;++/// How long a busy-vs-dead sample must span before ~0 CPU means anything.+pub const DEAD_SAMPLE_SECS: u64 = 15;++/// BUSY, DEAD, OR UNKNOWN — and the difference is what a caller should tell the+/// user to do.+///+/// LIVE EVIDENCE, 2026-09-08: SOLIDWORKS at 17.9 GB with an unsaved assembly+/// went not-responding with a COM timeout for about five minutes. Windows said+/// "not responding", so `is_hung` said hung and the honest-sounding hint said+/// "only the user can end it". It was burning ~70% of a core, memory was still+/// creeping, there was NO dialog, and it RECOVERED once Drew saved. Telling+/// somebody to end a task that is about to finish is a worse answer than saying+/// "wait".+///+/// So a not-responding window is only DEAD when it has also done essentially no+/// work over a sample long enough to mean it (`DEAD_SAMPLE_SECS`) and has raised+/// no new window. Anything else with CPU moving is BUSY. Without two readings —+/// or with the sample too short — the answer is UNKNOWN, never "dead".+pub fn liveness_verdict(+ responding: Option<bool>,+ cpu_active_seconds: Option<f64>,+ sample_seconds: Option<f64>,+ memory_delta_mb: Option<i64>,+ new_windows: Option<usize>,+) -> &'static str {+ liveness_verdict_for(responding, cpu_active_seconds, sample_seconds, memory_delta_mb, new_windows, None)+}++/// Above this fraction of one core, a not-responding process is doing WORK.+/// 2% of a core over the sample: comfortably below the ~70% measured on the+/// session that recovered, and far above the idle jitter a truly wedged process+/// shows.+pub const BUSY_CPU_FRACTION: f64 = 0.02;+/// Memory moving at least this fast (MB per minute) is work too.+pub const BUSY_MEM_MB_PER_MIN: f64 = 8.0;+/// After this long not responding, "busy" stops being a useful answer even if a+/// trickle of work continues: nobody should be told to keep waiting forever.+pub const BUSY_PATIENCE_SECS: u64 = 15 * 60;++/// The verdict, with the total time spent not-responding taken into account.+///+/// B1: the first version compared ABSOLUTE figures — `cpu > 0.25s` or+/// `|Δmem| >= 8MB` — with no reference to the span they were measured over. A+/// third of a second of CPU across a ten-minute sample is a process doing+/// essentially nothing, and it read as `busy`, which tells the user to wait+/// forever. Work is a RATE.+pub fn liveness_verdict_for(+ responding: Option<bool>,+ cpu_active_seconds: Option<f64>,+ sample_seconds: Option<f64>,+ memory_delta_mb: Option<i64>,+ new_windows: Option<usize>,+ not_responding_for_secs: Option<u64>,+) -> &'static str {+ match responding {+ Some(true) => return "responding",+ // We do not KNOW the window is unresponsive, so we cannot call the+ // process busy or dead on the strength of its CPU alone.+ None => return "unknown",+ Some(false) => {}+ }+ // A RATE, not a total. Without a span there is no rate and no verdict.+ let (Some(cpu), Some(span)) = (cpu_active_seconds, sample_seconds) else {+ return "unknown";+ };+ if span <= 0.0 {+ // Two readings in the same instant measure nothing.+ return "unknown";+ }+ let cpu_fraction = cpu / span;+ let mem_rate = memory_delta_mb.map(|d| (d.abs() as f64) * 60.0 / span).unwrap_or(0.0);+ let working = cpu_fraction > BUSY_CPU_FRACTION || mem_rate >= BUSY_MEM_MB_PER_MIN;+ if working {+ // ...but not forever. A session that has been like this for a very long+ // time is no longer something to keep waiting on, whatever the trickle.+ if not_responding_for_secs.unwrap_or(0) >= BUSY_PATIENCE_SECS {+ return "unknown";+ }+ return "busy";+ }+ // From here we are considering DEAD, which needs a sample worth the name.+ if span < DEAD_SAMPLE_SECS as f64 {+ return "unknown";+ }+ // B2: an UNREADABLE window census cannot support a dead verdict. A new+ // window means something is being ASKED; not knowing whether there is one is+ // not the same as knowing there is none.+ match new_windows {+ None => "unknown",+ Some(n) if n > 0 => "unknown",+ Some(_) => "dead",+ }+}++/// What to TELL the user, per verdict. The busy case must not read as a+/// death sentence and the dead case must not read as "wait a bit".+pub fn verdict_advice(verdict: &str) -> &'static str {+ match verdict {+ "responding" => "SOLIDWORKS is answering; nothing is wrong with the message loop.",+ "busy" => "SOLIDWORKS IS NOT RESPONDING BUT IT IS WORKING — CPU is being burned and/or memory is still moving, and no dialog is up. This is a long rebuild, a big save, or memory pressure, and it usually finishes: on 2026-09-08 a session in exactly this state recovered after about five minutes. TELL THE USER TO WAIT, and say what it was doing. Do NOT advise ending the task, and do not retry in a loop — every retry adds work to a machine that is already behind.",+ "dead" => "SOLIDWORKS is not responding and is doing NO measurable work (no CPU over the sample, memory flat, no dialog). Nothing this bridge can send will get through. ONLY THE USER CAN END IT — tell them what it was doing when it stopped and let them decide.",+ _ => "UNKNOWN: there were not two readings far enough apart to tell a thrashing session from a dead one. Poll swx_status again in 15-30 seconds rather than concluding anything — 'not responding' alone has already been observed on a session that recovered.",+ }+}++/// Is SOLIDWORKS hung?+///+/// `None` = UNKNOWN, and unknown is the answer far more often than the first+/// version allowed. BOTH signals must be present and both must be knowable:+/// Windows says the window is not answering messages, AND a COM call this+/// bridge made EXCEEDED ITS BUDGET.+///+/// `com_timed_out` is `None` when there was no COM call to judge — in+/// particular when the com host is BUSY WITH OUR OWN LONG VERB. The first+/// version counted busy as a timeout, so a rebuild this bridge started made+/// both "independent" signals fire from one cause and reported the user's+/// working session as hung.+pub fn is_hung(responding: Option<bool>, com_timed_out: Option<bool>) -> Option<bool> {+ match (responding, com_timed_out) {+ (Some(r), Some(t)) => Some(!r && t),+ _ => None,+ }+}++/// Why SOLIDWORKS is out of room, if it PROVABLY is.+///+/// ONE signal is sufficient and it is not a threshold: **SOLIDWORKS accepted a+/// NewDocument against its own stock template and handed back nothing.** That is+/// what exhaustion looks like from the API, and it is what was actually observed.+///+/// Memory and document counts are NOT sufficient, in either direction. The+/// first version raised `host_app_exhausted` above 12 GB / 40 documents, and the+/// same machine the same week ran a healthy session at 13.1 GB with 33+/// documents open — so the verb told a working session to close its documents+/// and restart. Those figures are now advisory only ([`memory_advisory`]).+pub fn exhaustion(_private_mb: Option<u64>, _open_docs: Option<u64>, new_doc_empty: bool) -> Option<String> {+ new_doc_empty.then(|| {+ "SOLIDWORKS returned no document from its own stock template — the signature of a session \+ that has run out of room"+ .to_string()+ })+}++/// The COMPOUND verdict, for a session that is not answering at all: it is+/// wedged AND a COM call timed out AND its memory is still climbing between two+/// reads. Any one of those alone is ordinary.+pub fn exhaustion_confirmed(+ new_doc_empty: bool,+ responding: Option<bool>,+ com_timed_out: Option<bool>,+ memory_rising: bool,+ private_mb: Option<u64>,+) -> Option<String> {+ if let Some(why) = exhaustion(private_mb, None, new_doc_empty) {+ return Some(why);+ }+ if is_hung(responding, com_timed_out) == Some(true) && memory_rising {+ return Some(format!(+ "SOLIDWORKS is not responding, a COM call timed out, and its private memory is still \+ climbing ({} MB)",+ private_mb.map(|m| m.to_string()).unwrap_or_else(|| "unknown".into())+ ));+ }+ None+}++/// A figure worth putting in front of a human — and nothing more.+pub fn memory_advisory(private_mb: Option<u64>, working_set_mb: Option<u64>, open_docs: Option<u64>) -> Option<String> {+ let mb = private_mb?;+ if mb < MEMORY_ADVISORY_MB {+ return None;+ }+ Some(format!(+ "sldworks.exe is holding {mb} MB of private memory ({} MB resident{}). At 17.4 GB on \+ 2026-09-03 this session stopped being able to create documents. This is an OBSERVATION, \+ not a verdict — SOLIDWORKS may be working perfectly.",+ working_set_mb.map(|w| w.to_string()).unwrap_or_else(|| "unknown".into()),+ open_docs.map(|d| format!(", {d} documents open")).unwrap_or_default(),+ ))+}++/// The hint that goes with `host_app_exhausted`.+pub const EXHAUSTED_HINT: &str =+ "SOLIDWORKS is still running, but it has no room left: new documents come back empty and \+ further work will fail in ways that look like unrelated bugs. Close the documents this \+ bridge created (swx_reset_workspace) and try again. If that is not enough, ASK THE USER to \+ restart SOLIDWORKS — it is their session and their unsaved work, and this bridge will not \+ end it for them. Do NOT retry the same call in a loop; each attempt costs more memory.";++/// The hint that goes with `host_app_hung`.+pub const HUNG_HINT: &str =+ "Windows reports the SOLIDWORKS window is not responding AND a COM call from this bridge \+ exceeded its budget. Nothing this bridge can send will get through: the message loop is not \+ running. ONLY THE USER CAN END IT — tell them SOLIDWORKS is not responding, say what it was \+ doing when it stopped, and let them decide whether to wait or end the task. This bridge will \+ not kill their CAD session to unblock itself; swx_kill exists but is gated behind their \+ explicit opt-in for exactly this reason.";++// ── the report ──────────────────────────────────────────────────────────────++/// The `launch` block on `swx_status` and `swx_start`.+/// THE SESSION CAME UP AFTER WE GAVE UP — and it was still ours.+///+/// Live, 2026-09-11: the launch machine wrote the attempt off as `exhausted`+/// while the user was at the sign-in window, and SOLIDWORKS then started from a+/// surviving launcher window. `attributed` stayed FALSE on a session this bridge+/// really did launch, and everything that keys on attribution followed it down:+/// the quit gate's bridge-launched proof failed, and `swx_close_all_documents`+/// refused as though the session were the user's own.+///+/// The evidence that it is ours is already recorded and does not expire:+///+/// * some route of THIS attempt had a launcher log we could prove was ours+/// and it logged `Run Begin` — the chain really built something;+/// * the running `sldworks.exe` was created AT OR AFTER our invoke, so it+/// cannot be a session that predates us;+/// * and no other launcher chain was seen (`foreign_chain_seen`), so there is+/// no second candidate for whose launch this was.+///+/// All three, or nothing changes. Re-attribution on two of them would be the+/// "first CATSTART after our invoke" guess this bridge spent four adversarial+/// rounds removing.+/// Walk `sldworks.exe`'s parents. Returns HOW it is ours, or `None`.+///+/// Bounded, because a parent chain read from a live census can contain a cycle if+/// a pid was reused while we walked it.+fn descends_from_our_chain(census: &[ProcInfo], mut pid: u32, a: &Attempt) -> Option<String> {+ let start = pid;+ // ── A PID IS NOT AN IDENTITY, and Windows does not validate a parent id:+ // `ppid` is recorded at creation and never cleared, so once our launcher+ // exits (which it always does — the chain exits as soon as sldworks.exe is+ // up) that NUMBER can belong to something else entirely. A bare+ // `ours.contains(&parent)` would then accept a process whose parent merely+ // REUSED the number of a launcher of ours that is long dead. Every other+ // identity in this crate is (pid, creation time); so is this one.+ let ours: Vec<ProcInfo> = our_identities(a);+ if ours.is_empty() {+ return reattr_refuse("no_identities");+ }+ let mut seen: Vec<u32> = Vec::new();+ for _ in 0..16 {+ if seen.contains(&pid) {+ return reattr_refuse("parent_walk_cycle");+ }+ seen.push(pid);+ let Some(p) = census.iter().find(|p| p.pid == pid) else {+ return reattr_refuse("walk_pid_not_in_census");+ };+ let Some(parent) = p.ppid else { return reattr_refuse("no_parent_recorded") };+ if let Some(mine) = ours.iter().find(|m| m.pid == parent) {+ // THE PARENT IS USUALLY DEAD, and that is normal: the launcher chain+ // exits the moment sldworks.exe is up. So it cannot be required to be+ // alive — but if something IS alive on that pid, it must be the+ // process we recorded, or the number has been reused and this is+ // somebody else's tree.+ if let Some(live) = census.iter().find(|c| c.pid == parent) {+ if !mine.same_process(live) {+ return reattr_refuse("alive_but_different_process");+ }+ }+ let Some(child) = census.iter().find(|c| c.pid == start) else {+ return reattr_refuse("child_not_in_census");+ };+ let Some(child_created) = child.created_at else {+ return reattr_refuse("child_creation_time_unknown");+ };+ let Some(parent_created) = mine.created_at else {+ return reattr_refuse("parent_creation_time_unknown");+ };+ // ── THE WINDOW. "A pid we once recorded" is not enough once that+ // process is dead: the NUMBER is free, and a user's launcher that+ // reuses it would have its SOLIDWORKS record our dead pid as its+ // parent with nothing alive left to contradict it — and "the child+ // is younger than the parent" is vacuously true for anything that+ // started later. So the parent must have been SEEN ALIVE with this+ // exact (pid, creation time) during the attempt, and the child must+ // have been created between that creation time and shortly after we+ // last watched it run. A process that appeared long after we last+ // saw the parent alive is not its child.+ let Some(last_seen) = a+ .chain_last_seen+ .iter()+ .find(|(p, c, _)| *p == parent && *c == parent_created)+ .map(|(_, _, t)| *t)+ else {+ return reattr_refuse("no_last_seen_record");+ };+ // CREATED WHILE THE PARENT RAN: at or after the parent's own+ // creation, and no later than the last moment we saw the parent+ // alive, plus one census interval for the gap between polls.+ let grace = lineage_grace_secs();+ if child_created < parent_created || child_created > last_seen + grace {+ return reattr_refuse("child_outside_window");+ }+ return Some(format!(+ "sldworks.exe descends from pid {parent} ({}, created {parent_created}), a process \+ this attempt WATCHED RUN until {last_seen}, and was created at {child_created} — \+ while that process was running (plus {grace}s for the census interval)",+ mine.image+ ));+ }+ pid = parent;+ }+ reattr_refuse("parent_not_in_ours")+}++/// How long after we last SAW a parent alive a child may still be called its+/// descendant.+///+/// A REAL CHILD IS CREATED WHILE ITS PARENT RUNS. The only reason this is not+/// zero is that our knowledge of "while it ran" is a POLL: the parent can die in+/// the gap between two censuses, and a child created in that gap was still+/// created while it ran — we just did not look at the right moment. So the grace+/// is exactly the census interval, and nothing wider: a flat 30s was three+/// census intervals of slack invented to be safe, and slack in an identity rule+/// is the space a recycled pid lives in.+pub fn lineage_grace_secs() -> u64 {+ poll_interval().as_secs().max(1)+}++/// The processes this attempt can prove it started, as IDENTITIES.+///+/// A recorded chain entry already carries its creation time. An `Attribution`+/// root may not (a `Start-Process -PassThru` pid arrives without one), and one+/// without a creation time cannot be matched by `same_process` — so it is only+/// usable if the census still holds a process of that pid whose creation time we+/// can read, which is exactly the "no handle, no kill" standard applied to a+/// question about lineage rather than about termination.+fn our_identities(a: &Attempt) -> Vec<ProcInfo> {+ let mut out: Vec<ProcInfo> = Vec::new();+ for p in a.chain.iter() {+ if p.created_at.is_some() {+ out.push(p.clone());+ }+ }+ if let Some(r) = a.attribution.as_ref() {+ if let Some(c) = r.created_at {+ out.push(ProcInfo {+ pid: r.pid,+ image: "CATSTART.exe".into(),+ created_at: Some(c),+ ppid: None,+ });+ }+ }+ out+}++// NOTE, 0.6.3: there was a `log_authored_by_our_chain` here, offered as a second+// lineage proof. It is gone, and deliberately not replaced: a launcher log written+// by a thread of our chain proves OUR LAUNCHER RAN, which is a different claim+// from "the SOLIDWORKS now running came from it". A user starting SOLIDWORKS from+// the Start Menu while our chain was also running satisfied it, and the bridge+// would have adopted their session on that evidence. The parent walk is the only+// thing that answers the question actually being asked.++/// WHY RE-ATTRIBUTION DID NOT HAPPEN, recorded every time it does not.+///+/// This exists because the feature was inert for three releases and each time the+/// evidence for WHY was reconstructed by reading code and guessing. A refusal+/// that names its own clause turns that into one line of output. It is written to+/// the launch report (`reattributionRefusedBecause`) and to the ledger.+static REATTR_REASON: Mutex<Option<String>> = Mutex::new(None);++/// The pid of the SOLIDWORKS this bridge is ATTACHED to, as last reported by the+/// com host. 0 means unknown. Set by `swx_status`, which is the only place that+/// asks; read by the lineage walk so the process a caller MEANS is tested first.+static ATTACHED_SW_PID: AtomicU32 = AtomicU32::new(0);++pub fn note_attached_solidworks(pid: Option<u32>) {+ ATTACHED_SW_PID.store(pid.unwrap_or(0), Ordering::SeqCst);+}++fn reattr_refuse(reason: &str) -> Option<String> {+ *REATTR_REASON.lock().unwrap_or_else(|e| e.into_inner()) = Some(reason.to_string());+ None+}++fn reattr_clear() {+ *REATTR_REASON.lock().unwrap_or_else(|e| e.into_inner()) = None;+}++/// THE SOLIDWORKS THIS BRIDGE'S ATTRIBUTION IS ABOUT, if any.+///+/// A verb that ENDS a session asks this before it acts: an attribution record is+/// a claim about ONE process, and it may not authorise acting on another. `None`+/// means no claim has been made, which is not a licence either — it simply means+/// this check has nothing to say.+pub fn attributed_solidworks() -> Option<(u32, u64)> {+ attempt().and_then(|a| a.attributed_sldworks)+}++pub fn reattribution_refused_because() -> Option<String> {+ REATTR_REASON.lock().unwrap_or_else(|e| e.into_inner()).clone()+}++pub fn reattribute_after_attach(os: &dyn LaunchOs, attached: bool) -> Option<String> {+ // COM MUST ACTUALLY BE ATTACHED. The first version of this took "SOLIDWORKS+ // is in the census" as its trigger and relabelled the attempt `attached`+ // — which is a false claim about the only thing this bridge counts as+ // success, and it broke the case the launch machine exists to report+ // honestly: sldworks up, COM not answering, probably a sign-in. Being up is+ // not being usable.+ if !attached {+ return reattr_refuse("not_attached");+ }+ let Some(a) = attempt() else { return reattr_refuse("no_attempt_recorded") };+ if !a.finished || a.outcome.as_deref() == Some("attached") {+ return reattr_refuse("outcome_not_eligible");+ }+ if a.foreign_chain_seen {+ return reattr_refuse("foreign_chain_seen");+ }+ // A route of ours that reached Run Begin, WITH the root the invoke handed+ // back — the same evidence standard as everywhere else in this module.+ // THE ROUTE THAT PROVED ITSELF. Preferably the per-route row; failing that,+ // the attempt's own `run_begin_seen`, which is the same first-hand fact+ // recorded one level up (a route can end without a row — see `Stop`).+ let proven_route: String = match a+ .tries+ .iter()+ .find(|t| t.run_begin_seen && t.root_pid.is_some())+ .or_else(|| a.tries.iter().find(|t| t.run_begin_seen))+ {+ Some(t) => t.route.to_string(),+ None if a.run_begin_seen => a+ .last_route_before_attach+ .clone()+ .or_else(|| a.tries.last().map(|t| t.route.to_string()))+ .unwrap_or_else(|| "unknown".to_string()),+ None => return reattr_refuse("route_unattributable"),+ };+ let Ok(census) = os.processes() else { return reattr_refuse("census_unreadable") };+ // ── EVERY sldworks.exe, NOT "the first one in the census".+ //+ // `find` took whichever the census listed first, and if that process had no+ // parent recorded the walk ended there — `no_parent_recorded` — while a+ // second sldworks.exe sitting further down the list descended from our own+ // launcher. That is the same defect `host_app_health` was rewritten for in+ // 0.4.x ("EVERY sldworks.exe is reported, not just the first"), reappearing+ // in the lineage walk.+ //+ // More than one is ordinary: a zombie from a crashed session, or the user's+ // own session beside ours. The question is not "what is the SOLIDWORKS" but+ // "does ANY running SOLIDWORKS descend from the chain we started", and the+ // one we are ATTACHED to is asked first because it is the one a caller+ // means.+ let all_sw: Vec<&ProcInfo> = census.iter().filter(|p| p.is("SLDWORKS.exe")).collect();+ if all_sw.is_empty() {+ return reattr_refuse("no_sldworks_in_census");+ }+ // ── THE PROCESS THAT DESCENDS FROM OUR CHAIN MUST BE THE ONE COM IS+ // ATTACHED TO. 0.6.6 used the attached pid only to ORDER the candidates+ // and took the first that descended — so with three SOLIDWORKS on the+ // machine (one COM-attached with no parent, one of ours, one of the+ // user's) it proved lineage for OUR launcher's child and then labelled the+ // session `attributed:true` while COM was attached to a different process+ // entirely. Everything downstream reads that label: the quit gate's+ // bridge-launched proof, and a quit ladder that builds its Identity from+ // the COM pid. The bridge would have been willing to END a session nothing+ // had proved was ours.+ //+ // Ordering is not a substitute for equality. The claim being made is "the+ // session this bridge is talking to is the one it launched", and only the+ // attached process can satisfy it.+ let attached_pid = ATTACHED_SW_PID.load(Ordering::SeqCst);+ let attached_pid = if attached_pid == 0 { None } else { Some(attached_pid) };+ let Some(want) = attached_pid else {+ return reattr_refuse("attached_pid_unknown");+ };+ // EVERY candidate is examined and every verdict recorded, so the ledger says+ // what was considered rather than "the last one stands".+ let mut per_candidate: Vec<Value> = Vec::new();+ let mut proved: Option<(&ProcInfo, String)> = None;+ for cand in &all_sw {+ let why = descends_from_our_chain(&census, cand.pid, &a);+ let clause = match &why {+ Some(_) => "descendsFromOurChain".to_string(),+ None => reattribution_refused_because().unwrap_or_else(|| "unknown".into()),+ };+ per_candidate.push(json!({+ "pid": cand.pid,+ "createdAt": cand.created_at,+ "isAttachedProcess": cand.pid == want,+ "verdict": clause,+ }));+ if why.is_some() && cand.pid == want && proved.is_none() {+ proved = Some((cand, why.unwrap()));+ }+ }+ let descends_but_not_attached: Vec<u32> = per_candidate+ .iter()+ .filter(|c| {+ c.get("verdict").and_then(|v| v.as_str()) == Some("descendsFromOurChain")+ && c.get("isAttachedProcess").and_then(|v| v.as_bool()) == Some(false)+ })+ .filter_map(|c| c.get("pid").and_then(|v| v.as_u64()).map(|p| p as u32))+ .collect();+ let Some((sw, lineage)) = proved else {+ crate::ledger::record(+ "solidworks_launch_reattribution_refused",+ json!({+ "attachedPid": want,+ "candidates": per_candidate,+ "why": if descends_but_not_attached.is_empty() {+ "no running SOLIDWORKS could be proved to descend from this attempt's chain"+ } else {+ "a SOLIDWORKS descending from our chain exists, but it is NOT the process COM \+ is attached to — so nothing proves the ATTACHED session is ours, and it is \+ the attached session every other verb acts on"+ },+ }),+ );+ return reattr_refuse(if descends_but_not_attached.is_empty() {+ "no_candidate_descends"+ } else {+ "attached_pid_not_ours"+ });+ };+ // The identity must be COMPLETE: a pid without a creation time is not one.+ if sw.created_at.is_none() {+ return reattr_refuse("sldworks_creation_time_unknown");+ }+ let Some(created) = sw.created_at else { return reattr_refuse("sldworks_creation_time_unknown") };+ // ── LINEAGE, not just timing. "No foreign chain was seen" is the ABSENCE of+ // an observation, and absence is not evidence — a launcher chain that+ // started and finished between two censuses was never seen at all, and on+ // that reading its SOLIDWORKS would have been adopted as ours purely for+ // starting after our invoke. So there must be a POSITIVE link from this+ // process back to something we know we started: a parent walk that reaches+ // our own chain, or a launcher log authored by a thread of it.+ //+ // THE PARENT WALK IS THE ONLY LINEAGE PROOF. 0.6.1 also accepted "a+ // launcher log written by a thread of our chain", and that was the wrong+ // proposition: it proves OUR LAUNCHER RAN, which is not the same claim as+ // "the SOLIDWORKS now running came from it". A user who starts SOLIDWORKS+ // from the Start Menu while our chain is also running satisfies it, and+ // the bridge would have adopted their session. Proving the wrong thing+ // confidently is worse than proving nothing.++ // NO SLACK. The bridge reads its own clock through the same call the+ // creation times come from (GetSystemTimeAsFileTime), so "created at or+ // after our invoke" needs none — see the Cargo.toml note on+ // Win32_System_SystemInformation.+ if created < a.invoked_at {+ // It predates our invoke: somebody else's session, and ours never came.+ return reattr_refuse("sldworks_predates_our_invoke");+ }+ let route = proven_route;+ let (sw_pid, sw_created) = (sw.pid, created);+ let root = a+ .tries+ .iter()+ .find(|t| t.run_begin_seen && t.root_pid.is_some())+ .and_then(|t| t.root_pid)+ .or_else(|| a.attribution.as_ref().map(|r| r.pid));+ mutate_store(|s| {+ if let Some(at) = s.attempt.as_mut() {+ at.outcome = Some("attachedAfterSignIn".into());+ at.stage = Stage::Attached;+ at.run_begin_seen = true;+ // THE EXACT SESSION THIS CLAIM IS ABOUT. Everything downstream that+ // acts on "the bridge launched this" — the quit gate's proof, and the+ // ladder that builds its Identity from the COM pid — can now check+ // that it is talking to the same process the claim was made about.+ at.attributed_sldworks = Some((sw_pid, sw_created));+ at.last_route_before_attach = Some(route.clone());+ if at.attribution.is_none() {+ if let (Some(pid), Some(c)) = (root, Some(created)) {+ // The ROOT this route reported, with the creation time we+ // have for it — not the SOLIDWORKS pid, which was never ours+ // to claim as a launcher root.+ let _ = c;+ at.attribution = Some(Attribution {+ pid,+ created_at: None,+ source: "reattributedAfterSignIn".to_string(),+ });+ }+ }+ }+ });+ reattr_clear();+ crate::ledger::record(+ "solidworks_launch_reattributed",+ json!({+ "route": route,+ "sldworksPid": sw.pid,+ "sldworksCreatedAt": created,+ "attachedPid": want,+ "isAttachedProcess": true,+ "candidates": per_candidate,+ "invokedAt": a.invoked_at,+ "wasOutcome": a.outcome,+ "lineage": lineage,+ "why": "this attempt was written off while the user was at the 3DEXPERIENCE sign-in, \+ and the session that came up afterwards is the one it started: our own chain \+ logged Run Begin, sldworks.exe was created at or after our invoke, and no \+ other launcher chain was seen",+ }),+ );+ Some(format!(+ "{lineage}; and that process (pid {sw_pid}, created {sw_created}) IS the one COM is \+ attached to (pid {want}) — which is what makes this a claim about the session every other \+ verb acts on, rather than about some other SOLIDWORKS that happens to be running"+ ))+}++pub fn report(os: &dyn LaunchOs) -> Value {+ report_with_attach(os, false)+}++/// The same report, told whether COM is attached RIGHT NOW — which is what lets+/// a session that came up after a sign-in be re-claimed (see+/// [`reattribute_after_attach`]). Only `swx_status` knows that fact, so only+/// `swx_status` passes it.+pub fn report_with_attach(os: &dyn LaunchOs, attached: bool) -> Value {+ // THE SESSION MAY HAVE COME UP AFTER WE GAVE UP. Asked here because this is+ // the function every caller reads attribution from — and the live failure was+ // that `attributed:false` outlived the launch that earned it. Cheap: it does+ // nothing at all unless the attempt is finished, un-attached, and SOLIDWORKS+ // is now running.+ let evidence = reattribute_after_attach(os, attached);+ let refused = reattribution_refused_because();+ let store = read_store(|s| s.clone());+ let live = in_flight(os);+ let backoff = backoff_remaining();+ match store.attempt {+ None => json!({+ "inFlight": false,+ "preferredRoute": store.preferred_route,+ "routesAvailable": ROUTES,+ "crashRecovery": if crash_recovery_enabled() { "armed" } else { "off" },+ "_hint": "No launch has been attempted by this bridge. swx_start begins one.",+ }),+ Some(a) => {+ let mut v = a.to_json();+ if let Some(obj) = v.as_object_mut() {+ // WHY THE SESSION WAS OR WAS NOT RE-CLAIMED, every time. This+ // feature was inert for three releases and each diagnosis meant+ // reading code and guessing; a named clause makes it one line of+ // output.+ obj.insert("reattributionEvidence".into(), json!(evidence));+ obj.insert("reattributionRefusedBecause".into(), json!(refused));+ obj.insert("inFlight".into(), json!(live.is_some()));+ obj.insert("preferredRoute".into(), json!(store.preferred_route));+ obj.insert("routesAvailable".into(), json!(ROUTES));+ obj.insert("advice".into(), json!(a.stage.advice()));+ obj.insert("consecutiveFailures".into(), json!(store.consecutive_failures));+ obj.insert("backoffSeconds".into(), json!(backoff));+ }+ v+ }+ }+}++// ── the host-app health block ───────────────────────────────────────────────++/// Remembered private-memory readings, so "still climbing" is a MEASUREMENT+/// rather than a guess: (pid, previous, latest).+#[derive(Clone, Copy)]+struct Sample {+ pid: u32,+ mb: Option<u64>,+ cpu: Option<f64>,+ /// When this reading was taken, at MILLISECOND resolution. Whole seconds made+ /// the sample span collapse to 0 whenever two reads landed inside one second+ /// — and a zero span is "unknown", so a caller who asked twice quickly was+ /// told the instrument had nothing, on a machine that was plainly working.+ at_ms: u128,+ /// When this process was FIRST seen not responding in an unbroken run. A+ /// "busy" answer is only useful while it is still plausible, so the ladder of+ /// advice needs to know how long the user has already been waiting.+ not_responding_since: Option<u64>,+}++static MEM_SAMPLES: Mutex<Option<Sample>> = Mutex::new(None);++/// What the PREVIOUS reading of this process said, and how long ago.+///+/// Two readings are the whole instrument: a single "not responding" has already+/// been observed on a session that recovered, so a verdict from one reading is a+/// guess. Returns (rising, cpu delta, sample span seconds, memory delta MB).+#[allow(clippy::type_complexity)]+fn sample_process(+ pid: u32,+ mb: Option<u64>,+ cpu: Option<f64>,+ responding: Option<bool>,+) -> (bool, Option<f64>, Option<f64>, Option<i64>, Option<u64>) {+ let now = epoch_secs();+ let mut g = MEM_SAMPLES.lock().unwrap_or_else(|e| e.into_inner());+ let prev = match *g {+ Some(p) if p.pid == pid => Some(p),+ _ => None,+ };+ // An unbroken run of not-responding readings; any responding reading resets it.+ let since = match (responding, prev.and_then(|p| p.not_responding_since)) {+ (Some(false), Some(t)) => Some(t),+ (Some(false), None) => Some(now),+ _ => None,+ };+ let now_ms = crate::launchos::epoch_ms();+ *g = Some(Sample { pid, mb, cpu, at_ms: now_ms, not_responding_since: since });+ let not_responding_for = since.map(|t| now.saturating_sub(t));+ let Some(p) = prev else { return (false, None, None, None, not_responding_for) };+ let span = Some(now_ms.saturating_sub(p.at_ms) as f64 / 1000.0);+ let cpu_delta = match (cpu, p.cpu) {+ (Some(a), Some(b)) => Some((a - b).max(0.0)),+ _ => None,+ };+ let mem_delta = match (mb, p.mb) {+ (Some(a), Some(b)) => Some(a as i64 - b as i64),+ _ => None,+ };+ let rising = mem_delta.map(|d| d > 0).unwrap_or(false);+ (rising, cpu_delta, span, mem_delta, not_responding_for)+}++/// What SOLIDWORKS is doing, read WITHOUT COM — the three questions COM cannot+/// answer when the answer is "it is wedged".+///+/// EVERY sldworks.exe is reported, not just the first. The first version took+/// whichever the census listed first, so a zombie left by a crashed session+/// supplied the memory, the handles and the `hung` verdict for a caller asking+/// about the session the user is working in.+pub fn host_app_health(+ os: &dyn LaunchOs,+ open_docs: Option<u64>,+ com_timed_out: Option<bool>,+ com_pid: Option<u32>,+) -> Value {+ let census = os.processes();+ let all: Vec<ProcInfo> = census+ .as_ref()+ .ok()+ .map(|c| c.iter().filter(|p| p.is("SLDWORKS.exe")).cloned().collect())+ .unwrap_or_default();+ if all.is_empty() {+ return json!({+ "processId": Value::Null,+ "running": match census { Ok(_) => json!(false), Err(_) => Value::Null },+ "responding": Value::Null,+ "hung": Value::Null,+ "processes": [],+ "processCount": match census { Ok(_) => json!(0), Err(_) => Value::Null },+ "censusError": census.err(),+ });+ }+ // WHICH ONE are the numbers about? The process COM is attached to, when COM+ // can say; otherwise the newest, which is the one a user just started — and+ // the answer says which rule was used.+ let (chosen, basis) = match com_pid.and_then(|p| all.iter().find(|x| x.pid == p)) {+ Some(p) => (p.clone(), "the process COM is attached to"),+ None => {+ let newest = all+ .iter()+ .max_by_key(|p| p.created_at.unwrap_or(0))+ .cloned()+ .expect("non-empty");+ (newest, "the newest sldworks.exe (COM could not say which it is attached to)")+ }+ };+ let hwnd = os.main_window_of_pid(chosen.pid);+ let responding = hwnd.and_then(|h| os.window_responding(h));+ let metrics = os.process_metrics(chosen.pid);+ let (rising, cpu_delta, sample_span, mem_delta, not_responding_for) =+ sample_process(chosen.pid, metrics.private_mb, metrics.cpu_seconds, responding);+ // `None` when the census could not be read — which must NOT support a dead+ // verdict (B2).+ let new_windows = os.windows_of_pid(chosen.pid).ok().map(|w| {+ w.iter().filter(|x| x.is_dialog() && x.visible).count()+ });+ let verdict = liveness_verdict_for(+ responding,+ cpu_delta,+ sample_span,+ mem_delta,+ new_windows,+ not_responding_for,+ );+ let hung = is_hung(responding, com_timed_out);+ let confirmed = exhaustion_confirmed(false, responding, com_timed_out, rising, metrics.private_mb);+ json!({+ "processId": chosen.pid,+ "running": true,+ "startedAt": chosen.created_at,+ "mainHwnd": hwnd,+ "responding": responding,+ "privateMemoryMB": metrics.private_mb,+ "workingSetMB": metrics.working_set_mb,+ "memoryRising": rising,+ "handles": metrics.handles,+ "openDocs": open_docs,+ "hung": hung,+ // BUSY vs DEAD. `hung` answers "is the message loop running"; this+ // answers "is anything actually happening", which is the question a+ // caller needs to decide between "wait" and "only the user can end it".+ "verdict": verdict,+ "verdictAdvice": verdict_advice(verdict),+ "cpuActiveSeconds": cpu_delta,+ "cpuSampleSeconds": sample_span,+ // The RATE the verdict was decided on, and how long the user has been+ // waiting — so "busy" can be reported as "like this for N minutes".+ "cpuFractionOfCore": match (cpu_delta, sample_span) {+ (Some(c), Some(s)) if s > 0.0 => json!((c / s * 1000.0).round() / 1000.0),+ _ => Value::Null,+ },+ "notRespondingForSeconds": not_responding_for,+ "memoryDeltaMB": mem_delta,+ "visibleDialogs": new_windows,+ "hungNote": if hung.is_none() {+ json!("UNKNOWN: 'hung' needs BOTH a not-responding window AND a COM call that \+ exceeded its budget. One of those could not be read — in particular, a com \+ host busy with this bridge's own long verb is not evidence of anything.")+ } else { Value::Null },+ "exhausted": confirmed,+ "memoryAdvisory": memory_advisory(metrics.private_mb, metrics.working_set_mb, open_docs),+ "processCount": all.len(),+ "processes": all.iter().map(|p| p.to_json()).collect::<Vec<_>>(),+ "reportedProcessBasis": basis,+ })+}++/// Is SOLIDWORKS running at all, as far as the OS layer can tell?+/// `None` means the census failed — never read that as "no".+pub fn sldworks_running(os: &dyn LaunchOs) -> Option<bool> {+ os.processes().ok().map(|c| c.iter().any(|p| p.is("SLDWORKS.exe")))+}++#[cfg(test)]+mod tests {+ use super::*;++ fn p(pid: u32, image: &str, created: Option<u64>, ppid: Option<u32>) -> ProcInfo {+ ProcInfo { pid, image: image.to_string(), created_at: created, ppid }+ }++ /// An attempt that watched one launcher run, for the lineage tests.+ fn watched(launcher_pid: u32, created: u64, last_seen: u64) -> Attempt {+ let mut a = unfinished_placeholder("test.lnk");+ a.chain = vec![p(launcher_pid, "SWXDesktopLauncher.exe", Some(created), None)];+ a.chain_last_seen = vec![(launcher_pid, created, last_seen)];+ a+ }++ /// PID REUSE, which is the whole reason lineage is not "the pid matches".+ ///+ /// Our launcher dies. The user's launcher is issued the SAME NUMBER. Their+ /// SOLIDWORKS records that number as its parent, and every cheap test passes:+ /// the pid matches one we recorded, the child is younger than our dead+ /// parent, and nothing is alive to contradict it. Only "created while the+ /// parent we WATCHED was still running" refuses it.+ #[test]+ fn a_child_created_after_we_last_saw_the_parent_alive_is_not_its_descendant() {+ let created = 1_000_000u64;+ let last_seen = 1_000_010u64;+ let a = watched(4242, created, last_seen);+ let grace = lineage_grace_secs();++ // WHILE WE WATCHED IT RUN: this is a child.+ let ok = vec![+ p(9001, "SLDWORKS.exe", Some(last_seen), Some(4242)),+ p(4242, "SWXDesktopLauncher.exe", Some(created), None),+ ];+ assert!(descends_from_our_chain(&ok, 9001, &a).is_some(), "created while the parent ran");++ // The parent is DEAD and the child appeared inside the census gap: still+ // a child, because our knowledge of "while it ran" is a poll.+ let gap = vec![p(9001, "SLDWORKS.exe", Some(last_seen + grace), Some(4242))];+ assert!(+ descends_from_our_chain(&gap, 9001, &a).is_some(),+ "one census interval after we last looked is still inside its lifetime as far as we know"+ );++ // LONG AFTER: the number was recycled, and this is somebody else's.+ let reused = vec![p(9001, "SLDWORKS.exe", Some(last_seen + grace + 60), Some(4242))];+ assert!(+ descends_from_our_chain(&reused, 9001, &a).is_none(),+ "a process created long after we last saw that pid alive is not its child — the NUMBER \+ was reused, and nothing alive is left to say so"+ );++ // OLDER THAN THE PARENT: impossible either way.+ let older = vec![p(9001, "SLDWORKS.exe", Some(created - 1), Some(4242))];+ assert!(descends_from_our_chain(&older, 9001, &a).is_none(), "a child cannot predate its parent");++ // ALIVE ON THAT PID, but a DIFFERENT process: the number is in use by+ // someone else right now, so the walk refuses even inside the window.+ let alive_other = vec![+ p(9001, "SLDWORKS.exe", Some(last_seen), Some(4242)),+ p(4242, "SWXDesktopLauncher.exe", Some(created + 5), None),+ ];+ assert!(+ descends_from_our_chain(&alive_other, 9001, &a).is_none(),+ "something else holds that pid now: not our process, not our child"+ );++ // NEVER SEEN ALIVE: a pid in `chain` with no `chain_last_seen` row is a+ // pid we recorded and never watched, which cannot bound anything.+ let mut unseen = watched(4242, created, last_seen);+ unseen.chain_last_seen.clear();+ assert!(+ descends_from_our_chain(&ok, 9001, &unseen).is_none(),+ "a parent we never watched run cannot date its children"+ );+ }++ /// RE-ATTRIBUTION IS ONLY POSSIBLE FOR AN ATTRIBUTED ROUTE — a RULE, not an+ /// accident of how `our_identities` happens to be built.+ ///+ /// `explorer` and `cmdStart` cannot say what they started, so nothing of+ /// theirs is ever recorded as our chain and there is no identity to walk a+ /// parent back to. A session that came up after one of them may well BE ours,+ /// and this bridge still will not claim it, because it cannot prove it.+ #[test]+ fn an_unattributable_route_has_no_identities_to_walk_back_to() {+ let mut a = unfinished_placeholder("test.lnk");+ // An unattributable route: no attribution root, and nothing recorded as+ // our chain because `attributed_chain` returns None without one.+ a.attribution = None;+ a.chain = Vec::new();+ assert!(+ our_identities(&a).is_empty(),+ "with no attributed root there is no identity to prove lineage against"+ );+ let census = vec![p(9001, "SLDWORKS.exe", Some(2_000_000), Some(4242))];+ assert!(+ descends_from_our_chain(&census, 9001, &a).is_none(),+ "so no session can descend from it, however plausible the timing"+ );+ // ...and a root WITHOUT a creation time is equally unusable: a pid that+ // arrived without one cannot be matched by `same_process`.+ a.attribution = Some(Attribution { pid: 4242, created_at: None, source: "test".into() });+ assert!(+ our_identities(&a).is_empty(),+ "a root with no creation time is not an identity — same rule as 'no handle, no kill'"+ );+ }++ #[test]+ fn the_allowlist_never_reaches_a_daemon_or_anything_older_than_our_invoke() {+ let invoked = 1_000_000;+ assert!(may_terminate("CATSTART.exe", Some(invoked), invoked), "created AT our invoke");+ assert!(may_terminate("catstart.exe", Some(invoked + 1), invoked), "image match is case-insensitive");+ // NO BACKWARD SLACK. One second is exactly how long it takes to click a+ // Start Menu, and the 0.4.0 version adopted anything within it.+ assert!(!may_terminate("CATSTART.exe", Some(invoked - 1), invoked), "one second older is THEIRS");+ assert!(!may_terminate("CATSTART.exe", None, invoked), "unknown age is never ours");+ for daemon in [+ "3DEXPERIENCELauncher.exe",+ "3DEXPERIENCELauncherSysTray.exe",+ "3DEXPERIENCELauncherService.exe",+ "3DEXPERIENCELauncherAnythingElse.exe",+ ] {+ assert!(!may_terminate(daemon, Some(invoked + 1), invoked), "{daemon} is never terminable");+ }+ for never in ["SLDWORKS.exe", "sldworks.exe", "explorer.exe", "swx-bridge.exe", "notepad.exe"] {+ assert!(!may_terminate(never, Some(invoked + 1), invoked), "{never} is never terminable");+ }+ }++ #[test]+ fn nothing_is_adopted_without_a_root_the_invoke_handed_back() {+ let invoked = 1_000_000;+ // Two indistinguishable chains, both younger than our invoke, neither+ // in the baseline. Same parent — which is what route `explorer`+ // produces for OUR launch too, so parentage cannot break the tie.+ let census = vec![+ p(90010, "CATSTART.exe", Some(invoked + 1), Some(4444)),+ p(90011, "SWXDesktopLauncher.exe", Some(invoked + 2), Some(90010)),+ p(70003, "CATSTART.exe", Some(invoked + 2), Some(4444)),+ p(70004, "SWXDesktopLauncher.exe", Some(invoked + 3), Some(70003)),+ p(40, "SLDWORKS.exe", Some(invoked + 5), Some(90011)),+ ];+ // NO ROOT: nothing is ours, however obvious it looks.+ assert!(+ attributed_chain(&census, None, &[], &[], invoked).is_none(),+ "without an attribution the answer is 'I cannot tell', not a guess"+ );+ assert!(+ chain_candidates(&census, &[], &[], invoked).is_empty(),+ "the descendant walk cannot invent a root"+ );++ // WITH the root the invoke handed back, the chain is exact.+ let root = Attribution {+ pid: 90010,+ created_at: Some(invoked + 1),+ source: "shellExecuteHandle".into(),+ };+ let mine = attributed_chain(&census, Some(&root), &[], &[], invoked).unwrap();+ let pids: Vec<u32> = mine.iter().map(|x| x.pid).collect();+ assert_eq!(pids, vec![90010, 90011], "the root and its descendants only: {mine:?}");+ assert!(!pids.contains(&40), "sldworks.exe descends from our chain and is still never terminable");+ assert!(!pids.contains(&70003) && !pids.contains(&70004), "the user's chain is untouched");++ // A root whose creation time we could not read is not an identity at+ // all — the answer is "no attribution", not "an empty chain".+ let bare = Attribution { pid: 90010, created_at: None, source: "x".into() };+ assert!(+ attributed_chain(&census, Some(&bare), &[], &[], invoked).is_none(),+ "this bridge does not terminate a bare number"+ );+ // A root whose number the MACHINE says now belongs to something else:+ // the lineage is void, so its apparent children are not adopted either.+ let stale = Attribution { pid: 90010, created_at: Some(invoked - 500), source: "x".into() };+ assert!(+ attributed_chain(&census, Some(&stale), &[], &[], invoked).unwrap().is_empty(),+ "a contradicted root seeds nothing"+ );++ // ...but a root the census CANNOT SEE is not contradicted, and still+ // carries its children. `ShellExecuteExW` hands back whatever the shell+ // created, which may not be one of the two terminable launcher images.+ let unwatched = Attribution { pid: 500, created_at: Some(invoked + 1), source: "shellExecuteHandle".into() };+ let via_shim = vec![+ p(501, "CATSTART.exe", Some(invoked + 2), Some(500)),+ p(502, "SWXDesktopLauncher.exe", Some(invoked + 3), Some(501)),+ ];+ let found = attributed_chain(&via_shim, Some(&unwatched), &[], &[], invoked).unwrap();+ let pids: Vec<u32> = found.iter().map(|x| x.pid).collect();+ assert_eq!(pids, vec![501, 502], "lineage crosses a root we may not terminate: {found:?}");+ }++ #[test]+ fn a_recorded_member_is_re_confirmed_by_identity_not_by_its_number() {+ let invoked = 1_000_000;+ let recorded = vec![p(90011, "SWXDesktopLauncher.exe", Some(invoked + 2), Some(90010))];+ let root = Attribution { pid: 90010, created_at: Some(invoked + 1), source: "x".into() };+ // CATSTART has exited (it does, ~3s into a healthy launch) and its+ // number now belongs to a chain the user started two minutes later.+ let census = vec![+ p(90010, "CATSTART.exe", Some(invoked + 120), Some(4444)),+ p(90011, "SWXDesktopLauncher.exe", Some(invoked + 2), Some(90010)),+ ];+ let mine = attributed_chain(&census, Some(&root), &recorded, &[], invoked).unwrap();+ let pids: Vec<u32> = mine.iter().map(|x| x.pid).collect();+ assert_eq!(pids, vec![90011], "the recycled root is not re-adopted: {mine:?}");+ }++ #[test]+ fn a_process_is_only_the_same_process_when_its_start_time_agrees() {+ let a = p(10, "CATSTART.exe", Some(1000), None);+ assert!(a.same_process(&p(10, "CATSTART.exe", Some(1000), None)));+ assert!(!a.same_process(&p(10, "CATSTART.exe", Some(1001), None)), "a reused pid");+ assert!(!a.same_process(&p(10, "SWXDesktopLauncher.exe", Some(1000), None)));+ assert!(+ !p(10, "CATSTART.exe", None, None).same_process(&p(10, "CATSTART.exe", None, None)),+ "two unknowns are not a match"+ );+ }++ #[test]+ fn the_route_plan_starts_with_what_worked_and_still_tries_everything() {+ assert_eq!(route_plan(None), ROUTES.to_vec());+ let plan = route_plan(Some("cmdStart"));+ assert_eq!(plan[0], "cmdStart");+ assert_eq!(plan.len(), ROUTES.len(), "no route is dropped, and none is repeated");+ assert_eq!(route_plan(Some("rm -rf /")), ROUTES.to_vec(), "a bogus route is ignored, not run");+ assert!(!is_route("rm -rf /"));+ }++ #[test]+ fn a_thrashing_solidworks_is_busy_not_dead() {+ // LIVE, 2026-09-08: 17.9 GB, unsaved assembly, not-responding + COM+ // timeout for ~5 minutes — and burning ~70% of a core with memory+ // creeping and no dialog. It RECOVERED. "Only the user can end it" was+ // the wrong advice, and telling somebody to end a task that is about to+ // finish is worse than telling them to wait.+ assert_eq!(+ liveness_verdict(Some(false), Some(21.0), Some(30.0), Some(40), Some(0)),+ "busy"+ );+ // Memory moving on its own is work too.+ assert_eq!(liveness_verdict(Some(false), Some(0.0), Some(30.0), Some(60), Some(0)), "busy");+ // BUSY is a RATE, so a SHORT sample is fine — but a ZERO-length one+ // measures nothing at all and is UNKNOWN, not busy.+ assert_eq!(liveness_verdict(Some(false), Some(5.0), Some(2.0), Some(0), Some(0)), "busy");+ assert_eq!(liveness_verdict(Some(false), Some(12.0), Some(0.0), Some(0), Some(0)), "unknown");+ // A TRICKLE over a long sample is NOT work: 0.3s of CPU across ten+ // minutes is a process doing essentially nothing, and calling that busy+ // tells the user to wait for ever.+ assert_eq!(liveness_verdict(Some(false), Some(0.3), Some(600.0), Some(0), Some(0)), "dead");+ // ...and an unreadable window census cannot support a dead verdict.+ assert_eq!(liveness_verdict(Some(false), Some(0.3), Some(600.0), Some(0), None), "unknown");+ // Real work that has been going on far too long stops being a reason to+ // keep waiting.+ assert_eq!(+ liveness_verdict_for(Some(false), Some(300.0), Some(600.0), Some(0), Some(0), Some(20 * 60)),+ "unknown"+ );+ // NO work over a long enough sample, and nothing on screen: dead.+ assert_eq!(liveness_verdict(Some(false), Some(0.0), Some(30.0), Some(0), Some(0)), "dead");+ // A dialog means something is being ASKED, not that nothing is happening.+ assert_eq!(liveness_verdict(Some(false), Some(0.0), Some(30.0), Some(0), Some(1)), "unknown");+ // ONE reading, or a sample too short to mean anything, is UNKNOWN —+ // never "dead". This is the guard that would have stopped the wrong call.+ assert_eq!(liveness_verdict(Some(false), None, None, None, None), "unknown");+ assert_eq!(liveness_verdict(Some(false), Some(0.0), Some(3.0), Some(0), Some(0)), "unknown");+ assert!(DEAD_SAMPLE_SECS >= 15, "a dead verdict needs a sample worth the name");+ // A responding app is not a liveness question at all.+ assert_eq!(liveness_verdict(Some(true), None, None, None, None), "responding");+ assert_eq!(liveness_verdict(None, Some(0.0), Some(30.0), Some(0), Some(0)), "unknown");+ }++ #[test]+ fn the_verdict_advice_says_wait_for_busy_and_never_for_dead() {+ let busy = verdict_advice("busy").to_lowercase();+ assert!(busy.contains("wait"), "busy must advise waiting: {busy}");+ assert!(!busy.contains("only the user can end"), "busy must not read as a death sentence");+ assert!(busy.contains("do not advise ending"));+ let dead = verdict_advice("dead").to_lowercase();+ assert!(dead.contains("only the user can end it"));+ assert!(!dead.contains("tell the user to wait"));+ let unknown = verdict_advice("unknown").to_lowercase();+ assert!(unknown.contains("poll") && unknown.contains("again"));+ }++ #[test]+ fn hung_needs_two_independent_signals_and_answers_unknown_otherwise() {+ assert_eq!(is_hung(Some(false), Some(true)), Some(true));+ assert_eq!(is_hung(Some(false), Some(false)), Some(false), "a rebuild is not a hang");+ assert_eq!(is_hung(Some(true), Some(true)), Some(false), "a COM timeout alone is a modal");+ // UNKNOWN, not false. `None` is what a com host BUSY WITH OUR OWN LONG+ // VERB produces — the 0.4.0 code counted that as a timeout, so the+ // bridge reported a working session as hung whenever it was itself+ // doing something slow.+ assert_eq!(is_hung(Some(false), None), None);+ assert_eq!(is_hung(None, Some(true)), None);+ }++ #[test]+ fn exhaustion_is_claimed_only_from_the_one_signal_that_proves_it() {+ // THE measured healthy session on the same machine the same week.+ assert!(exhaustion(Some(13_100), Some(33), false).is_none());+ assert!(exhaustion(Some(17_465), Some(52), false).is_none(), "even the wedged session's numbers");+ assert!(exhaustion(None, None, false).is_none());+ // SOLIDWORKS accepting a NewDocument and handing back nothing is the+ // signature, and it is sufficient on its own.+ assert!(exhaustion(None, None, true).unwrap().contains("stock template"));++ // The compound verdict needs all three, and is UNKNOWN-safe.+ assert!(exhaustion_confirmed(false, Some(false), Some(true), true, Some(20_000)).is_some());+ assert!(exhaustion_confirmed(false, Some(false), Some(true), false, Some(20_000)).is_none(), "not rising");+ assert!(exhaustion_confirmed(false, Some(false), None, true, Some(20_000)).is_none(), "no COM verdict");+ assert!(exhaustion_confirmed(false, Some(true), Some(true), true, Some(20_000)).is_none(), "responding");+ }++ #[test]+ fn the_memory_figure_is_advisory_and_only_above_what_was_ever_measured() {+ assert!(memory_advisory(Some(13_100), Some(9_000), Some(33)).is_none(), "a healthy big session");+ assert!(memory_advisory(Some(17_465), Some(12_000), Some(52)).is_none(), "even AT the measurement");+ let hint = memory_advisory(Some(18_000), Some(12_000), Some(52)).unwrap();+ assert!(hint.contains("18000") && hint.contains("12000"), "both figures, side by side: {hint}");+ assert!(hint.contains("not a verdict"), "{hint}");+ assert!(memory_advisory(None, None, None).is_none(), "no reading is not an observation");+ }++ #[test]+ fn a_control_lookup_is_a_find_only_when_it_says_so_structurally() {+ // The QUERY ECHO: the bridge sent `contains: "Restart SOLIDWORKS"`, so+ // any reply quoting it back contains the button name. Zero controls.+ assert!(!mentions_button(&json!({+ "success": true,+ "query": {"hwnd": 111, "contains": "Restart SOLIDWORKS", "role": "button"},+ "controls": [], "count": 0+ })));+ assert!(!mentions_button(&json!({"success": false})));+ assert!(!mentions_button(&json!({"success": true, "found": false, "name": "Restart SOLIDWORKS Design"})));+ assert!(!mentions_button(&json!({"success": true, "controls": [{"name": "Close"}]})));+ assert!(!mentions_button(&json!({"success": true, "somethingUnrecognised": 1})), "an unknown shape is a NO");+ // ...and the shapes that ARE a find.+ assert!(mentions_button(&json!({+ "success": true, "controls": [{"name": "Restart SOLIDWORKS Design", "invokable": true}]+ })));+ assert!(mentions_button(&json!({"success": true, "found": true, "name": "Restart SOLIDWORKS Design"})));+ assert!(mentions_button(&json!({+ "success": true, "control": {"automationId": "btnRestart SOLIDWORKS", "invokable": true}+ })));+ }++ #[test]+ fn a_crash_reporter_says_a_crash_happened_it_does_not_merely_mention_solidworks() {+ for yes in [+ "SOLIDWORKS Design has stopped working",+ "SOLIDWORKS Error Report",+ "SOLIDWORKS has stopped responding",+ "SOLIDWORKS Document Recovery",+ ] {+ assert!(is_crash_reporter_candidate(yes), "{yes} should be a candidate");+ }+ for no 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",+ "SOLIDWORKS",+ "Save changes to Part1?",+ "gallia - Google Chrome",+ ] {+ assert!(!is_crash_reporter_candidate(no), "{no} must NOT be a candidate");+ }+ }++ #[test]+ fn stages_ramp_in_order_and_carry_advice() {+ let ramp = [+ Stage::Invoked,+ Stage::LauncherStarted,+ Stage::RunBegin,+ Stage::HelperSignIn,+ Stage::SldworksUp,+ Stage::Attaching,+ Stage::Attached,+ ];+ for pair in ramp.windows(2) {+ assert!(pair[0] < pair[1], "{:?} must precede {:?}", pair[0], pair[1]);+ }+ for s in ramp {+ assert!(!s.name().is_empty());+ assert!(s.advice().len() > 20, "{s:?} needs advice a caller can act on");+ }+ assert_eq!(Stage::RunBegin.name(), "runBegin");+ }++ #[test]+ fn a_launcher_log_is_ours_only_when_the_evidence_says_so() {+ use crate::launchos::{stamp_as_utc_epoch, stamp_of, LogFile};+ let log = |stamp: Option<u64>, created: Option<u64>| LogFile {+ path: "x".into(),+ created_at: created,+ stamp,+ };+ // A REALISTIC pair: the stamp is local time, the fs time is UTC, and+ // they agree to within a timezone.+ let s1 = stamp_of("20260904172307_V6R2026x.HF4.13.log").unwrap();+ let s0 = stamp_of("20260904121543_V6R2026x.HF4.13.log").unwrap();+ let utc1 = stamp_as_utc_epoch(s1).unwrap();+ assert!(log(Some(s1), Some(utc1)).is_newer_than(Some(s0), Some(utc1 - 18_000)));+ assert!(!log(Some(s0), Some(utc1 - 18_000)).is_newer_than(Some(s1), Some(utc1)));++ // NO BASELINE = NO. This is what a FAILED directory listing produced,+ // and the old rule made every old log ours — five of the nine on the+ // real machine contain `Run Begin`.+ assert!(!log(Some(s1), Some(utc1)).is_newer_than(None, None));+ assert!(!log(None, Some(100)).is_newer_than(None, None));++ // EITHER clock may answer yes. Across a daylight-saving repeat the+ // local stamp goes BACKWARDS while the filesystem does not.+ let dst_ours = stamp_of("20261101013000_V6R2026x.HF4.13.log").unwrap();+ let dst_base = stamp_of("20261101015900_V6R2026x.HF4.13.log").unwrap();+ let dst_utc = stamp_as_utc_epoch(dst_ours).unwrap();+ assert!(+ log(Some(dst_ours), Some(dst_utc + 1_800)).is_newer_than(Some(dst_base), Some(dst_utc)),+ "the filesystem is monotonic even when the wall clock is not"+ );++ // Fall back to fs time when there is no stamp; refuse when neither reads.+ assert!(log(None, Some(100)).is_newer_than(None, Some(50)));+ assert!(!log(None, Some(50)).is_newer_than(None, Some(100)));+ assert!(!log(None, None).is_newer_than(None, Some(50)));++ // A stamp the filesystem contradicts is NOT trusted — one log copied+ // from another machine would otherwise poison a directory nothing prunes.+ assert!(log(Some(s1), Some(0)).trusted_stamp().is_none(), "a stamp 56 years off its file");+ assert!(log(Some(s1), Some(utc1)).trusted_stamp().is_some());+ // ...and neither is one from the future.+ let future = 20990101000000u64;+ assert!(log(Some(future), Some(crate::launchos::epoch_secs())).trusted_stamp().is_none());++ assert_eq!(stamp_of("20260904172307_V6R2026x.HF4.13.log"), Some(20260904172307));+ assert_eq!(stamp_of("V6R2026x.HF4.13.log"), None);+ assert_eq!(stamp_of("2026090417230_short.log"), None, "13 digits is not a stamp");+ // The civil-date maths, against a known instant.+ assert_eq!(stamp_as_utc_epoch(19700101000000), Some(0));+ assert_eq!(stamp_as_utc_epoch(20260904172307), Some(1_788_542_587));+ assert_eq!(stamp_as_utc_epoch(20261399000000), None, "month 13 is not a date");+ }++ #[test]+ fn only_the_two_routes_that_can_say_what_they_started_may_ever_kill() {+ assert!(route_is_attributable("shellExecute"));+ assert!(route_is_attributable("powershell"));+ assert!(!route_is_attributable("explorer"), "the shell forwards the link and returns nothing");+ assert!(!route_is_attributable("cmdStart"), "`start` returns as soon as it has handed off");+ // ...and the attributable ones are tried FIRST.+ assert_eq!(ROUTES[0], "shellExecute");+ assert_eq!(ROUTES[1], "powershell");+ assert!(ROUTES.iter().all(|r| is_route(r)));+ }+}
src/launchos.rsadded+1616@@ -0,0 +1,1616 @@+//! THE OS FACTS THE LAUNCH STATE MACHINE RUNS ON — and the fake that makes+//! every one of its transitions testable off Windows.+//!+//! `launch.rs` decides WHAT to do about a stalled 3DEXPERIENCE launcher chain.+//! This module answers the four questions it needs to decide anything, and+//! nothing else:+//!+//! * which launcher-chain processes exist, and **when each was created**+//! (the creation time is the whole safety property — see+//! [`crate::launch::may_terminate`]);+//! * which `SWXDesktopLauncher` / `SWXDesktopHelper` log files exist, when+//! each was created, and what is in them;+//! * is SOLIDWORKS' main window answering messages, and how much memory and+//! how many handles is the process holding;+//! * terminate ONE process, BY PID.+//!+//! WHY BY PID AND NEVER BY IMAGE NAME. `taskkill /IM CATSTART.exe` would kill a+//! chain the USER started thirty seconds earlier as readily as the one this+//! bridge started, and `taskkill /F /T` would take its children with it. Every+//! terminate here opens ONE pid, checks the image name it was opened for still+//! matches, and terminates that handle. There is no image-name kill anywhere in+//! this bridge's launch path.+//!+//! Off Windows (and under `SWX_LAUNCH_FAKE_DIR`) the whole surface is served by+//! [`FakeOs`], a directory-backed simulator the test suite drives. A launch+//! state machine that can only be exercised on one particular Windows desktop+//! with one particular CAD application installed is a state machine that is+//! never exercised at all.++use serde_json::{json, Value};+use std::time::{SystemTime, UNIX_EPOCH};++/// Point the OS layer at a simulated machine. Test-only; never set in+/// production. Its presence is what selects [`FakeOs`] over the real one.+pub const ENV_FAKE_DIR: &str = "SWX_LAUNCH_FAKE_DIR";++/// The images the launch machine looks at. Deliberately short: this is a+/// process census for ONE purpose, not a task manager.+pub const WATCHED_IMAGES: &[&str] = &[+ "CATSTART.exe",+ "SWXDesktopLauncher.exe",+ "SLDWORKS.exe",+ "3DEXPERIENCELauncher.exe",+ "3DEXPERIENCELauncherSysTray.exe",+ "3DEXPERIENCELauncherService.exe",+ // THE QUIT LADDER'S CENSUS (docs/QUIT-INVESTIGATION.md §5.4): the+ // per-session children a graceful exit must REAP, and the platform daemons+ // that must SURVIVE one. Their survival is consent, not a failure — see+ // `quit::EXPECTED_SURVIVORS`.+ "sldProcMon.exe",+ "sldsurfacing.exe",+ "sldimport.exe",+ "swCefSubProc.exe",+ "ENOPLMCSAClient.exe",+ "EdmServerV6.exe",+ "SWXDesktopHelper.exe",+ "sldBgDwld.exe",+ // PARENT LINKS ONLY — never terminable (see `launch::NEVER_TERMINATE`).+ // A CATSTART's parent is the shell that ran the .lnk, and without these in+ // the census every parent link out of a chain root dangles: the walk could+ // not tell a chain descended from OUR invoke from one descended from the+ // user's double-click, because neither parent existed to compare.+ "explorer.exe",+ "cmd.exe",+ "powershell.exe",+ "pwsh.exe",+];++/// One process, with the two facts that matter: WHEN it started, and WHO+/// started it.+#[derive(Clone, Debug, PartialEq)]+pub struct ProcInfo {+ pub pid: u32,+ /// The image name as the OS reports it (`CATSTART.exe`). Compared+ /// case-insensitively everywhere — Windows paths and image names are.+ pub image: String,+ /// Unix epoch seconds. `None` means the creation time could not be read,+ /// which [`crate::launch::may_terminate`] treats as "not ours".+ pub created_at: Option<u64>,+ /// Parent pid, so a chain member that appeared after we stopped looking can+ /// still be attributed to the CATSTART we started. `None` = unknown, which+ /// attributes nothing.+ pub ppid: Option<u32>,+}++impl ProcInfo {+ pub fn to_json(&self) -> Value {+ json!({"pid": self.pid, "image": self.image, "createdAt": self.created_at, "ppid": self.ppid})+ }+ pub fn is(&self, image: &str) -> bool {+ self.image.eq_ignore_ascii_case(image)+ }+ /// Identity for the kill path: a pid ALONE is not one, because Windows+ /// reuses pids. (pid, image, creation time) is.+ pub fn same_process(&self, other: &ProcInfo) -> bool {+ self.pid == other.pid+ && self.is(&other.image)+ && self.created_at.is_some()+ && self.created_at == other.created_at+ }+}++/// A launcher/helper log file and when it appeared. The launch machine cares+/// only about logs created AFTER its own invoke — an older one describes+/// somebody else's launch.+#[derive(Clone, Debug, PartialEq)]+pub struct LogFile {+ pub path: String,+ pub created_at: Option<u64>,+ /// The `yyyyMMddHHmmss` the LAUNCHER ITSELF put in the filename, as a plain+ /// comparable integer.+ ///+ /// Preferred over the filesystem's creation time for "did this log appear+ /// after our invoke?": the launcher writes the stamp when it starts, so it+ /// is the launcher's own account of when it ran, while `created()` can be+ /// absent on some filesystems, can be the time the file was COPIED, and on+ /// Windows survives a delete-and-recreate of the same name (file-system+ /// tunnelling) — which would make a brand-new log look older than our+ /// invoke and hide the very stall we are watching for. Compared only+ /// against other stamps, never converted to epoch, so no timezone is+ /// guessed.+ pub stamp: Option<u64>,+}++/// The widest real UTC offset is +14:00 and the widest negative -12:00; add an+/// hour of DST and some slack. A filename stamp is written in LOCAL time and+/// the filesystem records UTC, so the two may legitimately differ by up to+/// this much — and by no more.+const MAX_TZ_SKEW_SECS: u64 = 26 * 3600;+/// A stamp this far ahead of now (allowing for any timezone) did not come from+/// a clock this machine is keeping.+const FUTURE_STAMP_SLACK_SECS: u64 = 300;++/// `yyyyMMddHHmmss` read as if it were UTC. Used only to CORROBORATE a stamp+/// against the filesystem's own time — never as a timestamp in its own right,+/// because the launcher writes local time and this function does not know the+/// offset.+pub fn stamp_as_utc_epoch(stamp: u64) -> Option<u64> {+ let (y, mo, d) = ((stamp / 10_000_000_000) as i64, ((stamp / 100_000_000) % 100) as i64, ((stamp / 1_000_000) % 100) as i64);+ let (h, mi, sec) = ((stamp / 10_000) % 100, (stamp / 100) % 100, stamp % 100);+ if !(1970..=9999).contains(&y) || !(1..=12).contains(&mo) || !(1..=31).contains(&d) {+ return None;+ }+ if h > 23 || mi > 59 || sec > 60 {+ return None;+ }+ // days-from-civil (Howard Hinnant): exact, no table, no leap-year special case.+ let y2 = y - i64::from(mo <= 2);+ let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;+ let yoe = y2 - era * 400;+ let mp = (mo + 9) % 12;+ let doy = (153 * mp + 2) / 5 + d - 1;+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;+ let days = era * 146_097 + doe - 719_468;+ u64::try_from(days * 86_400 + (h * 3600 + mi * 60 + sec) as i64).ok()+}++impl LogFile {+ /// The filename stamp, but ONLY when the filesystem corroborates it.+ ///+ /// The launcher stamps LOCAL time. A stamp that cannot be reconciled with+ /// the file's own UTC creation time — a log copied from another machine, a+ /// clock correction, a file written by a different timezone's session —+ /// is not evidence of anything, and one such file in that directory (which+ /// nothing ever prunes) would poison every comparison after it.+ pub fn trusted_stamp(&self) -> Option<u64> {+ let stamp = self.stamp?;+ let as_utc = stamp_as_utc_epoch(stamp)?;+ if as_utc > epoch_secs() + MAX_TZ_SKEW_SECS + FUTURE_STAMP_SLACK_SECS {+ return None; // from the future, whatever timezone this is+ }+ let created = self.created_at?;+ (as_utc.abs_diff(created) <= MAX_TZ_SKEW_SECS).then_some(stamp)+ }++ /// Is this log NEWER than the newest one that existed before our invoke?+ ///+ /// TWO INDEPENDENT CLOCKS, and EITHER may answer yes — the launcher's own+ /// local-time stamp, or the filesystem's UTC creation time. Three defects+ /// in the first version, all of them making the gate's central measurement+ /// a fabrication:+ ///+ /// * it SHORT-CIRCUITED on the stamps and never consulted the filesystem.+ /// For the one hour a year a local clock repeats, our own log carries a+ /// LOWER stamp than one written before it, so the bridge concluded its+ /// own log did not exist and reported that nothing started.+ /// * `(Some(_), None) => true` accepted ANY log when there was no+ /// baseline — and no baseline is exactly what a FAILED directory+ /// listing produced. Five of the nine logs on the real machine contain+ /// `Run Begin`, so the bridge would read somebody else's launch from+ /// three days ago and jump straight past stall detection.+ /// * a future-dated stamp poisoned the directory permanently.+ ///+ /// With NO baseline the answer is NO. An unreadable listing is UNKNOWN, and+ /// unknown is never "the directory was empty".+ pub fn is_newer_than(&self, baseline_stamp: Option<u64>, baseline_created: Option<u64>) -> bool {+ if baseline_stamp.is_none() && baseline_created.is_none() {+ return false;+ }+ let stamp_newer = matches!(+ (self.trusted_stamp(), baseline_stamp),+ (Some(mine), Some(base)) if mine > base+ );+ let created_newer = matches!(+ (self.created_at, baseline_created),+ (Some(mine), Some(base)) if mine > base+ );+ stamp_newer || created_newer+ }+}++/// The thread id the launcher stamps on its own lines: `17:23:09 [116244] info`.+///+/// Read from the FIRST line that has one, because that line is written by the+/// launcher's main thread at startup — the thread most likely still alive while+/// we are watching. (`Run Begin` comes from a second thread ~2s later, which is+/// exactly the pair the ground-truth log shows: [107936] then [116244].)+pub fn writer_thread_of(log_text: &str) -> Option<u32> {+ for line in log_text.lines().take(40) {+ let open = line.find('[')?;+ let close = line[open..].find(']')? + open;+ if let Ok(tid) = line[open + 1..close].trim().parse::<u32>() {+ return Some(tid);+ }+ }+ None+}++/// `20260904172307_V6R2026x.HF4.13.log` -> `20260904172307`.+pub fn stamp_of(file_name: &str) -> Option<u64> {+ let digits: String = file_name.chars().take_while(|c| c.is_ascii_digit()).collect();+ (digits.len() == 14).then(|| digits.parse().ok())?+}++/// One top-level window, as the OS reports it — read WITHOUT ab.+///+/// Two callers need this and neither can depend on ab: the busy-vs-dead verdict+/// ("did a dialog appear, or is it silently doing nothing?") and, in 0.5.1, the+/// quit ladder's "no blocking dialog" precondition. `swx_status` on the dev+/// bridge reports `abAvailable:false`, and an unanswerable precondition refuses,+/// so depending on ab would make those permanently unavailable during an outage.+#[derive(Clone, Debug, PartialEq)]+pub struct WindowInfo {+ pub hwnd: i64,+ pub class_name: String,+ pub title: String,+ pub visible: bool,+}++impl WindowInfo {+ pub fn to_json(&self) -> Value {+ json!({+ "hwnd": self.hwnd,+ "className": self.class_name,+ "title": self.title,+ "visible": self.visible,+ })+ }+ /// The Win32 dialog class every SOLIDWORKS modal is.+ pub fn is_dialog(&self) -> bool {+ self.class_name == "#32770"+ }+}++/// What SOLIDWORKS is costing the machine right now.+#[derive(Clone, Debug, Default, PartialEq)]+pub struct ProcMetrics {+ /// Total CPU time (kernel + user) the process has consumed, in seconds. The+ /// DELTA over a short sample is what separates a process that is thrashing+ /// from one that is dead: on 2026-09-08 SOLIDWORKS at 17.9 GB went+ /// not-responding with a COM timeout for ~5 minutes and was reported as+ /// hung — while it was burning ~70% of a core and RECOVERED once the user+ /// saved. "Not responding" is a statement about a message loop, not about+ /// whether work is happening.+ pub cpu_seconds: Option<f64>,+ /// `PrivateUsage` — the commit charge, which is what runs out.+ pub private_mb: Option<u64>,+ /// `WorkingSetSize` — RESIDENT memory. Reported BESIDE private rather than+ /// instead of it: the two diverge by many gigabytes on a paged-out CAD+ /// session, and quoting one as "memory" invites a wrong conclusion about+ /// the other.+ pub working_set_mb: Option<u64>,+ pub handles: Option<u32>,+}++/// The OS facts, behind one trait so the state machine has exactly one seam and+/// the suite can drive every branch of it.+pub trait LaunchOs: Send + Sync {+ /// Every process whose image is in [`WATCHED_IMAGES`].+ ///+ /// `Err` means THE CENSUS FAILED — never an empty list. "I could not look"+ /// and "nothing is running" lead to opposite decisions, and collapsing the+ /// first into the second is the defect this whole file was written after.+ fn processes(&self) -> Result<Vec<ProcInfo>, String>;+ /// `SWXDesktopLauncher\*.log`, newest first.+ fn launcher_logs(&self) -> Result<Vec<LogFile>, String>;+ /// `SWXDesktopHelper\*.log`, newest first — the sign-in/licence stage.+ fn helper_logs(&self) -> Result<Vec<LogFile>, String>;+ fn read_log(&self, path: &str) -> Result<String, String>;+ /// Terminate ONE pid, having re-checked that it still is `image` AND was+ /// created at `created_at`.+ ///+ /// The creation time closes the TOCTOU: between the census that chose this+ /// pid and the kill, the process can exit and Windows can hand its number+ /// to something else. The check is re-done on the OPENED HANDLE, which is+ /// the only ordering that cannot be raced — a check against a fresh census+ /// is still a check against a different object from the one the handle+ /// refers to.+ fn terminate(&self, pid: u32, image: &str, created_at: Option<u64>) -> Result<(), String>;++ /// Is this pid running? `None` = cannot tell, which is never read as "yes".+ fn pid_alive(&self, pid: u32) -> Option<bool> {+ self.processes().ok().map(|c| c.iter().any(|p| p.pid == pid))+ }+ /// `IsHungAppWindow` on a top-level window. `None` = unknown, which is+ /// never reported as "responding".+ fn window_responding(&self, hwnd: i64) -> Option<bool>;+ /// The main top-level window owned by `pid`, for when COM cannot tell us.+ fn main_window_of_pid(&self, pid: u32) -> Option<i64>;++ /// WHICH PROCESS OWNS THIS THREAD? The launcher stamps a thread id in every+ /// line it writes (`17:23:09 [116244] info Run Begin`), so a log can be+ /// bound to the process that wrote it — which is the only positive proof+ /// that a log is OURS rather than merely newer than the ones we saw before.+ ///+ /// Best effort in one direction only: a match proves authorship, a miss+ /// proves nothing (threads exit, and the writing thread usually has by the+ /// time we read the file).+ fn thread_owner(&self, _tid: u32) -> Option<u32> {+ None+ }+ fn process_metrics(&self, pid: u32) -> ProcMetrics;+ /// Every top-level window owned by `pid`. `Err` = could not look, which is+ /// UNKNOWN — never "no dialogs".+ fn windows_of_pid(&self, _pid: u32) -> Result<Vec<WindowInfo>, String> {+ Err("this host cannot enumerate windows".into())+ }++ /// This process's own pid — recorded on a launch attempt so a LATER bridge+ /// can tell "somebody is supervising this" from "this was left behind".+ fn self_pid(&self) -> u32 {+ std::process::id()+ }+ fn now(&self) -> u64 {+ epoch_secs()+ }+ /// Name for the ledger, so a reader can tell a simulated run from a real one.+ fn kind(&self) -> &'static str;+}++pub fn epoch_secs() -> u64 {+ SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)+}++pub fn epoch_ms() -> u128 {+ SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0)+}++/// The OS layer for this process: the real Windows one, or the simulator when+/// `SWX_LAUNCH_FAKE_DIR` is set (or we are not on Windows at all, where the+/// real one could not answer anything).+pub fn os() -> Box<dyn LaunchOs> {+ if let Ok(dir) = std::env::var(ENV_FAKE_DIR) {+ if !dir.is_empty() {+ return Box::new(FakeOs { dir: std::path::PathBuf::from(dir) });+ }+ }+ #[cfg(windows)]+ {+ Box::new(win::WindowsOs)+ }+ #[cfg(not(windows))]+ {+ Box::new(UnknownOs)+ }+}++/// Off Windows with no simulator configured: every question answers UNKNOWN.+/// Not "nothing is running" — the honest answer to a question this host cannot+/// be asked.+pub struct UnknownOs;++impl LaunchOs for UnknownOs {+ fn processes(&self) -> Result<Vec<ProcInfo>, String> {+ Err("no process census on this host (not Windows, and no launch simulator configured)".into())+ }+ fn launcher_logs(&self) -> Result<Vec<LogFile>, String> {+ Err("no 3DEXPERIENCE launcher logs on this host".into())+ }+ fn helper_logs(&self) -> Result<Vec<LogFile>, String> {+ Err("no 3DEXPERIENCE helper logs on this host".into())+ }+ fn read_log(&self, _path: &str) -> Result<String, String> {+ Err("no launcher logs on this host".into())+ }+ fn terminate(&self, _pid: u32, _image: &str, _created_at: Option<u64>) -> Result<(), String> {+ Err("this host cannot terminate processes for the launch machine".into())+ }+ fn pid_alive(&self, _pid: u32) -> Option<bool> {+ None+ }+ fn window_responding(&self, _hwnd: i64) -> Option<bool> {+ None+ }+ fn main_window_of_pid(&self, _pid: u32) -> Option<i64> {+ None+ }+ fn process_metrics(&self, _pid: u32) -> ProcMetrics {+ ProcMetrics::default()+ }+ fn kind(&self) -> &'static str {+ "unavailable"+ }+}++// ── the simulator ───────────────────────────────────────────────────────────++/// A machine on disk. Everything the state machine can observe is derived from+/// two test-authored files plus the invocation log the mock com host appends+/// to, so a test writes a SCENARIO ("route A stalls, route B works") and then+/// measures what the bridge DID about it.+///+/// Layout under `SWX_LAUNCH_FAKE_DIR`:+///+/// ```text+/// routes.json {"<route>": {"chain":bool,"runBeginAfterMs":n|null,+/// "helperAfterMs":n|null,"sldworksAfterMs":n|null,+/// "attachAfterMs":n|null}}+/// preexisting.json [{"pid":6076,"image":"3DEXPERIENCELauncher.exe","ageSeconds":3600}]+/// sldworks.json {"responding":true,"privateMB":800,"handles":3000,"hwnd":4242}+/// invocations.jsonl appended by the mock com host: {"seq":1,"route":"explorer","atMs":...}+/// terminated.jsonl appended by terminate(): {"pid":...,"image":"...","atMs":...}+/// ```+pub struct FakeOs {+ pub dir: std::path::PathBuf,+}++/// What one route does on the simulated machine.+#[derive(Clone, Debug)]+pub struct RouteSpec {+ pub chain: bool,+ /// Can THIS invocation say what it started? Defaults to whatever the route+ /// can do in principle. Set `false` to model the shape `ShellExecuteExW` on+ /// a `.lnk` is most likely to produce: the shell hands the link to an+ /// already-running process (DDE) and returns NO HANDLE, so a route that can+ /// normally attribute does not, on this launch.+ pub attributable: Option<bool>,+ /// The chain runs, but its ROOT never appears in the census — modelling a+ /// handle-proven root whose image this bridge does not watch. Its children+ /// still appear, so lineage can (and must) carry across it.+ pub root_visible: bool,+ /// The chain runs but never appears in the process census — a launcher+ /// whose images this bridge does not watch, or one the census cannot see.+ /// Models "the invoke told us a root and we never found it".+ pub invisible_chain: bool,+ pub run_begin_after_ms: Option<u64>,+ pub helper_after_ms: Option<u64>,+ pub sldworks_after_ms: Option<u64>,+ pub attach_after_ms: Option<u64>,+}++impl Default for RouteSpec {+ /// THE OBSERVED FAILURE, as the default: the chain starts, the log stops at+ /// `SecurityContext:`, and nothing else ever happens. A scenario that does+ /// not mention a route gets the behaviour five of five real bridge launches+ /// produced on 2026-09-04.+ fn default() -> Self {+ RouteSpec {+ chain: true,+ attributable: None,+ root_visible: true,+ invisible_chain: false,+ run_begin_after_ms: None,+ helper_after_ms: None,+ sldworks_after_ms: None,+ attach_after_ms: None,+ }+ }+}++/// One simulated launcher invocation.+#[derive(Clone, Debug)]+pub struct Invocation {+ pub seq: u64,+ pub route: String,+ pub at_ms: u128,+}++impl Invocation {+ /// Deterministic pids, so a test can name the process it expects to die.+ pub fn catstart_pid(&self) -> u32 {+ 900_000 + (self.seq as u32) * 10+ }+ pub fn launcher_pid(&self) -> u32 {+ self.catstart_pid() + 1+ }+ pub fn sldworks_pid(&self) -> u32 {+ self.catstart_pid() + 2+ }+ pub fn log_path(&self) -> String {+ format!("fake://launcher/{}", self.seq)+ }+ pub fn helper_log_path(&self) -> String {+ format!("fake://helper/{}", self.seq)+ }+}++impl FakeOs {+ fn read_json(&self, name: &str) -> Value {+ std::fs::read_to_string(self.dir.join(name))+ .ok()+ .and_then(|t| serde_json::from_str(&t).ok())+ .unwrap_or(Value::Null)+ }++ /// Does this route create a launcher chain on the simulated machine?+ pub fn route_starts_chain(&self, route: &str) -> bool {+ self.spec(route).chain+ }++ /// The simulated thread ids a launcher process writes with: two per+ /// process, mirroring the real log's [main] then [run] pair.+ fn thread_of(launcher_pid: u32) -> u32 {+ launcher_pid + 500_000+ }++ /// Does THIS invocation hand back a root? A scenario may say `false` for a+ /// route that normally can, which is how the "no handle" branch is tested.+ pub fn route_reports_root(&self, route: &str) -> bool {+ let spec = self.spec(route);+ spec.chain && spec.attributable.unwrap_or(true)+ }++ fn spec(&self, route: &str) -> RouteSpec {+ let table = self.read_json("routes.json");+ let entry = table.get(route);+ let ms = |k: &str| entry.and_then(|e| e.get(k)).and_then(|v| v.as_u64());+ RouteSpec {+ chain: entry+ .and_then(|e| e.get("chain"))+ .and_then(|v| v.as_bool())+ .unwrap_or(true),+ attributable: entry.and_then(|e| e.get("attributable")).and_then(|v| v.as_bool()),+ root_visible: entry+ .and_then(|e| e.get("rootVisible"))+ .and_then(|v| v.as_bool())+ .unwrap_or(true),+ invisible_chain: entry+ .and_then(|e| e.get("invisibleChain"))+ .and_then(|v| v.as_bool())+ .unwrap_or(false),+ run_begin_after_ms: ms("runBeginAfterMs"),+ helper_after_ms: ms("helperAfterMs"),+ sldworks_after_ms: ms("sldworksAfterMs"),+ attach_after_ms: ms("attachAfterMs"),+ }+ }++ /// Every invocation the mock com host has made, oldest first.+ pub fn invocations(&self) -> Vec<Invocation> {+ std::fs::read_to_string(self.dir.join("invocations.jsonl"))+ .unwrap_or_default()+ .lines()+ .filter_map(|l| serde_json::from_str::<Value>(l).ok())+ .filter_map(|v| {+ Some(Invocation {+ seq: v.get("seq")?.as_u64()?,+ route: v.get("route")?.as_str()?.to_string(),+ at_ms: v.get("atMs")?.as_u64()? as u128,+ })+ })+ .collect()+ }++ fn terminated_pids(&self) -> Vec<u32> {+ std::fs::read_to_string(self.dir.join("terminated.jsonl"))+ .unwrap_or_default()+ .lines()+ .filter_map(|l| serde_json::from_str::<Value>(l).ok())+ .filter_map(|v| v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32))+ .collect()+ }++ /// An invocation whose chain we killed makes no further progress — exactly+ /// like the real thing, where killing CATSTART ends that attempt.+ fn alive(&self, inv: &Invocation, dead: &[u32]) -> bool {+ !dead.contains(&inv.catstart_pid()) && !dead.contains(&inv.launcher_pid())+ }++ fn elapsed_ms(&self, inv: &Invocation) -> u128 {+ epoch_ms().saturating_sub(inv.at_ms)+ }++ fn reached(&self, inv: &Invocation, after: Option<u64>) -> bool {+ match after {+ None => false,+ Some(ms) => self.elapsed_ms(inv) >= ms as u128,+ }+ }++ /// Record an invocation. Called by the MOCK com backend, which is the+ /// simulated equivalent of "the shortcut was run".+ pub fn record_invoke(dir: &std::path::Path, route: &str, shortcut: &str) -> u64 {+ let _ = std::fs::create_dir_all(dir);+ let path = dir.join("invocations.jsonl");+ let seq = std::fs::read_to_string(&path).unwrap_or_default().lines().count() as u64 + 1;+ let line = json!({"seq": seq, "route": route, "atMs": epoch_ms() as u64, "shortcut": shortcut});+ let mut text = std::fs::read_to_string(&path).unwrap_or_default();+ text.push_str(&line.to_string());+ text.push('\n');+ let _ = std::fs::write(&path, text);+ seq+ }++ /// Is a simulated SOLIDWORKS COM-attachable right now? Read by the MOCK com+ /// backend so the whole simulation has ONE source of truth: a route that+ /// stalls never becomes attachable, however long anybody polls.+ /// The pid of the SOLIDWORKS an ATTACHABLE invocation produced — the process+ /// COM would be talking to.+ ///+ /// The simulator used to publish a second SOLIDWORKS row for the mock's own+ /// "session", with a different pid and no parent, so one simulated session+ /// appeared in the census as two processes: one attached and parentless, one+ /// parented and unattached. That is not a machine state that exists, and it+ /// made the attached process unprovable by construction.+ pub fn attached_sldworks_pid(dir: &std::path::Path) -> Option<u32> {+ let me = FakeOs { dir: dir.to_path_buf() };+ let dead = me.terminated_pids();+ me.invocations()+ .into_iter()+ .filter(|i| me.alive(i, &dead))+ .find(|i| me.reached(i, me.spec(&i.route).attach_after_ms))+ .map(|i| i.sldworks_pid())+ }++ pub fn attachable(dir: &std::path::Path) -> bool {+ let me = FakeOs { dir: dir.to_path_buf() };+ let dead = me.terminated_pids();+ me.invocations()+ .iter()+ .filter(|i| me.alive(i, &dead))+ .any(|i| me.reached(i, me.spec(&i.route).attach_after_ms))+ }+}++impl LaunchOs for FakeOs {+ fn processes(&self) -> Result<Vec<ProcInfo>, String> {+ if self.dir.join("census-fails").exists() {+ return Err("simulated: the process census could not be taken".into());+ }+ let now = epoch_secs();+ let dead = self.terminated_pids();+ // A QUIT THAT HAPPENED reaps SOLIDWORKS and its per-session children from+ // the census. The mock backend (which performs the rung) and this census+ // (which measures it) read the SAME file, so the measurement can never+ // disagree with the act — the whole point of measuring by census.+ // A rung may report success while the process takes SECONDS to actually+ // go (a real `ExitApp` returns first), so each line carries the instant+ // its exit becomes EFFECTIVE. Until then SOLIDWORKS is still in the+ // census — which is exactly the divergence a single post-rung sample used+ // to hide.+ let quit = std::fs::read_to_string(self.dir.join("quit.jsonl"))+ .map(|t| {+ t.lines().filter(|l| !l.trim().is_empty()).any(|l| {+ let v: Value = serde_json::from_str(l).unwrap_or(Value::Null);+ let at = v+ .get("effectiveAtMs")+ .or_else(|| v.get("atMs"))+ .and_then(|x| x.as_u64())+ .unwrap_or(0);+ at as u128 <= epoch_ms()+ })+ })+ .unwrap_or(false);+ let reaped = |image: &str| -> bool {+ quit && (image.eq_ignore_ascii_case("SLDWORKS.exe")+ || crate::quit::SESSION_CHILDREN.iter().any(|c| c.eq_ignore_ascii_case(image)))+ };+ let mut out: Vec<ProcInfo> = Vec::new();+ // Processes that were ALREADY THERE. The whole point of the creation-time+ // rule is that these survive every stall recovery.+ if let Some(rows) = self.read_json("preexisting.json").as_array() {+ for r in rows {+ let Some(pid) = r.get("pid").and_then(|p| p.as_u64()) else { continue };+ let pid = pid as u32;+ if dead.contains(&pid) {+ continue;+ }+ let image = r.get("image").and_then(|i| i.as_str()).unwrap_or("").to_string();+ if reaped(&image) {+ continue;+ }+ out.push(ProcInfo {+ pid,+ image,+ created_at: Some(+ now.saturating_sub(r.get("ageSeconds").and_then(|a| a.as_u64()).unwrap_or(3600)),+ ),+ ppid: r.get("ppid").and_then(|x| x.as_u64()).map(|v| v as u32),+ });+ }+ }+ // The SIMULATED SOLIDWORKS, as the mock backend published it. Present+ // until a quit rung reaps it — so the ladder's success measurement reads+ // the same truth the rung acted on.+ if !quit {+ if let Ok(t) = std::fs::read_to_string(self.dir.join("mock-session")) {+ if let Ok(pid) = t.trim().parse::<i64>() {+ let pid = (pid.unsigned_abs() % 4_000_000_000) as u32;+ // ONE SESSION IS ONE PROCESS. If a route of this scenario+ // already produced a SOLIDWORKS, the mock's session IS that+ // process — publishing a second row with another pid and no+ // parent invents a machine state that cannot occur.+ let route_spawned = Self::attached_sldworks_pid(&self.dir);+ if route_spawned.is_some() {+ // fall through: the invocation-derived row below is the one+ } else if !dead.contains(&pid) && !out.iter().any(|p| p.pid == pid) {+ // A STABLE creation time, written once and reused. Deriving+ // it from `now` on every read made it DRIFT — and the quit+ // ladder's success test is "this pid AND this creation time+ // are gone", so a drifting one reported the process as gone+ // on the very next census. A real creation time never moves.+ let f = self.dir.join("sldworks-created");+ let created = match std::fs::read_to_string(&f)+ .ok()+ .and_then(|t| t.trim().parse::<u64>().ok())+ {+ Some(v) => v,+ None => {+ let v = now.saturating_sub(3600);+ let _ = std::fs::write(&f, v.to_string());+ v+ }+ };+ out.push(ProcInfo {+ pid,+ image: "SLDWORKS.exe".into(),+ created_at: Some(created),+ // LINEAGE, when a scenario wants to model it: the pid+ // that started SOLIDWORKS. Re-attribution after a+ // sign-in requires a POSITIVE link back to our own+ // chain, and this is how a test supplies (or withholds)+ // one. `sldworks-parent` holds a pid.+ ppid: std::fs::read_to_string(self.dir.join("sldworks-parent"))+ .ok()+ .and_then(|t| t.trim().parse::<u32>().ok()),+ });+ // ITS OWN CHILDREN, parented to it. A 3DEXPERIENCE+ // sign-in / licence / cloud-save window is a WEB VIEW,+ // and its hwnd belongs to a `swCefSubProc.exe` child —+ // so a dialog census that only knows the SOLIDWORKS pid+ // cannot see the one modal most likely to be up. The+ // fixture names the images; their pids are derived from+ // the parent's so a test can predict nothing about them+ // except the parentage, which is the whole point.+ for (i, img) in self+ .read_json("sldworks-children.json")+ .as_array()+ .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>())+ .unwrap_or_default()+ .iter()+ .enumerate()+ {+ let child = pid.wrapping_add(1 + i as u32);+ if dead.contains(&child) || out.iter().any(|p| p.pid == child) {+ continue;+ }+ out.push(ProcInfo {+ pid: child,+ image: img.clone(),+ created_at: Some(created + 1),+ ppid: Some(pid),+ });+ }+ }+ }+ }+ }+ for inv in self.invocations() {+ let spec = self.spec(&inv.route);+ let started = (inv.at_ms / 1000) as u64;+ if spec.chain && !spec.invisible_chain {+ for (pid, image, ppid) in [+ (inv.catstart_pid(), "CATSTART.exe", None),+ (inv.launcher_pid(), "SWXDesktopLauncher.exe", Some(inv.catstart_pid())),+ ] {+ // `rootVisible:false` hides the CATSTART the invoke reported+ // as its root while leaving its child in the census, which+ // is what an unwatched shim looks like from here.+ if pid == inv.catstart_pid() && !spec.root_visible {+ continue;+ }+ if !dead.contains(&pid) {+ out.push(ProcInfo { pid, image: image.into(), created_at: Some(started), ppid });+ }+ }+ }+ if self.alive(&inv, &dead) && self.reached(&inv, spec.sldworks_after_ms) {+ out.push(ProcInfo {+ pid: inv.sldworks_pid(),+ image: "SLDWORKS.exe".into(),+ created_at: Some(started),+ // A real sldworks.exe is spawned BY the launcher, so the+ // simulator models it as a child: the descendant walk must+ // meet it and refuse it, not never meet it.+ //+ // `sldworks-parent` OVERRIDES that, which is how a scenario+ // says "this SOLIDWORKS is the USER'S — it descends from a+ // launcher that was never ours". Without the override here a+ // fixture could only express the friendly case, and a+ // lineage proof that is never asked a hostile question is not+ // a proof.+ ppid: std::fs::read_to_string(self.dir.join("sldworks-parent"))+ .ok()+ .and_then(|t| t.trim().parse::<u32>().ok())+ .or(Some(inv.launcher_pid())),+ });+ }+ }+ // ── PID REUSE, which nothing else here could express.+ //+ // A pid is one number held by DIFFERENT PROCESSES AT DIFFERENT TIMES,+ // and that is the whole hazard the lineage rule exists for: our+ // launcher dies, the user's launcher is issued the same number, and+ // their SOLIDWORKS records it as its parent. Until now a fixture could+ // only say "this pid exists"; it could not RETIRE one and reissue it,+ // so the hostile case could only be posed in a unit test and never+ // end-to-end.+ //+ // `census-overrides.json` is a list of edits applied last, in order:+ // {"retire": 900011} -> remove that pid+ // {"pid": 900011, "image": "CATSTART.exe",+ // "createdAt": 1789…, "ppid": null} -> (re)issue it+ // Reissuing a retired pid with a NEW creation time is exactly the+ // situation `same_process` and the lineage window are written against.+ if let Some(rows) = self.read_json("census-overrides.json").as_array() {+ for r in rows {+ if let Some(pid) = r.get("retire").and_then(|x| x.as_u64()) {+ out.retain(|p| p.pid != pid as u32);+ continue;+ }+ let Some(pid) = r.get("pid").and_then(|x| x.as_u64()) else { continue };+ let pid = pid as u32;+ out.retain(|p| p.pid != pid);+ out.push(ProcInfo {+ pid,+ image: r+ .get("image")+ .and_then(|x| x.as_str())+ .unwrap_or("CATSTART.exe")+ .to_string(),+ // `ageSeconds` (now minus that many, as everywhere else in+ // this simulator) or an explicit `createdAt` epoch.+ created_at: r+ .get("ageSeconds")+ .and_then(|x| x.as_i64())+ .map(|age| if age >= 0 {+ now.saturating_sub(age as u64)+ } else {+ now + age.unsigned_abs()+ })+ .or_else(|| r.get("createdAt").and_then(|x| x.as_u64())),+ ppid: r.get("ppid").and_then(|x| x.as_u64()).map(|p| p as u32),+ });+ }+ }+ Ok(out)+ }++ fn launcher_logs(&self) -> Result<Vec<LogFile>, String> {+ let mut out: Vec<LogFile> = self+ .invocations()+ .iter()+ .filter(|i| self.spec(&i.route).chain)+ .map(|i| LogFile {+ path: i.log_path(),+ created_at: Some((i.at_ms / 1000) as u64),+ // Monotone in invocation time, exactly like the launcher's own+ // yyyyMMddHHmmss. Only ever compared with other stamps.+ stamp: Some(i.at_ms as u64),+ })+ .collect();+ out.reverse();+ Ok(out)+ }++ fn helper_logs(&self) -> Result<Vec<LogFile>, String> {+ let dead = self.terminated_pids();+ let mut out: Vec<LogFile> = self+ .invocations()+ .iter()+ .filter(|i| self.alive(i, &dead) && self.reached(i, self.spec(&i.route).helper_after_ms))+ .map(|i| LogFile {+ path: i.helper_log_path(),+ created_at: Some((i.at_ms / 1000) as u64),+ stamp: Some(i.at_ms as u64),+ })+ .collect();+ out.reverse();+ Ok(out)+ }++ fn read_log(&self, path: &str) -> Result<String, String> {+ let seq: u64 = path+ .rsplit('/')+ .next()+ .and_then(|s| s.parse().ok())+ .ok_or_else(|| format!("not a simulated log path: {path}"))?;+ let inv = self+ .invocations()+ .into_iter()+ .find(|i| i.seq == seq)+ .ok_or_else(|| format!("no simulated launch {seq}"))?;+ let dead = self.terminated_pids();+ // The REAL shape: the header and `SecurityContext:` are written by the+ // first thread and always appear; `Run Begin` comes from a SECOND thread+ // about two seconds later, and on the failing route it never comes.+ // The launcher stamps the writing THREAD's id on every line, and the+ // simulator models a distinct thread per launcher process so a log can+ // be bound to the process that wrote it — see `writer_thread_of`.+ let tid = Self::thread_of(inv.launcher_pid());+ let mut text = format!(+ "00:00:00 [{tid}] info PackagingVersionShortName: V6R2026x.HF4.13\n\+ 00:00:00 [{tid}] info AppName=SWXCSWK_AP\n\+ 00:00:00 [{tid}] info SecurityContext: \n"+ );+ if self.alive(&inv, &dead) && self.reached(&inv, self.spec(&inv.route).run_begin_after_ms) {+ text.push_str(&format!(+ "00:00:02 [{}] info Run Begin\n\+ 00:00:02 [{}] info Located C:\\Program Files\\...\\sldworks.exe majorversion: 34\n",+ tid + 1,+ tid + 1+ ));+ }+ Ok(text)+ }++ fn terminate(&self, pid: u32, image: &str, created_at: Option<u64>) -> Result<(), String> {+ let census = self.processes()?;+ let Some(p) = census.iter().find(|p| p.pid == pid) else {+ return Err(format!("pid {pid} is not running"));+ };+ if !p.is(image) {+ return Err(format!("pid {pid} is {} now, not {image} — refusing", p.image));+ }+ if let Some(want) = created_at {+ if p.created_at != Some(want) {+ return Err(format!(+ "pid {pid} started at {:?}, not {want} — a different process has this number now",+ p.created_at+ ));+ }+ }+ let path = self.dir.join("terminated.jsonl");+ let mut text = std::fs::read_to_string(&path).unwrap_or_default();+ text.push_str(&json!({"pid": pid, "image": image, "atMs": epoch_ms() as u64}).to_string());+ text.push('\n');+ std::fs::write(&path, text).map_err(|e| e.to_string())+ }++ fn window_responding(&self, _hwnd: i64) -> Option<bool> {+ self.read_json("sldworks.json").get("responding").and_then(|v| v.as_bool()).or(Some(true))+ }++ fn main_window_of_pid(&self, _pid: u32) -> Option<i64> {+ self.read_json("sldworks.json").get("hwnd").and_then(|v| v.as_i64()).or(Some(4242))+ }++ /// The simulated desktop: `windows.json`, scoped to the pid when a row names+ /// one. `window-census-fails` makes the census UNREADABLE, which callers must+ /// treat as unknown rather than as "nothing on screen".+ fn windows_of_pid(&self, pid: u32) -> Result<Vec<WindowInfo>, String> {+ if self.dir.join("window-census-fails").exists() {+ return Err("simulated: the window census could not be taken".into());+ }+ let mut out: Vec<WindowInfo> = Vec::new();+ let read = |r: &Value| -> Option<WindowInfo> {+ Some(WindowInfo {+ hwnd: r.get("hwnd")?.as_i64()?,+ class_name: r.get("className").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ title: r.get("title").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ visible: r.get("visible").and_then(|x| x.as_bool()).unwrap_or(true),+ })+ };+ if let Some(rows) = self.read_json("windows.json").as_array() {+ for r in rows {+ let owner = r.get("pid").and_then(|x| x.as_u64()).map(|p| p as u32);+ if owner.is_some() && owner != Some(pid) {+ continue;+ }+ if let Some(w) = read(r) {+ out.push(w);+ }+ }+ }+ // THE 3DEXPERIENCE ID SIGN-IN, on whichever process the scenario says+ // owns it. `sign-in.json` is {"ownerPid":N,...} and the file is DELETED+ // to model the user answering it — which is what lets a test prove that+ // the paused deadlines resume.+ let si = self.read_json("sign-in.json");+ if si.is_object() {+ let owner = si.get("ownerPid").and_then(|x| x.as_u64()).map(|p| p as u32);+ // No owner named: it belongs to whichever launcher pid asks, which is+ // how a scenario says "our chain is showing it".+ if owner.is_none() || owner == Some(pid) {+ out.push(WindowInfo {+ hwnd: si.get("hwnd").and_then(|x| x.as_i64()).unwrap_or(777001),+ class_name: si+ .get("className")+ .and_then(|x| x.as_str())+ .unwrap_or("DSYSysLoginPanelWebView2Class")+ .to_string(),+ title: si+ .get("title")+ .and_then(|x| x.as_str())+ .unwrap_or("Login | 3DEXPERIENCE ID | Dassault Systemes")+ .to_string(),+ visible: true,+ });+ }+ }+ // A dialog a QUIT RUNG raised. The ladder must stop on it, and this is+ // how a scenario puts one on screen between rungs.+ let d = self.read_json("dialog-open.json");+ if d.is_object() {+ if let Some(w) = read(&d) {+ out.push(w);+ }+ }+ Ok(out)+ }++ fn thread_owner(&self, tid: u32) -> Option<u32> {+ let dead = self.terminated_pids();+ self.invocations()+ .into_iter()+ .find(|i| Self::thread_of(i.launcher_pid()) == tid || Self::thread_of(i.launcher_pid()) + 1 == tid)+ .map(|i| i.launcher_pid())+ .filter(|p| !dead.contains(p))+ }++ fn process_metrics(&self, _pid: u32) -> ProcMetrics {+ let s = self.read_json("sldworks.json");+ let private_mb = s.get("privateMB").and_then(|v| v.as_u64()).or(Some(800));+ // `cpuSecondsPerRead` advances the simulated CPU clock on every read, so+ // a scenario can be "burning CPU" (>0) or "dead" (0).+ let step = s.get("cpuSecondsPerRead").and_then(|v| v.as_f64()).unwrap_or(0.0);+ let reads = {+ let f = self.dir.join("cpu-reads");+ let n = std::fs::read_to_string(&f).ok().and_then(|t| t.trim().parse::<u64>().ok()).unwrap_or(0);+ let _ = std::fs::write(&f, (n + 1).to_string());+ n+ };+ ProcMetrics {+ private_mb,+ working_set_mb: s+ .get("workingSetMB")+ .and_then(|v| v.as_u64())+ .or_else(|| private_mb.map(|p| p * 3 / 4)),+ cpu_seconds: s+ .get("cpuSeconds")+ .and_then(|v| v.as_f64())+ .or(Some(100.0))+ .map(|base| base + step * reads as f64),+ handles: s.get("handles").and_then(|v| v.as_u64()).map(|h| h as u32).or(Some(3000)),+ }+ }++ fn kind(&self) -> &'static str {+ "simulated"+ }+}++// ── the real one ────────────────────────────────────────────────────────────++#[cfg(windows)]+mod win {+ use super::*;+ use windows::core::PCWSTR;+ use windows::core::BOOL;+ use windows::Win32::Foundation::{CloseHandle, FILETIME, HANDLE, HWND, LPARAM};+ use windows::Win32::System::Diagnostics::ToolHelp::{+ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,+ TH32CS_SNAPPROCESS,+ };+ use windows::Win32::System::ProcessStatus::{+ K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,+ };+ use windows::Win32::System::Threading::{+ GetProcessHandleCount, GetProcessTimes, OpenProcess, TerminateProcess,+ PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE,+ };+ use windows::Win32::UI::WindowsAndMessaging::{+ EnumWindows, GetWindowLongW, GetWindowThreadProcessId, IsHungAppWindow, IsWindowVisible,+ GWL_STYLE, WS_VISIBLE,+ };++ pub struct WindowsOs;++ /// Where the 3DEXPERIENCE launcher and its helper write their logs. These+ /// are the two files that say whether a launch is PROGRESSING or has stalled+ /// at `SecurityContext:` — the single most useful signal on the machine, and+ /// the reason this bridge no longer has to guess.+ fn log_dir(sub: &str) -> Option<std::path::PathBuf> {+ let appdata = std::env::var("APPDATA").ok().filter(|s| !s.is_empty())?;+ Some(std::path::Path::new(&appdata).join("DassaultSystemes").join(sub))+ }++ fn logs_in(sub: &str) -> Result<Vec<LogFile>, String> {+ let dir = log_dir(sub).ok_or("APPDATA is not set")?;+ let entries = std::fs::read_dir(&dir)+ .map_err(|e| format!("cannot read {}: {e}", dir.display()))?;+ let mut out = Vec::new();+ for e in entries.flatten() {+ let name = e.file_name().to_string_lossy().to_string();+ // `*_dslcc.log` is the licence client's own trace, not the launcher's+ // progress log — it is large, noisy, and says nothing about Run Begin.+ if !name.to_ascii_lowercase().ends_with(".log") || name.contains("_dslcc") {+ continue;+ }+ // CREATED, falling back to MODIFIED — and they are not the same+ // fact. The launcher appends to its log for as long as it runs, so+ // `modified()` on a live log is "a moment ago" whatever the launch+ // is doing. The fallback is only reached on a filesystem that does+ // not record creation times (some network shares), where it is the+ // only thing available; on NTFS `created()` always answers, and the+ // filename stamp corroborates it either way.+ let created = e+ .metadata()+ .ok()+ .and_then(|m| m.created().or_else(|_| m.modified()).ok())+ .and_then(|t| t.duration_since(UNIX_EPOCH).ok())+ .map(|d| d.as_secs());+ out.push(LogFile {+ path: e.path().to_string_lossy().to_string(),+ created_at: created,+ stamp: stamp_of(&name),+ });+ }+ // Newest first, by the launcher's own stamp where it has one.+ out.sort_by(|a, b| b.stamp.cmp(&a.stamp).then_with(|| b.created_at.cmp(&a.created_at)));+ Ok(out)+ }++ fn filetime_to_epoch(ft: FILETIME) -> Option<u64> {+ let ticks = ((ft.dwHighDateTime as u64) << 32) | ft.dwLowDateTime as u64;+ if ticks == 0 {+ return None;+ }+ // FILETIME counts 100ns ticks from 1601-01-01; 11644473600 seconds to epoch.+ Some((ticks / 10_000_000).saturating_sub(11_644_473_600))+ }++ /// The creation time of an ALREADY-OPEN handle. This is the form that+ /// closes the kill-path TOCTOU: a handle refers to one particular process+ /// object for as long as it is open, so a check made through it cannot be+ /// answered about a different process that took the same pid.+ unsafe fn created_at_of_handle(h: HANDLE) -> Option<u64> {+ let mut created = FILETIME::default();+ let (mut exit, mut kernel, mut user) =+ (FILETIME::default(), FILETIME::default(), FILETIME::default());+ if unsafe { GetProcessTimes(h, &mut created, &mut exit, &mut kernel, &mut user) }.is_ok() {+ filetime_to_epoch(created)+ } else {+ None+ }+ }++ fn created_at_of(pid: u32) -> Option<u64> {+ unsafe {+ let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;+ let out = created_at_of_handle(h);+ let _ = CloseHandle(h);+ out+ }+ }++ /// The EnumWindows callback has nowhere to put its answer but a static, so+ /// only ONE enumeration may be in flight at a time — the supervisor thread+ /// and an HTTP worker answering `swx_status` can both ask at once, and two+ /// overlapping searches would hand one of them the OTHER process's window.+ /// An hwnd attributed to the wrong process is then fed to IsHungAppWindow,+ /// which is a wrong answer to the one question that must not be wrong.+ static ENUM_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());+ /// (pid asked about, hwnds found) for the window census. Serialised by+ /// `ENUM_LOCK` for the same reason `FOUND_HWND` is.+ static CENSUS: std::sync::Mutex<(u32, Vec<i64>)> = std::sync::Mutex::new((0, Vec::new()));+ static FOUND_HWND: std::sync::Mutex<(u32, i64)> = std::sync::Mutex::new((0, 0));++ unsafe extern "system" fn census_proc(hwnd: HWND, _l: LPARAM) -> BOOL {+ let mut guard = match CENSUS.lock() {+ Ok(g) => g,+ Err(e) => e.into_inner(),+ };+ let mut pid = 0u32;+ GetWindowThreadProcessId(hwnd, Some(&mut pid));+ if pid == guard.0 {+ guard.1.push(hwnd.0 as i64);+ }+ BOOL(1)+ }++ unsafe extern "system" fn enum_proc(hwnd: HWND, _l: LPARAM) -> BOOL {+ let mut guard = match FOUND_HWND.lock() {+ Ok(g) => g,+ Err(e) => e.into_inner(),+ };+ if guard.1 != 0 {+ return BOOL(1);+ }+ let mut pid = 0u32;+ GetWindowThreadProcessId(hwnd, Some(&mut pid));+ if pid == guard.0 && IsWindowVisible(hwnd).as_bool() {+ // Only a real top-level application window — a tool window with no+ // WS_VISIBLE style is not what IsHungAppWindow should be asked about.+ if GetWindowLongW(hwnd, GWL_STYLE) as u32 & WS_VISIBLE.0 != 0 {+ guard.1 = hwnd.0 as i64;+ }+ }+ BOOL(1)+ }++ impl LaunchOs for WindowsOs {+ fn processes(&self) -> Result<Vec<ProcInfo>, String> {+ unsafe {+ let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)+ .map_err(|e| format!("CreateToolhelp32Snapshot failed: {e}"))?;+ let mut entry = PROCESSENTRY32W {+ dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,+ ..Default::default()+ };+ let mut out = Vec::new();+ if Process32FirstW(snap, &mut entry).is_ok() {+ loop {+ let len =+ entry.szExeFile.iter().position(|c| *c == 0).unwrap_or(entry.szExeFile.len());+ let image = String::from_utf16_lossy(&entry.szExeFile[..len]);+ if WATCHED_IMAGES.iter().any(|w| w.eq_ignore_ascii_case(&image)) {+ let pid = entry.th32ProcessID;+ out.push(ProcInfo {+ pid,+ image,+ created_at: created_at_of(pid),+ ppid: Some(entry.th32ParentProcessID),+ });+ }+ if Process32NextW(snap, &mut entry).is_err() {+ break;+ }+ }+ }+ let _ = CloseHandle(snap);+ Ok(out)+ }+ }++ fn launcher_logs(&self) -> Result<Vec<LogFile>, String> {+ logs_in("SWXDesktopLauncher")+ }++ fn helper_logs(&self) -> Result<Vec<LogFile>, String> {+ logs_in("SWXDesktopHelper")+ }++ fn read_log(&self, path: &str) -> Result<String, String> {+ // The launcher holds its log open; a plain read still works, but a+ // huge `_dslcc` sibling could be megabytes, so cap what we take.+ let bytes = std::fs::read(path).map_err(|e| format!("cannot read {path}: {e}"))?;+ const CAP: usize = 256 * 1024;+ let slice = if bytes.len() > CAP { &bytes[..CAP] } else { &bytes[..] };+ Ok(String::from_utf8_lossy(slice).to_string())+ }++ /// Terminate ONE pid — image AND creation time re-checked ON THE OPEN+ /// HANDLE, immediately before the kill.+ ///+ /// A pid is reusable, so a census taken even milliseconds earlier is a+ /// statement about a process that may already be gone. The handle is+ /// the only thing that pins the identity: open first, verify through+ /// the handle, then terminate through that same handle. `PROCESS_TERMINATE`+ /// alone cannot answer GetProcessTimes, so the handle is opened with+ /// QUERY_LIMITED_INFORMATION as well.+ fn terminate(&self, pid: u32, image: &str, created_at: Option<u64>) -> Result<(), String> {+ let census = self.processes()?;+ let Some(p) = census.iter().find(|p| p.pid == pid) else {+ return Err(format!("pid {pid} is no longer running"));+ };+ if !p.is(image) {+ return Err(format!("pid {pid} is {} now, not {image} — refusing to terminate it", p.image));+ }+ let Some(want) = created_at else {+ return Err(format!(+ "pid {pid} has no recorded creation time, so it cannot be proven to be the \+ process this bridge started — refusing to terminate it"+ ));+ };+ unsafe {+ let h: HANDLE =+ OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION, false, pid)+ .map_err(|e| format!("OpenProcess({pid}) failed: {e}"))?;+ let live = created_at_of_handle(h);+ if live != Some(want) {+ let _ = CloseHandle(h);+ return Err(format!(+ "pid {pid} started at {live:?}, not {want}: between the census and the \+ kill this number was reused — refusing to terminate it"+ ));+ }+ let r = TerminateProcess(h, 1);+ let _ = CloseHandle(h);+ r.map_err(|e| format!("TerminateProcess({pid}) failed: {e}"))+ }+ }++ /// Liveness WITHOUT a full census — used to ask whether the bridge that+ /// recorded a launch attempt is still around. A pid we cannot open at+ /// all is reported as unknown, not as dead.+ fn pid_alive(&self, pid: u32) -> Option<bool> {+ unsafe {+ match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) {+ Ok(h) => {+ let alive = created_at_of_handle(h).is_some();+ let _ = CloseHandle(h);+ Some(alive)+ }+ // ERROR_INVALID_PARAMETER is "no such process"; anything+ // else (access denied on a service, say) is UNKNOWN.+ Err(e) if e.code().0 as u32 == 0x8007_0057 => Some(false),+ Err(_) => None,+ }+ }+ }++ /// The SAME clock the process creation times come from, converted the+ /// SAME way — so "created at or after our invoke" is a comparison+ /// between two readings of one clock, and needs no slack to absorb a+ /// conversion difference.+ fn now(&self) -> u64 {+ use windows::Win32::System::SystemInformation::GetSystemTimeAsFileTime;+ filetime_to_epoch(unsafe { GetSystemTimeAsFileTime() }).unwrap_or_else(epoch_secs)+ }++ fn window_responding(&self, hwnd: i64) -> Option<bool> {+ if hwnd == 0 {+ return None;+ }+ unsafe { Some(!IsHungAppWindow(HWND(hwnd as *mut std::ffi::c_void)).as_bool()) }+ }++ /// Walk the thread table for the owner of one thread id. The launcher+ /// stamps that id on every line it writes, so this is what turns "a log+ /// appeared" into "OUR launcher wrote this log".+ fn thread_owner(&self, tid: u32) -> Option<u32> {+ use windows::Win32::System::Diagnostics::ToolHelp::{+ Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,+ };+ unsafe {+ let snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0).ok()?;+ let mut entry = THREADENTRY32 {+ dwSize: std::mem::size_of::<THREADENTRY32>() as u32,+ ..Default::default()+ };+ let mut owner = None;+ if Thread32First(snap, &mut entry).is_ok() {+ loop {+ if entry.th32ThreadID == tid {+ owner = Some(entry.th32OwnerProcessID);+ break;+ }+ if Thread32Next(snap, &mut entry).is_err() {+ break;+ }+ }+ }+ let _ = CloseHandle(snap);+ owner+ }+ }++ /// Every top-level window owned by `pid`, with its class — the quit+ /// ladder's P3 instrument, and readable with no ab and no COM.+ fn windows_of_pid(&self, pid: u32) -> Result<Vec<WindowInfo>, String> {+ use windows::Win32::UI::WindowsAndMessaging::{GetClassNameW, GetWindowTextW};+ let _serialised = ENUM_LOCK.lock().unwrap_or_else(|e| e.into_inner());+ *CENSUS.lock().unwrap_or_else(|e| e.into_inner()) = (pid, Vec::new());+ unsafe {+ let _ = EnumWindows(Some(census_proc), LPARAM(0));+ }+ let hwnds = CENSUS.lock().unwrap_or_else(|e| e.into_inner()).1.clone();+ let mut out = Vec::new();+ for h in hwnds {+ let hwnd = HWND(h as *mut std::ffi::c_void);+ let mut class = [0u16; 256];+ let mut title = [0u16; 512];+ let (cl, tl) = unsafe {+ (GetClassNameW(hwnd, &mut class), GetWindowTextW(hwnd, &mut title))+ };+ out.push(WindowInfo {+ hwnd: h,+ class_name: String::from_utf16_lossy(&class[..cl.max(0) as usize]),+ title: String::from_utf16_lossy(&title[..tl.max(0) as usize]),+ visible: unsafe { IsWindowVisible(hwnd) }.as_bool(),+ });+ }+ Ok(out)+ }++ fn main_window_of_pid(&self, pid: u32) -> Option<i64> {+ let _serialised = ENUM_LOCK.lock().unwrap_or_else(|e| e.into_inner());+ let mut guard = FOUND_HWND.lock().unwrap_or_else(|e| e.into_inner());+ *guard = (pid, 0);+ drop(guard);+ unsafe {+ let _ = EnumWindows(Some(enum_proc), LPARAM(0));+ }+ let found = FOUND_HWND.lock().unwrap_or_else(|e| e.into_inner()).1;+ (found != 0).then_some(found)+ }++ fn process_metrics(&self, pid: u32) -> ProcMetrics {+ unsafe {+ let Ok(h) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else {+ return ProcMetrics::default();+ };+ let mut counters = PROCESS_MEMORY_COUNTERS_EX::default();+ let got = K32GetProcessMemoryInfo(+ h,+ &mut counters as *mut _ as *mut PROCESS_MEMORY_COUNTERS,+ std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,+ )+ .as_bool();+ let mut handles = 0u32;+ let got_handles = GetProcessHandleCount(h, &mut handles).is_ok();+ // Kernel + user time, in 100ns ticks. The absolute value is+ // meaningless; the DELTA across two reads is the busy-vs-dead+ // signal.+ let mut cpu = None;+ {+ let (mut c, mut e, mut k, mut u) = (+ FILETIME::default(),+ FILETIME::default(),+ FILETIME::default(),+ FILETIME::default(),+ );+ if GetProcessTimes(h, &mut c, &mut e, &mut k, &mut u).is_ok() {+ let ticks = |f: FILETIME| {+ ((f.dwHighDateTime as u64) << 32) | f.dwLowDateTime as u64+ };+ cpu = Some((ticks(k) + ticks(u)) as f64 / 10_000_000.0);+ }+ }+ let _ = CloseHandle(h);+ ProcMetrics {+ private_mb: got.then(|| (counters.PrivateUsage as u64) / (1024 * 1024)),+ working_set_mb: got.then(|| (counters.WorkingSetSize as u64) / (1024 * 1024)),+ cpu_seconds: cpu,+ handles: got_handles.then_some(handles),+ }+ }+ }++ fn kind(&self) -> &'static str {+ "windows"+ }+ }++ /// Run a `.lnk` through `ShellExecuteExW` with an EXPLICIT NORMAL show+ /// state — and BRING BACK THE IDENTITY OF WHAT IT STARTED.+ ///+ /// `SEE_MASK_NOCLOSEPROCESS` makes the shell hand us `hProcess` for the+ /// process it created. `GetProcessId` + `GetProcessTimes` on that handle+ /// give a (pid, creation time) pair that came from the invoke ITSELF, not+ /// from a census we searched afterwards hoping to recognise our own work.+ /// That is the only attribution this bridge accepts as authority to+ /// terminate anything — see `launch::AttributionRule`.+ ///+ /// Called from the com host, whose thread is STA-initialised: the shell's+ /// link resolver needs an apartment, and the HTTP workers have none.+ pub fn shell_execute_lnk(path: &str) -> Result<Option<(u32, Option<u64>)>, String> {+ use windows::Win32::System::Threading::GetProcessId;+ use windows::Win32::UI::Shell::{+ ShellExecuteExW, SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS,+ SHELLEXECUTEINFOW,+ };+ use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;+ let wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();+ let mut info = SHELLEXECUTEINFOW {+ cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as u32,+ fMask: SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC | SEE_MASK_NOCLOSEPROCESS,+ lpFile: PCWSTR(wide.as_ptr()),+ nShow: SW_SHOWNORMAL.0,+ ..Default::default()+ };+ unsafe { ShellExecuteExW(&mut info) }.map_err(|e| format!("ShellExecuteExW failed: {e}"))?;+ if info.hProcess.is_invalid() {+ // The shell handed the link to an ALREADY-RUNNING process (DDE) or+ // declined to say. No handle, no attribution — and therefore, later,+ // no kill.+ return Ok(None);+ }+ let pid = unsafe { GetProcessId(info.hProcess) };+ let created = unsafe { created_at_of_handle(info.hProcess) };+ let _ = unsafe { CloseHandle(info.hProcess) };+ Ok((pid != 0).then_some((pid, created)))+ }++ /// `Start-Process -PassThru` and READ THE PID IT PRINTS.+ ///+ /// Waits on the PowerShell child (which returns as soon as it has started+ /// the target, not when the target exits), so a non-zero exit or anything+ /// on stderr is reported as "this route did not start anything" rather than+ /// being mistaken for a stall in a launcher that never ran.+ pub fn powershell_start_lnk(path: &str) -> Result<Option<(u32, Option<u64>)>, String> {+ use std::os::windows::process::CommandExt;+ const CREATE_NO_WINDOW: u32 = 0x0800_0000;+ let quoted = path.replace('\'', "''");+ let out = std::process::Command::new("powershell.exe")+ .args([+ "-NoProfile",+ "-NonInteractive",+ "-Command",+ &format!("(Start-Process -FilePath '{quoted}' -PassThru).Id"),+ ])+ .creation_flags(CREATE_NO_WINDOW)+ .output()+ .map_err(|e| format!("could not run powershell: {e}"))?;+ if !out.status.success() {+ return Err(format!(+ "powershell exited {} — the route started nothing. stderr: {}",+ out.status.code().unwrap_or(-1),+ String::from_utf8_lossy(&out.stderr).trim()+ ));+ }+ let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();+ if !stderr.is_empty() {+ return Err(format!("powershell wrote to stderr, so the route is not trusted to have started anything: {stderr}"));+ }+ let pid: u32 = match String::from_utf8_lossy(&out.stdout).trim().parse() {+ Ok(p) => p,+ Err(_) => return Ok(None), // it ran, but would not say what it started+ };+ Ok(Some((pid, created_at_of(pid))))+ }+}++#[cfg(windows)]+pub use win::{powershell_start_lnk, shell_execute_lnk};++#[cfg(test)]+mod tests {+ use super::*;++ fn fake(tag: &str) -> (std::path::PathBuf, FakeOs) {+ let dir = std::env::temp_dir().join(format!("swx-fakeos-{}-{tag}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ std::fs::create_dir_all(&dir).unwrap();+ (dir.clone(), FakeOs { dir })+ }++ #[test]+ fn a_failed_census_is_an_error_never_an_empty_list() {+ let (dir, os) = fake("census");+ std::fs::write(dir.join("census-fails"), "").unwrap();+ assert!(os.processes().is_err(), "'I could not look' must not read as 'nothing is running'");+ let _ = std::fs::remove_dir_all(&dir);+ }++ #[test]+ fn the_simulator_models_the_stall_and_the_working_route() {+ let (dir, os) = fake("stall");+ std::fs::write(+ dir.join("routes.json"),+ r#"{"cmdStart":{"chain":true},+ "explorer":{"chain":true,"runBeginAfterMs":0,"sldworksAfterMs":0,"attachAfterMs":0}}"#,+ )+ .unwrap();+ FakeOs::record_invoke(&dir, "cmdStart", "x.lnk");+ let procs = os.processes().unwrap();+ assert_eq!(procs.len(), 2, "the stalled route still starts a chain: {procs:?}");+ let log = os.read_log(&os.launcher_logs().unwrap()[0].path).unwrap();+ assert!(log.contains("SecurityContext:"));+ assert!(!log.contains("Run Begin"), "the stall is the ABSENCE of Run Begin");+ assert!(!FakeOs::attachable(&dir));++ FakeOs::record_invoke(&dir, "explorer", "x.lnk");+ let log2 = os.read_log(&os.launcher_logs().unwrap()[0].path).unwrap();+ assert!(log2.contains("Run Begin"), "the working route reaches Run Begin");+ assert!(FakeOs::attachable(&dir));+ assert!(os.processes().unwrap().iter().any(|p| p.is("SLDWORKS.exe")));+ let _ = std::fs::remove_dir_all(&dir);+ }++ #[test]+ fn terminate_refuses_a_pid_that_is_not_the_image_it_was_asked_for() {+ let (dir, os) = fake("terminate");+ std::fs::write(dir.join("routes.json"), r#"{"cmdStart":{"chain":true}}"#).unwrap();+ let seq = FakeOs::record_invoke(&dir, "cmdStart", "x.lnk");+ let inv = os.invocations().into_iter().find(|i| i.seq == seq).unwrap();+ let live = os.processes().unwrap();+ let cat = live.iter().find(|p| p.pid == inv.catstart_pid()).unwrap().clone();+ // The wrong IMAGE for that pid is refused...+ assert!(os.terminate(cat.pid, "SWXDesktopLauncher.exe", cat.created_at).is_err());+ // ...and so is the right image with the WRONG CREATION TIME, which is+ // what a reused pid looks like.+ assert!(+ os.terminate(cat.pid, "CATSTART.exe", Some(1)).is_err(),+ "a pid whose creation time does not match is a different process now"+ );+ assert!(os.terminate(cat.pid, "CATSTART.exe", cat.created_at).is_ok());+ assert!(+ !os.processes().unwrap().iter().any(|p| p.pid == inv.catstart_pid()),+ "a terminated process leaves the census"+ );+ let _ = std::fs::remove_dir_all(&dir);+ }+}
src/ledger.rs+6−2@@ -70,7 +70,11 @@ pub fn record_in(root: &std::path::Path, action: &str, detail: Value) { let drop = lines.len() - LEDGER_MAX_LINES; lines.drain(0..drop); }- let _ = std::fs::write(&path, lines.join("\n") + "\n");+ // ATOMIC. The ledger is the one artefact that outlives the bridge — it is+ // the record of what this bridge did on somebody's machine while they were+ // not watching — and a plain `write` truncates before it fills. A kill in+ // that window would take the whole audit log, not the last line.+ let _ = crate::launch::write_atomic(&path, &(lines.join("\n") + "\n")); } /// The most recent `limit` actions, newest LAST (chronological, the order a@@ -114,7 +118,7 @@ pub fn record_time_in(root: &std::path::Path, verb: &str, seconds: f64) { arr.drain(0..drop); } }- let _ = std::fs::write(&path, Value::Object(store).to_string());+ let _ = crate::launch::write_atomic(&path, &Value::Object(store).to_string()); } /// `{verb: {count, p50, p90, minSec, maxSec}}` — the Fusion `verb_times.stages`
src/lib.rs+4@@ -33,7 +33,11 @@ pub mod detect; pub mod findfiles; pub mod httpc; pub mod jobs;+pub mod launch;+pub mod launchos; pub mod ledger;+pub mod owned;+pub mod quit; pub mod scratch; pub mod script; pub mod server;
src/owned.rsadded+324@@ -0,0 +1,324 @@+//! 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);+ }+}
src/quit.rsadded+1149@@ -0,0 +1,1149 @@+//! THE GRACEFUL QUIT LADDER — `swx_stop` v2.+//!+//! Full design and evidence: `docs/QUIT-INVESTIGATION.md` §5. This module holds+//! the rules that must be decidable without a machine (so they are testable) and+//! the measurements that come from the machine rather than from a COM return+//! value (so they cannot lie).+//!+//! ## The three laws+//!+//! 1. **NEVER A KILL.** No rung reaches `taskkill`, `TerminateProcess`,+//! `swx_kill`, or `RmForceShutdown`. `swx_kill` is a separate, differently+//! gated verb; `swx_stop` must never call it, escalate to it, or recommend it+//! in a hint. `RmForceShutdown` is not a runtime flag here — the Restart+//! Manager rung passes a literal `0`.+//! 2. **NEVER ANSWER A DIALOG.** The census runs before every rung and again+//! after each one. Any top-level dialog owned by the SOLIDWORKS pid stops the+//! ladder and is REPORTED, never clicked, never Escaped, never `WM_CLOSE`d.+//! On 2026-09-02 closing a stuck SOLIDWORKS modal through the window manager+//! took the whole application down.+//! 3. **NOTHING IS DISOWNED UNTIL THE PROCESS IS VERIFIABLY GONE.** v1 cleared+//! the created-document registry *before* it verified the quit, so a declined+//! quit left the bridge unable to touch its own documents. Success is+//! (pid + creation time) absent from a live census — never a COM call, never+//! `GetActiveObject`.+//!+//! ## Why the reference release is load-bearing+//!+//! An automation client's outstanding `IDispatch` is exactly what keeps a+//! SOLIDWORKS alive (`UserControl` semantics, §1.2). v1 asked "are you still+//! there?" while holding a live reference for the whole match arm, so it could+//! not tell "declined" from "quit, but pinned by our own pointer". Worse, a+//! `WM_CLOSE` sent while we still hold one is the most direct way to manufacture+//! the half-closed `SLDWORKS.exe -Embedding` orphan this design exists to avoid.+//!+//! The refcount reasoning, stated once: `variant::Disp` wraps a `windows`-crate+//! `IDispatch`, which is a refcounted COM pointer — `Clone` calls `AddRef` and+//! `Drop` calls `Release`. The com host holds at most ONE cached reference+//! (`ComBackend::sw`) plus whatever locals a command creates. So "release+//! everything" is: drop every local `Disp` (end of scope) and set `self.sw =+//! None`. `release_com` does exactly that and then reports+//! `outstanding: 0`, which the ladder requires before any rung and before any+//! measurement. Re-attaching for a later measurement would defeat the point, so+//! the measurement never touches COM at all.++use crate::launchos::{ProcInfo, WindowInfo};+use serde_json::{json, Value};++/// `swCommands_App_Exit` — File ▸ Exit, through the app's own command+/// dispatcher. Source: SOLIDWORKS API `swCommands_e` (see+/// docs/QUIT-INVESTIGATION.md §1.5). Not inline anywhere.+pub const SW_COMMANDS_APP_EXIT: i32 = 2825;++/// The rungs, in order. There is no rung (g): the ladder ends in an honest+/// refusal, never in a kill.+pub const RUNGS: &[&str] = &["a", "b", "c", "d", "e", "f"];++/// How far the ladder may climb unless the caller says otherwise.+///+/// **"a" until the live gate has run.** Shipping the whole ladder armed on day+/// one would be exactly the mistake crash-reporter recovery avoided by shipping+/// `off`: rungs (b)-(f) send window messages and run VBA inside somebody's CAD+/// session, and not one of them has been observed on a real machine.+pub const DEFAULT_MAX_RUNG: &str = "a";++/// What each rung actually does, for the report and the ledger.+pub fn rung_action(rung: &str) -> &'static str {+ match rung {+ "a" => "UserControl=false; ExitApp; release every COM reference",+ "b" => "RunCommand(swCommands_App_Exit=2825)",+ "c" => "SendMessageTimeout(WM_CLOSE) to the main frame",+ "d" => "PostMessage(WM_SYSCOMMAND, SC_CLOSE) to the main frame",+ "e" => "RunMacro2 of the shipped two-line exit macro",+ "f" => "Restart Manager RmShutdown (RmForceShutdown OMITTED)",+ _ => "unknown rung",+ }+}++/// Per-rung budget in seconds, from §5.3.+pub fn rung_budget_secs(rung: &str) -> u64 {+ match rung {+ "a" | "b" => 30,+ "c" | "d" => 45,+ "e" => 60,+ "f" => 90,+ _ => 30,+ }+}++pub fn is_rung(r: &str) -> bool {+ RUNGS.contains(&r)+}++/// The rungs to try, in order, up to and including `max`. An unknown `max`+/// yields nothing — the caller refuses rather than guessing.+pub fn rungs_up_to(max: &str) -> Vec<&'static str> {+ match RUNGS.iter().position(|r| *r == max) {+ None => Vec::new(),+ Some(i) => RUNGS[..=i].to_vec(),+ }+}++/// The per-session children a GRACEFUL exit reaps. Their survival after the pid+/// is gone means the exit was abrupt, not clean.+pub const SESSION_CHILDREN: &[&str] =+ &["sldProcMon.exe", "sldsurfacing.exe", "sldimport.exe", "swCefSubProc.exe"];++/// The processes that MUST SURVIVE a graceful quit — by design (§2.6).+///+/// Their continued presence is CONSENT, not a failure: they are the platform's+/// own daemons and the user's licence/vault plumbing, none of which belongs to+/// the SOLIDWORKS session the bridge is ending. Reporting them as "not cleaned+/// up" would invite somebody to kill them, which is the launch path's oldest+/// lesson (`docs/LAUNCH-RESILIENCE.md` §3).+pub const EXPECTED_SURVIVORS: &[&str] = &[+ "ENOPLMCSAClient.exe",+ "EdmServerV6.exe",+ "SWXDesktopHelper.exe",+ "sldBgDwld.exe",+ "3DEXPERIENCELauncher.exe",+ "3DEXPERIENCELauncherSysTray.exe",+ "3DEXPERIENCELauncherService.exe",+];++/// Images whose behaviour at exit is undocumented: RECORD, DO NOT JUDGE (§5.4).+pub const UNJUDGED: &[&str] = &["ViewServerV6.exe", "CATSTART.exe", "CATSysDemon.exe"];++/// The identity of the session being quit, captured BEFORE the first rung (P7).+///+/// Same attribution law as the launch path: without a pid **and** its creation+/// time, "the process went away" cannot be told from "a pid was reused", and+/// every rung's success measurement depends on that distinction.+#[derive(Clone, Debug, PartialEq)]+pub struct Identity {+ pub pid: u32,+ pub created_at: u64,+ pub main_hwnd: Option<i64>,+}++impl Identity {+ pub fn to_json(&self) -> Value {+ json!({"processId": self.pid, "processCreatedAt": self.created_at, "mainHwnd": self.main_hwnd})+ }+}++/// IS THE PROCESS GONE? The one success criterion.+///+/// `None` means the census could not be read — which is a rung that FAILED to be+/// measured, never a rung that worked. A pid alone is not enough: a matching pid+/// with a DIFFERENT creation time is a new process, and the session we were+/// quitting is gone.+pub fn process_gone(census: Result<&Vec<ProcInfo>, &String>, id: &Identity) -> Option<bool> {+ let c = census.ok()?;+ Some(!c.iter().any(|p| p.pid == id.pid && p.created_at == Some(id.created_at)))+}++/// EVERY PID A BLOCKING DIALOG COULD BELONG TO: SOLIDWORKS itself, plus the CEF+/// (browser) subprocesses it started.+///+/// The 3DEXPERIENCE sign-in, licence and cloud-save windows are web views, and a+/// web view's window belongs to `swCefSubProc.exe` — a CHILD of `sldworks.exe`,+/// with its own pid. A census of the SOLIDWORKS pid alone cannot see them, so+/// "no dialog is on screen" was provable while the platform was holding a modal+/// asking the user to sign in. Attribution is by parentage, never by class name:+/// only a `swCefSubProc.exe` whose ppid IS this session's pid counts, so another+/// SOLIDWORKS session's web view is never mistaken for ours.+///+/// `None` when the census could not be read — which makes the dialog question+/// UNKNOWN, and UNKNOWN refuses.+pub fn dialog_census_pids(census: Result<&Vec<ProcInfo>, &String>, id: &Identity) -> Option<Vec<u32>> {+ let c = census.ok()?;+ let mut pids = vec![id.pid];+ for p in c.iter() {+ if p.image.eq_ignore_ascii_case("swCefSubProc.exe") && p.ppid == Some(id.pid) {+ pids.push(p.pid);+ }+ }+ Some(pids)+}++/// The per-session children still alive, and the expected survivors still alive.+pub fn reap_report(census: &[ProcInfo]) -> (Vec<String>, Vec<String>, Vec<String>) {+ let names = |set: &[&str]| -> Vec<String> {+ census+ .iter()+ .filter(|p| set.iter().any(|s| s.eq_ignore_ascii_case(&p.image)))+ .map(|p| p.image.clone())+ .collect()+ };+ (names(SESSION_CHILDREN), names(EXPECTED_SURVIVORS), names(UNJUDGED))+}++/// IS THIS HWND STILL THE WINDOW WE MEASURED? Asked immediately before a message+/// rung sends anything.+///+/// Window handles are recycled. Rungs (c) and (d) send to an hwnd captured at+/// precondition time, and between then and the send SOLIDWORKS may have gone+/// (rung (a) is the rung before them, and its whole purpose is to make it go) —+/// at which point that handle can belong to somebody else's window, and+/// `WM_CLOSE` to somebody else's window is a close nobody asked for. Both facts+/// must answer: the window's owning pid, and that pid's creation time.+pub fn hwnd_still_owned(+ hwnd_pid: Option<u32>,+ hwnd_pid_created_at: Option<u64>,+ id: &Identity,+) -> bool {+ hwnd_pid == Some(id.pid) && hwnd_pid_created_at == Some(id.created_at)+}++/// A dialog that stops the ladder, classified — and NEVER answered.+#[derive(Clone, Debug, PartialEq)]+pub struct BlockingDialog {+ pub hwnd: i64,+ pub title: String,+ pub kind: &'static str,+}++impl BlockingDialog {+ pub fn to_json(&self) -> Value {+ json!({"hwnd": self.hwnd, "title": self.title, "kind": self.kind})+ }+ /// The error code this dialog produces. A save prompt is called out+ /// separately because at a VERIFIED zero documents it should be impossible —+ /// its appearance means our document count was wrong, which is a bug to+ /// report, not a dialog to dismiss.+ pub fn error_code(&self) -> &'static str {+ match self.kind {+ "save_changes" => "stop_blocked_by_save_prompt",+ _ => "stop_blocked_by_dialog",+ }+ }+}++/// The first blocking dialog in a window census, if any.+///+/// Only top-level `#32770` windows owned by the SOLIDWORKS pid count — the+/// census is already scoped to that pid, and the main frame is an `Afx:` class,+/// never a dialog.+pub fn first_blocking_dialog(windows: &[WindowInfo]) -> Option<BlockingDialog> {+ windows.iter().filter(|w| w.is_dialog() && w.visible).map(|w| {+ let (kind, _blocking, _never) = crate::abwin::classify(&w.title);+ BlockingDialog { hwnd: w.hwnd, title: w.title.clone(), kind }+ }).next()+}++/// What a rung reported doing. `Exited` is never trusted on its own — the census+/// is what decides — but a rung that says it was blocked stops the ladder,+/// because an access-denied is a fact about the machine's integrity levels, not+/// about SOLIDWORKS' willingness to quit.+#[derive(Clone, Copy, Debug, PartialEq)]+pub enum RungResult {+ /// The rung ran and the process is gone (census-confirmed).+ Quit,+ /// The rung ran and the process is still there.+ Declined,+ /// A top-level dialog appeared. LADDER STOPS.+ DialogAppeared,+ /// UIPI refused the message. LADDER STOPS — escalating past an+ /// access-denied would just try the same blocked gesture another way.+ UipiBlocked,+ /// The rung could not be attempted (not shipped, not permitted, no hwnd).+ Skipped,+ /// The rung ran and its effect could not be MEASURED. A rung that cannot be+ /// measured is a rung that failed.+ Unmeasured,+}++impl RungResult {+ pub fn name(&self) -> &'static str {+ match self {+ RungResult::Quit => "quit",+ RungResult::Declined => "declined",+ RungResult::DialogAppeared => "dialogAppeared",+ RungResult::UipiBlocked => "uipiBlocked",+ RungResult::Skipped => "skipped",+ RungResult::Unmeasured => "unmeasured",+ }+ }+ /// Does the ladder stop here, whatever the reason?+ pub fn stops_ladder(&self) -> bool {+ matches!(+ self,+ RungResult::Quit | RungResult::DialogAppeared | RungResult::UipiBlocked+ )+ }+}++/// Map the host's own word for what happened onto a result. An unrecognised+/// answer is `Unmeasured`, not `Declined`: "I do not understand the reply" is not+/// "it refused".+pub fn result_from_host(word: &str) -> RungResult {+ match word {+ "exited" | "quit" => RungResult::Quit,+ "declined" => RungResult::Declined,+ "dialogAppeared" | "dialog" => RungResult::DialogAppeared,+ "uipiBlocked" => RungResult::UipiBlocked,+ "skipped" => RungResult::Skipped,+ _ => RungResult::Unmeasured,+ }+}++/// One rung's full record.+#[derive(Clone, Debug)]+pub struct RungTry {+ pub rung: &'static str,+ pub action: &'static str,+ pub result: RungResult,+ pub process_gone: Option<bool>,+ pub elapsed_sec: f64,+ pub new_windows: usize,+ pub dialog: Option<BlockingDialog>,+ pub children_alive: Vec<String>,+ pub survivors_alive: Vec<String>,+ pub unjudged_alive: Vec<String>,+ /// Whether the census those three lists came from could be READ. Three empty+ /// lists otherwise say "everything was reaped, nothing survived" — the most+ /// reassuring possible reading of no information at all.+ pub census_readable: bool,+ pub detail: Option<String>,+ pub host_error: Option<String>,+}++impl RungTry {+ pub fn to_json(&self) -> Value {+ json!({+ "rung": self.rung,+ "action": self.action,+ "result": self.result.name(),+ // `null` when the census could not be read — a rung that cannot be+ // measured is a rung that failed, and it says so rather than+ // reporting a comfortable false.+ "processGone": self.process_gone,+ "elapsedSec": (self.elapsed_sec * 10.0).round() / 10.0,+ "newWindows": self.new_windows,+ "dialog": self.dialog.as_ref().map(|d| d.to_json()),+ "sessionChildrenStillAlive": self.children_alive,+ "expectedSurvivorsAlive": self.survivors_alive,+ "undocumentedStillAlive": self.unjudged_alive,+ // The three lists above are only evidence if this is true.+ "censusReadable": self.census_readable,+ "detail": self.detail,+ "hostError": self.host_error,+ })+ }+}++/// Why a quit was refused before any rung ran. Every variant carries a code a+/// caller can branch on, and the UNKNOWN ones are deliberately one code: the+/// caller's move is the same for all of them, and it is never "proceed".+#[derive(Clone, Debug, PartialEq)]+pub enum Refusal {+ /// A precondition could not be MEASURED. Never proceed.+ Unknown { what: &'static str, detail: String },+ /// A precondition was measured and is false.+ Known { code: &'static str, message: String },+}++impl Refusal {+ pub fn code(&self) -> &'static str {+ match self {+ Refusal::Unknown { .. } => "stop_preconditions_unknown",+ Refusal::Known { code, .. } => code,+ }+ }+ pub fn message(&self) -> String {+ match self {+ Refusal::Unknown { what, detail } => format!(+ "cannot verify {what} before quitting SOLIDWORKS, so this bridge will not try: \+ {detail}"+ ),+ Refusal::Known { message, .. } => message.clone(),+ }+ }+}++/// THE PRECONDITION SWEEP, as a pure decision over already-measured facts.+///+/// Every one is required and every one is re-measured immediately before each+/// rung — not inherited from the caller, not cached from an earlier verb. An+/// UNKNOWN in any of them refuses; `force` waives **P2 only**.+///+/// `force` deliberately does NOT waive P1, P3, P4, P5 or P7: those are not+/// questions about the user's data, they are the difference between a quit and a+/// wedge. v1's `force` waived the whole front gate, and v1 gated on the UNSAVED+/// count — so on 2026-09-08, with five of Drew's clean documents open and+/// `unsavedDocuments:0`, a bare `swx_stop` would have sailed past the guard and+/// tried to quit a session full of his work.+#[allow(clippy::too_many_arguments)]+/// DOES THE SESSION WE ARE ABOUT TO END MATCH THE ONE THE LAUNCH RECORD CLAIMS?+///+/// Belt and braces, added after a re-attribution bug labelled a session+/// `attributed:true` while COM was attached to a DIFFERENT process: the ladder+/// builds its `Identity` from the COM pid, and a wrong attribution record would+/// then have authorised ending a process nothing had proved was ours.+///+/// `claimed` is the launch's own record, `identity` is what COM answers for now.+/// A mismatch refuses. No claim at all (`None`) is not a mismatch — it is simply+/// a check with nothing to compare, and the other preconditions still stand.+pub fn identity_matches_attribution(+ claimed: Option<(u32, u64)>,+ identity: Option<&Identity>,+) -> Result<(), Refusal> {+ let (Some((pid, created)), Some(id)) = (claimed, identity) else { return Ok(()) };+ if id.pid == pid && id.created_at == created {+ return Ok(());+ }+ Err(Refusal::Known {+ code: "identity_mismatch",+ message: format!(+ "this bridge's launch record claims SOLIDWORKS pid {pid} (created {created}), but COM \+ is attached to pid {} (created {}) — so the session about to be ended is NOT the one \+ the attribution was proved for, and nothing here may act on that difference",+ id.pid, id.created_at+ ),+ })+}++pub fn check_preconditions(+ open_docs: Option<u64>,+ listed_documents: Option<u64>,+ docs_truncated: bool,+ unsaved: Option<u64>,+ dialog: Option<&BlockingDialog>,+ dialog_census_readable: bool,+ busy: Option<bool>,+ host_busy: bool,+ responding: Option<bool>,+ identity: Option<&Identity>,+ launch_in_flight: bool,+ force: bool,+) -> Result<(), Refusal> {+ // ORDER MATTERS. The checks that do NOT depend on being able to identify the+ // process come first, because a fact that is DEFINITELY TRUE is more useful+ // to a caller than an unknown: "six documents are open" is actionable, while+ // "I could not read the pid" masks it. Every one of these still refuses.+ //+ // P1 — the OPEN count, and only from a count the enumeration agreed with.+ if docs_truncated {+ return Err(Refusal::Unknown {+ what: "how many documents are open",+ detail: "the document set is too large to enumerate (likely leaked import temps), so \+ an empty list would mean 'unlistable', not 'none open'. Run \+ swx_close_import_temps, then retry."+ .into(),+ });+ }+ match open_docs {+ None => {+ return Err(Refusal::Unknown {+ what: "how many documents are open",+ detail: "GetDocumentCount did not answer. Quitting with the user's documents open \+ is the one outcome this verb exists to prevent."+ .into(),+ })+ }+ Some(0) => {}+ Some(n) => {+ return Err(Refusal::Known {+ code: "open_documents_present",+ message: format!(+ "{n} document(s) are open in the user's SOLIDWORKS — this bridge quits only a \+ session with NONE open, and `force` does not waive that"+ ),+ })+ }+ }+ // P1b — THE COUNT MUST AGREE WITH THE ENUMERATION. `GetDocumentCount` and+ // `GetDocuments` are two different COM reads, and the Python gate has always+ // cross-checked them before it would let a live run proceed; the verb did+ // not. When they disagree neither is evidence, and "zero" is precisely the+ // answer we are least entitled to assume. (Above the cap the enumeration is+ // deliberately empty — that case is already refused above as truncated.)+ match (open_docs, listed_documents) {+ (Some(c), Some(l)) if c != l => {+ return Err(Refusal::Unknown {+ what: "how many documents are open",+ detail: format!(+ "GetDocumentCount says {c} and the enumeration lists {l} — they DISAGREE, so \+ neither is evidence and 'none open' is not established"+ ),+ })+ }+ _ => {}+ }+ // P2 — the unsaved count. The one thing `force` may waive.+ if !force {+ match unsaved {+ None => {+ return Err(Refusal::Unknown {+ what: "whether any open document has unsaved changes",+ detail: "GetSaveFlag did not answer for every document. An unanswerable \+ question is not permission."+ .into(),+ })+ }+ Some(0) => {}+ Some(n) => {+ return Err(Refusal::Known {+ code: "unsaved_documents",+ message: format!("{n} open document(s) have unsaved changes"),+ })+ }+ }+ }+ // P7 — the identity. Everything below this line is measured against a+ // specific process, and without a pid AND its creation time "the process+ // went away" cannot be told from "a pid was reused".+ if identity.is_none() {+ return Err(Refusal::Unknown {+ what: "which SOLIDWORKS process this is (pid + creation time + main window)",+ detail: "GetProcessID / the process census / GetHWndx64 did not all answer. Every \+ rung's success measurement is 'this exact process is gone', which a pid \+ without a creation time cannot express."+ .into(),+ });+ }+ // P3 — no blocking dialog, from a census we could actually take.+ if !dialog_census_readable {+ return Err(Refusal::Unknown {+ what: "whether a dialog is on screen",+ detail: "the window census could not be taken. A quit sent at a modal is a different \+ act with a different outcome — on 2026-09-02 closing a stuck SOLIDWORKS \+ modal took the whole application down."+ .into(),+ });+ }+ if let Some(d) = dialog {+ return Err(Refusal::Known {+ code: d.error_code(),+ message: format!(+ "SOLIDWORKS is showing a dialog ({}): \"{}\" — this bridge never answers a \+ dialog, so the quit stops here",+ d.kind, d.title+ ),+ });+ }+ // P4 — not busy, on both sides.+ match busy {+ None => {+ return Err(Refusal::Unknown {+ what: "whether SOLIDWORKS is mid-command",+ detail: "CommandInProgress did not answer.".into(),+ })+ }+ Some(true) => {+ return Err(Refusal::Known {+ code: "host_app_busy",+ message: "SOLIDWORKS is in the middle of a command".into(),+ })+ }+ Some(false) => {}+ }+ if host_busy {+ return Err(Refusal::Known {+ code: "host_app_busy",+ message: "this bridge's own com host is busy with another verb".into(),+ });+ }+ // P5 — responding.+ match responding {+ None => {+ return Err(Refusal::Unknown {+ what: "whether the SOLIDWORKS window is responding",+ detail: "IsHungAppWindow could not be read for the main frame.".into(),+ })+ }+ Some(false) => {+ return Err(Refusal::Known {+ code: "host_app_hung",+ message: "the SOLIDWORKS window is not responding — a quit request cannot be \+ delivered to a message loop that is not running"+ .into(),+ })+ }+ Some(true) => {}+ }+ // A launch we started is still in flight: quitting into it would race our+ // own supervisor and leave a half-started session nobody owns.+ if launch_in_flight {+ return Err(Refusal::Known {+ code: "launch_in_flight",+ message: "this bridge is still launching SOLIDWORKS — poll swx_status until the \+ launch finishes before asking it to quit"+ .into(),+ });+ }+ Ok(())+}++/// The hint when a DIALOG stopped the ladder. Different from a decline in the+/// way that matters: the app is not refusing, it is ASKING — and the answer is+/// the user's to give.+pub fn blocked_hint(d: &BlockingDialog) -> String {+ let common = "This bridge never answers a dialog. Closing a stuck SOLIDWORKS modal through \+ the window manager took the whole application down on 2026-09-02, and the \+ ladder stops at the first new window for exactly that reason — nothing was \+ clicked, nothing was Escaped, and no further rung was tried. swx_state takes a \+ BACKGROUND screenshot (it steals no focus) so you can show the user what is on \+ screen. ANSWERING IT IS THEIR DECISION.";+ let title = &d.title;+ match d.kind {+ "save_changes" => format!(+ "SOLIDWORKS IS ASKING WHETHER TO SAVE: {title:?}. At a VERIFIED zero open documents \+ this dialog should be impossible, so its appearance means the document count this \+ bridge read was WRONG — a bug worth reporting, not a dialog to dismiss. The buttons \+ offer to write the user's work to This PC or to 3DEXPERIENCE, or to discard it. {common}"+ ),+ "license" => format!(+ "SOLIDWORKS IS ASKING ABOUT THE 3DEXPERIENCE SESSION OR LICENCE: {title:?}. A \+ sign-out affects the user's whole platform session, not just this application, and \+ this pass found no vendor documentation of a safe default — so there is no safe \+ choice for the bridge to make on their behalf. {common}"+ ),+ "document_recovery" => format!(+ "SOLIDWORKS IS SHOWING DOCUMENT RECOVERY: {title:?}. The last session ended badly and \+ there is unrecovered work behind that dialog. {common} swx_check_recovery describes it."+ ),+ _ => format!(+ "SOLIDWORKS RAISED A WINDOW THIS BRIDGE DOES NOT RECOGNISE: {title:?}. An \+ unanswerable question is not permission. {common}"+ ),+ }+}++/// The hint on a declined quit. It names what a HUMAN should click, and it must+/// never mention `swx_kill`.+pub fn declined_hint(rungs_tried: usize, max_rung: &str) -> String {+ format!(+ "SOLIDWORKS DID NOT QUIT, and nothing was forced. {rungs_tried} rung(s) up to '{max_rung}' \+ were tried and each one's measurement is in `rungsTried` — read `result` and \+ `processGone` per rung rather than the summary. WHAT A HUMAN SHOULD DO: ask the user to \+ click File ▸ Exit (or the window's X) in SOLIDWORKS themselves; that gesture works when \+ these do not, and it is theirs to make. If the ladder was pinned (`maxRung` defaults to \+ \"{}\" until the live quit gate has run), a higher rung may work — see \+ docs/QUIT-INVESTIGATION.md §5.3. Do NOT reach for a force-kill: on a Connected edition a \+ killed session leaves platform objects locked to the user, un-lockable except by hand, \+ and orphans the per-session children.",+ DEFAULT_MAX_RUNG+ )+}++#[cfg(test)]+mod tests {+ use super::*;++ fn id() -> Identity {+ Identity { pid: 107688, created_at: 1_788_899_741, main_hwnd: Some(6_622_700) }+ }++ fn proc(pid: u32, image: &str, created: Option<u64>) -> ProcInfo {+ ProcInfo { pid, image: image.into(), created_at: created, ppid: None }+ }++ fn win(hwnd: i64, class: &str, title: &str) -> WindowInfo {+ WindowInfo { hwnd, class_name: class.into(), title: title.into(), visible: true }+ }++ /// The signature that matters: everything measured, nothing waived.+ fn ok_preconditions(force: bool) -> Result<(), Refusal> {+ check_preconditions(+Some(0), Some(0), false, Some(0), None, true, Some(false), false, Some(true), Some(&id()),+ false, force,+ )+ }++ #[test]+ fn a_session_with_open_documents_is_never_quit_even_with_force() {+ // THE REGRESSION TEST FOR THE LIVE POSTURE. On 2026-09-08 five of Drew's+ // clean documents were open with unsavedDocuments:0, and v1 gated on the+ // UNSAVED count — so a bare swx_stop would have tried to quit them.+ for force in [false, true] {+ let r = check_preconditions(+Some(5), Some(5), false, Some(0), None, true, Some(false), false, Some(true), Some(&id()),+ false, force,+ );+ assert_eq!(+ r.as_ref().err().map(|e| e.code()),+ Some("open_documents_present"),+ "5 open documents must refuse (force={force}): {r:?}"+ );+ }+ }++ #[test]+ fn force_waives_the_unsaved_question_and_nothing_else() {+ // P2 only.+ assert!(check_preconditions(+Some(0), Some(0), false, Some(3), None, true, Some(false), false, Some(true), Some(&id()),+ false, true+ )+ .is_ok());+ assert_eq!(+ check_preconditions(+Some(0), Some(0), false, Some(3), None, true, Some(false), false, Some(true), Some(&id()),+ false, false+ )+ .err()+ .map(|e| e.code()),+ Some("unsaved_documents")+ );+ // ...and force waives NONE of the others.+ let dialog = BlockingDialog { hwnd: 9, title: "Save changes?".into(), kind: "save_changes" };+ let cases: Vec<(&str, Result<(), Refusal>)> = vec![+ ("open docs", check_preconditions(+Some(2), Some(2), false, Some(0), None, true, Some(false), false, Some(true), Some(&id()), false, true)),+ ("truncated", check_preconditions(+None, None, true, Some(0), None, true, Some(false), false, Some(true), Some(&id()), false, true)),+ ("dialog", check_preconditions(+Some(0), Some(0), false, Some(0), Some(&dialog), true, Some(false), false, Some(true), Some(&id()), false, true)),+ ("census unreadable", check_preconditions(+Some(0), Some(0), false, Some(0), None, false, Some(false), false, Some(true), Some(&id()), false, true)),+ ("busy", check_preconditions(+Some(0), Some(0), false, Some(0), None, true, Some(true), false, Some(true), Some(&id()), false, true)),+ ("host busy", check_preconditions(+Some(0), Some(0), false, Some(0), None, true, Some(false), true, Some(true), Some(&id()), false, true)),+ ("hung", check_preconditions(+Some(0), Some(0), false, Some(0), None, true, Some(false), false, Some(false), Some(&id()), false, true)),+ ("no identity", check_preconditions(+Some(0), Some(0), false, Some(0), None, true, Some(false), false, Some(true), None, false, true)),+ ("launch in flight", check_preconditions(+Some(0), Some(0), false, Some(0), None, true, Some(false), false, Some(true), Some(&id()), true, true)),+ ];+ for (what, r) in cases {+ assert!(r.is_err(), "force must not waive {what}");+ }+ }++ #[test]+ fn every_unknown_refuses_with_one_branchable_code() {+ // A caller's move is the same for all of them, and it is never "proceed".+ let unknowns: Vec<Result<(), Refusal>> = vec![+ check_preconditions(+None, None, false, Some(0), None, true, Some(false), false, Some(true), Some(&id()), false, false),+ check_preconditions(+None, None, true, Some(0), None, true, Some(false), false, Some(true), Some(&id()), false, false),+ check_preconditions(+Some(0), Some(0), false, None, None, true, Some(false), false, Some(true), Some(&id()), false, false),+ check_preconditions(+Some(0), Some(0), false, Some(0), None, false, Some(false), false, Some(true), Some(&id()), false, false),+ check_preconditions(+Some(0), Some(0), false, Some(0), None, true, None, false, Some(true), Some(&id()), false, false),+ check_preconditions(+Some(0), Some(0), false, Some(0), None, true, Some(false), false, None, Some(&id()), false, false),+ check_preconditions(+Some(0), Some(0), false, Some(0), None, true, Some(false), false, Some(true), None, false, false),+ ];+ for r in unknowns {+ assert_eq!(r.err().map(|e| e.code()), Some("stop_preconditions_unknown"));+ }+ assert!(ok_preconditions(false).is_ok(), "a fully-measured clean session may proceed");+ }++ #[test]+ fn a_save_prompt_gets_its_own_code_because_it_should_be_impossible() {+ let save = BlockingDialog { hwnd: 9, title: "Save changes to Part1?".into(), kind: "save_changes" };+ assert_eq!(save.error_code(), "stop_blocked_by_save_prompt");+ let other = BlockingDialog { hwnd: 9, title: "3DEXPERIENCE sign in".into(), kind: "license" };+ assert_eq!(other.error_code(), "stop_blocked_by_dialog");+ // ...and each stops the ladder from the precondition sweep.+ for d in [&save, &other] {+ let r = check_preconditions(+Some(0), Some(0), false, Some(0), Some(d), true, Some(false), false, Some(true), Some(&id()),+ false, true,+ );+ assert_eq!(r.err().map(|e| e.code()), Some(d.error_code()));+ }+ }++ #[test]+ fn the_dialog_census_finds_a_modal_and_ignores_the_frame() {+ let windows = vec![+ win(6_622_700, "Afx:00007FF6", "SOLIDWORKS Design"),+ win(9001, "#32770", "Save Modified Documents"),+ ];+ let d = first_blocking_dialog(&windows).expect("the modal is found");+ assert_eq!(d.hwnd, 9001);+ assert_eq!(d.kind, "save_changes");+ // The main frame alone is NOT a dialog.+ assert!(first_blocking_dialog(&windows[..1]).is_none());+ // An invisible dialog is not on screen.+ let mut hidden = win(9002, "#32770", "Save changes?");+ hidden.visible = false;+ assert!(first_blocking_dialog(&[hidden]).is_none());+ }++ #[test]+ fn process_gone_needs_the_creation_time_and_an_unreadable_census_is_unknown() {+ let alive = vec![proc(107688, "SLDWORKS.exe", Some(1_788_899_741))];+ assert_eq!(process_gone(Ok(&alive), &id()), Some(false));+ // Same pid, DIFFERENT creation time: our session is gone and something+ // else has the number.+ let reused = vec![proc(107688, "SLDWORKS.exe", Some(1_788_999_999))];+ assert_eq!(process_gone(Ok(&reused), &id()), Some(true));+ let empty: Vec<ProcInfo> = Vec::new();+ assert_eq!(process_gone(Ok(&empty), &id()), Some(true));+ // UNREADABLE: a rung that cannot be measured is a rung that failed.+ let err = "census failed".to_string();+ assert_eq!(process_gone(Err(&err), &id()), None);+ }++ #[test]+ fn the_expected_survivors_are_not_treated_as_a_failure() {+ // The pid is gone, the children are reaped, and the platform daemons are+ // still alive — which is a CLEAN quit, by design (§2.6).+ let census = vec![+ proc(6076, "3DEXPERIENCELauncher.exe", Some(1)),+ proc(90552, "3DEXPERIENCELauncherSysTray.exe", Some(1)),+ proc(700, "EdmServerV6.exe", Some(1)),+ proc(701, "ENOPLMCSAClient.exe", Some(1)),+ proc(9, "ViewServerV6.exe", Some(1)),+ ];+ assert_eq!(process_gone(Ok(&census), &id()), Some(true));+ let (children, survivors, unjudged) = reap_report(&census);+ assert!(children.is_empty(), "no per-session child survived: {children:?}");+ assert_eq!(survivors.len(), 4, "the daemons are alive and that is consent: {survivors:?}");+ assert_eq!(unjudged, vec!["ViewServerV6.exe"], "recorded, not judged");+ }++ #[test]+ fn the_ladder_order_is_fixed_and_pinned_at_a_by_default() {+ assert_eq!(RUNGS, &["a", "b", "c", "d", "e", "f"]);+ assert_eq!(DEFAULT_MAX_RUNG, "a", "shipped pinned until the live gate has run");+ assert_eq!(rungs_up_to("a"), vec!["a"]);+ assert_eq!(rungs_up_to("c"), vec!["a", "b", "c"]);+ assert_eq!(rungs_up_to("f").len(), 6);+ assert!(rungs_up_to("g").is_empty(), "an unknown max yields nothing to try");+ assert!(!is_rung("g"));+ // There is no rung (g), and no rung mentions a kill.+ for r in RUNGS {+ let a = rung_action(r).to_lowercase();+ for forbidden in ["kill", "terminate"] {+ assert!(!a.contains(forbidden), "rung {r} action mentions '{forbidden}': {a}");+ }+ // `force` may appear ONLY as the thing being omitted.+ if a.contains("force") {+ assert!(+ a.contains("omitted"),+ "rung {r} mentions force without saying it is omitted: {a}"+ );+ }+ assert!(rung_budget_secs(r) >= 30);+ }+ assert!(rung_action("f").contains("OMITTED"), "the Restart Manager rung says what it omits");+ }++ #[test]+ fn a_result_the_host_does_not_explain_is_unmeasured_not_declined() {+ assert_eq!(result_from_host("exited"), RungResult::Quit);+ assert_eq!(result_from_host("declined"), RungResult::Declined);+ assert_eq!(result_from_host("uipiBlocked"), RungResult::UipiBlocked);+ assert_eq!(result_from_host("dialogAppeared"), RungResult::DialogAppeared);+ assert_eq!(result_from_host("skipped"), RungResult::Skipped);+ assert_eq!(result_from_host("something new"), RungResult::Unmeasured);+ // Which of them stop the ladder.+ assert!(RungResult::Quit.stops_ladder());+ assert!(RungResult::DialogAppeared.stops_ladder());+ assert!(RungResult::UipiBlocked.stops_ladder());+ assert!(!RungResult::Declined.stops_ladder());+ assert!(!RungResult::Skipped.stops_ladder());+ assert!(!RungResult::Unmeasured.stops_ladder());+ }++ #[test]+ fn a_count_that_disagrees_with_the_enumeration_is_unknown_not_zero() {+ // GetDocumentCount and GetDocuments are two different COM reads. The+ // Python gate has always cross-checked them; the verb did not, so a+ // count of 0 beside a list of 1 was "nothing open, go ahead".+ let r = check_preconditions(+ Some(0), Some(1), false, Some(0), None, true, Some(false), false, Some(true),+ Some(&id()), false, true,+ );+ assert_eq!(r.as_ref().err().map(|e| e.code()), Some("stop_preconditions_unknown"), "{r:?}");+ assert!(r.unwrap_err().message().contains("DISAGREE"));+ // Agreement, and an absent enumeration, both proceed — above the cap the+ // truncated refusal has already fired.+ assert!(check_preconditions(+ Some(0), Some(0), false, Some(0), None, true, Some(false), false, Some(true),+ Some(&id()), false, true+ )+ .is_ok());+ assert!(check_preconditions(+ Some(0), None, false, Some(0), None, true, Some(false), false, Some(true),+ Some(&id()), false, true+ )+ .is_ok());+ }++ #[test]+ fn the_dialog_census_covers_the_cef_children_and_only_ours() {+ // A 3DEXPERIENCE sign-in window is a web view: its hwnd belongs to+ // swCefSubProc.exe, a CHILD of sldworks.exe. Censusing the SOLIDWORKS pid+ // alone proved "no dialog" while the platform held a modal.+ let ours = 107688u32;+ let census = vec![+ proc(ours, "SLDWORKS.exe", Some(1000)),+ ProcInfo { pid: 501, image: "swCefSubProc.exe".into(), created_at: Some(1001), ppid: Some(ours) },+ ProcInfo { pid: 502, image: "swCefSubProc.exe".into(), created_at: Some(1002), ppid: Some(ours) },+ // ANOTHER session's web view — never ours to judge or to send to.+ ProcInfo { pid: 777, image: "swCefSubProc.exe".into(), created_at: Some(1003), ppid: Some(999) },+ proc(600, "sldProcMon.exe", Some(1004)),+ ];+ let pids = dialog_census_pids(Ok(&census), &id()).expect("a readable census");+ assert_eq!(pids, vec![ours, 501, 502], "sldworks + its OWN cef children: {pids:?}");+ // An unreadable census is UNKNOWN, which refuses — never an empty list,+ // which would read as "no windows to worry about".+ let e = "census unreadable".to_string();+ assert!(dialog_census_pids(Err(&e), &id()).is_none());+ }++ /// A WRONG ATTRIBUTION RECORD MUST NOT BE ABLE TO END A DIFFERENT SESSION.+ /// The ladder builds its Identity from the COM pid; the launch record is a+ /// claim about one process. If they name different processes, the claim does+ /// not cover the act.+ #[test]+ fn the_launch_claim_must_be_about_the_session_com_answers_for() {+ let me = id();+ // No claim at all: nothing to compare, and the other preconditions stand.+ assert!(identity_matches_attribution(None, Some(&me)).is_ok());+ // The claim IS about this session.+ assert!(identity_matches_attribution(Some((me.pid, me.created_at)), Some(&me)).is_ok());+ // A different pid, or the same pid with a different creation time: refuse.+ for claim in [(me.pid + 1, me.created_at), (me.pid, me.created_at + 1)] {+ let r = identity_matches_attribution(Some(claim), Some(&me));+ assert_eq!(+ r.as_ref().err().map(|e| e.code()),+ Some("identity_mismatch"),+ "a claim about {claim:?} must not authorise acting on {me:?}"+ );+ }+ }++ #[test]+ fn a_recycled_window_handle_is_never_sent_to() {+ let me = id();+ // The same window: pid AND creation time agree.+ assert!(hwnd_still_owned(Some(me.pid), Some(me.created_at), &me));+ // The pid was REUSED: same number, different process.+ assert!(!hwnd_still_owned(Some(me.pid), Some(me.created_at + 5), &me));+ // Somebody else's window on our handle.+ assert!(!hwnd_still_owned(Some(me.pid + 1), Some(me.created_at), &me));+ // Either fact unreadable is a NO. Rungs (c)/(d) skip rather than send.+ assert!(!hwnd_still_owned(None, Some(me.created_at), &me));+ assert!(!hwnd_still_owned(Some(me.pid), None, &me));+ assert!(!hwnd_still_owned(None, None, &me));+ }++ #[test]+ fn the_declined_hint_never_recommends_a_kill() {+ let h = declined_hint(3, "c").to_lowercase();+ assert!(h.contains("file ▸ exit") || h.contains("file "), "it names the human gesture: {h}");+ assert!(!h.contains("swx_kill"), "never recommends the kill verb: {h}");+ assert!(h.contains("do not reach for a force-kill"));+ }+}++/// The shipped exit macro, if it is there.+///+/// Rung (e) runs `RunMacro2` on a two-line VBA project+/// (`Sub main() : Application.SldWorks.ExitApp : End Sub`) — the only rung whose+/// request comes from INSIDE the process being asked to quit, where no external+/// client's reference count applies. It is deliberately not generated at+/// runtime: it is a reviewed file, matched by content hash before use.+///+/// A `.swp` is a binary VBA project that cannot be authored without SOLIDWORKS,+/// so this repository does not ship one yet and the rung reports `skipped` until+/// it does. That is the honest state: a rung that cannot run says so, rather+/// than being quietly absent from the ladder.+pub const EXIT_MACRO_NAME: &str = "swx-exit.swp";+/// SHA-256 of the reviewed macro. `None` until the file is generated and+/// reviewed on a machine with SOLIDWORKS; the rung refuses to run an unhashed+/// or mismatched file.+pub const EXIT_MACRO_SHA256: Option<&str> = None;++pub fn exit_macro_path() -> Option<String> {+ let exe = std::env::current_exe().ok()?;+ let p = exe.parent()?.join("resources").join("macros").join(EXIT_MACRO_NAME);+ if !p.is_file() {+ return None;+ }+ // A hash we have not recorded yet cannot be matched, so the rung stays+ // unavailable rather than running an unreviewed macro in somebody's session.+ EXIT_MACRO_SHA256?;+ Some(p.to_string_lossy().to_string())+}++#[cfg(windows)]+pub mod win {+ //! The window-message and Restart-Manager rungs.+ //!+ //! These live in the com host rather than being delegated to ab, for one+ //! reason: ab's close is a UIA `WindowPattern.Close`, which is the mechanism+ //! that took the whole application down on 2026-09-02, and it is unavailable+ //! whenever ab cannot be reached (`abAvailable:false` on the dev bridge).+ //! `SendMessageTimeout` / `PostMessage` are twenty lines here, synchronous,+ //! timeout-bounded and measurable.+ use windows::Win32::Foundation::{GetLastError, SetLastError, HWND, LPARAM, WPARAM, ERROR_ACCESS_DENIED, WIN32_ERROR};+ use windows::Win32::UI::WindowsAndMessaging::{+ GetWindowThreadProcessId, PostMessageW, SendMessageTimeoutW, SMTO_ABORTIFHUNG, SMTO_NORMAL,+ WM_CLOSE, WM_SYSCOMMAND,+ };++ /// WHOSE WINDOW IS THIS, RIGHT NOW? `None` when the handle names no window+ /// any more.+ ///+ /// Handles are recycled. The hwnd a message rung sends to was captured at+ /// precondition time, and the rung before it exists to make that window go+ /// away — so between capture and send the handle can belong to somebody+ /// else's window, and `WM_CLOSE` to somebody else's window is a close nobody+ /// asked for.+ pub fn window_pid(h: i64) -> Option<u32> {+ let mut pid: u32 = 0;+ let tid = unsafe { GetWindowThreadProcessId(hwnd(h), Some(&mut pid)) };+ if tid == 0 || pid == 0 {+ None+ } else {+ Some(pid)+ }+ }++ /// `SC_CLOSE` — the system-menu Close.+ const SC_CLOSE: usize = 0xF060;++ fn hwnd(h: i64) -> HWND {+ HWND(h as *mut std::ffi::c_void)+ }++ /// UIPI FAILURES RETURN SUCCESS. `SendMessage`/`PostMessage` to a+ /// higher-integrity window fail with `ERROR_ACCESS_DENIED` and a return value+ /// indistinguishable from success, so `SetLastError(0)` first and read+ /// `GetLastError()` after — an unchecked one reports a quit that never+ /// happened (docs/QUIT-INVESTIGATION.md §2.3).+ fn blocked_by_uipi() -> bool {+ let last = unsafe { GetLastError() };+ last == ERROR_ACCESS_DENIED+ }++ /// Rung (c): what clicking X does, and on MFC literally what File ▸ Exit+ /// does. Timeout-bounded so a modal cannot wedge the com host.+ pub fn send_wm_close(h: i64) -> (bool, bool, String) {+ unsafe { SetLastError(WIN32_ERROR(0)) };+ let mut out: usize = 0;+ let r = unsafe {+ SendMessageTimeoutW(+ hwnd(h),+ WM_CLOSE,+ WPARAM(0),+ LPARAM(0),+ SMTO_ABORTIFHUNG | SMTO_NORMAL,+ 5_000,+ Some(&mut out as *mut usize),+ )+ };+ let blocked = blocked_by_uipi();+ let ok = r.0 != 0;+ (+ ok && !blocked,+ blocked,+ format!(+ "SendMessageTimeout(WM_CLOSE) -> hwnd {h}: returned {}, result {out}{}",+ r.0,+ if blocked { ", ERROR_ACCESS_DENIED (UIPI)" } else { "" }+ ),+ )+ }++ /// Rung (d): the system-menu Close. Posted, so it cannot block, and it+ /// enters through `OnSysCommand` before `DefWindowProc` turns it into a+ /// `WM_CLOSE` — an app that filters one may honour the other.+ pub fn post_sc_close(h: i64) -> (bool, bool, String) {+ unsafe { SetLastError(WIN32_ERROR(0)) };+ let posted =+ unsafe { PostMessageW(Some(hwnd(h)), WM_SYSCOMMAND, WPARAM(SC_CLOSE), LPARAM(0)) };+ let blocked = blocked_by_uipi();+ let ok = posted.is_ok();+ (+ ok && !blocked,+ blocked,+ format!(+ "PostMessage(WM_SYSCOMMAND, SC_CLOSE) -> hwnd {h}: {}{}",+ if ok { "posted" } else { "refused" },+ if blocked { ", ERROR_ACCESS_DENIED (UIPI)" } else { "" }+ ),+ )+ }++ /// Rung (f): the OS's own POLITE shutdown, and the only route that takes the+ /// pid **and its creation time** — the same identity pair the attribution law+ /// demands.+ ///+ /// **`RmForceShutdown` IS NOT REACHABLE FROM THIS CODE.** The flags argument+ /// is a literal `0`, not a parameter and not a runtime option: with the force+ /// flag omitted `RmShutdown` never terminates anything, and a refusal comes+ /// back as `ERROR_FAIL_SHUTDOWN (351)` with a readable per-process status.+ /// It sends `WM_QUERYENDSESSION(ENDSESSION_CLOSEAPP)` — which means "save+ /// automatically without prompting" — which is why this rung is LAST.+ pub fn restart_manager_shutdown(pid: u32, created_at_epoch: u64) -> (bool, String) {+ use windows::core::PWSTR;+ use windows::Win32::Foundation::{FILETIME, ERROR_SUCCESS};+ use windows::Win32::System::RestartManager::{+ RmEndSession, RmRegisterResources, RmShutdown, RmStartSession, RM_UNIQUE_PROCESS,+ CCH_RM_SESSION_KEY,+ };++ let mut session: u32 = 0;+ // CCH_RM_SESSION_KEY wide chars plus the terminator.+ let mut key = vec![0u16; CCH_RM_SESSION_KEY as usize + 1];+ let started =+ unsafe { RmStartSession(&mut session, None, PWSTR(key.as_mut_ptr())) };+ if started != ERROR_SUCCESS {+ return (false, format!("RmStartSession failed: {}", started.0));+ }+ // epoch seconds -> FILETIME (100ns ticks since 1601-01-01).+ let ticks = (created_at_epoch + 11_644_473_600) * 10_000_000;+ let ft = FILETIME {+ dwLowDateTime: (ticks & 0xFFFF_FFFF) as u32,+ dwHighDateTime: (ticks >> 32) as u32,+ };+ let target = [RM_UNIQUE_PROCESS { dwProcessId: pid, ProcessStartTime: ft }];+ let reg = unsafe { RmRegisterResources(session, None, Some(&target), None) };+ if reg != ERROR_SUCCESS {+ unsafe {+ let _ = RmEndSession(session);+ }+ return (false, format!("RmRegisterResources failed: {}", reg.0));+ }+ // ═══ FLAGS = 0. `RmForceShutdown` IS NOT WRITTEN ANYWHERE IN THIS FILE.+ // Not a parameter, not a runtime option, not a named constant that could+ // be passed by mistake — a literal zero. With the force flag omitted+ // RmShutdown NEVER terminates anything: it sends+ // WM_QUERYENDSESSION(ENDSESSION_CLOSEAPP) then WM_ENDSESSION (and a+ // WM_CLOSE to GUI apps that ignore those), and a refusal comes back as+ // ERROR_FAIL_SHUTDOWN (351) rather than a dead process.+ let shutdown = unsafe { RmShutdown(session, 0, None) };+ unsafe {+ let _ = RmEndSession(session);+ }+ let ok = shutdown == ERROR_SUCCESS;+ let note = if ok {+ format!("RmShutdown(pid {pid}, force OMITTED) returned success")+ } else {+ format!(+ "RmShutdown(pid {pid}, force OMITTED) refused: Win32 error {} \+ (351 = ERROR_FAIL_SHUTDOWN, meaning the app declined the OS's own polite request; \+ nothing was terminated)",+ shutdown.0+ )+ };+ (ok, note)+ }+}
src/scratch.rs+54−2@@ -32,6 +32,47 @@ pub fn root() -> PathBuf { } /// Where generated files go. Created on demand.+/// Where BRIDGE-CREATED DOCUMENTS are given a file of their own.+///+/// Under `exports/`, so the uninstall wipe already removes it and nothing new+/// has to be remembered about where the bridge writes.+/// A per-process counter for scratch document names, seeded from the clock so+/// two bridge runs in one session cannot collide.+/// Is this path a file the BRIDGE gave one of its own documents, rather than a+/// save the USER asked for?+///+/// The distinction is load-bearing from 0.6.0, because every bridge-created+/// document now has a file. `swx_reset_workspace` refuses to close a scratch+/// document that "has been saved to disk" without `force` — a guard that exists+/// to protect a document the user deliberately put somewhere. A file the bridge+/// wrote into its OWN cache, unasked, is not that; treating it as one would have+/// made the sweep refuse to clean up anything it had ever made.+pub fn is_bridge_scratch_path(path: &str) -> bool {+ let want = scratch_docs_dir().to_string_lossy().replace('/', "\\").to_ascii_lowercase();+ if want.is_empty() {+ return false;+ }+ let got = path.replace('/', "\\").to_ascii_lowercase();+ got.starts_with(&format!("{}\\", want.trim_end_matches('\\')))+}++pub fn next_scratch_seq() -> u64 {+ use std::sync::atomic::{AtomicU64, Ordering};+ static SEQ: AtomicU64 = AtomicU64::new(0);+ let n = SEQ.fetch_add(1, Ordering::SeqCst);+ let base = std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_secs())+ .unwrap_or(0);+ base.wrapping_mul(1000).wrapping_add(n)+}++pub fn scratch_docs_dir() -> PathBuf {+ let d = exports_dir().join("scratch");+ let _ = std::fs::create_dir_all(&d);+ d+}+ pub fn exports_dir() -> PathBuf { let d = root().join("exports"); let _ = std::fs::create_dir_all(&d);@@ -63,7 +104,18 @@ pub fn remove_artifacts_under(root: &Path) -> (Vec<String>, Vec<String>) { // erase the record of what it did by asking the bridge to tidy up. The // audit log is the one artifact that outlives the bridge; a human removes it // by deleting the folder, which is theirs to do.- for name in ["timings.json", "settings.json", "com-host-created.json", "mock-solidworks-session"] {+ for name in [+ "timings.json",+ "settings.json",+ "com-host-created.json",+ // The SERVER's half of the ownership record (bug #5) and the launch+ // machine's persisted attempt. Both are the bridge's own bookkeeping;+ // neither is an audit log, so both go on uninstall.+ "owned-docs.json",+ "launch-state.json",+ "mock-solidworks-session",+ "mock-solidworks-open-docs.json",+ ] { let f = root.join(name); if f.exists() { match std::fs::remove_file(&f) {@@ -267,5 +319,5 @@ pub fn set_setting_bool(key: &str, value: bool) { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }- let _ = std::fs::write(path, serde_json::Value::Object(s).to_string());+ let _ = crate::launch::write_atomic(&path, &serde_json::Value::Object(s).to_string()); }
src/verbs.rs+1047−185@@ -97,6 +97,34 @@ pub fn same_path(a: &str, b: &str) -> bool { !a.is_empty() && !b.is_empty() && norm(a) == norm(b) } +/// IS THIS OPEN DOCUMENT THE ONE WE RECORDED?+///+/// The single comparison both the ownership check and the prune must use.+/// They used different ones: `owns_doc` compared paths with [`same_path`]+/// (case- and separator-insensitive, which is what Windows means by "the same+/// file"), while `prune_scratch` compared them BYTE-EXACT. SOLIDWORKS hands the+/// same file back in different casings from different APIs, so a document could+/// pass the ownership test and still be pruned out of the registry that grants+/// it — and the next write to it was refused as "this bridge did not create+/// it". Bug #5's user-visible symptom, from a different cause.+///+/// A document with a PATH is identified by the path. One that has never been+/// saved is identified by title, and only against another unsaved document —+/// an entry we have since saved refers to a file, not to whatever now carries+/// its old name.+pub fn doc_matches(+ stored_title: &str,+ stored_path: Option<&str>,+ open_title: &str,+ open_path: Option<&str>,+) -> bool {+ match (stored_path, open_path) {+ (Some(a), Some(b)) => same_path(a, b),+ (None, None) => !stored_title.is_empty() && stored_title == open_title,+ _ => false,+ }+}+ /// One document this bridge created. See [`Ctx::scratch_docs`]. #[derive(Clone, Debug, PartialEq)] pub struct ScratchDoc {@@ -146,37 +174,91 @@ impl Target { impl Ctx { pub fn new(detection: Detection) -> Self {+ // BUG #5. The registry is loaded from disk, not started empty: a bridge+ // restart used to disown every document the bridge had created, and on+ // 2026-09-03 that made 23 of its own crate documents un-closable and+ // un-savable. Entries whose SOLIDWORKS process id has since been reused+ // by a different process are dropped on the way in (see+ // `crate::owned::reconcile`) — this can only ever REMOVE a claim.+ let restored: Vec<ScratchDoc> = crate::owned::restore(); Self { com: Arc::new(ComHost::new()), jobs: Jobs::new(), detection: RwLock::new(detection),- scratch_docs: std::sync::Mutex::new(Vec::new()),+ scratch_docs: std::sync::Mutex::new(restored), } } + /// Write the registry through to disk. Called after every change, so the+ /// file is the truth a restart (or a second look after a com-host respawn)+ /// reads back.+ fn persist_scratch(&self) {+ let docs = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner()).clone();+ crate::owned::save(&docs, crate::owned::session_started_at);+ }+ /// Remember a document this bridge created, so the sweep may close it. pub(crate) fn remember_scratch(&self, title: &str, session: Option<i64>) { if title.is_empty() { return; }- let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());- if !v.iter().any(|d| d.title == title && d.session == session) {- v.push(ScratchDoc { title: title.to_string(), session, path: None });+ {+ let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());+ if !v.iter().any(|d| d.title == title && d.session == session) {+ v.push(ScratchDoc { title: title.to_string(), session, path: None });+ }+ }+ self.persist_scratch();+ }++ /// Remember a document the bridge created that ALREADY HAS A PATH — an+ /// import, whose `GetPathName` is its `…\Temp\swx<pid>\IC~~\…` conversion+ /// temp even though it was never saved anywhere real.+ ///+ /// A NEW entry, never a mutation of an existing one. `rename_scratch` finds+ /// its target BY TITLE, so using it here would reassign the recorded path of+ /// a *different* owned document that happens to share the imported title —+ /// SOLIDWORKS titles an imported STEP `<stem>.SLDPRT`, so importing beside a+ /// saved part of the same name is the ordinary case, and that would have+ /// disowned the user-facing document (the collision `resolve` already warns+ /// about, arriving from the ownership side instead).+ pub(crate) fn remember_scratch_at(&self, title: &str, session: Option<i64>, path: &str) {+ if title.is_empty() || path.is_empty() {+ return;+ }+ {+ let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());+ let already = v.iter().any(|d| {+ d.session == session+ && d.path.as_deref().map(|p| same_path(p, path)).unwrap_or(false)+ });+ if !already {+ v.push(ScratchDoc {+ title: title.to_string(),+ session,+ path: Some(path.to_string()),+ });+ } }+ self.persist_scratch(); } pub(crate) fn forget_scratch(&self, title: &str) { self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner()).retain(|d| d.title != title);+ self.persist_scratch(); } /// A scratch document that has been saved keeps its identity under its new /// title, so `save_as` does not silently disown it. pub(crate) fn rename_scratch(&self, from: &str, to: &str, path: Option<String>) {- let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());- if let Some(d) = v.iter_mut().find(|d| d.title == from) {- d.title = to.to_string();- d.path = path;+ {+ let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());+ if let Some(d) = v.iter_mut().find(|d| d.title == from) {+ d.title = to.to_string();+ d.path = path;+ } }+ self.persist_scratch(); } /// Drop every entry that cannot still be ours: a different SOLIDWORKS@@ -187,15 +269,27 @@ impl Ctx { /// for each open document — matched on PATH where the entry has one, /// because a title alone cannot tell two documents apart. pub(crate) fn prune_scratch(&self, session: Option<i64>, open: &[(String, Option<String>)]) {- let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());- v.retain(|d| {- d.session.is_some()- && d.session == session- && match &d.path {- Some(p) => open.iter().any(|(_, op)| op.as_deref() == Some(p.as_str())),- None => open.iter().any(|(t, op)| op.is_none() && *t == d.title),- }- });+ // BUG #5, THE OTHER HALF. `session` is None exactly when SOLIDWORKS+ // could not be asked — a com-host respawn mid-call, a timeout, a wedged+ // app. The old code pruned anyway, and since EVERY entry fails the+ // `d.session == None` test, one unanswerable probe permanently disowned+ // every document the bridge had created. An unreadable session is+ // "I do not know", and I-do-not-know is not grounds to forget.+ if session.is_none() {+ return;+ }+ {+ let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());+ v.retain(|d| {+ d.session.is_some()+ && d.session == session+ // ONE comparison, shared with `owns_doc`. See `doc_matches`.+ && open.iter().any(|(t, op)| {+ doc_matches(&d.title, d.path.as_deref(), t, op.as_deref())+ })+ });+ }+ self.persist_scratch(); } /// (title, path) for every document currently believed to be ours — the@@ -232,20 +326,9 @@ impl Ctx { return false; } let docs = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());- match path {- Some(p) if !p.is_empty() => docs- .iter()- .any(|d| d.session == session && d.path.as_deref().map(|q| same_path(q, p)).unwrap_or(false)),- _ => {- if title.is_empty() {- return false;- }- // Unsaved: title + session, and ONLY against entries that are- // themselves still unsaved. An entry we have since saved refers- // to a file, not to whatever now carries its old name.- docs.iter().any(|d| d.session == session && d.path.is_none() && d.title == title)- }- }+ let path = path.filter(|p| !p.is_empty());+ docs.iter()+ .any(|d| d.session == session && doc_matches(&d.title, d.path.as_deref(), title, path)) } pub fn detection(&self) -> Detection {@@ -407,6 +490,12 @@ fn swx_status(ctx: &Ctx, _args: &Value) -> Value { // which a 580s Pack and Go could be holding. Read a non-blocking snapshot // first; only query the host for live COM state when it is actually idle. let snap = ctx.com.snapshot();+ // `com_timed_out` MUST mean "a COM call exceeded its budget". It used to be+ // `error present OR the host is busy` — and the host is busy exactly when+ // THIS BRIDGE is running a long verb, which is exactly when SOLIDWORKS+ // stops pumping its message loop. Two "independent" signals from one cause,+ // and the user's working session reported as hung. Busy is now UNKNOWN.+ let mut com_timed_out: Option<bool> = None; let com = if snap.busy { json!({ "busy": true,@@ -417,6 +506,7 @@ fn swx_status(ctx: &Ctx, _args: &Value) -> Value { } else { match ctx.call("status", json!({}), 20) { Ok(mut v) => {+ com_timed_out = Some(false); if let Value::Object(map) = &mut v { // Post-call: the status query spawns the child if needed, so // read the pid AFTER, not from the pre-call snapshot.@@ -425,7 +515,21 @@ fn swx_status(ctx: &Ctx, _args: &Value) -> Value { } v }- Err(e) => json!({"error": e.message, "hostPid": ctx.com.pid()}),+ Err(e) => {+ // Only a BUDGET OVERRUN is a timeout, and the com host says so+ // itself rather than this line re-deciding it from the wording+ // of a message. An UNRECOGNISED failure is UNKNOWN, not+ // "did not time out": a crashed child tells us nothing either+ // way about whether SOLIDWORKS is wedged.+ com_timed_out = if e.timed_out { Some(true) } else { None };+ json!({+ "error": e.message,+ "hostPid": ctx.com.pid(),+ "timedOut": e.timed_out,+ "note": "a com-host failure that is not a timeout says nothing about whether \+ SOLIDWORKS itself is responding",+ })+ } } }; let hint = if !detection.installed && !crate::is_mock() {@@ -433,20 +537,48 @@ fn swx_status(ctx: &Ctx, _args: &Value) -> Value { } else { "swx_docs lists open documents; swx_open opens one (background). swx_bom / \ swx_mass_properties / swx_interference are the analysis verbs; swx_find_files searches \- disk without needing SOLIDWORKS open."+ disk without needing SOLIDWORKS open. `launch` reports any launch in flight (stage, \+ route, routes already tried); `solidworks.responding` / `.privateMemoryMB` / `.handles` \+ are read WITHOUT COM, so they still answer when SOLIDWORKS is wedged." .to_string() };+ // THE HALF COM CANNOT ANSWER. `responding`, memory and handles are read+ // from the OS — which is the whole point, because the moment those numbers+ // matter is the moment COM has stopped replying. A com `status` that timed+ // out is the COM half of the hang test; IsHungAppWindow is the other half.+ let os = crate::launchos::os();+ let open_docs = com.get("openDocs").and_then(|v| v.as_u64());+ let com_pid = com.get("processId").and_then(|v| v.as_u64()).map(|p| p as u32);+ // THE SOLIDWORKS WE ARE ATTACHED TO, for the lineage walk: with more than one+ // sldworks.exe on the machine (a zombie from a crash, or the user's own+ // session beside ours) the one a caller MEANS is the one COM answers for.+ crate::launch::note_attached_solidworks(com_pid);+ let health = crate::launch::host_app_health(os.as_ref(), open_docs, com_timed_out, com_pid);+ let mut solidworks = detection.to_json();+ if let (Value::Object(map), Value::Object(h)) = (&mut solidworks, &health) {+ for (key, v) in h {+ map.insert(key.clone(), v.clone());+ }+ } ok(json!({ "bridgeVersion": crate::BRIDGE_VERSION, "mock": crate::is_mock(), "userWrites": crate::writes::posture(),+ // TOLD WHETHER COM IS ATTACHED, because that is the fact that decides+ // whether a session which came up after a sign-in may be re-claimed as+ // ours — and only this verb has it.+ "launch": crate::launch::report_with_attach(+ os.as_ref(),+ com.get("running").and_then(|v| v.as_bool()).unwrap_or(false)+ || com.get("attached").and_then(|v| v.as_bool()).unwrap_or(false),+ ), // Whether the ab-delegated verbs (window info, dialogs, screenshot, // taskbar badge, kill) can work at all. A bridge started by hand rather // than spawned by Adom Bridge never inherits ADOM_DIRECT_API_URL, and // every one of those verbs then fails for a reason that has nothing to // do with SOLIDWORKS. "abAvailable": crate::caller::ab_available(),- "solidworks": detection.to_json(),+ "solidworks": solidworks, "com": com, "windowEtiquette": "background", "_hint": hint,@@ -496,17 +628,30 @@ fn swx_state(ctx: &Ctx, args: &Value) -> Value { // through ab's generic verbs and neither is allowed to fail the verb. let want_shot = arg_bool(args, "screenshot", true); let hwnd_i64 = main_hwnd.as_i64();+ let mut windows_error: Option<String> = None; let (screenshot, dialogs) = if want_shot && (detection.running || crate::is_mock()) {- let windows = crate::abwin::list_windows(Duration::from_secs(20)).unwrap_or_default();+ // AN UNREACHABLE ab IS NOT "NO DIALOGS". `unwrap_or_default()` turned a+ // failed window enumeration into an empty census, and `blockingDialogs:+ // []` is what a caller reads as "nothing is in the way" right before it+ // drives a SOLIDWORKS that is sitting on a modal.+ let windows = match crate::abwin::list_windows(Duration::from_secs(20)) {+ Ok(w) => Some(w),+ Err(e) => {+ windows_error = Some(e);+ None+ }+ }; let sw_pid = ctx .call("app_state", json!({}), 20) .ok() .and_then(|s| s.get("processId").and_then(|p| p.as_i64()));- let census = crate::abwin::dialog_census(&windows, hwnd_i64, sw_pid);+ let census = windows+ .as_ref()+ .map(|w| crate::abwin::dialog_census(w, hwnd_i64, sw_pid)); let shot = hwnd_i64.map(|h| crate::abwin::screenshot(h, Duration::from_secs(45))); (shot.unwrap_or(Value::Null), census) } else {- (Value::Null, Vec::new())+ (Value::Null, Some(Vec::new())) }; ok(json!({ "running": detection.running,@@ -516,7 +661,10 @@ fn swx_state(ctx: &Ctx, args: &Value) -> Value { "activeDocument": active.get("title").cloned().unwrap_or(Value::Null), "mainHwnd": main_hwnd, "busy": busy,- "blockingDialogs": dialogs,+ // `null` when the desktop could not be enumerated — never `[]`.+ "blockingDialogs": dialogs.clone().map(Value::from).unwrap_or(Value::Null),+ "blockingDialogsKnown": dialogs.is_some(),+ "blockingDialogsError": windows_error, "screenshot": screenshot, "timings": crate::ledger::stages(), "focusEvents": [],@@ -547,10 +695,16 @@ fn swx_start(ctx: &Ctx, args: &Value) -> Value { // ATTACH FIRST. If SOLIDWORKS is already up, this is the whole verb — and on // SOLIDWORKS Connected it is the ONLY thing that can work. if let Ok(r) = ctx.call("start", json!({"visible": arg_bool(args, "visible", true), "launch": false}), 60) {+ // Whatever brought SOLIDWORKS up — our own supervisor, or the user+ // clicking their Start Menu while we watched — the attempt is over and+ // the route that was in flight is the one that worked.+ crate::launch::mark_attached(); return ok_merge( r, json!({ "wasLaunched": false,+ "attached": true,+ "stillLaunching": false, "edition": detection.edition(), "windowEtiquette": "background", "_hint": "Already running and COM-attached — nothing was launched. swx_open or \@@ -634,13 +788,42 @@ fn swx_start(ctx: &Ctx, args: &Value) -> Value { /// gated — is deliberate and it is the honest one: starting SOLIDWORKS is undone /// by closing it, whereas stopping it was, until this route existed, not undoable /// by this bridge at all.+///+/// THIS VERB NO LONGER INVOKES THE LAUNCHER ITSELF. It hands the work to+/// [`crate::launch`], which invokes ONE route, watches the launcher's own log+/// for `Run Begin`, cleans up after a stall and tries the next route — and, most+/// importantly, is the single place a launch can start from. Four launcher+/// chains accumulated on Drew's desktop on 2026-09-04 because this verb's own+/// hint promised idempotence ("call swx_start again, which ATTACHES rather than+/// launching a second copy") that the code did not implement. It does now, and+/// the promise is kept by a persisted attempt rather than by a sentence. fn start_via_shortcut( ctx: &Ctx, args: &Value, detection: &crate::detect::Detection, shortcut: &str, ) -> Value {- if arg_str(args, "reason").is_none() {+ let os = crate::launchos::os();++ // ── 1. IS ONE ALREADY IN FLIGHT? ───────────────────────────────────────+ // Before anything else, including the reason gate: a caller polling with+ // `swx_start` (which this bridge's own hint tells them to do) must never be+ // refused for a missing reason and must never start a second launcher.+ if let Some(a) = crate::launch::in_flight(os.as_ref()) {+ return still_launching(&a, os.as_ref(), detection, "an attempt this bridge started is still running");+ }++ // ── 2. IS SOLIDWORKS ALREADY UP BUT UNREACHABLE? ───────────────────────+ // We got here because the attach in `swx_start` failed. If sldworks.exe is+ // nevertheless running, LAUNCHING AGAIN IS THE WRONG MOVE — it would put a+ // second SOLIDWORKS on the user's desktop. Observed live on 2026-09-03: the+ // process was alive at 17.4 GB of private memory while COM attach failed.+ if crate::launch::sldworks_running(os.as_ref()) == Some(true) {+ return running_but_unreachable(ctx, os.as_ref(), detection);+ }++ // ── 3. THE REASON GATE ─────────────────────────────────────────────────+ let Some(reason) = arg_str(args, "reason") else { return err_code( "reason_required", "swx_start needs reason:\"why\" to launch SOLIDWORKS on the user's desktop",@@ -650,111 +833,489 @@ fn start_via_shortcut( you are about to do with SOLIDWORKS, in one line. Nothing else is required — \ starting the application creates nothing and destroys nothing.", );- }- // A COLD CONNECTED START RUNS ONE TO THREE MINUTES. The request cannot be- // held for that: the com host is the server's only channel to SOLIDWORKS, so- // a long blocking call freezes swx_progress and every other verb along with- // it. So this verb invokes the launcher, polls for a WHILE, and then answers- // honestly that the launch is still in flight — the same shape fusion_start- // uses, and the reason `stillLaunching` exists.+ };++ // ── 4. BEGIN. Exactly one attempt can win this. ────────────────────────+ let caller = crate::caller::current().map(|c| c.thread).unwrap_or_default(); let job = ctx.jobs.start( "app_start", "Starting SOLIDWORKS via the 3DEXPERIENCE launcher (cold start runs 1-3 minutes)", 180, ); let started = std::time::Instant::now();- let invoked = match ctx.call("launch_shortcut", json!({"shortcut": shortcut}), 60) {- Err(e) => return sidecar_error(e),- Ok(r) => r,+ let channel = crate::launch::Channel { com: std::sync::Arc::clone(&ctx.com) };+ let attempt = match crate::launch::begin(channel, shortcut, reason, &caller) {+ Ok(a) => a,+ // Lost the race with a simultaneous swx_start. That call owns the+ // launch; this one reports it and launches nothing.+ Err(crate::launch::BeginRefusal::InFlight(a)) => {+ return still_launching(&a, os.as_ref(), detection, "another call started this launch first")+ }+ // BACKOFF. The last attempt failed, and a retry that immediately swept+ // all four routes again is how four launcher chains ended up on+ // somebody's desktop. Say so, and say for how long.+ Err(crate::launch::BeginRefusal::Backoff { seconds, attempt }) => {+ let mut out = err_code(+ "host_app_launch_backoff",+ format!(+ "the last launch attempt failed; this bridge waits {seconds}s before trying \+ again rather than starting more launcher chains"+ ),+ "A retry that re-sweeps every route starts a fresh CATSTART/SWXDesktopLauncher \+ chain per route, and that is how four of them accumulated on a user's desktop. \+ The wait grows with each consecutive failure, and the next attempt tries ONE \+ route rather than the whole plan. Meanwhile the honest move is the human one: \+ ASK THE USER to start SOLIDWORKS from their Start Menu — an interactive launch \+ works when these do not. swx_status.launch shows what has been tried.",+ );+ if let Value::Object(map) = &mut out {+ map.insert("backoffSeconds".into(), json!(seconds));+ map.insert("launch".into(), crate::launch::report(os.as_ref()));+ if let Some(a) = attempt {+ map.insert("lastOutcome".into(), json!(a.outcome));+ map.insert("lastError".into(), json!(a.error));+ }+ }+ return out;+ } };- crate::ledger::record(- "solidworks_launched",- json!({- "edition": detection.edition(),- "route": "platformShortcut",- "shortcut": shortcut,- "reason": arg_str(args, "reason"),- }),- );- let mut attached = invoked.get("attached").and_then(|v| v.as_bool()).unwrap_or(false);- let mut version = invoked.get("version").cloned().unwrap_or(Value::Null);- // Poll for a BOUNDED window, then hand the wait back to the caller. Every- // probe is an attach attempt (`launch:false`) and never a second launch:- // firing the launcher twice is how a user ends up with two sign-in windows.- //- // The default is deliberately SHORT — shorter than any HTTP client's read- // budget, because a verb that outlives the request that called it just looks- // like a hang, and the first version of this waited 45 seconds and did- // exactly that to its own test suite. A caller who has a longer budget and- // wants to block can say so; nobody has to guess.++ // ── 5. WAIT, BOUNDED. ──────────────────────────────────────────────────+ // The supervisor keeps running after this verb returns, so the budget here+ // is a COURTESY, not the launch's lifetime. It stays short by default:+ // a verb that outlives the HTTP request that called it just looks like a+ // hang. `waitSeconds` lets a caller with a longer budget block instead. let budget = std::time::Duration::from_secs( args.get("waitSeconds").and_then(|v| v.as_u64()).unwrap_or(20).min(240), );- while !attached && started.elapsed() < budget {- std::thread::sleep(std::time::Duration::from_secs(2));- if let Ok(r) = ctx.call("start", json!({"visible": arg_bool(args, "visible", true), "launch": false}), 30) {- attached = true;- version = r.get("version").cloned().unwrap_or(Value::Null);+ let mut current = attempt;+ while started.elapsed() < budget {+ std::thread::sleep(std::time::Duration::from_millis(400));+ current = crate::launch::attempt().unwrap_or(current);+ if current.finished || current.stage == crate::launch::Stage::Attached {+ break; } }+ let attached = current.stage == crate::launch::Stage::Attached; if attached { crate::ledger::record_time("swx_start", started.elapsed().as_secs_f64());+ crate::launch::mark_attached();+ }+ let version = if attached {+ ctx.call("start", json!({"visible": arg_bool(args, "visible", true), "launch": false}), 30)+ .ok()+ .and_then(|r| r.get("version").cloned())+ .unwrap_or(Value::Null)+ } else {+ Value::Null+ };+ ok(json!({+ "started": true,+ "attached": attached,+ // NOT "started:false". The launch WAS made — the application is coming+ // up on the user's desktop. Reporting a failure here would invite a+ // caller to launch it again, which is the one thing that must not happen.+ "stillLaunching": !attached,+ "wasLaunched": true,+ "version": version,+ "edition": detection.edition(),+ "launchRoute": "platformShortcut",+ "route": current.route,+ "stage": current.stage.name(),+ "shortcut": shortcut,+ "elapsedSeconds": started.elapsed().as_secs(),+ "launch": crate::launch::report(os.as_ref()),+ "windowEtiquette": "background",+ "progress": job.finished(),+ "_hint": if attached {+ "SOLIDWORKS started via the 3DEXPERIENCE launcher and is COM-attached. \+ swx_new_document or swx_open next.".to_string()+ } else {+ format!(+ "THE LAUNCH IS IN FLIGHT, not failed. Stage: {}. {} A supervisor inside this \+ bridge is watching the launcher's own log; if this route stalls before \+ 'Run Begin' it will end ONLY the chain it started and try the next route by \+ itself, so there is nothing for you to retry. Poll swx_status (its `launch` \+ block carries the live stage) or call swx_start again — calling it again during \+ a launch NEVER starts a second one, it reports this same attempt. Do NOT invoke \+ the shortcut any other way in the meantime: two launches means two sign-in \+ windows on somebody's desktop. waitSeconds:<n> (up to 240) blocks here instead \+ of polling.",+ current.stage.name(),+ current.stage.advice(),+ )+ },+ }))+}++/// The answer to `swx_start` while a launch this bridge started is still+/// running. `success:true` and `stillLaunching:true` — nothing failed, and the+/// one thing a caller must not do is launch again.+fn still_launching(+ a: &crate::launch::Attempt,+ os: &dyn crate::launchos::LaunchOs,+ detection: &crate::detect::Detection,+ why: &str,+) -> Value {+ ok(json!({+ "started": true,+ "attached": false,+ "stillLaunching": true,+ "wasLaunched": false,+ "launchedNow": false,+ "reasonNotLaunched": why,+ "stage": a.stage.name(),+ "route": a.route,+ "ageSeconds": a.age_secs(),+ "edition": detection.edition(),+ "launchRoute": "platformShortcut",+ "shortcut": a.shortcut,+ "launch": crate::launch::report(os),+ "windowEtiquette": "background",+ "_hint": format!(+ "NOTHING WAS LAUNCHED BY THIS CALL, deliberately: {why} ({}s ago, stage '{}'). {} \+ Poll swx_status — its `launch` block carries the live stage, the route being tried \+ and every route already tried — or call swx_start again, which will keep answering \+ like this until SOLIDWORKS attaches or every route is exhausted. Do NOT invoke the \+ shortcut any other way in the meantime: two launches means two sign-in windows on \+ somebody's desktop.",+ a.age_secs(),+ a.stage.name(),+ a.stage.advice(),+ ),+ }))+}++/// SOLIDWORKS IS RUNNING AND WE CANNOT ATTACH. Three genuinely different+/// situations, and the bridge must not blur them into "not running" — which is+/// what it said on 2026-09-03, when the honest answer was "it is alive and out+/// of memory".+fn running_but_unreachable(+ ctx: &Ctx,+ os: &dyn crate::launchos::LaunchOs,+ detection: &crate::detect::Detection,+) -> Value {+ let status = ctx.call("status", json!({}), 10);+ let open_docs = status.as_ref().ok().and_then(|r| r.get("openDocs").and_then(|v| v.as_u64()));+ let com_pid = status+ .as_ref()+ .ok()+ .and_then(|r| r.get("processId").and_then(|v| v.as_u64()))+ .map(|p| p as u32);+ // Did a COM call EXCEED ITS BUDGET, or merely fail? Only the first is the+ // COM half of the hang test.+ let com_timed_out = match &status {+ Ok(_) => Some(false),+ // An unrecognised com failure is UNKNOWN — see swx_status.+ Err(e) if e.timed_out => Some(true),+ Err(_) => None,+ };+ let health = crate::launch::host_app_health(os, open_docs, com_timed_out, com_pid);+ let hung = health.get("hung").and_then(|v| v.as_bool()).unwrap_or(false);+ let exhausted = health.get("exhausted").and_then(|v| v.as_str()).map(String::from);+ let mut out = if hung {+ err_code(+ "host_app_hung",+ "SOLIDWORKS is running but not responding: Windows reports its window is hung and a \+ COM call from this bridge timed out",+ crate::launch::HUNG_HINT,+ )+ } else if let Some(why) = exhausted {+ err_code(+ "host_app_exhausted",+ format!("SOLIDWORKS is running but out of room — {why}"),+ crate::launch::EXHAUSTED_HINT,+ )+ } else {+ err_code(+ "host_app_com_unreachable",+ "SOLIDWORKS is running, but it is not answering COM and this bridge will not start a \+ second copy",+ "sldworks.exe IS on the user's machine — so launching again would put a SECOND \+ SOLIDWORKS on their desktop, which is never the right move. The usual cause on a \+ Connected edition is a 3DEXPERIENCE sign-in or licence window waiting for an answer \+ only the user can give: ask them to look at their screen. swx_state (with a \+ background screenshot) shows what it is displaying, and swx_status.solidworks \+ reports whether the window is responding at all.",+ )+ };+ if let Value::Object(map) = &mut out {+ map.insert("solidworksProcess".into(), health);+ map.insert("edition".into(), json!(detection.edition()));+ map.insert("launch".into(), crate::launch::report(os));+ map.insert("windowEtiquette".into(), json!("background"));+ }+ out+}++/// EVERY PRECONDITION FACT, measured in one pass, with no COM call that could+/// block on a wedged session doing the measuring of whether it is wedged.+///+/// This exists as a struct because the sweep has to be REPEATABLE: the ladder+/// re-runs it before every rung. It was originally swept once, before the loop,+/// which meant "verified zero documents open" held for rung (a) only — a+/// document opened while rung (a) settled was still open when rung (b) fired+/// `RunCommand(swCommands_App_Exit)` at the session, and the reasoning that a+/// save prompt at a verified zero "should be impossible" depended on the promise+/// this struct now keeps.+struct StopEvidence {+ open_docs: Option<u64>,+ listed_docs: Option<u64>,+ docs_truncated: bool,+ unsaved: Option<u64>,+ busy: Option<bool>,+ identity: Option<crate::quit::Identity>,+ dialog: Option<crate::quit::BlockingDialog>,+ /// The pids the dialog census actually covered — SOLIDWORKS plus its CEF+ /// subprocesses, which is where a 3DEXPERIENCE sign-in window lives.+ dialog_pids: Vec<u32>,+ census_readable: bool,+ responding: Option<bool>,+ host_busy: bool,+ launch_busy: bool,+ census: Result<Vec<crate::launchos::ProcInfo>, String>,+}++impl StopEvidence {+ fn sweep(ctx: &Ctx, os: &dyn crate::launchos::LaunchOs) -> Self {+ use crate::quit;+ let state = ctx.call("app_state", json!({}), 30);+ let get = |k: &str| state.as_ref().ok().and_then(|s| s.get(k).cloned());+ let open_docs = get("documentCount").and_then(|v| v.as_u64());+ let docs_truncated =+ get("documentsTruncated").and_then(|v| v.as_bool()).unwrap_or(false);+ // The ENUMERATION's own length, for the count cross-check. Above the cap+ // it is deliberately empty and `documentsTruncated` refuses first, so it+ // is only compared when the host claims to have listed everything.+ let listed_docs = if docs_truncated {+ None+ } else {+ get("documents").and_then(|v| v.as_array().map(|a| a.len() as u64))+ };+ let unsaved = get("unsavedDocuments").and_then(|v| v.as_u64());+ let busy = get("busy").and_then(|v| v.as_bool());+ let com_pid = get("processId").and_then(|v| v.as_u64()).map(|p| p as u32);+ let com_hwnd = get("mainHwnd").and_then(|v| v.as_i64());++ // P7: pid + creation time + main window, from the census (no COM).+ let census = os.processes();+ let identity = com_pid.and_then(|pid| {+ let created = census+ .as_ref()+ .ok()+ .and_then(|c| c.iter().find(|p| p.pid == pid).and_then(|p| p.created_at))?;+ Some(quit::Identity {+ pid,+ created_at: created,+ main_hwnd: com_hwnd.or_else(|| os.main_window_of_pid(pid)),+ })+ });+ // P3: the window census, readable with no ab and no COM — over EVERY pid+ // a modal could belong to, not just sldworks.exe.+ let mut dialog_pids: Vec<u32> = Vec::new();+ let mut census_readable = false;+ let mut dialog = None;+ if let Some(id) = identity.as_ref() {+ if let Some(pids) = quit::dialog_census_pids(census.as_ref(), id) {+ let mut all_read = true;+ let mut windows: Vec<crate::launchos::WindowInfo> = Vec::new();+ for pid in &pids {+ match os.windows_of_pid(*pid) {+ Ok(w) => windows.extend(w),+ // ONE unreadable pid makes the whole answer UNKNOWN: the+ // dialog we cannot see is exactly the one that matters.+ Err(_) => all_read = false,+ }+ }+ dialog_pids = pids;+ census_readable = all_read;+ dialog = quit::first_blocking_dialog(&windows);+ }+ }+ // P5: responding, without COM.+ let responding = identity+ .as_ref()+ .and_then(|i| i.main_hwnd)+ .and_then(|h| os.window_responding(h));+ Self {+ open_docs,+ listed_docs,+ docs_truncated,+ unsaved,+ busy,+ identity,+ dialog,+ dialog_pids,+ census_readable,+ responding,+ host_busy: ctx.com.snapshot().busy,+ launch_busy: crate::launch::in_flight(os).is_some(),+ census,+ }+ }++ fn check(&self, force: bool) -> Result<(), crate::quit::Refusal> {+ crate::quit::check_preconditions(+ self.open_docs,+ self.listed_docs,+ self.docs_truncated,+ self.unsaved,+ self.dialog.as_ref(),+ self.census_readable,+ self.busy,+ self.host_busy,+ self.responding,+ self.identity.as_ref(),+ self.launch_busy,+ force,+ ) }- ok_merge(- invoked,++ fn to_json(&self, force: bool) -> Value { json!({- "started": true,- "attached": attached,- // NOT "started:false". The launch WAS made — the application is- // coming up on the user's desktop. Reporting a failure here would- // invite a caller to launch it again, which is the one thing that- // must not happen.- "stillLaunching": !attached,- "wasLaunched": true,- "version": version,- "edition": detection.edition(),- "launchRoute": "platformShortcut",- "shortcut": shortcut,- "elapsedSeconds": started.elapsed().as_secs(),- "windowEtiquette": "background",- "progress": job.finished(),- "_hint": if attached {- "SOLIDWORKS started via the 3DEXPERIENCE launcher and is COM-attached. \- swx_new_document or swx_open next."- } else {- "THE LAUNCH IS IN FLIGHT, not failed: the launcher was invoked and SOLIDWORKS \- is coming up, but it is not COM-attachable yet. A Connected cold start runs 1-3 \- minutes and may stop at a 3DEXPERIENCE sign-in the user has to answer. Poll \- swx_status (or call swx_start again, which ATTACHES to what is already coming \- up rather than launching a second copy) until running:true. Do NOT invoke the \- launcher any other way in the meantime — two launches means two sign-in \- windows on somebody's desktop. waitSeconds:<n> (up to 240) blocks here \- instead of polling, if your own budget allows it."- },- }),- )+ "openDocuments": self.open_docs,+ "listedDocuments": self.listed_docs,+ "documentsTruncated": self.docs_truncated,+ "unsavedDocuments": self.unsaved,+ "dialog": self.dialog.as_ref().map(|d| d.to_json()),+ "dialogCensusReadable": self.census_readable,+ "dialogCensusPids": self.dialog_pids,+ "busy": self.busy,+ "hostBusy": self.host_busy,+ "responding": self.responding,+ "identity": self.identity.as_ref().map(|i| i.to_json()),+ "launchInFlight": self.launch_busy,+ "force": force,+ })+ } } +/// The caller-facing hint for each refusal code, so the front gate and a+/// mid-ladder refusal say the same thing about the same fact.+fn stop_refusal_hint(code: &str) -> &'static str {+ match code {+ "open_documents_present" =>+ "This bridge quits ONLY a session with no documents open, and `force` does not waive \+ that — force waives the UNSAVED question only. Close the documents first (they are \+ the user's; ask), or leave SOLIDWORKS running.",+ "stop_blocked_by_save_prompt" =>+ "A save prompt with a VERIFIED zero open documents should be impossible, so this is a \+ bug to report, not a dialog to dismiss: the document count was wrong. This bridge \+ never answers a save prompt — the buttons offer to write the user's work to This PC \+ or to 3DEXPERIENCE, and that is their decision. Ask them to answer it.",+ "stop_blocked_by_dialog" =>+ "SOLIDWORKS is asking something. This bridge never answers a dialog — closing a stuck \+ modal through the window manager took the whole application down on 2026-09-02. \+ swx_state takes a background screenshot; show the user and let them answer.",+ "stop_preconditions_unknown" =>+ "Nothing was attempted. Every precondition must be MEASURED, and an unanswerable one \+ is not permission — quitting on a guess is how a session ends up half-closed with the \+ user's work in it. Retry once the reading is available (swx_status reports each of \+ these), or leave it running.",+ "identity_mismatch" =>+ "The launch record and the live COM session name DIFFERENT SOLIDWORKS processes, so \+ this bridge cannot say the session it would end is the one it launched. Nothing was \+ attempted. swx_status reports both; swx_start re-establishes which session it is \+ talking to, and a bridge restart clears a stale claim.",+ "stop_identity_changed" =>+ "The SOLIDWORKS this bridge is attached to is NOT the one the ladder started against \+ — a different pid, or the same pid with a different creation time. Nothing further \+ was sent: every rung's success measurement is 'this exact process is gone', and it \+ would now be measuring the wrong process. Call swx_status, then start over.",+ _ =>+ "Nothing was attempted. swx_status reports the open-document count, whether SOLIDWORKS \+ is responding, and whether this bridge's com host is busy.",+ }+}++/// `swx_stop` v2 — THE MEASURED LADDER (docs/QUIT-INVESTIGATION.md §5).+///+/// Preconditions verified by evidence, every COM reference released before any+/// rung, success measured by a PROCESS CENSUS rather than a COM call, and nothing+/// disowned until the process is verifiably gone. Never a kill. Never answers a+/// dialog. See `crate::quit` for the three laws and the refcount reasoning. fn swx_stop(ctx: &Ctx, args: &Value) -> Value { gate!(ctx);- // Quitting a CAD application with unsaved work raises a save dialog that- // nobody is there to answer — which is how SOLIDWORKS ends up wedged. Count- // first, refuse loudly, and make `force` the caller's explicit decision.- if !arg_bool(args, "force", false) {- if let Ok(state) = ctx.call("app_state", json!({}), 20) {- let unsaved = state.get("unsavedDocuments").and_then(|v| v.as_u64()).unwrap_or(0);- if unsaved > 0 {- return err_code(- "unsaved_documents",- format!("{unsaved} open document(s) have unsaved changes"),- "Quitting now makes SOLIDWORKS raise a \"Save changes?\" dialog with nobody \- to answer it, and the app hangs there. Save them (swx_save), close them \- (swx_close_doc), or pass force:true if the user has said the changes are \- disposable. swx_get_app_state names them.",- );- }+ use crate::quit;+ let os = crate::launchos::os();+ let force = arg_bool(args, "force", false);+ let started = std::time::Instant::now();++ // ── THE LADDER PLAN. Pinned at rung "a" by default: rungs (b)-(f) send+ // window messages and run VBA inside somebody's CAD session and not one+ // of them has been observed on a real machine.+ let max_rung = args+ .get("maxRung")+ .and_then(|v| v.as_str())+ .unwrap_or(quit::DEFAULT_MAX_RUNG)+ .to_string();+ let plan = quit::rungs_up_to(&max_rung);+ if plan.is_empty() {+ return err_code(+ "unknown_rung",+ format!("maxRung:\"{max_rung}\" is not a rung of the ladder"),+ "Rungs are \"a\" (ExitApp) through \"f\" (Restart Manager, opt-in). The default is \+ \"a\" until the live quit gate has run; see docs/QUIT-INVESTIGATION.md §5.3.",+ );+ }+ let allow_rm = arg_bool(args, "allowRestartManager", false);++ // ── IS THERE ANYTHING TO STOP? A clear answer for the commonest case, so+ // "nothing is running" never arrives dressed as an unmeasurable+ // precondition. The com host's own word, preserved.+ if let Ok(st) = ctx.call("status", json!({}), 20) {+ if st.get("running").and_then(|v| v.as_bool()) == Some(false) {+ return ok_merge(+ json!({+ "stopped": false,+ "reason": "not running",+ "success": false,+ "errorCode": "host_app_not_running",+ "error": "SOLIDWORKS is not running, so there is nothing to stop",+ "rungsTried": [],+ }),+ json!({+ "windowEtiquette": "background",+ "_hint": "Nothing was attempted and nothing needed to be: the com host reports \+ no attached SOLIDWORKS. swx_status confirms it. If a SOLIDWORKS \+ window IS on the user's screen, this bridge cannot see it — that is \+ an ATTACH problem, not a quit problem, and swx_start reports which.",+ "_reportIssues": report_issues(),+ }),+ );+ }+ }++ // ── PRECONDITION EVIDENCE, measured now, never inherited from the caller,+ // and re-measured before EVERY rung further down.+ let ev = StopEvidence::sweep(ctx, os.as_ref());+ let census0 = ev.census.clone();+ // ── THE LAUNCH'S CLAIM MUST BE ABOUT THE SESSION COM ANSWERS FOR.+ // Re-swept with everything else below, because this verb re-measures+ // before every rung and an attribution that was true at rung (a) is not+ // automatically true at rung (b).+ if let Err(refusal) =+ quit::identity_matches_attribution(crate::launch::attributed_solidworks(), ev.identity.as_ref())+ .and_then(|_| ev.check(force))+ {+ // A REFUSAL IS RECORDED. v1 wrote no ledger line at its front gate, so+ // "we tried to quit and were told no" was invisible afterwards.+ crate::ledger::record(+ "solidworks_quit_refused",+ json!({"code": refusal.code(), "why": refusal.message(), "maxRung": max_rung}),+ );+ let mut out =+ err_code(refusal.code(), refusal.message(), stop_refusal_hint(refusal.code()));+ if let Value::Object(m) = &mut out {+ m.insert("preconditions".into(), ev.to_json(force));+ m.insert("rungsTried".into(), json!([]));+ m.insert("windowEtiquette".into(), json!("background")); }+ return out; }+ let id = ev.identity.clone().expect("checked by the precondition sweep");++ // ── P6: the process-level opt-in, a confirm and a human reason. Unchanged. let grant = match crate::writes::authorize_in( ctx, args,@@ -764,78 +1325,368 @@ fn swx_stop(ctx: &Ctx, args: &Value) -> Value { None, ) { Ok(g) => g,- Err(e) => return e,- };- match ctx.call("stop", crate::writes::with_grant(json!({}), &grant), 60) {- Err(e) => sidecar_error(e),- // ASKED TO QUIT IS NOT QUIT. The host verifies by re-attaching, and a- // re-attach that still succeeds means SOLIDWORKS is on screen — usually- // because it is holding a "Save changes?" modal, or because a Connected- // edition declined. That came back as `success:true, stopped:false` with- // no code and no explanation, which reads as "fine" to a caller and to- // an AI planning its next step. A verb that did not do the thing reports- // a FAILURE, with why and what to do about it.- Ok(r) if r.get("stopped").and_then(|v| v.as_bool()) == Some(false) => {- let why = r.get("reason").and_then(|v| v.as_str()).unwrap_or("");- let still = r.get("stillRunning").and_then(|v| v.as_bool()).unwrap_or(true);+ Err(e) => { crate::ledger::record(- "solidworks_stop_declined",- json!({"forced": arg_bool(args, "force", false), "stillRunning": still, "reason": why}),+ "solidworks_quit_refused",+ json!({"code": "user_writes_disabled", "why": "the write opt-in was not granted"}), );- let mut out = err_code(- if !still { "host_app_not_running" } else { "host_app_stop_declined" },- if !still {- "SOLIDWORKS was not running, so there was nothing to stop".to_string()- } else {- format!(- "SOLIDWORKS was asked to quit and is STILL RUNNING{}",- if why.is_empty() { String::new() } else { format!(" ({why})") }- )- },- if !still {- "Nothing changed. swx_status shows whether it is up."- } else {- "ExitApp returned but a re-attach still succeeded, so the application did \- not go away. Almost always it is holding a modal nobody answered — a \- \"Save changes?\" on the way out. swx_get_app_state names the documents \- with unsaved changes and swx_blocking_dialogs shows what is on screen; \- save or close them, then retry. Do NOT reach for swx_kill to force it: \- that discards the unsaved work this dialog is asking about, and on a \- Connected edition this bridge cannot start SOLIDWORKS again."- },- );- if let (Value::Object(m), Value::Object(r)) = (&mut out, r) {- for (k, v) in r {- m.entry(k).or_insert(v);+ return e;+ }+ };++ let job = ctx.jobs.start("app_stop", "Asking SOLIDWORKS to quit (measured ladder)", 60);+ let census_before = census0.as_ref().ok().cloned().unwrap_or_default();+ crate::ledger::record(+ "solidworks_quit_started",+ json!({+ "identity": id.to_json(),+ "plan": plan,+ "maxRung": max_rung,+ "allowRestartManager": allow_rm,+ "reason": arg_str(args, "reason"),+ }),+ );++ let mut tries: Vec<quit::RungTry> = Vec::new();+ let mut winning: Option<&'static str> = None;+ let mut blocked: Option<quit::BlockingDialog> = None;++ for rung in plan.iter() {+ // Rung (f) is opt-in: ENDSESSION_CLOSEAPP means "save automatically+ // without prompting", so it is the one rung whose failure mode is a+ // silent write rather than a dialog we can stop on.+ if *rung == "f" && !allow_rm {+ tries.push(quit::RungTry {+ rung,+ action: quit::rung_action(rung),+ result: quit::RungResult::Skipped,+ process_gone: None,+ elapsed_sec: 0.0,+ new_windows: 0,+ dialog: None,+ children_alive: Vec::new(),+ survivors_alive: Vec::new(),+ unjudged_alive: Vec::new(),+ // Nothing was measured because nothing was sent.+ census_readable: false,+ detail: Some(+ "the Restart Manager rung needs allowRestartManager:true — its shutdown \+ request tells applications to save WITHOUT prompting, so it is never taken \+ implicitly"+ .into(),+ ),+ host_error: None,+ });+ continue;+ }+ // ── RE-MEASURE EVERY PRECONDITION, BEFORE EVERY RUNG. Not once before+ // the loop: a rung takes seconds to settle, and a document opened in+ // that gap was still open when the NEXT rung fired at the session.+ // `force` is passed unchanged, so it still waives the unsaved question+ // only.+ if !tries.is_empty() {+ let now = StopEvidence::sweep(ctx, os.as_ref());+ // The same process, still? A different pid — or the same pid with a+ // different creation time — means the ladder would be measuring+ // something it never acted on.+ let same = now.identity.as_ref().map(|i| i.pid == id.pid && i.created_at == id.created_at);+ let claim = crate::launch::attributed_solidworks();+ let refusal = match quit::identity_matches_attribution(claim, now.identity.as_ref())+ .and_then(|_| now.check(force))+ {+ Err(r) => Some(r),+ Ok(()) if same == Some(false) => Some(quit::Refusal::Known {+ code: "stop_identity_changed",+ message: "the SOLIDWORKS this bridge is attached to is no longer the process \+ the ladder started against"+ .into(),+ }),+ Ok(()) => None,+ };+ if let Some(refusal) = refusal {+ crate::ledger::record(+ "solidworks_quit_refused",+ json!({+ "code": refusal.code(),+ "why": refusal.message(),+ "maxRung": max_rung,+ "afterRungs": tries.len(),+ "midLadder": true,+ }),+ );+ let mut out = err_code(+ refusal.code(),+ format!("{} (measured after rung '{}')", refusal.message(), tries.last().map(|t| t.rung).unwrap_or("?")),+ stop_refusal_hint(refusal.code()),+ );+ if let Value::Object(m) = &mut out {+ m.insert("preconditions".into(), now.to_json(force));+ m.insert(+ "rungsTried".into(),+ json!(tries.iter().map(|t| t.to_json()).collect::<Vec<_>>()),+ );+ m.insert("stopped".into(), json!(false));+ m.insert("stillRunning".into(), json!(quit::process_gone(now.census.as_ref(), &id).map(|g| !g)));+ m.insert("identity".into(), id.to_json());+ m.insert("maxRung".into(), json!(max_rung));+ m.insert("windowEtiquette".into(), json!("background"));+ m.insert("refusedMidLadder".into(), json!(true)); }+ let _ = job.finished();+ return out; }- out }- Ok(r) => {- crate::ledger::record("solidworks_stopped", json!({"forced": arg_bool(args, "force", false)}));- ok_merge(- r,- json!({- "_hint": "SOLIDWORKS was asked to quit properly. On SOLIDWORKS Connected \- this bridge CANNOT start it again — the user has to relaunch it \- from the 3DEXPERIENCE Platform — so do not stop it casually.",- }),- )+ let rung_started = std::time::Instant::now();+ // RELEASE EVERY REFERENCE FIRST. Rung (a) also ends by releasing; this+ // makes rungs (b)-(f) START released, which is the ladder-wide invariant.+ let _ = ctx.call("release_com", json!({}), 20);+ let asked = ctx.call(+ "quit_rung",+ json!({+ "rung": rung,+ "mainHwnd": id.main_hwnd,+ "processId": id.pid,+ "processCreatedAt": id.created_at,+ }),+ quit::rung_budget_secs(rung) + 20,+ );+ let (host_word, detail, host_error) = match &asked {+ Ok(r) => (+ r.get("result").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ r.get("detail").and_then(|v| v.as_str()).map(String::from),+ None,+ ),+ // A rung that WORKED tears the COM server down underneath the call+ // that asked for it, so a transport failure here is not a verdict —+ // the census below decides.+ Err(e) => (String::new(), None, Some(e.message.clone())),+ };+ // ── MEASURE, and never with a COM call. Release again first so nothing+ // this process holds can keep the app alive while we ask.+ let _ = ctx.call("release_com", json!({}), 10);+ let refs = ctx+ .call("com_refs", json!({}), 10)+ .ok()+ .and_then(|r| r.get("outstanding").and_then(|v| v.as_u64()))+ .unwrap_or(0);+ // ── WAIT FOR THE EXIT, don't sample once for it. `ExitApp` RETURNS and+ // SOLIDWORKS then takes seconds to tear a session down (10 CEF+ // subprocesses, three out-of-process servers, the journal). A single+ // reading 1.5 s later called that "declined" — and the ladder's+ // response to a decline is to escalate, sending a window message into+ // an application that was already exiting. So poll at the settle+ // interval up to THIS rung's own budget, and stop early on the two+ // answers that end the ladder: gone, or a dialog on screen.+ let interval = std::time::Duration::from_millis(+ std::env::var("SWX_QUIT_SETTLE_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(1500),+ );+ // How long to WAIT for the exit: this rung's own budget, because that is+ // the longest the rung is allowed to take at all. SWX_QUIT_EXIT_WAIT_SECS+ // shortens it — the suite and the gate both drive many rungs and neither+ // needs a real half-minute to watch a simulated process go.+ let wait_secs = std::env::var("SWX_QUIT_EXIT_WAIT_SECS")+ .ok()+ .and_then(|v| v.parse::<u64>().ok())+ .unwrap_or_else(|| quit::rung_budget_secs(rung));+ let wait_until = std::time::Instant::now() + std::time::Duration::from_secs(wait_secs);+ let mut census;+ let mut gone;+ let mut after_windows;+ let mut new_dialog;+ let mut polls = 0u32;+ loop {+ std::thread::sleep(interval);+ polls += 1;+ census = os.processes();+ gone = quit::process_gone(census.as_ref(), &id);+ after_windows = os.windows_of_pid(id.pid);+ new_dialog = after_windows.as_ref().ok().and_then(|w| quit::first_blocking_dialog(w));+ if gone == Some(true) || new_dialog.is_some() || std::time::Instant::now() >= wait_until+ {+ break;+ }+ }+ let (children, survivors, unjudged) =+ census.as_ref().ok().map(|c| quit::reap_report(c)).unwrap_or_default();+ let new_windows = after_windows+ .as_ref()+ .ok()+ .map(|w| w.iter().filter(|x| x.is_dialog() && x.visible).count())+ .unwrap_or(0);++ let mut result = match gone {+ Some(true) => quit::RungResult::Quit,+ Some(false) => quit::result_from_host(&host_word),+ // A rung that cannot be MEASURED is a rung that failed.+ None => quit::RungResult::Unmeasured,+ };+ if result != quit::RungResult::Quit && new_dialog.is_some() {+ result = quit::RungResult::DialogAppeared;+ }+ let mut detail = detail;+ if polls > 1 {+ detail = Some(format!(+ "{} — the exit was measured over {polls} census reads (a rung returns before the \+ process is gone)",+ detail.unwrap_or_default()+ ));+ }+ if refs > 0 {+ detail = Some(format!(+ "{} — WARNING: {refs} COM reference(s) were still outstanding at the measurement",+ detail.unwrap_or_default()+ ));+ }+ tries.push(quit::RungTry {+ rung,+ action: quit::rung_action(rung),+ result,+ process_gone: gone,+ elapsed_sec: rung_started.elapsed().as_secs_f64(),+ new_windows,+ dialog: new_dialog.clone(),+ children_alive: children,+ survivors_alive: survivors,+ unjudged_alive: unjudged,+ census_readable: census.is_ok(),+ detail,+ host_error,+ });+ crate::ledger::record(+ "solidworks_quit_rung",+ json!({"rung": rung, "result": result.name(), "processGone": gone}),+ );+ if result == quit::RungResult::Quit {+ winning = Some(rung);+ break;+ }+ if result == quit::RungResult::DialogAppeared {+ blocked = new_dialog;+ break;+ }+ if result == quit::RungResult::UipiBlocked {+ break;+ }+ }++ let census_after = os.processes();+ let stopped = winning.is_some();+ // v1 shipped `stillRunning`, and it is the field a caller reads to answer+ // "is it gone?" without parsing the ladder. Keep it — MEASURED from the+ // final census, and null (never false) when the census could not be read,+ // because an unreadable census is not proof of an exit.+ let still_running = quit::process_gone(census_after.as_ref(), &id).map(|gone| !gone);+ // NOTHING IS DISOWNED UNTIL THE PROCESS IS VERIFIABLY GONE. v1 cleared the+ // registry before it verified, so a declined quit left the bridge unable to+ // touch its own documents.+ if stopped {+ ctx.scratch_docs.lock().unwrap_or_else(|e| e.into_inner()).clear();+ crate::owned::clear();+ crate::ledger::record(+ "solidworks_stopped",+ json!({"rung": winning, "authorized": grant.user_write, "identity": id.to_json()}),+ );+ }+ let rungs_json: Vec<Value> = tries.iter().map(|t| t.to_json()).collect();+ let census_json = |c: &Result<Vec<crate::launchos::ProcInfo>, String>| -> Value {+ match c {+ Ok(v) => json!(v.iter().map(|p| p.to_json()).collect::<Vec<_>>()),+ Err(e) => json!({"error": e}), }+ };+ let body = json!({+ "stopped": stopped,+ "winningRung": winning,+ "rungsTried": rungs_json,+ "maxRung": max_rung,+ "elapsedSeconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0,+ "identity": id.to_json(),+ // The sweep that let the FIRST rung run. Each rung's own re-sweep is+ // what gated the rungs after it; a mid-ladder refusal returns its own.+ "preconditions": ev.to_json(force),+ "forceWaivedUnsavedOnly": force,+ "processCensusBefore": census_before.iter().map(|p| p.to_json()).collect::<Vec<_>>(),+ "processCensusAfter": census_json(&census_after),+ "expectedSurvivorsAreNotAFailure": quit::EXPECTED_SURVIVORS,+ "stillRunning": still_running,+ "blockingDialog": blocked.as_ref().map(|d| d.to_json()),+ "windowEtiquette": "background",+ "progress": job.finished(),+ });+ if stopped {+ return ok_merge(body, json!({+ "_hint": format!(+ "SOLIDWORKS quit on rung '{}' ({}), verified by process census — the pid and its \+ creation time are gone from the machine, not merely absent from the COM running \+ object table. Per-session children reaped; `expectedSurvivorsAlive` lists the \+ platform daemons that survive a graceful exit BY DESIGN (ENOPLMCSAClient, \+ EdmServerV6, 3DEXPERIENCELauncher*) — their presence is consent, not a failure, \+ and nothing should ever kill them. On a Connected edition starting it again takes \+ 1-3 minutes through swx_start.",+ winning.unwrap_or("?"),+ quit::rung_action(winning.unwrap_or("a")),+ ),+ }));+ }+ let mut out = body;+ if let Value::Object(m) = &mut out {+ m.insert("success".into(), json!(false));+ m.insert(+ "errorCode".into(),+ json!(match blocked.as_ref() {+ Some(d) => d.error_code(),+ None => "host_app_stop_declined",+ }),+ );+ m.insert(+ "error".into(),+ json!(match blocked.as_ref() {+ Some(d) => format!(+ "the quit stopped at a dialog SOLIDWORKS raised ({}): \"{}\" — this bridge \+ never answers one",+ d.kind, d.title+ ),+ None => format!(+ "SOLIDWORKS did not quit; {} rung(s) were tried and measured",+ tries.len()+ ),+ }),+ );+ m.insert(+ "_hint".into(),+ json!(match blocked.as_ref() {+ Some(d) => quit::blocked_hint(d),+ None => quit::declined_hint(tries.len(), &max_rung),+ }),+ );+ m.insert("_reportIssues".into(), json!(report_issues())); }+ out } fn swx_docs(ctx: &Ctx, _args: &Value) -> Value { gate!(ctx); match ctx.call("docs", json!({}), 30) { Err(e) => sidecar_error(e),- Ok(r) => ok_merge(- r,- json!({- "_hint": "Pass any doc's `path` to the analysis verbs to target it without \- activating it — no window changes, nothing steals focus.",- }),- ),+ // The com host already degraded above the cap and set `truncated`, so+ // this never blocks the full 30s on a huge count. When it truncated,+ // the host's own `_hint` (the drain guidance) is the right one and must+ // not be overwritten — ok_merge lets extras win, so pass none.+ Ok(r) => {+ if r.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false) {+ ok_merge(r, json!({}))+ } else {+ ok_merge(+ r,+ json!({+ "_hint": "Pass any doc's `path` to the analysis verbs to target it \+ without activating it — no window changes, nothing steals \+ focus. `count` is authoritative (GetDocumentCount); if it is \+ ever much larger than the list, run swx_close_import_temps.",+ }),+ )+ }+ } } } @@ -885,7 +1736,7 @@ fn swx_open(ctx: &Ctx, args: &Value) -> Value { // through the same choke point as every other one. let mut open_grant = crate::writes::Grant { host_args: json!({}), user_write: false }; if !read_only {- let (session, _open, _active) = crate::verbs3::ownership_context(ctx);+ let (session, _open, _active, _truncated) = crate::verbs3::ownership_context(ctx); let owned = ctx.owns_doc("", Some(&path), session); let target = crate::verbs::Target { title: crate::comhost::mock::base_name(&path).to_string(),@@ -1581,9 +2432,18 @@ fn swx_interference(ctx: &Ctx, args: &Value) -> Value { r, json!({ "progress": job.finished(),- "_hint": "count==0 means no interferences at the components' CURRENT positions \- — it is a snapshot, not a motion study. coincidenceCounts:true also \- flags touching faces (usually noise on a real assembly).",+ "_hint": "READ `count` CAREFULLY, and never with a truthiness check. \+ count==0 means NO INTERFERENCES at the components' CURRENT positions \+ — a snapshot, not a motion study. count==null means SOLIDWORKS would \+ NOT SAY: the enumeration failed, and that is NOT a clear result. \+ `null` is falsy in every language this bridge's callers use, so \+ `if (!r.count)` and `if not r[\"count\"]` both turn 'I do not know' \+ into 'all clear' — on a clearance report somebody builds a physical \+ part from. Branch on `countKnown` (or `count === 0`) instead, and \+ re-run the check when it is false. `listed` is how many interferences \+ came back in the list; a count that disagrees with it is refused \+ outright as `interference_enumeration_failed`. coincidenceCounts:true \+ also flags touching faces (usually noise on a real assembly).", }), ), }@@ -1871,6 +2731,7 @@ pub const CATALOG: &[(&str, &str, &str)] = &[ ("swx_activate_document", "exec", "make an open document the active one"), ("swx_close_all_documents", "exec", "close EVERY open document (confirm:true; skips unsaved by default)"), ("swx_reset_workspace", "exec", "close only the scratch documents this bridge created"),+ ("swx_close_import_temps", "exec", "drain leaked STEP/IGES import temporaries (…\\Temp\\swx<pid>\\IC~~\\…); default posture, never a saved user doc"), ("swx_kill", "process", "force-kill SOLIDWORKS (reason + confirm; discards unsaved work)"), ("swx_window_info", "read", "SOLIDWORKS' windows and its main hwnd (read-only, nothing raised)"), ("swx_dismiss_blocking_dialogs", "exec", "census of blocking SOLIDWORKS dialogs; close:true is opt-in, gated, and never touches an unattributed window"),@@ -1987,6 +2848,7 @@ pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value { "swx_activate_document" => crate::verbs3::activate_document, "swx_close_all_documents" => crate::verbs3::close_all_documents, "swx_reset_workspace" => crate::verbs3::reset_workspace,+ "swx_close_import_temps" => crate::verbs3::close_import_temps, "swx_kill" => crate::verbs3::kill, "swx_window_info" => crate::verbs3::window_info, "swx_dismiss_blocking_dialogs" => crate::verbs3::dismiss_blocking_dialogs,
src/verbs3.rs+1132−43@@ -50,17 +50,36 @@ fn require_confirm(args: &Value, verb: &str, what: &str) -> Option<Value> { /// One `app_state` call, giving everything the ownership decision needs: which /// SOLIDWORKS session this is, what is open in it, and which document is active.-pub(crate) fn ownership_context(ctx: &Ctx) -> (Option<i64>, Vec<Value>, Option<String>) {+pub(crate) fn ownership_context(ctx: &Ctx) -> (Option<i64>, Vec<Value>, Option<String>, bool) { match ctx.call("app_state", json!({}), 30) { Ok(s) => ( s.get("processId").and_then(|v| v.as_i64()), s.get("documents").and_then(|d| d.as_array()).cloned().unwrap_or_default(), s.get("activeDocument").and_then(|t| t.as_str()).map(String::from),+ // TRUNCATED: too many documents to enumerate, so `documents` is+ // empty as "unlistable", not as "none open". Ownership must not be+ // pruned against it.+ s.get("documentsTruncated").and_then(|v| v.as_bool()).unwrap_or(false), ),- Err(_) => (None, Vec::new(), None),+ Err(_) => (None, Vec::new(), None, false), } } +/// Prune ownership against the open set — but NEVER when the set is truncated,+/// because an empty "unlistable" set would disown every document the bridge+/// created (bug #5's shape, reached through the import-temp leak).+pub(crate) fn prune_unless_truncated(+ ctx: &Ctx,+ session: Option<i64>,+ open: &[Value],+ truncated: bool,+) {+ if truncated {+ return;+ }+ ctx.prune_scratch(session, &open_identities(open));+}+ /// (title, path) for each open document — what pruning and ambiguity checks need. pub(crate) fn open_identities(open: &[Value]) -> Vec<(String, Option<String>)> { open.iter()@@ -73,6 +92,40 @@ pub(crate) fn open_identities(open: &[Value]) -> Vec<(String, Option<String>)> { .collect() } +/// Was that failure really "SOLIDWORKS has no room left"? Returns the honest+/// `host_app_exhausted` when it was, and `None` when there is no evidence for+/// it — an unreadable memory figure never manufactures the verdict.+pub(crate) fn exhausted_error(ctx: &Ctx, e: &crate::comhost::SidecarError) -> Option<Value> {+ // THE ONE SUFFICIENT SIGNAL. Not "this call failed and memory looks high":+ // the first version relabelled EVERY sidecar error as exhaustion whenever+ // the session was large, which on a healthy 13.1 GB machine turned a+ // template typo into "close your documents and restart SOLIDWORKS".+ let created_nothing = e.message.contains("created no document");+ if !created_nothing {+ return None;+ }+ let os = crate::launchos::os();+ let status = ctx.call("status", json!({}), 10);+ let open_docs = status.as_ref().ok().and_then(|r| r.get("openDocs").and_then(|v| v.as_u64()));+ let com_pid = status+ .as_ref()+ .ok()+ .and_then(|r| r.get("processId").and_then(|v| v.as_u64()))+ .map(|p| p as u32);+ let health = crate::launch::host_app_health(os.as_ref(), open_docs, Some(false), com_pid);+ let why = crate::launch::exhaustion(None, open_docs, created_nothing)?;+ let mut out = err_code(+ "host_app_exhausted",+ format!("SOLIDWORKS is running but out of room — {why}"),+ crate::launch::EXHAUSTED_HINT,+ );+ if let Value::Object(map) = &mut out {+ map.insert("hostAppError".into(), json!(e.message));+ map.insert("solidworksProcess".into(), health);+ }+ Some(out)+}+ fn doc_title(d: &Value) -> String { d.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string() }@@ -95,8 +148,27 @@ fn doc_path(d: &Value) -> Option<String> { /// documents at the same path. A `targetDocument` naming nothing open is an /// error, never a silent fall back. pub(crate) fn resolve_document(ctx: &Ctx, args: &Value, verb: &str) -> Result<Target, Value> {- let (session, open, active) = ownership_context(ctx);- ctx.prune_scratch(session, &open_identities(&open));+ let (session, open, active, truncated) = ownership_context(ctx);+ prune_unless_truncated(ctx, session, &open, truncated);+ // A TRUNCATED SNAPSHOT CANNOT RESOLVE A DOCUMENT. `open` is empty because+ // the set is unlistable, so every lookup below would answer "no open+ // document is titled X" — a confident wrong answer about the user's session,+ // and the same empty-to-confident collapse this bridge has been rewritten+ // four times to stop making. Refuse with what is actually true.+ if truncated {+ return Err(err_code(+ "document_list_unavailable",+ format!(+ "{verb} cannot identify its target: SOLIDWORKS has more documents open than can \+ be enumerated (or would not say how many), so the open-document list is UNKNOWN \+ — not empty"+ ),+ "This is almost always leaked STEP/IGES import temporaries: run \+ swx_close_import_temps to drain them, then retry. swx_docs reports the count and a \+ sample. Nothing was changed, and this bridge still owns every document it created — \+ the ownership registry is deliberately NOT pruned against an unlistable session.",+ ));+ } resolve_target_inner(ctx, args, verb, session, &open, active.as_deref()) } @@ -307,6 +379,9 @@ pub fn activate_document(ctx: &Ctx, args: &Value) -> Value { /// `swx_close_all_documents` — destructive, so it is confirm-gated. pub fn close_all_documents(ctx: &Ctx, args: &Value) -> Value { gate!(ctx);+ if let Err(e) = identity_claim_ok(ctx) {+ return e;+ } if let Some(g) = require_confirm(args, "swx_close_all_documents", "close EVERY open SOLIDWORKS document, including ones the user opened themselves") { return g; }@@ -338,8 +413,8 @@ pub fn close_all_documents(ctx: &Ctx, args: &Value) -> Value { Ok(g) => g, Err(e) => return e, };- let (session, open, _active) = ownership_context(ctx);- ctx.prune_scratch(session, &open_identities(&open));+ let (session, open, _active, truncated) = ownership_context(ctx);+ prune_unless_truncated(ctx, session, &open, truncated); // A document SOLIDWORKS will not answer for is treated as dirty. `dirty` // absent or null used to be read as clean via `unwrap_or(false)`, so such a@@ -366,6 +441,24 @@ pub fn close_all_documents(ctx: &Ctx, args: &Value) -> Value { let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(); let path = d.get("path").and_then(|p| p.as_str()).filter(|s| !s.is_empty()); let owned = ctx.owns_doc(&title, path, session);+ // A PATHLESS DOCUMENT IS CLOSED BY ITS TITLE, and `CloseDoc` resolves+ // a title case-insensitively — so this is the same hazard the drain+ // guards, in the other verb that reaches it. Without the guard, our+ // untitled `Part1.SLDPRT` beside the user's saved `part1.SLDPRT` would+ // hand SOLIDWORKS a name that matches both and let it choose.+ if path.is_none() && !title_resolves_uniquely(&open, &title) {+ skipped.push(json!({+ "title": title,+ "path": Value::Null,+ "dirty": true,+ "why": "this document has NO PATH, so its title is the only name it has — and \+ another open document answers to that name too (SOLIDWORKS resolves a \+ title without regard to case). Nothing was attempted: no API call can \+ say which one is meant. Close it in SOLIDWORKS, or save one of them \+ under another name.",+ }));+ continue;+ } // NO SECOND SERVER-SIDE CHECK HERE, and the reason is worth stating: // reaching this line already required the FULL opt-in (the // session-ending grant above), which is the identical condition a@@ -422,6 +515,26 @@ pub fn close_all_documents(ctx: &Ctx, args: &Value) -> Value { .iter() .filter_map(|s: &Value| s.get("title").and_then(|t| t.as_str()).map(str::to_string)) .collect();+ // A FLOODED SESSION HAS NO LISTS. The host omits them above the+ // enumeration cap (GetDocuments does not answer on 2144 documents),+ // and `remaining: null` must not read as "nothing was left" — that is+ // the empty-to-confident collapse, in the verb whose whole job is to+ // account for every document still open.+ if r.get("listsOmittedTooManyDocuments").and_then(|v| v.as_bool()).unwrap_or(false) {+ skipped.push(json!({+ "title": Value::Null,+ "path": Value::Null,+ "dirty": Value::Null,+ "why": format!(+ "UNKNOWN: {} document(s) were open and {} are open now — too many to \+ enumerate, so this bridge cannot name which ones are still there. It is \+ not a claim that none are. swx_docs reports the count and a sample; if \+ these are leaked import temporaries, swx_close_import_temps drains them.",+ r.get("documentCountBefore").cloned().unwrap_or(Value::Null),+ r.get("documentCountAfter").cloned().unwrap_or(Value::Null),+ ),+ }));+ } for d in r.get("remaining").and_then(|v| v.as_array()).cloned().unwrap_or_default() { let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(); if named.iter().any(|n| *n == title) {@@ -475,14 +588,568 @@ pub fn close_all_documents(ctx: &Ctx, args: &Value) -> Value { /// `swx_reset_workspace` — close the scratch documents THIS BRIDGE created, and /// nothing else. The ledger is what makes that possible: a document the bridge /// did not create is never in it, so it is never closed here.+/// `swx_close_import_temps` — drain leaked STEP/IGES-import temporaries.+///+/// These are documents SOLIDWORKS created under the bridge's own temp directory+/// (`…\Temp\swx<pid>\IC~~\…`) while converting an imported file, and never+/// saved to a real location. A live session was found holding 1342 of them+/// (8 GB) — the same two fixtures imported over and over and never closed, the+/// real root of the crate-build memory exhaustion. See docs/IMPORT-LEAK.md.+///+/// Gated with a `reason` (it closes documents on the user's desktop, and the+/// reason is written for them) but NOT the write opt-in: the IC~~ path is proof+/// the document is a transient bridge temp, stronger than the ownership+/// registry — it survives the pid change that made the 1342 un-ownable, and no+/// user document can match it. So it works in the DEFAULT posture, by design.+///+/// Drains in BOUNDED batches: each `close_import_temps` call closes at most a+/// batch and reports `done`, and this verb loops until done or a wall budget,+/// so neither a single com call nor this verb runs unbounded on a huge count.+pub fn close_import_temps(ctx: &Ctx, args: &Value) -> Value {+ gate!(ctx);+ if let Some(g) = require_reason(+ args,+ "swx_close_import_temps",+ "it closes leaked STEP/IGES import temporaries on the user's SOLIDWORKS session",+ ) {+ return g;+ }+ let batch = args.get("batch").and_then(|v| v.as_u64()).unwrap_or(200).clamp(1, 1000);+ let started = std::time::Instant::now();+ let budget = std::time::Duration::from_secs(+ args.get("maxSeconds").and_then(|v| v.as_u64()).unwrap_or(240).clamp(5, 580),+ );+ let mut total_closed = 0u64;+ let mut batches = 0u64;+ let mut before: Option<i64> = None;+ let mut after: Option<i64> = None;+ let mut done = false;+ let mut last_err: Option<String> = None;+ // THE ROOT THE DOCUMENTS WERE COMPARED AGAINST, and the documents that carry+ // the launcher's tail but failed that comparison. Without these a matched-+ // nothing drain is indistinguishable from a nothing-to-match drain, which is+ // the whole of adversary pass 8's finding.+ let mut temp_root: Value = Value::Null;+ let mut temp_roots: Value = Value::Null;+ let mut elsewhere: u64 = 0;+ let mut elsewhere_sample: Value = Value::Null;+ let mut scanned: Value = Value::Null;+ let mut walk_timed_out: Value = Value::Null;+ let mut walk_complete: Value = Value::Null;+ let mut walk_stop: Value = Value::Null;+ let mut collisions_over: Value = Value::Null;+ let mut closed_docs: Vec<Value> = Vec::new();+ let mut close_ignored_path = false;+ let mut close_outcome_unknown = false;+ let mut collisions: Vec<Value> = Vec::new();+ let mut close_failed: Vec<Value> = Vec::new();+ let mut wrong_document_closed = false;+ loop {+ match ctx.call("close_import_temps", json!({"max": batch}), 60) {+ Err(e) => {+ last_err = Some(e.message);+ break;+ }+ Ok(r) => {+ // NO RESOLVED %TEMP%, NO DRAIN. The temp root is the whole+ // location proof (a shape test alone qualified a restored profile+ // backup on another volume), so when the host cannot resolve it+ // there is nothing this verb can safely identify — and a+ // `closed:0` success would read as "there was nothing to drain"+ // on a session holding hundreds of them.+ if r.get("tempRoot").map(|v| v.is_null()).unwrap_or(false) {+ return err_code(+ "temp_root_unknown",+ "cannot drain import temporaries: the com host could not resolve this \+ user's %TEMP%, and the resolved temp directory is the ONLY proof that a \+ document is a transient import temp rather than a file the user owns",+ "Nothing was closed and nothing is claimed about the session. The drain \+ closes documents with no save prompt, so it identifies them by their \+ location under the resolved %TEMP% — a path that merely LOOKS like a temp \+ (a restored profile backup, another user's profile) is not enough. \+ swx_docs still reports the count and a sample; the user can close them in \+ SOLIDWORKS, and a bridge restart in their own session usually resolves \+ %TEMP% again.",+ );+ }+ batches += 1;+ temp_root = r.get("tempRoot").cloned().unwrap_or(Value::Null);+ temp_roots = r.get("tempRoots").cloned().unwrap_or(Value::Null);+ elsewhere = r.get("importTempsElsewhere").and_then(|v| v.as_u64()).unwrap_or(0);+ elsewhere_sample =+ r.get("importTempsElsewhereSample").cloned().unwrap_or(Value::Null);+ // How far the host's WALK got. On a flooded session this verb+ // looks at a bounded window per call by design — never at the+ // whole session, which is the call that killed the com host.+ scanned = r.get("documentsScanned").cloned().unwrap_or(Value::Null);+ walk_timed_out = r.get("walkTruncatedByTime").cloned().unwrap_or(Value::Null);+ walk_complete = r.get("walkComplete").cloned().unwrap_or(Value::Null);+ walk_stop = r.get("walkStop").cloned().unwrap_or(Value::Null);+ collisions_over = r.get("titleCollisionsCheckedOver").cloned().unwrap_or(Value::Null);+ if r.get("closeIgnoredPath").and_then(|v| v.as_bool()).unwrap_or(false) {+ close_ignored_path = true;+ }+ // ABSENT IS UNKNOWN, NOT FALSE. `unwrap_or(false)` here turned a+ // field an older com host never sends into a confident "the+ // outcome was determined" — in the one place that decides whether+ // a human has to go and look. The handshake now refuses a version+ // skew outright, and this is the second guard: a reply that+ // reports a close FAILURE while saying nothing about the outcome+ // is treated as undetermined.+ match r.get("closeOutcomeUnknown").and_then(|v| v.as_bool()) {+ Some(true) => close_outcome_unknown = true,+ Some(false) => {}+ None => {+ if r.get("closeFailed").and_then(|v| v.as_array()).map(|a| !a.is_empty())+ .unwrap_or(false)+ {+ close_outcome_unknown = true;+ }+ }+ }+ // EVERY DOCUMENT CLOSED, with its title AND its path — the caller+ // needs to know WHICH files went, not how many.+ if let Some(a) = r.get("closedDocs").and_then(|v| v.as_array()) {+ closed_docs.extend(a.iter().cloned());+ }+ // A TITLE IS NOT AN IDENTITY: victims whose title another open+ // document shares are never closed, and the caller has to be told+ // WHICH, because the fix is theirs to make (save one of them under+ // another name, or close it themselves).+ if let Some(a) = r.get("titleCollisions").and_then(|v| v.as_array()) {+ collisions.extend(a.iter().cloned());+ }+ if let Some(a) = r.get("closeFailed").and_then(|v| v.as_array()) {+ close_failed.extend(a.iter().cloned());+ }+ if r.get("wrongDocumentClosed").and_then(|v| v.as_bool()).unwrap_or(false) {+ wrong_document_closed = true;+ }+ let closed = r.get("closed").and_then(|v| v.as_u64()).unwrap_or(0);+ total_closed += closed;+ if before.is_none() {+ before = r.get("documentCountBefore").and_then(|v| v.as_i64());+ }+ after = r.get("documentCountAfter").and_then(|v| v.as_i64());+ done = r.get("done").and_then(|v| v.as_bool()).unwrap_or(true);+ if closed > 0 {+ crate::ledger::record(+ "import_temps_closed",+ json!({"closed": closed, "batch": batches, "by": "swx_close_import_temps"}),+ );+ }+ // Stop when the host says there is nothing left, when a batch+ // closed nothing (no progress), or when the wall budget is up.+ // STOP IMMEDIATELY on the impossible one. If a close reported+ // success and the document is still open, something else was+ // closed — nothing further is attempted, and the caller is told+ // loudly rather than left to notice.+ if wrong_document_closed+ || close_ignored_path+ || close_outcome_unknown+ || done+ || closed == 0+ || started.elapsed() >= budget+ {+ break;+ }+ }+ }+ }+ if let Some(e) = last_err {+ // Report what the host said, but keep whatever we DID close visible —+ // a partial drain that then hit an error still made progress.+ return err_code(+ "import_temp_drain_failed",+ format!("closed {total_closed} import temp(s), then the com host errored: {e}"),+ "Some import temporaries were closed before the error; run swx_close_import_temps again to continue, and swx_docs to see the current count.",+ );+ }+ // ── THE SECOND KIND OF IMPORT TEMPORARY, added 0.6.0 after the live run.+ // The 1342 leaked temps had `…\swx<pid>\IC~~\…` paths, so the drain's whole+ // definition of "a temporary" was that path shape. A FRESH IMPORT HAS NO+ // PATH AT ALL (`LoadFile4`, measured 2026-09-11) — so on the machine this+ // verb exists for, three open import temporaries matched nothing and the+ // drain reported `closed:0, done:true`: the silent no-op again, from a new+ // cause.+ //+ // A document THIS BRIDGE CREATED BY IMPORTING, still unsaved, is a+ // temporary by ownership rather than by path — and the ownership registry+ // is the record that says so. As of 0.6.0 every import is given a file at+ // birth, so this is the path for documents created BEFORE that (or with+ // keepUnsaved:true), and for anything the identity step could not name.+ let (session, open_docs, _active, list_truncated) = ownership_context(ctx);+ let mut owned_unsaved: Vec<Value> = Vec::new();+ // AN UNLISTABLE SET IS NOT A UNIQUE ONE. With the open documents unlistable+ // this bridge cannot tell whether a title it is about to close by name is+ // shared — so it does not attempt any of them, and `done` cannot be true.+ let mut registry_sweep_blind = false;+ if list_truncated {+ registry_sweep_blind = !ctx.scratch_titles().is_empty();+ }+ if !list_truncated {+ for d in &open_docs {+ let title = doc_title(d);+ let path = doc_path(d).filter(|p| !p.is_empty());+ if path.is_some() {+ continue; // pathed: the location rule above already judged it+ }+ if ctx.owns_doc(&title, None, session) {+ owned_unsaved.push(json!({"title": title, "path": Value::Null}));+ }+ }+ }+ // Close the ones we can still name. A pathless document can only be named by+ // TITLE, so it is closable only while that title is unique among the open+ // documents — which is exactly what `close_doc` enforces, and exactly why+ // 0.6.0 stopped creating pathless documents in the first place.+ let mut owned_closed = 0u64;+ let mut unaddressable: Vec<Value> = Vec::new();+ for d in &owned_unsaved {+ let title = d.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();+ // CASE-INSENSITIVELY, because that is how `CloseDoc` RESOLVES a name on+ // Windows. Comparing with `==` made the guard blind to an ambiguity the+ // close itself can act on: the bridge's untitled `Part1.SLDPRT` beside+ // the user's saved `part1.SLDPRT` looked unique here and resolved to+ // whichever SOLIDWORKS found first there. A guard must ask the question+ // the way the act will answer it.+ let same = open_docs+ .iter()+ .filter(|o| doc_title(o).eq_ignore_ascii_case(&title))+ .count();+ if !title_resolves_uniquely(&open_docs, &title) {+ unaddressable.push(json!({+ "title": title,+ "openWithThatTitle": same,+ "why": "this bridge created this document and it has NO PATH, so its title is the \+ only name it has — and another open document shares it, up to the case \+ SOLIDWORKS ignores when it resolves a name. No API call can say which one \+ is meant, so nothing was attempted.",+ }));+ continue;+ }+ match ctx.call("close_doc", json!({"docName": title, "save": false}), 60) {+ Ok(_) => {+ owned_closed += 1;+ ctx.forget_scratch(&title);+ }+ Err(e) => unaddressable.push(json!({+ "title": title,+ "openWithThatTitle": same,+ "why": format!("the com host would not close it: {}", e.message),+ })),+ }+ }+ let total_closed = total_closed + owned_closed;++ // A DRAIN THAT MATCHED NOTHING WHILE DOCUMENTS CARRY THE LAUNCHER'S TAIL has+ // not finished — it has compared against the wrong directory, and saying+ // "every open import temporary was closed" there is a confident wrong claim+ // about the user's session. On 0.5.3 that is exactly what it said, with three+ // temps open. A miss is the safe direction for the CLOSE; it is never the safe+ // direction for the REPORT.+ let root_mismatch = elsewhere > 0 && total_closed == 0;+ // A CLOSE THAT WENT TO THE WRONG DOCUMENT is the one outcome this verb must+ // never produce, so it is reported as a FAILURE with everything measured —+ // not as a partial success with a note.+ // WE DO NOT KNOW WHAT HAPPENED, and that is the answer. The count could not be+ // read after a close that reported success on a document that is still open —+ // and a count can become unreadable BECAUSE something large just changed, so+ // the reassuring reading ("the call did nothing") is exactly the one this+ // bridge may not take.+ if close_outcome_unknown {+ crate::ledger::record(+ "import_temp_drain_outcome_undetermined",+ json!({"closed": total_closed, "closeFailed": close_failed.clone()}),+ );+ let victim = close_failed+ .last()+ .and_then(|f| f.get("path").and_then(|p| p.as_str()))+ .unwrap_or("(unknown)")+ .to_string();+ let mut out = err_code(+ "close_outcome_unknown",+ format!(+ "UNDETERMINED: CloseDoc returned success for {victim}, that document is still \+ open, and the open-document count could not be read afterwards — so it cannot be \+ told whether NOTHING was closed or ANOTHER document was. {total_closed} \+ temporary/ies had been closed before this point"+ ),+ "A HUMAN MUST LOOK. Ask the user what is on their screen: if a document of theirs has \+ closed, it was NOT saved by this bridge. `closeFailed` names the path this call was \+ about and the count immediately before it. The drain stopped and will not continue \+ until the count reads again — an unreadable count usually means the session is under \+ pressure, and swx_status reports the process's memory.",+ );+ if let Value::Object(m) = &mut out {+ m.insert("outcome".into(), json!("undetermined"));+ m.insert("closeOutcomeUnknown".into(), json!(true));+ m.insert("closed".into(), json!(total_closed));+ m.insert("closedDocs".into(), json!(closed_docs));+ m.insert("closeFailed".into(), json!(close_failed));+ m.insert("titleCollisions".into(), json!(collisions));+ m.insert("documentCountBefore".into(), before.map(Value::from).unwrap_or(Value::Null));+ m.insert("documentCountAfter".into(), after.map(Value::from).unwrap_or(Value::Null));+ m.insert("windowEtiquette".into(), json!("background"));+ }+ return out;+ }+ // THE PREMISE FAILED, NOT THE MACHINE. `CloseDoc`'s help text says only "name+ // of the document to close"; that a FULL PATH is accepted is this bridge's+ // assumption. If the call returns success and changes nothing, that assumption+ // is wrong on this build — and the answer is NOT to fall back to a title,+ // because a title is not an identity and that is the whole reason the close key+ // was changed. Refuse, name the premise, and leave the session alone.+ if close_ignored_path {+ crate::ledger::record(+ "import_temp_drain_close_by_path_unsupported",+ json!({"closed": total_closed, "closeFailed": close_failed.clone()}),+ );+ let mut out = err_code(+ "close_by_path_unsupported",+ format!(+ "STOPPED: CloseDoc(<full path>) returned success, the document is still open, and \+ the open-document count did not move — this SOLIDWORKS build does not appear to \+ accept a full path as the close key. {total_closed} temporary/ies had been closed \+ before this point"+ ),+ "This bridge will NOT retry by title. `CloseDoc` resolves a name, titles are not \+ unique across paths, and a session with leaked temporaries usually holds several \+ documents sharing one title — closing by title would let SOLIDWORKS choose which one \+ died. So nothing further was attempted. Report this with the VERBATIM response (it is \+ the one premise in the drain that has never been proven on a live machine); meanwhile \+ the user can close the temporaries in SOLIDWORKS, and swx_docs reports the count and \+ a sample.",+ );+ if let Value::Object(m) = &mut out {+ m.insert("closed".into(), json!(total_closed));+ m.insert("closedDocs".into(), json!(closed_docs));+ m.insert("closeFailed".into(), json!(close_failed));+ m.insert("closeIgnoredPath".into(), json!(true));+ m.insert("titleCollisions".into(), json!(collisions));+ m.insert("documentCountBefore".into(), before.map(Value::from).unwrap_or(Value::Null));+ m.insert("documentCountAfter".into(), after.map(Value::from).unwrap_or(Value::Null));+ m.insert("windowEtiquette".into(), json!("background"));+ }+ return out;+ }+ if wrong_document_closed {+ crate::ledger::record(+ "import_temp_drain_wrong_document",+ json!({"closed": total_closed, "closeFailed": close_failed.clone()}),+ );+ let mut out = err_code(+ "wrong_document_closed",+ format!(+ "STOPPED: a close reported success and the document it was asked to close is \+ STILL OPEN, which means a DIFFERENT document may have been closed instead. \+ {total_closed} temporary/ies had been closed before this point"+ ),+ "This should be impossible — the drain closes by full path and refuses any victim \+ whose title another open document shares — so it is a bug to report, with the \+ VERBATIM response. Nothing further was attempted. Tell the user which documents were \+ open: if one of theirs has closed, it was NOT saved by this bridge and its unsaved \+ changes are gone; SOLIDWORKS' own File ▸ Open Recent still lists it if it had a \+ saved path.",+ );+ if let Value::Object(m) = &mut out {+ m.insert("closed".into(), json!(total_closed));+ m.insert("closedDocs".into(), json!(closed_docs));+ m.insert("closeFailed".into(), json!(close_failed));+ m.insert("titleCollisions".into(), json!(collisions));+ m.insert("documentCountBefore".into(), before.map(Value::from).unwrap_or(Value::Null));+ m.insert("documentCountAfter".into(), after.map(Value::from).unwrap_or(Value::Null));+ m.insert("windowEtiquette".into(), json!("background"));+ }+ return out;+ }+ ok(json!({+ "closed": total_closed,+ "batches": batches,+ "documentCountBefore": before,+ "documentCountAfter": after,+ // NOT DONE while documents this bridge created are still open and it+ // cannot name them. That is the state the live run was in, and it+ // reported `done:true`.+ "done": done && !root_mismatch && unaddressable.is_empty() && !registry_sweep_blind,+ // The directory the documents' paths had to sit under, so a mismatch is+ // diagnosable from the output on a machine where nobody can attach a+ // debugger.+ "tempRoot": temp_root,+ "tempRoots": temp_roots,+ "importTempsElsewhere": elsewhere,+ "importTempsElsewhereSample": elsewhere_sample,+ "ownedUnsavedImportsClosed": owned_closed,+ // The registry sweep was not even attempted: see `done`.+ "ownedUnsavedSweepBlind": registry_sweep_blind,+ // NAMED, not counted: a caller who has to tell the user "close these+ // yourself" needs the titles.+ "ownedUnsavedImportsUnaddressable": unaddressable,+ "documentsScanned": scanned,+ "walkTruncatedByTime": walk_timed_out,+ // Did the last walk REACH THE END of the session? A `GetNext` that failed+ // mid-walk is not the end of the session, and `done` must not say it was.+ "walkComplete": walk_complete,+ // reachedEnd | hitMax | timedOut | iterationFailed+ "walkStop": walk_stop,+ "closedDocs": closed_docs,+ "titleCollisions": collisions,+ // The collision check only sees the documents the walk PASSED.+ "titleCollisionsCheckedOver": collisions_over,+ "closeFailed": close_failed,+ "windowEtiquette": "background",+ "_hint": if registry_sweep_blind {+ "This bridge owns documents that may still be open, and the open-document set is too \+ large to enumerate — so it cannot tell whether any of their titles is shared, and a \+ title-only document may only be closed by name. Nothing was attempted on them. Drain \+ the pathed temporaries first (run this again), then swx_docs will enumerate and this \+ sweep can see what it is doing."+ .to_string()+ } else if !unaddressable.is_empty() {+ format!(+ "{} document(s) this bridge created are still open and CANNOT BE NAMED: they have \+ no path, and another open document shares each one's title, so no SOLIDWORKS API \+ call can say which is meant. Nothing was attempted on them. Ask the user to close \+ them in SOLIDWORKS (File > Close), or close every document and start again. This \+ cannot happen to documents created by 0.6.0 or later, which are given a file of \+ their own at birth — see `documentIdentity` on swx_import and swx_new_document.",+ unaddressable.len()+ )+ } else if !collisions.is_empty() {+ format!(+ "{} import temporary/ies were NOT closed because another OPEN document carries the \+ same title, and `CloseDoc` resolves a document by name without prompting — asking \+ by that title would let SOLIDWORKS choose which one died. `titleCollisions` lists \+ the title and every open path that shares it. Close it yourself in SOLIDWORKS, or \+ save the other document under another name, then run this again. Everything else \+ in the batch WAS closed ({} of them), each one by its full path and verified gone \+ afterwards. NOTE: collisions were checked among the {} document(s) this call \+ SCANNED, not across the whole session — a same-titled document outside that window \+ is not in this list.",+ collisions.len(),+ total_closed,+ collisions_over+ )+ } else if root_mismatch {+ format!(+ "NOTHING WAS CLOSED, and this is not the same as nothing being open: {elsewhere} \+ open document(s) carry the launcher's own \\swx<pid>\\IC~~\\<file> path but are \+ NOT under any temp directory this bridge could resolve ({}). The temp root this \+ process sees may not be the one SOLIDWORKS uses — Adom Bridge can run under a \+ different profile or a redirected TEMP, and these temps were written by an \+ earlier bridge process. Nothing was closed BY DESIGN: the resolved temp \+ directory is the whole proof that a document is transient rather than the user's, \+ and the close does not prompt. To drain them, find the directory in \+ `importTempsElsewhereSample` and restart the bridge with SWX_TEMP_ROOT set to the \+ temp directory above the swx<pid> folder — or ask the user to close them in \+ SOLIDWORKS. swx_docs reports the count.",+ match &temp_roots {+ Value::Array(a) if !a.is_empty() => a+ .iter()+ .filter_map(|v| v.as_str())+ .collect::<Vec<_>>()+ .join(", "),+ _ => "none".to_string(),+ }+ )+ } else if done {+ "Every open STEP/IGES import temporary was closed. These are documents SOLIDWORKS created under the bridge's temp dir while converting an imported file and never saved anywhere real — never a user document (a user file has a real saved path). Import leaves one open unsaved document per call, like swx_new_document; close or save it to avoid leaking more."+ .to_string()+ } else {+ "Closed a batch of import temporaries and there are more — run swx_close_import_temps again to continue. It is bounded per call so it cannot wedge the com host on a huge count. swx_docs shows the current count."+ .to_string()+ },+ }))+}++/// THE LAUNCH'S CLAIM MUST BE ABOUT THE SESSION THIS VERB WOULD ACT ON.+///+/// Shared by every verb that can END or EMPTY somebody's SOLIDWORKS. An+/// attribution record is a claim about ONE process; if COM is now answering for a+/// different one, that claim authorises nothing here. When no claim exists this+/// returns `Ok` and the ordinary rules apply unchanged — a user's own session is+/// still refused by the rules that already refuse it.+///+/// WIRED INTO THREE VERBS, and that is the point: `identity_matches_attribution`+/// shipped in 0.6.7 unit-tested and with ZERO production callers — the fifth+/// guard in this project to do so, and the third about attribution. A guard that+/// nothing calls is a comment with a test attached.+fn identity_claim_ok(ctx: &Ctx) -> Result<(), Value> {+ let claimed = crate::launch::attributed_solidworks();+ if claimed.is_none() {+ return Ok(());+ }+ // The live identity, from COM and the census — the same pair the quit ladder+ // builds its `Identity` from.+ let state = ctx.call("app_state", json!({}), 20);+ let pid = state+ .as_ref()+ .ok()+ .and_then(|s| s.get("processId"))+ .and_then(|v| v.as_u64())+ .map(|p| p as u32);+ let os = crate::launchos::os();+ let identity = pid.and_then(|pid| {+ let created = os+ .processes()+ .ok()?+ .iter()+ .find(|p| p.pid == pid)+ .and_then(|p| p.created_at)?;+ Some(crate::quit::Identity { pid, created_at: created, main_hwnd: None })+ });+ match crate::quit::identity_matches_attribution(claimed, identity.as_ref()) {+ Ok(()) => Ok(()),+ Err(r) => Err(err_code(+ r.code(),+ r.message(),+ "The launch record and the live COM session name DIFFERENT SOLIDWORKS processes, so \+ this bridge cannot say the session it would act on is the one it launched. Nothing \+ was attempted. swx_status reports both; swx_start re-establishes which session it is \+ talking to, and restarting the bridge clears a stale claim.",+ )),+ }+}+ pub fn reset_workspace(ctx: &Ctx, args: &Value) -> Value { gate!(ctx);+ if let Err(e) = identity_claim_ok(ctx) {+ return e;+ } // PRUNE FIRST. Ownership is scoped to a SOLIDWORKS session and to documents // that are still open; without this the reported list (and the sweep) could // name a title from a previous session — which, since SOLIDWORKS restarts // its Part numbering, might now be the USER's document.- let (session, open_docs, _active) = ownership_context(ctx);- ctx.prune_scratch(session, &open_identities(&open_docs));+ let (session, open_docs, _active, truncated) = ownership_context(ctx);+ prune_unless_truncated(ctx, session, &open_docs, truncated);+ // A SWEEP THAT CANNOT SEE THE SESSION IS NOT A CLEAN SWEEP. With the open+ // list unlistable this reported `success:true, closed:[], remaining:[]` while+ // `trackedScratchDocuments` listed rows of its own — a silent no-op that a+ // caller reads as "there was nothing to clean". Refuse the same way+ // `resolve_document` does.+ if truncated {+ let mut out = err_code(+ "document_list_unavailable",+ "swx_reset_workspace cannot sweep: SOLIDWORKS has more documents open than can be \+ enumerated (or would not say how many), so the open-document list is UNKNOWN — not \+ empty, and nothing can be identified as ours to close",+ "This is almost always leaked STEP/IGES import temporaries: run \+ swx_close_import_temps to drain them (it identifies them by path and needs no write \+ opt-in), then retry. NOTHING was closed. The ownership registry is intact — it is \+ deliberately not pruned against an unlistable session — so retrying after the drain \+ sweeps exactly what it should.",+ );+ if let Value::Object(m) = &mut out {+ // What the bridge BELIEVES it owns is still worth reporting: it is+ // the thing a caller would have swept, and seeing it is how they can+ // tell "nothing to do" from "I could not look".+ m.insert("trackedScratchDocuments".into(), json!(ctx.scratch_titles()));+ m.insert("closed".into(), json!([]));+ m.insert("remaining".into(), Value::Null);+ m.insert("solidworksSession".into(), json!(session));+ }+ return out;+ } let only: Option<Vec<String>> = args.get("titles").and_then(|v| v.as_array()).map(|a| { a.iter().filter_map(|v| v.as_str()).map(String::from).collect()@@ -506,8 +1173,48 @@ pub fn reset_workspace(ctx: &Ctx, args: &Value) -> Value { continue; } }+ // A FILE THE BRIDGE GAVE IT IS NOT A SAVE THE USER ASKED FOR. From+ // 0.6.0 every bridge-created document has a file (that is what makes it+ // addressable); one inside the bridge's own scratch directory is still+ // scratch, and the sweep exists to close exactly those.+ let saved = saved+ && !path.map(crate::scratch::is_bridge_scratch_path).unwrap_or(false); if saved && !force {- skipped.push(json!({"title": title, "why": "has been saved to disk; pass force:true"}));+ // A PATH IS NOT A SAVE. `saved = path.is_some()` conflates the two,+ // and an import temporary has a path (`…\Temp\swx<pid>\IC~~\…`)+ // while never having been saved anywhere the user could find it.+ // Telling a caller it "has been saved to disk; pass force:true"+ // invites a `force` sweep to prove a fact that was never true; the+ // honest route for a temp is the verb that exists for temps.+ let is_temp = path.map(crate::comhost::host::is_import_temp).unwrap_or(false);+ skipped.push(json!({+ "title": title,+ "path": path,+ "importTemp": is_temp,+ "why": if is_temp {+ "this is a STEP/IGES import TEMPORARY — it has a path under the bridge's temp \+ directory but was never saved anywhere real. swx_close_import_temps drains \+ these (no write opt-in needed); `force` is not the right tool."+ } else {+ "has been saved to disk; pass force:true"+ },+ }));+ continue;+ }+ // A PATHLESS DOCUMENT IS CLOSED BY ITS TITLE, and `CloseDoc` resolves a+ // title without regard to case — so the sweep asks the same question the+ // drain does, in the other verb that reaches this act. Our untitled+ // `Part1.SLDPRT` beside the user's saved `part1.SLDPRT` is one name to+ // SOLIDWORKS, and it would choose.+ if path.is_none() && !title_resolves_uniquely(&open_docs, &title) {+ skipped.push(json!({+ "title": title,+ "path": Value::Null,+ "why": "ours, but it has NO PATH and another open document answers to the same \+ title (SOLIDWORKS resolves a title without regard to case) — so no API \+ call can say which one is meant and nothing was attempted. Close it in \+ SOLIDWORKS, or save the other document under another name.",+ })); continue; } match ctx.call("close_doc", json!({"docName": title.clone(), "path": path, "save": false}), 60) {@@ -519,11 +1226,15 @@ pub fn reset_workspace(ctx: &Ctx, args: &Value) -> Value { Err(e) => skipped.push(json!({"title": title, "why": e.message})), } }- let remaining = ctx- .call("docs", json!({}), 30)- .ok()- .and_then(|r| r.get("docs").cloned())- .unwrap_or_else(|| json!([]));+ // NULL, NOT `[]`, when the read failed or came back truncated. An empty+ // array here reads as "nothing is left open", which is the one thing a+ // failed read must never be allowed to say.+ let remaining = match ctx.call("docs", json!({}), 30) {+ Ok(r) if r.get("truncated").and_then(|v| v.as_bool()) != Some(true) => {+ r.get("docs").cloned().unwrap_or(Value::Null)+ }+ _ => Value::Null,+ }; ok(json!({ "closed": closed, "untitledScratchClosed": closed.len(),@@ -728,7 +1439,11 @@ pub fn dismiss_blocking_dialogs(ctx: &Ctx, args: &Value) -> Value { let all = match crate::abwin::list_windows(AB_TIMEOUT) { Err(e) => { return ok(json!({- "dialogs": [],+ // NULL, not []. An empty census reads as "nothing is in the+ // way" — which is precisely the wrong thing to tell a caller+ // about to drive a SOLIDWORKS that may be sitting on a modal.+ "dialogs": Value::Null,+ "dialogsKnown": false, "abError": e, "_hint": "ab could not enumerate windows, so no dialog census is possible right \ now. swx_status still reports whether SOLIDWORKS is COM-attachable, \@@ -869,8 +1584,10 @@ pub fn close_window(ctx: &Ctx, args: &Value) -> Value { // window killer, and it must not become one by accident: the hwnd has to be // one we can attribute to SOLIDWORKS. let (main, pid) = window_identity(ctx);- let attributed = crate::abwin::list_windows(AB_TIMEOUT)- .map(|all| crate::abwin::solidworks_windows(&all, main, pid))+ let listed = crate::abwin::list_windows(AB_TIMEOUT)+ .map(|all| crate::abwin::solidworks_windows(&all, main, pid));+ let ab_error = listed.as_ref().err().cloned();+ let attributed = listed .unwrap_or_default() .into_iter() .find(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd));@@ -919,8 +1636,15 @@ pub fn close_window(ctx: &Ctx, args: &Value) -> Value { if !confirmed { return json!({ "success": false,- "errorCode": "window_not_attributed",- "error": format!("hwnd {hwnd} cannot be proved to belong to SOLIDWORKS"),+ // The refusal is the same either way — but WHY it was refused is+ // not, and a caller that reads "not attributable" when the truth is+ // "ab could not be asked" will go looking for the wrong problem.+ "errorCode": if ab_error.is_some() { "window_list_unavailable" } else { "window_not_attributed" },+ "error": match &ab_error {+ Some(e) => format!("hwnd {hwnd} could not be checked: ab would not list the desktop's windows ({e})"),+ None => format!("hwnd {hwnd} cannot be proved to belong to SOLIDWORKS"),+ },+ "abError": ab_error, "window": attributed, "mainHwnd": main, "_hint": "This verb closes SOLIDWORKS' own windows, and nothing else. ab's window \@@ -1022,18 +1746,24 @@ pub fn check_recovery(ctx: &Ctx, _args: &Value) -> Value { gate!(ctx); let com = ctx.call("check_recovery", json!({}), 30).unwrap_or_else(|e| json!({"error": e.message})); let (hwnd, pid) = window_identity(ctx);- let dialogs = crate::abwin::list_windows(AB_TIMEOUT)- .map(|all| crate::abwin::dialog_census(&all, hwnd, pid))- .unwrap_or_default();- let recovery: Vec<Value> = dialogs- .iter()- .filter(|d| d.get("kind").and_then(|k| k.as_str()) == Some("document_recovery"))- .cloned()- .collect();+ // "ab could not be asked" is NOT "there is no recovery pane". This verb+ // answers a question about a session that has just come back from a crash;+ // a fabricated `recoveryPending:false` is the worst available answer.+ let census = crate::abwin::list_windows(AB_TIMEOUT)+ .map(|all| crate::abwin::dialog_census(&all, hwnd, pid));+ let recovery: Option<Vec<Value>> = census.as_ref().ok().map(|dialogs| {+ dialogs+ .iter()+ .filter(|d| d.get("kind").and_then(|k| k.as_str()) == Some("document_recovery"))+ .cloned()+ .collect()+ }); ok(json!({- "recoveryDialogs": recovery.clone(),- "recoveryPending": !recovery.is_empty(),- "dialogs": dialogs,+ "recoveryDialogs": recovery.clone().map(Value::from).unwrap_or(Value::Null),+ "recoveryPending": recovery.as_ref().map(|r| !r.is_empty()).map(Value::from).unwrap_or(Value::Null),+ "recoveryKnown": recovery.is_some(),+ "dialogs": census.as_ref().ok().cloned().map(Value::from).unwrap_or(Value::Null),+ "abError": census.as_ref().err().cloned(), "com": com, "_hint": "SOLIDWORKS has no COM member that answers 'is the recovery pane up', so this \ is a WINDOW-level answer: a Document Recovery dialog on screen means the last \@@ -1074,6 +1804,108 @@ pub fn verb_times(_ctx: &Ctx, _args: &Value) -> Value { // ── documents: create, import, save-as, show ──────────────────────────────── /// `swx_new_document` — the verb Drew asked for. LIVE-PROVEN.+/// GIVE A NEWLY-CREATED DOCUMENT AN IDENTITY OF ITS OWN, immediately.+///+/// LIVE, 2026-09-11: `drain-gate.py` imported one STEP fixture twice and BOTH+/// documents came back with `path:""`. A fresh `LoadFile4` import has no path at+/// all — the `…\IC~~\…` conversion paths on the 1342 leaked temps came from a+/// different, older route — and SOLIDWORKS titles both imports from the same file+/// stem. The result was three open documents with one title and no paths, and+/// therefore NO WAY TO NAME ANY ONE OF THEM: `swx_save_as`, `swx_reset_workspace`+/// and `swx_close_doc` all refused with `ambiguous_target` (correctly — a title+/// is not an identity), the drain matched none of them, and the session could+/// only be cleared by hand.+///+/// A title is not an identity, so this bridge stops creating documents that have+/// nothing else. Immediately after a document is created, it is saved into the+/// bridge's own scratch directory under a unique name, which makes it:+/// addressable by path, ownable by path, drainable, closable, and impossible to+/// confuse with a second document of the same title.+///+/// THE TRADE-OFF, stated plainly: a file now exists on disk that did not before.+/// It is inside the bridge's own cache (`exports/scratch/`), it is removed by+/// `swx_uninstall`, and `keepUnsaved:true` opts out — at the price of a document+/// that may be unaddressable, which the response then says out loud.+/// IS THIS TITLE A NAME THAT RESOLVES TO EXACTLY ONE OPEN DOCUMENT?+///+/// Asked the way `CloseDoc` will answer it: SOLIDWORKS resolves a name+/// case-INSENSITIVELY on Windows, so `Part1.SLDPRT` and `part1.SLDPRT` are one+/// name to the act even though they are two strings to a comparison. A guard that+/// asks a different question from the act is not a guard.+///+/// This exists as one function because there are TWO places that close a+/// pathless document by title — the registry drain and the workspace sweep — and+/// a rule enforced in one of them is a rule with a hole in it.+fn title_resolves_uniquely(open_docs: &[Value], title: &str) -> bool {+ open_docs.iter().filter(|o| doc_title(o).eq_ignore_ascii_case(title)).count() == 1+}++fn give_identity(ctx: &Ctx, title: &str, reported_path: Option<&str>, kind: &str) -> Value {+ // Already has one: an `open`ed file, or a backend that pathed it for us.+ if reported_path.map(|p| !p.trim().is_empty()).unwrap_or(false) {+ return json!({"identity": "path", "path": reported_path, "title": title});+ }+ let stem = title.rsplit_once('.').map(|(a, _)| a).unwrap_or(title);+ let ext = match kind {+ "assembly" => "SLDASM",+ "drawing" => "SLDDRW",+ _ => "SLDPRT",+ };+ let dir = crate::scratch::scratch_docs_dir();+ // UNIQUE BY CONSTRUCTION: the session, the title stem and a counter. Two+ // imports of one fixture in one session must not collide, and a document+ // from a PREVIOUS session must not be overwritten by this one.+ let n = crate::scratch::next_scratch_seq();+ let file = format!("{}-{n}.{ext}", sanitise_stem(stem));+ let out = dir.join(&file);+ let out_s = out.to_string_lossy().to_string();+ // The ACTIVE document, deliberately unnamed in the call: SOLIDWORKS has just+ // activated what it created, and naming it by title is the very ambiguity+ // this exists to prevent. copy:false, because a COPY would write the file and+ // leave the open document as pathless as it was.+ match ctx.call("save_as", json!({"out": out_s, "copy": false}), 120) {+ Ok(r) => {+ let saved = r+ .get("savedAs")+ .and_then(|v| v.as_str())+ .map(String::from)+ .unwrap_or_else(|| out_s.clone());+ // THE TITLE CHANGES WITH THE FILE. A real Save As renames the open+ // document to the file it now is, so the caller — and the ownership+ // registry — must be told the name it will actually answer to.+ let new_title = crate::comhost::mock::base_name(&saved);+ json!({+ "identity": "path",+ "path": saved,+ "title": new_title,+ "scratchFile": true,+ })+ }+ Err(e) => json!({+ "identity": "titleOnly",+ "path": Value::Null,+ "scratchFile": false,+ "warning": format!(+ "this document could not be given a file of its own ({}), so its TITLE is the only \+ name it has — and a title is not an identity. If a second document of the same \+ title is opened, neither can be addressed by any verb (`ambiguous_target`), \+ including to close it. Save it somewhere with swx_save_as, or close it.",+ e.message+ ),+ }),+ }+}++/// A file name a filesystem will accept, from a document title.+fn sanitise_stem(stem: &str) -> String {+ let cleaned: String = stem+ .chars()+ .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })+ .collect();+ let trimmed = cleaned.trim_matches('_').to_string();+ if trimmed.is_empty() { "doc".into() } else { trimmed }+}+ pub fn new_document(ctx: &Ctx, args: &Value) -> Value { gate!(ctx); let kind = arg_str(args, "type").unwrap_or("part").to_string();@@ -1081,11 +1913,49 @@ pub fn new_document(ctx: &Ctx, args: &Value) -> Value { let call_args = json!({"type": kind, "template": args.get("template").cloned()}); let started = std::time::Instant::now(); match ctx.call("new_document", call_args, 110) {- Err(e) => sidecar_error(e),+ // A FAILED new_document IS THE EXHAUSTION SIGNAL. On 2026-09-03 both the+ // part and the assembly template came back with no document at 17.4 GB+ // of private memory and 52 open documents, and the bridge reported it as+ // an ordinary template problem — which sent the caller looking for a+ // fault in a template that was fine. Look at what the machine is+ // actually holding before blaming the template.+ Err(e) => match exhausted_error(ctx, &e) {+ Some(v) => v,+ None => sidecar_error(e),+ }, Ok(r) => { crate::ledger::record_time("swx_new_document", started.elapsed().as_secs_f64());+ let mut identity = json!({"identity": "unknown"}); if let Some(t) = r.get("created").and_then(|c| c.get("title")).and_then(|t| t.as_str()) {- ctx.remember_scratch(t, ownership_context(ctx).0);+ let t = t.to_string();+ let reported = r+ .get("created")+ .and_then(|c| c.get("path"))+ .and_then(|p| p.as_str())+ .filter(|p| !p.is_empty())+ .map(String::from);+ // IDENTITY AT BIRTH, for the same reason as `import`: SOLIDWORKS+ // restarts its Part1/Part2 numbering every session, so a title is+ // not even unique across time, let alone across documents.+ identity = if arg_bool(args, "keepUnsaved", false) {+ json!({+ "identity": "titleOnly",+ "path": reported,+ "scratchFile": false,+ "warning": "keepUnsaved:true — this document has only a TITLE. SOLIDWORKS \+ reuses Part1/Part2 every session, so a second document of that \+ title makes both unaddressable (ambiguous_target).",+ })+ } else {+ give_identity(ctx, &t, reported.as_deref(), &kind)+ };+ let session = ownership_context(ctx).0;+ let now_titled =+ identity.get("title").and_then(|v| v.as_str()).unwrap_or(&t).to_string();+ match identity.get("path").and_then(|p| p.as_str()).filter(|p| !p.is_empty()) {+ Some(p) => ctx.remember_scratch_at(&now_titled, session, p),+ None => ctx.remember_scratch(&now_titled, session),+ } } crate::ledger::record( "document_created",@@ -1095,21 +1965,159 @@ pub fn new_document(ctx: &Ctx, args: &Value) -> Value { "template": r.get("template").cloned(), }), );+ let mut r = r;+ if let Some(o) = r.as_object_mut() {+ if let Some(c) = o.get_mut("created").and_then(|v| v.as_object_mut()) {+ if let Some(t) = identity.get("title").and_then(|v| v.as_str()) {+ c.insert("title".into(), json!(t));+ }+ if let Some(p) =+ identity.get("path").and_then(|p| p.as_str()).filter(|p| !p.is_empty())+ {+ c.insert("path".into(), json!(p));+ }+ }+ }+ let titled_only = identity.get("identity").and_then(|v| v.as_str()) == Some("titleOnly"); ok_merge(r, json!({+ "documentIdentity": identity, "windowEtiquette": "background", "progress": job.finished(),- "_hint": "A NEW, UNSAVED document is now open and active — it has a title \- (Part1) and no path until you swx_save_as it. Build geometry with \- swx_run_script, then swx_save_as to put it on disk; swx_close_doc \- {save:false} throws it away. The bridge remembers it created this one \- (swx_action_ledger), which is what lets swx_reset_workspace clean up \- after itself without touching the user's own documents.",+ "_hint": if titled_only {+ "A NEW document is open and active, with a TITLE and NO PATH — so it can only \+ be named by that title, and SOLIDWORKS reuses Part1/Part2 every session. If a \+ second document of this title appears, neither can be addressed by any verb, \+ including to close it. swx_save_as gives it a name of its own."+ } else {+ "A NEW document is open and active, and the bridge has already given it a file \+ of its own in its scratch directory (`documentIdentity.path`) so it can be \+ addressed, owned and closed by PATH rather than by a title two documents can \+ share. Build geometry with swx_run_script, then swx_save_as to put it where \+ the user wants it; swx_close_doc {save:false} throws it away, and \+ swx_uninstall removes the scratch directory. Pass keepUnsaved:true to skip \+ the scratch file and accept the ambiguity."+ }, })) } } } /// `swx_import` — a neutral CAD file in as a new document (fusion_import_step).+/// A MUTATING CALL TIMED OUT. WHAT DID IT DO BEFORE IT STOPPED ANSWERING?+///+/// LIVE, 2026-09-11: an import whose com host timed out AFTER `LoadFile4` had+/// already succeeded left a document open that the bridge did not know about.+/// The verb reported failure — correctly, it could not confirm anything — and the+/// document was still there at the next baseline, owned by nobody, and (being+/// pathless and same-titled) unaddressable by any verb.+///+/// A timeout is "I do not know", and the answer to "I do not know" is to LOOK.+/// On the respawned host: list the documents, and adopt one that is unsaved, is+/// titled after the file we were importing, and was not open before the call. If+/// the list cannot be read, say so — never claim the session is clean.+///+/// Returns what to tell the caller: `None` when nothing was adopted and nothing+/// is in doubt.+fn reconcile_after_timeout(+ ctx: &Ctx,+ stem: &str,+ before_titles: &[String],+ before_count: Option<u64>,+) -> Option<Value> {+ let (session, open_docs, _active, truncated) = ownership_context(ctx);+ if truncated {+ return Some(json!({+ "reconciled": false,+ "adopted": Value::Null,+ "warning": "the call timed out AFTER it may have already opened the document, and the \+ open-document list cannot be enumerated, so this bridge cannot tell whether \+ a document was created. It is NOT claiming the session is unchanged. Run \+ swx_docs (count and sample) and swx_close_import_temps.",+ }));+ }+ let stem_l = stem.to_ascii_lowercase();+ // EVERY ONE OF THESE, or nothing is adopted. Adoption makes this bridge+ // willing to CLOSE a document, so "probably ours" is not a standard it may+ // use — and the cheapest wrong answer here is claiming one of the user's.+ //+ // * UNSAVED — an import that succeeded has not been saved by anyone;+ // * the TITLE STEM matches the file we were importing;+ // * it was NOT OPEN before our call (creation order, from the snapshot+ // taken immediately before it);+ // * and the open-document count really did rise by exactly one, so the+ // session agrees that exactly one document appeared.+ let count_rose_by_one = match (before_count, open_docs.len() as u64) {+ (Some(b), now) => now == b + 1,+ // No count before the call: we cannot say the session gained exactly one.+ (None, _) => false,+ };+ let candidates: Vec<&Value> = open_docs+ .iter()+ .filter(|d| {+ let t = doc_title(d);+ let unsaved = !d.get("saved").and_then(|v| v.as_bool()).unwrap_or(false)+ && doc_path(d).filter(|p| !p.is_empty()).is_none();+ !before_titles.iter().any(|b| b.eq_ignore_ascii_case(&t))+ && t.to_ascii_lowercase().contains(&stem_l)+ && unsaved+ })+ .collect();+ if candidates.len() == 1 && !count_rose_by_one {+ return Some(json!({+ "reconciled": false,+ "adopted": Value::Null,+ "candidate": doc_title(candidates[0]),+ "warning": format!(+ "one document matching '{stem}' appeared after the timed-out call, but the \+ open-document count did not rise by exactly one ({before_count:?} -> {}), so \+ this bridge cannot say that document is the one its call created. It claims \+ NOTHING — adoption is what lets it close a document, and it will not close one \+ on a maybe. swx_docs lists what is open.",+ open_docs.len()+ ),+ }));+ }+ match candidates.len() {+ 0 => None,+ 1 => {+ let t = doc_title(candidates[0]);+ ctx.remember_scratch(&t, session);+ crate::ledger::record(+ "document_adopted_after_timeout",+ json!({"title": t, "stem": stem, "why": "a mutating call timed out after the document had already been created; this bridge now owns it and can close it"}),+ );+ Some(json!({+ "reconciled": true,+ "adopted": {"title": t},+ "evidence": {+ "unsaved": true,+ "titleStemMatches": stem,+ "notOpenBeforeTheCall": true,+ "openCountRoseByExactlyOne": true,+ },+ "warning": format!(+ "the call timed out, but a document titled '{t}' appeared that was not open \+ before it, is unsaved, matches the file being imported, and is the only \+ document the session gained — the import had already succeeded when the com \+ host stopped answering. This bridge has ADOPTED it, so swx_close_doc and \+ swx_reset_workspace can now clean it up."+ ),+ }))+ }+ n => Some(json!({+ "reconciled": false,+ "adopted": Value::Null,+ "candidates": candidates.iter().map(|d| doc_title(d)).collect::<Vec<_>>(),+ "warning": format!(+ "the call timed out and {n} new unsaved documents matching '{stem}' are open — \+ this bridge will NOT guess which (if any) is the one its call created, so it \+ claims none of them. They may be unaddressable (same title, no path); ask the \+ user to close them in SOLIDWORKS."+ ),+ })),+ }+}+ pub fn import(ctx: &Ctx, args: &Value) -> Value { gate!(ctx); let path = match arg_str(args, "filePath").or_else(|| arg_str(args, "path")) {@@ -1125,24 +2133,105 @@ pub fn import(ctx: &Ctx, args: &Value) -> Value { }; let job = ctx.jobs.start("import", format!("Importing {}", crate::comhost::mock::base_name(&path)), 60); let started = std::time::Instant::now();+ // WHAT WAS OPEN BEFORE, so a timeout can be reconciled against it.+ let before_docs = ownership_context(ctx).1;+ let before_titles: Vec<String> = before_docs.iter().map(doc_title).collect();+ let before_count = Some(before_docs.len() as u64); match ctx.call("import", json!({"filePath": path}), 280) {+ Err(e) if e.timed_out => {+ let stem = crate::comhost::mock::base_name(&path);+ let stem = stem.rsplit_once('.').map(|(a, _)| a).unwrap_or(&stem).to_string();+ let mut out = sidecar_error(e);+ if let Some(rec) = reconcile_after_timeout(ctx, &stem, &before_titles, before_count) {+ if let Value::Object(m) = &mut out {+ m.insert("reconciliation".into(), rec);+ }+ }+ out+ } Err(e) => sidecar_error(e), Ok(r) => { crate::ledger::record_time("swx_import", started.elapsed().as_secs_f64());+ let mut identity = json!({"identity": "unknown"}); if let Some(t) = r.get("imported").and_then(|c| c.get("title")).and_then(|t| t.as_str()) {- ctx.remember_scratch(t, ownership_context(ctx).0);+ // ONE ROW, NOT TWO. Recording the title AND then the path added a+ // second, pathless row for the same document — five imports made+ // ten rows, and on an unlistable session (where the prune is+ // correctly skipped) the phantoms accumulate: 1342 leaked temps+ // would have written 2684 rows on every registry save.+ // RECORD THE PATH WHEN THERE IS ONE. An imported document is unsaved but it+ // still HAS a path — SOLIDWORKS' `GetPathName` returns its+ // `…\Temp\swx<pid>\IC~~\…` conversion temp — and ownership is+ // decided BY PATH whenever a document has one (`doc_matches`).+ // Remembering only the title meant the registry held `path:None`+ // while the open document reported a path, the two never matched,+ // and the bridge refused to close the very document it had just+ // imported: "which this bridge did not create". The mock hid this+ // for a release by reporting an empty path for temps.+ // An imported document is unsaved but it still HAS a path —+ // `GetPathName` returns its `…\Temp\swx<pid>\IC~~\…` conversion+ // temp — and ownership is decided BY PATH whenever a document has+ // one (`doc_matches`). A title-only row would never match it.+ let session = ownership_context(ctx).0;+ let reported = r+ .get("imported")+ .and_then(|c| c.get("path"))+ .and_then(|p| p.as_str())+ .filter(|p| !p.is_empty())+ .map(String::from);+ // IDENTITY AT BIRTH. A fresh import is PATHLESS live, and two+ // imports of one file share a title — see `give_identity`.+ identity = if arg_bool(args, "keepUnsaved", false) {+ json!({+ "identity": "titleOnly",+ "path": reported,+ "scratchFile": false,+ "warning": "keepUnsaved:true — this document has only a TITLE, and a fresh \+ import has no path of its own. If another document of the same \+ title is opened, neither can be addressed by any verb \+ (ambiguous_target), including to close it.",+ })+ } else {+ give_identity(ctx, t, reported.as_deref(), "part")+ };+ let now_titled =+ identity.get("title").and_then(|v| v.as_str()).unwrap_or(t).to_string();+ match identity.get("path").and_then(|p| p.as_str()).filter(|p| !p.is_empty()) {+ Some(p) => ctx.remember_scratch_at(&now_titled, session, p),+ None => ctx.remember_scratch(&now_titled, session),+ } } crate::ledger::record( "document_created", json!({"title": r.get("imported").and_then(|c| c.get("title")).cloned(), "via": "import"}), );+ let mut r = r;+ if let Some(o) = r.as_object_mut() {+ if let Some(imp) = o.get_mut("imported").and_then(|v| v.as_object_mut()) {+ if let Some(t) = identity.get("title").and_then(|v| v.as_str()) {+ imp.insert("title".into(), json!(t));+ }+ if let Some(p) =+ identity.get("path").and_then(|p| p.as_str()).filter(|p| !p.is_empty())+ {+ // The path the document ACTUALLY has now, so a caller who+ // reads `imported.path` can address it.+ imp.insert("path".into(), json!(p));+ }+ }+ } ok_merge(r, json!({+ "documentIdentity": identity, "windowEtiquette": "background", "progress": job.finished(),- "_hint": "Imported as a NEW unsaved document. Which formats work depends on the \- translators the install has — an unsupported one comes back as a \- clear refusal, not a dialog. swx_inspect_bodies tells you what \- actually arrived.",+ "_hint": "Imported as a NEW open document, exactly like \+ swx_new_document — it stays open and holds memory until you save it \+ (swx_save_as) or close it (swx_close_doc). Importing repeatedly \+ without closing is what leaks documents; swx_close_import_temps drains \+ any left behind. `documentsOpened`/`netCountOk` report that exactly one \+ document was added. Which formats work depends on the install's \+ translators — an unsupported one is a clear refusal, not a dialog; \+ swx_inspect_bodies tells you what actually arrived.", })) } }
src/writes.rs+69−3@@ -231,9 +231,19 @@ pub fn authorize( // (2) The destination must not already exist. This is the guard the DSL's // `save_as` shipped alone, correctly, while `save_as`, `export` and // `pack_and_go` silently destroyed whatever was there.- let clobbers = destination- .map(|d| !d.is_empty() && std::path::Path::new(d).exists())- .unwrap_or(false);+ // THREE ANSWERS. An unverifiable destination refuses outright, before the+ // opt-in is even consulted — the opt-in answers "may I overwrite this?", and+ // this is "I do not know what is there".+ let state = destination_state(destination);+ if let Destination::Unverifiable(io) = &state {+ let d = destination.unwrap_or("");+ return Err(crate::verbs::err_code(+ "destination_unverifiable",+ unverifiable_message(d, io),+ UNVERIFIABLE_HINT,+ ));+ }+ let clobbers = state == Destination::Occupied; // (1) The document must be ours. let foreign = kind.touches_document() && target.map(|t| !t.owned).unwrap_or(false);@@ -337,6 +347,62 @@ pub fn authorize( /// [`authorize`], plus the list of documents the caller COULD work on — the /// field that turns a refusal into a next step. Kept separate so the decision /// itself stays a pure function with no `Ctx` to stand up in a unit test.+/// IS THE DESTINATION FREE? THREE ANSWERS, NOT TWO.+///+/// `Path::exists()` returns `false` both for "nothing is there" and for "I could+/// not look" — a parent directory this process cannot traverse, a UNC share it+/// has no token for, a device that is not ready, a path over the length limit.+/// The write gate read the second as the first, which is the empty-to-confident+/// collapse in the one place in this bridge that decides whether somebody's file+/// survives.+///+/// It matters because the two processes DO NOT SHARE A FILESYSTEM VIEW. The com+/// host is a child of Adom Bridge; SOLIDWORKS runs in the user's interactive+/// session with the user's network token. `\\server\share\part.sldprt` that this+/// bridge cannot stat is a file SOLIDWORKS can open — and overwrite — with the+/// gate having already said yes.+///+/// `symlink_metadata` rather than `try_exists`, deliberately: a DANGLING SYMLINK+/// is something at that path, and writing through it would land somewhere the+/// caller did not name. `Ok(_)` is occupied, `NotFound` is free, and every other+/// error is UNVERIFIABLE — which refuses.+#[derive(Debug, Clone, PartialEq)]+pub enum Destination {+ /// Nothing is there, and we could see that for ourselves.+ Free,+ /// Something is there. Overwriting it needs the full opt-in.+ Occupied,+ /// The question could not be answered. NOT permission.+ Unverifiable(String),+}++pub fn destination_state(destination: Option<&str>) -> Destination {+ let Some(d) = destination.filter(|d| !d.is_empty()) else { return Destination::Free };+ match std::fs::symlink_metadata(std::path::Path::new(d)) {+ Ok(_) => Destination::Occupied,+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Destination::Free,+ Err(e) => Destination::Unverifiable(format!("{e}")),+ }+}++/// The refusal a caller gets when the destination could not be checked. Shared so+/// the two enforcement layers cannot drift into different words for one rule.+pub fn unverifiable_message(destination: &str, io_error: &str) -> String {+ format!(+ "this bridge cannot check whether anything is already at {destination} ({io_error}), and a \+ destination it cannot check is NOT free"+ )+}++pub const UNVERIFIABLE_HINT: &str =+ "The write was refused because the question could not be answered, not because the answer was \+ no. This bridge and SOLIDWORKS do not share a filesystem view — the com host is a child of \+ Adom Bridge, while SOLIDWORKS runs in the user's session with their network token — so a \+ path this process cannot stat (an unreachable UNC share, a directory it cannot traverse, a \+ device that is not ready, a path over the length limit) may hold a file SOLIDWORKS would \+ happily overwrite. Check the destination yourself, or use a path this bridge can see. The \+ opt-in does NOT waive this: it is not a permission question.";+ pub fn authorize_in( ctx: &crate::verbs::Ctx, args: &Value,
tests/adversary.rsadded+616@@ -0,0 +1,616 @@+//! 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_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."+ );+}
tests/adversary10.rsadded+152@@ -0,0 +1,152 @@+//! ADVERSARIAL REVIEW OF 0.5.6 — pass ten, the drain's close key and its window.+//!+//! The close key is fixed the right way: the victim is chosen by path and closed+//! BY THAT PATH, with a post-close verify through `GetOpenDocumentByName` — which+//! the SOLIDWORKS API documents as requiring a fully qualified path, so the+//! verify's key is correct. A victim whose title another SCANNED document shares+//! is refused and reported. `close_doc`/`reset_workspace` keep resolving by path+//! with `ambiguous_target` intact.+//!+//! What is not sound is the WINDOW. The drain walks a bounded window and then+//! reasons about the session as though it had seen all of it.+//!+//! Every #[test] here FAILS on 449bcfb.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// A session of 60 documents: an import temp first, 58 ordinary saved parts, and+/// a SECOND import temp past the end of the walk window that `batch:5` produces+/// (`max*4` floored at `max+50` = 55).+fn session_with_a_temp_beyond_the_window(scratch: &TempDir) {+ let root = r"C:\Users\drew\AppData\Local\Temp";+ let mut docs: Vec<Value> = vec![json!({+ "docType": "part", "path": "untitled:t0", "title": "First.step.SLDPRT",+ "saved": false, "importTemp": true,+ "tempPath": format!("{root}\\swx107688\\IC~~\\First.step.SLDPRT")+ })];+ for i in 0..58 {+ docs.push(json!({+ "docType": "part", "path": format!("C:\\work\\P{i}.SLDPRT"),+ "title": format!("P{i}.SLDPRT"), "saved": true, "importTemp": false+ }));+ }+ docs.push(json!({+ "docType": "part", "path": "untitled:t60", "title": "Far.step.SLDPRT",+ "saved": false, "importTemp": true,+ "tempPath": format!("{root}\\swx107688\\IC~~\\Far.step.SLDPRT")+ }));+ std::fs::write(+ scratch.join("mock-solidworks-open-docs.json"),+ json!({"running": true, "untitled": 2, "active": "untitled:t0",+ "docs": docs, "files": {}})+ .to_string(),+ )+ .unwrap();+}++fn drain(scratch: &TempDir) -> (Bridge, Value) {+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({"reason": "draining"}));+ let r = b.call("swx_close_import_temps", json!({"reason": "draining leaked temps", "batch": 5}));+ (b, r)+}++/// `done` is computed from `hit_limit`, and `hit_limit` is seeded from+/// `walk_stop == WalkStop::TimedOut` ALONE:+///+/// ```text+/// let walk_timed_out = walk_stop == WalkStop::TimedOut;+/// let mut hit_limit = walk_timed_out;+/// ```+///+/// `WalkStop::HitMax` (the walk hit its scan cap without reaching the end) and+/// `WalkStop::IterationFailed` (a document closed mid-walk invalidated the+/// iteration) both leave `hit_limit` false. So when fewer than `max` victims are+/// found inside a window that did NOT reach the end of the session, the drain+/// reports `done:true` — with temps still open beyond the window.+///+/// `WalkStop::complete()` exists for exactly this and is not consulted here.+/// The evidence is already in the response: `documentsScanned` (55) against+/// `documentCountBefore` (60).+#[test]+fn done_is_never_true_when_the_walk_did_not_reach_the_end() {+ let scratch = TempDir::new("adv10-a");+ session_with_a_temp_beyond_the_window(&scratch);+ let (b, r) = drain(&scratch);++ let scanned = r["documentsScanned"].as_u64().unwrap_or(0);+ let before = r["documentCountBefore"].as_u64().unwrap_or(0);+ assert!(+ scanned > 0 && before > scanned,+ "precondition: the walk covered {scanned} of {before} documents: {}",+ s(&r)+ );+ assert!(+ r["done"] != true,+ "the drain reported done:true having scanned only {scanned} of {before} open documents. \+ `hit_limit` is seeded from WalkStop::TimedOut alone, so a walk that stopped at its scan \+ cap (HitMax) or on an invalidated iteration (IterationFailed) is treated as one that \+ reached the end. WalkStop::complete() is defined for this and is not used in the `done` \+ computation, and the two numbers that settle it are already in this response.\n{}\n\+ --- server log ---\n{}",+ s(&r),+ b.log()+ );+}++/// The collision check compares each victim's title against `seen` — the walked+/// WINDOW — so a same-titled document outside it is invisible. That is safe for+/// the CLOSE (the path is the key), but the report then says `titleCollisions: []`+/// as though it had looked at the session. It has not, and the drain must say the+/// check was window-limited: `walkStop` is computed and named+/// (`WalkStop::name()`) and never published, so a caller cannot tell+/// `reachedEnd` from `hitMax` from `iterationFailed`.+#[test]+fn the_report_says_how_far_the_walk_got_and_what_that_limits() {+ let scratch = TempDir::new("adv10-b");+ session_with_a_temp_beyond_the_window(&scratch);+ let (_b, r) = drain(&scratch);+ assert!(+ !r["walkStop"].is_null(),+ "the four-state WalkStop is computed and named inside the host and never reaches the \+ caller, so `titleCollisions: []` and `done` cannot be interpreted: a collision check over \+ 55 of 60 documents is not the same claim as one over the session.\n{}",+ s(&r)+ );+}++/// MOCK FIDELITY, and it is why the 257 green tests say nothing about any of the+/// above. The real backend selects victims from the WALKED WINDOW; the mock+/// selects them with `self.docs.iter().filter(is_temp).take(max)` over the WHOLE+/// session, and its collision check iterates the whole session too. Measured on+/// the fixture above: `documentsScanned: 55`, `closed: 2` — the mock closed a+/// document its own reported scan window never reached.+///+/// So the mock proves the close KEY (which is what 0.5.6 fixed) and cannot prove+/// anything about the window: not the collision check's blind spot, not `done`,+/// not `importTempsElsewhere`'s coverage.+#[test]+fn the_mock_only_closes_documents_it_actually_walked() {+ let scratch = TempDir::new("adv10-c");+ session_with_a_temp_beyond_the_window(&scratch);+ let (_b, r) = drain(&scratch);+ let scanned = r["documentsScanned"].as_u64().unwrap_or(0);+ let closed = r["closed"].as_u64().unwrap_or(0);+ // The far temp is the 60th document; a window of 55 cannot have reached it,+ // so a faithful backend closes exactly one.+ assert!(+ closed <= 1,+ "the mock closed {closed} temporaries while reporting documentsScanned:{scanned} — one of \+ them sits at document 60, outside the window it says it walked. Victim selection and the \+ collision check are not window-bounded in the mock, so every window property the real \+ backend has is untested.\n{}",+ s(&r)+ );+}
tests/adversary11.rsadded+131@@ -0,0 +1,131 @@+//! ADVERSARIAL REVIEW OF 0.5.7 — pass eleven.+//!+//! Pass ten's three findings are closed at the mechanism. `done` now requires+//! `walk_stop.complete() && scanned_all && collisions.is_empty() &&+//! close_failed.is_empty()`, so a walk that stopped at its scan cap or on an+//! invalidated iteration can no longer report a finished drain. `walkStop`,+//! `titleCollisionsCheckedOver`, `closedDocs`, `closeFailed`, `titleCollisions`,+//! `closeIgnoredPath` and `wrongDocumentClosed` are all forwarded to the caller.+//! The mock's drain is now window-bounded (`.take(window)`, `walk_complete =+//! window >= self.docs.len()`), so the window properties are testable at last.+//! And `mock-closedoc-ignores-path` exists, so the capability case has a fixture.+//!+//! One branch of the new discriminator has no fixture, and it is the branch that+//! decides whether a user's document was closed.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// THE DISCRIMINATOR'S THIRD CASE HAS NO FIXTURE.+///+/// When `CloseDoc(path)` returns success and the document is still open, 0.5.7+/// asks whether the open-document count moved:+///+/// ```text+/// let nothing_happened = match (expected, now) {+/// (Some(e), Some(n)) => n >= e,+/// _ => true, // <-- an UNREADABLE count+/// };+/// ```+///+/// An unreadable count takes the BENIGN branch: `close_by_path_unsupported`, and+/// a `why` that states as fact that "the call did nothing". But the count can+/// become unreadable BECAUSE something large just changed — closing the wrong+/// document (a big assembly) is exactly the kind of event that makes+/// `GetDocumentCount` stop answering. In that case the drain reports a+/// capability problem and accuses nobody, when the truth is that a document may+/// have been closed and nobody can say which.+///+/// Everywhere else in this bridge an unknown refuses and SAYS unknown. Here it+/// picks the reassuring answer. There should be a third outcome — stop, and+/// report the outcome as UNDETERMINED with the paths to check — and it needs a+/// fixture before it can exist: the mock's drain reads its count from a `Vec`+/// length, so `mock-doccount-none` does not reach it and the `_ => true` arm is+/// dead code as far as the suite is concerned.+#[test]+fn the_drain_can_model_an_unreadable_count_during_a_close() {+ let scratch = TempDir::new("adv11-scratch");+ let root = r"C:\Users\drew\AppData\Local\Temp";+ std::fs::write(+ scratch.join("mock-solidworks-open-docs.json"),+ json!({+ "running": true, "untitled": 1, "active": "untitled:t0",+ "docs": [{+ "docType": "part", "path": "untitled:t0", "title": "First.step.SLDPRT",+ "saved": false, "importTemp": true,+ "tempPath": format!("{root}\\swx107688\\IC~~\\First.step.SLDPRT")+ }],+ "files": {}+ })+ .to_string(),+ )+ .unwrap();+ // CloseDoc ignores the path form (the capability case) AND the count cannot+ // be read — the two together are the arm with no coverage.+ std::fs::write(scratch.join("mock-closedoc-ignores-path"), "").unwrap();+ std::fs::write(scratch.join("mock-doccount-none"), "").unwrap();++ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({"reason": "draining"}));+ let r = b.call("swx_close_import_temps", json!({"reason": "draining leaked temps", "batch": 1}));++ assert!(+ r["documentCountBefore"].is_null(),+ "`mock-doccount-none` is set and the drain still reported a document count, because the \+ mock reads it from a Vec length rather than through the same unreadable-count path the \+ real backend has. The `_ => true` arm of the discriminator — an UNREADABLE count taken \+ as 'the call did nothing' — therefore has no fixture, and it is the arm that decides \+ between a capability problem and a user's document having been closed.\n{}",+ s(&r)+ );+}++/// Pass ten's `done` rule, pinned. A walk that stopped at its scan cap must not+/// report a finished drain even when every victim it FOUND was closed. This+/// passes on 0.5.7 and is here so the rule cannot quietly regress.+#[test]+fn done_still_requires_a_walk_that_reached_the_end() {+ let scratch = TempDir::new("adv11-window");+ let root = r"C:\Users\drew\AppData\Local\Temp";+ let mut docs: Vec<Value> = vec![json!({+ "docType": "part", "path": "untitled:t0", "title": "First.step.SLDPRT",+ "saved": false, "importTemp": true,+ "tempPath": format!("{root}\\swx107688\\IC~~\\First.step.SLDPRT")+ })];+ for i in 0..58 {+ docs.push(json!({+ "docType": "part", "path": format!("C:\\work\\P{i}.SLDPRT"),+ "title": format!("P{i}.SLDPRT"), "saved": true, "importTemp": false+ }));+ }+ docs.push(json!({+ "docType": "part", "path": "untitled:t60", "title": "Far.step.SLDPRT",+ "saved": false, "importTemp": true,+ "tempPath": format!("{root}\\swx107688\\IC~~\\Far.step.SLDPRT")+ }));+ std::fs::write(+ scratch.join("mock-solidworks-open-docs.json"),+ json!({"running": true, "untitled": 2, "active": "untitled:t0",+ "docs": docs, "files": {}})+ .to_string(),+ )+ .unwrap();++ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({"reason": "draining"}));+ let r = b.call("swx_close_import_temps", json!({"reason": "draining", "batch": 5}));+ let scanned = r["documentsScanned"].as_u64().unwrap_or(0);+ let before = r["documentCountBefore"].as_u64().unwrap_or(0);+ assert!(before > scanned, "precondition: the walk was window-limited: {}", s(&r));+ assert!(+ r["done"] != true,+ "done:true after scanning {scanned} of {before}: {}",+ s(&r)+ );+}
tests/adversary12.rsadded+139@@ -0,0 +1,139 @@+//! ADVERSARIAL REVIEW OF 0.5.8 — final pass.+//!+//! The third outcome is right: `close_outcome_unknown` sets+//! `outcome:"undetermined"`, names the victim's title and path and the pre-close+//! count, says in words that it cannot be told whether nothing closed or another+//! document did, and BREAKS. It never makes the benign claim. Every count read in+//! the drain goes through the one `Self::document_count` seam, so the mock can+//! make it unreadable everywhere at once.+//!+//! The remaining finding is in the write gate, and it is data loss.++mod common;++use common::Bridge;+use serde_json::{json, Value};+use std::os::unix::fs::PermissionsExt;++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// THE DESTINATION TEST CANNOT TELL "ABSENT" FROM "COULD NOT LOOK".+///+/// The choke point's second condition is:+///+/// ```text+/// let clobbers = destination+/// .map(|d| !d.is_empty() && std::path::Path::new(d).exists())+/// .unwrap_or(false);+/// ```+///+/// `Path::exists()` is `fs::metadata(p).is_ok()` — it returns **false** for a+/// path that does not exist AND for one the process could not stat: permission+/// denied on a parent, a UNC share the bridge has no token for, a device not+/// ready, a path over the length limit. All of those become "the destination is+/// free", and a bridge-created document is then written there in the DEFAULT+/// posture with no opt-in, no `confirm` and no reason.+///+/// The bridge and SOLIDWORKS do not always have the same view of the filesystem.+/// The com host is a child of ab; SOLIDWORKS runs in the user's interactive+/// session with their network token. `\\server\share\part.sldprt` that the bridge+/// cannot stat is one SOLIDWORKS can open and overwrite — and the gate has+/// already said yes.+///+/// This is the one rule in the bridge that decides whether somebody's file+/// survives, and it is the only place left where an unanswerable question is+/// read as permission. `fs::try_exists` returns `io::Result<bool>` and+/// distinguishes the three cases; an `Err` must refuse.+#[test]+fn a_destination_the_bridge_cannot_stat_is_not_treated_as_free() {+ let base = std::path::PathBuf::from("/tmp/adv12-unreadable-dest");+ let _ = std::fs::remove_dir_all(&base);+ std::fs::create_dir_all(base.join("share")).unwrap();+ // The user's file. It is really there.+ std::fs::write(base.join("share/user-part.SLDPRT"), b"the user's only copy").unwrap();+ // ...behind a parent the bridge cannot traverse, which is what an+ // unreachable UNC share or a permission-denied path looks like to `exists()`.+ std::fs::set_permissions(base.join("share"), std::fs::Permissions::from_mode(0o000)).unwrap();+ let dest = base.join("share/user-part.SLDPRT");+ assert!(+ !dest.exists(),+ "precondition: Path::exists() reports FALSE for a file that is really there"+ );++ let b = Bridge::start(); // DEFAULT posture: no SWX_ALLOW_USER_WRITES+ b.call("swx_start", json!({"reason": "modelling"}));+ let made = b.call("swx_new_document", json!({"type": "part"}));+ let title = made["created"]["title"].as_str().unwrap_or("Part1").to_string();++ let r = b.call(+ "swx_save_as",+ json!({"targetDocument": title, "out": dest.to_string_lossy()}),+ );+ // Restore before asserting, so a failure cannot leave an unreadable dir behind.+ let _ = std::fs::set_permissions(base.join("share"), std::fs::Permissions::from_mode(0o755));++ let code = r["errorCode"].as_str().unwrap_or("");+ assert!(+ // `destination_unverifiable` is the code 0.5.9 shipped for this refusal —+ // added to this list, not substituted for it: the assertion is still+ // "the gate REFUSED, with a refusal code", and the list was written+ // before the name existed. Nothing that means "allowed" is in it.+ matches!(+ code,+ "destination_exists"+ | "user_writes_disabled"+ | "destination_unreadable"+ | "destination_unverifiable"+ ),+ "the write gate GRANTED a save into a destination it could not stat — a file the user \+ owns is sitting at that exact path. The call then failed only because this test's \+ filesystem refused it; on Windows, where SOLIDWORKS holds the user's token and the \+ bridge may not, the host would have written and the user's file would be gone. \+ `Path::exists()` cannot tell 'absent' from 'could not look', and the choke point reads \+ the second as permission.\nerrorCode: {code:?}\n{}",+ s(&r)+ );+}++/// The rest of the destination rule, pinned. These pass and must keep passing:+/// an existing file blocks the write in the default posture, and a genuinely new+/// path under a readable directory is allowed without any opt-in (the+/// choke-point rule the drain gate depends on).+#[test]+fn the_destination_rule_still_holds_where_the_answer_is_knowable() {+ let base = std::path::PathBuf::from("/tmp/adv12-knowable");+ let _ = std::fs::remove_dir_all(&base);+ std::fs::create_dir_all(&base).unwrap();+ std::fs::write(base.join("taken.SLDPRT"), b"the user's file").unwrap();++ let b = Bridge::start();+ b.call("swx_start", json!({"reason": "modelling"}));+ let made = b.call("swx_new_document", json!({"type": "part"}));+ let title = made["created"]["title"].as_str().unwrap_or("Part1").to_string();++ // An OCCUPIED destination is refused without the opt-in.+ let over = b.call(+ "swx_save_as",+ json!({"targetDocument": title, "out": base.join("taken.SLDPRT").to_string_lossy()}),+ );+ assert!(+ over["success"] != true,+ "an existing user file was overwritten in the default posture: {}",+ s(&over)+ );++ // A NEW path is allowed with no opt-in at all — bridge-created document,+ // destination that does not exist.+ let fresh = b.call(+ "swx_save_as",+ json!({"targetDocument": title, "out": base.join("fresh.SLDPRT").to_string_lossy()}),+ );+ assert!(+ fresh["success"] == true,+ "the choke-point rule should allow a bridge-created document to a NEW path without an \+ opt-in; this is the case tools/drain-gate.py depends on: {}",+ s(&fresh)+ );+}
tests/adversary13.rsadded+210@@ -0,0 +1,210 @@+//! ADVERSARIAL REVIEW OF 0.6.0 — pass thirteen.+//!+//! The sign-in work is careful: `helperSignIn` recognises the login window by+//! CLASS **and** owner pid in our own chain, the deadlines pause rather than+//! kill, and an unreadable census pauses too. `reset_workspace`'s new "a file+//! the bridge gave it is not a save the user asked for" rule is gated behind+//! `owns_doc` first, so it can only ever relax the guard on a document already+//! proven ours — it cannot reach a user's file. The registry drain checks title+//! uniqueness against the WHOLE open set, not just the owned ones, which is the+//! right set.+//!+//! I attacked two of those guards and BOTH HELD. Neither test below is a+//! finding; both are REGRESSION PINS that pass on 59fff9b, kept because the+//! properties they pin are the ones a future refactor would break quietly.+//! What each one did and did not establish is written on it.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};+use swx_bridge::launchos::FakeOs;++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++// ════════════════════════════════════════════════════════════════════════════+// 1. THE UNIQUENESS GUARD AND THE CLOSE KEY DISAGREE ABOUT CASE+// ════════════════════════════════════════════════════════════════════════════++/// The registry drain closes a title-only document only while its title is+/// unique among the open set:+///+/// ```text+/// let same = open_docs.iter().filter(|o| doc_title(o) == title).count();+/// if same != 1 { ...refuse... }+/// ```+///+/// That comparison is `==` — byte-exact and case-SENSITIVE. The call it guards+/// is `close_doc {docName}`, which resolves through `CloseDoc`, and Windows+/// name resolution is case-INSENSITIVE. So a user document titled+/// `part1.SLDPRT` beside our `Part1.SLDPRT` is invisible to the guard+/// (`same == 1`, "unique") and fully visible to the close.+///+/// PIN, NOT A FINDING. I built the collision (`Part1` ours, `part1` the user's,+/// both open) and the drain closed OURS — the right one. So the mismatch did+/// NOT produce a wrong close here, and I am not claiming it does: the mock's+/// `find_by_name` resolves by path before title, which may be doing the work+/// that `CloseDoc` would not. What stands is a code-level concern only: the+/// guard is `==` while `CloseDoc` resolves case-insensitively on Windows, so the+/// guard cannot NOTICE an ambiguity the close can act on, and real name+/// resolution has no documented tie-break. `eq_ignore_ascii_case` in the guard+/// costs nothing and removes the question.+#[test]+fn the_uniqueness_guard_compares_titles_the_way_closedoc_resolves_them() {+ let scratch = TempDir::new("adv13-case");+ // The USER's saved document, titled to differ from ours only in CASE. An+ // unsaved SOLIDWORKS title carries no extension, so the collision is+ // `Part1` (ours) against `part1` (theirs) — not `Part1.SLDPRT`.+ std::fs::write(+ scratch.join("mock-solidworks-open-docs.json"),+ json!({+ "running": true, "untitled": 0, "active": null,+ "docs": [{"docType": "part", "path": "C:\\Users\\drew\\work\\part1.SLDPRT",+ "title": "part1", "saved": true, "importTemp": false}],+ "files": {}+ })+ .to_string(),+ )+ .unwrap();++ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({"reason": "draining"}));+ // OURS, created through the bridge so the ownership registry is REAL — an+ // earlier version of this test hand-wrote owned-docs.json with a guessed+ // session id, which `owns_doc` rejected, so the registry sweep never ran and+ // the assertion below passed vacuously.+ let made = b.call("swx_new_document", json!({"type": "part", "keepUnsaved": true}));+ assert_eq!(+ made["created"]["title"].as_str(),+ Some("Part1"),+ "precondition: a title-only document of ours, colliding with the user's only in case: {}",+ s(&made)+ );+ let r = b.call("swx_close_import_temps", json!({"reason": "draining leaked temps"}));+ // NOT VACUOUS: the registry sweep must have REACHED our document and judged+ // it. 0.6.2 refuses it — the collision is detected up to case, which is how+ // CloseDoc resolves — and that refusal is the evidence the branch ran.+ let unaddressable = r["ownedUnsavedImportsUnaddressable"].as_array().cloned().unwrap_or_default();+ let judged = unaddressable.iter().find(|u| u["title"] == "Part1");+ assert!(+ judged.is_some(),+ "precondition: the registry sweep reached our title-only document and judged it: {}",+ s(&r)+ );+ assert_eq!(+ judged.unwrap()["openWithThatTitle"].as_u64(),+ Some(2),+ "the guard must count the user's `part1` as sharing our `Part1` — CloseDoc resolves \+ names case-insensitively, so a case-SENSITIVE count would call ours unique and close \+ it by a name that can resolve to theirs: {}",+ s(&r)+ );+ let after = b.call("swx_docs", json!({}));+ let users_doc_alive = after["docs"]+ .as_array()+ .map(|a| {+ a.iter().any(|d| {+ d.get("path").and_then(|p| p.as_str()) == Some("C:\\Users\\drew\\work\\part1.SLDPRT")+ })+ })+ .unwrap_or(false);+ assert!(+ users_doc_alive,+ "REGRESSION: the user's saved document is gone. The registry drain judges a title-only \+ document unique with a case-SENSITIVE `==` and then closes it by name, and CloseDoc \+ resolves case-insensitively — so the guard cannot see an ambiguity the close can act \+ on.\ndrain: {}\nafter: {}",+ s(&r),+ s(&after)+ );+}++// ════════════════════════════════════════════════════════════════════════════+// 2. RE-ATTRIBUTION TREATS "NEVER OBSERVED" AS "NEVER HAPPENED"+// ════════════════════════════════════════════════════════════════════════════++/// `reattribute_after_attach` adopts a running SOLIDWORKS as ours when our route+/// reached `Run Begin`, `sldworks.created >= invokedAt`, and+/// `!a.foreign_chain_seen`.+///+/// The third condition is a record of what we HAPPENED TO SEE. Once the attempt+/// finishes the supervisor stops polling, so a launcher chain the user starts+/// afterwards is never observed and `foreign_chain_seen` stays false — while the+/// timing test passes trivially, because their session is newer than our invoke.+/// The bridge then records `attachedAfterSignIn` for a session it did not start.+///+/// What follows from that is the consent problem: `quit-gate.py`'s+/// `require_bridge_launched` reads exactly these fields, so the quit ladder+/// becomes willing to end the user's session, and `close_all_documents` becomes+/// willing to close their documents.+///+/// PIN, NOT A FINDING. The attack did not reproduce: on a four-route attempt the+/// bridge had already latched `foreignChainSeen: true`, and the attempt-level+/// `runBeginSeen` was false, so re-attribution was refused twice over before my+/// scenario could reach it. Whether a SINGLE-route attempt that never observes a+/// foreign chain could slip through, I did not establish and do not assert.+/// The hardening still worth making: the census carries `ProcInfo.ppid`, and a+/// SOLIDWORKS ours started descends from the launcher WE started — lineage is+/// evidence, where "we never saw one" is only absence of observation.+#[test]+fn a_session_descended_from_a_foreign_launcher_is_never_adopted_as_ours() {+ let dir = TempDir::new("adv13-reattr");+ // OUR route reaches Run Begin and never produces sldworks — the sign-in+ // shape — so the attempt finishes un-attached.+ std::fs::write(+ dir.join("routes.json"),+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0},+ "powershell": {"chain": true, "runBeginAfterMs": 0},+ "explorer": {"chain": true, "runBeginAfterMs": 0},+ "cmdStart": {"chain": true, "runBeginAfterMs": 0},+ // The USER's Start-Menu launch, which really does produce one.+ "userStartMenu": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0}})+ .to_string(),+ )+ .unwrap();+ std::fs::write(dir.join("preexisting.json"), json!([]).to_string()).unwrap();+ let lnk = format!("{}/SOLIDWORKS Design.lnk", dir.path());+ std::fs::write(&lnk, "x").unwrap();++ let b = Bridge::start_in(+ TempDir::new("adv13-scratch"),+ &[+ ("SWX_SOLIDWORKS_SHORTCUT", &lnk),+ ("SWX_LAUNCH_FAKE_DIR", &dir.path()),+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "1"),+ ("SWX_LAUNCH_SLDWORKS_SECS", "2"),+ ("SWX_LAUNCH_ATTACH_SECS", "3"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ("SWX_LAUNCH_BACKOFF_SECS", "0"),+ ],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ // Let every route finish and the supervisor stop polling.+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(40);+ loop {+ let st = b.call("swx_status", json!({}));+ if st["launch"]["finished"] == true || std::time::Instant::now() > deadline {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(200));+ }+ // NOW the user starts SOLIDWORKS themselves. Nothing of ours is watching,+ // so their chain is never entered in `foreignChainSeen`.+ FakeOs::record_invoke(std::path::Path::new(&dir.path()), "userStartMenu", &lnk);+ std::thread::sleep(std::time::Duration::from_millis(400));++ let st = b.call("swx_status", json!({}));+ let outcome = st["launch"]["outcome"].as_str().unwrap_or("");+ assert_ne!(+ outcome, "attachedAfterSignIn",+ "REGRESSION: the bridge adopted a SOLIDWORKS the USER started. `foreign_chain_seen` \+ records what was OBSERVED, and once the supervisor stops polling nothing is; the timing \+ test passes trivially for any session newer than our invoke. quit-gate's \+ require_bridge_launched reads these very fields, so the quit ladder would then be \+ willing to end the user's session.\n{}\n--- server log ---\n{}",+ s(&st["launch"]),+ b.log()+ );+}
tests/adversary14.rsadded+188@@ -0,0 +1,188 @@+//! ADVERSARIAL REVIEW OF 0.6.1 — pass fourteen.+//!+//! NO CONFIRMED FINDINGS. 0.6.1's re-attribution now runs on every `report`, so+//! the question is whether a status read can flip a user's session to ours. It+//! cannot on timing alone: a POSITIVE lineage link is required, and both proofs+//! fail closed once our chain is dead — `log_authored_by_our_chain` needs+//! `thread_owner(tid)`, which needs the writer's process ALIVE, and the parent+//! walk needs a live census entry to start from.+//!+//! THIS PIN CANNOT CURRENTLY BE SATISFIED, and that is the finding. It fails on+//! its own precondition, because `foreign_chain_seen` latches on OUR OWN+//! launcher (see the second test) and `reattribute_after_attach` bails on that+//! flag before it consults lineage at all. So the lineage proof — the whole+//! point of 0.6.1 and 0.6.2's hardening of it — is unreachable on an ordinary+//! run, and neither the Builder's tests nor my earlier pass-14 pin ever+//! exercised it. Fix the flag and this pin starts testing what it says.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};+use swx_bridge::launchos::FakeOs;++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// Our route reaches `Run Begin` and dies without producing SOLIDWORKS — the+/// shape 0.5.9 mis-handled. The user then starts their own, which is newer than+/// our invoke and descends from THEIR launcher. Re-attribution must refuse, and+/// must keep refusing on every subsequent read (no flip-flop).+#[test]+fn a_session_with_no_lineage_link_is_never_adopted_however_often_status_is_read() {+ let dir = TempDir::new("adv14-lineage");+ std::fs::write(+ dir.join("routes.json"),+ // ONE route only, and it ends in `sldworksUpNotAttached` (Run Begin,+ // a SOLIDWORKS appears, COM never attaches) so the ladder STOPS after+ // it. That matters: on a multi-route attempt `foreignChainSeen` latches+ // true and `reattribute_after_attach` bails on THAT before lineage is+ // ever consulted — which is how an earlier version of this pin passed+ // without exercising the lineage proof at all.+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0}})+ .to_string(),+ )+ .unwrap();+ std::fs::write(dir.join("preexisting.json"), json!([]).to_string()).unwrap();+ // The user's SOLIDWORKS descends from a launcher that was never ours.+ std::fs::write(dir.join("sldworks-parent"), "4242").unwrap();+ let lnk = format!("{}/SOLIDWORKS Design.lnk", dir.path());+ std::fs::write(&lnk, "x").unwrap();++ let b = Bridge::start_in(+ TempDir::new("adv14-scratch"),+ &[+ ("SWX_SOLIDWORKS_SHORTCUT", &lnk),+ ("SWX_LAUNCH_FAKE_DIR", &dir.path()),+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "1"),+ ("SWX_LAUNCH_SLDWORKS_SECS", "2"),+ ("SWX_LAUNCH_ATTACH_SECS", "3"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ("SWX_LAUNCH_BACKOFF_SECS", "0"),+ ],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(40);+ loop {+ let st = b.call("swx_status", json!({}));+ if st["launch"]["finished"] == true || std::time::Instant::now() > deadline {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(200));+ }+ // NOT VACUOUS: re-attribution must actually be REACHABLE here, so every+ // guard ahead of the lineage proof has to be satisfied first.+ let pre = b.call("swx_status", json!({}));+ assert!(+ pre["launch"]["finished"] == true+ && pre["launch"]["outcome"] != "attached"+ && pre["launch"]["foreignChainSeen"] != true,+ "precondition: a finished, un-attached attempt with NO foreign chain seen, so lineage is \+ the guard under test rather than foreignChainSeen: {}",+ s(&pre["launch"])+ );+ assert!(+ pre["launch"]["routesTried"]+ .as_array()+ .map(|a| a.iter().any(|t| t["runBeginSeen"] == true))+ .unwrap_or(false),+ "precondition: a route of ours reached Run Begin, so `proven` resolves: {}",+ s(&pre["launch"])+ );+ assert!(+ pre["solidworks"]["processId"].is_number(),+ "precondition: a SOLIDWORKS is in the census for the lineage walk to start from: {}",+ s(&pre["solidworks"])+ );++ // EVERY read, not just the first: re-attribution runs on every report now,+ // so a property that holds once must hold repeatedly.+ for i in 1..=5 {+ std::thread::sleep(std::time::Duration::from_millis(200));+ let st = b.call("swx_status", json!({}));+ let outcome = st["launch"]["outcome"].as_str().unwrap_or("").to_string();+ assert_ne!(+ outcome, "attachedAfterSignIn",+ "read {i}: the bridge adopted a SOLIDWORKS the USER started. It is newer than our \+ invoke, but it descends from a launcher that was never ours — timing plus the \+ ABSENCE of a foreign-chain observation is not a lineage proof.\n{}\n\+ --- server log ---\n{}",+ s(&st["launch"]),+ b.log()+ );+ }+}++/// OUR OWN LAUNCHER IS COUNTED AS A FOREIGN CHAIN.+///+/// `watch_route` asks, on each census, whether any terminable process is+/// "not ours, not in the baseline, not the attribution root" — and it asks it+/// BEFORE `attributed_chain` records the chain in the same iteration. On the+/// first census after an invoke, `chain_pids_now()` is still empty (the chain is+/// cleared at the top of each route) and the only pid excluded by name is the+/// attribution ROOT, the CATSTART. `SWXDesktopLauncher.exe` — the root's own+/// child, the second half of the chain we just started — matches every clause+/// and latches `foreign_chain_seen`.+///+/// The fixture below contains NO other launcher chain: `preexisting.json` is+/// empty and one route runs once, so every terminable process in that census is+/// ours by construction. A `foreignChainSeen: true` here can only be our own.+///+/// It matters because `reattribute_after_attach` bails on `foreign_chain_seen`+/// before it consults lineage at all. So the sign-in recovery that 0.6.1 added+/// and 0.6.2 hardened is unreachable on an ordinary run — which is also why both+/// the Builder's case-collision tests and my own pass-14 pin passed without ever+/// exercising the lineage proof.+#[test]+fn our_own_launcher_is_not_a_foreign_chain() {+ let dir = TempDir::new("adv14-selfforeign");+ std::fs::write(+ dir.join("routes.json"),+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0}})+ .to_string(),+ )+ .unwrap();+ // NOTHING else on the machine: no daemons, no other chain.+ std::fs::write(dir.join("preexisting.json"), json!([]).to_string()).unwrap();+ let lnk = format!("{}/SOLIDWORKS Design.lnk", dir.path());+ std::fs::write(&lnk, "x").unwrap();++ let b = Bridge::start_in(+ TempDir::new("adv14-scratch2"),+ &[+ ("SWX_SOLIDWORKS_SHORTCUT", &lnk),+ ("SWX_LAUNCH_FAKE_DIR", &dir.path()),+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "1"),+ ("SWX_LAUNCH_SLDWORKS_SECS", "2"),+ ("SWX_LAUNCH_ATTACH_SECS", "3"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ("SWX_LAUNCH_BACKOFF_SECS", "0"),+ ],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(40);+ loop {+ let st = b.call("swx_status", json!({}));+ if st["launch"]["finished"] == true || std::time::Instant::now() > deadline {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(200));+ }+ let st = b.call("swx_status", json!({}));+ let chain: Vec<u64> = st["launch"]["chainPids"]+ .as_array()+ .map(|a| a.iter().filter_map(|p| p["pid"].as_u64()).collect())+ .unwrap_or_default();+ assert!(+ st["launch"]["foreignChainSeen"] != true,+ "the attempt recorded a FOREIGN chain on a machine whose only launcher processes are the \+ ones it started itself (chainPids {chain:?}, preexisting empty, one route). The check \+ runs before `attributed_chain` records the chain, so our own SWXDesktopLauncher — the \+ attribution root's own child — matches 'not ours, not baseline, not the root'. \+ `reattribute_after_attach` bails on this flag before it ever reaches the lineage proof, \+ so sign-in recovery is unreachable on an ordinary run.\n{}\n--- server log ---\n{}",+ s(&st["launch"]),+ b.log()+ );+}
tests/adversary15.rsadded+103@@ -0,0 +1,103 @@+//! ADVERSARIAL REVIEW OF 0.6.3 — pass fifteen, gating the deploy.+//!+//! 0.6.3 fixes the self-foreign bug for ATTRIBUTABLE routes: the foreignness+//! question is now asked after the chain is recorded, and every descendant of+//! our own attribution root is excluded. The descendant set is seeded from the+//! root our INVOKE created (the CATSTART), never from a shared ancestor — and+//! `explorer.exe` is not in `WATCHED_IMAGES` at all, so it cannot bridge two+//! trees. A user's Start-Menu CATSTART can therefore never become a "descendant"+//! of ours. Re-attribution is gated on COM being attached, and `Stop` finally+//! records a RouteTry.+//!+//! The self-foreign bug is not gone, though. It is gone for the routes that have+//! a root.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// AN UNATTRIBUTABLE ROUTE STILL CALLS ITS OWN CHAIN A STRANGER.+///+/// The exclusion that 0.6.3 added is `!root_pids.contains(p.pid) &&+/// !descendants.contains(p.pid)`, and both sets are empty when+/// `w.attribution` is `None` — which is exactly what `explorer` and `cmdStart`+/// are, by design ("no handle, no kill"). Their chains are also never entered in+/// `a.chain`, because `attributed_chain` returns `None` without a root. So every+/// clause passes for a launcher process we started ourselves, and+/// `foreign_chain_seen` latches on it.+///+/// The fixture below has NO other launcher chain: `preexisting.json` is empty+/// and each route is invoked once, so every terminable process in every census+/// is ours by construction. A `foreignChainSeen: true` here can only be our own.+///+/// It still matters for the same reason as last pass: `reattribute_after_attach`+/// bails on this flag before it consults lineage, so sign-in recovery remains+/// unreachable on any attempt that gets as far as route three. It is reachable+/// on routes one and two, which is the shape the live sign-in actually took — so+/// this is narrower than the bug 0.6.3 fixed, not the same one.+#[test]+fn an_unattributable_routes_own_chain_is_not_a_foreign_chain() {+ let dir = TempDir::new("adv15-selfforeign");+ std::fs::write(+ dir.join("routes.json"),+ // Every route starts a chain and stalls before Run Begin, so the ladder+ // walks all four and reaches the two that cannot name what they started.+ json!({"shellExecute": {"chain": true},+ "powershell": {"chain": true},+ "explorer": {"chain": true},+ "cmdStart": {"chain": true}})+ .to_string(),+ )+ .unwrap();+ std::fs::write(dir.join("preexisting.json"), json!([]).to_string()).unwrap();+ let lnk = format!("{}/SOLIDWORKS Design.lnk", dir.path());+ std::fs::write(&lnk, "x").unwrap();++ let b = Bridge::start_in(+ TempDir::new("adv15-scratch"),+ &[+ ("SWX_SOLIDWORKS_SHORTCUT", &lnk),+ ("SWX_LAUNCH_FAKE_DIR", &dir.path()),+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "1"),+ ("SWX_LAUNCH_SLDWORKS_SECS", "2"),+ ("SWX_LAUNCH_ATTACH_SECS", "3"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ("SWX_LAUNCH_BACKOFF_SECS", "0"),+ ],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(45);+ loop {+ let st = b.call("swx_status", json!({}));+ if st["launch"]["finished"] == true || std::time::Instant::now() > deadline {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(200));+ }+ let st = b.call("swx_status", json!({}));+ let routes: Vec<String> = st["launch"]["routesTried"]+ .as_array()+ .map(|a| a.iter().filter_map(|t| t["route"].as_str().map(String::from)).collect())+ .unwrap_or_default();+ assert!(+ routes.len() >= 3,+ "precondition: the ladder reached an unattributable route: {routes:?}"+ );+ assert!(+ st["launch"]["foreignChainSeen"] != true,+ "the attempt recorded a FOREIGN chain on a machine whose only launcher processes are the \+ ones it started itself (routes {routes:?}, preexisting empty). For `explorer` and \+ `cmdStart` there is no attribution root, so `root_pids` and `descendants` are both empty \+ and their own chains match every clause of the foreignness test. \+ `reattribute_after_attach` bails on this flag before it reaches lineage, so sign-in \+ recovery stays unreachable on any attempt that gets this far.\n{}\n\+ --- server log ---\n{}",+ s(&st["launch"]),+ b.log()+ );+}
tests/adversary16.rsadded+121@@ -0,0 +1,121 @@+//! ADVERSARIAL REVIEW OF 0.6.4 — pass seventeen.+//!+//! `possiblyOurs` is sound. A launcher that appeared after an unattributable+//! invoke is recorded as unattributed and excluded from the foreign set, and it+//! never reaches the kill path: cleanup is seeded from `attributed_chain`, which+//! needs a root an unattributable route does not have. "Possibly ours" is a+//! reason not to kill and not to accuse, and those are the same answer here.+//!+//! The pin below is the behavioural half of that claim: a chain the USER starts+//! during our unattributable route is neither terminated nor adopted.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// The user clicks their Start Menu while our `explorer` route — which cannot+/// name what it started — is running. Their chain is classed `possiblyOurs`, so+/// it is not accused; the question this pins is that being un-accused does not+/// make it killable, and that the session it produces is not later adopted.+///+/// Re-attribution cannot reach it for a structural reason worth stating:+/// `our_identities` is built from `a.chain` and `a.attribution`, both of which+/// are per-route and both of which are EMPTY for an unattributable route. With+/// no identity to descend from, `descends_from_our_chain` returns before it+/// looks at anything.+#[test]+fn a_user_chain_during_an_unattributable_route_is_neither_killed_nor_adopted() {+ let dir = TempDir::new("adv16-possibly");+ std::fs::write(+ dir.join("routes.json"),+ json!({"shellExecute": {"chain": true},+ "powershell": {"chain": true},+ "explorer": {"chain": true},+ "cmdStart": {"chain": true}})+ .to_string(),+ )+ .unwrap();+ // The user's own launcher chain, reported as created "now" on every census —+ // i.e. after our invoke, which is what puts it in `possiblyOurs`.+ std::fs::write(+ dir.join("preexisting.json"),+ json!([{"pid": 70003, "image": "CATSTART.exe", "ageSeconds": 0},+ {"pid": 70004, "image": "SWXDesktopLauncher.exe", "ageSeconds": 0, "ppid": 70003}])+ .to_string(),+ )+ .unwrap();+ let lnk = format!("{}/SOLIDWORKS Design.lnk", dir.path());+ std::fs::write(&lnk, "x").unwrap();++ let b = Bridge::start_in(+ TempDir::new("adv16-scratch"),+ &[+ ("SWX_SOLIDWORKS_SHORTCUT", &lnk),+ ("SWX_LAUNCH_FAKE_DIR", &dir.path()),+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "1"),+ ("SWX_LAUNCH_SLDWORKS_SECS", "2"),+ ("SWX_LAUNCH_ATTACH_SECS", "3"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ("SWX_LAUNCH_BACKOFF_SECS", "0"),+ ],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(45);+ loop {+ let st = b.call("swx_status", json!({}));+ if st["launch"]["finished"] == true || std::time::Instant::now() > deadline {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(200));+ }++ // NOT VACUOUS: their chain must really have been classed possibly-ours, which+ // is the state whose safety is being pinned.+ let st = b.call("swx_status", json!({}));+ let possibly: Vec<u64> = st["launch"]["possiblyOurs"]+ .as_array()+ .map(|a| a.iter().filter_map(|v| v.as_u64()).collect())+ .unwrap_or_default();+ assert!(+ !possibly.is_empty(),+ "precondition: at least one launcher was classed possiblyOurs, which is the state whose \+ safety is being pinned. (The pre-existing pair above lands in each route's BASELINE and \+ is excluded before this filter — correctly; what reaches `possiblyOurs` here is the \+ unattributable routes' own chains.): {}",+ s(&st["launch"])+ );++ // THE EFFECT, on the machine: not one of their processes was terminated.+ let killed: Vec<u64> = std::fs::read_to_string(dir.join("terminated.jsonl"))+ .unwrap_or_default()+ .lines()+ .filter_map(|l| serde_json::from_str::<Value>(l).ok())+ .filter_map(|v| v.get("pid")?.as_u64())+ .collect();+ for pid in &possibly {+ assert!(+ !killed.contains(pid),+ "pid {pid} was classed `possiblyOurs` and then TERMINATED. Possibly-ours must mean \+ 'do not accuse', never 'may be killed': the kill path is seeded from the attributed \+ root, which an unattributable route does not have.\nterminated: {killed:?}\n\+ possiblyOurs: {possibly:?}\n{}",+ s(&st["launch"])+ );+ }+ // ...and the pre-existing pair — a chain that was already there — is untouched too.+ for theirs in [70003u64, 70004] {+ assert!(!killed.contains(&theirs), "pre-existing pid {theirs} was terminated: {killed:?}");+ }+ // ...and their session is not adopted as ours.+ assert_ne!(+ st["launch"]["outcome"].as_str().unwrap_or(""),+ "attachedAfterSignIn",+ "a session reached through an unattributable route was adopted as ours: {}",+ s(&st["launch"])+ );+}
tests/adversary17.rsadded+137@@ -0,0 +1,137 @@+//! ADVERSARIAL REVIEW OF 0.6.5 — pass eighteen.+//!+//! The Builder's report is right, and it is worse than "the tests pin the wrong+//! guard": re-attribution does not fire in the POSITIVE case either. Built end+//! to end below, with every documented precondition satisfied and COM genuinely+//! attached, the attempt stays `sldworksUpNotAttached` and no+//! `solidworks_launch_reattributed` entry reaches the ledger.+//!+//! That makes this the THIRD release in which the sign-in recovery path is+//! inert, each time for a different reason, and the second in which the tests+//! covering it proved something other than what they claimed.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// THE POSITIVE CASE, end to end — the live 0.5.9 shape.+///+/// An ATTRIBUTABLE route (`shellExecute`) invokes, its log reaches `Run Begin`,+/// SOLIDWORKS appears, COM does not attach inside the budget (the sign-in), and+/// the ladder stops with `sldworksUpNotAttached` WITHOUT killing the chain. The+/// user then clears the sign-in, COM attaches, and the next status read should+/// re-claim the session: the sldworks' parent is the launcher this attempt+/// recorded and watched run.+///+/// At the point of refusal every precondition the code documents is satisfied,+/// and I verified each from outside:+///+/// ```text+/// com.attached true+/// launch.finished true+/// launch.outcome "sldworksUpNotAttached" (not "attached")+/// launch.foreignChainSeen false+/// routesTried[0] runBeginSeen:true, rootPid:900010, rootConfirmed:true+/// launch.chainPids 900010 (CATSTART) + 900011 (SWXDesktopLauncher), both+/// with creation times, both still ALIVE in the census+/// launch.chainLastSeen [[900010,T,T+2],[900011,T,T+2]]+/// the sldworks in the census has ppid 900011 and was created at T+/// ```+///+/// So the walk should find parent 900011 in `our_identities`, find it alive,+/// match it with `same_process`, and place the child inside+/// `[parent_created, last_seen + grace]`. It refuses anyway, and nothing in the+/// report or the ledger says which clause said no.+///+/// THE DIAGNOSTIC IS THE REAL ASK. Three passes have now failed to determine+/// this from outside the process. `reattribute_after_attach` should record its+/// refusal — `reattributionRefusedBecause` on the launch report, naming the+/// clause — so the next person does not have to infer it.+#[test]+fn re_attribution_succeeds_when_every_precondition_is_met() {+ let dir = TempDir::new("adv17-positive");+ std::fs::write(+ dir.join("routes.json"),+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0,+ "sldworksAfterMs": 0, "attachAfterMs": 6000}})+ .to_string(),+ )+ .unwrap();+ std::fs::write(dir.join("preexisting.json"), json!([]).to_string()).unwrap();+ let lnk = format!("{}/SOLIDWORKS Design.lnk", dir.path());+ std::fs::write(&lnk, "x").unwrap();++ let b = Bridge::start_in(+ TempDir::new("adv17-scratch"),+ &[+ ("SWX_SOLIDWORKS_SHORTCUT", &lnk),+ ("SWX_LAUNCH_FAKE_DIR", &dir.path()),+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "1"),+ ("SWX_LAUNCH_SLDWORKS_SECS", "2"),+ ("SWX_LAUNCH_ATTACH_SECS", "2"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ("SWX_LAUNCH_BACKOFF_SECS", "0"),+ ],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);+ loop {+ let st = b.call("swx_status", json!({}));+ if st["launch"]["finished"] == true || std::time::Instant::now() > deadline {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(150));+ }+ let mid = b.call("swx_status", json!({}));+ assert_eq!(+ mid["launch"]["outcome"].as_str(),+ Some("sldworksUpNotAttached"),+ "precondition: the ladder stopped with SOLIDWORKS up and COM unattached: {}",+ s(&mid["launch"])+ );+ assert!(+ mid["launch"]["foreignChainSeen"] != true+ && mid["launch"]["chainPids"].as_array().map(|a| a.len()).unwrap_or(0) == 2,+ "precondition: no foreign chain, and our own chain is recorded with identities: {}",+ s(&mid["launch"])+ );++ // The sign-in clears and COM attaches. The mock only flips its attached flag+ // inside `start`, so poll the way a caller is told to.+ std::thread::sleep(std::time::Duration::from_millis(6000));+ let started = b.call("swx_start", json!({"reason": "polling after the sign-in"}));+ assert_eq!(+ started["attached"].as_bool(),+ Some(true),+ "precondition: COM really did attach: {}",+ s(&started)+ );++ let st = b.call("swx_status", json!({}));+ assert_eq!(+ st["com"]["attached"].as_bool(),+ Some(true),+ "precondition: swx_status sees COM attached, so report_with_attach gets `true`: {}",+ s(&st["com"])+ );+ let ledger = s(&b.call("swx_action_ledger", json!({"limit": 200})));+ assert!(+ st["launch"]["outcome"] == "attachedAfterSignIn"+ || ledger.contains("solidworks_launch_reattributed"),+ "re-attribution did not fire although every precondition it documents is met: COM \+ attached, the attempt finished un-attached with no foreign chain, a route row carrying \+ runBeginSeen and a rootPid, and our own launcher recorded ALIVE with a creation time and \+ a lastSeen — with the SOLIDWORKS in the census recording that launcher as its parent. \+ Nothing in the report or the ledger names the clause that refused, which is why three \+ passes have failed to find it from outside: `reattribute_after_attach` needs to record \+ WHY it declined.\nlaunch: {}\nroutesTried: {}\nchainLastSeen: {}",+ s(&st["launch"]),+ s(&st["launch"]["routesTried"]),+ s(&st["launch"]["chainLastSeen"])+ );+}
tests/adversary18.rsadded+190@@ -0,0 +1,190 @@+//! ADVERSARIAL REVIEW OF 0.6.6 — pass nineteen.+//!+//! Sign-in recovery RUNS now: my pass-18 positive case passes, and the root+//! cause the Builder found (the walk took the first sldworks.exe, which had no+//! parent) is genuinely fixed.+//!+//! But "ANY running SOLIDWORKS descends from our chain" is the wrong+//! proposition, and it is a consent bug. The attempt is marked attributed and+//! `attachedAfterSignIn` on the strength of lineage to one process, while COM —+//! which is what every subsequent verb acts through — is attached to a+//! DIFFERENT one. Nothing binds the claim to the pid it was proved for.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// `reattribute_after_attach` orders the candidates with the COM-attached pid+/// first and then takes the FIRST ONE THAT DESCENDS:+///+/// ```text+/// for cand in &ordered {+/// if let Some(why) = descends_from_our_chain(&census, cand.pid, &a) {+/// found = Some((cand, why));+/// break;+/// ```+///+/// `attached_pid` is used only to order the list. There is no check that the+/// process it settled on IS the attached one. So with more than one+/// `SLDWORKS.exe` running — ours, descended from our launcher, plus another COM+/// happens to be attached to — the attempt is claimed as ours on the strength of+/// a process nobody is talking to.+///+/// What follows is the consent problem. `quit-gate.py::require_bridge_launched`+/// reads `launch.attributed` and `runBeginSeen`; the quit ladder then builds its+/// `Identity` from the COM `processId`. So the ladder becomes willing to end a+/// session whose only qualification is that a DIFFERENT process descended from+/// our chain. `swx_close_all_documents` acts through the same COM connection.+///+/// The claim has to bind to the pid it was proved for: succeed only when the+/// descending process IS the COM-attached one, and otherwise refuse with a named+/// reason (`attached_pid_not_ours`) so a caller can see why.+#[test]+fn re_attribution_claims_only_the_process_com_is_attached_to() {+ let dir = TempDir::new("adv18-twosw");+ std::fs::write(+ dir.join("routes.json"),+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0,+ "sldworksAfterMs": 0, "attachAfterMs": 6000}})+ .to_string(),+ )+ .unwrap();+ std::fs::write(dir.join("preexisting.json"), json!([]).to_string()).unwrap();+ let lnk = format!("{}/SOLIDWORKS Design.lnk", dir.path());+ std::fs::write(&lnk, "x").unwrap();++ let b = Bridge::start_in(+ TempDir::new("adv18-scratch"),+ &[+ ("SWX_SOLIDWORKS_SHORTCUT", &lnk),+ ("SWX_LAUNCH_FAKE_DIR", &dir.path()),+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "1"),+ ("SWX_LAUNCH_SLDWORKS_SECS", "2"),+ ("SWX_LAUNCH_ATTACH_SECS", "2"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ("SWX_LAUNCH_BACKOFF_SECS", "0"),+ ],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ // A SECOND SOLIDWORKS appears while we are launching — the user's, with a+ // parent that was never ours. (One already present would make swx_start+ // refuse outright, which is why it has to arrive mid-flight.)+ std::thread::sleep(std::time::Duration::from_millis(300));+ std::fs::write(+ dir.join("census-overrides.json"),+ json!([{"pid": 88556, "image": "SLDWORKS.exe", "ageSeconds": 0, "ppid": 4242}]).to_string(),+ )+ .unwrap();++ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);+ loop {+ let st = b.call("swx_status", json!({}));+ if st["launch"]["finished"] == true || std::time::Instant::now() > deadline {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(150));+ }+ std::thread::sleep(std::time::Duration::from_millis(6000));+ b.call("swx_start", json!({"reason": "polling after the sign-in"}));+ let st = b.call("swx_status", json!({}));++ // NOT VACUOUS: more than one SOLIDWORKS must really be running, and the+ // attempt must really have been claimed.+ let procs = st["solidworks"]["processes"].as_array().cloned().unwrap_or_default();+ assert!(+ procs.len() >= 2,+ "precondition: more than one SLDWORKS.exe is running: {}",+ s(&st["solidworks"])+ );+ if st["launch"]["outcome"] != "attachedAfterSignIn" {+ return; // it refused; nothing to prove here.+ }++ // THE CLAIM MUST BE ABOUT THE PROCESS COM IS TALKING TO.+ let attached = st["com"]["processId"].as_u64();+ let chain: Vec<u64> = st["launch"]["chainPids"]+ .as_array()+ .map(|a| a.iter().filter_map(|p| p["pid"].as_u64()).collect())+ .unwrap_or_default();+ let attached_descends = attached+ .and_then(|want| {+ procs+ .iter()+ .find(|p| p["pid"].as_u64() == Some(want))+ .and_then(|p| p["ppid"].as_u64())+ })+ .map(|ppid| chain.contains(&ppid))+ .unwrap_or(false);+ assert!(+ attached_descends,+ "the attempt was claimed as ours (`attachedAfterSignIn`, attributed:true) while COM is \+ attached to pid {attached:?}, which does NOT descend from this attempt's chain \+ {chain:?}. Lineage was proved for a different SOLIDWORKS. Every verb downstream acts \+ through this COM connection — the quit ladder builds its Identity from this very \+ processId, and quit-gate's require_bridge_launched reads only `attributed` and \+ `runBeginSeen` — so the ladder becomes willing to end a session nothing proved was \+ ours.\ncom: {}\nsolidworks: {}\nlaunch: {}",+ s(&st["com"]),+ s(&st["solidworks"]),+ s(&st["launch"])+ );+}++/// ...and the claim should be auditable. `reattributionEvidence` is the ROUTE+/// NAME (`"shellExecute"`); it does not say which pid's lineage was proved, so+/// nothing in the report lets a reader notice that the proved pid and the+/// attached pid differ.+#[test]+fn the_reattribution_evidence_names_the_process_it_proved() {+ let dir = TempDir::new("adv18-evidence");+ std::fs::write(+ dir.join("routes.json"),+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0,+ "sldworksAfterMs": 0, "attachAfterMs": 6000}})+ .to_string(),+ )+ .unwrap();+ std::fs::write(dir.join("preexisting.json"), json!([]).to_string()).unwrap();+ let lnk = format!("{}/SOLIDWORKS Design.lnk", dir.path());+ std::fs::write(&lnk, "x").unwrap();+ let b = Bridge::start_in(+ TempDir::new("adv18-scratch2"),+ &[+ ("SWX_SOLIDWORKS_SHORTCUT", &lnk),+ ("SWX_LAUNCH_FAKE_DIR", &dir.path()),+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "1"),+ ("SWX_LAUNCH_SLDWORKS_SECS", "2"),+ ("SWX_LAUNCH_ATTACH_SECS", "2"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ("SWX_LAUNCH_BACKOFF_SECS", "0"),+ ],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);+ loop {+ let st = b.call("swx_status", json!({}));+ if st["launch"]["finished"] == true || std::time::Instant::now() > deadline {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(150));+ }+ std::thread::sleep(std::time::Duration::from_millis(6000));+ b.call("swx_start", json!({"reason": "polling"}));+ let st = b.call("swx_status", json!({}));+ if st["launch"]["outcome"] != "attachedAfterSignIn" {+ return;+ }+ let ev = st["launch"]["reattributionEvidence"].as_str().unwrap_or("");+ assert!(+ ev.chars().any(|c| c.is_ascii_digit()),+ "`reattributionEvidence` is {ev:?} — the route name. A claim this consequential has to \+ name the process it was proved for and the parent it descended from, or a reader cannot \+ check it against the pid COM is attached to.\n{}",+ s(&st["launch"])+ );+}
tests/adversary2.rsadded+411@@ -0,0 +1,411 @@+//! ADVERSARIAL REVIEW OF 0.4.1 — round two.+//!+//! Round one's fourteen findings are closed (see `tests/adversary.rs`, all+//! passing). These are the ways round to the same places, plus the surfaces the+//! fixes themselves opened. Every test here FAILS on 6af68ab.+//!+//! The theme, again: an unanswerable question answered definitely. The kill+//! path now verifies identity beautifully at the moment of the kill — and+//! decides WHICH processes are ours from a first-come-first-served rule that+//! cannot tell our launcher chain from the user's.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};+use std::time::{Duration, Instant};+use swx_bridge::launch;+use swx_bridge::launchos::{stamp_of, LogFile, ProcInfo};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++fn p(pid: u32, image: &str, created: u64, ppid: Option<u32>) -> ProcInfo {+ ProcInfo { pid, image: image.to_string(), created_at: Some(created), ppid }+}++struct Sim {+ dir: TempDir,+}++impl Sim {+ fn new(tag: &str, routes: Value) -> Sim {+ let dir = TempDir::new(tag);+ std::fs::write(dir.join("routes.json"), routes.to_string()).unwrap();+ let sim = Sim { dir };+ sim.write_preexisting(Sim::daemons());+ sim+ }++ 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 write_preexisting(&self, rows: Vec<Value>) {+ std::fs::write(self.dir.join("preexisting.json"), Value::Array(rows).to_string()).unwrap();+ }++ 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> {+ self.lines("terminated.jsonl").iter().filter_map(|v| v.get("pid")?.as_u64()).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_ATTACH_POLL_MS", "150"),+ ("SWX_LAUNCH_INFLIGHT_SECS", "120"),+ ("SWX_LAUNCH_BACKOFF_SECS", "0"),+ ];+ 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. CHAIN ATTRIBUTION — the fix moved the problem, it did not remove it+// ════════════════════════════════════════════════════════════════════════════++/// `chain_candidates` doc: *"A process whose parent is a pid we never started is+/// never adopted."* — except for the one that matters. The root rule is+///+/// ```text+/// let is_root = p.is("CATSTART.exe") && ids.is_empty();+/// ```+///+/// i.e. **the first CATSTART to appear after our invoke, in census order, is+/// declared ours** with no evidence at all. Parentage cannot help: the census+/// only contains `WATCHED_IMAGES`, so a CATSTART's parent (explorer.exe,+/// cmd.exe, powershell.exe) is never in it and `descends` is always false for+/// the root. Worse, for route `explorer` — the DEFAULT, tried first — our+/// CATSTART and a Start-Menu CATSTART have literally the same parent: the+/// user's shell.+///+/// The field case is the one the release already documents: a route that starts+/// nothing (`M1`, fixed in this very round), while the user — watching nothing+/// happen — starts SOLIDWORKS themselves. Their chain is then the only CATSTART+/// younger than our invoke, so it is adopted as ours and ended at the deadline.+#[test]+fn a_user_chain_that_appears_while_our_route_started_nothing_is_never_adopted() {+ // Every route invokes and starts nothing at all — the failure this round+ // taught the bridge to name honestly.+ let m = Sim::new(+ "adv2-adopt",+ json!({"explorer": {"chain": false}, "shellExecute": {"chain": false},+ "powershell": {"chain": false}, "cmdStart": {"chain": false}}),+ );+ let b = m.boot(TempDir::new("adv2-scratch"), &[]);+ b.call("swx_start", json!({"reason": "modelling a bracket", "waitSeconds": 0}));++ // The user gives up waiting and starts SOLIDWORKS from their Start Menu.+ // ageSeconds 0 => created now, i.e. after our invoke and not in our baseline.+ std::thread::sleep(Duration::from_millis(400));+ let mut rows = Sim::daemons();+ rows.push(json!({"pid": 70003, "image": "CATSTART.exe", "ageSeconds": 0}));+ rows.push(json!({"pid": 70004, "image": "SWXDesktopLauncher.exe", "ageSeconds": 0, "ppid": 70003}));+ m.write_preexisting(rows);++ // Watch what the bridge decides is "our chain" while it is deciding it.+ let mut adopted: Vec<Value> = Vec::new();+ for _ in 0..8 {+ std::thread::sleep(Duration::from_millis(150));+ let st = b.call("swx_status", json!({}));+ if let Some(pids) = st["launch"]["chainPids"].as_array() {+ adopted.extend(pids.iter().cloned());+ }+ }+ let st = wait_for_launch(&b, 30);++ let claimed: Vec<u64> =+ adopted.iter().filter_map(|c| c.get("pid").and_then(|p| p.as_u64())).collect();+ for theirs in [70003u64, 70004] {+ assert!(+ !claimed.contains(&theirs),+ "the bridge recorded pid {theirs} — a launcher chain the USER started — as part of \+ the chain IT started. Nothing it invoked ever ran, so the first CATSTART to appear \+ after the invoke was adopted on the strength of being first.\n\+ claimed: {claimed:?}\nlaunch: {}\n--- server log ---\n{}",+ s(&st["launch"]),+ b.log()+ );+ assert!(+ !m.terminated().contains(&theirs),+ "...and then terminated it: {:?}",+ m.terminated()+ );+ }+}++/// The same rule, stated purely: two CATSTARTs appear after our invoke and+/// neither is in the baseline. One is ours, one is the user's, and nothing in+/// the census distinguishes them — so the answer must be "I cannot tell", not+/// "whichever the OS listed first".+#[test]+fn the_first_catstart_after_our_invoke_is_not_proof_that_it_is_ours() {+ let invoked = 1_000_000u64;+ let census = vec![+ // The user's, listed first — toolhelp order is roughly pid order, and+ // pids do not say who asked for the process.+ p(70003, "CATSTART.exe", invoked + 2, Some(4444)),+ p(70004, "SWXDesktopLauncher.exe", invoked + 3, Some(70003)),+ // Ours.+ p(90010, "CATSTART.exe", invoked + 1, Some(4444)),+ p(90011, "SWXDesktopLauncher.exe", invoked + 2, Some(90010)),+ ];+ let ours = launch::chain_candidates(&census, &[], &[], invoked);+ let pids: Vec<u32> = ours.iter().map(|x| x.pid).collect();+ assert!(+ !pids.contains(&70003) && !pids.contains(&70004),+ "with two indistinguishable chains the bridge adopted the one the census listed FIRST \+ and called it ours: {pids:?}. Both have the same parent (the shell), which is what \+ route 'explorer' produces for our own launch too, so parentage cannot break the tie \+ either. The honest answer is to attribute neither and say the chain is unknown."+ );+}++/// `chain_candidates` pass 1 re-confirms the previous census's answer by PID+/// ALONE:+///+/// ```text+/// if ids.contains(&p.pid) && may_terminate(&p.image, p.created_at, invoked_at) {+/// ```+///+/// `ProcInfo::same_process` — added this round precisely because "a pid alone is+/// not an identity" — is used at the kill and not here. CATSTART exits about+/// three seconds into a healthy launch, so its number is free for the rest of a+/// four-minute watch; anything that takes it and happens to be a launcher image+/// is re-adopted into our chain, and the kill path then verifies the identity of+/// the process we wrongly adopted.+#[test]+fn a_recycled_pid_is_not_re_adopted_into_our_chain_by_number_alone() {+ let invoked = 1_000_000u64;+ // 90010 WAS our CATSTART: seeded from an earlier census, created at+ // invoked+1. It exited. The number now belongs to a chain the user started+ // two minutes later.+ let census = vec![p(90010, "CATSTART.exe", invoked + 120, Some(4444))];+ let ours = launch::chain_candidates(&census, &[], &[90010], invoked);+ assert!(+ ours.is_empty(),+ "pid 90010 was re-adopted into our chain although it is a different process now \+ (created at {} vs the {} we recorded). The seed is a list of numbers, not of \+ identities — ProcInfo::same_process exists for exactly this and is not used here.",+ invoked + 120,+ invoked + 1+ );+}++/// `run_supervisor` records `baseline_known`, and `watch_route` correctly+/// records NO chain when the baseline could not be taken. What it then does is+/// go on as if there were nothing to clean: `clean_up_chain` reads an empty+/// recorded chain, asks the census (which now works), and answers+/// `failed: false` — "there was nothing to attribute" rather than "I was never+/// able to attribute anything". Three more routes follow, each starting another+/// chain, with the first still running.+#[test]+fn a_baseline_that_could_not_be_taken_is_not_a_clean_cleanup() {+ let m = Sim::new("adv2-blindbaseline", json!({}));+ // The census is down when the route takes its baseline...+ std::fs::write(m.dir.join("census-fails"), "").unwrap();+ let b = m.boot(TempDir::new("adv2-scratch"), &[]);+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ // ...and back up a moment later, so every LATER look succeeds.+ std::thread::sleep(Duration::from_millis(400));+ let _ = std::fs::remove_file(m.dir.join("census-fails"));+ let st = wait_for_launch(&b, 40);++ assert!(+ !m.invocations().is_empty(),+ "precondition: a route was invoked and started a chain: {:?}",+ m.invocations()+ );+ let first = &st["launch"]["routesTried"][0];+ let killed = m.terminated();+ // The simulator's first chain is 900010/900011.+ let accounted = killed.contains(&900010)+ || first["cleanupFailed"] == true+ || !first["chainLeftRunning"].as_array().map(|a| a.is_empty()).unwrap_or(true);+ assert!(+ accounted,+ "route 1 started a launcher chain the bridge could not attribute (its baseline census \+ failed), and the report records the stall with cleanupFailed:false and nothing left \+ running — so the machine went on to invoke {} routes in total while that chain was \+ still on the machine, unkilled and unmentioned.\nterminated: {killed:?}\n\+ routesTried[0]: {}\n--- server log ---\n{}",+ m.invocations().len(),+ s(first),+ b.log()+ );+}++// ════════════════════════════════════════════════════════════════════════════+// 2. THE LAUNCHER-LOG BASELINE — a new empty-to-confident collapse+// ════════════════════════════════════════════════════════════════════════════++/// `run_supervisor`:+///+/// ```text+/// let (base_stamp, base_created) = newest_log(&os.launcher_logs().unwrap_or_default());+/// ```+///+/// A launcher-log listing that FAILED (APPDATA unset, the directory unreadable,+/// a permissions change) becomes "there were no logs" — and `is_newer_than`+/// answers `(Some(_), None) => true`, i.e. **any log is ours**. The bridge then+/// reads a log from a previous day, finds `Run Begin` in it (five of the nine+/// logs on the real machine contain it), jumps straight to `runBegin`, skips+/// stall detection entirely, and two minutes later reports "logged 'Run Begin'+/// but no sldworks.exe appeared" — a fabricated account of a stall that never+/// happened, drawn from somebody else's launch.+#[test]+fn a_log_listing_that_failed_does_not_make_every_old_log_ours() {+ let three_days_old = LogFile {+ path: r"C:\Users\drew\AppData\Roaming\DassaultSystemes\SWXDesktopLauncher\20260902162153_V6R2026x.HF4.13.log".into(),+ created_at: Some(1_788_384_116),+ stamp: stamp_of("20260902162153_V6R2026x.HF4.13.log"),+ };+ assert!(+ three_days_old.stamp.is_some(),+ "precondition: the launcher's own filename stamp parses"+ );+ assert!(+ !three_days_old.is_newer_than(None, None),+ "with NO baseline — which is what a failed directory listing produces, because \+ `launcher_logs()` is `unwrap_or_default()`ed — a three-day-old log is accepted as \+ evidence that the chain we just invoked started. An unreadable listing is UNKNOWN; it \+ is not 'the directory was empty'."+ );+}++/// `is_newer_than` short-circuits on the stamps and never consults the+/// filesystem when both are present: `(Some(mine), Some(base)) => return mine > base`.+///+/// The launcher stamps LOCAL time. For the one hour a year that a local clock+/// repeats, a log written after our invoke carries a stamp LOWER than one+/// written before it, and the bridge concludes its own log does not exist — so+/// every route reports "produced NO launcher log at all: nothing started",+/// kills nothing, and moves on. Any single log with a stamp from the future+/// (a clock correction, a file copied from another machine) poisons the+/// directory permanently, because these logs are never pruned.+#[test]+fn a_launch_during_the_repeated_daylight_saving_hour_still_finds_its_own_log() {+ // 01:59:00 EDT, then the clock goes back: our launch runs at 01:30:00 EST,+ // twenty-nine minutes LATER in real time and an hour earlier on the wall.+ let baseline = stamp_of("20261101015900_V6R2026x.HF4.13.log");+ let ours = LogFile {+ path: "…/20261101013000_V6R2026x.HF4.13.log".into(),+ // The filesystem, which records UTC, knows perfectly well it is newer.+ created_at: Some(1_793_000_000),+ stamp: stamp_of("20261101013000_V6R2026x.HF4.13.log"),+ };+ assert!(+ ours.is_newer_than(baseline, Some(1_792_998_000)),+ "our own launcher log was rejected because its LOCAL-time filename stamp is lower than \+ a log written half an hour earlier on the other side of a daylight-saving change. The \+ stamp comparison returns before the filesystem time — which is monotonic — is even \+ looked at, so the bridge reports that nothing started."+ );+}++// ════════════════════════════════════════════════════════════════════════════+// 3. THE NEWLY NULLABLE COUNTS — the shape is right, the guidance is not+// ════════════════════════════════════════════════════════════════════════════++/// `cmd_interference` now answers `count: null` when SOLIDWORKS would not give+/// a count — correct, and the most consequential null in the bridge, because+/// this verb's output is a clearance report somebody builds a physical thing+/// from. Its `_hint` still says only:+///+/// > `count==0` means no interferences at the components' CURRENT positions+///+/// A caller told that, holding `{"count": null}`, writes `if (!r.count)` — and+/// in every language the bridge's callers use, `null` is falsy exactly like+/// `0`. The verb that took the most care to stop manufacturing an all-clear+/// still teaches the idiom that manufactures one.+#[test]+fn the_interference_verb_says_what_an_unknown_count_means() {+ let b = Bridge::start();+ b.call("swx_start", json!({"reason": "checking clearances"}));+ let made = b.call("swx_new_document", json!({"type": "assembly"}));+ let title = made["created"]["title"].as_str().unwrap_or("Assem1").to_string();+ let r = b.call("swx_interference", json!({"targetDocument": title}));+ assert!(r["success"] == true, "precondition: the verb runs: {}", s(&r));++ let hint = r["_hint"].as_str().unwrap_or("").to_lowercase();+ assert!(+ hint.contains("null") || hint.contains("unknown") || hint.contains("could not"),+ "the hint explains count==0 and says nothing about count==null, which this verb can now \+ return and which every caller's truthiness check will read as zero. On a clearance \+ report that is the one mistake that reaches a physical part.\nhint: {}\n{}",+ r["_hint"],+ s(&r)+ );+}++/// The consequence of the adoption above, measured on the machine rather than+/// in the report: does the bridge actually END the user's launcher chain?+///+/// Kept separate from the attribution test because the two are different+/// claims, and this is the one that says whether 0.4.1 can act on somebody's+/// desktop without their consent.+#[test]+fn a_user_chain_that_appears_while_our_route_started_nothing_is_never_terminated() {+ let m = Sim::new(+ "adv2-adoptkill",+ json!({"explorer": {"chain": false}, "shellExecute": {"chain": false},+ "powershell": {"chain": false}, "cmdStart": {"chain": false}}),+ );+ let b = m.boot(TempDir::new("adv2-scratch"), &[]);+ b.call("swx_start", json!({"reason": "modelling a bracket", "waitSeconds": 0}));+ std::thread::sleep(Duration::from_millis(300));+ let mut rows = Sim::daemons();+ rows.push(json!({"pid": 70003, "image": "CATSTART.exe", "ageSeconds": 0}));+ rows.push(json!({"pid": 70004, "image": "SWXDesktopLauncher.exe", "ageSeconds": 0, "ppid": 70003}));+ m.write_preexisting(rows);+ let st = wait_for_launch(&b, 30);++ let killed = m.terminated();+ assert!(+ !killed.contains(&70003) && !killed.contains(&70004),+ "the bridge TERMINATED the user's own launcher chain — processes it never started, on a \+ machine where nothing it invoked ever ran. terminated: {killed:?}\nlaunch: {}\n\+ --- server log ---\n{}",+ s(&st["launch"]),+ b.log()+ );+}
tests/adversary20.rsadded+135@@ -0,0 +1,135 @@+//! ADVERSARIAL REVIEW OF 0.6.8 — pass twenty-one.+//!+//! The wiring is real: I re-ran the call-site mutations myself rather than+//! reading them. Neutering the `swx_stop` FRONT GATE fails+//! `swx_stop_refuses_when_the_launch_claim_names_another_session`; neutering the+//! `close_all_documents`/`reset_workspace` helper fails two e2e tests. Behaviour+//! with no claim recorded is unchanged by construction —+//! `identity_matches_attribution(None, _) => Ok(())` — so a session this bridge+//! never claimed is still refused by the preconditions that were there before.+//!+//! Two gaps, both about what the suite can SEE rather than what the bridge does,+//! and both demonstrated by mutation:+//!+//! 1. The PER-RUNG re-sweep call site (`verbs.rs`, the second of the two in+//! `swx_stop`) is untested. Replacing it with `Ok(())` and running ALL 301+//! tests produces zero failures. It is also not testable as things stand:+//! the comparison is `attributed_solidworks()` against the identity built+//! from the COM `processId`, and the mock's reported processId cannot be+//! changed between rungs from a fixture. A `mock-com-pid` fault read per+//! call would make it stageable — and the per-rung site is the one that+//! catches the session changing DURING the ladder, which is the whole+//! reason it exists.+//!+//! 2. `tests/wiring.rs` cannot see TRANSITIVE deadness. With both live+//! `swx_stop` call sites neutered and a single never-called function added+//! that mentions `identity_matches_attribution`, the wiring test still+//! passes. "Has a production caller" is a weaker property than "is+//! reachable", and the guard that shipped inert in 0.6.7 would satisfy it.+//!+//! This file is the third thing: the same regression net, extended to the guards+//! `wiring.rs` leaves out. All of them are wired TODAY — I checked — so this+//! passes now and exists to stop one of them being quietly unwired later.++use std::path::Path;++/// Guards that are load-bearing and absent from `wiring.rs::GUARDS`.+const ALSO_LOAD_BEARING: &[(&str, &str)] = &[+ ("prune_unless_truncated",+ "an unlistable open set must never disown the documents this bridge created — bug #5"),+ ("owns_doc",+ "the ownership gate: the bridge writes only to documents it made, in this session"),+ ("doc_matches",+ "one path comparison, shared by the ownership check and the prune, or they disagree"),+ ("safe_out",+ "a relative or traversing destination is refused before it reaches the host"),+ ("is_hung",+ "hung needs BOTH signals, and an unknown is never a yes"),+ ("exhaustion_confirmed",+ "exhaustion is a compound verdict, never a memory number on its own"),+ ("same_process",+ "a pid is not an identity: pid + image + creation time is"),+];++fn production_sources() -> Vec<(String, String)> {+ let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");+ let mut out = Vec::new();+ let mut stack = vec![root];+ while let Some(dir) = stack.pop() {+ let Ok(entries) = std::fs::read_dir(&dir) else { continue };+ for e in entries.flatten() {+ let p = e.path();+ if p.is_dir() {+ stack.push(p);+ } else if p.extension().map(|x| x == "rs").unwrap_or(false) {+ if let Ok(text) = std::fs::read_to_string(&p) {+ out.push((p.to_string_lossy().to_string(), strip_test_modules(&text)));+ }+ }+ }+ }+ out+}++/// Remove `#[cfg(test)] mod tests { … }` by brace counting — a call from inside+/// one is not a production caller. Same approach as `wiring.rs`.+fn strip_test_modules(text: &str) -> String {+ let mut out = String::with_capacity(text.len());+ let mut rest = text;+ while let Some(i) = rest.find("#[cfg(test)]") {+ out.push_str(&rest[..i]);+ let after = &rest[i..];+ let Some(brace) = after.find('{') else { break };+ let mut depth = 0i32;+ let mut end = None;+ for (j, c) in after[brace..].char_indices() {+ match c {+ '{' => depth += 1,+ '}' => {+ depth -= 1;+ if depth == 0 {+ end = Some(brace + j + 1);+ break;+ }+ }+ _ => {}+ }+ }+ match end {+ Some(e) => rest = &after[e..],+ None => return out,+ }+ }+ out.push_str(rest);+ out+}++#[test]+fn the_other_load_bearing_guards_have_production_callers_too() {+ let sources = production_sources();+ assert!(!sources.is_empty(), "no source files were read");+ let mut unwired: Vec<String> = Vec::new();+ for (name, why) in ALSO_LOAD_BEARING {+ let called = sources.iter().any(|(_, text)| {+ text.lines()+ .map(str::trim)+ .filter(|t| {+ !t.starts_with("//")+ && !t.starts_with("fn ")+ && !t.starts_with("pub fn ")+ && !t.starts_with("pub(crate) fn ")+ })+ .any(|t| t.contains(&format!("{name}(")))+ });+ if !called {+ unwired.push(format!("{name} — {why}"));+ }+ }+ assert!(+ unwired.is_empty(),+ "these guards have no production caller, so they are written and not run:\n {}\n\+ (The same check `wiring.rs` makes, for the guards it does not list. If one of these was \+ retired on purpose, remove it here — that should be a decision, not a silent drift.)",+ unwired.join("\n ")+ );+}
tests/adversary3.rsadded+256@@ -0,0 +1,256 @@+//! ADVERSARIAL REVIEW OF 0.4.2 — round three, "the attribution law".+//!+//! The law itself holds. I could not find a way to make the bridge terminate a+//! process it did not start: attribution now comes only from the invoke's own+//! handle, the walk descends and cannot invent a root, seeds are live+//! identity-confirmed processes, and the kill re-verifies on the open handle.+//! Rounds one and two are closed at the mechanism.+//!+//! What is left is the OTHER half of the same question. The bridge no longer+//! adopts a stranger's PROCESSES — but it still adopts a stranger's EVIDENCE.+//! A launcher log is attributed to us on "newer than the newest log that+//! existed before our invoke", with no link to the chain we started, so a+//! launch the user begins while we watch supplies our stage, our `Run Begin`+//! and, at the end, the route we remember as the one that works.+//!+//! Every test here FAILS on f818f14.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};+use std::time::{Duration, Instant};+use swx_bridge::launch::{self, Attribution};+use swx_bridge::launchos::{FakeOs, ProcInfo};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++fn p(pid: u32, image: &str, created: u64, ppid: Option<u32>) -> ProcInfo {+ ProcInfo { pid, image: image.to_string(), created_at: Some(created), ppid }+}++/// A simulated machine on which NONE of the bridge's four routes starts+/// anything — and on which the user then starts SOLIDWORKS themselves.+struct Sim {+ dir: TempDir,+ lnk: String,+}++impl Sim {+ fn new(tag: &str, routes: 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"),+ json!([+ {"pid": 6076, "image": "3DEXPERIENCELauncher.exe", "ageSeconds": 30000},+ {"pid": 90552, "image": "3DEXPERIENCELauncherSysTray.exe", "ageSeconds": 30000}+ ])+ .to_string(),+ )+ .unwrap();+ let lnk = format!("{}/SOLIDWORKS Design.lnk", dir.path());+ std::fs::write(&lnk, "not really a shortcut").unwrap();+ Sim { dir, lnk }+ }++ /// The user starts SOLIDWORKS from their Start Menu. The simulator records+ /// it exactly as it records one of ours — because from the outside, that is+ /// precisely how indistinguishable the two are.+ fn user_starts_solidworks(&self) {+ FakeOs::record_invoke(std::path::Path::new(&self.dir.path()), "userStartMenu", &self.lnk);+ }++ fn terminated(&self) -> Vec<u64> {+ std::fs::read_to_string(self.dir.join("terminated.jsonl"))+ .unwrap_or_default()+ .lines()+ .filter_map(|l| serde_json::from_str::<Value>(l).ok())+ .filter_map(|v| v.get("pid")?.as_u64())+ .collect()+ }++ fn boot(&self) -> Bridge {+ let env: Vec<(&str, &str)> = vec![+ ("SWX_SOLIDWORKS_SHORTCUT", Box::leak(self.lnk.clone().into_boxed_str())),+ ("SWX_LAUNCH_FAKE_DIR", Box::leak(self.dir.path().into_boxed_str())),+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "3"),+ ("SWX_LAUNCH_SLDWORKS_SECS", "6"),+ ("SWX_LAUNCH_ATTACH_SECS", "12"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ("SWX_LAUNCH_ATTACH_POLL_MS", "150"),+ ("SWX_LAUNCH_INFLIGHT_SECS", "120"),+ ("SWX_LAUNCH_BACKOFF_SECS", "0"),+ ];+ Bridge::start_in(TempDir::new("adv3-scratch"), &env)+ }+}++/// Every route runs and starts nothing; the user's own launch works.+fn nothing_of_ours_starts() -> Value {+ json!({+ "shellExecute": {"chain": false},+ "powershell": {"chain": false},+ "explorer": {"chain": false},+ "cmdStart": {"chain": false},+ "userStartMenu": {"chain": true, "runBeginAfterMs": 0, "helperAfterMs": 50,+ "sldworksAfterMs": 100, "attachAfterMs": 600}+ })+}++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));+ }+}++/// Drive the shared scenario: our route starts nothing, the user starts+/// SOLIDWORKS 350ms later, and it comes up.+fn someone_elses_launch(tag: &str) -> (Sim, Bridge, Value) {+ let m = Sim::new(tag, nothing_of_ours_starts());+ let b = m.boot();+ b.call("swx_start", json!({"reason": "modelling a bracket", "waitSeconds": 0}));+ std::thread::sleep(Duration::from_millis(350));+ m.user_starts_solidworks();+ let st = wait_for_launch(&b, 30);+ (m, b, st)+}++// ════════════════════════════════════════════════════════════════════════════+// THE EVIDENCE IS STILL ADOPTED, EVEN THOUGH THE PROCESSES ARE NOT+// ════════════════════════════════════════════════════════════════════════════++/// `watch_route` attributes a launcher log on ONE test — is it newer than the+/// newest log that existed before our invoke? That answers "this log is not+/// pre-existing". It does not answer "this log is ours", and nothing else is+/// asked: the log is not required to belong to the chain the invoke handed+/// back, nor is any attribution required to exist at all.+///+/// So on a run where our route started nothing — which this bridge detects,+/// names, and (rightly) keeps going after — a launch the user starts while we+/// are watching writes the log we then read as our own, and `Run Begin` in+/// THEIR log becomes our `runBeginSeen`.+///+/// The same rule that stopped the bridge adopting the user's PROCESSES has to+/// be applied to their EVIDENCE: a log is ours when our attributed chain is+/// alive to have written it, or the state is `unattributedLog` and says so.+#[test]+fn a_launcher_log_from_someone_elses_launch_is_not_our_run_begin() {+ let (_m, b, st) = someone_elses_launch("adv3-userlog");+ assert!(+ st["launch"]["outcome"] == "attached",+ "precondition: the user's launch succeeded and the bridge attached to it: {}",+ s(&st["launch"])+ );+ assert!(+ st["launch"]["attributed"] == false,+ "precondition: our own route started nothing and attributed nothing: {}",+ s(&st["launch"])+ );+ assert!(+ st["launch"]["runBeginSeen"] != json!(true),+ "the bridge recorded that it saw 'Run Begin' — from a launcher log written by a launch \+ the USER started. Its own route started nothing at all: there is no chain of ours that \+ could have written a log. 'Newer than the baseline' means the log is not pre-existing; \+ it does not mean it is ours.\n{}\n--- server log ---\n{}",+ s(&st["launch"]),+ b.log()+ );+}++/// The consequence, and the reason this is not cosmetic.+///+/// `finish("attached")` guards the route memory with `run_begin_seen` — added+/// in round two precisely so that "a user who starts SOLIDWORKS from their+/// Start Menu mid-flight" could not teach the bridge that whatever route it+/// happened to be trying is the one that works. That guard is defeated by the+/// hole above: the user's `Run Begin` sets `run_begin_seen`, so the bridge+/// writes `preferredRoute` for a route that started NOTHING — and then tries+/// that route first on every future cold start, on this machine, for ever.+#[test]+fn a_route_that_started_nothing_is_never_remembered_as_the_one_that_works() {+ let (_m, b, st) = someone_elses_launch("adv3-preferred");+ assert!(+ st["launch"]["outcome"] == "attached" && st["launch"]["attributed"] == false,+ "precondition: attached, with nothing attributed to our own route: {}",+ s(&st["launch"])+ );+ assert!(+ st["launch"]["preferredRoute"].is_null(),+ "the bridge learned '{}' as the route that works on this machine — from a run in which \+ that route started nothing whatsoever and the USER launched SOLIDWORKS. It is now \+ first in the plan for every future cold start. The round-two guard (`run_begin_seen`) \+ was supposed to prevent exactly this, and it is satisfied by somebody else's log.\n{}\n\+ --- server log ---\n{}",+ st["launch"]["preferredRoute"].as_str().unwrap_or("?"),+ s(&st["launch"]),+ b.log()+ );+}++/// The one piece of good news worth pinning down so it cannot regress: the+/// user's PROCESSES are still safe. Nothing descends from an attributed root,+/// so nothing is killed. This test passes today; it is here so that a future+/// change to the log rule cannot quietly re-open the process side.+#[test]+fn the_users_own_launcher_chain_is_still_never_terminated() {+ let (m, b, st) = someone_elses_launch("adv3-userprocs");+ let killed = m.terminated();+ assert!(+ killed.is_empty(),+ "the bridge terminated {killed:?} — processes belonging to a launch it did not start.\n\+ {}\n--- server log ---\n{}",+ s(&st["launch"]),+ b.log()+ );+}++// ════════════════════════════════════════════════════════════════════════════+// THE WALK CANNOT CROSS A ROOT THE CENSUS DOES NOT LIST+// ════════════════════════════════════════════════════════════════════════════++/// `attributed_chain` confirms the root by finding it IN THE CENSUS — and the+/// census is filtered to the six `WATCHED_IMAGES`. `ShellExecuteExW` hands back+/// whatever process the shell created for the `.lnk`, and the release is+/// explicit that no real reply has ever been observed (§9). If that process is+/// anything other than `CATSTART.exe` or `SWXDesktopLauncher.exe` — a shim, a+/// wrapper, a future Platform build, `rundll32` — the root cannot be confirmed,+/// the seed list is empty, the walk has nothing to descend from, and the real+/// CATSTART it started is never attributed.+///+/// The bridge then reports that stall as `cleanup:"nothingToClean"` with+/// `attributed:true` — a CLEAN recovery — and goes on to the next route, with a+/// launcher chain of its own still running. The parent link is right there: the+/// orphan's ppid is the handle-proven pid we were given.+#[test]+fn a_root_the_census_cannot_see_still_carries_its_children() {+ let invoked = 1_000_000u64;+ let root = Attribution {+ pid: 500,+ created_at: Some(invoked + 1),+ source: "shellExecuteHandle".into(),+ };+ // The root itself is not one of the six watched images, so it is not in the+ // census at all. The chain it started IS.+ let census = vec![+ p(501, "CATSTART.exe", invoked + 2, Some(500)),+ p(502, "SWXDesktopLauncher.exe", invoked + 3, Some(501)),+ ];+ let chain = launch::attributed_chain(&census, Some(&root), &[], &[], invoked);+ let pids: Vec<u32> = chain.clone().unwrap_or_default().iter().map(|x| x.pid).collect();+ assert!(+ pids.contains(&501) && pids.contains(&502),+ "the invoke handed back pid 500 as the root it started, and pid 501 records 500 as its \+ parent — but the walk found nothing ({pids:?}) because 500 is not itself a terminable \+ image, so it never became a seed. The supervisor reads the empty result as \+ 'nothingToClean' and moves on, leaving the chain running. A handle-proven root is \+ proof of lineage whether or not the root is a process we may terminate."+ );+}
tests/adversary4.rsadded+202@@ -0,0 +1,202 @@+//! ADVERSARIAL REVIEW OF 0.4.3 — round four, and the last.+//!+//! The evidence law is nearly right. `watch_route` now asks whether OUR chain+//! existed before the log was written, and an unattributable route attributes+//! no log at all — which closes the case where the bridge had started nothing.+//!+//! What is left is that the test is NECESSARY and not SUFFICIENT. "Our chain+//! existed before this log was written" is true of every log written after our+//! chain started, including the user's. And the newest log wins the `find`, so+//! a launch the user begins while ours is stalling is preferentially the one we+//! read.+//!+//! Both tests here FAIL on 48a7e2c.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};+use std::time::{Duration, Instant};+use swx_bridge::launchos::FakeOs;++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++struct Sim {+ dir: TempDir,+ lnk: String,+}++impl Sim {+ fn new(tag: &str, routes: 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"),+ json!([{"pid": 6076, "image": "3DEXPERIENCELauncher.exe", "ageSeconds": 30000}])+ .to_string(),+ )+ .unwrap();+ let lnk = format!("{}/SOLIDWORKS Design.lnk", dir.path());+ std::fs::write(&lnk, "not really a shortcut").unwrap();+ Sim { dir, lnk }+ }++ /// The user starts SOLIDWORKS from their Start Menu, mid-flight.+ fn user_starts_solidworks(&self) {+ FakeOs::record_invoke(std::path::Path::new(&self.dir.path()), "userStartMenu", &self.lnk);+ }++ fn boot(&self, extra: &[(&str, &str)]) -> Bridge {+ let mut env: Vec<(&str, &str)> = vec![+ ("SWX_SOLIDWORKS_SHORTCUT", Box::leak(self.lnk.clone().into_boxed_str())),+ ("SWX_LAUNCH_FAKE_DIR", Box::leak(self.dir.path().into_boxed_str())),+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "3"),+ ("SWX_LAUNCH_SLDWORKS_SECS", "6"),+ ("SWX_LAUNCH_ATTACH_SECS", "12"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ("SWX_LAUNCH_ATTACH_POLL_MS", "150"),+ ("SWX_LAUNCH_BACKOFF_SECS", "0"),+ ];+ env.extend_from_slice(extra);+ Bridge::start_in(TempDir::new("adv4-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));+ }+}++/// Our route DOES start a chain — so it is attributed, its root is confirmed,+/// and the round-three gate ("our chain must have existed") is satisfied. It+/// simply never reaches `Run Begin`. The user then starts SOLIDWORKS while we+/// are watching, and THEIR log is the newest one in the directory.+fn ours_stalls_theirs_works(tag: &str) -> (Sim, Bridge, Value) {+ let m = Sim::new(+ tag,+ json!({+ // Ours: a real chain, attributable, confirmed — and it stalls at+ // SecurityContext, exactly as measured on 2026-09-04.+ "shellExecute": {"chain": true},+ "powershell": {"chain": true},+ "explorer": {"chain": true},+ "cmdStart": {"chain": true},+ // Theirs: a Start-Menu launch that works.+ "userStartMenu": {"chain": true, "runBeginAfterMs": 0, "helperAfterMs": 50,+ "sldworksAfterMs": 100, "attachAfterMs": 600}+ }),+ );+ let b = m.boot(&[]);+ b.call("swx_start", json!({"reason": "modelling a bracket", "waitSeconds": 0}));+ // Long enough for our own root to be confirmed (the first poll is at 100ms),+ // well inside the 3s Run Begin budget.+ std::thread::sleep(Duration::from_millis(600));+ m.user_starts_solidworks();+ let st = wait_for_launch(&b, 30);+ (m, b, st)+}++/// `watch_route`'s test is:+///+/// ```text+/// (Some(root), Some(written)) => root_confirmed && root.created_at <= written+/// ```+///+/// which asks "did our chain exist before this log was written". Every log+/// written after our chain started passes it — the user's included. And+/// `launcher_logs()` returns newest-first while the selection is a `find`, so+/// when a second launch is running the bridge preferentially reads THEIR log.+///+/// Our own route stalls at SecurityContext and its log never contains+/// `Run Begin`. The bridge reports that it saw one.+#[test]+fn a_newer_log_from_a_second_launch_is_not_our_run_begin() {+ let (_m, b, st) = ours_stalls_theirs_works("adv4-newerlog");+ assert!(+ st["launch"]["outcome"] == "attached",+ "precondition: the user's launch worked and the bridge attached: {}",+ s(&st["launch"])+ );+ assert!(+ st["launch"]["attributed"] == true && st["launch"]["rootConfirmed"] == true,+ "precondition: our own route was attributed and its root confirmed: {}",+ s(&st["launch"])+ );+ assert!(+ st["launch"]["runBeginSeen"] != json!(true),+ "our route's own launcher log stops at SecurityContext and never logs 'Run Begin' — the \+ bridge read it from a NEWER log written by a launch the user started while ours was \+ stalling. 'Our chain existed before this log was written' is necessary and not \+ sufficient: it is true of every log written after our chain started.\n{}\n\+ --- server log ---\n{}",+ s(&st["launch"]),+ b.log()+ );+}++/// ...and the consequence is the same one round three found: the+/// `run_begin_seen` guard on the route memory is satisfied by somebody else's+/// evidence, so a route that stalled at SecurityContext is written down as the+/// route that works on this machine and tried first for ever.+#[test]+fn a_route_that_stalled_is_never_remembered_as_the_one_that_works() {+ let (_m, b, st) = ours_stalls_theirs_works("adv4-preferred");+ assert!(+ st["launch"]["outcome"] == "attached",+ "precondition: attached: {}",+ s(&st["launch"])+ );+ assert!(+ st["launch"]["preferredRoute"].is_null(),+ "the bridge learned '{}' as the route that works — a route whose own launcher chain \+ stalled at SecurityContext and was cleaned up. Every future cold start on this machine \+ now begins with it.\n{}\n--- server log ---\n{}",+ st["launch"]["preferredRoute"].as_str().unwrap_or("?"),+ s(&st["launch"]),+ b.log()+ );+}++/// The gate's whole purpose is a PER-ROUTE table: which route reached+/// `Run Begin`, which produced a log, which left something behind. But the log+/// evidence lives only on the ATTEMPT — `launcherLog`, `runBeginSeen` and+/// `unattributedLog` are attempt-level fields, and `runBeginSeen` is reset per+/// route while `unattributedLog` and `rootConfirmed` are not.+///+/// `tools/launch-gate.py::route_table` therefore reads all three from the+/// attempt inside its per-route loop and prints the SAME value on every row —+/// the last route's log attributed to all of them, and "unattributed" against+/// routes that never saw one. A `RouteTry` has to carry its own evidence for+/// the gate's central measurement to mean anything.+#[test]+fn every_route_records_the_log_evidence_that_belongs_to_it() {+ let m = Sim::new(+ "adv4-perroute",+ json!({"shellExecute": {"chain": true}, "powershell": {"chain": true},+ "explorer": {"chain": true}, "cmdStart": {"chain": true}}),+ );+ let b = m.boot(&[("SWX_LAUNCH_RUN_BEGIN_SECS", "1")]);+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 40);++ let tried = st["launch"]["routesTried"].as_array().cloned().unwrap_or_default();+ assert!(!tried.is_empty(), "precondition: at least one route was tried: {}", s(&st["launch"]));+ let first = &tried[0];+ assert!(+ first.get("launcherLog").is_some() && first.get("runBeginSeen").is_some(),+ "routesTried[0] carries no log evidence of its own — only route, stage, cleanup and a \+ note. The gate reads launcherLog/runBeginSeen/unattributedLog from the ATTEMPT for \+ every row, so its per-route table cannot distinguish the route that reached Run Begin \+ from the three that did not.\nroutesTried[0]: {}\n--- server log ---\n{}",+ s(first),+ b.log()+ );+}
tests/adversary5.rsadded+152@@ -0,0 +1,152 @@+//! ADVERSARIAL REVIEW OF 0.5.0 — import / document-lifecycle robustness.+//!+//! The drain verb is well guarded where it counts: it re-reads each document's+//! path immediately before closing and re-checks `is_import_temp`, so a+//! document saved out of the temp dir between enumerate and close is skipped+//! (TOCTOU covered); it is bounded per call by `max` and a 40s wall; and the+//! server loop stops on no-progress, so 1342 cannot wedge or spin the host.+//! `cmd_import` fails closed on an unreadable count, so it never double-imports.+//!+//! What is NOT sound is the ownership PROOF itself. `is_import_temp` requires+//! two path markers to be PRESENT, not to be in the launcher's actual+//! `…\Temp\swx<pid>\IC~~\…` relationship — so a path can carry both markers in+//! the wrong order, or in unrelated folders, and be closed WITHOUT A SAVE+//! PROMPT. Closing a user's document is data loss.+//!+//! Every #[test] here FAILS on 33d56d7.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};+use swx_bridge::comhost::host::is_import_temp;++/// THE RESOLVED `%TEMP%` these assertions are about.+///+/// `is_import_temp` anchors to the temp root the process resolves, and on this+/// Linux host that is `/tmp` — which no Windows path can ever sit under. The+/// rule under test is "is this path under the USER'S resolved temp dir", so the+/// test says which dir that is. (0.5.3: the anchor used to have a shape-test+/// fallback that made this unnecessary, and that fallback qualified a restored+/// profile backup on another volume.)+fn with_live_temp() {+ std::env::set_var("SWX_TEMP_ROOT", swx_bridge::comhost::mock::MOCK_TEMP_ROOT);+}++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++// ════════════════════════════════════════════════════════════════════════════+// 1. THE OWNERSHIP PROOF — markers present is not markers in relationship+// ════════════════════════════════════════════════════════════════════════════++/// A REAL import temp is always `…\Temp\swx<pid>\…\IC~~\…` — the `IC~~`+/// conversion folder is a DESCENDANT of the `swx<pid>` temp folder. `is_import_temp`+/// checks only that `\temp\swx` and `\ic~~\` each appear SOMEWHERE, in any order.+/// A path with `\IC~~\` BEFORE `\Temp\swx` cannot be a launcher temp, yet it+/// qualifies — and `cmd_close_import_temps` closes what qualifies, with+/// `CloseDoc` (no save prompt).+#[test]+fn markers_in_the_wrong_order_are_not_an_import_temp() {+ with_live_temp();+ // IC~~ segment first, temp\swx second: impossible for a real launcher temp.+ let contrived = r"C:\work\IC~~\drafts\Temp\swx_export\customer-master.sldprt";+ assert!(+ !is_import_temp(contrived),+ "'{contrived}' was accepted as a drainable import temp although its IC~~ segment PRECEDES \+ its Temp\\swx segment — the reverse of the launcher's real layout. is_import_temp \+ requires the two markers to be present, not to stand in the swx<pid>\\…\\IC~~ \+ relationship, so a user path carrying both tokens is closed without a save prompt."+ );+}++/// The same looseness with a plausible user directory: someone keeps design+/// backups under a folder they named `swx-archive` inside a `Temp` folder, with+/// an `IC~~` subfolder of their own. Both markers, adjacent to nothing the+/// launcher made — and the file has a real, saved location the user would lose.+#[test]+fn a_user_folder_that_happens_to_contain_both_tokens_is_not_a_temp() {+ with_live_temp();+ let user = r"D:\Temp\swx-archive\2026\IC~~\customer-final.sldprt";+ assert!(+ !is_import_temp(user),+ "'{user}' — a user's own saved file under folders they named — matched the import-temp \+ proof and would be closed without a save prompt. The proof must be the CONTIGUOUS \+ launcher pattern (Temp\\swx<digits>\\…\\IC~~\\), not 'both tokens appear'."+ );+}++/// And the proof should be anchored to the launcher's pid form: `swx` followed+/// by digits. `\temp\swx` matches `\temp\swxanything`, so a user folder literally+/// named `swxstuff` under any `Temp` qualifies the whole path.+#[test]+fn the_swx_marker_should_be_the_launchers_pid_form_not_any_swx_prefix() {+ with_live_temp();+ let user = r"C:\Users\drew\AppData\Local\Temp\swxnotes\IC~~\keep.sldprt";+ assert!(+ !is_import_temp(user),+ "'{user}' qualified because '\\temp\\swx' matches 'swxnotes'. The launcher's folder is \+ swx<pid> — swx followed by DIGITS. Matching any swx-prefixed folder name widens the \+ proof past what the launcher actually writes."+ );+}++/// The cases that MUST still match, so a fix does not throw the baby out — a+/// real temp with forward slashes, 8.3 short name, and mixed case. These pass+/// today and must keep passing.+#[test]+fn genuine_temps_still_match_after_any_tightening() {+ with_live_temp();+ for p in [+ r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT",+ "C:/Users/drew/AppData/Local/Temp/swx107688/IC~~/First.step.SLDPRT",+ r"C:\USERS\DREW\APPDATA\LOCAL\TEMP\SWX107688\IC~~\FIRST.STEP.SLDPRT",+ ] {+ assert!(is_import_temp(p), "a genuine launcher temp must still be recognised: {p}");+ }+}++// ════════════════════════════════════════════════════════════════════════════+// 4. THE MOCK/REAL DIVERGENCE the Builder flagged+// ════════════════════════════════════════════════════════════════════════════++/// The real backend's `doc_info` reads `GetPathName`, which for an import temp+/// returns the `…\IC~~\…` path — NON-EMPTY. The mock's `doc_info` returns+/// `path:""` for the same document (unsaved → empty), and its comment claims+/// "both backends give it". They do not: `is_import_temp`, the write gate's+/// "destination exists" check, `save_as`, and path-keyed ownership all read+/// `doc_info.path`, and for a temp the mock feeds them "" while the real+/// backend feeds them the temp path. Every path-keyed consumer is therefore+/// UNTESTED for a temp, and could treat a real temp (non-empty path) differently+/// from what the mock proves.+#[test]+fn a_mock_import_temp_carries_the_path_a_real_one_would() {+ with_live_temp();+ let b = Bridge::start();+ b.call("swx_start", json!({"reason": "importing a STEP file"}));+ // A fixture the importer will accept in mock mode.+ let step = TempDir::new("adv5-step");+ let file = step.join("First.step");+ std::fs::write(&file, b"ISO-10303-21;\n").unwrap();++ let imp = b.call("swx_import", json!({"filePath": file.to_string_lossy()}));+ assert!(imp["success"] == true, "precondition: import ran: {}", s(&imp));++ let docs = b.call("swx_docs", json!({}));+ let list = docs["docs"].as_array().cloned().unwrap_or_default();+ let temp = list.iter().find(|d| {+ d.get("title").and_then(|t| t.as_str()).map(|t| t.contains("First")).unwrap_or(false)+ });+ assert!(temp.is_some(), "precondition: the imported doc is listed: {}", s(&docs));+ let path = temp.unwrap().get("path").and_then(|p| p.as_str()).unwrap_or("");+ assert!(+ is_import_temp(path),+ "the mock reports path='{path}' for an import temp, so `is_import_temp` — the ownership \+ proof the whole drain relies on — is FALSE against the mock's own doc_info. The real \+ backend's GetPathName returns the IC~~ path, so the path-keyed consumers (the drain \+ match, the write gate's exists-check, save_as, ownership-by-path) are exercised on a \+ value the real backend never produces for a temp.\ndoc: {}",+ s(temp.unwrap())+ );+}
tests/adversary6.rsadded+346@@ -0,0 +1,346 @@+//! ADVERSARIAL REVIEW OF 0.5.1 — pass six.+//!+//! F1 is properly fixed in SHAPE: `is_import_temp` is now a segment walk, and+//! every path my round-five tests closed on stays closed. F2 is closed in both+//! places (an unreadable count is UNKNOWN-and-truncated, never a full marshal).+//! F3's caveat is honest. `liveness_verdict` gets the hard part right — no+//! `dead` from one sample, a short span, or unknown responsiveness, and+//! `verdict_advice("dead")` puts the decision with the user.+//!+//! What is left is two things the segment walk still cannot see, and a family of+//! verbs that read an UNLISTABLE session as an EMPTY one.+//!+//! Every #[test] here FAILS on e723504 except the two positive controls, which+//! are here so a fix cannot over-tighten.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};+use swx_bridge::comhost::host::is_import_temp;+use swx_bridge::launch::liveness_verdict;++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++fn fixture(dir: &TempDir, name: &str) -> String {+ let p = dir.join(name);+ std::fs::write(&p, b"ISO-10303-21;\nHEADER;\nENDSEC;\n").unwrap();+ p.to_string_lossy().to_string()+}++// ════════════════════════════════════════════════════════════════════════════+// 1. DATA LOSS: the walk proves a LAYOUT, not a LOCATION+// ════════════════════════════════════════════════════════════════════════════++/// The walk asks for a `temp` segment, then `swx<digits>`, then `IC~~`, then a+/// file. It never asks WHERE that chain is. `%TEMP%` is a specific directory;+/// `D:\Temp\` is a folder a user made.+///+/// The realistic route in: the 1342 leaked temps are real files still sitting on+/// disk. Recovering one — copying `…\Temp\swx107688\IC~~\First.step.SLDPRT`+/// somewhere safe that keeps the folder layout, or just opening it where it lies+/// — makes it the user's document. `cmd_close_import_temps` then closes it with+/// `CloseDoc`, which does not prompt, in the DEFAULT posture, with no write+/// opt-in and no host veto (the drain is deliberately exempt).+#[test]+fn a_temp_shaped_chain_on_another_volume_is_not_the_launchers_temp() {+ let users_own = r"D:\Temp\swx107688\IC~~\First.step.SLDPRT";+ assert!(+ !is_import_temp(users_own),+ "'{users_own}' is not under %TEMP% — it is a folder the user made (or a copy they took of \+ a leaked temp they wanted to keep), and it qualified for a no-prompt CloseDoc. The proof \+ has to be anchored to the resolved %TEMP% root, not just to the segment shape."+ );+}++/// The live evidence is unambiguous about the layout:+/// `C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT` — `IC~~`+/// sits DIRECTLY under `swx<pid>`. The walk accepts it at any depth below, which+/// widens the surface for no benefit.+///+/// The asymmetry says which way to err: a MISS leaves a temp undrained (memory,+/// recoverable by a restart), a FALSE MATCH closes a document without a prompt+/// (data loss). Tighten to the observed layout.+#[test]+fn ic_must_sit_directly_under_the_swx_pid_folder() {+ let deep = r"C:\Users\drew\AppData\Local\Temp\swx107688\projects\2026\IC~~\customer.sldprt";+ assert!(+ !is_import_temp(deep),+ "'{deep}' matched although IC~~ is three levels below swx107688. The launcher writes \+ swx<pid>\\IC~~\\ directly; accepting any depth below buys nothing and widens a \+ no-prompt-close rule."+ );+}++/// The cases that MUST keep matching — the real layout, forward slashes, the 8.3+/// short form, mixed case. These pass today.+#[test]+fn the_real_launcher_layout_still_matches() {+ for p in [+ r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT",+ "C:/Users/drew/AppData/Local/Temp/swx107688/IC~~/First.step.SLDPRT",+ r"C:\USERS\DREW\APPDATA\LOCAL\TEMP\SWX107~1\IC~~\FIRST.STEP.SLDPRT",+ ] {+ assert!(is_import_temp(p), "the launcher's own layout must still be recognised: {p}");+ }+}++/// ...and the round-five closures must stay closed.+#[test]+fn the_round_five_data_loss_paths_stay_closed() {+ for p in [+ r"C:\work\IC~~\drafts\Temp\swx_export\customer-master.sldprt",+ r"D:\Temp\swx-archive\2026\IC~~\customer-final.sldprt",+ r"C:\Users\drew\AppData\Local\Temp\swxnotes\IC~~\keep.sldprt",+ // IC~~ as the FILE, not a folder.+ r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~",+ ] {+ assert!(!is_import_temp(p), "round five closed this and it must stay closed: {p}");+ }+}++// ════════════════════════════════════════════════════════════════════════════+// 2. AN UNLISTABLE SESSION READ AS AN EMPTY ONE+// ════════════════════════════════════════════════════════════════════════════++/// `resolve_document` refuses honestly with `document_list_unavailable` when the+/// open set is truncated — that fix is real. But two verbs take the same+/// truncated (therefore EMPTY) `open` list and iterate it instead of refusing:+/// `reset_workspace` and `close_all_documents`. They correctly skip the+/// ownership prune, then find nothing to close, and report `success:true` with+/// `closed:[]`.+///+/// A caller that asked the bridge to clean up its own documents is told the+/// workspace is clean. On the session this release exists for — 1342 open docs —+/// that is the most misleading possible answer.+#[test]+fn reset_workspace_refuses_when_it_cannot_see_the_session() {+ let b = Bridge::start_with(&[("SWX_DOC_ENUM_CAP", "3")]);+ b.call("swx_start", json!({"reason": "modelling"}));+ let steps = TempDir::new("adv6-steps");+ // Five bridge-created documents, over the cap of three.+ for i in 0..5 {+ let f = fixture(&steps, &format!("Part{i}.step"));+ b.call("swx_import", json!({"filePath": f}));+ }+ let docs = b.call("swx_docs", json!({}));+ assert!(+ docs["truncated"] == true,+ "precondition: the open set is over the cap and unlistable: {}",+ s(&docs)+ );++ let r = b.call("swx_reset_workspace", json!({"reason": "cleaning up after the build"}));+ let closed_none = r["closed"].as_array().map(|a| a.is_empty()).unwrap_or(true);+ assert!(+ !(r["success"] == true && closed_none),+ "swx_reset_workspace reported success having closed NOTHING, on a session holding five \+ documents this bridge created — because the open list was truncated, so the loop it \+ iterates was empty. The honest answer is document_list_unavailable, the same refusal \+ resolve_document makes.\n{}",+ s(&r)+ );+}++/// POSITIVE CONTROL, and it passes: `close_all_documents` does NOT depend on the+/// truncated list to act — it delegates to the host's single `CloseAllDocuments`+/// COM call and reports the documents the host says it closed. (An earlier+/// version of this test passed for the wrong reason, refused by the write+/// posture before it ever reached the loop; the opt-in below fixes that.)+#[test]+fn close_all_documents_refuses_when_it_cannot_see_the_session() {+ // The write opt-in is ON, so the verb reaches the loop under review rather+ // than being refused by the posture — an earlier version of this test passed+ // because the posture refused it first, which proves nothing about the hole.+ let b = Bridge::start_with(&[+ ("SWX_DOC_ENUM_CAP", "3"),+ ("SWX_ALLOW_USER_WRITES", "1"),+ ]);+ b.call("swx_start", json!({"reason": "modelling"}));+ let steps = TempDir::new("adv6-steps2");+ for i in 0..5 {+ let f = fixture(&steps, &format!("Part{i}.step"));+ b.call("swx_import", json!({"filePath": f}));+ }+ assert!(+ b.call("swx_docs", json!({}))["truncated"] == true,+ "precondition: unlistable"+ );++ let r = b.call(+ "swx_close_all_documents",+ json!({"reason": "clearing the session", "confirm": true, "allowUserWrites": true}),+ );+ assert!(+ r["errorCode"] != "user_writes_not_enabled",+ "precondition: the write posture must not refuse this before the loop is reached: {}",+ s(&r)+ );+ let says_ok = r["success"] == true;+ let closed_none = r["closed"].as_array().map(|a| a.is_empty()).unwrap_or(true);+ assert!(+ !(says_ok && closed_none),+ "swx_close_all_documents reported success having closed nothing on an unlistable session. \+ A destructive verb must refuse when it cannot see what it would act on, not report a \+ clean no-op.\n{}",+ s(&r)+ );+}++// ════════════════════════════════════════════════════════════════════════════+// 4. BUSY IS AN ABSOLUTE, NOT A RATE+// ════════════════════════════════════════════════════════════════════════════++/// `liveness_verdict` gets the asymmetry right — `dead` needs two readings, a+/// span of at least `DEAD_SAMPLE_SECS`, and no new window, and unknown+/// responsiveness is never a verdict. But `moving` is an ABSOLUTE threshold:+///+/// ```text+/// cpu_active_seconds > 0.25 || memory_delta_mb.abs() >= 8+/// ```+///+/// with no reference to the span. Over a ten-minute sample, 0.3 seconds of CPU+/// is 0.05% of one core — a housekeeping timer, not a rebuild — and an 8 MB+/// wobble is noise. Both read as `busy`, whose advice is "TELL THE USER TO WAIT+/// … it usually finishes … Do NOT advise ending the task". A wedged session with+/// one spinning background thread is therefore reported as "wait" for ever, and+/// there is no elapsed-time input that could ever escalate it.+#[test]+fn a_trickle_of_cpu_over_a_long_sample_is_not_evidence_of_work() {+ // 0.3s of CPU across 600s: 0.05% of a core.+ let v = liveness_verdict(Some(false), Some(0.3), Some(600.0), Some(0), Some(0));+ assert_ne!(+ v, "busy",+ "0.3s of CPU over a 600s sample is 0.05% of one core — noise, not a rebuild — and the \+ verdict was '{v}', whose advice tells the caller to wait and not to end the task. The \+ threshold is absolute; it has to be a RATE against the sample span."+ );+ // And an 8 MB drift over ten minutes is not progress either.+ let m = liveness_verdict(Some(false), Some(0.0), Some(600.0), Some(8), Some(0));+ assert_ne!(+ m, "busy",+ "an 8 MB memory change across 600s was read as work in progress ('{m}')."+ );+}++/// The properties that MUST hold, pinned so a rate fix cannot break them.+#[test]+fn the_liveness_asymmetry_holds() {+ // A real rebuild: 21s of CPU in 30s.+ assert_eq!(liveness_verdict(Some(false), Some(21.0), Some(30.0), Some(40), Some(0)), "busy");+ // Unknown responsiveness is never a verdict.+ assert_eq!(liveness_verdict(None, Some(0.0), Some(60.0), Some(0), Some(0)), "unknown");+ // One reading / too short a span is never dead.+ assert_eq!(liveness_verdict(Some(false), None, None, None, None), "unknown");+ assert_eq!(liveness_verdict(Some(false), Some(0.0), Some(3.0), Some(0), Some(0)), "unknown");+ // A dialog on screen means something is being asked, not that nothing runs.+ assert_eq!(liveness_verdict(Some(false), Some(0.0), Some(60.0), Some(0), Some(1)), "unknown");+ // No work over a long sample, nothing on screen: dead.+ assert_eq!(liveness_verdict(Some(false), Some(0.0), Some(60.0), Some(0), Some(0)), "dead");+}+++// ════════════════════════════════════════════════════════════════════════════+// 3. ONE IMPORT, TWO OWNERSHIP ROWS+// ════════════════════════════════════════════════════════════════════════════++/// F3's fix added `remember_scratch_at` so an import temp is remembered BY PATH+/// (ownership is decided by path whenever a document has one). But the+/// unconditional `remember_scratch(title, session)` above it was kept, and the+/// two dedupe on different keys — the first on `(title, session)` with+/// `path:None`, the second on `(path, session)`. Neither sees the other, so+/// every import writes TWO rows for ONE document.+///+/// The phantom `path:None` row claims the TITLE with no path, which is a wider+/// ownership claim than what was created; `trackedScratchDocuments` reports+/// double; and `owned-docs.json` — rewritten atomically on every+/// remember/forget/rename/prune — carries twice the rows it should, which on the+/// session this release exists for (1342 temps) is 2684.+#[test]+fn imports_record_one_owned_row_each_even_when_unlistable() {+ // WHY THE CAP MATTERS HERE: with a listable session the prune that runs at+ // the top of reset_workspace drops the phantom row (its path:None cannot+ // match an open document that reports a path), so the duplication is+ // invisible. On a TRUNCATED session the prune is correctly skipped — and the+ // phantoms then accumulate unbounded, which is precisely the 1342-temp state+ // this release exists for.+ let b = Bridge::start_with(&[("SWX_DOC_ENUM_CAP", "3")]);+ b.call("swx_start", json!({"reason": "importing"}));+ let steps = TempDir::new("adv6-dup");+ for i in 0..5 {+ let f = fixture(&steps, &format!("Widget{i}.step"));+ b.call("swx_import", json!({"filePath": f}));+ }+ assert!(+ b.call("swx_docs", json!({}))["truncated"] == true,+ "precondition: the session is unlistable, so the prune is skipped"+ );++ let r = b.call("swx_reset_workspace", json!({"reason": "checking what is tracked"}));+ let tracked = r["trackedScratchDocuments"].as_array().cloned().unwrap_or_default();+ assert_eq!(+ tracked.len(),+ 5,+ "five imports produced {} ownership rows. remember_scratch (title, path:None) and \+ remember_scratch_at (title, path) both fire and dedupe on different keys, so each import \+ writes TWO rows; on a truncated session nothing prunes the phantom away. owned-docs.json \+ is rewritten atomically on every remember/forget/prune, so at the 1342 temps this \+ release was written for that is 2684 rows per write — and the title-only row claims \+ ownership of the title with no path at all.\ntracked: {:?}",+ tracked.len(),+ tracked+ );+}++/// And the reason the sweep gives for skipping a temp is wrong. `reset_workspace`+/// computes `saved = path.is_some()`, but an import temp HAS a path+/// (`…\IC~~\…`) and has never been saved anywhere the user could find it. The+/// verb therefore skips its own import temps with "has been saved to disk; pass+/// force:true" — a statement that is not true of a conversion temporary, and one+/// that invites a caller to pass `force:true` on a session where the honest+/// answer is "use swx_close_import_temps".+#[test]+fn an_import_temp_is_not_described_as_saved_to_disk() {+ let b = Bridge::start();+ b.call("swx_start", json!({"reason": "importing"}));+ let steps = TempDir::new("adv6-skipreason");+ let f = fixture(&steps, "Bracket.step");+ b.call("swx_import", json!({"filePath": f}));++ let r = b.call("swx_reset_workspace", json!({"reason": "sweeping"}));+ let why: Vec<String> = r["skipped"]+ .as_array()+ .cloned()+ .unwrap_or_default()+ .iter()+ .filter_map(|x| x.get("why").and_then(|w| w.as_str()).map(String::from))+ .collect();+ assert!(+ !why.iter().any(|w| w.contains("saved to disk")),+ "the sweep skipped an import temporary saying it 'has been saved to disk'. It has a temp \+ PATH, which is not the same as being saved: nothing the user could open is on disk at a \+ location they chose. `saved = path.is_some()` conflates the two.\nskipped: {:?}\n{}",+ why,+ s(&r)+ );+}++/// `new_windows.unwrap_or(0) > 0` — a window census that FAILED becomes "no+/// dialog on screen", which is one of the three things `dead` requires. So an+/// unreadable desktop contributes positively to the gravest verdict the bridge+/// can reach, on a machine where the census failing and SOLIDWORKS being wedged+/// are correlated. Every other unknown in this function returns "unknown"; this+/// one is silently a zero.+#[test]+fn an_unreadable_window_census_cannot_support_a_dead_verdict() {+ let v = liveness_verdict(Some(false), Some(0.0), Some(60.0), Some(0), None);+ assert_ne!(+ v, "dead",+ "the window census could not be taken (new_windows:None) and the verdict was still \+ '{v}'. `unwrap_or(0)` turns 'I could not look for a dialog' into 'there is no dialog', \+ which is a precondition of `dead` — the one verdict whose advice sends the user to end \+ their CAD session."+ );+}
tests/adversary7.rsadded+185@@ -0,0 +1,185 @@+//! ADVERSARIAL REVIEW OF 0.5.2 — pass seven, the QUIT ladder.+//!+//! The ladder's measurement discipline is the best work in this codebase. I+//! could not break any of it: `process_gone` returns `None` on an unreadable+//! census and `None` becomes `Unmeasured`, never `Quit`; `stillRunning` is+//! `null` (never `false`) when the census cannot be read; the registry is+//! cleared ONLY after a census-confirmed exit; every rung releases its+//! `IDispatch` and the server re-releases and COUNTS references before+//! measuring, warning when any are outstanding; a new dialog or a UIPI block+//! stops the ladder; rung (f) refuses without a pid AND a creation time; and+//! the default `maxRung` is "a", so the message rungs do not run unasked.+//!+//! Two things do not hold. One is the strongest claim in `src/quit.rs`. The+//! other is the %TEMP% anchor from pass six, which has a fallback that undoes it.+//!+//! Both #[test]s here FAIL on 8d0cd2d.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};+use swx_bridge::comhost::host::is_import_temp_under;++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++// ════════════════════════════════════════════════════════════════════════════+// 1. THE PRECONDITIONS ARE SWEPT ONCE, NOT BEFORE EVERY RUNG+// ════════════════════════════════════════════════════════════════════════════++/// `check_preconditions`' own doc comment:+///+/// > Every one is required and **every one is re-measured immediately before+/// > each rung** — not inherited from the caller, not cached from an earlier+/// > verb.+///+/// It is called exactly once (`verbs.rs:1144`), before the `for rung in plan`+/// loop. Nothing inside the loop re-checks the document count, the unsaved+/// count, busy, responding, or the dialog census as a PRECONDITION — the loop+/// measures only what a rung DID.+///+/// So on a multi-rung ladder the "verified zero documents" invariant holds only+/// for rung (a). A document opened while rung (a) is settling is still open when+/// rung (b) fires `RunCommand(swCommands_APP_EXIT)` at the session. The ladder+/// does stop if that raises a Save prompt — but `BlockingDialog::error_code`+/// calls a save prompt at a verified zero "impossible … a bug to report", and+/// this is how it becomes possible.+#[test]+fn preconditions_are_remeasured_before_every_rung() {+ let dir = TempDir::new("adv7-quit");+ // Rung (a) declines, rung (b) would exit — a two-rung ladder with a settle+ // gap between them that a second caller can act in.+ std::fs::write(dir.join("rungs.json"), json!({"a": "declined", "b": "exited"}).to_string())+ .unwrap();+ std::fs::write(dir.join("windows.json"), json!([]).to_string()).unwrap();+ std::fs::write(+ dir.join("sldworks.json"),+ json!({"responding": true, "hwnd": 4242, "privateMB": 800}).to_string(),+ )+ .unwrap();++ let b = std::sync::Arc::new(Bridge::start_in(+ TempDir::new("adv7-scratch"),+ &[+ ("SWX_LAUNCH_FAKE_DIR", &dir.path()),+ ("SWX_ALLOW_USER_WRITES", "1"),+ // A wide settle so the document below lands between the rungs.+ ("SWX_QUIT_SETTLE_MS", "2500"),+ ],+ ));+ b.call("swx_start", json!({}));++ let quitter = {+ let b = std::sync::Arc::clone(&b);+ std::thread::spawn(move || {+ b.call(+ "swx_stop",+ json!({"confirm": true, "allowUserWrites": true,+ "reason": "the caller is finished with SOLIDWORKS", "maxRung": "b"}),+ )+ })+ };+ // Land a document in the gap after rung (a)'s measurement.+ std::thread::sleep(std::time::Duration::from_millis(1200));+ let made = b.call("swx_new_document", json!({"type": "part"}));+ let r = quitter.join().expect("the quit thread");++ assert!(+ made["success"] == true,+ "precondition: a document really was opened mid-ladder: {}",+ s(&made)+ );+ // MEASURE THE ACT, not the outcome. Whether rung (b) happened to succeed is+ // luck; whether it was ATTEMPTED at a session that now had a document open+ // is the safety property.+ let attempted = r["rungsTried"].as_array().map(|a| a.len()).unwrap_or(0);+ assert_eq!(+ attempted, 1,+ "the ladder attempted {attempted} rungs: it went on to fire rung (b) \+ (RunCommand(swCommands_APP_EXIT)) at a session where a document had been opened while \+ rung (a) was settling. check_preconditions is called ONCE, before the loop — its own doc \+ comment promises it runs before EVERY rung, and the 'a save prompt at a verified zero \+ document count is impossible, a bug to report' reasoning depends on that promise. The \+ ladder must re-sweep and refuse with open_documents_present instead.\n{}\n\+ --- server log ---\n{}",+ s(&r),+ b.log()+ );+}++// ════════════════════════════════════════════════════════════════════════════+// 2. THE %TEMP% ANCHOR HAS A FALLBACK THAT UNDOES IT+// ════════════════════════════════════════════════════════════════════════════++/// Pass six's D1 fix anchors the drain to the resolved `%TEMP%` — and then ORs+/// in a shape test:+///+/// ```text+/// matches_resolved || canonical // canonical = parent ends appdata\local\temp+/// ```+///+/// The `canonical` arm makes the anchor advisory. Any path whose tail is+/// `…\AppData\Local\Temp\swx<digits>\IC~~\<file>` qualifies wherever it lives:+/// a RESTORED PROFILE BACKUP (`E:\Backups\drew\AppData\Local\Temp\…`), another+/// user's profile, a copy someone took of a leaked temp they wanted to keep. The+/// drain closes what qualifies with `CloseDoc`, which does not prompt, in the+/// default posture with no write opt-in and no host veto.+///+/// It also fires when `%TEMP%` is UNREADABLE — `matches_resolved` is+/// `unwrap_or(false)`, so `canonical` alone decides. Everywhere else in this+/// bridge an unreadable precondition refuses; here it guesses.+#[test]+fn the_temp_anchor_is_not_undone_by_a_shape_test() {+ let live_temp = r"C:\Users\drew\AppData\Local\Temp";++ // A restored profile backup on another volume. Not the live %TEMP%.+ let backup = r"E:\Backups\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT";+ assert!(+ !is_import_temp_under(backup, Some(live_temp)),+ "'{backup}' is not under the resolved %TEMP% ({live_temp}) — it is a restored profile \+ backup — and it qualified for a no-prompt CloseDoc because its tail matches \+ appdata\\local\\temp. When %TEMP% IS readable, `matches_resolved` must be the whole test."+ );++ // Another user's profile, while ours is readable.+ let other = r"C:\Users\someoneelse\AppData\Local\Temp\swx107688\IC~~\x.SLDPRT";+ assert!(+ !is_import_temp_under(other, Some(live_temp)),+ "'{other}' belongs to another user's profile and still qualified."+ );++ // %TEMP% unreadable: the shape test is then the ONLY test, so a path a user+ // could have anywhere on disk drains. An unreadable precondition must refuse.+ assert!(+ !is_import_temp_under(backup, None),+ "with %TEMP% unreadable the shape test alone qualified '{backup}'. Every other \+ unreadable precondition in this bridge refuses; this one guesses, and the act it \+ authorises is a close without a save prompt."+ );+}++/// The cases that MUST keep matching, so a fix cannot break the drain: the live+/// %TEMP% (long form), the 8.3 short profile name, forward slashes, mixed case.+#[test]+fn the_live_temp_dir_still_drains() {+ let live = r"C:\Users\drew\AppData\Local\Temp";+ for p in [+ r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT",+ "C:/Users/drew/AppData/Local/Temp/swx107688/IC~~/First.step.SLDPRT",+ r"C:\USERS\DREW\APPDATA\LOCAL\TEMP\SWX107~1\IC~~\FIRST.STEP.SLDPRT",+ ] {+ assert!(+ is_import_temp_under(p, Some(live)),+ "a genuine temp under the live %TEMP% must still drain: {p}"+ );+ }+ // The 8.3 PROFILE form resolves to the same directory; it must still drain+ // even though it is not string-equal to the resolved root.+ let short = r"C:\Users\DREW~1\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT";+ assert!(+ is_import_temp_under(short, Some(live)),+ "the 8.3 short profile form of the live %TEMP% must still drain: {short}"+ );+}
tests/adversary8.rsadded+109@@ -0,0 +1,109 @@+//! ADVERSARIAL REVIEW OF 0.5.3 — final pass.+//!+//! Every pass-7 closure holds AT THE MECHANISM, not just at the test:+//!+//! D-1 the `canonical` arm is gone; `temp_root: None` returns false before+//! anything else is examined; the root is normalised for separators,+//! trailing slash and case; segment COUNTS must be equal (no prefix+//! trickery); and `is_short_form_of` requires the 8.3 head to be exactly+//! `min(len, 6)` characters, so `a~1` cannot stand in for `adversary`.+//! Q-1 `StopEvidence::sweep` + `check(force)` run before EVERY rung after the+//! first, with an identity comparison that yields `stop_identity_changed`.+//! Q-2 the mock has `exitAfterMs`; the ladder polls rather than taking one+//! sample.+//! Q-3 only a `swCefSubProc.exe` whose `ppid` IS this session's pid counts.+//! Q-4 `listed_documents` is now a parameter of `check_preconditions`.+//! Q-5 `GetWindowThreadProcessId` re-verifies the hwnd before a message rung.+//! G-1 `require_bridge_launched` exists in the gate.+//! plus `censusReadable` on the evidence block.+//!+//! What is left is the risk the tightening itself created, and it is the one+//! that decides whether tomorrow's drain step means anything.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// THE ANCHOR IS ONLY AS GOOD AS THE ROOT IT RESOLVES.+///+/// `temp_root()` reads the BRIDGE PROCESS's `%TEMP%`, and `is_import_temp_under`+/// then requires the document's path to sit exactly under it. That is right, and+/// it closes the data-loss hole. But the two can legitimately differ: the com+/// host inherits ab's environment, and ab may run under a service profile, a+/// redirected `TEMP`, or simply a different account from the interactive session+/// SOLIDWORKS lives in. More to the point, the 1342 leaked temps this verb+/// exists for were written by a DIFFERENT, DEAD bridge process — the design+/// deliberately does not check the pid, but it does now assume that process's+/// `%TEMP%` is ours.+///+/// When the roots disagree the drain matches nothing. It then reports+/// `success:true`, `closed:0`, `done:true`, and a hint that says **"Every open+/// STEP/IGES import temporary was closed."** Three of them are open. That is a+/// confident wrong claim about the user's session, and the operator running+/// launch → drain → quit tomorrow would read step 2 as done and be surprised by+/// the quit gate refusing on 1342 open documents.+///+/// A miss is the safe direction for the CLOSE. It is not the safe direction for+/// the REPORT.+#[test]+fn a_drain_that_matched_nothing_does_not_claim_it_drained_everything() {+ // The bridge's %TEMP% is not the one the documents' paths sit under.+ let b = Bridge::start_with(&[("SWX_TEMP_ROOT", r"C:\Windows\Temp")]);+ b.call("swx_start", json!({"reason": "importing"}));+ let steps = TempDir::new("adv8-steps");+ for i in 0..3 {+ let p = steps.join(&format!("Widget{i}.step"));+ std::fs::write(&p, b"ISO-10303-21;\n").unwrap();+ b.call("swx_import", json!({"filePath": p.to_string_lossy()}));+ }+ let docs = b.call("swx_docs", json!({}));+ assert!(+ docs["count"].as_u64().unwrap_or(0) >= 3,+ "precondition: import temporaries are open: {}",+ s(&docs)+ );++ let r = b.call("swx_close_import_temps", json!({"reason": "draining leaked temps"}));+ let closed = r["closed"].as_u64().unwrap_or(0);+ let hint = r["_hint"].as_str().unwrap_or("");+ assert!(+ !(closed == 0 && hint.contains("Every open STEP/IGES import temporary was closed")),+ "the drain matched NOTHING (closed:0) with {} documents open whose paths carry the \+ launcher's swx<pid>\\IC~~\\<file> tail, and reported that every import temporary was \+ closed. The %TEMP% this bridge resolved is not the one those paths sit under, and the \+ response says nothing about it: no tempRoot, no count of tail-matching documents that \+ failed the root test. A caller cannot tell 'there was nothing to drain' from 'I compared \+ against the wrong directory'.\n{}\n--- server log ---\n{}",+ docs["count"],+ s(&r),+ b.log()+ );+}++/// The same fact stated for the operator: whatever the drain concludes, the+/// response has to name the root it compared against, or a mismatch is+/// undiagnosable from the output.+#[test]+fn the_drain_reports_the_temp_root_it_compared_against() {+ let b = Bridge::start_with(&[("SWX_TEMP_ROOT", r"C:\Windows\Temp")]);+ b.call("swx_start", json!({"reason": "importing"}));+ let steps = TempDir::new("adv8-steps2");+ let p = steps.join("Widget.step");+ std::fs::write(&p, b"ISO-10303-21;\n").unwrap();+ b.call("swx_import", json!({"filePath": p.to_string_lossy()}));++ let r = b.call("swx_close_import_temps", json!({"reason": "draining leaked temps"}));+ let text = s(&r).to_lowercase();+ assert!(+ text.contains("temproot") || text.contains("windows\\\\temp") || text.contains("temp root"),+ "the drain's response does not say which %TEMP% it required the documents to sit under, \+ so a no-op cannot be told from a root mismatch. One field (`tempRoot`) makes the \+ difference diagnosable on a live machine where nobody can attach a debugger.\n{}",+ s(&r)+ );+}
tests/adversary9.rsadded+188@@ -0,0 +1,188 @@+//! ADVERSARIAL REVIEW OF 0.5.5 — pass nine, the drain's close key.+//!+//! The 0.5.5 walk is right where it was wrong: `walk_documents` is bounded by a+//! count AND a time box, reports `walkTruncatedByTime`, and re-reads each path as+//! it passes so a victim is identified from live evidence. `documents()` now+//! REFUSES above the cap and every caller propagates that error with `?`, so no+//! consumer turns "too many to marshal" into "none open". `importTempsElsewhere`+//! is explicitly documented as a count over the SCANNED window and is reported+//! beside `documentsScanned`. The latency fault is charged per document at the+//! points that pay a real round trip.+//!+//! But the victim is chosen BY PATH and then closed BY TITLE:+//!+//! ```text+//! for (title, path) in victims {+//! if sw.call("CloseDoc", &[&Var::bstr(&title)]).is_ok() {+//! ```+//!+//! `ISldWorks::CloseDoc` resolves a document by NAME, and SOLIDWORKS titles are+//! not unique across paths. Drew's session right now holds saved+//! `…\validation\First.step-1.SLDPRT` files whose titles are shared by import+//! temporaries. Closing by title asks SOLIDWORKS to pick, and `CloseDoc` does not+//! prompt.+//!+//! THE MOCK CANNOT SHOW THIS. Its drain removes by `d.path` — a unique internal+//! key — so it always closes exactly the right document, and the 254 green tests+//! are not evidence about the close key. The test below therefore asserts the+//! GUARD that is missing rather than pretending the mock reproduces the fault.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// Drew's live session, reduced to the two documents that matter: a SAVED file+/// he owns and an import temporary that carries the SAME TITLE.+fn session_with_a_title_collision(scratch: &TempDir) {+ let temp_root = r"C:\Users\drew\AppData\Local\Temp";+ std::fs::write(+ scratch.join("mock-solidworks-open-docs.json"),+ json!({+ "running": true,+ "untitled": 2,+ "active": "C:\\Users\\drew\\validation\\First.step-1.SLDPRT",+ "docs": [+ // The user's own saved document. NOT a temp, and its loss is+ // unrecoverable.+ {+ "docType": "part",+ "path": "C:\\Users\\drew\\validation\\First.step-1.SLDPRT",+ "title": "First.step-1.SLDPRT",+ "saved": true,+ "importTemp": false+ },+ // A leaked conversion temporary that happens to carry the same+ // title — which is exactly what repeated imports of one fixture+ // produce.+ {+ "docType": "part",+ "path": "untitled:import:1:First.step-1.SLDPRT",+ "title": "First.step-1.SLDPRT",+ "saved": false,+ "importTemp": true,+ "tempPath": format!("{temp_root}\\swx107688\\IC~~\\First.step-1.SLDPRT")+ }+ ],+ "files": {}+ })+ .to_string(),+ )+ .unwrap();+}++/// The drain must not close a victim whose title is shared by another open+/// document — because the only close key it has is that title.+///+/// Nothing in the response even lets a caller SEE the collision: no+/// `titleCollisions`, no per-victim "this title is unique among open documents",+/// no post-close verification that the document still at that path is the one+/// that went. On a real backend that is the difference between draining a+/// temporary and discarding Drew's validation part with no save prompt.+#[test]+fn the_drain_refuses_a_victim_whose_title_another_open_document_shares() {+ let scratch = TempDir::new("adv9-scratch");+ session_with_a_title_collision(&scratch);+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({"reason": "draining"}));++ let before = b.call("swx_docs", json!({}));+ let titled = |v: &Value| -> usize {+ v["docs"]+ .as_array()+ .map(|a| {+ a.iter()+ .filter(|d| d.get("title").and_then(|t| t.as_str()) == Some("First.step-1.SLDPRT"))+ .count()+ })+ .unwrap_or(0)+ };+ assert_eq!(+ titled(&before),+ 2,+ "precondition: two open documents share the title, one saved and one a temp: {}",+ s(&before)+ );++ let r = b.call("swx_close_import_temps", json!({"reason": "draining leaked temps"}));+ let text = s(&r).to_lowercase();+ assert!(+ text.contains("titlecollision")+ || text.contains("ambiguous")+ || text.contains("shares its title")+ || r["closed"].as_u64() == Some(0),+ "the drain closed a victim by TITLE while a second open document — a SAVED file the user \+ owns at C:\\Users\\drew\\validation\\First.step-1.SLDPRT — carries the same title, and \+ the response says nothing about it. CloseDoc resolves by name and does not prompt, so on \+ the real backend SOLIDWORKS chooses which of the two goes. The victim is identified by \+ PATH; it has to be closed by something that identifies it too.\n{}",+ s(&r)+ );+}++/// And the invariant itself, pinned. This PASSES today only because the mock+/// removes by its own unique key rather than by title — so it is a regression pin+/// for whenever the mock is made faithful to `CloseDoc`, not evidence that the+/// real backend is safe.+#[test]+fn a_saved_document_sharing_a_temps_title_survives_the_drain() {+ let scratch = TempDir::new("adv9-scratch2");+ session_with_a_title_collision(&scratch);+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({"reason": "draining"}));+ b.call("swx_close_import_temps", json!({"reason": "draining leaked temps"}));++ let after = b.call("swx_docs", json!({}));+ let saved_alive = after["docs"]+ .as_array()+ .map(|a| {+ a.iter().any(|d| {+ d.get("path").and_then(|p| p.as_str())+ == Some("C:\\Users\\drew\\validation\\First.step-1.SLDPRT")+ })+ })+ .unwrap_or(false);+ assert!(+ saved_alive,+ "the user's SAVED document at …\\validation\\First.step-1.SLDPRT is gone after a drain \+ that was supposed to touch only conversion temporaries.\n{}",+ s(&after)+ );+}++/// `walk_documents` stops on the first `GetNext` that does not answer:+///+/// ```text+/// let next = doc.obj("GetNext", &[]).ok().flatten();+/// ```+///+/// A document closed by the user (or by SOLIDWORKS) mid-walk invalidates the+/// iteration, `GetNext` fails, `.ok().flatten()` makes it `None`, and the walk+/// ENDS — with `walkTruncatedByTime:false` and therefore `done:true`. It never+/// closes a wrong document (each path is re-read as it is passed), but a walk+/// that was cut short is reported as one that finished. On a flooded session that+/// is the difference between "drained" and "stopped after four of 2144".+///+/// The fix is a comparison the verb already has both sides of: `documentsScanned`+/// against `documentCountBefore`.+#[test]+fn a_walk_that_ended_early_is_not_reported_as_complete() {+ let scratch = TempDir::new("adv9-scratch3");+ session_with_a_title_collision(&scratch);+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({"reason": "draining"}));+ let r = b.call("swx_close_import_temps", json!({"reason": "draining leaked temps"}));+ let text = s(&r).to_lowercase();+ assert!(+ text.contains("walkcomplete") || text.contains("walkincomplete"),+ "the response reports `walkTruncatedByTime` and `documentsScanned` but has no field that \+ says whether the walk actually REACHED THE END of the session. GetNext failing on an \+ invalidated iteration is indistinguishable in this output from a walk that finished, and \+ `done` is computed as if it finished.\n{}",+ s(&r)+ );+}
tests/common/mod.rs+60−14@@ -25,8 +25,14 @@ use swx_bridge::httpc; pub const BIN: &str = env!("CARGO_BIN_EXE_swx-bridge"); -/// A temp directory that cleans itself up.-pub struct TempDir(pub PathBuf);+/// A temp directory that cleans itself up — unless it is a BORROWED view of+/// one somebody else owns.+///+/// The distinction matters for exactly one kind of test: "a DIFFERENT server+/// process reads back what this one persisted". Two owners of the same path+/// means the first drop deletes the state the second was meant to read, and the+/// test then proves the opposite of what it claims.+pub struct TempDir(pub PathBuf, bool); impl TempDir { pub fn new(tag: &str) -> TempDir {@@ -35,7 +41,12 @@ impl TempDir { let dir = std::env::temp_dir().join(format!("swx-test-{}-{tag}-{n}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create temp dir");- TempDir(dir)+ TempDir(dir, true)+ }++ /// A view of a directory owned elsewhere: usable, never deleted.+ pub fn borrowed(dir: &std::path::Path) -> TempDir {+ TempDir(dir.to_path_buf(), false) } pub fn join(&self, name: &str) -> PathBuf { self.0.join(name)@@ -47,7 +58,9 @@ impl TempDir { impl Drop for TempDir { fn drop(&mut self) {- let _ = std::fs::remove_dir_all(&self.0);+ if self.1 {+ let _ = std::fs::remove_dir_all(&self.0);+ } } } @@ -84,8 +97,15 @@ impl Bridge { /// Boot the real server in mock mode on a free port, with its own scratch /// dir, and wait until /status answers. pub fn start_with(extra_env: &[(&str, &str)]) -> Bridge {+ Bridge::start_in(TempDir::new("scratch"), extra_env)+ }++ /// Boot with a scratch dir the caller owns — the only way to prove that+ /// something the bridge PERSISTS (the ownership registry, the route that+ /// worked) is read back by a DIFFERENT server process. A test that reboots+ /// into a fresh temp dir proves the opposite of what it claims to.+ pub fn start_in(scratch: TempDir, extra_env: &[(&str, &str)]) -> Bridge { let port = free_port();- let scratch = TempDir::new("scratch"); let log_dir = TempDir::new("log"); let log_path = log_dir.join("bridge.log"); let log = std::fs::File::create(&log_path).expect("create log");@@ -161,7 +181,11 @@ impl Bridge { *self.last_verb.lock().unwrap() = Some(command.to_string()); self.call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let body = json!({"command": command, "args": args}).to_string();- let r = httpc::post_json(&self.url("/command"), headers, &body, Duration::from_secs(30))+ // 120s, not 30: `swx_stop` WAITS for the exit it asked for, up to the+ // rung's own budget (30s for rung (a)), because a real `ExitApp` returns+ // before the process is gone. A client that gives up before the verb does+ // proves nothing about the verb.+ let r = httpc::post_json(&self.url("/command"), headers, &body, Duration::from_secs(120)) .unwrap_or_else(|e| panic!("POST {command}: {e}\nlog:\n{}", self.log())); assert_eq!( r.status,@@ -260,6 +284,11 @@ pub struct AbStub { pub url: String, calls: std::sync::Arc<Mutex<Vec<Value>>>, windows: std::sync::Arc<Mutex<Value>>,+ /// Per-ab-verb replies, for the verbs whose ANSWER is what the bridge+ /// branches on. `desktop_find_control` is the gate between "a window looked+ /// like a crash reporter" and "we clicked a button on it", so a stub that+ /// answered every verb with a window list could not tell those apart.+ replies: std::sync::Arc<Mutex<std::collections::BTreeMap<String, Value>>>, _thread: std::thread::JoinHandle<()>, } @@ -272,12 +301,16 @@ impl AbStub { let sink = std::sync::Arc::clone(&calls); let windows = std::sync::Arc::new(Mutex::new(windows)); let wins = std::sync::Arc::clone(&windows);+ let replies: std::sync::Arc<Mutex<std::collections::BTreeMap<String, Value>>> =+ std::sync::Arc::new(Mutex::new(std::collections::BTreeMap::new()));+ let reps = std::sync::Arc::clone(&replies); let thread = std::thread::spawn(move || { for stream in listener.incoming() { let Ok(mut s) = stream else { break }; use std::io::{Read, Write}; let mut buf = Vec::new(); let mut chunk = [0u8; 4096];+ let mut command = String::new(); // Read headers, then exactly Content-Length bytes. loop { let n = match s.read(&mut chunk) {@@ -297,19 +330,27 @@ impl AbStub { if buf.len() >= hdr_end + 4 + len { let body = &text[hdr_end + 4..]; if let Ok(v) = serde_json::from_str::<Value>(body) {+ command = v+ .get("command")+ .and_then(|c| c.as_str())+ .unwrap_or("")+ .to_string(); sink.lock().unwrap().push(v); } break; } } }- let payload = json!({- "success": true,- "status": "ok",- "exitCode": 0,- "stdout": "SUCCESS",- "output": json!({"data": {"windows": wins.lock().unwrap().clone()}}).to_string(),- })+ let payload = match reps.lock().unwrap().get(&command) {+ Some(custom) => custom.clone(),+ None => json!({+ "success": true,+ "status": "ok",+ "exitCode": 0,+ "stdout": "SUCCESS",+ "output": json!({"data": {"windows": wins.lock().unwrap().clone()}}).to_string(),+ }),+ } .to_string(); let _ = write!( s,@@ -319,7 +360,12 @@ impl AbStub { ); } });- AbStub { url: format!("http://127.0.0.1:{port}"), calls, windows, _thread: thread }+ AbStub { url: format!("http://127.0.0.1:{port}"), calls, windows, replies, _thread: thread }+ }++ /// Answer one ab verb with a specific payload.+ pub fn set_response(&self, command: &str, payload: Value) {+ self.replies.lock().unwrap().insert(command.to_string(), payload); } /// Replace the window list. Needed because attribution matches on OWNER PID,
tests/e2e.rs+289−18@@ -368,16 +368,34 @@ fn every_verb_over_http_against_the_real_binary() { let scratch_title = r["created"]["title"].as_str().unwrap_or("").to_string(); check!( b,- "new_document names it the way SOLIDWORKS does",- scratch_title.starts_with("Part") && scratch_title[4..].parse::<u32>().is_ok(),+ "new_document names it from SOLIDWORKS' own Part<n>, and gives it a FILE",+ scratch_title.starts_with("Part") && scratch_title.ends_with(".SLDPRT"), scratch_title.clone() ); check!( b,- "a brand-new document has NO path until it is saved",- r["created"]["path"] == "",+ "...so it has an identity that is not just a title (0.6.0)",+ r["documentIdentity"]["identity"] == "path"+ && r["created"]["path"].as_str().unwrap_or("").contains("scratch"), s(&r) );+ // 0.6.0: the old claim here was "a brand-new document has NO path until it is+ // saved". That was true, and it was the defect — three pathless documents+ // sharing one title were addressable by nothing. `keepUnsaved:true` is where+ // that state now lives, and it is reported rather than silent.+ let bare = b.call("swx_new_document", json!({"type": "part", "keepUnsaved": true}));+ check!(+ b,+ "keepUnsaved:true still makes a pathless document...",+ bare["created"]["path"] == "" && bare["documentIdentity"]["identity"] == "titleOnly",+ s(&bare)+ );+ check!(+ b,+ "...and SAYS that its title is the only name it has",+ bare["documentIdentity"]["warning"].as_str().unwrap_or("").contains("ambiguous_target"),+ s(&bare)+ ); check!(b, "new_document reports the template it used", r["template"].is_string(), s(&r)); let r = b.call("swx_new_document", json!({"type": "sandwich"}));@@ -677,7 +695,27 @@ fn every_verb_over_http_against_the_real_binary() { let step_in = built.join("alias.step"); let r = b.call("swx_import", json!({"filePath": step_in.to_string_lossy()})); check!(b, "import brings a neutral file in as a new document", r["success"] == true, s(&r));- check!(b, "the imported document is unsaved", r["imported"]["path"] == "", s(&r));+ // An imported document reports its CONVERSION TEMP path — what the real+ // backend's GetPathName returns — and is never a saved user file. The mock+ // used to report "" here, which meant `is_import_temp` (the proof the whole+ // drain rests on) was never exercised against a realistic value.+ check!(+ b,+ "the imported document reports the conversion temp path a real one would",+ swx_bridge::comhost::host::is_import_temp(r["imported"]["path"].as_str().unwrap_or("")),+ s(&r)+ );+ // Import reports the net document count it added, and says so — one open+ // unsaved doc, like new_document (WORKSTREAM A: the leak was callers+ // importing without ever closing). See the import_lifecycle suite for the+ // enumeration cap and the drain verb in depth.+ check!(b, "import asserts exactly one new document", r["documentsOpened"] == 1 && r["netCountOk"] == true, s(&r));+ check!(b, "...and its hint says it stays open until closed", r["_hint"].as_str().unwrap_or("").contains("stays open"), s(&r));+ let temps = b.call("swx_close_import_temps", json!({"reason": "drain import temps left by this suite"}));+ check!(b, "swx_close_import_temps runs in the default posture with just a reason", temps["success"] == true, s(&temps));+ check!(b, "...and reports how many it closed and whether it finished", temps["closed"].is_u64() && temps["done"] == true, s(&temps));+ let temps_gate = b.call("swx_close_import_temps", json!({}));+ check!(b, "...and needs a reason (it closes documents on the user's desktop)", temps_gate["errorCode"] == "reason_required", s(&temps_gate)); let r = b.call("swx_import", json!({})); check!(b, "import without a filePath is rejected", r["success"] == false, s(&r)); @@ -746,8 +784,17 @@ fn every_verb_over_http_against_the_real_binary() { let r = b.call("swx_close_window", json!({"hwnd": 4242, "reason": "clearing my own dialog", "confirm": true})); check!(b, "close_window reports ab being unreachable instead of claiming success", r["success"] == false, s(&r)); + // ab is deliberately unreachable in this harness, so the recovery pane —+ // which is a WINDOW, not a COM fact — cannot be looked for. The honest+ // answer is UNKNOWN. This assertion used to require `false`, which is the+ // most misleading possible answer to "did the last session end badly?". let r = b.call("swx_check_recovery", json!({}));- check!(b, "check_recovery answers with no recovery pending", r["recoveryPending"] == false, s(&r));+ check!(+ b,+ "check_recovery says UNKNOWN when the desktop cannot be enumerated, never 'no'",+ r["recoveryPending"].is_null() && r["recoveryKnown"] == false && r["abError"].is_string(),+ s(&r)+ ); let r = b.call("swx_set_driving_badge", json!({})); check!(b, "the driving badge defaults ON", r["showDrivingBadge"] == true, s(&r));@@ -872,12 +919,24 @@ fn every_verb_over_http_against_the_real_binary() { s(&docs) ); - // close_window must not become a general window killer.+ // close_window must not become a general window killer. With ab+ // unreachable the refusal stands, and it now says WHICH refusal it is:+ // "the desktop could not be listed" is a different fact from "this window+ // is not SOLIDWORKS'", and a caller told the second goes looking for the+ // wrong problem. let r = b.call("swx_close_window", json!({"hwnd": 999999, "reason": "tidy up", "confirm": true})); check!( b, "close_window refuses an hwnd it cannot attribute to SOLIDWORKS",- r["errorCode"] == "window_not_attributed",+ r["success"] == false+ && (r["errorCode"] == "window_not_attributed"+ || r["errorCode"] == "window_list_unavailable"),+ s(&r)+ );+ check!(+ b,+ "...and with ab unreachable it says so, rather than blaming the window",+ r["errorCode"] == "window_list_unavailable" && r["abError"].is_string(), s(&r) ); @@ -1062,7 +1121,24 @@ fn every_verb_over_http_against_the_real_binary() { let r = b.call("swx_state", json!({})); check!(b, "state", r["success"] == true && !r["openDocs"].is_null(), s(&r)); check!(b, "state etiquette", r["windowEtiquette"] == "background", s(&r));- check!(b, "state carries a dialog census", r["blockingDialogs"].is_array(), s(&r));+ // ab is unreachable here, so the census is UNKNOWN — and `blockingDialogs`+ // must say so rather than hand back an empty array, which a caller reads as+ // "nothing is in the way" right before it drives a SOLIDWORKS that may be+ // sitting on a modal.+ check!(+ b,+ "state carries a dialog census",+ r["blockingDialogs"].is_array() || r["blockingDialogs"].is_null(),+ s(&r)+ );+ check!(+ b,+ "...and an unreachable ab makes it UNKNOWN, never an empty 'nothing in the way'",+ r["blockingDialogs"].is_null()+ && r["blockingDialogsKnown"] == false+ && r["blockingDialogsError"].is_string(),+ s(&r)+ ); check!(b, "state carries this machine's measured timings", r["timings"].is_object(), s(&r)); check!(b, "state names the active document", r["activeDocument"].is_string(), s(&r)); let r = b.call("swx_state", json!({"screenshot": false}));@@ -1105,12 +1181,35 @@ fn every_verb_over_http_against_the_real_binary() { let d = b.call("swx_props_set", json!({"properties": {"Unsaved": "yes"}})); check!(b, "the exit fixture really is dirty", d["doc"]["dirty"] == true, s(&d)); let r = b.call("swx_stop", json!({}));- check!(b, "stop refuses while a document has unsaved changes", r["errorCode"] == "unsaved_documents", s(&r));+ // v2 gates on the OPEN-document count FIRST, and that refusal is the+ // stronger one: v1 gated on UNSAVED, so five of Drew's CLEAN documents with+ // unsavedDocuments:0 would have sailed past the guard. `force` waives the+ // unsaved question and never this one.+ check!(+ b,+ "stop refuses while the user's documents are open",+ r["errorCode"] == "open_documents_present",+ s(&r)+ );+ check!(b, "...and says force does not waive it", r["_hint"].as_str().unwrap_or("").contains("force` does not"), s(&r)); // Quitting ends the user's SOLIDWORKS session — on a Connected edition the // bridge cannot start it again — so it needs the process-level opt-in too.- let r = b.call("swx_stop", json!({"force": true}));- check!(b, "stop needs the process-level opt-in", r["errorCode"] == "user_writes_disabled", s(&r));- let on = Bridge::start_with(&[("SWX_ALLOW_USER_WRITES", "1")]);+ // Checked on an EMPTY session, because the open-document refusal above+ // would otherwise answer first and this consent gate would go unproven.+ let no_optin = quittable_machine("verb-stop-off", r#"{"a":"exited"}"#);+ let off = Bridge::start_with(&[+ ("SWX_LAUNCH_FAKE_DIR", &no_optin.path()),+ ("SWX_QUIT_SETTLE_MS", "80"),+ ]);+ off.call("swx_start", json!({}));+ let r = off.call("swx_stop", json!({"force": true}));+ check!(off, "stop needs the process-level opt-in", r["errorCode"] == "user_writes_disabled", s(&r));+ let optin = quittable_machine("verb-stop-on", r#"{"a":"exited"}"#);+ let on = Bridge::start_with(&[+ ("SWX_ALLOW_USER_WRITES", "1"),+ ("SWX_LAUNCH_FAKE_DIR", &optin.path()),+ ("SWX_QUIT_SETTLE_MS", "80"),+ ]); on.call("swx_start", json!({})); let r = on.call("swx_stop", json!({ "force": true, "allowUserWrites": true, "confirm": true, "reason": "the user asked me to close SOLIDWORKS"@@ -1343,6 +1442,17 @@ fn a_rename_during_export_is_reported_not_swallowed() { check!(clean, "...and the title is genuinely unchanged", r["doc"]["title"] == "bracket.sldprt", s(&r)); } +/// A fake-OS directory in which the quit ladder can actually MEASURE a quit:+/// the mock publishes its session pid there and `FakeOs` synthesises the+/// SOLIDWORKS process, so `swx_stop`'s "the pid and its creation time are gone"+/// test has something to observe. Without one the census is unavailable and the+/// ladder correctly refuses (`stop_preconditions_unknown`) — see tests/quit.rs.+fn quittable_machine(tag: &str, script: &str) -> TempDir {+ let d = TempDir::new(tag);+ std::fs::write(d.join("quit-script.json"), script).unwrap();+ d+}+ #[test] fn ownership_does_not_survive_a_solidworks_restart_or_a_close() { // Registry entries were keyed on TITLE alone and forgotten only by@@ -1353,7 +1463,12 @@ fn ownership_does_not_survive_a_solidworks_restart_or_a_close() { // a crash, or by CloseAllDocuments. // Started WITH the opt-in: this test has to stop and restart SOLIDWORKS to // create a second session, and quitting the user's session is gated.- let b = Bridge::start_with(&[("SWX_ALLOW_USER_WRITES", "1")]);+ let machine = quittable_machine("own-quit", r#"{"a":"exited"}"#);+ let b = Bridge::start_with(&[+ ("SWX_ALLOW_USER_WRITES", "1"),+ ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+ ("SWX_QUIT_SETTLE_MS", "80"),+ ]); b.call("swx_start", json!({})); let r = b.call("swx_new_document", json!({"type": "part"})); let title = r["created"]["title"].as_str().unwrap_or("").to_string();@@ -1405,9 +1520,24 @@ fn ownership_does_not_survive_a_solidworks_restart_or_a_close() { let r = b.call("swx_reset_workspace", json!({"titles": ["nothing"]})); check!(b, "which the bridge tracks", r["trackedScratchDocuments"].as_array().unwrap().contains(&json!(again)), s(&r)); - b.call("swx_stop", json!({"force": true, "allowUserWrites": true, "confirm": true, "reason": "test needs a second session"}));+ // v2 QUITS ONLY A SESSION WITH NO DOCUMENTS OPEN, and `force` does not waive+ // that (v1 gated on the UNSAVED count, which is how five of Drew's clean+ // documents could have been quit out from under him). So the sweep comes+ // first — which is also what a caller would have to do.+ b.call("swx_reset_workspace", json!({"force": true, "allowUserWrites": true, "confirm": true, "reason": "clearing before the restart"}));+ b.call("swx_close_all_documents", json!({+ "confirm": true, "allowUserWrites": true, "closeUnsaved": true,+ "reason": "the test needs an empty session before quitting"+ }));+ let stopped = b.call("swx_stop", json!({"force": true, "allowUserWrites": true, "confirm": true, "reason": "test needs a second session"}));+ check!(b, "with no documents open, the quit succeeds", stopped["stopped"] == true, s(&stopped)); b.call("swx_start", json!({}));- let r = b.call("swx_new_document", json!({"type": "part"}));+ // `keepUnsaved:true` so the document really is untitled-only: the HAZARD this+ // test is about is SOLIDWORKS reusing `Part1` across sessions, and from 0.6.0+ // a bridge-created document is given a unique file at birth precisely so that+ // hazard cannot reach it. The hazard is still real for a document that opts+ // out, and the ownership scoping still has to hold for it.+ let r = b.call("swx_new_document", json!({"type": "part", "keepUnsaved": true})); let after_restart = r["created"]["title"].as_str().unwrap_or("").to_string(); check!( b,@@ -2316,7 +2446,12 @@ fn a_document_that_has_never_been_saved_is_addressable_everywhere() { // title so loosely that it starts matching theirs. b.call("swx_open", json!({"path": "C:\\users\\gearbox.sldasm"})); - let made = b.call("swx_new_document", json!({"type": "part"}));+ // `keepUnsaved:true`, because 0.6.0 gives every new document a file of its+ // own at birth — precisely so this situation stops arising. The PATHLESS case+ // is still reachable (and this is how), and everything it proves still has to+ // hold for it: a document whose only name is its title must be addressable by+ // that title, everywhere.+ let made = b.call("swx_new_document", json!({"type": "part", "keepUnsaved": true})); let title = made["created"]["title"].as_str().unwrap_or("").to_string(); check!(b, "swx_new_document created a part", made["success"] == true, s(&made)); check!(b, "...with NO path, which is the whole difficulty", made["created"]["path"] == "", s(&made));@@ -2716,7 +2851,12 @@ fn asking_solidworks_to_quit_is_not_the_same_as_it_quitting() { // done. The host had done its job: it re-attaches after ExitApp and reports // honestly that SOLIDWORKS is still there. The SERVER then dressed that up // as a success.- let b = Bridge::start_with(&[("SWX_ALLOW_USER_WRITES", "1")]);+ let machine = quittable_machine("stop-honesty", r#"{"a":"exited"}"#);+ let b = Bridge::start_with(&[+ ("SWX_ALLOW_USER_WRITES", "1"),+ ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+ ("SWX_QUIT_SETTLE_MS", "80"),+ ]); // Not started: the host's own "nothing to stop" answer is the same shape. let r = b.call("swx_stop", json!({ "force": true, "confirm": true, "allowUserWrites": true, "reason": "tidy up"@@ -3159,3 +3299,134 @@ fn running_a_macro_is_arbitrary_code_and_is_gated_as_such() { })); check!(on, "a file that is not a macro is refused", r["success"] == false, s(&r)); }++// ════════════════════════════════════════════════════════════════════════════+// A GUARD THAT NOTHING CALLS IS A COMMENT WITH A TEST ATTACHED+// ════════════════════════════════════════════════════════════════════════════++/// Stage a launch record whose attribution is about a DIFFERENT SOLIDWORKS than+/// the one COM will answer for. This is the shape a stale or wrong claim has, and+/// the three verbs that can end or empty a session must all refuse it.+fn stage_a_claim_about_another_process(scratch: &TempDir) {+ std::fs::write(+ scratch.join("launch-state.json"),+ json!({+ "attempt": {+ "id": 1,+ "startedAt": 1_700_000_000u64,+ "shortcut": "SOLIDWORKS Design.lnk",+ "route": "shellExecute",+ "invokedAt": 1_700_000_000u64,+ "stage": "attached",+ "finished": true,+ "outcome": "attachedAfterSignIn",+ "runBeginSeen": true,+ // THE CLAIM: a process this bridge says it launched — which is+ // NOT the one the mock will report as attached.+ "attributedSolidworks": {"pid": 424242, "createdAt": 1_700_000_001u64},+ },+ "preferredRoute": "shellExecute",+ "consecutiveFailures": 0,+ })+ .to_string(),+ )+ .unwrap();+}++/// WIRING, not the function. `identity_matches_attribution` shipped in 0.6.7+/// unit-tested and with ZERO production callers — the fifth guard in this project+/// to do that, and the third about attribution. These tests go through the VERBS,+/// so deleting the call site (not the function) fails them.+#[test]+fn swx_stop_refuses_when_the_launch_claim_names_another_session() {+ let scratch = TempDir::new("claim-stop");+ stage_a_claim_about_another_process(&scratch);+ // A machine whose session this bridge CAN identify: without one the live+ // identity is unknown, and an unknown refuses for that reason instead —+ // which would leave the claim check unexercised.+ let machine = quittable_machine("claim-stop-machine", r#"{"a":"exited"}"#);+ let b = Bridge::start_in(+ TempDir::borrowed(&scratch.0),+ &[("SWX_ALLOW_USER_WRITES", "1"), ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+ ("SWX_QUIT_SETTLE_MS", "80")],+ );+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", json!({+ "confirm": true, "allowUserWrites": true, "force": true,+ "reason": "the caller asked to end the session",+ }));+ check!(+ b,+ "swx_stop refuses a session its own launch record does not cover",+ r["errorCode"] == "identity_mismatch",+ s(&r)+ );+ check!(+ b,+ "...and names both processes",+ r["error"].as_str().unwrap_or("").contains("424242"),+ s(&r)+ );+}++#[test]+fn swx_close_all_documents_refuses_when_the_launch_claim_names_another_session() {+ let scratch = TempDir::new("claim-closeall");+ stage_a_claim_about_another_process(&scratch);+ // A machine whose session this bridge CAN identify: without one the live+ // identity is unknown, and an unknown refuses for that reason instead —+ // which would leave the claim check unexercised.+ let machine = quittable_machine("claim-closeall-machine", r#"{"a":"exited"}"#);+ let b = Bridge::start_in(+ TempDir::borrowed(&scratch.0),+ &[("SWX_ALLOW_USER_WRITES", "1"), ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+ ("SWX_QUIT_SETTLE_MS", "80")],+ );+ b.call("swx_start", json!({}));+ let r = b.call("swx_close_all_documents", json!({+ "confirm": true, "allowUserWrites": true, "closeUnsaved": true,+ "reason": "the caller asked for a clean session",+ }));+ check!(+ b,+ "swx_close_all_documents refuses it too",+ r["errorCode"] == "identity_mismatch",+ s(&r)+ );+}++#[test]+fn swx_reset_workspace_refuses_when_the_launch_claim_names_another_session() {+ let scratch = TempDir::new("claim-reset");+ stage_a_claim_about_another_process(&scratch);+ // A machine whose session this bridge CAN identify: without one the live+ // identity is unknown, and an unknown refuses for that reason instead —+ // which would leave the claim check unexercised.+ let machine = quittable_machine("claim-reset-machine", r#"{"a":"exited"}"#);+ let b = Bridge::start_in(+ TempDir::borrowed(&scratch.0),+ &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+ );+ b.call("swx_start", json!({}));+ let r = b.call("swx_reset_workspace", json!({"reason": "clearing our scratch documents"}));+ check!(+ b,+ "swx_reset_workspace refuses it too — it closes documents by name in that session",+ r["errorCode"] == "identity_mismatch",+ s(&r)+ );+}++/// ...and with NO claim recorded, nothing changes: the ordinary rules decide.+#[test]+fn with_no_launch_claim_the_ordinary_rules_still_decide() {+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ let r = b.call("swx_reset_workspace", json!({"reason": "clearing"}));+ check!(+ b,+ "a bridge that never launched anything is not blocked by the claim check",+ r["errorCode"] != "identity_mismatch",+ s(&r)+ );+}
tests/import_lifecycle.rsadded+949@@ -0,0 +1,949 @@+//! WORKSTREAM A — import / document-enumeration robustness.+//!+//! A live 0.4.4 dev bridge was found holding 1342 open documents (8 GB), all+//! `%TEMP%\swx<pid>\IC~~\First.step.SLDPRT` — the same two STEP fixtures+//! imported over and over and never closed. `swx_docs` blocked the full 30s+//! marshalling them and got the com host killed; that leak was the real root of+//! the crate-build memory exhaustion (see docs/IMPORT-LEAK.md).+//!+//! `swx_import`'s contract is CORRECT and unchanged: it returns one open,+//! unsaved, bridge-created document, exactly like `swx_new_document`. These+//! tests cover the three defects around it: enumeration must degrade (never+//! wedge), a drain verb must clear the temps in the default posture, and import+//! must assert the net count it added.+//!+//! The cap is lowered to a handful via `SWX_DOC_ENUM_CAP` so the degrade/drain+//! paths are reached with a few imports rather than 200+.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// A tiny STEP fixture on disk (content is irrelevant to the mock).+fn fixture(dir: &TempDir, name: &str) -> String {+ let p = dir.join(name);+ std::fs::write(&p, b"ISO-10303-21;\nHEADER;\nENDSEC;\n").unwrap();+ p.to_string_lossy().to_string()+}++#[test]+fn import_asserts_exactly_one_new_document_and_says_it_stays_open() {+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ let files = TempDir::new("fixtures");+ let r = b.call("swx_import", json!({"filePath": fixture(&files, "First.step")}));+ check!(b, "import succeeds", r["success"] == true, s(&r));+ check!(+ b,+ "it added exactly one open document and says so",+ r["documentsOpened"] == 1 && r["netCountOk"] == true,+ s(&r)+ );+ check!(b, "...and reports the resulting title", r["imported"]["title"].as_str().unwrap_or("").ends_with(".SLDPRT"), s(&r));+ check!(+ b,+ "...and the hint says it stays open until closed or saved, like new_document",+ r["_hint"].as_str().unwrap_or("").contains("stays open"),+ s(&r)+ );+}++#[test]+fn a_huge_document_count_degrades_instead_of_timing_out() {+ // Cap 3: the 4th open document tips swx_docs into the truncated report.+ let b = Bridge::start_with(&[("SWX_DOC_ENUM_CAP", "3")]);+ b.call("swx_start", json!({}));+ let files = TempDir::new("fixtures");+ for i in 0..5 {+ let r = b.call("swx_import", json!({"filePath": fixture(&files, &format!("F{i}.step"))}));+ check!(b, "each import adds one", r["documentsOpened"] == 1, s(&r));+ }+ let docs = b.call("swx_docs", json!({}));+ check!(b, "swx_docs still answers success — never a timeout", docs["success"] == true, s(&docs));+ check!(b, "...and reports it truncated rather than enumerating", docs["truncated"] == true, s(&docs));+ check!(b, "...with the authoritative count", docs["count"].as_u64().unwrap_or(0) >= 5, s(&docs));+ check!(+ b,+ "...a bounded sample, not the whole list",+ docs["sample"].as_array().map(|a| a.len() <= 10 && !a.is_empty()).unwrap_or(false),+ s(&docs)+ );+ check!(b, "...the full docs array is empty (not enumerated)", docs["docs"] == json!([]), s(&docs));+ check!(+ b,+ "...and the hint points at the drain verb",+ docs["_hint"].as_str().unwrap_or("").contains("swx_close_import_temps"),+ s(&docs)+ );+ // swx_status must also stay fast — its openDocs is GetDocumentCount, and+ // the launch/health block must not have wedged on the count either.+ let st = b.call("swx_status", json!({}));+ check!(b, "swx_status answers with a large count and does not wedge", st["success"] == true, s(&st));+}++#[test]+fn close_import_temps_drains_them_in_the_default_posture() {+ let b = Bridge::start_with(&[("SWX_DOC_ENUM_CAP", "3")]);+ b.call("swx_start", json!({}));+ let files = TempDir::new("fixtures");+ for i in 0..6 {+ b.call("swx_import", json!({"filePath": fixture(&files, &format!("Leak{i}.step"))}));+ }+ // A non-temp document the drain must NEVER touch.+ let made = b.call("swx_new_document", json!({"type": "part"}));+ let keep = made["created"]["title"].as_str().unwrap_or("Part1").to_string();++ // Default posture (no SWX_ALLOW_USER_WRITES): a reason is enough.+ let gate = b.call("swx_close_import_temps", json!({}));+ check!(b, "the drain needs a reason", gate["errorCode"] == "reason_required", s(&gate));++ let drain = b.call("swx_close_import_temps", json!({"reason": "clearing leaked STEP import temps"}));+ check!(b, "it runs without the write opt-in", drain["success"] == true, s(&drain));+ check!(b, "...and reports how many it closed", drain["closed"].as_u64().unwrap_or(0) >= 6, s(&drain));+ check!(b, "...and that it finished", drain["done"] == true, s(&drain));++ // The imports are gone; the real document the bridge created is untouched.+ let docs = b.call("swx_docs", json!({}));+ check!(b, "the count dropped below the cap once the temps were drained", docs["truncated"] != json!(true), s(&docs));+ let titles: Vec<String> = docs["docs"].as_array().cloned().unwrap_or_default()+ .iter().filter_map(|d| d["title"].as_str().map(String::from)).collect();+ check!(b, "the new_document survived the drain", titles.contains(&keep), format!("{titles:?}"));+ check!(+ b,+ "no import temp remains",+ !titles.iter().any(|t| t.contains(".step") || t.starts_with("Leak")),+ format!("{titles:?}")+ );+}++#[test]+fn the_drain_is_bounded_per_call_and_reports_more_remaining() {+ // batch:2 forces more than one pass over 5 temps, so `done:false` is+ // exercised — the property that keeps a 1342-count drain from wedging the+ // com host in a single unbounded call.+ let b = Bridge::start_with(&[("SWX_DOC_ENUM_CAP", "3")]);+ b.call("swx_start", json!({}));+ let files = TempDir::new("fixtures");+ for i in 0..5 {+ b.call("swx_import", json!({"filePath": fixture(&files, &format!("B{i}.step"))}));+ }+ let first = b.call("swx_close_import_temps", json!({"reason": "bounded drain", "batch": 2, "maxSeconds": 5}));+ check!(b, "a bounded drain still succeeds", first["success"] == true, s(&first));+ check!(b, "...and closed at least the first batch", first["closed"].as_u64().unwrap_or(0) >= 2, s(&first));+ // Whether one verb call finished depends on wall budget; a second call+ // must always be able to finish the rest.+ let second = b.call("swx_close_import_temps", json!({"reason": "finish the drain"}));+ check!(b, "a second call finishes", second["success"] == true && second["done"] == true, s(&second));+ let docs = b.call("swx_docs", json!({}));+ check!(b, "everything drained", docs["truncated"] != json!(true), s(&docs));+}++#[test]+fn import_temps_survive_a_com_host_respawn_and_are_still_drainable() {+ // The 1342 belonged to a DEAD bridge process — the identifying proof is the+ // IC~~ path, which survives any respawn or pid change, not the in-memory+ // registry. Killing the com host mid-life must not make the temps+ // un-drainable.+ let scratch = TempDir::new("scratch");+ let dir = scratch.0.clone();+ let b = Bridge::start_in(TempDir::borrowed(&dir), &[("SWX_DOC_ENUM_CAP", "3")]);+ b.call("swx_start", json!({}));+ let files = TempDir::new("fixtures");+ for i in 0..5 {+ b.call("swx_import", json!({"filePath": fixture(&files, &format!("R{i}.step"))}));+ }+ let host_pid = b.call("swx_status", json!({}))["com"]["hostPid"].as_u64().unwrap();+ assert!(+ std::process::Command::new("kill").args(["-9", &host_pid.to_string()]).status().unwrap().success(),+ "kill com host {host_pid}"+ );+ std::thread::sleep(std::time::Duration::from_millis(300));+ // The mock persists its session across respawns, so the temps are still+ // open; the drain identifies them by path, not by a registry the respawn+ // cleared.+ let drain = b.call("swx_close_import_temps", json!({"reason": "drain after a respawn"}));+ check!(b, "the drain works after a respawn", drain["success"] == true, s(&drain));+ check!(b, "...and still closed the temps", drain["closed"].as_u64().unwrap_or(0) >= 5, s(&drain));+}++#[test]+fn an_unreadable_document_count_is_unknown_and_never_enumerated() {+ // F2. `truncated = count.map(..).unwrap_or(false)` fell through to the FULL+ // enumeration when the count came back unreadable — which is the 30s wedge+ // the cap exists to prevent, and most likely precisely when the session is+ // under the memory pressure that made the count unreadable.+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ let files = TempDir::new("fixtures");+ for i in 0..3 {+ b.call("swx_import", json!({"filePath": fixture(&files, &format!("U{i}.step"))}));+ }+ std::fs::write(b.scratch.join("mock-doccount-none"), "").unwrap();++ let docs = b.call("swx_docs", json!({}));+ check!(b, "swx_docs still answers", docs["success"] == true, s(&docs));+ check!(b, "the count is null — UNKNOWN, not zero", docs["count"].is_null(), s(&docs));+ check!(b, "...and it is reported as truncated, so nothing was enumerated", docs["truncated"] == true, s(&docs));+ check!(b, "...with no list and no sample invented", docs["docs"] == json!([]) && docs["sample"] == json!([]), s(&docs));+ check!(+ b,+ "...and the hint says UNKNOWN is not zero",+ docs["_hint"].as_str().unwrap_or("").contains("not zero"),+ s(&docs)+ );++ // OWNERSHIP MUST NOT BE PRUNED against an unlistable session — the registry+ // still holds what the bridge created.+ let tracked = b.call("swx_reset_workspace", json!({"titles": ["nothing-by-this-name"]}));+ check!(+ b,+ "ownership survived an unreadable count (the prune is skipped when truncated)",+ tracked["trackedScratchDocuments"].as_array().map(|a| !a.is_empty()).unwrap_or(false),+ s(&tracked)+ );++ // ...and a verb that cannot identify its target says THAT, rather than+ // claiming the document is not open — which would be a confident wrong+ // answer about the user's session.+ let closed = b.call("swx_close_doc", json!({"name": "Part1", "save": false}));+ check!(+ b,+ "a verb refuses with 'list unavailable', never a false 'not open'",+ closed["errorCode"] == "document_list_unavailable",+ s(&closed)+ );+ check!(+ b,+ "...and points at the drain",+ closed["_hint"].as_str().unwrap_or("").contains("swx_close_import_temps"),+ s(&closed)+ );+}++#[test]+fn a_mock_temp_is_matched_by_the_same_path_proof_the_real_drain_uses() {+ // F3. The mock used to report path:"" for a temp and match the drain on a+ // mock-only flag, so `is_import_temp` — the ownership proof the whole drain+ // rests on — was never exercised against a value the real backend produces.+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ let files = TempDir::new("fixtures");+ let r = b.call("swx_import", json!({"filePath": fixture(&files, "Proof.step")}));+ let path = r["imported"]["path"].as_str().unwrap_or("").to_string();+ check!(+ b,+ "an imported document reports the temp path a real GetPathName would",+ swx_bridge::comhost::host::is_import_temp(&path),+ format!("path={path} / {}", s(&r))+ );+ // ...and a document the bridge created normally is NOT a temp.+ let made = b.call("swx_new_document", json!({"type": "part"}));+ let mpath = made["created"]["path"].as_str().unwrap_or("");+ check!(b, "a new_document is not an import temp", !swx_bridge::comhost::host::is_import_temp(mpath), s(&made));+}++/// ADVERSARY PASS 7, D-1. The drain's location proof is the RESOLVED `%TEMP%`,+/// and nothing else — the shape of the path is not a substitute for it. So when+/// `%TEMP%` cannot be resolved, the drain must REFUSE rather than report that it+/// found nothing to close: on a session holding hundreds of leaked temps,+/// `closed:0, done:true` is a lie a caller acts on.+#[test]+fn an_unresolvable_temp_dir_refuses_the_drain_instead_of_closing_nothing() {+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ let files = TempDir::new("fixtures-noroot");+ b.call("swx_import", json!({"filePath": fixture(&files, "Leak.step")}));++ // %TEMP% unreadable, between calls.+ std::fs::write(b.scratch.join("mock-temp-root-unknown"), "").unwrap();+ let r = b.call(+ "swx_close_import_temps",+ json!({"reason": "draining the leak the session is holding"}),+ );+ check!(+ b,+ "the drain REFUSES when the temp root is unknown",+ r["success"] == false && r["errorCode"] == "temp_root_unknown",+ s(&r)+ );+ check!(+ b,+ "...and says why in terms of the harm, not the plumbing",+ r["error"].as_str().unwrap_or("").contains("the ONLY proof")+ && r["_hint"].as_str().unwrap_or("").contains("no save prompt"),+ s(&r)+ );+ check!(b, "...and closed nothing", r["closed"].is_null(), s(&r));++ // With the root readable again, the same call drains.+ std::fs::remove_file(b.scratch.join("mock-temp-root-unknown")).unwrap();+ let ok = b.call("swx_close_import_temps", json!({"reason": "draining the leak"}));+ check!(+ b,+ "the drain works again once %TEMP% resolves",+ ok["success"] == true && ok["closed"].as_u64().unwrap_or(0) >= 1,+ s(&ok)+ );+}++/// ADVERSARY PASS 8, F. The drain compares a path SOLIDWORKS reported against a+/// directory THIS process resolved, and the two can legitimately differ — ab may+/// run under a service profile or a redirected `TEMP`, and the temps this verb+/// exists for were written by an earlier, dead bridge process. On 0.5.3 a+/// mismatch matched nothing and answered `success:true, closed:0, done:true`+/// with the hint *"Every open STEP/IGES import temporary was closed"* — three of+/// them open. A miss is the safe direction for the CLOSE, never for the REPORT.+#[test]+fn a_root_mismatch_is_reported_as_a_mismatch_not_as_a_finished_drain() {+ let b = Bridge::start_with(&[("SWX_TEMP_ROOT", r"C:\Windows\Temp")]);+ b.call("swx_start", json!({}));+ let files = TempDir::new("fixtures-mismatch");+ for i in 0..3 {+ b.call("swx_import", json!({"filePath": fixture(&files, &format!("Widget{i}.step"))}));+ }+ let r = b.call("swx_close_import_temps", json!({"reason": "draining leaked temps"}));+ check!(b, "nothing was closed — the root really does not match", r["closed"] == 0, s(&r));+ check!(+ b,+ "...the response names the root it compared against",+ r["tempRoot"].as_str().unwrap_or("").contains("windows\\temp"),+ s(&r)+ );+ check!(+ b,+ "...counts the documents that carry the launcher's tail but failed that test",+ r["importTempsElsewhere"] == 3,+ s(&r)+ );+ check!(+ b,+ "...with a sample path an operator can act on",+ r["importTempsElsewhereSample"].as_str().unwrap_or("").contains("IC~~"),+ s(&r)+ );+ check!(b, "...and does NOT claim the drain is done", r["done"] == false, s(&r));+ let hint = r["_hint"].as_str().unwrap_or("");+ check!(+ b,+ "...and never says every temporary was closed",+ !hint.contains("Every open STEP/IGES import temporary was closed"),+ hint.to_string()+ );+ check!(+ b,+ "...but says what to do about it",+ hint.contains("SWX_TEMP_ROOT") && hint.contains("may not be the one SOLIDWORKS uses"),+ hint.to_string()+ );+}++/// The other direction: when the root DOES match, the report still names it — an+/// operator reading a live run should never have to infer which directory was+/// compared — and a real drain is still reported as done.+#[test]+fn a_matching_root_is_named_in_the_report_too() {+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ let files = TempDir::new("fixtures-match");+ b.call("swx_import", json!({"filePath": fixture(&files, "Widget.step")}));+ let r = b.call("swx_close_import_temps", json!({"reason": "draining"}));+ check!(b, "it drained", r["closed"] == 1 && r["done"] == true, s(&r));+ check!(+ b,+ "...and named the root it required the documents to sit under",+ r["tempRoot"].as_str().unwrap_or("").contains("temp"),+ s(&r)+ );+ check!(b, "...with no near misses left behind", r["importTempsElsewhere"] == 0, s(&r));+}++/// LIVE DEFECT, 2026-09-09, Drew's 2144-document session: `swx_docs` still timed+/// out at 30 s and the com host was killed, while `GetDocumentCount` answered in+/// 0.7 s. The degrade path called `GetDocuments` — one call that marshals EVERY+/// handle — and then `GetPathName` on all 2144 of them to fill a field named+/// `importTempsInSample`. The mock's enumeration was instant, so the "degrades+/// instead of timing out" claim in the ecosystem table was mock-proven and false.+///+/// This test holds the live session: 2144 documents, a cap of 200, and a+/// per-document cost of 15 ms (2144 x 15 ms ~ 32 s, so the old code cannot pass).+/// A mock that cannot be slow cannot prove anything about a budget.+#[test]+fn a_flooded_session_is_summarised_in_seconds_not_marshalled_for_thirty() {+ let b = Bridge::start_with(&[+ ("SWX_MOCK_OPEN_DOCS", "2144"),+ ("SWX_DOC_ENUM_CAP", "200"),+ ("SWX_MOCK_DOC_LATENCY_MS", "15"),+ ]);+ b.call("swx_start", json!({}));++ let t0 = std::time::Instant::now();+ let r = b.call("swx_docs", json!({}));+ let took = t0.elapsed();+ check!(+ b,+ "swx_docs answers about a 2144-document session in seconds",+ took < std::time::Duration::from_secs(3),+ format!("took {:.1}s / {}", took.as_secs_f64(), s(&r))+ );+ check!(b, "...with the count", r["count"] == 2144, s(&r));+ check!(b, "...marked truncated", r["truncated"] == true, s(&r));+ check!(+ b,+ "...and a sample, not the list",+ r["sample"].as_array().map(|a| a.len()).unwrap_or(0) == 10 && r["docs"] == json!([]),+ s(&r)+ );+ check!(+ b,+ "the import-temp count is over the SAMPLE and says so",+ r["importTempsInSample"] == 10 && r["importTempsInSampleOf"] == 10,+ s(&r)+ );++ // The DRAIN is the verb this session exists for, and it had the same shape:+ // it marshalled every handle before it closed anything.+ let t1 = std::time::Instant::now();+ // `maxSeconds` bounds the VERB (it loops batches until the session is+ // drained); `batch` bounds each com call. Both matter on a 2144-document+ // session, and the point here is that neither one marshals it.+ let d = b.call(+ "swx_close_import_temps",+ json!({"batch": 50, "maxSeconds": 5, "reason": "draining the flood"}),+ );+ let drain_took = t1.elapsed();+ check!(+ b,+ "the drain closes a bounded batch without marshalling the session",+ d["success"] == true+ && d["closed"].as_u64().unwrap_or(0) >= 50+ && drain_took < std::time::Duration::from_secs(20),+ format!("took {:.1}s / {}", drain_took.as_secs_f64(), s(&d))+ );+ check!(+ b,+ "...and does not claim the flood is drained when it stopped at its budget",+ d["done"] == false,+ s(&d)+ );+ check!(+ b,+ "...and it scans a bounded window, not everything open",+ d["documentsScanned"].as_u64().unwrap_or(u64::MAX) < 2144,+ s(&d)+ );++ // ...and every OTHER verb that reads the session has to survive it too: this+ // is the call `ownership_context` makes on nearly every verb.+ let t2 = std::time::Instant::now();+ let st = b.call("swx_get_app_state", json!({}));+ check!(+ b,+ "app_state degrades on the same session instead of enumerating it",+ t2.elapsed() < std::time::Duration::from_secs(3)+ && st["documentsTruncated"] == true+ && st["documents"] == json!([]),+ format!("took {:.1}s / {}", t2.elapsed().as_secs_f64(), s(&st))+ );+}++/// ADVERSARY PASS 9. The victim was chosen by PATH and closed by TITLE, and+/// `ISldWorks::CloseDoc` resolves by name without prompting — so on Drew's live+/// session (leaked temps titled `First.step-1.SLDPRT` beside his SAVED+/// `…\validation\First.step-1.SLDPRT`) SOLIDWORKS would have picked which one+/// died, and the response would have recorded the temp's path as closed either+/// way. The mock could not show it: its drain removed by a unique internal key.+///+/// The mock now models `CloseDoc`'s by-name resolution, so this is a real test of+/// the close key: a title collision is REFUSED, a unique title still drains, and+/// the user's saved document is still open at the end.+#[test]+fn a_title_collision_is_refused_while_a_unique_temp_in_the_same_batch_still_drains() {+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ let files = TempDir::new("adv9-mine");+ // Two imports of the SAME fixture: SOLIDWORKS titles both `Twin.step-1…`-ish,+ // which is precisely how a session ends up with colliding titles.+ b.call("swx_import", json!({"filePath": fixture(&files, "Twin.step")}));+ b.call("swx_import", json!({"filePath": fixture(&files, "Twin.step")}));+ // ...and one import whose title nothing else shares.+ b.call("swx_import", json!({"filePath": fixture(&files, "Lonely.step")}));++ let before = b.call("swx_docs", json!({}));+ let titles: Vec<String> = before["docs"]+ .as_array()+ .cloned()+ .unwrap_or_default()+ .iter()+ .map(|d| d["title"].as_str().unwrap_or("").to_string())+ .collect();+ let twins = titles.iter().filter(|t| t.contains("Twin")).count();++ let r = b.call("swx_close_import_temps", json!({"reason": "draining"}));+ if twins > 1 {+ // The colliding pair must be reported and NOT closed...+ check!(+ b,+ "a shared title is reported as a collision, with the paths",+ r["titleCollisions"].as_array().map(|a| !a.is_empty()).unwrap_or(false)+ && r["titleCollisions"][0]["paths"].as_array().map(|a| a.len() >= 2).unwrap_or(false),+ s(&r)+ );+ check!(b, "...and the drain does not call itself done", r["done"] == false, s(&r));+ check!(+ b,+ "...and the hint tells the user what to do about it",+ r["_hint"].as_str().unwrap_or("").contains("same title")+ && r["_hint"].as_str().unwrap_or("").contains("Close it yourself"),+ r["_hint"].as_str().unwrap_or("").to_string()+ );+ }+ // ...while the document whose title is unique is gone.+ let after = b.call("swx_docs", json!({}));+ let after_titles: Vec<String> = after["docs"]+ .as_array()+ .cloned()+ .unwrap_or_default()+ .iter()+ .map(|d| d["title"].as_str().unwrap_or("").to_string())+ .collect();+ check!(+ b,+ "the temporary with a unique title WAS drained",+ !after_titles.iter().any(|t| t.contains("Lonely")),+ format!("{after_titles:?} / {}", s(&r))+ );+ check!(+ b,+ "every close is verified, and none went to the wrong document",+ r["closeFailed"].as_array().map(|a| a.is_empty()).unwrap_or(true)+ && r["errorCode"].is_null(),+ s(&r)+ );+}++/// `swx_close_doc` closes by PATH too, and the mock's faithful `CloseDoc` is what+/// makes that provable: with two open documents sharing a title, closing the one+/// identified by path must leave the other alone.+#[test]+fn closing_one_of_two_same_titled_documents_leaves_the_other_open() {+ let b = Bridge::start_with(&[("SWX_ALLOW_USER_WRITES", "1")]);+ b.call("swx_start", json!({}));+ let files = TempDir::new("adv9-closedoc");+ let a = b.call("swx_import", json!({"filePath": fixture(&files, "Pair.step")}));+ let bb = b.call("swx_import", json!({"filePath": fixture(&files, "Pair.step")}));+ let p1 = a["imported"]["path"].as_str().unwrap_or("").to_string();+ let p2 = bb["imported"]["path"].as_str().unwrap_or("").to_string();+ check!(+ b,+ "two open documents, different paths",+ !p1.is_empty() && !p2.is_empty() && p1 != p2,+ format!("{p1} / {p2}")+ );++ let closed = b.call("swx_close_doc", json!({"path": p1.clone()}));+ check!(b, "the one named by path closed", closed["success"] == true, s(&closed));+ let after = b.call("swx_docs", json!({}));+ let paths: Vec<String> = after["docs"]+ .as_array()+ .cloned()+ .unwrap_or_default()+ .iter()+ .map(|d| d["path"].as_str().unwrap_or("").to_string())+ .collect();+ check!(+ b,+ "...and the OTHER document, which shares its title, is still open",+ paths.iter().any(|p| p == &p2) && !paths.iter().any(|p| p == &p1),+ format!("{paths:?}")+ );+}++/// ADVERSARY PASS 10, item 4. That `CloseDoc` accepts a FULL PATH is this+/// bridge's premise: the help text says only "name of the document to close", and+/// no rung of this has run live. If the premise is wrong the call returns success+/// and changes nothing — which must be reported as a CAPABILITY failure, must not+/// be confused with closing the wrong document, and must NEVER be retried by+/// title (a title is not an identity, which is the whole reason the close key+/// changed).+#[test]+fn a_close_by_path_that_does_nothing_is_a_capability_failure_not_a_wrong_close() {+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ let files = TempDir::new("adv10-premise");+ b.call("swx_import", json!({"filePath": fixture(&files, "Premise.step")}));+ let before = b.call("swx_docs", json!({}));+ let count_before = before["count"].as_u64().unwrap_or(0);++ // The premise fails: CloseDoc(path) returns success and closes nothing.+ std::fs::write(b.scratch.join("mock-closedoc-ignores-path"), "").unwrap();+ let r = b.call("swx_close_import_temps", json!({"reason": "draining"}));+ check!(+ b,+ "it is reported as a capability problem, by its own error code",+ r["success"] == false && r["errorCode"] == "close_by_path_unsupported",+ s(&r)+ );+ check!(+ b,+ "...NOT as a wrong document closed",+ r["errorCode"] != "wrong_document_closed" && r["closeIgnoredPath"] == true,+ s(&r)+ );+ check!(+ b,+ "...and it refuses to fall back to a title, in as many words",+ r["_hint"].as_str().unwrap_or("").contains("will NOT retry by title"),+ r["_hint"].as_str().unwrap_or("").to_string()+ );+ // NOTHING was closed, and the session is exactly as it was.+ let after = b.call("swx_docs", json!({}));+ check!(+ b,+ "the session is untouched",+ after["count"].as_u64().unwrap_or(0) == count_before,+ format!("{} -> {}", count_before, s(&after))+ );++ // With the premise holding, the same call drains.+ std::fs::remove_file(b.scratch.join("mock-closedoc-ignores-path")).unwrap();+ let ok = b.call("swx_close_import_temps", json!({"reason": "draining"}));+ check!(+ b,+ "and once the path form works, it drains and names what it closed",+ ok["success"] == true+ && ok["closed"].as_u64().unwrap_or(0) >= 1+ && ok["closedDocs"][0]["path"].as_str().unwrap_or("").contains("IC~~"),+ s(&ok)+ );+ check!(+ b,+ "...with the walk's own verdict published",+ ok["walkStop"] == "reachedEnd" && ok["walkComplete"] == true,+ s(&ok)+ );+ check!(+ b,+ "...and the collision claim scoped to what it scanned",+ ok["titleCollisionsCheckedOver"].is_number(),+ s(&ok)+ );+}++/// ADVERSARY PASS 11, G-2. `tools/drain-gate.py` needs to save a copy of a+/// bridge-created document into a bridge-created scratch directory, and it must+/// be able to do that in the DEFAULT posture — no `SWX_ALLOW_USER_WRITES`.+///+/// That is the choke-point rule working as designed (the document is ours, and+/// the destination does not exist, so nothing of the user's can be lost), and the+/// gate's collision half depends on it. Pinned here so the rule cannot regress+/// into "every save needs the opt-in", which would make the live proof+/// unreachable on a bridge that is not running wide open.+#[test]+fn the_default_posture_lets_the_bridge_save_its_own_document_to_a_new_path() {+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ let files = TempDir::new("default-save");+ let r = b.call("swx_import", json!({"filePath": fixture(&files, "Gate.step")}));+ let title = r["imported"]["title"].as_str().unwrap_or("").to_string();+ let path = r["imported"]["path"].as_str().unwrap_or("").to_string();+ let out = files.join("Gate-twin.SLDPRT");++ let saved = b.call("swx_save_as", json!({+ "path": path, "name": title, "out": out.to_string_lossy(), "copy": true,+ "reason": "the drain gate saves a copy of its own document to prove the close key",+ }));+ check!(+ b,+ "a BRIDGE-CREATED document saves to a NON-EXISTING path with no write opt-in",+ saved["success"] == true,+ s(&saved)+ );+ let opened = b.call("swx_open", json!({+ "path": out.to_string_lossy(),+ "reason": "the drain gate opens the copy so its title collides with the temporary's",+ }));+ check!(+ b,+ "...and the bridge can open the file it just wrote, same posture",+ opened["success"] == true,+ s(&opened)+ );+ // ...and the guard is still a guard: a path that ALREADY EXISTS and is not+ // ours still needs the opt-in.+ let theirs = files.join("TheirFile.SLDPRT");+ std::fs::write(&theirs, b"not ours").unwrap();+ let over = b.call("swx_save_as", json!({+ "path": path, "name": title, "out": theirs.to_string_lossy(), "copy": true,+ "reason": "attempting to overwrite a file the bridge did not write",+ }));+ check!(+ b,+ "...while overwriting an EXISTING file still refuses in the default posture",+ over["success"] == false,+ s(&over)+ );+}++// ════════════════════════════════════════════════════════════════════════════+// LIVE, 2026-09-11 — A FRESH IMPORT HAS NO PATH, AND TWO OF THEM SHARE A TITLE+// ════════════════════════════════════════════════════════════════════════════++/// `drain-gate.py` imported one fixture twice on Drew's machine and BOTH+/// documents reported `path:""`. A fresh `LoadFile4` import has no path at all —+/// the `IC~~` paths on the 1342 leaked temps came from a different route — and+/// SOLIDWORKS titles both imports from the same file stem. Three open documents,+/// one title, no paths: `swx_save_as`, `swx_reset_workspace` and `swx_close_doc`+/// all refused with `ambiguous_target` (correctly), the drain matched none of+/// them and reported `closed:0, done:true`, and the session could only be cleared+/// by hand.+///+/// A title is not an identity, so the bridge stops creating documents that have+/// nothing else.+#[test]+fn two_imports_of_one_file_are_each_addressable_because_each_gets_a_file() {+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ // The LIVE shape: imports come back pathless.+ std::fs::write(b.scratch.join("mock-import-pathless"), "").unwrap();+ let files = TempDir::new("identity-live");+ let fx = fixture(&files, "DrainGate.step");++ let one = b.call("swx_import", json!({"filePath": fx.clone()}));+ let two = b.call("swx_import", json!({"filePath": fx.clone()}));+ check!(b, "both imports succeeded", one["success"] == true && two["success"] == true,+ format!("{} / {}", s(&one), s(&two)));+ let p1 = one["documentIdentity"]["path"].as_str().unwrap_or("").to_string();+ let p2 = two["documentIdentity"]["path"].as_str().unwrap_or("").to_string();+ check!(+ b,+ "each import was given a file of its own, at birth",+ !p1.is_empty() && !p2.is_empty() && p1 != p2,+ format!("{p1:?} / {p2:?}")+ );+ let t1 = one["imported"]["title"].as_str().unwrap_or("").to_string();+ let t2 = two["imported"]["title"].as_str().unwrap_or("").to_string();+ check!(b, "...and therefore DIFFERENT titles", t1 != t2, format!("{t1} / {t2}"));++ // The thing that was impossible live: address ONE of them, by name.+ let closed = b.call("swx_close_doc", json!({"path": p1.clone()}));+ check!(b, "one of the two can be closed, unambiguously", closed["success"] == true, s(&closed));+ let docs = b.call("swx_docs", json!({}));+ let paths: Vec<String> = docs["docs"].as_array().cloned().unwrap_or_default()+ .iter().map(|d| d["path"].as_str().unwrap_or("").to_string()).collect();+ check!(b, "...and the OTHER one is untouched", paths.iter().any(|p| p == &p2), format!("{paths:?}"));+}++/// And the drain has to be able to clear a session full of the OLD kind —+/// pathless, bridge-created imports — because documents made before 0.6.0 (or+/// with keepUnsaved:true) still exist. It owns them by REGISTRY, not by path+/// shape, and it must not claim `done` while any it cannot name is still open.+#[test]+fn the_drain_closes_bridge_owned_pathless_imports_and_names_what_it_cannot() {+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ std::fs::write(b.scratch.join("mock-import-pathless"), "").unwrap();+ let files = TempDir::new("identity-old");+ // keepUnsaved:true reproduces exactly what the live session held.+ let a = b.call("swx_import", json!({"filePath": fixture(&files, "Old.step"), "keepUnsaved": true}));+ check!(b, "an opted-out import is pathless", a["imported"]["path"] == "", s(&a));+ check!(+ b,+ "...and SAYS its title is the only name it has",+ a["documentIdentity"]["identity"] == "titleOnly",+ s(&a)+ );++ let drain = b.call("swx_close_import_temps", json!({"reason": "clearing the session"}));+ check!(+ b,+ "the drain closes it by OWNERSHIP even though no IC~~ path matches",+ drain["ownedUnsavedImportsClosed"].as_u64().unwrap_or(0) == 1,+ s(&drain)+ );+ let docs = b.call("swx_docs", json!({}));+ check!(b, "...and it is really gone", docs["count"] == 0, s(&docs));+}++/// BUG C. An import whose com host TIMED OUT after `LoadFile4` had already+/// succeeded left a document open that the bridge did not know it had made — it+/// was still there at the next baseline, owned by nobody. A timeout is "I do not+/// know", and the answer to that is to LOOK.+#[test]+fn an_import_that_timed_out_after_it_worked_is_reconciled_not_abandoned() {+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ let files = TempDir::new("timeout-recon");+ let fx = fixture(&files, "Ghost.step");++ // The host answers the mutating call with a TIMEOUT while the document it+ // created stays open — the live shape.+ std::fs::write(b.scratch.join("mock-import-pathless"), "").unwrap();+ let before = b.call("swx_docs", json!({}))["count"].as_u64().unwrap_or(0);+ std::fs::write(b.scratch.join("mock-import-timeout-after-open"), "").unwrap();+ let r = b.call("swx_import", json!({"filePath": fx}));+ let _ = std::fs::remove_file(b.scratch.join("mock-import-timeout-after-open"));++ check!(b, "the verb reports the failure honestly", r["success"] == false, s(&r));+ let docs = b.call("swx_docs", json!({}));+ check!(+ b,+ "precondition: the document really was created despite the timeout",+ docs["count"].as_u64().unwrap_or(0) == before + 1,+ s(&docs)+ );+ check!(+ b,+ "the bridge ADOPTED the document its timed-out call had created",+ r["reconciliation"]["reconciled"] == true+ && r["reconciliation"]["adopted"]["title"].is_string(),+ s(&r)+ );+ // ...which is the whole point: it can now clean up after itself.+ let swept = b.call("swx_reset_workspace", json!({"reason": "clearing the adopted document"}));+ check!(+ b,+ "...so the sweep can close it",+ swept["closed"].as_array().map(|a| !a.is_empty()).unwrap_or(false),+ s(&swept)+ );+}++/// PASS 13, hardening 3. Adoption is what makes this bridge willing to CLOSE a+/// document, so "one document appeared and it looks right" is not a standard it+/// may use. If the session gained MORE than the one document our call was about,+/// the match is not evidence that ours is the one we can see — and the cheapest+/// wrong answer is claiming one of the user's.+#[test]+fn a_timed_out_import_adopts_nothing_when_the_session_gained_more_than_one_document() {+ let b = Bridge::start();+ b.call("swx_start", json!({}));+ std::fs::write(b.scratch.join("mock-import-pathless"), "").unwrap();+ let files = TempDir::new("recon-noisy");++ // The user opens something of their own WHILE our import is timing out: the+ // session gains two documents, only one of which is ours.+ std::fs::write(b.scratch.join("mock-import-timeout-after-open"), "").unwrap();+ std::fs::write(b.scratch.join("mock-open-extra-doc"), "").unwrap();+ let r = b.call("swx_import", json!({"filePath": fixture(&files, "Noisy.step")}));+ let _ = std::fs::remove_file(b.scratch.join("mock-import-timeout-after-open"));+ let _ = std::fs::remove_file(b.scratch.join("mock-open-extra-doc"));++ check!(b, "the verb still reports the failure", r["success"] == false, s(&r));+ check!(+ b,+ "it adopts NOTHING when the session gained more than our one document",+ r["reconciliation"]["reconciled"] == false && r["reconciliation"]["adopted"].is_null(),+ s(&r)+ );+ check!(+ b,+ "...and says why, in terms of what it could not establish",+ r["reconciliation"]["warning"].as_str().unwrap_or("").contains("exactly one"),+ s(&r)+ );+}++/// PASS 14, item 2. The guard has to hold on BOTH paths that close a pathless+/// document by title — the registry drain and the workspace sweep — because+/// `CloseDoc` resolves a name case-insensitively and `Part1.SLDPRT` /+/// `part1.SLDPRT` are one name to the act.+fn session_with_a_case_collision(scratch: &TempDir, session: i64) {+ // PIN THE SESSION ID. Ownership is scoped to the SOLIDWORKS session, so a+ // fixture that writes an ownership row has to agree with the session the mock+ // will report — otherwise the row is correctly ignored as belonging to some+ // other session, and the guard under test is never reached.+ std::fs::write(scratch.join("mock-solidworks-session"), session.to_string()).unwrap();+ std::fs::write(+ scratch.join("mock-solidworks-open-docs.json"),+ json!({+ "running": true, "untitled": 2, "active": "untitled:ours",+ "docs": [+ // OURS, pathless: closable only by title.+ {"docType": "part", "path": "untitled:ours", "title": "Part1.SLDPRT",+ "saved": false, "importTemp": false},+ // THE USER'S, differing only in case, and saved — unrecoverable.+ {"docType": "part", "path": "C:\\Users\\drew\\work\\part1.SLDPRT",+ "title": "part1.SLDPRT", "saved": true, "importTemp": false}+ ],+ "files": {}+ })+ .to_string(),+ )+ .unwrap();+ std::fs::write(+ scratch.join("owned-docs.json"),+ json!({"documents": [{"title": "Part1.SLDPRT", "session": session, "path": null}]})+ .to_string(),+ )+ .unwrap();+}++#[test]+fn the_registry_drain_refuses_a_title_that_differs_only_in_case() {+ let scratch = TempDir::new("case-drain");+ session_with_a_case_collision(&scratch, 700837);+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({"reason": "draining"}));++ let r = b.call("swx_close_import_temps", json!({"reason": "draining"}));+ let after = b.call("swx_docs", json!({}));+ let alive: Vec<String> = after["docs"].as_array().cloned().unwrap_or_default()+ .iter().map(|d| d["path"].as_str().unwrap_or("").to_string()).collect();+ check!(+ b,+ "the user's saved part1.SLDPRT is untouched",+ alive.iter().any(|p| p == "C:\\Users\\drew\\work\\part1.SLDPRT"),+ format!("{alive:?} / {}", s(&r))+ );+ let titles: Vec<String> = after["docs"].as_array().cloned().unwrap_or_default()+ .iter().map(|d| d["title"].as_str().unwrap_or("").to_string()).collect();+ check!(+ b,+ "...and OUR Part1.SLDPRT was not closed either — neither can be named",+ titles.iter().any(|t| t == "Part1.SLDPRT"),+ format!("{titles:?}")+ );+ check!(+ b,+ "...the refusal is reported, not silent",+ r["ownedUnsavedImportsUnaddressable"].as_array().map(|a| !a.is_empty()).unwrap_or(false)+ && r["done"] == false,+ s(&r)+ );+}++/// The SAME hazard in the other verb that closes a pathless document BY TITLE.+///+/// Not `swx_close_all_documents`: that is one `CloseAllDocuments` call, closing+/// everything by explicit authorization, and it names nothing. `swx_reset_workspace`+/// is the one that picks documents out of the session by name, which is where a+/// name that matches two documents can do harm.+#[test]+fn the_workspace_sweep_refuses_a_title_that_differs_only_in_case() {+ let scratch = TempDir::new("case-sweep");+ session_with_a_case_collision(&scratch, 700837);+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({"reason": "sweeping"}));++ let r = b.call("swx_reset_workspace", json!({"reason": "clearing our scratch documents"}));+ let after = b.call("swx_docs", json!({}));+ let alive: Vec<String> = after["docs"].as_array().cloned().unwrap_or_default()+ .iter().map(|d| d["path"].as_str().unwrap_or("").to_string()).collect();+ check!(+ b,+ "the user's saved part1.SLDPRT survived a sweep aimed at OUR Part1.SLDPRT",+ alive.iter().any(|p| p == "C:\\Users\\drew\\work\\part1.SLDPRT"),+ format!("{alive:?} / {}", s(&r))+ );+ check!(+ b,+ "...and the sweep SAYS it could not name the document it meant",+ s(&r).contains("answers to the same title"),+ s(&r)+ );+}
tests/launch.rsadded+1739@@ -0,0 +1,1739 @@+//! LAUNCH RESILIENCE, end to end against the real compiled binary.+//!+//! Every test here drives a SIMULATED MACHINE (`SWX_LAUNCH_FAKE_DIR`) whose+//! behaviour is written down as a scenario — "route A starts a chain and stalls+//! at SecurityContext; route B reaches Run Begin and attaches" — and then+//! measures WHAT THE BRIDGE DID ABOUT IT on that machine: which processes it+//! ended, how many launcher invocations it made, what it left running. Not what+//! it returned.+//!+//! That distinction is the whole point. The pre-0.4 bridge returned+//! `stillLaunching:true` with a hint promising "call swx_start again, which+//! ATTACHES rather than launching a second copy" — and every one of those calls+//! fired the launcher again. The return value was right and the effect was+//! wrong, four times over, on somebody's real desktop.++mod common;++use common::{AbStub, Bridge, TempDir};+use serde_json::{json, Value};+use std::time::{Duration, Instant};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// The pids `FakeOs` gives the Nth simulated launcher invocation.+fn chain_pids(n: u64) -> (u64, u64) {+ let base = 900_000 + n * 10;+ (base, base + 1)+}++/// A simulated machine. `routes` says what each launch route does; the+/// pre-existing processes are the ones that MUST still be there afterwards.+struct Machine {+ dir: TempDir,+}++impl Machine {+ fn new(tag: &str, routes: Value) -> Machine {+ let dir = TempDir::new(tag);+ std::fs::write(dir.join("routes.json"), routes.to_string()).unwrap();+ // The Platform's daemons and a chain the USER started a minute ago.+ // Nothing this bridge does may touch any of them, ever.+ std::fs::write(+ dir.join("preexisting.json"),+ json!([+ {"pid": 6076, "image": "3DEXPERIENCELauncher.exe", "ageSeconds": 30000},+ {"pid": 90552, "image": "3DEXPERIENCELauncherSysTray.exe", "ageSeconds": 30000},+ {"pid": 90553, "image": "3DEXPERIENCELauncherService.exe", "ageSeconds": 30000},+ {"pid": 70001, "image": "CATSTART.exe", "ageSeconds": 60},+ {"pid": 70002, "image": "SWXDesktopLauncher.exe", "ageSeconds": 60}+ ])+ .to_string(),+ )+ .unwrap();+ Machine { dir }+ }++ fn sldworks(self, state: Value) -> Machine {+ std::fs::write(self.dir.join("sldworks.json"), state.to_string()).unwrap();+ self+ }++ /// Also list SOLIDWORKS itself as already running.+ fn with_running_solidworks(self) -> Machine {+ let mut rows: Vec<Value> =+ serde_json::from_str(&std::fs::read_to_string(self.dir.join("preexisting.json")).unwrap())+ .unwrap();+ rows.push(json!({"pid": 88556, "image": "SLDWORKS.exe", "ageSeconds": 7200}));+ std::fs::write(self.dir.join("preexisting.json"), Value::Array(rows).to_string()).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()+ }++ /// Every launcher invocation the bridge made, in order.+ 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()+ }++ /// Every process the bridge terminated, in order.+ 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())),+ // Compressed budgets: the real ones are 20s / 120s / 240s, and a+ // suite that waited them out would never be run.+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "2"),+ ("SWX_LAUNCH_SLDWORKS_SECS", "4"),+ ("SWX_LAUNCH_ATTACH_SECS", "8"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ("SWX_LAUNCH_ATTACH_POLL_MS", "150"),+ ("SWX_LAUNCH_INFLIGHT_SECS", "120"),+ ];+ env.extend_from_slice(extra);+ Bridge::start_in(scratch, &env)+ }+}++/// Poll `swx_status` until the launch attempt finishes, or give up. Returns the+/// last status seen — a test asserts on the MACHINE, this only paces it.+fn wait_for_launch(b: &Bridge, secs: u64) -> Value {+ let deadline = Instant::now() + Duration::from_secs(secs);+ let mut last = json!({});+ loop {+ last = b.call("swx_status", json!({}));+ if last["launch"]["finished"] == true {+ return last;+ }+ if Instant::now() > deadline {+ return last;+ }+ std::thread::sleep(Duration::from_millis(150));+ }+}++#[test]+fn a_stalled_route_is_cleaned_up_and_the_next_route_takes_over() {+ // FAILURE MODE A, exactly as measured on 2026-09-04: the chain starts, the+ // launcher log stops at `SecurityContext:`, `Run Begin` never comes.+ let m = Machine::new(+ "fallback",+ // Both ATTRIBUTABLE routes: only a route that can say what it started+ // may have its chain cleaned up, so the fallback story is told with the+ // two that can. See `launch::AttributionRule`.+ json!({+ "shellExecute": {"chain": true},+ "powershell": {"chain": true, "runBeginAfterMs": 0, "helperAfterMs": 100,+ "sldworksAfterMs": 200, "attachAfterMs": 400}+ }),+ );+ let b = m.boot(TempDir::new("scratch"), &[]);++ let r = b.call("swx_start", json!({"reason": "modelling a bracket for Drew", "waitSeconds": 0}));+ check!(b, "swx_start reports a launch in flight, not a failure", r["success"] == true && r["stillLaunching"] == true, s(&r));++ let st = wait_for_launch(&b, 25);+ check!(b, "swx_start reaches an attached SOLIDWORKS by falling back", st["launch"]["outcome"] == "attached", s(&st));++ // THE EFFECT, on the machine.+ check!(+ b,+ "exactly two launcher invocations: the stalled route, then the next one",+ m.invocations() == vec!["shellExecute".to_string(), "powershell".to_string()],+ format!("{:?}", m.invocations())+ );+ let (cat1, lnk1) = chain_pids(1);+ let killed = m.terminated();+ check!(+ b,+ "ONLY the stalled chain we started was ended — by pid, both of it, nothing else",+ killed.iter().map(|(p, _)| *p).collect::<Vec<_>>() == vec![cat1, lnk1],+ format!("terminated: {killed:?}")+ );+ check!(+ b,+ "...and it was ended as the images it really was",+ killed.iter().all(|(_, i)| i == "CATSTART.exe" || i == "SWXDesktopLauncher.exe"),+ format!("{killed:?}")+ );+ for guarded in [6076u64, 90552, 90553, 70001, 70002, 88556] {+ check!(+ b,+ "no daemon, no pre-existing chain and no SOLIDWORKS was ever terminated",+ !killed.iter().any(|(p, _)| *p == guarded),+ format!("pid {guarded} was terminated: {killed:?}")+ );+ }+ let (cat2, lnk2) = chain_pids(2);+ check!(+ b,+ "the WORKING route's chain was left alone",+ !killed.iter().any(|(p, _)| *p == cat2 || *p == lnk2),+ format!("{killed:?}")+ );++ // The stall is on the record, with what it cost.+ let tried = &st["launch"]["routesTried"];+ check!(b, "the stalled route is recorded", tried[0]["route"] == "shellExecute" && tried[0]["stalled"] == true, s(&st["launch"]));+ check!(+ b,+ "...and that the chain it ended was one the INVOKE identified, not one it recognised",+ tried[0]["attributed"] == true && tried[0]["cleanup"] == "cleaned",+ s(&st["launch"])+ );+ check!(+ b,+ "...naming the exact processes it ended",+ tried[0]["killed"][0]["pid"] == cat1 && tried[0]["killed"][1]["pid"] == lnk1,+ s(&st["launch"])+ );+ let ledger = b.call("swx_action_ledger", json!({"limit": 200}));+ check!(b, "the stall and the cleanup are in the human's ledger", s(&ledger).contains("solidworks_launch_stalled"), s(&ledger));+ check!(b, "...and so is Run Begin never arriving", s(&ledger).contains("never logged 'Run Begin'"), s(&ledger));+}++#[test]+fn the_route_that_worked_is_remembered_and_tried_first_next_time() {+ let scratch = TempDir::new("scratch");+ let m = Machine::new(+ "preferred",+ json!({+ "shellExecute": {"chain": true},+ "powershell": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 100, "attachAfterMs": 300}+ }),+ );+ {+ let b = m.boot(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({"reason": "first launch of the day", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 25);+ check!(b, "the working route is remembered on disk", st["launch"]["preferredRoute"] == "powershell", s(&st["launch"]));+ }+ // A DIFFERENT SERVER PROCESS, same scratch dir. The memory has to be the+ // FILE, not the process — a bridge is restarted far more often than a+ // desktop is.+ let state: Value = serde_json::from_str(+ &std::fs::read_to_string(scratch.join("launch-state.json")).unwrap(),+ )+ .unwrap();+ assert_eq!(state["preferredRoute"], "powershell", "{}", s(&state));++ // Every route still stalls except the remembered one, and the remembered+ // one is tried FIRST — so a second cold start costs one invocation, not two.+ let m2 = Machine::new(+ "preferred2",+ json!({"powershell": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 100, "attachAfterMs": 300}}),+ );+ std::fs::copy(scratch.join("launch-state.json"), m2.dir.join("seed.json")).unwrap();+ let scratch2 = TempDir::new("scratch2");+ std::fs::copy(scratch.join("launch-state.json"), scratch2.join("launch-state.json")).unwrap();+ let b2 = m2.boot(scratch2, &[]);+ b2.call("swx_start", json!({"reason": "second launch", "waitSeconds": 0}));+ let st2 = wait_for_launch(&b2, 25);+ check!(b2, "the second cold start attaches", st2["launch"]["outcome"] == "attached", s(&st2["launch"]));+ check!(+ b2,+ "...on the FIRST invocation, because the remembered route led the plan",+ m2.invocations() == vec!["powershell".to_string()],+ format!("{:?}", m2.invocations())+ );+ check!(b2, "...and nothing had to be cleaned up", m2.terminated().is_empty(), format!("{:?}", m2.terminated()));+}++#[test]+fn three_calls_during_a_launch_start_exactly_zero_extra_launchers() {+ // FAILURE MODE B. The old hint said calling again ATTACHES rather than+ // launching a second copy. It did not. Four launcher chains accumulated.+ let m = Machine::new(+ "inflight",+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "helperAfterMs": 50,+ "sldworksAfterMs": 150, "attachAfterMs": 600000}}),+ );+ let b = m.boot(TempDir::new("scratch"), &[("SWX_LAUNCH_ATTACH_SECS", "120")]);++ let first = b.call("swx_start", json!({"reason": "starting SOLIDWORKS for Drew", "waitSeconds": 0}));+ check!(b, "the first call starts the launch", first["stillLaunching"] == true, s(&first));++ // Let the machine get past Run Begin so the calls land mid-flight.+ std::thread::sleep(Duration::from_millis(700));+ for n in 1..=3 {+ let again = b.call("swx_start", json!({"reason": "checking on it", "waitSeconds": 0}));+ check!(b, "a call during flight never fails", again["success"] == true, s(&again));+ check!(+ b,+ "...it reports the SAME attempt as still launching",+ again["stillLaunching"] == true && again["launchedNow"] == false,+ format!("call {n}: {}", s(&again))+ );+ check!(+ b,+ "...and it says, in words, that it launched nothing",+ again["_hint"].as_str().unwrap_or("").contains("NOTHING WAS LAUNCHED BY THIS CALL"),+ s(&again)+ );+ check!(+ b,+ "...carrying the live stage so the caller knows what it is waiting for",+ ["invoked", "launcherStarted", "runBegin", "helperSignIn", "sldworksUp", "attaching"]+ .contains(&again["stage"].as_str().unwrap_or("")),+ s(&again)+ );+ }+ // A call with NO reason must not be refused either — polling with swx_start+ // is what this bridge's own hint tells a caller to do.+ let bare = b.call("swx_start", json!({}));+ check!(b, "polling with swx_start needs no reason while a launch is in flight", bare["success"] == true && bare["stillLaunching"] == true, s(&bare));++ check!(+ b,+ "ONE launcher invocation, after five swx_start calls",+ m.invocations() == vec!["shellExecute".to_string()],+ format!("{:?}", m.invocations())+ );+ check!(b, "and nothing was terminated on the way", m.terminated().is_empty(), format!("{:?}", m.terminated()));+ // swx_status tells the same story, which is where a caller should be looking.+ let st = b.call("swx_status", json!({}));+ check!(b, "swx_status carries the live launch block", st["launch"]["inFlight"] == true && st["launch"]["route"] == "shellExecute", s(&st["launch"]));+}++#[test]+fn the_machine_stops_at_the_first_chain_it_cannot_prove_is_its_own() {+ // All four routes behave the way cmd/start did on 2026-09-04: they start a+ // launcher chain that never reaches Run Begin.+ //+ // The two ATTRIBUTABLE routes hand back the root they started, so their+ // chains are ended. `explorer` cannot say what it started — and it started+ // SOMETHING (a launcher log appeared) — so the machine stops there rather+ // than putting a fourth chain on the desktop beside one nobody can find.+ let m = Machine::new("exhaust", json!({}));+ let b = m.boot(+ TempDir::new("scratch"),+ &[("SWX_LAUNCH_RUN_BEGIN_SECS", "1"), ("SWX_LAUNCH_BACKOFF_SECS", "0")],+ );+ b.call("swx_start", json!({"reason": "trying to start SOLIDWORKS", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 40);++ check!(b, "the attempt ends", st["launch"]["outcome"] == "exhausted", s(&st["launch"]));+ check!(+ b,+ "it stopped at the first route whose chain it could not prove was its own",+ m.invocations() == vec!["shellExecute", "powershell", "explorer"],+ format!("{:?}", m.invocations())+ );+ let tried = st["launch"]["routesTried"].as_array().cloned().unwrap_or_default();+ check!(+ b,+ "the two attributable routes ended the chains they started",+ tried[0]["attributed"] == true+ && tried[0]["cleanup"] == "cleaned"+ && tried[1]["attributed"] == true+ && tried[1]["cleanup"] == "cleaned",+ s(&st["launch"])+ );+ check!(+ b,+ "...and the unattributable one is recorded as exactly that, not as a clean recovery",+ tried[2]["attributed"] == false && tried[2]["cleanup"] == "notAttributable",+ s(&st["launch"])+ );+ check!(+ b,+ "...saying, in words, that a chain may remain which the bridge cannot identify",+ tried[2]["cleanupError"].as_str().unwrap_or("").contains("MAY still be running"),+ s(&st["launch"])+ );++ // THE EFFECT. Four processes ended — both attributable chains — and the+ // chain the bridge could not prove was its own was LEFT ALONE.+ let killed = m.terminated();+ check!(b, "two attributable chains, four processes", killed.len() == 4, format!("{killed:?}"));+ for ours in [900010u64, 900011, 900020, 900021] {+ check!(b, "each attributed chain was ended", killed.iter().any(|(p, _)| *p == ours), format!("{killed:?}"));+ }+ for unproven in [900030u64, 900031] {+ check!(+ b,+ "a chain the bridge could not prove was its own is NEVER terminated",+ !killed.iter().any(|(p, _)| *p == unproven),+ format!("{killed:?}")+ );+ }+ for guarded in [6076u64, 90552, 90553, 70001, 70002] {+ check!(b, "no daemon and no pre-existing chain was touched", !killed.iter().any(|(p, _)| *p == guarded), format!("{killed:?}"));+ }+ check!(+ b,+ "the advice is to ask the USER, which is the honest remaining move",+ st["launch"]["advice"].as_str().unwrap_or("").contains("ASK THE USER"),+ s(&st["launch"])+ );++ // And a fresh swx_start afterwards is allowed to try again — a finished+ // attempt is not a permanent lock.+ let again = b.call("swx_start", json!({"reason": "trying once more", "waitSeconds": 0}));+ check!(b, "a finished attempt does not block the next one", again["success"] == true, s(&again));+ let deadline = Instant::now() + Duration::from_secs(10);+ while m.invocations().len() <= 3 && Instant::now() < deadline {+ std::thread::sleep(Duration::from_millis(100));+ }+ check!(b, "...and it really did invoke again", m.invocations().len() > 3, format!("{:?}", m.invocations()));+ std::thread::sleep(Duration::from_millis(1500));+ check!(+ b,+ "a retry after a failure tries ONE route, not the whole plan again",+ m.invocations().len() == 4,+ format!("{:?}", m.invocations())+ );+}++#[test]+fn a_route_that_cannot_say_what_it_started_is_still_tried_when_it_started_nothing() {+ // The stop rule is about what a route LEFT BEHIND, not about whether it was+ // attributable. A route that started nothing at all leaves nothing behind,+ // so the machine may go on and try the ones that might actually work — the+ // two unattributable routes are kept precisely because they may be the ones+ // that launch SOLIDWORKS on this machine.+ let m = Machine::new(+ "nochain-continue",+ json!({"shellExecute": {"chain": false}, "powershell": {"chain": false},+ "explorer": {"chain": false},+ "cmdStart": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 100, "attachAfterMs": 300}}),+ );+ let b = m.boot(TempDir::new("scratch"), &[("SWX_LAUNCH_RUN_BEGIN_SECS", "1")]);+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 40);++ check!(b, "the last route works and the launch attaches", st["launch"]["outcome"] == "attached", s(&st["launch"]));+ check!(+ b,+ "every route was tried, because none of the earlier ones left anything behind",+ m.invocations() == vec!["shellExecute", "powershell", "explorer", "cmdStart"],+ format!("{:?}", m.invocations())+ );+ check!(b, "and nothing was terminated anywhere", m.terminated().is_empty(), format!("{:?}", m.terminated()));+ check!(+ b,+ "a route that started nothing says so, rather than inventing a stall at SecurityContext",+ st["launch"]["routesTried"][0]["note"]+ .as_str()+ .unwrap_or("")+ .contains("NO launcher log at all"),+ s(&st["launch"])+ );+ // AND IT IS NOT MEMORISED. `cmdStart` cannot say what it started, so no+ // launcher log can be attributed to it — the `Run Begin` in the log that+ // appeared might have been written by a launch the USER started while we+ // watched. SOLIDWORKS is up and usable (that is what `attached` means and+ // it is proven by COM), but nothing proves THIS ROUTE did it, so the route+ // memory is left alone rather than taught something that may be false.+ check!(+ b,+ "an unattributable route is never learned as 'the one that works'",+ st["launch"]["preferredRoute"].is_null() && st["launch"]["runBeginSeen"] == false,+ s(&st["launch"])+ );+ check!(+ b,+ "...and the log it could not attribute is reported as exactly that",+ st["launch"]["unattributedLog"] == true,+ s(&st["launch"])+ );+}++#[test]+fn a_root_the_census_cannot_see_is_still_confirmed_by_its_children() {+ // `rootVisible:false` — the invoke hands back a root whose image this+ // bridge does not watch (a shim, a wrapper), so the census never shows it.+ // Its CHILDREN are there, and their ppid points at the handle-proven pid,+ // so the lineage is confirmed and the chain is cleaned up. Getting this+ // wrong reported a "clean recovery" with a real chain still running.+ let m = Machine::new(+ "rootinvisible",+ json!({"shellExecute": {"chain": true, "rootVisible": false}}),+ );+ let b = m.boot(TempDir::new("scratch"), &[("SWX_LAUNCH_RUN_BEGIN_SECS", "1")]);+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 30);++ let tried = &st["launch"]["routesTried"][0];+ check!(b, "the lineage was confirmed through the invisible root", tried["rootConfirmed"] == true, s(&st["launch"]));+ check!(b, "...so the chain was cleaned, not reported clean", tried["cleanup"] == "cleaned", s(&st["launch"]));+ let killed = m.terminated();+ check!(+ b,+ "the child the root started WAS ended",+ killed.iter().any(|(p, _)| *p == 900011),+ format!("{killed:?}")+ );+ check!(+ b,+ "...and the root itself was not, because the census never showed it",+ !killed.iter().any(|(p, _)| *p == 900010),+ format!("{killed:?}")+ );+}++#[test]+fn each_route_carries_the_log_evidence_that_belongs_to_it() {+ // The gate's per-route table is its central measurement. Every field it+ // prints must come from the ROUTE, not from the attempt — the attempt's+ // copies are reset when the next route begins, so reading them per row+ // printed the last route's evidence against all of them.+ let m = Machine::new(+ "perroute",+ json!({+ "shellExecute": {"chain": false},+ "powershell": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 100, "attachAfterMs": 300}+ }),+ );+ let b = m.boot(TempDir::new("scratch"), &[("SWX_LAUNCH_RUN_BEGIN_SECS", "1")]);+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 30);+ let tried = st["launch"]["routesTried"].as_array().cloned().unwrap_or_default();++ check!(b, "both routes are in the table", tried.len() == 2, s(&st["launch"]));+ check!(+ b,+ "the route that started nothing has no log and no Run Begin OF ITS OWN",+ tried[0]["launcherLog"].is_null() && tried[0]["runBeginSeen"] == false,+ s(&tried[0])+ );+ check!(+ b,+ "the route that worked has both, and says which root it was given",+ tried[1]["runBeginSeen"] == true+ && tried[1]["launcherLog"].is_string()+ && tried[1]["rootPid"].is_u64()+ && tried[1]["rootConfirmed"] == true,+ s(&tried[1])+ );+ check!(+ b,+ "the attach is recorded as an ordering hint that is explicitly NOT proof",+ st["launch"]["lastRouteBeforeAttach"]["route"] == "powershell"+ && st["launch"]["lastRouteBeforeAttach"]["proof"] == false,+ s(&st["launch"])+ );+}++#[test]+fn an_attributable_route_that_never_sees_its_own_chain_stops_the_machine() {+ // The invoke handed back a root and the census never showed it, nor+ // anything descending from it. That is not "there was nothing to kill" —+ // it is "I never found what I started", and another route would put a+ // second chain beside one nobody can account for.+ let m = Machine::new(+ "notconfirmed",+ // `attributable:false` alone models "no handle"; here the route DOES+ // report a root, but the simulated chain never appears in the census.+ // Route 1 starts nothing (so the machine reaches route 2), and route 2+ // reports a root whose chain the census never shows. Before the+ // per-route reset, `root_confirmed` carried over and `notConfirmed`+ // was unreachable after route 1.+ json!({"shellExecute": {"chain": false},+ "powershell": {"chain": true, "invisibleChain": true}}),+ );+ let b = m.boot(TempDir::new("scratch"), &[("SWX_LAUNCH_RUN_BEGIN_SECS", "1")]);+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 30);++ let tried = &st["launch"]["routesTried"][1];+ check!(b, "the route is recorded as never confirmed", tried["cleanup"] == "notConfirmed", s(&st["launch"]));+ check!(b, "...as a FAILURE to account, not a clean recovery", tried["cleanupFailed"] == true, s(&st["launch"]));+ check!(b, "...and it says a chain may remain", tried["chainMayRemain"] == true, s(&st["launch"]));+ check!(+ b,+ "the machine stopped rather than starting another chain",+ m.invocations().len() == 2,+ format!("{:?}", m.invocations())+ );+ check!(b, "and nothing was terminated", m.terminated().is_empty(), format!("{:?}", m.terminated()));+}++#[test]+fn an_attributable_route_that_gets_no_handle_cleans_up_nothing_and_stops() {+ // THE SHAPE ShellExecuteExW IS MOST LIKELY TO PRODUCE on a .lnk: the shell+ // hands the link to an already-running process (DDE) and returns no handle,+ // so a route that CAN normally attribute does not, on this launch. It must+ // behave exactly like `explorer`: adopt nothing, kill nothing, and stop+ // once a launcher log shows something started.+ let m = Machine::new(+ "nohandle",+ json!({"shellExecute": {"chain": true, "attributable": false}}),+ );+ let b = m.boot(TempDir::new("scratch"), &[("SWX_LAUNCH_RUN_BEGIN_SECS", "1")]);+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 30);++ check!(b, "nothing was attributed", st["launch"]["attributed"] == false, s(&st["launch"]));+ let tried = &st["launch"]["routesTried"][0];+ check!(b, "the route is recorded as unattributable", tried["cleanup"] == "notAttributable", s(&st["launch"]));+ check!(b, "...and says a chain may remain", tried["chainMayRemain"] == true, s(&st["launch"]));+ check!(+ b,+ "the machine stopped after ONE route, because a log says something started",+ m.invocations() == vec!["shellExecute".to_string()],+ format!("{:?}", m.invocations())+ );+ check!(b, "NOTHING was terminated", m.terminated().is_empty(), format!("{:?}", m.terminated()));+ let chain = m.lines("invocations.jsonl").len();+ check!(b, "one invocation only", chain == 1, format!("{chain}"));+}++#[test]+fn a_retry_after_a_failure_waits_instead_of_starting_more_chains() {+ let m = Machine::new("backoff", json!({}));+ let b = m.boot(+ TempDir::new("scratch"),+ &[("SWX_LAUNCH_RUN_BEGIN_SECS", "1"), ("SWX_LAUNCH_BACKOFF_SECS", "300")],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 40);+ check!(b, "precondition: the attempt failed", st["launch"]["outcome"] == "exhausted", s(&st["launch"]));+ let after_first = m.invocations().len();++ let r = b.call("swx_start", json!({"reason": "and again", "waitSeconds": 0}));+ check!(b, "the retry is refused with a branchable code", r["errorCode"] == "host_app_launch_backoff", s(&r));+ check!(b, "...saying how long", r["backoffSeconds"].as_u64().unwrap_or(0) > 0, s(&r));+ check!(+ b,+ "...and pointing at the human, which is the honest remaining move",+ r["_hint"].as_str().unwrap_or("").contains("ASK THE USER"),+ s(&r)+ );+ std::thread::sleep(Duration::from_millis(800));+ check!(+ b,+ "NOT ONE new launcher chain was started by the refused retry",+ m.invocations().len() == after_first,+ format!("{:?}", m.invocations())+ );+}++#[test]+fn solidworks_up_but_not_attaching_stops_the_machine_instead_of_launching_another() {+ // sldworks.exe IS on the user's desktop — probably at a 3DEXPERIENCE+ // sign-in only they can answer. That is NOT a stalled launcher: the chain+ // must not be killed for it, no further route may be tried (a second route+ // would put a SECOND SOLIDWORKS on their screen), and it must not be+ // reported as attached.+ let m = Machine::new(+ "upnotattached",+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 100}}),+ );+ let b = m.boot(TempDir::new("scratch"), &[("SWX_LAUNCH_ATTACH_SECS", "3")]);+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 25);++ check!(+ b,+ "the outcome names what is true: up, and not attached",+ st["launch"]["outcome"] == "sldworksUpNotAttached",+ s(&st["launch"])+ );+ check!(b, "...and it is NEVER reported as attached", st["launch"]["stage"] != "attached", s(&st["launch"]));+ check!(+ b,+ "...pointing at the sign-in only the user can answer",+ st["launch"]["error"].as_str().unwrap_or("").contains("sign-in"),+ s(&st["launch"])+ );+ check!(b, "no second route was tried", m.invocations() == vec!["shellExecute".to_string()], format!("{:?}", m.invocations()));+ check!(b, "and the chain was NOT killed — SOLIDWORKS is coming up on their screen", m.terminated().is_empty(), format!("{:?}", m.terminated()));+ check!(+ b,+ "the route is not remembered as one that worked",+ st["launch"]["preferredRoute"].is_null(),+ s(&st["launch"])+ );+}++#[test]+fn the_crash_reporters_restart_button_is_clicked_once_and_nothing_else_is() {+ // FAILURE MODE C, 2026-09-03: sldworks crashed on first spawn, the crash+ // reporter appeared, and clicking "Restart SOLIDWORKS Design" launched it.+ // pid 900012 is the sldworks.exe the simulator spawns for invocation 1 —+ // the crash reporter must be ATTRIBUTED to the process we are launching,+ // not merely titled like one.+ let ab = AbStub::start(json!([+ {"hwnd": 111, "title": "SOLIDWORKS Design has stopped working", "className": "#32770", "pid": 900012, "rect": {"left": 0}},+ {"hwnd": 222, "title": "Save changes to Part1?", "className": "#32770", "pid": 900012, "rect": {"left": 0}},+ {"hwnd": 333, "title": "gallia - Google Chrome", "className": "Chrome_WidgetWin_1", "pid": 4242, "rect": {"left": 0}}+ ]));+ ab.set_response(+ "desktop_find_control",+ json!({"success": true, "found": true, "name": "Restart SOLIDWORKS Design", "role": "button", "invokable": true}),+ );+ ab.set_response("desktop_ui_click", json!({"success": true, "invoked": true, "name": "Restart SOLIDWORKS Design"}));++ let m = Machine::new(+ "crash",+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 100, "attachAfterMs": 1200}}),+ );+ let b = m.boot(+ TempDir::new("scratch"),+ &[+ ("ADOM_DIRECT_API_URL", &ab.url),+ ("ADOM_BRIDGE_TOKEN", "test-token"),+ // SHIPPED OFF. The click is decided from the shape of ab's+ // desktop_find_control reply, and that shape has never been seen on+ // a real desktop — so the feature is armed explicitly, here and by+ // the live gate, and nowhere else.+ ("SWX_CRASH_RECOVERY", "on"),+ ],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS to model a bracket", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 25);+ check!(b, "the launch still completes", st["launch"]["outcome"] == "attached", s(&st["launch"]));++ let clicks = ab.calls_to("desktop_ui_click");+ check!(b, "the Restart button was clicked EXACTLY once, however many polls ran", clicks.len() == 1, format!("{clicks:?}"));+ let args = &clicks[0]["args"];+ check!(b, "...on the crash reporter's window", args["hwnd"] == 111, s(&clicks[0]));+ check!(b, "...targeting the button BY NAME, never a coordinate", args["contains"] == "Restart SOLIDWORKS" && args["role"] == "button", s(&clicks[0]));+ check!(+ b,+ "...with a reason a human can read in their Activity Log",+ args["reason"].as_str().unwrap_or("").contains("crashed"),+ s(&clicks[0])+ );+ check!(+ b,+ "the button was CONFIRMED to exist before it was clicked",+ !ab.calls_to("desktop_find_control").is_empty(),+ format!("{:?}", ab.calls())+ );+ // NOTHING ELSE was touched. The Save-changes dialog is the one that must+ // never be answered by a bridge, and it was on screen the whole time.+ check!(b, "no window was closed", ab.calls_to("desktop_ui_window").is_empty(), format!("{:?}", ab.calls()));+ check!(b, "no shell command was run", ab.calls_to("shell_execute").is_empty(), format!("{:?}", ab.calls()));+ check!(+ b,+ "the user's Save-changes dialog was never touched",+ !ab.calls().iter().any(|c| c.to_string().contains("222")),+ format!("{:?}", ab.calls())+ );+ let ledger = b.call("swx_action_ledger", json!({"limit": 200}));+ check!(b, "the click is on the record", s(&ledger).contains("solidworks_crash_reporter_restarted"), s(&ledger));+}++#[test]+fn a_window_that_merely_looks_like_a_crash_reporter_is_never_clicked() {+ // The gate is the BUTTON, not the title. ab says the control is not there,+ // so nothing may be clicked — a title is a guess and a click is an act.+ let ab = AbStub::start(json!([+ {"hwnd": 111, "title": "SOLIDWORKS Error Report", "className": "#32770", "pid": 900012, "rect": {"left": 0}}+ ]));+ ab.set_response("desktop_find_control", json!({"success": true, "found": false}));+ let m = Machine::new(+ "nocrash",+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 100, "attachAfterMs": 1000}}),+ );+ let b = m.boot(+ TempDir::new("scratch"),+ &[+ ("ADOM_DIRECT_API_URL", &ab.url),+ ("ADOM_BRIDGE_TOKEN", "test-token"),+ ("SWX_CRASH_RECOVERY", "on"),+ ],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ wait_for_launch(&b, 25);+ check!(b, "no click was dispatched at all", ab.calls_to("desktop_ui_click").is_empty(), format!("{:?}", ab.calls()));+ check!(b, "...though the window WAS looked at", !ab.calls_to("desktop_find_control").is_empty(), format!("{:?}", ab.calls()));+}++#[test]+fn crash_recovery_is_off_unless_it_is_explicitly_armed() {+ // It ships DISABLED. The decision to click rests entirely on the shape of+ // ab's desktop_find_control reply, and no reply from that verb has ever+ // been observed on a real desktop — so until the live gate captures one,+ // the bridge reports the crash reporter and does nothing about it.+ let ab = AbStub::start(json!([+ {"hwnd": 111, "title": "SOLIDWORKS Design has stopped working", "className": "#32770", "pid": 900012, "rect": {"left": 0}}+ ]));+ ab.set_response(+ "desktop_find_control",+ json!({"success": true, "found": true, "name": "Restart SOLIDWORKS Design", "invokable": true}),+ );+ let m = Machine::new(+ "crashoff",+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 100, "attachAfterMs": 1000}}),+ );+ let b = m.boot(+ TempDir::new("scratch"),+ &[("ADOM_DIRECT_API_URL", &ab.url), ("ADOM_BRIDGE_TOKEN", "test-token")],+ );+ b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ let st = wait_for_launch(&b, 25);+ check!(b, "the launch is unaffected", st["launch"]["outcome"] == "attached", s(&st["launch"]));+ check!(+ b,+ "ab was never even asked about the window, let alone told to click it",+ ab.calls_to("desktop_find_control").is_empty() && ab.calls_to("desktop_ui_click").is_empty(),+ format!("{:?}", ab.calls())+ );+ check!(+ b,+ "...and the report says so, so a reader knows the feature is not armed",+ st["launch"]["crashRecovery"] == "off",+ s(&st["launch"])+ );+}++#[test]+fn a_hung_solidworks_is_reported_as_hung_and_a_second_one_is_never_launched() {+ // FAILURE MODE D. SOLIDWORKS is alive, the window is not answering, COM+ // times out. The old bridge said "not running, and this edition cannot be+ // started programmatically" — and on a shortcut machine it would have+ // launched a SECOND SOLIDWORKS onto the user's desktop.+ let m = Machine::new("hung", json!({}))+ .with_running_solidworks()+ .sldworks(json!({"responding": false, "hwnd": 4242, "privateMB": 2400, "handles": 9000}));+ let scratch = TempDir::new("scratch");+ let scratch_path = scratch.0.clone();+ let b = m.boot(scratch, &[]);+ // BOTH signals, from two independent causes: Windows says the window is not+ // answering (above), and a COM call this bridge made overran its budget.+ // Neither alone is a hang, and the bridge must not manufacture the second+ // one out of its own busy com host — see the adversary test.+ std::fs::write(scratch_path.join("mock-com-timeout"), "").unwrap();++ let r = b.call("swx_start", json!({"reason": "trying to use SOLIDWORKS"}));+ check!(b, "the bridge says it is HUNG, with a branchable code", r["errorCode"] == "host_app_hung", s(&r));+ check!(+ b,+ "...and says only the user can end it, and why",+ r["_hint"].as_str().unwrap_or("").contains("ONLY THE USER CAN END IT"),+ s(&r)+ );+ check!(b, "NOTHING was launched", m.invocations().is_empty(), format!("{:?}", m.invocations()));+ check!(b, "and nothing was killed", m.terminated().is_empty(), format!("{:?}", m.terminated()));+ check!(+ b,+ "the evidence is reported, not just the verdict",+ r["solidworksProcess"]["responding"] == false && r["solidworksProcess"]["processId"] == 88556,+ s(&r)+ );++ // swx_status tells the same story WITHOUT COM — which is the point, because+ // COM is what has stopped answering.+ let st = b.call("swx_status", json!({}));+ check!(b, "swx_status reports responding:false read from the OS, not from COM", st["solidworks"]["responding"] == false, s(&st["solidworks"]));+ check!(b, "...with the memory and handle counts a human needs", st["solidworks"]["privateMemoryMB"] == 2400 && st["solidworks"]["handles"] == 9000, s(&st["solidworks"]));+ check!(+ b,+ "...and resident memory beside the commit charge, so neither is read as 'memory'",+ st["solidworks"]["workingSetMB"].is_u64(),+ s(&st["solidworks"])+ );++ // TAKE THE TIMEOUT AWAY and the verdict must go with it: a window that is+ // not pumping messages, on its own, is a rebuild — not a hang.+ std::fs::remove_file(scratch_path.join("mock-com-timeout")).unwrap();+ let st2 = b.call("swx_status", json!({}));+ check!(+ b,+ "one signal alone is never a hang",+ st2["solidworks"]["hung"] != json!(true),+ s(&st2["solidworks"])+ );+}++#[test]+fn a_very_large_session_is_an_observation_not_a_verdict() {+ // 2026-09-03: 17.4 GB private, 52 documents, and BOTH stock templates+ // returned no document. That measurement is worth REPORTING — and it is not+ // a verdict. The 0.4.0 threshold (12 GB / 40 docs) called the same+ // machine's HEALTHY 13.1 GB / 33-document session out of room and told the+ // user to restart it, so memory is now advisory and the only sufficient+ // signal is SOLIDWORKS handing back no document (below).+ let m = Machine::new("exhausted", json!({}))+ .with_running_solidworks()+ .sldworks(json!({"responding": true, "hwnd": 4242, "privateMB": 17900, "handles": 21770}));+ let b = m.boot(TempDir::new("scratch"), &[]);++ let r = b.call("swx_start", json!({"reason": "trying to model"}));+ check!(+ b,+ "swx_start refuses to launch a second copy, and names what is ACTUALLY known",+ r["errorCode"] == "host_app_com_unreachable",+ s(&r)+ );+ check!(b, "nothing was launched", m.invocations().is_empty(), format!("{:?}", m.invocations()));++ let st = b.call("swx_status", json!({}));+ check!(+ b,+ "the memory figure is reported as an OBSERVATION",+ st["solidworks"]["memoryAdvisory"].as_str().unwrap_or("").contains("17900"),+ s(&st["solidworks"])+ );+ check!(+ b,+ "...explicitly not a verdict",+ st["solidworks"]["memoryAdvisory"].as_str().unwrap_or("").contains("not a verdict"),+ s(&st["solidworks"])+ );+ check!(+ b,+ "...and `exhausted` stays null, because nothing has proved it",+ st["solidworks"]["exhausted"].is_null(),+ s(&st["solidworks"])+ );+}++#[test]+fn a_new_document_that_comes_back_empty_is_exhaustion_not_a_template_fault() {+ let m = Machine::new(+ "newdoc",+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0, "attachAfterMs": 0}}),+ );+ let b = m.boot(TempDir::new("scratch"), &[]);+ b.call("swx_start", json!({"reason": "modelling", "waitSeconds": 10}));+ let ok = b.call("swx_new_document", json!({"type": "part"}));+ check!(b, "a healthy session creates documents normally", ok["success"] == true, s(&ok));++ // Now SOLIDWORKS accepts the call and hands back nothing — the signature.+ std::fs::write(b.scratch.join("mock-newdoc-empty"), "").unwrap();+ let r = b.call("swx_new_document", json!({"type": "assembly"}));+ check!(b, "an empty NewDocument is reported as exhaustion", r["errorCode"] == "host_app_exhausted", s(&r));+ check!(b, "...quoting what SOLIDWORKS actually said", r["hostAppError"].as_str().unwrap_or("").contains("created no document"), s(&r));+ check!(b, "...and telling the caller not to loop on it", r["_hint"].as_str().unwrap_or("").contains("Do NOT retry"), s(&r));+}++#[test]+fn ownership_survives_an_unanswerable_probe_and_a_com_host_respawn() {+ // BUG #5, in both the shapes that were observed. No simulator needed: this+ // is about the SERVER's registry, not about launching.+ let scratch = TempDir::new("scratch");+ let scratch_path = scratch.0.clone();+ let b = Bridge::start_in(TempDir::borrowed(&scratch_path), &[]);+ b.call("swx_start", json!({}));+ let made = b.call("swx_new_document", json!({"type": "part"}));+ let title = made["created"]["title"].as_str().unwrap_or("Part1").to_string();+ check!(b, "the bridge created a document", made["success"] == true, s(&made));++ let write = b.call("swx_props_set", json!({"targetDocument": title, "properties": {"Owner": "bridge"}}));+ check!(b, "it may write to its own document", write["success"] == true, s(&write));++ // ...and the claim is ON DISK, not only in this process's memory.+ let registry: Value =+ serde_json::from_str(&std::fs::read_to_string(scratch_path.join("owned-docs.json")).unwrap()).unwrap();+ check!(b, "the ownership registry is persisted", s(®istry).contains(&title), s(®istry));++ // THE OBSERVED FAILURE: one probe SOLIDWORKS could not answer. The old code+ // read that as "no session, nothing open" and pruned the registry empty —+ // permanently disowning documents the bridge had created seconds earlier.+ std::fs::write(scratch_path.join("mock-fail-app-state"), "").unwrap();+ let blind = b.call("swx_props_set", json!({"targetDocument": title, "properties": {"Owner": "bridge"}}));+ check!(b, "an unanswerable probe fails HONESTLY", blind["success"] == false, s(&blind));+ check!(+ b,+ "...and never claims the document was not ours",+ !s(&blind).contains("did not create"),+ s(&blind)+ );+ std::fs::remove_file(scratch_path.join("mock-fail-app-state")).unwrap();++ let after = b.call("swx_props_set", json!({"targetDocument": title, "properties": {"Owner": "bridge"}}));+ check!(b, "OWNERSHIP SURVIVED the blind window", after["success"] == true, s(&after));++ // THE SECOND SHAPE: the com host is killed mid-build and respawns.+ let host_pid = b.call("swx_status", json!({}))["com"]["hostPid"].as_u64().unwrap();+ assert!(+ std::process::Command::new("kill")+ .args(["-9", &host_pid.to_string()])+ .status()+ .expect("kill the com host")+ .success(),+ "could not kill com host {host_pid}"+ );+ std::thread::sleep(Duration::from_millis(300));+ let st = b.call("swx_status", json!({}));+ check!(b, "the com host really was replaced", st["com"]["hostPid"].as_u64().unwrap_or(host_pid) != host_pid, s(&st["com"]));+ let survived = b.call("swx_props_set", json!({"targetDocument": title, "properties": {"Owner": "bridge"}}));+ check!(b, "OWNERSHIP SURVIVED the respawn", survived["success"] == true, s(&survived));++ // ...and a whole new SERVER reads the registry back off disk.+ drop(b);+ let b2 = Bridge::start_in(TempDir::borrowed(&scratch_path), &[]);+ let st2 = b2.call("swx_status", json!({}));+ check!(b2, "a restarted bridge restores the registry", b2.log().contains("ownership registry restored"), b2.log());+ check!(b2, "...and is otherwise healthy", st2["success"] == true, s(&st2));+}++#[test]+fn a_bridge_that_cannot_see_the_machine_still_launches_rather_than_refusing() {+ // No simulator, not Windows: the process census cannot be taken. UNKNOWN+ // must not become "SOLIDWORKS is running" (which would refuse every launch)+ // NOR a silent claim that it is not. The launch proceeds, and the report+ // says the census failed rather than inventing a census.+ let lnk = TempDir::new("lnk");+ let path = format!("{}/SOLIDWORKS Design.lnk", lnk.path());+ std::fs::write(&path, "x").unwrap();+ let b = Bridge::start_with(&[+ ("SWX_SOLIDWORKS_SHORTCUT", &path),+ ("SWX_LAUNCH_RUN_BEGIN_SECS", "1"),+ ("SWX_LAUNCH_POLL_MS", "100"),+ ]);+ let r = b.call("swx_start", json!({"reason": "starting SOLIDWORKS", "waitSeconds": 0}));+ check!(b, "the launch is attempted", r["success"] == true && r["stillLaunching"] == true, s(&r));+ let st = b.call("swx_status", json!({}));+ check!(+ b,+ "an unavailable census is reported as unavailable, never as 'not running'",+ st["solidworks"]["running"].is_null() || st["solidworks"]["censusError"].is_string(),+ s(&st["solidworks"])+ );+ check!(+ b,+ "...and responsiveness is UNKNOWN rather than a comfortable 'yes'",+ st["solidworks"]["responding"].is_null(),+ s(&st["solidworks"])+ );+}+++#[test]+fn a_corrupt_registry_falls_back_to_the_previous_generation() {+ // `write_atomic` keeps one generation as `.bak`, and it is a strictly+ // better answer than owning nothing: it is a registry this bridge itself+ // wrote one write ago, so at worst it is missing the newest claim — while+ // owning nothing refuses every document the bridge created before the+ // restart, which IS bug #5.+ let scratch = TempDir::new("scratch");+ let dir = scratch.0.clone();+ {+ let b = Bridge::start_in(TempDir::borrowed(&dir), &[]);+ b.call("swx_start", json!({}));+ let a = b.call("swx_new_document", json!({"type": "part"}));+ check!(b, "a document is created and owned", a["success"] == true, s(&a));+ // A second write, so a .bak generation exists containing the first.+ let c = b.call("swx_new_document", json!({"type": "part"}));+ check!(b, "...and a second", c["success"] == true, s(&c));+ }+ check!(+ Bridge::start_in(TempDir::borrowed(&dir), &[]),+ "precondition: a backup generation exists",+ dir.join("owned-docs.bak").exists(),+ format!("{:?}", std::fs::read_dir(&dir).unwrap().count())+ );++ // A rewrite interrupted half way through.+ std::fs::write(dir.join("owned-docs.json"), r#"{"documents":[{"title":"Part1","sess"#).unwrap();+ let b2 = Bridge::start_in(TempDir::borrowed(&dir), &[]);+ let log = b2.log();+ check!(b2, "the corruption is reported, loudly", log.contains("UNREADABLE") || log.contains("could not be read"), log.clone());+ check!(+ b2,+ "...and the previous generation is used instead of owning nothing",+ log.contains("recovered from owned-docs.bak"),+ log+ );+}++#[test]+fn an_unknown_interference_count_reaches_the_caller_as_null_with_guidance() {+ // The shape has to be TESTED, not just produced: `count: null` on a+ // clearance report is the one null in this bridge that can reach a physical+ // part, and every caller's truthiness check reads it as zero.+ let b = Bridge::start();+ b.call("swx_start", json!({"reason": "checking clearances"}));+ let made = b.call("swx_new_document", json!({"type": "assembly"}));+ let title = made["created"]["title"].as_str().unwrap_or("Assem1").to_string();++ let ok = b.call("swx_interference", json!({"targetDocument": title}));+ check!(b, "a normal check reports a count and says it is known", ok["count"] == 1 && ok["countKnown"] == true, s(&ok));++ std::fs::write(b.scratch.join("mock-interference-count-unknown"), "").unwrap();+ let r = b.call("swx_interference", json!({"targetDocument": title}));+ check!(b, "an uncountable check answers null, never 0", r["count"].is_null() && r["countKnown"] == false, s(&r));+ check!(+ b,+ "...and the hint tells the caller not to use a truthiness check on it",+ r["_hint"].as_str().unwrap_or("").contains("never with a truthiness check")+ && r["_hint"].as_str().unwrap_or("").contains("countKnown"),+ s(&r)+ );+ check!(+ b,+ "...saying plainly that null is not a clear result",+ r["_hint"].as_str().unwrap_or("").contains("NOT a clear result"),+ s(&r)+ );++ // The same shape on the other two newly-nullable counts.+ std::fs::write(b.scratch.join("mock-doccount-unknown"), "").unwrap();+ let rec = b.call("swx_check_recovery", json!({}));+ check!(b, "an unknown open-document count is null, not 0", rec["com"]["openDocuments"].is_null(), s(&rec));++ std::fs::write(b.scratch.join("mock-component-count-unknown"), "").unwrap();+ let add = b.call("swx_add_component", json!({+ "targetDocument": title,+ "components": [{"file": "C:\\mock\\bracket.sldprt", "xMm": 0, "yMm": 0, "zMm": 0}]+ }));+ check!(+ b,+ "an uncountable assembly refuses the insert rather than risking a DOUBLE insert",+ add["success"] == false && add["errorCode"] == "component_count_failed",+ s(&add)+ );+ check!(+ b,+ "...and says why, in terms of the harm",+ add["error"].as_str().unwrap_or("").contains("TWICE"),+ s(&add)+ );+}+++#[test]+fn a_not_responding_solidworks_that_is_burning_cpu_is_reported_as_busy() {+ // LIVE, 2026-09-08: 17.9 GB with an unsaved assembly, not-responding and a+ // COM timeout for ~5 minutes, ~70% of a core, memory creeping, NO dialog —+ // and it RECOVERED once Drew saved. The bridge said "only the user can end+ // it". `verdict` exists so a caller can tell "wait" from "it is gone".+ let m = Machine::new("busy", json!({}))+ .with_running_solidworks()+ .sldworks(json!({+ "responding": false, "hwnd": 4242, "privateMB": 17900, "handles": 21000,+ // Every metrics read advances the simulated CPU clock: work IS+ // happening, which is the whole point.+ "cpuSeconds": 1000.0, "cpuSecondsPerRead": 12.0+ }));+ let scratch = TempDir::new("scratch");+ let dir = scratch.0.clone();+ let b = m.boot(scratch, &[]);+ std::fs::write(dir.join("mock-com-timeout"), "").unwrap();++ // Two reads far enough apart: the verdict needs a sample, and one reading is+ // never enough to call a session dead.+ b.call("swx_status", json!({}));+ let st = b.call("swx_status", json!({}));+ let sw = &st["solidworks"];+ check!(b, "the window is not responding and a COM call timed out", sw["hung"] == true, s(sw));+ check!(b, "...but the verdict is BUSY, not dead", sw["verdict"] == "busy", s(sw));+ check!(+ b,+ "...and the advice is to WAIT, not to end the task",+ sw["verdictAdvice"].as_str().unwrap_or("").contains("WAIT")+ && !sw["verdictAdvice"].as_str().unwrap_or("").contains("ONLY THE USER CAN END IT"),+ s(sw)+ );+ check!(b, "...with the CPU it burned across the sample", sw["cpuActiveSeconds"].as_f64().unwrap_or(0.0) > 0.0, s(sw));+ check!(b, "...and the memory delta beside it", sw["memoryDeltaMB"].is_i64(), s(sw));+}++#[test]+fn one_reading_never_calls_a_session_dead() {+ // The guard that would have stopped the wrong call: with no previous sample+ // there is no CPU delta, so the verdict is UNKNOWN however bad it looks.+ let m = Machine::new("onesample", json!({}))+ .with_running_solidworks()+ .sldworks(json!({"responding": false, "hwnd": 4242, "privateMB": 17900, "cpuSecondsPerRead": 0.0}));+ let scratch = TempDir::new("scratch");+ let dir = scratch.0.clone();+ let b = m.boot(scratch, &[]);+ std::fs::write(dir.join("mock-com-timeout"), "").unwrap();+ let st = b.call("swx_status", json!({}));+ let sw = &st["solidworks"];+ check!(b, "a single reading is UNKNOWN, never dead", sw["verdict"] == "unknown", s(sw));+ check!(+ b,+ "...and it says to poll again rather than concluding",+ sw["verdictAdvice"].as_str().unwrap_or("").contains("Poll swx_status again"),+ s(sw)+ );+}++// ════════════════════════════════════════════════════════════════════════════+// LIVE, 2026-09-11 — A SIGN-IN WAIT IS NOT A STALL+// ════════════════════════════════════════════════════════════════════════════++/// Drew's machine, and the worst thing this bridge has done to date.+///+/// A cold launch stopped at the 3DEXPERIENCE ID sign-in — a native WebView2+/// window (`DSYSysLoginPanelWebView2Class`) owned by `SWXDesktopLauncher.exe` in+/// the bridge's OWN chain, with no controls UIA can even see. The+/// "sldworks.exe within 120s" deadline then fired while he was reading it, and+/// the machine killed its own chain — including the window he was about to type+/// into — escalated to the next route, killed that too, and finished+/// `exhausted`.+///+/// The launch was not stalled. It was waiting for a person, and a person is+/// slower than any budget worth setting.+#[test]+fn a_sign_in_window_pauses_every_deadline_instead_of_killing_the_chain() {+ // sldworks NEVER appears while the sign-in is up: on the old code the+ // 4-second budget here would fire and kill the chain.+ let m = Machine::new(+ "signin",+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0}}),+ );+ // The sign-in window, owned by whichever of our launcher pids is asked.+ std::fs::write(+ m.dir.join("sign-in.json"),+ json!({"hwnd": 777001, "className": "DSYSysLoginPanelWebView2Class",+ "title": "Login | 3DEXPERIENCE ID | Dassault Systemes"})+ .to_string(),+ )+ .unwrap();+ let b = m.boot(TempDir::new("signin-scratch"), &[]);+ let r = b.call("swx_start", json!({"waitSeconds": 0, "reason": "the user asked for SOLIDWORKS"}));+ check!(b, "the launch is in flight", r["success"] == true || r["stillLaunching"] == true, s(&r));++ // Well past the sldworks budget (4s here, 120s live) with the window up.+ std::thread::sleep(std::time::Duration::from_secs(7));+ let st = b.call("swx_status", json!({}));+ let launch = &st["launch"];+ check!(+ b,+ "NOTHING was killed while the user was being asked to sign in",+ m.terminated().is_empty(),+ format!("{:?}", m.terminated())+ );+ check!(+ b,+ "...the machine did not escalate to another route",+ m.invocations() == vec!["shellExecute".to_string()],+ format!("{:?}", m.invocations())+ );+ check!(+ b,+ "...the stage SAYS it is waiting on a sign-in",+ launch["stage"] == "helperSignIn" || launch["signInSeen"] == true,+ s(launch)+ );+ check!(b, "...and the attempt is not finished", launch["finished"] != true, s(launch));++ // The user signs in: the window closes and SOLIDWORKS comes up.+ std::fs::remove_file(m.dir.join("sign-in.json")).unwrap();+ std::fs::write(+ m.dir.join("routes.json"),+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0,+ "attachAfterMs": 0}})+ .to_string(),+ )+ .unwrap();+ let mut attached = false;+ for _ in 0..60 {+ std::thread::sleep(std::time::Duration::from_millis(250));+ let st = b.call("swx_status", json!({}));+ if st["launch"]["stage"] == "attached" || st["com"]["running"] == true {+ attached = true;+ break;+ }+ }+ let st = b.call("swx_status", json!({}));+ check!(b, "once signed in, the launch attaches", attached, s(&st["launch"]));+ check!(+ b,+ "...and the session is still recorded as OURS — attribution survives the wait",+ st["launch"]["attributed"] == true,+ s(&st["launch"])+ );+ check!(b, "...with nothing killed, start to finish", m.terminated().is_empty(),+ format!("{:?}", m.terminated()));+}++/// The other half: an unreadable window census must PAUSE too. Resuming a+/// deadline on a census we could not take is how the machine killed a window it+/// could not see.+#[test]+fn a_window_census_that_cannot_be_read_pauses_the_deadline_too() {+ let m = Machine::new(+ "signin-blind",+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0}}),+ );+ std::fs::write(m.dir.join("window-census-fails"), "").unwrap();+ let b = m.boot(TempDir::new("signin-blind-scratch"), &[]);+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "launching"}));+ std::thread::sleep(std::time::Duration::from_secs(7));+ check!(+ b,+ "a blind census does not license a kill",+ m.terminated().is_empty(),+ format!("{:?}", m.terminated())+ );+}++/// RE-ATTRIBUTION NEEDS LINEAGE, not just timing.+///+/// A session that comes up after the bridge gave up IS often the one it started+/// — that is the live failure 0.6.0 fixed. But "it started after our invoke and+/// we never saw another launcher chain" is timing plus the ABSENCE of an+/// observation, and absence is not evidence: a chain that started and finished+/// between two censuses was never seen at all. 0.6.1 requires a positive link —+/// a parent walk that reaches our own chain, or a launcher log a thread of our+/// chain wrote.+#[test]+fn a_session_with_no_link_to_our_chain_is_not_adopted_after_the_fact() {+ let m = Machine::new(+ "reattr-nolineage",+ // Every route fails to attach, so the attempt finishes un-attached.+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0},+ "powershell": {"chain": false},+ "explorer": {"chain": false},+ "cmdStart": {"chain": false}}),+ );+ let b = m.boot(TempDir::new("reattr-nolineage-scratch"), &[]);+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "launching"}));+ // Let the attempt run itself out.+ let mut finished = false;+ for _ in 0..80 {+ std::thread::sleep(std::time::Duration::from_millis(250));+ if b.call("swx_status", json!({}))["launch"]["finished"] == true {+ finished = true;+ break;+ }+ }+ check!(b, "precondition: the attempt finished without attaching", finished, "never finished");++ // NOW SOLIDWORKS APPEARS — with no parent link to anything of ours.+ std::fs::write(+ m.dir.join("preexisting.json"),+ json!([{"pid": 99001, "image": "SLDWORKS.exe", "ageSeconds": 0}]).to_string(),+ )+ .unwrap();+ let st = b.call("swx_status", json!({}));+ check!(+ b,+ "a SOLIDWORKS with no proven link to our chain is NOT claimed as ours",+ st["launch"]["attributed"] != true && st["launch"]["outcome"] != "attachedAfterSignIn",+ s(&st["launch"])+ );+}++/// PASS 16, finding 2. A DEAD parent may be lineage — the launcher always exits+/// once SOLIDWORKS is up — but "a pid we once recorded" is not enough once that+/// process is gone: the NUMBER is free. A user's launcher that reuses it would+/// have its SOLIDWORKS record our dead pid as its parent, with nothing alive left+/// to contradict it, and "the child is younger than the parent" is vacuously true+/// of anything that started later.+///+/// So the child must have been created inside the window we actually WATCHED the+/// parent run, plus a grace for the poll interval.+#[test]+fn a_child_that_appeared_long_after_our_launcher_died_is_not_its_descendant() {+ let m = Machine::new(+ "lineage-window",+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0}}),+ );+ let b = m.boot(TempDir::new("lineage-window-scratch"), &[]);+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "launching"}));+ let mut finished = false;+ for _ in 0..80 {+ std::thread::sleep(std::time::Duration::from_millis(250));+ if b.call("swx_status", json!({}))["launch"]["finished"] == true {+ finished = true;+ break;+ }+ }+ check!(b, "precondition: the attempt finished", finished, "never finished");++ // A SOLIDWORKS that claims our launcher's pid as its parent but was created+ // an hour after we last saw that launcher alive: a recycled pid, not a child.+ let chain = b.call("swx_status", json!({}))["launch"]["chainPids"].clone();+ let launcher = chain+ .as_array()+ .and_then(|a| a.iter().find(|p| p["image"] == "SWXDesktopLauncher.exe"))+ .and_then(|p| p["pid"].as_u64())+ .unwrap_or(0);+ check!(b, "precondition: we recorded a launcher pid", launcher > 0, format!("{chain}"));+ std::fs::write(m.dir.join("sldworks-parent"), launcher.to_string()).unwrap();+ std::fs::write(+ m.dir.join("preexisting.json"),+ json!([{"pid": 99123, "image": "SLDWORKS.exe", "ageSeconds": -3600}]).to_string(),+ )+ .unwrap();++ let st = b.call("swx_status", json!({}));+ check!(+ b,+ "a process created long after that launcher died is not adopted as its child",+ st["launch"]["outcome"] != "attachedAfterSignIn",+ s(&st["launch"])+ );+}++/// PID REUSE, END TO END — through the whole bridge, with the simulator finally+/// able to pose it (`census-overrides.json` retires a pid and reissues it with a+/// new creation time).+///+/// HONEST LIMIT OF THIS TEST, because a test nobody can characterise is worse+/// than none: it asserts the OUTCOME (the bridge does not adopt that session),+/// and I could NOT establish which guard produces it. Disabling the `same_process`+/// check and the lifetime-window check — together, not just one — leaves it+/// passing, so something earlier in `reattribute_after_attach` is refusing and+/// this test does not pin the lineage rule. The rule itself is proved directly,+/// with a mutation check that does fail when it is removed, in+/// `launch::tests::a_child_created_after_we_last_saw_the_parent_alive_is_not_its_descendant`.+/// What this test genuinely covers is the WHOLE PATH: a census that retires and+/// reissues a pid does not end with somebody else's session recorded as ours.+#[test]+fn a_recycled_launcher_pid_does_not_make_someone_elses_solidworks_ours() {+ let m = Machine::new(+ "pid-reuse",+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0}}),+ );+ let b = m.boot(TempDir::new("pid-reuse-scratch"), &[]);+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "launching"}));+ let mut finished = false;+ for _ in 0..80 {+ std::thread::sleep(std::time::Duration::from_millis(250));+ if b.call("swx_status", json!({}))["launch"]["finished"] == true {+ finished = true;+ break;+ }+ }+ check!(b, "precondition: the attempt finished", finished, "never finished");+ let chain = b.call("swx_status", json!({}))["launch"]["chainPids"].clone();+ let launcher = chain+ .as_array()+ .and_then(|a| a.iter().find(|p| p["image"] == "SWXDesktopLauncher.exe"))+ .and_then(|p| p["pid"].as_u64())+ .unwrap_or(0);+ check!(b, "precondition: we recorded and watched a launcher", launcher > 0, format!("{chain}"));++ let before = b.call("swx_status", json!({}))["launch"]["outcome"].clone();++ // OUR launcher is retired and the number REISSUED to the user's, created now+ // — inside the grace window, so timing alone cannot tell them apart. Their+ // SOLIDWORKS, created now too, names that number as its parent.+ std::fs::write(+ m.dir.join("census-overrides.json"),+ json!([+ {"retire": launcher},+ {"pid": launcher, "image": "SWXDesktopLauncher.exe", "ageSeconds": 0},+ {"pid": 991234, "image": "SLDWORKS.exe", "ageSeconds": 0, "ppid": launcher}+ ])+ .to_string(),+ )+ .unwrap();+ // ...and COM becomes attachable, which is what makes re-attribution REACHABLE+ // at all: without an attach it returns before it ever consults lineage, and+ // this test would pass while proving nothing.+ std::fs::write(+ m.dir.join("routes.json"),+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0,+ "attachAfterMs": 0}})+ .to_string(),+ )+ .unwrap();+ // The user signs in and SOLIDWORKS comes up; the next call attaches to it —+ // which is the real sequence, and the only thing that makes re-attribution+ // REACHABLE. Without an attach it returns before consulting lineage at all+ // and this test would pass while proving nothing.+ let mut attached = false;+ for _ in 0..40 {+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "attaching to the signed-in session"}));+ std::thread::sleep(std::time::Duration::from_millis(250));+ if b.call("swx_status", json!({}))["com"]["running"] == true {+ attached = true;+ break;+ }+ }+ check!(b, "precondition: COM attached, so lineage is the guard under test", attached, "never");++ let st = b.call("swx_status", json!({}));+ check!(+ b,+ "a SOLIDWORKS whose parent merely REUSED our launcher's pid is not adopted",+ st["launch"]["outcome"] != "attachedAfterSignIn",+ format!("was {before} / now {}", s(&st["launch"]))+ );+}++/// RE-ATTRIBUTION IS ONLY POSSIBLE FOR AN ATTRIBUTED ROUTE, end to end.+///+/// `explorer` and `cmdStart` cannot say what they started, so nothing of theirs+/// is ever recorded as our chain and there is no identity to walk a parent back+/// to. A session that comes up after one of them may well BE ours — and this+/// bridge still will not claim it, because it cannot prove it.+///+/// The RULE is pinned deterministically in+/// `launch::tests::an_unattributable_route_has_no_identities_to_walk_back_to`;+/// this one covers the path through the running bridge. Same honest limit as the+/// test above: it asserts the outcome, not which guard produced it.+#[test]+fn an_unattributable_route_can_never_re_attribute_a_session() {+ let m = Machine::new(+ "reattr-unattributable",+ // shellExecute reports no root at all here; explorer runs and stalls.+ json!({"shellExecute": {"chain": false}, "powershell": {"chain": false},+ "explorer": {"chain": true, "runBeginAfterMs": 0},+ "cmdStart": {"chain": false}}),+ );+ let b = m.boot(TempDir::new("reattr-unattributable-scratch"), &[]);+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "launching"}));+ let mut finished = false;+ for _ in 0..100 {+ std::thread::sleep(std::time::Duration::from_millis(250));+ if b.call("swx_status", json!({}))["launch"]["finished"] == true {+ finished = true;+ break;+ }+ }+ check!(b, "precondition: the attempt finished", finished, "never finished");++ // A SOLIDWORKS appears, descended from the explorer route's own launcher —+ // which really may be ours. The bridge cannot PROVE it, so it does not say it.+ let st0 = b.call("swx_status", json!({}));+ check!(+ b,+ "precondition: no attribution root was recorded for the route that ran",+ st0["launch"]["attribution"].is_null() || st0["launch"]["attributed"] != true,+ s(&st0["launch"])+ );+ std::fs::write(+ m.dir.join("preexisting.json"),+ json!([{"pid": 99321, "image": "SLDWORKS.exe", "ageSeconds": 0}]).to_string(),+ )+ .unwrap();+ // COM attaches — so re-attribution is REACHED and refuses on the rule, rather+ // than never being asked.+ std::fs::write(+ m.dir.join("routes.json"),+ json!({"explorer": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0,+ "attachAfterMs": 0}})+ .to_string(),+ )+ .unwrap();+ let mut attached = false;+ for _ in 0..40 {+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "attaching to the session"}));+ std::thread::sleep(std::time::Duration::from_millis(250));+ if b.call("swx_status", json!({}))["com"]["running"] == true {+ attached = true;+ break;+ }+ }+ check!(b, "precondition: COM attached, so the rule is what refuses", attached, "never");++ let st = b.call("swx_status", json!({}));+ check!(+ b,+ "an unattributable route never re-attributes a session, however plausible it looks",+ st["launch"]["outcome"] != "attachedAfterSignIn" && st["launch"]["attributed"] != true,+ s(&st["launch"])+ );+}++/// THE POSITIVE CASE'S MIRROR. Same scenario, one thing changed: our launcher is+/// retired and its pid REISSUED to a different process before COM attaches. The+/// session must NOT be adopted, and the report must name which clause refused —+/// so the next person diagnosing this reads one field instead of the source.+///+/// Mutation-checked: removing the lineage rule makes this fail.+#[test]+fn a_reissued_launcher_pid_is_refused_and_the_report_names_the_clause() {+ let m = Machine::new(+ "reissue-named",+ // The attach lands AFTER the attempt has given up — which is the whole+ // sign-in shape, and the only state in which re-attribution is even+ // consulted.+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0,+ "attachAfterMs": 6000}}),+ );+ std::fs::write(m.dir.join("preexisting.json"), json!([]).to_string()).unwrap();+ let b = m.boot(TempDir::new("reissue-named-scratch"), &[("SWX_LAUNCH_ATTACH_SECS", "2")]);+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "launching"}));++ // Let the chain be recorded and watched.+ let mut launcher = 0u64;+ for _ in 0..60 {+ std::thread::sleep(std::time::Duration::from_millis(200));+ let st = b.call("swx_status", json!({}));+ if let Some(a) = st["launch"]["chainPids"].as_array() {+ if let Some(p) = a.iter().find(|p| p["image"] == "SWXDesktopLauncher.exe") {+ launcher = p["pid"].as_u64().unwrap_or(0);+ if st["launch"]["finished"] == true {+ break;+ }+ }+ }+ }+ check!(b, "precondition: our launcher was recorded and watched", launcher > 0, "none");++ // ...then that pid is REISSUED to a different process, and a SOLIDWORKS+ // claiming it as its parent appears. Both facts are exactly what a recycled+ // pid looks like from outside.+ std::fs::write(+ m.dir.join("census-overrides.json"),+ json!([+ {"retire": launcher},+ {"pid": launcher, "image": "SWXDesktopLauncher.exe", "ageSeconds": 0},+ {"pid": 991234, "image": "SLDWORKS.exe", "ageSeconds": 0, "ppid": launcher}+ ])+ .to_string(),+ )+ .unwrap();++ let mut st = json!({});+ for _ in 0..40 {+ std::thread::sleep(std::time::Duration::from_millis(250));+ st = b.call("swx_status", json!({}));+ if st["com"]["attached"] == true {+ break;+ }+ }+ check!(b, "precondition: COM attached, so the lineage walk runs", st["com"]["attached"] == true, s(&st["com"]));+ check!(+ b,+ "the session is NOT adopted on a reissued pid",+ st["launch"]["outcome"] != "attachedAfterSignIn",+ s(&st["launch"])+ );+ // TWO LAYERS, since 0.6.7: the report carries the COMPOSITE reason (no+ // candidate could be proved), and the ledger carries the per-candidate+ // clause — because "the last one stands" hid which process failed which test.+ let why = st["launch"]["reattributionRefusedBecause"].as_str().unwrap_or("").to_string();+ check!(+ b,+ "...the report names the composite reason",+ why == "no_candidate_descends" || why == "attached_pid_not_ours",+ format!("reattributionRefusedBecause={why:?} / {}", s(&st["launch"]))+ );+ let ledger = s(&b.call("swx_action_ledger", json!({"limit": 80})));+ check!(+ b,+ "...and the ledger names the clause the REISSUED pid failed on",+ ledger.contains("alive_but_different_process") || ledger.contains("child_outside_window"),+ ledger.chars().rev().take(900).collect::<String>().chars().rev().collect::<String>()+ );+}++/// CONSENT-AFFECTING, from pass 19. Re-attribution must claim ONLY the process+/// COM is attached to.+///+/// 0.6.6 used the attached pid to ORDER the candidates and took the first that+/// descended — so with two SOLIDWORKS running it could prove lineage for OUR+/// launcher's child while COM was attached to a DIFFERENT process, and then+/// label the session `attributed:true`. Everything downstream reads that label:+/// the quit gate's bridge-launched proof, and a quit ladder that builds its+/// Identity from the COM pid. The bridge would have been willing to END a session+/// nothing had proved was ours.+///+/// Staged here so the attached process is NOT ours and a non-attached one IS:+/// the route's own SOLIDWORKS is re-parented to a stranger, and a second one is+/// given our launcher as its parent.+#[test]+fn re_attribution_claims_only_the_process_com_is_attached_to() {+ let m = Machine::new(+ "attached-only",+ json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0,+ "attachAfterMs": 6000}}),+ );+ std::fs::write(m.dir.join("preexisting.json"), json!([]).to_string()).unwrap();+ let b = m.boot(TempDir::new("attached-only-scratch"), &[("SWX_LAUNCH_ATTACH_SECS", "2")]);+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "launching"}));++ let mut launcher = 0u64;+ let mut sw_pid = 0u64;+ for _ in 0..60 {+ std::thread::sleep(std::time::Duration::from_millis(200));+ let st = b.call("swx_status", json!({}));+ if let Some(a) = st["launch"]["chainPids"].as_array() {+ if let Some(p) = a.iter().find(|p| p["image"] == "SWXDesktopLauncher.exe") {+ launcher = p["pid"].as_u64().unwrap_or(0);+ }+ }+ if let Some(p) = st["solidworks"]["processId"].as_u64() {+ sw_pid = p;+ }+ if st["launch"]["finished"] == true && launcher > 0 && sw_pid > 0 {+ break;+ }+ }+ check!(b, "precondition: chain and SOLIDWORKS observed", launcher > 0 && sw_pid > 0,+ format!("launcher={launcher} sw={sw_pid}"));++ // The ATTACHED process is re-parented to a stranger; a SECOND SOLIDWORKS,+ // which COM is NOT attached to, descends from our launcher — created at the+ // moment we last SAW that launcher alive, so it is genuinely inside the+ // lineage window and the ONLY thing that can refuse it is the pid-equality+ // rule under test.+ let last_seen = b.call("swx_status", json!({}))["launch"]["chainLastSeen"]+ .as_array()+ .and_then(|rows| {+ rows.iter()+ .find(|r| r[0].as_u64() == Some(launcher))+ .and_then(|r| r[2].as_u64())+ })+ .unwrap_or(0);+ check!(b, "precondition: we know when the launcher was last seen", last_seen > 0, "none");+ // ABSOLUTE, not an age: the census computes "now" when it is read, so a+ // relative age drifts by however long the test takes to get there — and the+ // window is one census interval wide.+ std::fs::write(+ m.dir.join("census-overrides.json"),+ json!([+ {"pid": sw_pid, "image": "SLDWORKS.exe", "ageSeconds": 0, "ppid": 4242},+ {"pid": 900777, "image": "SLDWORKS.exe", "createdAt": last_seen, "ppid": launcher}+ ])+ .to_string(),+ )+ .unwrap();++ let mut st = json!({});+ for _ in 0..40 {+ std::thread::sleep(std::time::Duration::from_millis(250));+ st = b.call("swx_status", json!({}));+ if st["com"]["attached"] == true {+ break;+ }+ }+ check!(b, "precondition: COM attached", st["com"]["attached"] == true, s(&st["com"]));+ check!(+ b,+ "precondition: the attached process is NOT the one that descends from our chain",+ st["com"]["processId"].as_u64() == Some(sw_pid),+ s(&st["com"])+ );+ // NOTE on `attributed`: it stays TRUE here, and correctly — this attempt DID+ // launch a chain and attribute its root. What must not happen is the extra+ // claim that the session COM is now talking to is that launch's product.+ check!(+ b,+ "the session is NOT re-claimed, because the process COM answers for is not ours",+ st["launch"]["outcome"] != "attachedAfterSignIn",+ s(&st["launch"])+ );+ check!(+ b,+ "...and no claim is recorded about which SOLIDWORKS this attempt produced",+ st["launch"]["attributedSolidworks"].is_null(),+ s(&st["launch"])+ );+ check!(+ b,+ "...and the refusal NAMES that clause",+ st["launch"]["reattributionRefusedBecause"] == "attached_pid_not_ours",+ format!("{}", st["launch"]["reattributionRefusedBecause"])+ );+}
tests/protocol.rs+143−1@@ -13,7 +13,7 @@ mod common; -use common::Bridge;+use common::{Bridge, TempDir}; use serde_json::json; use std::time::{Duration, Instant}; @@ -213,9 +213,15 @@ fn the_com_host_child_speaks_the_protocol_directly() { // server drives it. If the framing ever changes, this fails without needing // a server at all. use std::io::{BufRead, BufReader, Write};+ // Its OWN scratch dir. The simulated SOLIDWORKS session is persisted there+ // (a com-host respawn must not close the user's documents, because a real+ // one does not), so a host sharing the default root would inherit whatever+ // another test left open.+ let scratch = TempDir::new("protocol-child"); let mut child = std::process::Command::new(common::BIN) .arg("--com-host") .env("SWX_MOCK", "1")+ .env("SWX_SCRATCH_DIR", scratch.path()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null())@@ -733,3 +739,139 @@ fn the_host_applies_the_whole_opt_in_chain_and_honours_it() { let _ = child.wait(); let _ = std::fs::remove_dir_all(&dir); }++/// ADVERSARY PASS 12, the OTHER layer. The server's write gate refuses a+/// destination it cannot stat — but the com host's veto is supposed to be+/// INDEPENDENT, and two vetoes that disagree about what "the destination is free"+/// means are one veto with extra steps.+///+/// This drives the com host directly, over its own stdio protocol, with the write+/// opt-in FULLY granted — so the server's refusal cannot be what stops it. The+/// host must refuse on its own, by the same rule, with the same code.+#[test]+fn the_com_hosts_own_veto_refuses_a_destination_it_cannot_stat() {+ use std::io::Write;+ use std::os::unix::fs::PermissionsExt;++ let dir = std::env::temp_dir().join(format!("swx-proto-dest-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ std::fs::create_dir_all(dir.join("share")).unwrap();+ // The user's file, behind a parent this process cannot traverse — what an+ // unreachable UNC share looks like to `Path::exists()`.+ std::fs::write(dir.join("share/user-part.SLDPRT"), b"the user's only copy").unwrap();+ std::fs::set_permissions(dir.join("share"), std::fs::Permissions::from_mode(0o000)).unwrap();+ let dest = dir.join("share/user-part.SLDPRT");+ assert!(!dest.exists(), "precondition: exists() says false for a file that is really there");++ let mut child = std::process::Command::new(common::BIN)+ .arg("--com-host")+ .env("SWX_MOCK", "1")+ .env("SWX_SCRATCH_DIR", &dir)+ // THE OPT-IN IS ON. Whatever refuses below is not the consent gate.+ .env("SWX_ALLOW_USER_WRITES", "1")+ .stdin(std::process::Stdio::piped())+ .stdout(std::process::Stdio::piped())+ .spawn()+ .expect("spawn com host");+ let mut stdin = child.stdin.take().unwrap();+ let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());+ {+ let mut line = String::new();+ std::io::BufRead::read_line(&mut stdout, &mut line).unwrap();+ }+ writeln!(stdin, r#"{{"id":1,"cmd":"start","args":{{"launch":true}}}}"#).unwrap();+ let _ = common::read_line_json(&mut stdout);+ writeln!(stdin, r#"{{"id":2,"cmd":"new_document","args":{{"type":"part"}}}}"#).unwrap();+ let made = common::read_line_json(&mut stdout);+ let title = made["result"]["created"]["title"].as_str().unwrap_or("Part1").to_string();++ // A document the BRIDGE created, saved to a path the host cannot check, with+ // the full opt-in supplied.+ let req = serde_json::json!({+ "id": 3, "cmd": "save_as",+ "args": {+ "docName": title, "out": dest.to_string_lossy(), "copy": true,+ "allowUserWrites": true, "confirm": true,+ "reason": "the caller asked for this, in full"+ }+ });+ writeln!(stdin, "{req}").unwrap();+ let r = common::read_line_json(&mut stdout);+ let _ = std::fs::set_permissions(dir.join("share"), std::fs::Permissions::from_mode(0o755));++ assert_eq!(+ r["ok"], false,+ "the com host's OWN veto allowed a write to a destination it could not stat, with the \+ opt-in granted. The server refusing is not enough: the host is the layer that actually \+ issues the COM call, and it is the one that may not hold the user's network token: {r}"+ );+ assert_eq!(+ r["errorCode"], "destination_unverifiable",+ "...and it must refuse by the SAME rule and the SAME code as the server, or the two \+ layers are not enforcing one rule: {r}"+ );+ assert!(+ r["error"].as_str().unwrap_or("").contains("cannot check"),+ "...saying what it could not answer: {r}"+ );++ writeln!(stdin, r#"{{"id":0,"cmd":"__exit__"}}"#).unwrap();+ let _ = child.wait();+ let _ = std::fs::remove_dir_all(&dir);+}++/// ADVERSARY PASS 12, sweep item. The com host and the server are SEPARATE+/// BINARIES and can be a mismatched pair — a half-applied update, a stale exe in+/// a cache directory. A field this server reads and that host never sends then+/// arrives as `false`, and one of those fields is `closeOutcomeUnknown`, where a+/// dropped `true` becomes "the outcome was determined; nobody needs to look".+///+/// So the handshake carries a version, and a skew is an explicit error.+#[test]+fn the_ready_handshake_carries_a_version_so_a_skew_cannot_be_silent() {+ use std::io::Write;+ let dir = std::env::temp_dir().join(format!("swx-proto-ver-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ std::fs::create_dir_all(&dir).unwrap();++ let mut child = std::process::Command::new(common::BIN)+ .arg("--com-host")+ .env("SWX_MOCK", "1")+ .env("SWX_SCRATCH_DIR", &dir)+ .stdin(std::process::Stdio::piped())+ .stdout(std::process::Stdio::piped())+ .spawn()+ .expect("spawn com host");+ let mut stdin = child.stdin.take().unwrap();+ let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());+ // RAW LINES until the event. `common::read_line_json` deliberately SKIPS+ // event lines — and the event line is exactly what this test is about — so+ // using it here waits forever. Bounded, so a host that never says `ready`+ // FAILS this test instead of hanging it.+ let ready = {+ let mut found = serde_json::Value::Null;+ for _ in 0..20 {+ let mut line = String::new();+ if std::io::BufRead::read_line(&mut stdout, &mut line).unwrap_or(0) == 0 {+ break;+ }+ let v: serde_json::Value =+ serde_json::from_str(line.trim()).unwrap_or(serde_json::Value::Null);+ if v.get("event").is_some() {+ found = v;+ break;+ }+ }+ found+ };+ assert_eq!(ready["event"], "ready", "the first line is still the ready event: {ready}");+ assert_eq!(+ ready["version"].as_str(),+ Some(env!("CARGO_PKG_VERSION")),+ "the ready line must name the host's version, or the server cannot tell a matched pair \+ from one that will silently drop fields: {ready}"+ );+ writeln!(stdin, r#"{{"id":0,"cmd":"__exit__"}}"#).unwrap();+ let _ = child.wait();+ let _ = std::fs::remove_dir_all(&dir);+}
tests/quit.rsadded+507@@ -0,0 +1,577 @@+//! WORKSTREAM B — the graceful quit ladder (`swx_stop` v2).+//!+//! Design and evidence: `docs/QUIT-INVESTIGATION.md` §5. Every test drives a+//! simulated machine whose per-rung outcome is a written-down script+//! (`quit-script.json`), because the mock's old behaviour — rung (a) always+//! quits — made rungs (b)-(f) unreachable from any test, which is the fourth+//! time in this project that "the mock modelled the easy case" hid a defect.+//!+//! Success is measured by PROCESS CENSUS (the pid and its creation time absent),+//! never by a COM call: the whole reason v1 could not tell "declined" from+//! "quit, but pinned by our own pointer".++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// A simulated machine with a scripted ladder.+struct Sim {+ dir: TempDir,+}++impl Sim {+ fn new(tag: &str, script: Value) -> Sim {+ let dir = TempDir::new(tag);+ std::fs::write(dir.join("quit-script.json"), script.to_string()).unwrap();+ // The platform daemons and the per-session children. A graceful exit+ // reaps the children and leaves the daemons alone.+ std::fs::write(+ dir.join("preexisting.json"),+ json!([+ {"pid": 6076, "image": "3DEXPERIENCELauncher.exe", "ageSeconds": 30000},+ {"pid": 90552, "image": "3DEXPERIENCELauncherSysTray.exe", "ageSeconds": 30000},+ {"pid": 701, "image": "ENOPLMCSAClient.exe", "ageSeconds": 30000},+ {"pid": 702, "image": "EdmServerV6.exe", "ageSeconds": 30000},+ {"pid": 800, "image": "sldProcMon.exe", "ageSeconds": 500},+ {"pid": 801, "image": "swCefSubProc.exe", "ageSeconds": 500}+ ])+ .to_string(),+ )+ .unwrap();+ std::fs::write(+ dir.join("sldworks.json"),+ json!({"responding": true, "hwnd": 6622700, "privateMB": 900, "handles": 4000}).to_string(),+ )+ .unwrap();+ Sim { dir }+ }++ fn windows(self, rows: Value) -> Sim {+ std::fs::write(self.dir.join("windows.json"), rows.to_string()).unwrap();+ self+ }++ fn sldworks(self, v: Value) -> Sim {+ std::fs::write(self.dir.join("sldworks.json"), v.to_string()).unwrap();+ self+ }++ fn quit_happened(&self) -> bool {+ self.dir.join("quit.jsonl").exists()+ }++ fn boot(&self, extra: &[(&str, &str)]) -> Bridge {+ let mut env: Vec<(&str, &str)> = vec![+ ("SWX_LAUNCH_FAKE_DIR", Box::leak(self.dir.path().into_boxed_str())),+ ("SWX_ALLOW_USER_WRITES", "1"),+ // The ladder waits for the machine to settle after each rung; a test+ // does not need a real second and a half.+ ("SWX_QUIT_SETTLE_MS", "80"),+ // ...and does not need a real 30 seconds to watch a simulated+ // process fail to exit, either. The WAIT itself is the thing under+ // test in `an_exit_that_takes_seconds_is_a_quit_not_a_decline`, which+ // sets its own.+ ("SWX_QUIT_EXIT_WAIT_SECS", "3"),+ ];+ env.extend_from_slice(extra);+ Bridge::start_in(TempDir::new("quit-scratch"), &env)+ }+}++fn full_args(max_rung: &str) -> Value {+ json!({+ "confirm": true,+ "allowUserWrites": true,+ "reason": "the caller is finished with SOLIDWORKS",+ "maxRung": max_rung+ })+}++#[test]+fn rung_a_quits_a_clean_zero_document_session_and_stops_there() {+ let m = Sim::new("quit-a", json!({"a": "exited"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", full_args("f"));++ check!(b, "it quit", r["success"] == true && r["stopped"] == true, s(&r));+ check!(b, "on rung a", r["winningRung"] == "a", s(&r));+ check!(+ b,+ "THE LADDER STOPS AT THE FIRST RUNG THAT WORKED — no rung runs after success",+ r["rungsTried"].as_array().map(|a| a.len() == 1).unwrap_or(false),+ s(&r)+ );+ check!(b, "...and the win was measured by census, not by a COM answer", r["rungsTried"][0]["processGone"] == true, s(&r));+ check!(b, "the machine agrees a quit happened", m.quit_happened(), "quit.jsonl missing");+ // §2.6: the daemons SURVIVE by design, and that is consent.+ let survivors = r["rungsTried"][0]["expectedSurvivorsAlive"].as_array().cloned().unwrap_or_default();+ check!(b, "the platform daemons are still alive and that is not a failure", survivors.len() >= 4, s(&r["rungsTried"][0]));+ check!(+ b,+ "...and the per-session children were reaped",+ r["rungsTried"][0]["sessionChildrenStillAlive"] == json!([]),+ s(&r["rungsTried"][0])+ );+ check!(+ b,+ "the hint says the daemons surviving is by design",+ r["_hint"].as_str().unwrap_or("").contains("consent"),+ s(&r)+ );+}++#[test]+fn the_ladder_climbs_and_reports_every_rung_it_tried() {+ // (a) and (b) decline, (c) — the WM_CLOSE rung — quits.+ let m = Sim::new("quit-abc", json!({"a": "declined", "b": "declined", "c": "exited"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", full_args("f"));++ check!(b, "it quit on rung c", r["stopped"] == true && r["winningRung"] == "c", s(&r));+ let tried = r["rungsTried"].as_array().cloned().unwrap_or_default();+ check!(b, "three rungs are recorded, in order", tried.len() == 3, s(&r));+ check!(b, "a declined", tried[0]["rung"] == "a" && tried[0]["result"] == "declined" && tried[0]["processGone"] == false, s(&tried[0]));+ check!(b, "b declined", tried[1]["rung"] == "b" && tried[1]["result"] == "declined", s(&tried[1]));+ check!(b, "c quit", tried[2]["rung"] == "c" && tried[2]["result"] == "quit", s(&tried[2]));+ check!(+ b,+ "every rung names what it actually did",+ tried[0]["action"].as_str().unwrap_or("").contains("ExitApp")+ && tried[1]["action"].as_str().unwrap_or("").contains("2825")+ && tried[2]["action"].as_str().unwrap_or("").contains("WM_CLOSE"),+ s(&r)+ );+ check!(b, "...and the census is reported before and after", r["processCensusBefore"].is_array() && r["processCensusAfter"].is_array(), s(&r));+}++#[test]+fn the_default_is_pinned_at_rung_a_until_the_gate_has_run() {+ // Shipping the whole ladder armed on day one is the mistake crash recovery+ // avoided by shipping `off`.+ let m = Sim::new("quit-pinned", json!({"a": "declined", "b": "exited"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", json!({+ "confirm": true, "allowUserWrites": true, "reason": "quit please"+ }));+ check!(b, "it did not quit, because rung b was never tried", r["success"] == false, s(&r));+ check!(b, "only rung a ran", r["rungsTried"].as_array().map(|a| a.len() == 1).unwrap_or(false), s(&r));+ check!(b, "...and maxRung says so", r["maxRung"] == "a", s(&r));+ check!(b, "SOLIDWORKS is untouched", !m.quit_happened(), "quit.jsonl exists");+ check!(+ b,+ "the hint names the human gesture and never a kill",+ r["_hint"].as_str().unwrap_or("").contains("File ▸ Exit")+ && !r["_hint"].as_str().unwrap_or("").contains("swx_kill"),+ s(&r)+ );+}++#[test]+fn a_dialog_stops_the_ladder_and_is_never_answered() {+ // Rung (a) raises a save prompt. The ladder must STOP, report the title, and+ // close nothing — on 2026-09-02 closing a stuck modal through the window+ // manager took the whole application down.+ let m = Sim::new("quit-dialog", json!({"a": "dialog", "b": "exited"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", full_args("f"));++ check!(b, "the quit failed", r["success"] == false, s(&r));+ check!(+ b,+ "a save prompt gets its OWN code, because at a verified 0 documents it should be impossible",+ r["errorCode"] == "stop_blocked_by_save_prompt",+ s(&r)+ );+ check!(b, "the dialog is reported verbatim", r["blockingDialog"]["title"] == "Save Modified Documents", s(&r));+ check!(b, "the ladder stopped at that rung", r["rungsTried"].as_array().map(|a| a.len() == 1).unwrap_or(false), s(&r));+ check!(b, "rung b never ran, so nothing escalated past the dialog", !m.quit_happened(), "quit.jsonl exists");+ check!(+ b,+ "the hint says answering it is the user's decision",+ r["_hint"].as_str().unwrap_or("").to_lowercase().contains("their decision")+ && r["_hint"].as_str().unwrap_or("").to_lowercase().contains("never answers a dialog"),+ s(&r)+ );+}++#[test]+fn a_blocked_message_is_reported_as_blocked_not_declined() {+ // UIPI failures return SUCCESS, so an unchecked GetLastError would report a+ // quit that never happened. An access-denied is a fact about the machine's+ // integrity levels, not about SOLIDWORKS' willingness to quit, so the ladder+ // stops rather than trying the same blocked gesture another way.+ let m = Sim::new("quit-uipi", json!({"a": "declined", "b": "declined", "c": "uipiBlocked", "d": "exited"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", full_args("f"));+ let tried = r["rungsTried"].as_array().cloned().unwrap_or_default();+ check!(b, "the quit failed", r["success"] == false, s(&r));+ check!(b, "rung c is recorded as BLOCKED, not declined", tried[2]["result"] == "uipiBlocked", s(&tried[2]));+ check!(b, "and the ladder stopped there", tried.len() == 3, s(&r));+ check!(b, "rung d never ran", !m.quit_happened(), "quit.jsonl exists");+}++#[test]+fn a_session_with_open_documents_is_never_quit_even_with_force() {+ // THE REGRESSION TEST FOR THE LIVE POSTURE. v1 gated on the UNSAVED count,+ // and on 2026-09-08 five of Drew's clean documents were open with+ // unsavedDocuments:0 — so a bare swx_stop would have tried to quit them.+ let m = Sim::new("quit-opendocs", json!({"a": "exited"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ for _ in 0..5 {+ b.call("swx_new_document", json!({"type": "part"}));+ }+ let mut args = full_args("f");+ args["force"] = json!(true);+ let r = b.call("swx_stop", args);+ check!(b, "it refuses", r["success"] == false, s(&r));+ check!(b, "with the open-document code, distinct from unsaved", r["errorCode"] == "open_documents_present", s(&r));+ check!(b, "no rung ran at all", r["rungsTried"] == json!([]), s(&r));+ check!(b, "SOLIDWORKS is untouched", !m.quit_happened(), "quit.jsonl exists");+ check!(+ b,+ "...and it says force does not waive this",+ r["_hint"].as_str().unwrap_or("").contains("force` does not"),+ s(&r)+ );+ // The refusal is RECORDED. v1 wrote no ledger line at its front gate.+ let led = b.call("swx_action_ledger", json!({"limit": 50}));+ check!(b, "a refusal is in the ledger", s(&led).contains("solidworks_quit_refused"), s(&led));+}++#[test]+fn a_precondition_that_cannot_be_measured_refuses_and_never_proceeds() {+ // The window census is unreadable, so "is a dialog up" has no answer — and a+ // quit sent at a modal is a different act with a different outcome.+ let m = Sim::new("quit-blind", json!({"a": "exited"}));+ std::fs::write(m.dir.join("window-census-fails"), "").unwrap();+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", full_args("f"));+ check!(b, "it refuses", r["success"] == false, s(&r));+ check!(b, "with one branchable unknown code", r["errorCode"] == "stop_preconditions_unknown", s(&r));+ check!(b, "naming what could not be measured", r["error"].as_str().unwrap_or("").contains("dialog"), s(&r));+ check!(b, "no rung ran", r["rungsTried"] == json!([]), s(&r));+ check!(b, "SOLIDWORKS is untouched", !m.quit_happened(), "quit.jsonl exists");+}++#[test]+fn a_not_responding_session_is_never_asked_to_quit() {+ // A quit request cannot be delivered to a message loop that is not running,+ // and the window rungs aimed at a wedged frame are exactly how a half-closed+ // orphan is made.+ let m = Sim::new("quit-hung", json!({"a": "exited"}))+ .sldworks(json!({"responding": false, "hwnd": 6622700, "privateMB": 17900}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", full_args("f"));+ check!(b, "it refuses", r["success"] == false && r["errorCode"] == "host_app_hung", s(&r));+ check!(b, "no rung ran", r["rungsTried"] == json!([]), s(&r));+ check!(b, "SOLIDWORKS is untouched", !m.quit_happened(), "quit.jsonl exists");+}++#[test]+fn the_restart_manager_rung_is_opt_in_only() {+ // ENDSESSION_CLOSEAPP means "save automatically without prompting", so rung+ // (f) is the one rung whose failure mode is a silent write rather than a+ // dialog we can stop on. It is never taken implicitly.+ let m = Sim::new("quit-rm", json!({+ "a": "declined", "b": "declined", "c": "declined", "d": "declined", "e": "declined",+ "f": "exited"+ }));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));++ let r = b.call("swx_stop", full_args("f"));+ let tried = r["rungsTried"].as_array().cloned().unwrap_or_default();+ check!(b, "without the opt-in it does not quit", r["success"] == false, s(&r));+ check!(b, "...and rung f is recorded as SKIPPED, not declined", tried[5]["result"] == "skipped", s(&tried[5]));+ check!(+ b,+ "...saying it needs allowRestartManager:true",+ tried[5]["detail"].as_str().unwrap_or("").contains("allowRestartManager"),+ s(&tried[5])+ );+ check!(b, "SOLIDWORKS is untouched", !m.quit_happened(), "quit.jsonl exists");++ // WITH the opt-in it runs, and it is the last rung.+ let mut args = full_args("f");+ args["allowRestartManager"] = json!(true);+ let r2 = b.call("swx_stop", args);+ check!(b, "with the opt-in, rung f quits it", r2["stopped"] == true && r2["winningRung"] == "f", s(&r2));+ check!(b, "having tried all six", r2["rungsTried"].as_array().map(|a| a.len() == 6).unwrap_or(false), s(&r2));+}++#[test]+fn a_quit_that_did_not_quit_is_a_failure_with_every_rung_measured() {+ let m = Sim::new("quit-declined", json!({+ "a": "declined", "b": "declined", "c": "declined", "d": "declined", "e": "declined"+ }));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", full_args("e"));+ check!(b, "it is a FAILURE, not a quiet success", r["success"] == false, s(&r));+ check!(b, "with the declined code", r["errorCode"] == "host_app_stop_declined", s(&r));+ check!(b, "winningRung is null", r["winningRung"].is_null(), s(&r));+ let tried = r["rungsTried"].as_array().cloned().unwrap_or_default();+ check!(b, "all five rungs measured", tried.len() == 5, s(&r));+ for t in &tried {+ check!(b, "each rung reports processGone explicitly", t["processGone"] == false, s(t));+ check!(b, "...and how long it took", t["elapsedSec"].is_number(), s(t));+ }+ check!(b, "elapsed is reported for the whole attempt", r["elapsedSeconds"].is_number(), s(&r));+}++#[test]+fn nothing_is_disowned_until_the_process_is_verifiably_gone() {+ // v1 cleared the created-document registry BEFORE it verified, so a declined+ // quit left the bridge unable to touch its own documents.+ let m = Sim::new("quit-disown", json!({"a": "declined"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let made = b.call("swx_new_document", json!({"type": "part"}));+ let title = made["created"]["title"].as_str().unwrap_or("Part1").to_string();++ // The quit is refused (documents are open) — and even a rung-level decline+ // must not disown anything.+ let mut args = full_args("a");+ args["force"] = json!(true);+ let r = b.call("swx_stop", args);+ check!(b, "the quit did not happen", r["stopped"] != json!(true), s(&r));++ // The bridge still owns its document.+ let w = b.call("swx_props_set", json!({"targetDocument": title, "properties": {"Owner": "bridge"}}));+ check!(b, "the bridge still owns what it created", w["success"] == true, s(&w));+}++#[test]+fn nothing_in_the_stop_path_ever_kills_anything() {+ // A BEHAVIOURAL assertion: across a full declined ladder, nothing was+ // terminated and no expected survivor disappeared.+ let m = Sim::new("quit-nokill", json!({+ "a": "declined", "b": "declined", "c": "declined", "d": "declined", "e": "declined"+ }));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let mut args = full_args("f");+ args["allowRestartManager"] = json!(true);+ let r = b.call("swx_stop", args);+ check!(b, "it declined", r["success"] == false, s(&r));+ check!(+ b,+ "NOTHING was terminated — the launch path's kill log is empty",+ !m.dir.join("terminated.jsonl").exists(),+ "terminated.jsonl exists"+ );+ let after = r["processCensusAfter"].as_array().cloned().unwrap_or_default();+ for want in ["3DEXPERIENCELauncher.exe", "ENOPLMCSAClient.exe", "EdmServerV6.exe", "sldProcMon.exe"] {+ check!(+ b,+ "every process that was alive before is alive after",+ after.iter().any(|p| p["image"] == want),+ format!("{want} missing from {}", s(&r["processCensusAfter"]))+ );+ }+ // ...and the response never suggests a kill.+ let body = s(&r);+ check!(b, "the report never mentions swx_kill or taskkill", !body.contains("swx_kill") && !body.contains("taskkill"), body);+}++#[test]+fn the_verification_releases_every_reference_before_it_asks() {+ // An automation client's outstanding IDispatch is exactly what keeps a+ // SOLIDWORKS alive, so v1 could not tell "declined" from "quit, but pinned+ // by our own pointer" — it asked while holding one.+ let m = Sim::new("quit-refs", json!({"a": "declined"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", full_args("a"));+ let d = r["rungsTried"][0]["detail"].as_str().unwrap_or("").to_string();+ check!(+ b,+ "no rung reports outstanding references at its measurement",+ !d.contains("still outstanding"),+ s(&r["rungsTried"][0])+ );+}++// ════════════════════════════════════════════════════════════════════════════+// ADVERSARY PASS 7 — the once-swept precondition, and the one-sample exit+// ════════════════════════════════════════════════════════════════════════════++/// A REAL `ExitApp` RETURNS AND THE PROCESS THEN TAKES SECONDS TO GO — ten CEF+/// subprocesses, three out-of-process servers and a journal to close. v2 sampled+/// the census once, 1.5 s after the rung, and called anything still present+/// "declined" — and the ladder's answer to a decline is to ESCALATE, sending a+/// window message into an application that was already exiting.+///+/// So the measurement waits, at the settle interval, up to the rung's own+/// budget. `exitAfterMs` in the simulator is what makes that a tested path+/// rather than a comment: the census keeps reporting the process for the delay,+/// exactly as a real slow exit does.+#[test]+fn an_exit_that_takes_seconds_is_a_quit_not_a_decline() {+ let m = Sim::new("quit-slow", json!({"a": {"result": "exited", "exitAfterMs": 1200}}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", full_args("f"));+ check!(+ b,+ "a slow exit is measured as a QUIT on rung (a), not escalated past",+ r["stopped"] == true && r["winningRung"] == "a",+ s(&r)+ );+ let tried = r["rungsTried"].as_array().cloned().unwrap_or_default();+ check!(b, "...and no rung after it was sent", tried.len() == 1, s(&r));+ check!(+ b,+ "...measured over MORE than one census read (the first one saw it alive)",+ tried[0]["detail"].as_str().unwrap_or("").contains("census reads"),+ s(&tried[0])+ );+ check!(b, "...and the process really is gone", r["stillRunning"] == false, s(&r));+}++/// The three reap lists are only evidence if the census they came from could be+/// READ. Three empty lists otherwise say "everything was reaped, nothing+/// survived" — the most reassuring possible reading of no information at all.+#[test]+fn the_reap_lists_say_whether_the_census_was_readable() {+ let m = Sim::new("quit-censusflag", json!({"a": "exited"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", full_args("a"));+ let tried = r["rungsTried"].as_array().cloned().unwrap_or_default();+ check!(+ b,+ "every rung reports whether its census was readable",+ tried.iter().all(|t| t["censusReadable"].is_boolean()),+ s(&r)+ );+ check!(b, "...and here it was", tried[0]["censusReadable"] == true, s(&tried[0]));+}++/// The dialog census must cover the pids a 3DEXPERIENCE modal can live in: a+/// sign-in / licence / cloud-save window is a WEB VIEW, and its hwnd belongs to+/// `swCefSubProc.exe` — a child of `sldworks.exe` with its own pid.+#[test]+fn the_dialog_census_covers_the_web_view_processes_too() {+ let m = Sim::new("quit-cef", json!({"a": "exited"}));+ // The simulated session's OWN children — parented to the simulated+ // SOLIDWORKS, whose pid no fixture can know in advance.+ std::fs::write(+ m.dir.join("sldworks-children.json"),+ json!(["swCefSubProc.exe", "swCefSubProc.exe", "sldProcMon.exe"]).to_string(),+ )+ .unwrap();+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", full_args("a"));+ let pids = r["preconditions"]["dialogCensusPids"].as_array().cloned().unwrap_or_default();+ check!(+ b,+ "the census names more than the SOLIDWORKS pid — the CEF child is in it",+ pids.len() >= 2,+ s(&r["preconditions"])+ );+}++/// THE PER-RUNG RE-SWEEP IS NOT DECORATION.+///+/// `swx_stop` re-measures every precondition before every rung, and the launch+/// claim check is part of that sweep. Nothing could demonstrate it: the+/// simulator's attached pid was fixed for the life of a run, so the FRONT GATE+/// would always be what caught a mismatch and the per-rung call site could have+/// been deleted without a single test noticing.+///+/// `mock-com-pid` hands out a different pid on each read. Here the session is the+/// one the launch record claims when rung (a) runs, and a DIFFERENT one by the+/// time rung (b) would — which is the situation the re-sweep exists for: an+/// attribution true at rung (a) is not automatically true at rung (b).+#[test]+fn the_session_changing_between_rungs_is_caught_by_the_per_rung_sweep() {+ // Rung (a) declines, so the ladder would go on to rung (b).+ let m = Sim::new("rung-identity", json!({"a": "declined", "b": "exited"}));+ // A FIXED identity for the session the claim is about, so the fixture can+ // name it up front: the pid the mock reports and the creation time the+ // census gives it.