app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
← Commit history
0.9.5 source: adversary passes 42+43 folded (gated by default, log bound to session, a window's words count); manifest as published
28 files changed
+1889−165
BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+1−1README.md+28−1SKILL.md+3−2bridge.json+1−1docs/ECOSYSTEM-TABLE.md+3−3docs/LAUNCH-RESILIENCE.md+50docs/QUIT-INVESTIGATION.md+90−3package.json+1−1page.json+1−7references/closing-and-recovery.md+5−3references/launch-and-sign-in.md+23−6src/comhost/com.rs+3src/comhost/mock.rs+29src/perflog.rs+151−17src/quit.rs+131−11src/readiness.rs+137−37src/session_history.rs+29−3src/verbs.rs+176−32tests/adversary40.rs+232tests/adversary41.rs+156tests/quit.rs+131tests/readiness.rs+265−4tests/wiring.rs+64tools/drain-gate.py+37−9tools/launch-gate.py+103−13tools/quit-gate.py+37−9BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.4+0.9.5
Cargo.lock+1−1@@ -107,7 +107,7 @@ dependencies = [ [[package]] name = "swx-bridge"-version = "0.9.4"+version = "0.9.5" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.4"+version = "0.9.5" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+28−1@@ -458,7 +458,34 @@ not. ## Roadmap -- v0.9.4 (this): **a notification is not a modal.** Measured 2026-10-01 on+- v0.9.5 (this): **the gates say why they failed.** 0.9.4 was LIVE-PROVEN on+ 2026-10-01: the notification rule (a quit in 5.1 s with the 3DEXPERIENCE notice+ on screen), priming (declined at 130 s, primed, quit in 3.4 s), and the full+ cycle (launch, ownership sweep, drain 5/5, quit gate exit 0). The age data+ refines the priming rule: never-had-a-document sessions quit unprimed at ~25 s+ and declined from 50 s to 8 min — measured ranges, recorded as such. The+ "transient" fast empty `shell_execute` failure was never transient (a curl to a+ dead port on 09-30; the launch gate's UIA probe on 10-01, every time): every+ gate script is now wrapped to print its own error text and native exit code,+ the UIA capture says why it has nothing and can no longer abort a gate whose+ claims passed, and `swx_status` exposes `solidworks.sessionHistory`+ (`everHadDocument` true/false/null with its basis) so a caller can predict a+ priming. Folded in, adversary pass 42: the startup gate is now the DEFAULT —+ every verb that reaches SOLIDWORKS waits unless it is on a pinned 16-verb+ exemption list (swx_show_*, the export verbs and swx_com_eval had been+ reachable during add-in load); the performance log only speaks for the session+ it was written in (a log older than the process is never "complete"), COM+ alone never decides on a machine whose log writes markers, and an add-in+ loaded on demand after the start no longer leaves a session "starting"+ forever; the grace unlocks READ verbs only; an authorized quit reaches the+ window-message rungs during startup; a non-zero document count at any poll+ records the session's history; and a prime whose close fails names the part+ to drain. And adversary pass 43: a window's WORDS now count when judging a+ dialog — a save, recovery or import prompt in its text blocks, two or more+ button-like captions drawn in any control class block (the measured template+ placeholders are not button-like), and measured notification evidence beats a+ licence-type title alone. 470 tests.+- v0.9.4: **a notification is not a modal.** Measured 2026-10-01 on hosted 0.9.3 (where an ab update was also measured to leave the data root byte-for-byte untouched, and readiness worked — with `StartupProcessCompleted` TRUE before the log marker, so both signals stay required): the 3DEXPERIENCE
SKILL.md+3−2@@ -65,8 +65,9 @@ BEFORE you do it: A cold start of the Connected edition takes 1-3 minutes; `stillLaunching:true` is not a failure, and calling `swx_start` again attaches instead of launching twice. ATTACHED IS NOT READY: document commands sent too early crashed- SOLIDWORKS (measured 2026-10-01), so document verbs refuse `host_app_starting`- until `solidworks.ready` is true — wait `retryAfterSeconds` and call again.+ SOLIDWORKS (measured 2026-10-01), so every verb that reaches it refuses+ `host_app_starting` until `solidworks.ready` is true — wait `retryAfterSeconds`+ and call again. `startingUnverifiable:true` means only reads may run. `stage:"helperSignIn"` means a person must sign in — only the user can. Never launch `sldworks.exe` or the launcher any other way. 7. **Name the document you mean.** Pass `targetDocument` (a title) or `path` on
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "solidworks", "displayName": "SOLIDWORKS",- "version": "0.9.4",+ "version": "0.9.5", "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",
docs/ECOSYSTEM-TABLE.md+3−3@@ -109,11 +109,11 @@ templates**, alisamsam **22**. | 41 | **COM retry-on-reject** (busy host: RPC_E_CALL_REJECTED / RETRYLATER backoff on every call) | ? | **✓ live 0.3.3** — 50-read smoke gate, 0 under-reporting, 11 rejects absorbed | ✗ | ✗ | ✗ | ✗ | ✗ | | 42 | **Real-work proof** (multi-assembly build + BOM exported from the assembly, verified against a packet) | ✓ (electronics) | **✓ live** — NSF crate: 5 assemblies, 383 placements, 32-row BOM, 28/28 part numbers match | ✗ | ✗ | ✗ | ✗ | ✗ | | 43 | **Import / resource lifecycle — no leaked temps, drainable, enumeration never wedges** | ✗ | **✓ 0.5.0** — enumeration degrades above a cap (`{count,truncated,sample}`, never a 30s timeout+kill); `swx_close_import_temps` drains STEP/IGES `…\Temp\swx<pid>\IC~~\…` temps in the DEFAULT posture (path is the ownership proof, survives a pid change); import asserts a net +1. Root-caused a live 1342-doc / 8 GB leak. **0.5.3**: the resolved `%TEMP%` is the WHOLE location proof (8.3-aware, no shape-test fallback), and with no resolved root the drain refuses (`temp_root_unknown`) instead of closing nothing quietly. **0.5.4**: the report says which root it compared against (`tempRoot`) and counts documents that carry the launcher's tail but sit elsewhere (`importTempsElsewhere` + a sample), so a drain that matched nothing can no longer claim it drained everything. **0.5.6**: the victim is CLOSED by the path it was chosen by (it was closed by TITLE, which `CloseDoc` resolves by name without prompting — a live session held temps sharing a saved file's title), a shared title is refused and reported, and every close is verified by path — the mismatch case is real, because ab's `%TEMP%` need not be the one SOLIDWORKS used | ✗ | ✗ | ✗ | ✗ | ✗ |-| 44 | **Graceful QUIT that is measured, not assumed** (never a kill, never answers a dialog, proves the exit from the process census) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (rung a)** — `swx_stop {userAuthorized, maxRung:"a"}` quit a bridge-launched session on the FIRST rung (UserControl=false; ExitApp; every COM reference released), verified absent from the process census; `swx_shutdown` then removed a stray empty instance (pid 145188) and `tasklist` showed no `sldworks.exe` at all. **`ExitApp` is NOT declined at zero documents on this edition** — the long-held `host_app_stop_declined` belief was a SAVE-PROMPT STALL misread as a refusal, which is why the ladder was built pinned at rung (a) and why the preserve-then-close sweep is what actually unblocked it. A rung ladder (a `ExitApp` → f Restart Manager *without* `RmForceShutdown`), pinned at rung (a); 7 re-measured preconditions where UNKNOWN refuses and `force` waives the UNSAVED question ONLY; every COM reference released before and after each rung, with outstanding refs named in the report; success = *this pid AND this creation time* absent from the census, never `GetActiveObject`; per-session children reaped while the documented platform daemons are left alone; a dialog stops the ladder permanently and is never answered; nothing disowned until the exit is verified. every precondition re-measured before EVERY rung (a mid-ladder refusal is a real outcome), the exit POLLED to the rung's budget rather than sampled once (a real `ExitApp` returns before the process goes), the dialog census covering the `swCefSubProc` web-view children by parentage, the count cross-checked against the enumeration, and the message rungs re-verifying window ownership before they send. `tools/quit-gate.py` proves the session was bridge-launched, and **its FULL CYCLE PASSED LIVE 2026-09-30 through the bridge ab hosts**: rung (a), process gone 29.5 s, per-session servers reaped, nothing on screen, no crash reporter, every rung reported, and SOLIDWORKS relaunched by the bridge 54.7 s later. The gate now reaches the bridge through ab's verb routing (the side-loaded dev copy on 24695 is terminated by ab 2.1.163 within 30 s) and passes `userAuthorized:true` — the standing authorization — rather than the write-gate pair, which the hosted bridge rightly never enables. **MEASURED 2026-10-01: the decline is about session HISTORY** — rung (a) was declined three times on a session that had never held a document and quit in 3.4 s once one had been created and closed; since 0.9.3 the ladder PRIMES such a session (empty, no dialog, user-authorized only) and asks again, and the gate's "no longer running" claim — which had passed vacuously after declines — now requires `running:false` and is self-tested | ✗ | ✗ | ✗ | ✗ | ✗ |+| 44 | **Graceful QUIT that is measured, not assumed** (never a kill, never answers a dialog, proves the exit from the process census) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (rung a)** — `swx_stop {userAuthorized, maxRung:"a"}` quit a bridge-launched session on the FIRST rung (UserControl=false; ExitApp; every COM reference released), verified absent from the process census; `swx_shutdown` then removed a stray empty instance (pid 145188) and `tasklist` showed no `sldworks.exe` at all. **`ExitApp` is NOT declined at zero documents on this edition** — the long-held `host_app_stop_declined` belief was a SAVE-PROMPT STALL misread as a refusal, which is why the ladder was built pinned at rung (a) and why the preserve-then-close sweep is what actually unblocked it. A rung ladder (a `ExitApp` → f Restart Manager *without* `RmForceShutdown`), pinned at rung (a); 7 re-measured preconditions where UNKNOWN refuses and `force` waives the UNSAVED question ONLY; every COM reference released before and after each rung, with outstanding refs named in the report; success = *this pid AND this creation time* absent from the census, never `GetActiveObject`; per-session children reaped while the documented platform daemons are left alone; a dialog stops the ladder permanently and is never answered; nothing disowned until the exit is verified. every precondition re-measured before EVERY rung (a mid-ladder refusal is a real outcome), the exit POLLED to the rung's budget rather than sampled once (a real `ExitApp` returns before the process goes), the dialog census covering the `swCefSubProc` web-view children by parentage, the count cross-checked against the enumeration, and the message rungs re-verifying window ownership before they send. `tools/quit-gate.py` proves the session was bridge-launched, and **its FULL CYCLE PASSED LIVE 2026-09-30 through the bridge ab hosts**: rung (a), process gone 29.5 s, per-session servers reaped, nothing on screen, no crash reporter, every rung reported, and SOLIDWORKS relaunched by the bridge 54.7 s later. The gate now reaches the bridge through ab's verb routing (the side-loaded dev copy on 24695 is terminated by ab 2.1.163 within 30 s) and passes `userAuthorized:true` — the standing authorization — rather than the write-gate pair, which the hosted bridge rightly never enables. **MEASURED 2026-10-01: the decline is about session HISTORY** — rung (a) was declined three times on a session that had never held a document and quit in 3.4 s once one had been created and closed; since 0.9.3 the ladder PRIMES such a session (empty, no dialog, user-authorized only) and asks again — **LIVE-PROVEN 2026-10-01 on 0.9.4**: declined at 130 s, primed, quit in 3.4 s (declines measured from 50 s to 8 min old; such sessions quit unprimed at ~25 s), and `swx_status` exposes the session's document history — and the gate's "no longer running" claim — which had passed vacuously after declines — now requires `running:false` and is self-tested | ✗ | ✗ | ✗ | ✗ | ✗ | | 48 | **A title collision between SAVED documents is impossible here — measured, not assumed** | ✗ | **✓ MEASURED LIVE 2026-09-17** — `tools/drain-gate.py --fixture new` copied a bridge-created document's own file to a second directory under the same basename and called `swx_open` on it. SOLIDWORKS REFUSED: `errors = 65536` (`swFileWithSameTitleAlreadyOpen`). So the drain's title-collision guard — which refuses to close any document whose title another open document shares, and names both paths — can only ever fire for PATHLESS documents, which is exactly the state identity-at-birth removes. The guard stays (a document the bridge did not create can still be pathless, and an older bridge's leftovers are), and the measurement is recorded next to it in `com.rs` so nobody "simplifies" it on the assumption that saved collisions are the common case. No MCP server measures this at all | ✗ | ✗ | ✗ | ✗ | ✗ |-| 51 | **Dialogs judged by MEASURED MODALITY, not by existence** (a notification never blocks; a modal always does) | ✗ | **✓ 0.9.4, from a blocker MEASURED 2026-10-01**: the 3DEXPERIENCE "Connected to …" notice — a title-less `#32770` tool window whose owner frame stayed ENABLED for 95 s, with no Button-class control — blocked every quit, sweep and import for minutes. Now a window blocks when its kind is known-blocking, its owner frame is disabled, or it has a Button-class control; it is a `notification` (reported with owner, ownerEnabled, styles, rect and child controls; never answered) only when all of that was measured; unread evidence fails closed. Template placeholder captions are flagged, never presented as the window's words. No MCP server looks at a dialog at all | ✗ | ✗ | ✗ | ✗ | ✗ |-| 50 | **Readiness gating — attached is not ready** (no document command reaches a SOLIDWORKS that has not finished starting) | ✗ | **✓ 0.9.3, from a crash MEASURED 2026-10-01**: `swx_new_document` 8 s after COM attach, `swx_open` at 9 s and `swx_close_all_safely` at 12 s crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (performance log: LOAD_ADDIN still running, SWEXH/UNHANDLED, the 3DEXPERIENCE marker only after the document commands, SWERR_REPORT); a control launch with no document commands crashed nothing. Now a READY stage follows `attached`, decided by two independent signals — COM `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker after the last LOAD_ADDIN — with an honest grace and timeout that never promote to ready; fifteen document and quit verbs refuse `host_app_starting` with `retryAfterSeconds` until then; a sweep during which SOLIDWORKS dies says `host_app_gone` with the log's crash markers. The rescue held under the crash (26,129 bytes verified). No MCP server distinguishes attached from ready at all | ✗ | ✗ | ✗ | ✗ | ✗ |+| 51 | **Dialogs judged by MEASURED MODALITY, not by existence** (a notification never blocks; a modal always does) | ✗ | **✓ LIVE-PROVEN 2026-10-01 on 0.9.4** — with the 3DEXPERIENCE "Connected" notice on screen, `swx_stop` quit in 5.1 s and reported it as a notification with its evidence. **0.9.4, from a blocker MEASURED 2026-10-01**: the 3DEXPERIENCE "Connected to …" notice — a title-less `#32770` tool window whose owner frame stayed ENABLED for 95 s, with no Button-class control — blocked every quit, sweep and import for minutes. Now a window blocks when its kind is known-blocking, its owner frame is disabled, or it has a Button-class control; it is a `notification` (reported with owner, ownerEnabled, styles, rect and child controls; never answered) only when all of that was measured; unread evidence fails closed. Template placeholder captions are flagged, never presented as the window's words. **0.9.5 (adversary pass 43): the window's WORDS count — a save/recovery/import kind in its text blocks whatever the evidence, two or more button-like captions in any control class block, and measured notification evidence overrides a licence-type title alone.** No MCP server looks at a dialog at all | ✗ | ✗ | ✗ | ✗ | ✗ |+| 50 | **Readiness gating — attached is not ready** (no document command reaches a SOLIDWORKS that has not finished starting) | ✗ | **✓ 0.9.3, from a crash MEASURED 2026-10-01**: `swx_new_document` 8 s after COM attach, `swx_open` at 9 s and `swx_close_all_safely` at 12 s crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (performance log: LOAD_ADDIN still running, SWEXH/UNHANDLED, the 3DEXPERIENCE marker only after the document commands, SWERR_REPORT); a control launch with no document commands crashed nothing. Now a READY stage follows `attached`, decided by two independent signals — COM `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker after the last LOAD_ADDIN — with an honest grace and timeout that never promote to ready; every verb that reaches SOLIDWORKS refuses `host_app_starting` with `retryAfterSeconds` until then — **0.9.5: gated by default with a pinned 16-verb exemption list, the performance log bound to this session's process creation time (an earlier session's log is never "complete"), COM alone never enough on a machine that writes markers, an on-demand add-in told from a start by quiet + which add-in, the grace unlocking reads only, and the window-message quit rungs ungated under userAuthorized**; a sweep during which SOLIDWORKS dies says `host_app_gone` with the log's crash markers. The rescue held under the crash (26,129 bytes verified). No MCP server distinguishes attached from ready at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 49 | **The user's rescued work survives the bridge's own updates — and ab's** (recovery never lives in a directory anything else owns) | ✗ | **✓ 0.9.1: the data root is `%LOCALAPPDATA%\adom-solidworks-bridge\`, a directory only this bridge writes to** — moved out of `Adom Bridge\` altogether after adversary pass 39 found 0.9.0's `Adom Bridge\solidworks-data` sitting inside the directory ab's installer and uninstaller own, whose fate on an ab uninstall or reinstall is documented nowhere; both earlier locations are migrated oldest first, every copy recorded by (root, path, size, mtime, sha256) so a legacy file that changes after its copy is copied again BESIDE it and reported `changedSinceCopy`, never settled by name — and since 0.9.2 a small file whose size and mtime match is hashed too, and two versions of the ledger or the ownership registry are MERGED (union, both versions kept beside) rather than one picked; a temp-directory fallback makes every sweep refuse (`data_root_is_temp`), and an unwritable recovery folder is refused once before the first save (`recovery_dir_unwritable`). **0.9.0, from a SEV-1 MEASURED 2026-09-30**: `bridges-cache\solidworks` was found CREATED 2026-09-18 09:42 by ab's auto-install of 0.8.9 — the updater re-extracts the whole directory, and every recovery folder from the day before, the ledger and the ownership registry went with it (test documents only; nothing of Drew's was lost). The data root is now `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`, a sibling of the install cache; the first start MIGRATES what an older bridge left (copied under a temporary name and renamed into place once verified byte for byte — so a start that dies mid-copy never leaves a truncated rescue under the real name — never over an existing file, originals left in place, each file once, a copy that does not verify removed and retried next start); `swx_recover_list` still reads the old location and marks each rescue there `atRiskFromUpdate`, saying whether a verified copy exists — and in the data root calls a file whose copy has not landed `notCopiedYet` (pointing at its only copy), never "gone"; and a sweep REFUSES (`recovery_inside_install_dir`, nothing written, nothing closed) if its recovery folder would land under the install directory or the install cache — however the path is spelled (links, `..`, case). Every rule mutation-verified. No MCP server keeps a recovery folder at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 47 | **Interconnect-aware import: refuses honestly, with the reason and the setting** | ✗ | **✓ MEASURED + shipped 0.8.2** — on SOLIDWORKS Connected 2026 with `swMultiCAD_Enable3DInterconnect` TRUE there is NO programmatic neutral-file import route at all: LoadFile4 returns nothing (`errors=1`) both with `Nothing` and with a real `GetImportFileData` object, LoadFile2 returns false, OpenDoc6 answers `swFileRequiresRepairError` (2097152) across four option values and `swInvalidFileTypeError` (1024) for docType 0. So `swx_import` makes ONE LoadFile4 attempt (a future edition that accepts it simply works) and then fails with `interconnect_blocks_import`, naming the exact setting — Tools > Options > System Options > Import > Enable 3D Interconnect — the measurement date, and the fact that **this bridge does not change user settings, not even temporarily**. No MCP server reads that toggle at all: they report a translator failure, which sends the reader hunting a missing translator that is not the problem | ✗ | ✗ | ✗ | ✗ | ✗ | | 46 | **3D Interconnect is understood, obeyed and reported** (the user's own setting decides how a neutral file comes in; the bridge reads it and never writes it) | ✗ | **✓ LIVE-MEASURED 2026-09-17, routed in 0.8.0** — `GetUserPreferenceToggle(691)` = TRUE on the install, so a STEP is not translated at all: `LoadFile4` executes and returns nothing with `errors=1`, and SOLIDWORKS instead OPENS the file with `OpenDoc6(path, swDocPART, …)` as a part holding a LINKED 3D Interconnect feature. `swx_import` reads the toggle, routes accordingly, and reports `interconnect` / `linked` / `sourcePath` so a caller knows the part is not self-contained. The preference is NEVER written — not even flip-and-restore, because a crash between the halves would leave the user's CAD configured differently than they left it — and `dissolve:true` is REFUSED until the link-breaking call is measured on this edition. Import-diagnostics auto-run (toggle 291) is also TRUE, so the modal dialog it raises is classified, reported as `stoppedByDialog`, and never answered. Toggle ids read from the installed `SolidWorks.Interop.swconst.dll` | ✗ | ✗ | ✗ | ✗ | ✗ |
docs/LAUNCH-RESILIENCE.md+50@@ -1081,6 +1081,28 @@ marker appears; whether `LOAD_ADDIN`-then-marker holds on every start (the log format is undocumented — this is a heuristic read of one machine's log). The launch gate now waits for READY before any probe of its own. +**0.9.5 — the gate inverted, the log bound to the session (adversary pass 42).**++| | |+|---|---|+| Gated by default | Every verb waits for readiness unless it is on `STARTUP_EXEMPT` (16 verbs, each with its reason in `src/verbs.rs`: the status/start/describe probes, the bridge-local and disk verbs, the window-census and window-message verbs, the screenshot, the badge, and `swx_kill`). The 15-name gate list had left `swx_show_*` (an `open`), `swx_export` and the ten `swx_export_*` (`SaveAs3` — the crash log's `CMD:DocSave`), `swx_com_eval`, `swx_set_parameter`, `swx_props_set`, `swx_rebuild_check`, `swx_bom`, `swx_pack_and_go`, `swx_activate_document` and `swx_set_preference` reachable during add-in load. The gate also moved BEFORE the handler table (several arms return directly). `tests/wiring.rs` pins the exemption list name by name; a new verb is gated until someone adds it there |+| The log speaks only for THIS session | A log last written before this process's creation time (census; the host's own word if there is no census) is `notThisSession` — never `complete`. Within a bound log the latest start is located by position (the format has no timestamps this bridge relies on) |+| COM alone is never enough on a machine that writes markers | Measured: `StartupProcessCompleted` was TRUE before the marker. Once a marker has been seen in this machine's log (remembered in the data root, `perflog-markers-seen`), a session with no marker of its own is `loading`, and an untied log makes the session `unverifiable` — COM's `true` does not decide. Only an edition whose log has never held a marker lets COM decide alone |+| An add-in loaded ON DEMAND | A `LOAD_ADDIN` after this session's marker is read as on-demand (ready) only when COM says the start completed, the log has been quiet for 60 s (`ON_DEMAND_QUIET_SECS`), and none of the `LOAD_ADDIN` lines after the marker names the 3DEXPERIENCE add-in (every start loads it — it writes the marker — so naming it means a start, possibly wedged). The verdict `onDemandAddInAfterComplete` carries this basis in `why` |+| The grace unlocks READS only | `state:"unverifiable"` + `readsAllowed:true` after 60 s: catalog-`read` verbs run; every other verb refuses `host_app_starting` with `startingUnverifiable:true` ("cannot verify startup completed") |+| Quit during startup | Under `userAuthorized`, `swx_stop` with `maxRung` c or d passes the gate with rungs a, b and e (COM) recorded `skipped`; (c) and (d) need neither COM nor readiness. Without that, the refusal names the way out (`windowMessageRungs`) |++**Residuals, stated:** (1) a session that has written a non-`LOAD_ADDIN` line to+the log but no `LOAD_ADDIN` yet would still show the previous session's marker+after the last `LOAD_ADDIN` — the mtime binding cannot see that, because the+lines carry no timestamps this bridge has measured; (2) the on-demand rule's+"names the 3DEXPERIENCE add-in" test assumes the `LOAD_ADDIN` line names the+add-in — if it does not, the rule degrades to quiet + COM; (3) the first start+on a machine whose log has never held a marker lets COM decide. The next live+run should capture: a `LOAD_ADDIN` line verbatim, whether a start writes a+header line before its first `LOAD_ADDIN`, and an on-demand Toolbox load's+lines.+ ## Live proof 4 — 0.9.3 on Drew's machine (2026-10-01) | | Measured |@@ -1121,3 +1143,31 @@ a tool window with an enabled owner and no Button-class control. Anything unread fails closed: blocking. A window whose readable captions are all template placeholders says `textIsTemplatePlaceholder:true` and does not present them as its words. `neverAnswer` is unchanged.++## Live proof 5 — 0.9.4 on Drew's machine (2026-10-01)++Forced with `refresh_bridges` (0.9.3 → 0.9.4).++| | Measured |+|---|---|+| Notification rule | with the 3DEXPERIENCE "Connected" notice on screen, `swx_stop` quit in 5.1 s and reported it as a `notification` (blocking false, the reason, `textIsTemplatePlaceholder`, the evidence) |+| Launch gate | 16 PASS — READY at 19.3 s; three `swx_start` after READY launched nothing. It still EXITED 3: its last, optional step (the UIA reply capture) failed every run — see below |+| Ownership sweep | PASS both halves (Part1 25,551 bytes verified and closed; Drew's part closed in place, bytesNow 32,311) |+| Drain gate | 5 / 5 |+| Quit gate | PASSED, exit 0 — quit 4.4 s, relaunch 17.2 s |+| Priming | proven live: declined at 130 s on a never-had-a-document session, primed, quit in 3.4 s |+| Age of a decline | never-had-a-document sessions quit unprimed at ~25 s (twice) and declined at 50 s, 130 s, 150 s, 8 min; platform load 24 s — QUIT-INVESTIGATION §5.17 |++**THE "TRANSIENT" WAS NEVER TRANSIENT — corrected.** The fast, empty `shell_execute`+failure (exit 1, empty stdout and stderr, ~260–300 ms) recorded on 09-30 and 10-01+as a transient was, measured: on 09-30, `curl.exe` against the dead loopback port+(curl exit 7 surfacing as an empty exit 1); on 10-01, the launch gate's UIA probe,+every time (four attempts in a row, and on both earlier runs) — its script curls+`$env:ADOM_DIRECT_API_URL`, which is not set in the shell `shell_execute` gives it.+0.9.5: every gate script runs inside `_wrap`, which prints `PS-ERROR:` lines for a+terminating error and, on a non-zero native exit, `PS-NATIVE-EXIT:` plus every+error PowerShell recorded, so empty stderr cannot hide a failure again (the+bounded retry stays only for a script that never ran); the UIA capture says why it+has nothing (no direct-API address in that shell, curl's exit and output, or the+exception) and, being optional and read-only, can no longer abort a gate whose+claims passed — a failure is a NOTE.
docs/QUIT-INVESTIGATION.md+90−3@@ -1858,9 +1858,13 @@ same 32,311 bytes and the same mtime after the sweep). See `adom-bridge --target adom_drew2 --ai-thread <thread> <verb> <json>`, and unwraps the routed reply (`result`, when present — ab merges top-level fields and adds an `output` string). `SWX_BRIDGE_URL` forces the old loopback route.- A fast `shell_execute` failure with no output at all (exit 1, under 5 s) is a- measured transient — the same command ran 5/5 clean in isolation — so it is- retried ONCE, and the retry is printed.+ A fast `shell_execute` failure with no output at all (exit 1, under 5 s) was+ called a transient here and retried. **CORRECTED 2026-10-01: it was never+ transient** — on 09-30 it was `curl.exe` against the dead loopback port (curl+ exit 7 surfacing as an empty exit 1), and on 10-01 it was the launch gate's UIA+ probe, every time. Since 0.9.5 every gate script is wrapped to print its own+ error text and native exit code, so an empty failure can only mean the script+ never ran; the bounded retry stays for that case alone. 2. **The grant.** The gate passed `allowUserWrites` + `confirm`, and was refused `user_writes_disabled` with attribution intact: the bridge ab hosts never carries `SWX_ALLOW_USER_WRITES`, and it should not — the write gate is not what@@ -1939,3 +1943,86 @@ measured; unknown = blocking. Every dialog report carries the evidence, and `tools/quit-gate.py` prints it in full when a window stops the ladder. The licence dialog of 2026-09-17 stays blocking BY KIND — its text and buttons were measured, its owner's enablement was not.++### 5.17 LIVE-PROVEN 2026-10-01 on 0.9.4 — notification, full cycle, priming, and the age data++* **The notification rule (§5.16), proven:** with the 3DEXPERIENCE "Connected"+ notice still on screen, `swx_stop` quit in 5.1 s and reported it as+ `{kind:"notification", blocking:false, why:"a tool window whose owner frame is+ ENABLED and which has no Button-class control", textIsTemplatePlaceholder:true,+ placeholderCaptions:[…], evidence:{…}}`.+* **The full cycle:** launch gate 16 PASS (READY at 19.3 s; three `swx_start`+ after READY launched nothing); ownership sweep PASS both halves (Part1 25,551+ bytes verified and closed; Drew's part closed in place, bytesNow 32,311); drain+ gate 5/5; QUIT GATE PASSED, exit 0 (quit 4.4 s, relaunch 17.2 s).+* **Priming (§5.15), proven:** a bridge-launched session with NO documents, quit+ requested at 130 s old → `rungsTried: [{rung a, declined, processGone false,+ primed false, 30.7 s}, {rung a, quit, processGone true, primed true, 3.4 s, "after+ PRIMING the session (one bridge-owned scratch part created and closed:+ 'Part1-1790875316002.SLDPRT')"}]`.+* **The age data, which refines the rule.** Never-had-a-document sessions QUIT+ WITHOUT priming at ~25 s old (twice — the quit gate's relaunch on 0.9.3 and on+ 0.9.4, 2.9–3.6 s) and DECLINED at 50 s, 130 s, 150 s and 8 min old. The+ platform load that session logged was `<3DXLOAD_TIME>24000`. So, as MEASURED+ RANGES and not as a single cause: a decline needs BOTH no document history AND+ a session past roughly its 3DEXPERIENCE platform connection (declines seen from+ 50 s; clean quits seen at ~25 s; nothing measured in between). Priming answers+ the measured case; `swx_status` now exposes `solidworks.sessionHistory`+ (`everHadDocument` true/false/null with its `basis`) so a caller can predict it.++### 5.18 0.9.5 — priming's other end, and quitting a session that has not finished starting (adversary pass 42)++* **A session that has held a document is never primed** — both ends are now+ pinned (`quit::a_session_that_has_held_a_document_is_never_primed`): the bridge+ RECORDS a document it saw (a verb that made one, a reply that shows one, and+ — new — a non-zero `GetDocumentCount` at any readiness poll, which every gated+ verb and `swx_status` make), and `prime_session` READS it. Removing either the+ guard or the recording fails that test. `everHadDocument:false` is worded+ "none seen by this bridge": a document opened and closed between two polls+ would not be seen.+* **A prime whose close fails** says the bridge's own scratch part is still+ open, gives its path, and says to drain it (`swx_reset_workspace` closes only+ bridge-created documents) — not just "not empty afterwards".+* **The quit during startup.** `swx_stop` is gated like every verb that reaches+ COM. The window-message rungs (c) WM_CLOSE and (d) SC_CLOSE need neither COM+ nor a finished start, so under `userAuthorized:true` a plan reaching them runs+ during startup with rungs (a), (b) and (e) recorded `skipped` ("this COM rung+ was not sent"). The precondition sweep still reads the document count (a read)+ and the window census; priming, which creates a document, is on rung (a) only+ and so never runs during startup. Without that authorization and plan, the+ refusal carries `windowMessageRungs` naming the way out.++### 5.19 0.9.5 — a window's WORDS count, and a licence-type title alone does not (adversary pass 43)++* **W1 (sev-2).** `judge_window` read a window's text and never used it: a tool+ window with an enabled owner whose body says "Do you want to save changes to+ Part1.SLDPRT?" with Save / Don't Save / Cancel drawn as Static (SOLIDWORKS+ draws many controls itself, as the Connected notice draws its text) was judged+ a notification. Now the kind classifier runs over the body text and button+ captions as well as the title — a save, recovery or import-diagnostics kind+ from EITHER blocks, and no evidence overrides it — and two or more+ button-like captions (Save, Don't Save, Cancel, Yes, No, OK, Retry, Abort,+ Ignore, Continue, Close, …; whole caption, case-insensitive, any control+ class, visible or hidden) mean something is on offer: blocking. The measured+ TEMPLATE PLACEHOLDERS ("Button1", "Button2", "Hyperlink Text 1/2", "Title",+ "Description") are explicitly not button-like, so the measured notice stays a+ notification. Both are rows in the cases table+ (`quit::tests::windows_are_judged_by_measured_modality`).+* **W2 (sev-3).** The title check ran before modality, and anything titled+ "…3DEXPERIENCE…", "…licen…" or "…activat…" classified `license`, which always+ blocked — so the Connected notice WITH a title would block every quit again.+ Measured notification evidence (tool window + owner enabled + controls read ++ no Button class + fewer than two button-like captions) now overrides a+ title-only licence-type match. It never overrides save, recovery or import+ kinds; other title kinds still block by title; the real License Manager still+ blocks on its Button-class OK.+* **Pinned, newly:** a HIDDEN Button-class control still makes a window blocking+ (`tests/adversary41.rs`).+* **Open gaps, pre-dating 0.9.4, stated:** a modal whose class is not `#32770` is+ never judged (the census looks at dialogs only), and a prompt drawn INSIDE the+ main frame (PropertyManager, task pane) is a child window the census never+ sees. What bounds the damage: a sweep saves every document before closing+ anything, and `swx_stop` requires zero unsaved documents (P2; only `force`+ waives it), so ExitApp past a missed modeless question loses the question,+ not a document — and with unsaved work SOLIDWORKS raises its own MODAL save+ prompt, which disables the frame and is caught by the post-rung census.
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "solidworks-bridge",- "version": "0.9.4",+ "version": "0.9.5", "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."- }+ ] }
references/closing-and-recovery.md+5−3@@ -112,9 +112,11 @@ Read this before closing any document, quitting SOLIDWORKS, draining leaked impo second attempt in `rungsTried` says `primed:true`. Nothing is ever primed while a document is open. - **A NOTIFICATION does not stop anything.** A window is judged by measured- modality: it blocks when it is a known blocking kind, its owner frame is- disabled, or it offers a button; a tool window over an enabled frame with- nothing to click is a `notification` (`kind:"notification"`, `blocking:false`)+ modality and by its words: it blocks when its title or text names a save,+ recovery or import prompt, its owner frame is disabled, or it offers a choice+ (a Button control, or two or more captions like Save / Don't Save / Cancel in+ any control); a tool window over an enabled frame with nothing to click is a+ `notification` (`kind:"notification"`, `blocking:false`) — reported under `notifications` with its `evidence`, never answered, and the quit, sweep or import goes ahead. The 3DEXPERIENCE "Connected to …" notice is one (measured 2026-10-01). `textIsTemplatePlaceholder:true` means the captions
references/launch-and-sign-in.md+23−6@@ -70,12 +70,29 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE after COM attach crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising. `swx_start` now reports `stage:"starting"` (`stillStarting:true`) until SOLIDWORKS has finished starting, then `stage:"ready"`; `swx_status` says- `solidworks.ready` and what it is based on (`StartupProcessCompleted`, the- performance log, or a grace it names). Every document verb and the quit refuse- `host_app_starting` with `retryAfterSeconds` until then — wait that long and- call again; never try to get around it. `readinessTimedOut:true` means it has- been over five minutes: look at the user's screen (a dialog, a sign-in) rather- than forcing anything.+ `solidworks.ready` and what it is based on (`readiness.basedOn`,+ `readiness.performanceLog.verdict` and `why`, and the `rule`). EVERY verb+ that reaches SOLIDWORKS waits — the gate is the default since 0.9.5, and only+ a short list of verbs that never reach COM (status, start, describe, the disk+ and window verbs, kill) is exempt. They refuse `host_app_starting` with+ `retryAfterSeconds` until then — wait that long and call again; never try to+ get around it. `readinessTimedOut:true` means it has been over five minutes:+ look at the user's screen (a dialog, a sign-in) rather than forcing anything.+- **How ready is decided.** The performance log only speaks for THIS session+ (written since the process started); a log from an earlier session is+ `notThisSession`, never `complete`. COM's `StartupProcessCompleted` is true+ before the start finishes (measured), so it is never enough alone on a+ machine whose log writes 3DEXPERIENCE markers. An add-in loaded on demand+ after the start (a quiet log, an add-in other than 3DEXPERIENCE, COM true)+ does not make a started session "starting" again.+- **`state:"unverifiable"`** — nothing can verify the start. After the grace+ (`readsAllowed:true`) READ verbs run; anything that creates, opens, saves,+ closes or changes a document refuses with `startingUnverifiable:true`+ ("cannot verify startup completed"). The clock never authorises a document+ command.+- **Quitting during startup:** under `userAuthorized:true`, `swx_stop` with+ `maxRung:"c"` or `"d"` runs with the COM rungs skipped — the window-message+ rungs need neither COM nor a finished start. - `bridge_readiness` (ab-level, never spawns anything) answers "is SOLIDWORKS installed / is the bridge warm" — use it for what-do-I-have questions.
src/comhost/com.rs+3@@ -4250,6 +4250,9 @@ impl ComBackend { "running": true, "startupCompleted": sw.flag("StartupProcessCompleted", &[]), "processId": sw.int("GetProcessID", &[]),+ // One call that always answers; a non-zero count at any poll records+ // the session as having held a document (session_history).+ "documentCount": Self::document_count(&sw), })) }
src/comhost/mock.rs+29@@ -241,6 +241,18 @@ fn fake_dir() -> Option<std::path::PathBuf> { /// 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.+/// The simulated SOLIDWORKS's creation time, epoch seconds: the data root's+/// `mock-sldworks-created` if a test wrote one, else an hour ago, written once.+fn mock_created_at() -> u64 {+ let f = crate::scratch::root().join("mock-sldworks-created");+ if let Some(v) = std::fs::read_to_string(&f).ok().and_then(|t| t.trim().parse::<u64>().ok()) {+ return v;+ }+ let v = crate::launchos::epoch_secs().saturating_sub(3600);+ let _ = std::fs::write(&f, v.to_string());+ v+}+ fn fault(name: &str) -> bool { crate::scratch::root().join(name).exists() }@@ -2041,6 +2053,12 @@ impl MockBackend { "close_doc" => { self.require_running()?; let path = self.resolve(args)?;+ // A CLOSE THAT FAILS: the document stays open. What the quit+ // ladder's prime must report as "the bridge's own part is still+ // open; drain it" (adversary pass 42, S6).+ if fault("mock-closedoc-fails") {+ return Err(HostError::msg("CloseDoc left the document open (simulated)"));+ } let title = base_name(&path).to_string(); self.docs.retain(|d| d.path != path); if self.active.as_deref() == Some(path.as_str()) {@@ -3070,6 +3088,17 @@ impl MockBackend { // per-read sequence the quit tests count read by read, and the // readiness gate must not consume a read of it. "processId": if self.running { json!(self.session()) } else { Value::Null },+ // The poll that records a session as having held a document+ // (session_history), exactly as com.rs reports it.+ "documentCount": if self.running && !fault("mock-doccount-none") {+ json!(self.docs.len())+ } else {+ Value::Null+ },+ // The simulated process's creation time, for a host with no+ // process census (off Windows, no launch simulator): stable,+ // written once — an hour ago unless a test says otherwise.+ "processCreatedAt": if self.running { json!(mock_created_at()) } else { Value::Null }, })) }
src/perflog.rs+151−17@@ -61,28 +61,162 @@ fn tail() -> Option<String> { Some(String::from_utf8_lossy(&buf).to_string()) } -/// HAS THE CURRENT START FINISHED, by the log? `None` when the log cannot say:-/// there is no log, or it has never held a 3DEXPERIENCE marker (an edition that-/// does not write one, or a log too new to tell). `Some(true)` when a marker-/// sits after the last `LOAD_ADDIN` line — the add-ins of the latest start have-/// loaded and the platform has reported. `Some(false)` when the latest-/// `LOAD_ADDIN` line is newer than every marker: a start is still loading.-pub fn startup_complete() -> (Option<bool>, String) {+/// How long the log must have been QUIET before a `LOAD_ADDIN` after this+/// session's 3DEXPERIENCE marker is read as an add-in loaded ON DEMAND (a user+/// enabling Toolbox or Simulation later) rather than as startup still running.+pub const ON_DEMAND_QUIET_SECS: u64 = 60;++/// What the log says about the RUNNING session's start.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum LogVerdict {+ /// No log, or a log that has never held a 3DEXPERIENCE marker (an edition+ /// that does not write one): the log cannot judge, and is not used.+ Unavailable,+ /// The edition writes markers, but nothing ties the log to THIS session: no+ /// process creation time, or a log last written before the process started+ /// (adversary pass 42: a day-old log ending in a completed start read+ /// "complete" for a session that had not written a line). Not ready.+ NotThisSession,+ /// The latest `LOAD_ADDIN` is newer than the latest marker and the log is+ /// still being written: a start is loading. Not ready.+ Loading,+ /// A marker after the latest `LOAD_ADDIN`, in a log written since the+ /// process started. Ready, by the log.+ Complete,+ /// A marker, then a `LOAD_ADDIN`, then nothing for `ON_DEMAND_QUIET_SECS`:+ /// an add-in loaded on demand after the start completed (adversary pass 42).+ /// Ready, by the log — ONLY together with COM's StartupProcessCompleted.+ OnDemandAfterComplete,+}++impl LogVerdict {+ pub fn name(self) -> &'static str {+ match self {+ LogVerdict::Unavailable => "unavailable",+ LogVerdict::NotThisSession => "notThisSession",+ LogVerdict::Loading => "loading",+ LogVerdict::Complete => "complete",+ LogVerdict::OnDemandAfterComplete => "onDemandAddInAfterComplete",+ }+ }+}++/// HAS THIS SESSION'S START FINISHED, by the log? `session_start` is the+/// process creation time in epoch seconds — the bridge knows it from the process+/// census — and the log is only allowed to speak for the session if it was last+/// written at or after it. The log's lines carry no timestamps this bridge can+/// rely on, so within the log the latest start is located by position: its+/// `LOAD_ADDIN` lines, then its 3DEXPERIENCE marker. Measured 2026-10-01: COM's+/// StartupProcessCompleted was already TRUE before the marker of a real start, so+/// a log that cannot be tied to the session never lets COM decide alone.+pub fn startup_complete(session_start: Option<u64>) -> (LogVerdict, String) {+ let Some(path) = path() else {+ return (LogVerdict::Unavailable, "no SOLIDWORKS performance log to read".into());+ }; let Some(t) = tail() else {- return (None, "no SOLIDWORKS performance log to read".into());+ return (LogVerdict::Unavailable, format!("the performance log {} could not be read", path.display())); }; let last_marker = LOAD_MARKERS.iter().filter_map(|m| t.rfind(m).map(|i| (i, *m))).max();+ // DOES THIS MACHINE'S SOLIDWORKS WRITE MARKERS AT ALL? Once a marker has been+ // seen in this log it is remembered (data root), so a tail that has rolled+ // past every old marker — or a session that has not written its own yet —+ // never turns the log back into "no signal" and lets COM decide alone.+ if last_marker.is_some() {+ remember_markers_seen();+ }+ if last_marker.is_none() && !markers_seen_before() {+ return (+ LogVerdict::Unavailable,+ "the performance log has never held a 3DEXPERIENCE load marker on this machine, so it \+ cannot judge"+ .into(),+ );+ }+ let mtime = std::fs::metadata(&path)+ .and_then(|md| md.modified())+ .ok()+ .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())+ .map(|d| d.as_secs());+ let Some(start) = session_start else {+ return (+ LogVerdict::NotThisSession,+ "this SOLIDWORKS process's creation time is unknown, so the log cannot be tied to it".into(),+ );+ };+ match mtime {+ Some(mt) if mt >= start => {}+ Some(mt) => {+ return (+ LogVerdict::NotThisSession,+ format!(+ "the log was last written {}s BEFORE this SOLIDWORKS process started: it speaks for \+ an earlier session, not this one",+ start - mt+ ),+ )+ }+ None => return (LogVerdict::NotThisSession, "the log's modification time could not be read".into()),+ }+ let Some((m, name)) = last_marker else {+ return (+ LogVerdict::Loading,+ "this machine's SOLIDWORKS writes a 3DEXPERIENCE load marker, and none is in the log \+ since this session began writing to it"+ .into(),+ );+ }; let last_addin = t.rfind("LOAD_ADDIN");- match (last_marker, last_addin) {- (None, _) => (- None,- "the performance log holds no 3DEXPERIENCE load marker to judge by".into(),- ),- (Some((m, name)), Some(a)) if a > m => (- Some(false),- format!("add-ins are still loading: the last LOAD_ADDIN line is newer than the last {name}"),+ match last_addin {+ Some(a) if a > m => {+ let quiet = mtime.map(|mt| crate::launchos::epoch_secs().saturating_sub(mt)).unwrap_or(0);+ // A START loads the 3DEXPERIENCE add-in — it is what writes the marker+ // — so a LOAD_ADDIN for it after the last marker is a start in+ // progress, however quiet the log has gone (wedged behind a dialog).+ let after = &t[m..];+ let start_block = after+ .lines()+ .any(|l| l.contains("LOAD_ADDIN") && l.to_ascii_uppercase().contains("3DEXPERIENCE"));+ if quiet >= ON_DEMAND_QUIET_SECS && !start_block {+ (+ LogVerdict::OnDemandAfterComplete,+ format!(+ "a {name} marker, then LOAD_ADDIN line(s) not naming the 3DEXPERIENCE add-in, \+ then no log activity for {quiet}s (>= {ON_DEMAND_QUIET_SECS}s): an add-in \+ loaded on demand after the start completed — ready only if COM also says the \+ start completed"+ ),+ )+ } else {+ (+ LogVerdict::Loading,+ format!(+ "add-ins are still loading: the last LOAD_ADDIN is newer than the last {name}{}, \+ and the log was written {quiet}s ago",+ if start_block { " and names the 3DEXPERIENCE add-in (a start, not an on-demand load)" } else { "" }+ ),+ )+ }+ }+ _ => (+ LogVerdict::Complete,+ format!("{name} written after the last LOAD_ADDIN line, in a log written since this process started"), ),- (Some((_, name)), _) => (Some(true), format!("{name} written after the last LOAD_ADDIN line")),+ }+}++fn markers_flag() -> PathBuf {+ crate::scratch::root().join("perflog-markers-seen")+}++fn markers_seen_before() -> bool {+ markers_flag().exists()+}++fn remember_markers_seen() {+ let f = markers_flag();+ if !f.exists() {+ let _ = std::fs::create_dir_all(crate::scratch::root());+ let _ = std::fs::write(&f, crate::launchos::epoch_secs().to_string()); } }
src/quit.rs+131−11@@ -241,22 +241,61 @@ pub fn text_is_template_placeholder(c: &crate::launchos::DialogContents) -> bool /// every quit for minutes — and it was the 3DEXPERIENCE "Connected to …" /// NOTIFICATION: owner frame ENABLED for all 20 reads over 95 s, WS_EX_TOOLWINDOW, /// no Button-class child, custom-drawn. A window BLOCKS when:-/// * its kind (from its title) is one of the known blocking kinds — a save-/// prompt, recovery, licence, import diagnostics, …; or+/// * its TITLE or its TEXT says it is a save prompt, a recovery or an import+/// diagnostics prompt (never overridden by any evidence); or+/// * its title says another known blocking kind (update, settings, message,+/// export options) — a licence-type title alone is the exception (0.9.5,+/// adversary pass 43): measured notification evidence overrides it, because+/// the Connected notice with a title would block every quit and the real+/// License Manager blocks on its Button-class OK anyway; or /// * its owner frame is DISABLED — a real modal; or-/// * it has a Button-class child — something on offer.+/// * it has a Button-class child (hidden ones count) — something on offer; or+/// * it shows TWO OR MORE button-like captions (Save, Don't Save, Cancel, Yes,+/// No, OK, Retry, …) in any control class, visible or hidden — SOLIDWORKS+/// draws many controls itself. The measured template placeholders+/// ("Button1", "Hyperlink Text 1", "Title", …) are not button-like. /// It is a `notification` — reported, never answered, and NOT a reason to refuse /// a quit, a sweep or an import — only when ALL of these were MEASURED: it is a-/// tool window, its owner frame is enabled, and its controls were read and none-/// is a Button. Anything unread FAILS CLOSED: blocking.+/// tool window, its owner frame is enabled, its controls were read, none is a+/// Button and fewer than two captions offer a choice. Anything unread FAILS+/// CLOSED: blocking.+///+/// OPEN GAPS that pre-date 0.9.4 (named by adversary pass 43): a modal whose+/// class is not `#32770` is never judged at all, and a prompt drawn INSIDE the+/// main frame (PropertyManager, task pane) is a child window the census never+/// sees. What bounds the damage there: a sweep saves every document before it+/// closes anything, and swx_stop requires zero unsaved documents. pub fn judge_window( w: &WindowInfo, contents: Option<&crate::launchos::DialogContents>, ) -> (bool, &'static str, String) {- let (kind, _blocking, _never) = crate::abwin::classify(&w.title);- if kind != "unknown" {- return (true, kind, format!("a known blocking kind ({kind}) by its title"));+ let title_kind = crate::abwin::classify(&w.title).0;+ // THE WINDOW'S OWN WORDS (adversary pass 43, W1): the classifier runs over+ // the body text and button captions as well as the title. SOLIDWORKS titles+ // its prompts "SOLIDWORKS" and puts the question in the body.+ let body_kind = contents.and_then(|c| {+ c.text+ .iter()+ .chain(c.buttons.iter())+ .map(|t| crate::abwin::classify(t).0)+ .find(|k| *k != "unknown")+ });+ // A save, recovery or import prompt blocks whatever the evidence says — by+ // title OR by text. No measured modality overrides these.+ if NEVER_OVERRIDDEN_KINDS.contains(&title_kind) {+ return (true, title_kind, format!("a known blocking kind ({title_kind}) by its title"));+ }+ if let Some(k) = body_kind.filter(|k| NEVER_OVERRIDDEN_KINDS.contains(k)) {+ return (true, k, format!("a known blocking kind ({k}) by its text"));+ }+ // Any other title kind blocks by title — EXCEPT a licence-type title, which+ // measured notification evidence may override (W2): the 3DEXPERIENCE+ // "Connected" notice with a title would otherwise block every quit again,+ // and the real License Manager blocks on its Button-class OK regardless.+ if title_kind != "unknown" && title_kind != "license" {+ return (true, title_kind, format!("a known blocking kind ({title_kind}) by its title")); }+ let kind = title_kind; match w.owner_enabled { Some(false) => return (true, kind, "its owner frame is DISABLED: a modal".into()), None => return (true, kind, "whether its owner frame is enabled could not be read".into()),@@ -268,19 +307,72 @@ pub fn judge_window( if c.has_button_class_child() { return (true, kind, "it has a Button-class control: something on offer".into()); }+ let offered = button_like_captions(c);+ if offered.len() >= 2 {+ return (+ true,+ kind,+ format!(+ "it shows {} button-like captions ({}) — something is on offer, whatever class \+ draws them",+ offered.len(),+ offered.join(", ")+ ),+ );+ } match w.exstyle { Some(x) if x & crate::launchos::WS_EX_TOOLWINDOW != 0 => ( false, "notification",- "a tool window whose owner frame is ENABLED and which has no Button-class control: \- a notification, not a modal"- .into(),+ if kind == "license" {+ "a licence-type TITLE, but measured notification evidence overrides a title-only \+ guess: a tool window whose owner frame is ENABLED, controls read, no Button-class \+ control and no button-like captions"+ .into()+ } else {+ "a tool window whose owner frame is ENABLED and which has no Button-class control \+ and no button-like captions: a notification, not a modal"+ .into()+ }, ), Some(_) => (true, kind, "not a tool window: not provably a notification".into()), None => (true, kind, "its extended style could not be read".into()), } } +/// Kinds that block whatever the measured evidence says — a choice about the+/// user's work (save), their recovered work (recovery), or the geometry an+/// import produces.+pub const NEVER_OVERRIDDEN_KINDS: &[&str] = &["save_changes", "document_recovery", "import_diagnostics"];++/// Captions that offer a choice. Matched whole, case-insensitive, with `&`+/// accelerators and trailing dots removed. The measured TEMPLATE PLACEHOLDERS+/// ("Button1", "Button2", "Hyperlink Text 1/2", "Title", "Description") are NOT+/// button-like: they are what the Connected notice's template left behind.+pub const BUTTON_LIKE: &[&str] = &[+ "save", "don't save", "do not save", "dont save", "save all", "cancel", "yes", "no", "ok",+ "retry", "abort", "ignore", "continue", "close", "discard", "yes to all", "no to all",+];++/// The button-like captions among a window's text and buttons, any control+/// class, visible or hidden (the text read includes hidden controls).+pub fn button_like_captions(c: &crate::launchos::DialogContents) -> Vec<String> {+ let mut out: Vec<String> = Vec::new();+ for t in c.text.iter().chain(c.buttons.iter()) {+ let n = t+ .replace('&', "")+ .replace('\u{2019}', "'")+ .trim()+ .trim_end_matches(|ch| ch == '.' || ch == '\u{2026}')+ .trim()+ .to_lowercase();+ if BUTTON_LIKE.contains(&n.as_str()) && !out.iter().any(|o| o.to_lowercase() == n) {+ out.push(t.trim().to_string());+ }+ }+ out+}+ /// THE DIALOG CENSUS, JUDGED: every visible `#32770`, its contents read, each /// judged. `blocking` is the first that blocks; `notifications` are the ones /// that do not — reported, never answered.@@ -842,6 +934,14 @@ mod tests { let license = contents(&[("Static", true), ("Button", true)], &["Failed to check 1 license(s): …"], &["OK"]); let with_button = contents(&[("Static", true), ("Button", true)], &["Continue?"], &["OK"]); let modal_statics = contents(&[("Static", true)], &["Working…"], &[]);+ let custom_save = contents(+ &[("Static", true), ("Static", true), ("Static", true), ("Static", true)],+ &["Do you want to save changes to Part1.SLDPRT?", "Save", "Don't Save", "Cancel"],+ &[],+ );+ let two_choices = contents(&[("Static", true), ("Static", false), ("Static", false)], &["Proceed?", "&Yes", "No"], &[]);+ let recovered_text = contents(&[("Static", true)], &["SOLIDWORKS recovered 2 documents"], &[]);+ let one_close = contents(&[("Static", true), ("Static", true)], &["Connected", "Close"], &[]); type Row<'a> = (&'a str, &'a str, Option<bool>, Option<u32>, Option<&'a DialogContents>, bool, &'a str); let rows: Vec<Row> = vec![ ("the 3DEXPERIENCE connected notice (measured)", "", Some(true), Some(0x90080), Some(&connected), false, "notification"),@@ -857,6 +957,23 @@ mod tests { ("the licence dialog (blocking by kind; owner not measured)", "License Manager", None, None, Some(&license), true, "license"), ("the licence dialog even with an enabled owner", "License Manager", Some(true), Some(0x90080), Some(&license), true, "license"), ("a save prompt, by kind", "Save Modified Documents", Some(true), Some(0x90080), Some(&connected), true, "save_changes"),+ // 0.9.5, adversary pass 43 — the window's WORDS (not measured live:+ // constructed from the measured notice's evidence).+ ("a custom-drawn save prompt: notice evidence, the question in its text", "SOLIDWORKS", Some(true), Some(0x90080),+ Some(&custom_save), true, "save_changes"),+ ("a custom-drawn choice: notice evidence, two button-like Static captions", "", Some(true), Some(0x90080),+ Some(&two_choices), true, "unknown"),+ ("ONE button-like caption is not a choice", "", Some(true), Some(0x90080), Some(&one_close), false, "notification"),+ ("the measured notice WITH a licence-type title: evidence beats a title-only guess", "3DEXPERIENCE Platform",+ Some(true), Some(0x90080), Some(&connected), false, "notification"),+ ("...and the same title on a modal still blocks, as license", "3DEXPERIENCE Platform", Some(false), Some(0x90080),+ Some(&connected), true, "license"),+ ("a recovery prompt in its TEXT, notice evidence", "", Some(true), Some(0x90080),+ Some(&recovered_text), true, "document_recovery"),+ ("a recovery title is never overridden by evidence", "Document Recovery", Some(true), Some(0x90080),+ Some(&connected), true, "document_recovery"),+ ("an import-diagnostics title is never overridden by evidence", "Import Diagnostics", Some(true), Some(0x90080),+ Some(&connected), true, "import_diagnostics"), ]; for (label, title, owner_enabled, exstyle, c, want_blocking, want_kind) in rows { let (blocking, kind, why) = judge_window(&window(title, owner_enabled, exstyle), c);@@ -864,6 +981,9 @@ mod tests { } assert!(text_is_template_placeholder(&connected), "the template's captions are not the window's words"); assert!(!text_is_template_placeholder(&license));+ // The measured placeholders are NOT button-like: the notice stays a notice.+ assert!(button_like_captions(&connected).is_empty(), "template placeholders are not choices");+ assert_eq!(button_like_captions(&custom_save), vec!["Save", "Don't Save", "Cancel"]); } /// The signature that matters: everything measured, nothing waived.
src/readiness.rs+137−37@@ -11,18 +11,35 @@ //! //! Two signals, each independent of the other: //! * COM: `ISldWorks::StartupProcessCompleted` (host command `startup_state`);-//! * the performance log: a 3DEXPERIENCE load marker written after the last-//! `LOAD_ADDIN` line (`perflog::startup_complete`) — file-based, no COM.-//! READY = every available signal says so. MEASURED 2026-10-01 on hosted 0.9.3:+//! * the performance log, BOUND TO THIS SESSION (`perflog::startup_complete`):+//! only a log written at or after this process's creation time may speak for+//! it, and within it a 3DEXPERIENCE load marker after the last `LOAD_ADDIN`+//! line says the start finished — file-based, no COM.+//!+//! THE RULE (0.9.5, adversary pass 42). MEASURED 2026-10-01 on hosted 0.9.3: //! `StartupProcessCompleted` was already TRUE before the log's marker appeared, //! and the session became ready 3.4 s after attach when `<3DXINITERROR>` was-//! written after the last `LOAD_ADDIN` — so COM alone is NOT sufficient, and-//! requiring both is the rule. When NEITHER can be read, a grace of-//! `GRACE_SECS` after the bridge first saw this session stands in, and the-//! answer says so (`basedOn: ["graceAfterFirstSeen"]`) — never silently. A start-//! that does not become ready within `TIMEOUT_SECS` stays `starting`, with-//! `readinessTimedOut:true`: it is never promoted to ready by the clock.-//! Once a session (by process id) is ready it stays ready.+//! written after the last `LOAD_ADDIN`. So:+//! * log COMPLETE for this session, COM not false -> ready;+//! * log says a `LOAD_ADDIN` came after this session's marker and the log has+//! been quiet for `perflog::ON_DEMAND_QUIET_SECS` (an add-in a user loaded+//! ON DEMAND after the start), COM true -> ready;+//! * log LOADING -> starting;+//! * the edition writes no markers at all (log unavailable) -> COM decides;+//! * the edition writes markers but none ties to THIS session (a log from an+//! earlier session, or a creation time the bridge cannot learn) -> the log+//! is UNREADABLE for this session and COM alone is NEVER enough, because COM+//! answers true before the first marker is written.+//! With nothing that can verify the start, the session is `unverifiable`: after+//! `GRACE_SECS` READ verbs may run (`readsAllowed`), but a verb that creates,+//! opens, saves, closes or changes a document — or quits — keeps refusing+//! `host_app_starting`: the clock never authorises a document command. A start+//! that does not become ready within `TIMEOUT_SECS` is `readinessTimedOut`, still+//! starting. Once a session (by process id) is verified ready it stays ready.+//!+//! Every assessment also reads `GetDocumentCount` (in `startup_state`): a+//! non-zero count at ANY poll records the session as having held a document+//! (`session_history`). use serde_json::{json, Value}; use std::collections::HashMap;@@ -33,6 +50,15 @@ pub const GRACE_SECS: u64 = 60; pub const TIMEOUT_SECS: u64 = 300; pub const RETRY_AFTER_SECS: u64 = 5; +/// The grace, overridable (`SWX_READINESS_GRACE_SECS`) so the suite need not+/// wait a real minute. It only ever unlocks READ verbs.+fn grace_secs() -> u64 {+ std::env::var("SWX_READINESS_GRACE_SECS")+ .ok()+ .and_then(|v| v.trim().parse().ok())+ .unwrap_or(GRACE_SECS)+}+ struct Seen { first: Instant, ready: bool,@@ -42,7 +68,9 @@ static SESSIONS: Mutex<Option<HashMap<i64, Seen>>> = Mutex::new(None); /// The readiness of the running session, with what it is based on. `running` /// false (or a host that cannot answer) means there is no session to judge, and-/// `ready` is null.+/// `ready` is null. `ready` is true only when a signal VERIFIED the start;+/// `readsAllowed` is also true once the grace has run out on a session nothing+/// can verify. pub fn assess(ctx: &crate::verbs::Ctx) -> Value { let state = match ctx.call("startup_state", json!({}), 20) { Ok(s) => s,@@ -56,67 +84,136 @@ pub fn assess(ctx: &crate::verbs::Ctx) -> Value { } let pid = state.get("processId").and_then(|v| v.as_i64()); let com = state.get("startupCompleted").and_then(|v| v.as_bool());- let (log, log_why) = crate::perflog::startup_complete();+ // THIS PROCESS'S CREATION TIME, from the process census — the same identity+ // the quit ladder and the session history use. A host that cannot take a+ // census may report it itself (`processCreatedAt`).+ let created: Option<u64> = pid+ .and_then(|p| u32::try_from(p).ok())+ .and_then(|p| {+ crate::launchos::os()+ .processes()+ .ok()+ .and_then(|ps| ps.iter().find(|x| x.pid == p).and_then(|x| x.created_at))+ })+ .or_else(|| state.get("processCreatedAt").and_then(|v| v.as_u64()));+ // ANY POLL THAT SEES A DOCUMENT RECORDS IT (adversary pass 42, S5).+ if let (Some(p), Some(n)) = (+ pid.and_then(|p| u32::try_from(p).ok()),+ state.get("documentCount").and_then(|v| v.as_u64()),+ ) {+ if n > 0 {+ crate::session_history::mark(p, created);+ }+ }+ let (log, log_why) = crate::perflog::startup_complete(created);+ use crate::perflog::LogVerdict as L; let mut guard = SESSIONS.lock().unwrap_or_else(|e| e.into_inner()); let map = guard.get_or_insert_with(HashMap::new); let key = pid.unwrap_or(-1); let seen = map.entry(key).or_insert(Seen { first: Instant::now(), ready: false }); let age = seen.first.elapsed().as_secs(); let mut based_on: Vec<&str> = Vec::new();- let ready = if seen.ready && pid.is_some() {+ // Some(true) verified ready, Some(false) verified starting, None unverifiable.+ let verdict: Option<bool> = if seen.ready && pid.is_some() { based_on.push("alreadyReadyThisSession");- true+ Some(true) } else {- let mut verdicts: Vec<bool> = Vec::new();- if let Some(c) = com {+ if com.is_some() { based_on.push("StartupProcessCompleted");- verdicts.push(c); }- if let Some(l) = log {+ if log != L::Unavailable { based_on.push("performanceLog");- verdicts.push(l); }- if verdicts.is_empty() {- based_on.push("graceAfterFirstSeen");- age >= GRACE_SECS- } else {- verdicts.iter().all(|v| *v)+ match (log, com) {+ (_, Some(false)) => Some(false),+ (L::Loading, _) => Some(false),+ (L::Complete, _) => Some(true),+ (L::OnDemandAfterComplete, Some(true)) => Some(true),+ (L::OnDemandAfterComplete, None) => None,+ (L::Unavailable, Some(true)) => Some(true),+ (L::Unavailable, None) => None,+ // The edition writes markers, none is this session's: COM alone is+ // never enough (it answers true before the first marker).+ (L::NotThisSession, _) => None, } };+ let ready = verdict == Some(true); if ready && pid.is_some() { seen.ready = true; }+ let unverifiable = verdict.is_none();+ if unverifiable {+ based_on.push("graceAfterFirstSeen");+ }+ let reads_allowed = ready || (unverifiable && age >= grace_secs()); json!({ "running": true, "ready": ready,+ "state": if ready { "ready" } else if unverifiable { "unverifiable" } else { "starting" },+ "readsAllowed": reads_allowed, "processId": pid,+ "processCreatedAt": created, "basedOn": based_on, "startupProcessCompleted": com,- "performanceLog": {"complete": log, "why": log_why},+ "performanceLog": {"verdict": log.name(), "why": log_why},+ "rule": "ready = the performance log shows THIS session's start complete (COM not \+ false), or COM true with an on-demand add-in after the session's marker, or COM \+ true on an edition that writes no markers. COM alone is never enough while the \+ edition writes markers and none is this session's. Unverifiable sessions allow \+ read verbs after the grace; never document commands.", "secondsSinceFirstSeen": age, "readinessTimedOut": !ready && age >= TIMEOUT_SECS, }) } -/// THE GATE: no document-creating, -opening, -saving or -closing COM call — and-/// no quit — until SOLIDWORKS has finished starting. `None` lets the verb run:-/// SOLIDWORKS is ready, or not running at all (the verb then answers that for-/// itself), or the host could not say (the verb's own call meets the same-/// failure and reports it). `Some` is the refusal.-pub fn refuse_until_ready(ctx: &crate::verbs::Ctx, verb: &str) -> Option<Value> {+/// What a verb does to SOLIDWORKS, for the gate.+#[derive(Clone, Copy, PartialEq, Eq)]+pub enum Touch {+ /// Reads only (catalog risk `read`): may run once the grace has run out on a+ /// session nothing can verify.+ Read,+ /// Creates, opens, saves, closes, changes or quits: needs a VERIFIED start.+ Mutate,+}++/// THE GATE: no COM verb — and no quit — until SOLIDWORKS has finished+/// starting. `None` lets the verb run: SOLIDWORKS is ready (or, for a READ verb,+/// unverifiable past the grace), or not running at all (the verb then answers+/// that for itself), or the host could not say (the verb's own call meets the+/// same failure and reports it). `Some` is the refusal.+pub fn refuse_until_ready(ctx: &crate::verbs::Ctx, verb: &str, touch: Touch) -> Option<Value> { let r = assess(ctx); if r.get("ready").and_then(|v| v.as_bool()) != Some(false) { return None; }+ let unverifiable = r.get("state").and_then(|v| v.as_str()) == Some("unverifiable");+ let reads_allowed = r.get("readsAllowed").and_then(|v| v.as_bool()) == Some(true);+ if touch == Touch::Read && reads_allowed {+ return None;+ } let timed_out = r.get("readinessTimedOut").and_then(|v| v.as_bool()) == Some(true); let mut out = crate::verbs::err_code( "host_app_starting",- format!(- "{verb} refused: SOLIDWORKS is attached but has not finished starting (add-ins, the \- 3DEXPERIENCE platform load). Document commands sent in that window crashed \- SOLIDWORKS on 2026-10-01."- ),- if timed_out {+ if unverifiable {+ format!(+ "{verb} refused: the bridge cannot verify that SOLIDWORKS has finished starting \+ (no signal it can read ties a completed start to this session), and a verb that \+ touches documents is never sent on the clock alone. Document commands sent while \+ SOLIDWORKS was still starting crashed it on 2026-10-01."+ )+ } else {+ format!(+ "{verb} refused: SOLIDWORKS is attached but has not finished starting (add-ins, the \+ 3DEXPERIENCE platform load). Document commands sent in that window crashed \+ SOLIDWORKS on 2026-10-01."+ )+ },+ if unverifiable {+ "Cannot verify startup completed. Read verbs run once `readiness.readsAllowed` is true \+ (the grace); this one does not. Look at swx_status's `solidworks.readiness` \+ (`performanceLog.why` says why the log cannot vouch for this session) and at the \+ user's screen. Nothing was sent to SOLIDWORKS."+ } else if timed_out { "SOLIDWORKS has not reported a finished start for over five minutes. It is still \ treated as starting — the clock never promotes it to ready. Look at swx_status's \ `solidworks.readiness` and at the user's screen (a dialog, a sign-in); do not \@@ -128,6 +225,9 @@ pub fn refuse_until_ready(ctx: &crate::verbs::Ctx, verb: &str) -> Option<Value> ); if let Value::Object(m) = &mut out { m.insert("retryAfterSeconds".into(), json!(RETRY_AFTER_SECS));+ if unverifiable {+ m.insert("startingUnverifiable".into(), json!(true));+ } m.insert("readiness".into(), r); } Some(out)
src/session_history.rs+29−3@@ -59,8 +59,10 @@ pub fn mark(pid: u32, created: Option<u64>) { let _ = crate::launch::write_atomic(&path(), &Value::Object(m).to_string()); } -/// `Some(true)`: this session has held a document. `Some(false)`: the bridge-/// launched it and has watched it since, and it never has. `None`: unknown.+/// `Some(true)`: this session has held a document. `Some(false)`: NONE SEEN BY+/// THIS BRIDGE — it launched the session and no poll since has seen a document+/// (a non-zero `GetDocumentCount` at any readiness poll, or a verb reply showing+/// one, sets it true). `None`: unknown. pub fn ever_had_document(pid: u32, created: Option<u64>) -> Option<bool> { if load().get(&key(pid, created)).and_then(|v| v.get("everHadDocument")).and_then(|v| v.as_bool()) == Some(true)@@ -96,6 +98,30 @@ pub fn note_reply(ctx: &crate::verbs::Ctx, command: &str, reply: &Value) { let created = crate::launchos::os() .processes() .ok()- .and_then(|ps| ps.iter().find(|p| p.pid == pid).and_then(|p| p.created_at));+ .and_then(|ps| ps.iter().find(|p| p.pid == pid).and_then(|p| p.created_at))+ .or_else(|| state.get("processCreatedAt").and_then(|v| v.as_u64())); mark(pid, created); }++/// THE SESSION'S HISTORY, for `swx_status` — so a caller can predict whether a+/// quit will need priming. `everHadDocument` is true, false or null, and `basis`+/// says which evidence decided it.+pub fn describe(pid: u32, created: Option<u64>) -> Value {+ let ever = ever_had_document(pid, created);+ let basis = match ever {+ Some(true) => "a document was seen open in this session",+ Some(false) => "none seen by this bridge: it launched this session, and no poll it has made since \+ (GetDocumentCount on every readiness check, and every verb reply) has seen a \+ document open — a document opened and closed between two polls would not be seen",+ None => "the bridge did not watch this session from its start, so a document may have come and gone unseen",+ };+ json!({+ "everHadDocument": ever,+ "basis": basis,+ "processId": pid,+ "processCreatedAt": created,+ "note": "MEASURED 2026-10-01: a session that has never held a document quit without help at \+ ~25 s old but DECLINED ExitApp at 50 s, 130 s, 150 s and 8 min old; swx_stop primes \+ such a session (userAuthorized, empty, no dialog) before asking again.",+ })+}
src/verbs.rs+176−32@@ -1419,7 +1419,35 @@ fn swx_stop(ctx: &Ctx, args: &Value) -> Value { let mut winning: Option<&'static str> = None; let mut blocked: Option<quit::BlockingDialog> = None; + // DURING STARTUP (adversary pass 42): the dispatch gate let this quit through+ // only because the user authorized it and the plan reaches a window-message+ // rung. The COM rungs are not sent to a SOLIDWORKS that has not finished+ // starting; (c) and (d) act on the main frame and need neither.+ let startup_not_verified = arg_bool(args, "_startupNotVerified", false); for rung in plan.iter() {+ if startup_not_verified && matches!(*rung, "a" | "b" | "e") {+ 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(),+ census_readable: false,+ detail: Some(+ "SOLIDWORKS has not finished starting (or the start cannot be verified), so \+ this COM rung was not sent; the window-message rungs need no COM"+ .into(),+ ),+ host_error: None,+ primed: false,+ });+ continue;+ } // 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.@@ -1856,6 +1884,11 @@ fn prime_session( return Err(format!("the scratch part could not be created: {}", made.get("error").cloned().unwrap_or(Value::Null))); } let title = made.pointer("/created/title").and_then(|v| v.as_str()).unwrap_or("").to_string();+ let scratch_path = made+ .pointer("/created/path")+ .or_else(|| made.pointer("/documentIdentity/path"))+ .and_then(|v| v.as_str())+ .map(String::from); crate::session_history::mark(id.pid, Some(id.created_at)); let closed = crate::verbs3::reset_workspace(ctx, &json!({})); let after = ctx@@ -1871,11 +1904,43 @@ fn prime_session( closed)"}), ); if after != Some(0) {- return Err(format!(- "the scratch part '{title}' was created but the session is not empty afterwards \- (documents open: {}) — ExitApp is not asked again",- after.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into())- ));+ // WHOSE DOCUMENT IS STILL OPEN? If the sweep did not report closing the+ // scratch part, it is the bridge's own, and the caller is told where it+ // is and how to drain it (adversary pass 42, S6).+ let closed_it = closed+ .get("closed")+ .and_then(|v| v.as_array())+ .map(|a| a.iter().any(|t| t.as_str() == Some(title.as_str())))+ .unwrap_or(false);+ let still = ctx+ .call("app_state", json!({}), 20)+ .ok()+ .and_then(|s| s.get("documents").cloned())+ .and_then(|d| d.as_array().cloned())+ .map(|docs| {+ docs.iter().any(|d| {+ d.get("title").and_then(|t| t.as_str()) == Some(title.as_str())+ || (scratch_path.is_some()+ && d.get("path").and_then(|p| p.as_str()) == scratch_path.as_deref())+ })+ });+ let ours_open = still.unwrap_or(!closed_it);+ return Err(if ours_open {+ format!(+ "the scratch part '{title}' was created but could NOT be closed — the bridge's own \+ part is still open at {} (documents open: {}). Drain it: swx_reset_workspace closes \+ only bridge-created documents (or swx_close_doc with that path). ExitApp is not \+ asked again",+ scratch_path.as_deref().unwrap_or("(no path reported)"),+ after.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into())+ )+ } else {+ format!(+ "the scratch part '{title}' was created and closed, but the session is not empty \+ afterwards (documents open: {}) — somebody else's document; ExitApp is not asked again",+ after.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into())+ )+ }); } Ok(format!("'{title}'")) }@@ -2995,26 +3060,67 @@ fn swx_describe(_ctx: &Ctx, _args: &Value) -> Value { // ── dispatch ──────────────────────────────────────────────────────────────── -/// The verbs that wait for SOLIDWORKS to finish starting (`readiness`): every-/// one that creates, opens, saves or closes a document, and the quit.-pub const STARTUP_GATED: &[&str] = &[- "swx_new_document",- "swx_open",- "swx_import",- "swx_save",- "swx_save_as",- "swx_run_script",- "swx_run_macro",- "swx_add_component",- "swx_close_doc",- "swx_close_all_documents",- "swx_close_all_safely",- "swx_close_import_temps",- "swx_reset_workspace",- "swx_shutdown",- "swx_stop",+/// THE VERBS THAT DO NOT WAIT FOR SOLIDWORKS TO FINISH STARTING. Every other+/// verb is GATED BY DEFAULT (adversary pass 42: a fifteen-name gate list left+/// swx_show_*, the export verbs and swx_com_eval — the crash log's own+/// `CMD:DocSave` — reachable during add-in load). A new verb is gated until it+/// is added here, and `tests/wiring.rs` pins this list name by name. Each one is+/// here because it never reaches the com host, or must work on a start that is+/// wedged:+pub const STARTUP_EXEMPT: &[(&str, &str)] = &[+ ("swx_status", "the probe that reports readiness itself"),+ ("swx_readiness", "process detection only; never reaches the com host"),+ ("swx_start", "launches and attaches; reports `stage: starting|ready`"),+ ("swx_describe", "the catalog; bridge-local"),+ ("swx_progress", "the bridge's own job table"),+ ("swx_action_ledger", "the bridge's own ledger file"),+ ("swx_verb_times", "the bridge's own timing file"),+ ("swx_uninstall", "the bridge's own artifacts on disk"),+ ("swx_find_files", "searches the disk; works without SOLIDWORKS"),+ ("swx_recover_list", "reads the recovery folders on disk"),+ ("swx_window_info", "a window census; no COM — a startup dialog is what it is for"),+ ("swx_dismiss_blocking_dialogs", "a window census (close is opt-in); no COM"),+ ("swx_close_window", "one window message by hwnd (reason + confirm); no COM"),+ ("swx_screenshot", "through ab's generic screenshot; no com host"),+ ("swx_set_driving_badge", "the taskbar badge; no com host"),+ ("swx_kill", "the last resort for a wedged start (reason + confirm); reads the document count only to say what is lost"), ]; +/// swx_stop is gated with one carve-out: under `userAuthorized`, a plan that+/// reaches the WINDOW-MESSAGE rungs (c, d) runs during startup with the COM rungs+/// (a, b, e) skipped — those two need neither COM nor readiness, and a start+/// wedged behind a dialog is exactly when a quit needs them.+pub fn is_startup_exempt(command: &str) -> bool {+ STARTUP_EXEMPT.iter().any(|(v, _)| *v == command)+}++/// Read or mutate, for the gate: catalog risk `read` reads; `exec`, `write` and+/// `process` mutate.+pub fn startup_touch(command: &str) -> crate::readiness::Touch {+ match CATALOG.iter().find(|(v, _, _)| *v == command).map(|(_, r, _)| *r) {+ Some("read") => crate::readiness::Touch::Read,+ _ => crate::readiness::Touch::Mutate,+ }+}++/// swx_stop's carve-out from the startup gate: `Some(args)` (marked so the+/// ladder skips its COM rungs) when the user authorized the quit and the plan+/// reaches a window-message rung.+fn stop_window_rungs_during_startup(command: &str, args: &Value) -> Option<Value> {+ if command != "swx_stop" || !arg_bool(args, "userAuthorized", false) {+ return None;+ }+ let max = args.get("maxRung").and_then(|v| v.as_str()).unwrap_or("a");+ if !crate::quit::rungs_up_to(max).iter().any(|r| matches!(*r, "c" | "d")) {+ return None;+ }+ let mut a = args.clone();+ if let Value::Object(m) = &mut a {+ m.insert("_startupNotVerified".into(), json!(true));+ }+ Some(a)+}+ pub fn is_known(command: &str) -> bool { CATALOG.iter().any(|(v, _, _)| *v == command) }@@ -3030,6 +3136,40 @@ pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value { panic!("test-induced panic in {command}"); } + // NOT BEFORE SOLIDWORKS HAS FINISHED STARTING. Every verb not on+ // STARTUP_EXEMPT waits for readiness: measured 2026-10-01, document commands+ // sent 8 s after COM attach crashed SOLIDWORKS while its 3DEXPERIENCE add-in+ // was still initialising. Reads may run on an unverifiable session after the+ // grace; mutations never do. See src/readiness.rs.+ let mut gated_args: Option<Value> = None;+ // BEFORE the handler table: several arms return directly (swx_show_*, the+ // export verbs), and a gate placed after the table never saw them.+ if is_known(command) && !is_startup_exempt(command) {+ if let Some(refusal) =+ crate::readiness::refuse_until_ready(ctx, command, startup_touch(command))+ {+ match stop_window_rungs_during_startup(command, args) {+ Some(a) => gated_args = Some(a),+ None => {+ let mut refusal = refusal;+ if command == "swx_stop" {+ if let Value::Object(m) = &mut refusal {+ m.insert(+ "windowMessageRungs".into(),+ json!("Under userAuthorized:true with maxRung \"c\" or \"d\", \+ swx_stop runs during startup with the COM rungs (a, b, e) \+ skipped: the window-message rungs need neither COM nor a \+ finished start."),+ );+ }+ }+ return refusal;+ }+ }+ }+ }+ let args: &Value = gated_args.as_ref().unwrap_or(args);+ let handler: fn(&Ctx, &Value) -> Value = match command { "swx_status" => swx_status, "swx_readiness" => swx_readiness,@@ -3113,15 +3253,6 @@ pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value { } }; - // NOT BEFORE SOLIDWORKS HAS FINISHED STARTING. Every verb that creates, opens,- // saves or closes a document — and the quit — waits for readiness: measured- // 2026-10-01, document commands sent 8 s after COM attach crashed SOLIDWORKS- // while its 3DEXPERIENCE add-in was still initialising. See src/readiness.rs.- if STARTUP_GATED.contains(&command) {- if let Some(refusal) = crate::readiness::refuse_until_ready(ctx, command) {- return refusal;- }- } let mut out = handler(ctx, args); // A SESSION'S HISTORY: whether it has ever held a document decides how the // quit ladder treats a declined ExitApp (see src/session_history.rs).@@ -3155,8 +3286,21 @@ pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value { map.insert("ready".into(), json!(ready)); map.insert("readiness".into(), r); } else if let Some(Value::Object(sw)) = map.get_mut("solidworks") {+ // THE SESSION'S DOCUMENT HISTORY, so a caller can predict a+ // priming (0.9.5).+ let history = r+ .get("processId")+ .and_then(|v| v.as_u64())+ .map(|pid| {+ // The creation time readiness resolved (census, else+ // the host's own word) — the same key the poll marked.+ let created = r.get("processCreatedAt").and_then(|v| v.as_u64());+ crate::session_history::describe(pid as u32, created)+ })+ .unwrap_or(Value::Null); sw.insert("ready".into(), json!(ready)); sw.insert("readiness".into(), r);+ sw.insert("sessionHistory".into(), history); } } }
tests/adversary40.rsadded+232@@ -0,0 +1,232 @@+//! ADVERSARIAL REVIEW OF 0.9.3 — pass forty-two.+//!+//! readiness.rs, perflog.rs and session_history.rs are NOT cfg(windows): all+//! three compile and run here, driven by the simulator's `mock-startup-*`+//! faults and `SWX_PERF_LOG`. The COM side of `startup_state` (the real+//! `StartupProcessCompleted` call in com.rs) and LiveOs are cfg(windows) and+//! were NOT RUN; nothing below is a claim about them.+//!+//! WHAT CHECKS OUT+//! * The pass-41 ledger fixes: multiset-max merge; per-line `seq` (max + 1);+//! trim only past 2x cap, the removed lines archived to+//! `ledger-archive-<stamp>[-n].jsonl` (collision suffix) with a+//! `ledger_archived` line. Overlapping `seq` ranges can only arise from two+//! 0.9.3+ ledgers in two roots; the legacy roots are never written by 0.9.3,+//! so that needs a contrived configuration. After any merge `max + 1` is+//! still unique going forward.+//! * Once ready, a session (by pid) stays ready, so an on-demand LOAD_ADDIN+//! AFTER the bridge has seen it ready does not flip it back.+//! * The prime: emptiness is checked before and after; `mark` runs before the+//! close, so a session is primed at most once; `reset_workspace` closes only+//! bridge-created documents, so a user document that appears between the+//! check and the create (TOCTOU) is not closed — and the "not empty+//! afterwards" check then stops the ladder. A prime whose close FAILS leaves+//! the bridge's own scratch part (which has a file since 0.6.0) open and+//! stops: safe, but reported only as "not empty afterwards", not as "the+//! bridge's own part is still open; drain it".+//! * session-history.json is keyed pid:creationTime on disk, so everHadDocument+//! survives a com-host respawn and cannot transfer to a reused pid.++mod common;+use common::*;+use serde_json::{json, Value};+use std::time::{Duration, SystemTime};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++fn a_visible_machine(tag: &str) -> TempDir {+ let d = TempDir::new(tag);+ std::fs::write(d.join("windows.json"), "[]").unwrap();+ d+}++fn session(scratch: &TempDir, docs: Value) {+ std::fs::write(+ scratch.join("mock-solidworks-open-docs.json"),+ json!({"running": true, "untitled": 1, "active": Value::Null, "docs": docs, "files": {}}).to_string(),+ )+ .unwrap();+}++/// SEVERITY 1 (for this release's purpose) — VERBS THAT ISSUE THE SAME COM+/// COMMANDS THE CRASH LOG NAMES ARE NOT ON THE GATE.+///+/// `STARTUP_GATED` lists 15 verbs. Not on it, and each one touches a document:+///+/// * `swx_show_part` / `_assembly` / `_drawing {path}` — OPEN a document: the+/// same `open` host call `swx_open` makes, and `swx_open` IS gated.+/// `session_history::note_reply` counts them as "made one". (I first wrote+/// that they CREATE one; reading `verbs3::show` corrected me.)+/// * `swx_export` and the ten `swx_export_*` — `SaveAs3` on the active+/// document. The 2026-10-01 crash log reads `<PROCMEM> CMD:DocSave`+/// immediately before `<RUNTIME>` and `<SWERR_REPORT>`.+/// * `swx_com_eval` — arbitrary COM.+/// * `swx_set_parameter`, `swx_props_set`, `swx_rebuild_check`, `swx_bom`,+/// `swx_pack_and_go`, `swx_activate_document`, `swx_set_preference`.+///+/// So the measured crash — a document command during add-in load — is still+/// reachable through a verb whose name does not happen to be on a list. The list+/// is the wrong shape: gate every `exec`/`write` verb that reaches COM, and+/// exempt the few that must work during startup (`swx_status`, the window+/// rungs). `tests/wiring.rs` can then check the exemption list instead of the+/// gate list, so a new verb is gated by default.+///+/// Sev-1 because it defeats the release's own premise — the gate exists to stop+/// exactly this crash. 0.9.3 should still stay published: 0.9.2 gated nothing.+#[test]+fn every_verb_that_creates_or_saves_a_document_waits_for_startup() {+ let scratch = TempDir::new("adv40-ungated");+ std::fs::write(+ scratch.join("mock-solidworks-open-docs.json"),+ json!({"running": true, "untitled": 1, "active": "C:\\Users\\drew\\work\\bracket.SLDPRT",+ "docs": [{"docType": "part", "path": "C:\\Users\\drew\\work\\bracket.SLDPRT",+ "title": "bracket.SLDPRT", "saved": true, "dirty": false}],+ "files": {}}).to_string(),+ )+ .unwrap();+ std::fs::write(scratch.join("mock-startup-incomplete"), "").unwrap();+ let machine = a_visible_machine("adv40-ungated-m");+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+ b.call("swx_start", json!({}));+ let gated = b.call("swx_new_document", json!({"type": "part"}));+ assert!(gated["errorCode"] == json!("host_app_starting"), "precondition: the gate is active: {}", s(&gated));++ let mut through: Vec<String> = Vec::new();+ for (verb, args) in [+ ("swx_show_part", json!({"path": "C:\\Users\\drew\\work\\other.SLDPRT"})),+ ("swx_export_step", json!({"outputPath": "C:\\out\\bracket.step"})),+ ("swx_com_eval", json!({"expr": "ActiveDoc.GetTitle", "reason": "probe"})),+ ] {+ let r = b.call(verb, args);+ if r["errorCode"] != json!("host_app_starting") {+ through.push(format!("{verb}: {}", r["errorCode"].as_str().unwrap_or(if r["success"] == json!(true) { "SUCCESS" } else { "other" })));+ }+ }+ assert!(+ through.is_empty(),+ "with SOLIDWORKS still starting, these document-touching verbs were NOT held at the \+ gate: {through:?}. swx_show_* {{path}} opens a document (swx_open's own call) and the export verbs SaveAs3 (the crash \+ log's CMD:DocSave) — the measured 2026-10-01 crash, through a verb not on a 15-name list. \+ Gate by default; list the exemptions."+ );+}++/// SEVERITY 2 — AN ADD-IN LOADED ON DEMAND BEFORE THE BRIDGE FIRST LOOKS MAKES A+/// FULLY STARTED SESSION "STARTING" FOREVER — AND UNQUITTABLE.+///+/// `perflog::startup_complete` says `Some(false)` whenever the last `LOAD_ADDIN`+/// line is newer than the last 3DEXPERIENCE marker. That marker is written once+/// per start. A user who starts SOLIDWORKS by hand and later enables an add-in+/// (Toolbox, Simulation) writes a `LOAD_ADDIN` after it; if the bridge first sees+/// the session after that, the log says "still loading" for the rest of the+/// session. READY requires every readable signal to agree, so COM's `true` is+/// out-voted, `readinessTimedOut` arrives at five minutes, and it stays+/// `starting` — by design, "never promoted to ready by the clock".+///+/// And `swx_stop`, `swx_close_all_safely` and `swx_shutdown` are on the gate.+/// So the bridge cannot close or quit a perfectly healthy session — the same+/// dead end as a session genuinely wedged at startup behind a dialog, where the+/// WINDOW-MESSAGE rungs (which need no COM and no readiness) are exactly what a+/// quit would use. Fail-safe, but it puts "ask the user to close it" back as the+/// only way out.+///+/// Fix: bind the log to the session (below), and let a positive COM+/// `StartupProcessCompleted` on a session the log cannot place win; never gate+/// the quit's window rungs on readiness.+#[test]+fn an_add_in_loaded_after_the_marker_does_not_make_a_started_session_unready_forever() {+ let scratch = TempDir::new("adv40-ondemand");+ session(&scratch, json!([]));+ let log = scratch.join("SolidWorksPerformance.log");+ std::fs::write(+ &log,+ "<LOAD_ADDIN> SolidWorks Toolbox\n<LOAD_ADDIN> 3DEXPERIENCE\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n\+ <LOAD_ADDIN> SOLIDWORKS Simulation\n",+ )+ .unwrap();+ // BUILDER NOTE (0.9.5): the rule that tells an on-demand LOAD_ADDIN from a+ // start still loading is "COM StartupProcessCompleted=true, a marker for this+ // session, then a LOAD_ADDIN and no log activity for ON_DEMAND_QUIET_SECS+ // (60 s)". A log written this instant cannot meet that — it is exactly what a+ // start still loading add-ins looks like — so the fixture's log was last+ // written two minutes ago, after the (simulated, hour-old) process started.+ std::fs::File::options()+ .write(true)+ .open(&log)+ .unwrap()+ .set_modified(SystemTime::now() - Duration::from_secs(120))+ .unwrap();+ let logp = log.to_string_lossy().to_string();+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ b.call("swx_start", json!({}));+ let st = b.call("swx_status", json!({}));+ let r = b.call("swx_new_document", json!({"type": "part"}));+ let stop = b.call("swx_stop", json!({"userAuthorized": true, "reason": "the user asked to quit"}));+ assert!(+ r["readiness"]["startupProcessCompleted"] == json!(true) || st.to_string().contains("\"startupProcessCompleted\":true"),+ "precondition: COM says the start completed: {} / {}", s(&r), s(&st)+ );+ assert!(+ r["errorCode"] != json!("host_app_starting") && stop["errorCode"] != json!("host_app_starting"),+ "COM says SOLIDWORKS finished starting and the 3DEXPERIENCE marker is in the log, but one \+ add-in loaded on demand after it, so the log reads \"still loading\" for the rest of the \+ session: document verbs AND swx_stop are refused, and the five-minute timeout never \+ promotes it. A healthy session the bridge cannot close or quit.\n new_document: {}\n \+ stop: {}",+ s(&r),+ s(&stop)+ );+}++/// SEVERITY 2 — THE PERFORMANCE LOG IS NOT BOUND TO THIS SESSION.+///+/// `perflog::path()` takes the most recently written log; `startup_complete`+/// reads its tail. Nothing compares the log with the running process — no pid,+/// no creation time, not even file mtime >= process start. A log whose last+/// entries are a COMPLETED earlier start therefore reads `Some(true)` for a+/// session that has not written a line yet. With COM unable to answer — exactly+/// the early-startup window — that one stale witness is the whole verdict, and+/// the session is READY at second 0: the 8-second mistake, with no wait at all.+/// The bridge already knows the process creation time (attribution, session+/// history); a log last modified before it cannot speak for this session.+///+/// ON THE GRACE (asked to argue it): `ready` after 60 s with neither signal is+/// the same class of guess as the 8 s that crashed, at 2.5x the one measured+/// platform load (24 s after a 29.7 s attach). It is defensible only because it+/// is labelled (`basedOn: graceAfterFirstSeen`) — but the verbs read `ready`,+/// not `basedOn`, and the module's own rule says the clock never promotes a+/// start to ready. I would keep the grace for READ verbs and keep document verbs+/// refused with `startingUnverifiable`, so the clock cannot authorise a crash.+#[test]+fn a_log_from_an_earlier_session_cannot_vouch_for_this_one() {+ let scratch = TempDir::new("adv40-stale");+ session(&scratch, json!([]));+ std::fs::write(scratch.join("mock-startup-unreadable"), "").unwrap();+ let log = scratch.join("SolidWorksPerformance.log");+ std::fs::write(&log, "<LOAD_ADDIN> 3DEXPERIENCE\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n").unwrap();+ std::fs::File::options()+ .write(true)+ .open(&log)+ .unwrap()+ .set_modified(SystemTime::now() - Duration::from_secs(86_400))+ .unwrap();+ let logp = log.to_string_lossy().to_string();+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_new_document", json!({"type": "part"}));+ let st = b.call("swx_status", json!({}));+ assert!(+ st.to_string().contains("\"startupProcessCompleted\":null"),+ "precondition: COM cannot answer: {}", s(&st)+ );+ assert!(+ r["errorCode"] == json!("host_app_starting"),+ "COM cannot say whether SOLIDWORKS has started, and the only other witness is a \+ performance log last written a DAY ago — a previous session's completed start. It was \+ taken as this session's: swx_new_document ran at once, with no grace. Bind the log to the \+ session (mtime >= the process creation time the bridge already knows).\n {}",+ s(&r)+ );+}
tests/adversary41.rsadded+156@@ -0,0 +1,156 @@+//! ADVERSARIAL REVIEW OF 0.9.4 — pass forty-three: quit::judge_window.+//!+//! `judge_window`, `dialog_census` and `abwin::classify` are not cfg(windows):+//! they are exercised here directly. The LiveOs code that MEASURES a window+//! (GW_OWNER, GWL_STYLE/EXSTYLE, the child-control walk) is cfg(windows) and was+//! READ, NOT RUN.+//!+//! WHAT CHECKS OUT+//! * Ownerless windows fail closed. LiveOs reads the DIRECT owner+//! (`GetWindow(GW_OWNER)`); no owner -> `owner_enabled: None` -> "could not be+//! read" -> BLOCKING. A zero owner style is likewise treated as a failed read.+//! * The direct owner is the right one for modality: a modal disables whatever+//! owns it. A modal owned by another popup disables that popup and is judged+//! blocking; a tool window owned by a still-enabled popup is a notification,+//! while the popup's own modal (if any) is judged separately.+//! * Hidden Button-class children COUNT (`has_button_class_child` ignores+//! visibility). That is the safe direction; its cost is that a notice whose+//! template carries hidden Button controls blocks every quit again — the+//! pre-0.9.4 symptom. Visible-only would trade that for a modal whose buttons+//! are created hidden and shown late. I would keep hidden-counts.+//! * A real modal appearing between the precondition census and the rung is+//! caught: verbs.rs re-censuses after every rung (1755-1764), judged, until+//! the process is gone, a blocking dialog appears, or the wait ends.+//! * Consequence of a FALSE notification, by verb: a sweep saves every document+//! before closing anything, so "never discard" holds whatever a dialog was;+//! swx_stop requires zero unsaved documents (P2, only `force` waives it), so+//! ExitApp past a misread modeless question loses the QUESTION, not a+//! document — and with unsaved work SOLIDWORKS raises its own MODAL save+//! prompt, which disables the frame and is caught by the post-rung census.+//! That bound is why finding 1 below is sev-2 and not sev-1.+//! * The localised License Manager: if its title is not English, `classify`+//! misses it — and it is still blocking, because it has a Button-class OK.++use swx_bridge::launchos::{DialogContents, WindowInfo, WS_EX_TOOLWINDOW};+use swx_bridge::quit::judge_window;++/// The window measured on 2026-10-01: #32770, style 0x94000044, exstyle 0x90080,+/// 301x67, owned by the enabled main frame.+fn measured_notice(title: &str) -> WindowInfo {+ WindowInfo {+ hwnd: 331_370,+ class_name: "#32770".into(),+ title: title.into(),+ visible: true,+ owner: Some(1001),+ owner_enabled: Some(true),+ style: Some(0x9400_0044),+ exstyle: Some(0x0009_0080),+ rect: Some([100, 100, 401, 167]),+ }+}++fn statics(text: &[&str]) -> DialogContents {+ DialogContents {+ text: text.iter().map(|s| s.to_string()).collect(),+ controls: text.iter().map(|_| ("Static".to_string(), true)).collect(),+ ..Default::default()+ }+}++/// SEVERITY 2 — THE WINDOW'S OWN WORDS ARE READ AND THEN IGNORED.+///+/// `judge_window` classifies by TITLE, then modality, then Button class. The+/// text it read is never consulted. SOLIDWORKS titles its prompts "SOLIDWORKS"+/// and puts the question in the body, and it draws many controls itself — the+/// very notice this release was built around draws its text in Static controls.+/// A modeless tool window, owner enabled, whose body says "Do you want to save+/// changes to Part1.SLDPRT?" and whose Save / Don't Save / Cancel are owner-drawn+/// (Static, or a custom class) is judged a NOTIFICATION: reported, never a reason+/// to stop a quit, a sweep or an import.+///+/// Bounded (see the module note): the sweep saves first; swx_stop needs zero+/// unsaved documents and SOLIDWORKS re-asks modally. But a notification is+/// "nothing anyone needs to decide", and this one is a decision. Run `classify`+/// over the read text too, and treat more than one clickable-looking caption+/// (Save/Don't Save/Cancel/Yes/No/Retry…) as something on offer, whatever its+/// class.+#[test]+fn a_custom_drawn_save_prompt_is_not_judged_a_notification() {+ let w = measured_notice("SOLIDWORKS");+ let c = statics(&[+ "Do you want to save changes to Part1.SLDPRT?",+ "Save",+ "Don't Save",+ "Cancel",+ ]);+ assert_eq!(w.exstyle.unwrap() & WS_EX_TOOLWINDOW, WS_EX_TOOLWINDOW, "precondition: a tool window");+ let (blocking, kind, why) = judge_window(&w, Some(&c));+ assert!(+ blocking,+ "a window that SAYS \"Do you want to save changes to Part1.SLDPRT?\" and offers \+ Save / Don't Save / Cancel — drawn as Static, the way this release's own measured notice \+ draws its text — was judged {kind:?}, non-blocking: {why}. judge_window reads the text \+ and never looks at it. Classify the text as well as the title."+ );+}++/// SEVERITY 3 — A TITLED 3DEXPERIENCE NOTIFICATION REGRESSES TO THE LIVE SYMPTOM.+///+/// The title check runs BEFORE the measured modality, and `classify` maps any+/// title containing "3dexperience" (also "licen", "activat") to `license`, a+/// known blocking kind — pinned that way in the abwin cases table. The notice+/// measured today was title-less, so 0.9.4 works on it. The same notice with a+/// title — "3DEXPERIENCE Platform", "Connected to …" — and identical evidence+/// (tool window, owner enabled, Static only) is BLOCKING by title and stops every+/// quit, which is exactly the symptom 0.9.4 fixed. Safe direction, availability+/// cost. For `license` the title adds nothing a real licence dialog needs (the+/// License Manager has a Button-class OK and blocks on that); let measured+/// notification evidence beat a title-only guess for the kinds that are not a+/// save, recovery or import prompt.+#[test]+fn a_titled_3dexperience_notice_with_notification_evidence_is_a_notification() {+ let w = measured_notice("3DEXPERIENCE Platform");+ let c = statics(&["Connected to OI000616940"]);+ let (blocking, kind, why) = judge_window(&w, Some(&c));+ assert!(+ !blocking,+ "the measured notice with a TITLE added — same tool window, enabled owner, Static-only \+ controls — is judged {kind:?} and BLOCKING by its title alone ({why}), so it stops \+ every quit: the 0.9.4 live symptom, back. Measured modality should beat a title-only \+ guess for kinds that are not save/recovery/import prompts."+ );+}++/// PIN — the rows of the decision that must not move. Passes today.+#[test]+fn judge_window_fails_closed_on_every_unread_or_modal_fact() {+ let notice = measured_notice("");+ let text = statics(&["Title", "Description"]);+ assert!(!judge_window(¬ice, Some(&text)).0, "the measured notice is a notification");++ let mut ownerless = notice.clone();+ ownerless.owner = None;+ ownerless.owner_enabled = None;+ assert!(judge_window(&ownerless, Some(&text)).0, "ownerless: owner state unread -> blocking");++ let mut modal = notice.clone();+ modal.owner_enabled = Some(false);+ assert!(judge_window(&modal, Some(&text)).0, "owner disabled -> blocking");++ assert!(judge_window(¬ice, None).0, "controls unread -> blocking");++ let mut hidden_button = text.clone();+ hidden_button.controls.push(("Button".into(), false));+ assert!(judge_window(¬ice, Some(&hidden_button)).0, "a HIDDEN Button-class child still blocks");++ let mut not_tool = notice.clone();+ not_tool.exstyle = Some(0x0009_0000);+ assert!(judge_window(¬_tool, Some(&text)).0, "not a tool window -> blocking");++ let mut unread_ex = notice.clone();+ unread_ex.exstyle = None;+ assert!(judge_window(&unread_ex, Some(&text)).0, "extended style unread -> blocking");++ assert!(judge_window(&measured_notice("Save Modified Documents"), Some(&text)).0, "a save title blocks");+}
tests/quit.rs+131@@ -745,3 +745,134 @@ fn the_same_window_over_a_disabled_frame_blocks_and_says_why() { s(&r) ); }++// ── 0.9.5 (adversary pass 42): PRIMING'S OTHER END, AND QUIT DURING STARTUP ─++/// A SESSION THAT HAS HELD A DOCUMENT IS NEVER PRIMED. Both ends of the rule:+/// the bridge RECORDS that the session held one (a scratch part, created and+/// closed), and the prime's guard READS it — so a declined rung (a) on that+/// session stands, nothing is created, and the attempt says why.+#[test]+fn a_session_that_has_held_a_document_is_never_primed() {+ let m = Sim::new("quit-prime-held", json!({"a": "declined"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let made = b.call("swx_new_document", json!({"type": "part"}));+ check!(b, "precondition: a document was made", made["success"] == true, s(&made));+ let reset = b.call("swx_reset_workspace", json!({}));+ check!(b, "precondition: ...and closed", reset["success"] == true, s(&reset));+ let st = b.call("swx_status", json!({}));+ check!(+ b,+ "swx_status: the session is recorded as having held a document",+ st["solidworks"]["sessionHistory"]["everHadDocument"] == true,+ s(&st["solidworks"]["sessionHistory"])+ );+ let r = b.call("swx_stop", authorized("a"));+ let tries = r["rungsTried"].as_array().cloned().unwrap_or_default();+ check!(+ b,+ "swx_stop: the decline stands, unprimed, and says the session has held documents",+ r["stopped"] == false+ && tries.len() == 1+ && tries[0]["result"] == "declined"+ && tries[0]["detail"].as_str().unwrap_or("").contains("has held documents"),+ s(&r)+ );+ check!(b, "swx_stop: ...and no scratch part was made to prime it", !ledger_has(&b, "quit_prime"), String::new());+}++/// `false` MEANS "NONE SEEN BY THIS BRIDGE", and says so.+#[test]+fn ever_had_document_false_is_worded_as_none_seen_by_this_bridge() {+ let m = Sim::new("quit-history-wording", json!({"a": "exited"}));+ // The bridge LAUNCHED this session: the launch record attributes it, by a+ // fixed pid and creation time.+ std::fs::write(m.dir.join("sldworks-created"), "1700000000").unwrap();+ let scratch = TempDir::new("quit-history-wording-scratch");+ std::fs::write(scratch.join("mock-solidworks-session"), "700950").unwrap();+ std::fs::write(+ scratch.join("launch-state.json"),+ json!({+ "attempt": {+ "id": 1, "startedAt": 1700000000u64, "shortcut": "x.lnk",+ "route": "shellExecute", "invokedAt": 1700000000u64,+ "stage": "attached", "finished": true, "outcome": "attached",+ "runBeginSeen": true,+ "attributedSolidworks": {"pid": 700950, "createdAt": 1700000000u64},+ },+ "preferredRoute": "shellExecute", "consecutiveFailures": 0,+ })+ .to_string(),+ )+ .unwrap();+ let b = Bridge::start_in(+ scratch,+ &[("SWX_LAUNCH_FAKE_DIR", Box::leak(m.dir.path().into_boxed_str())), ("SWX_ALLOW_USER_WRITES", "1")],+ );+ b.call("swx_start", json!({}));+ let st = b.call("swx_status", json!({}));+ let h = &st["solidworks"]["sessionHistory"];+ check!(+ b,+ "swx_status: a bridge-launched, never-seen-a-document session reads false — 'none seen by this bridge'",+ h["everHadDocument"] == false && h["basis"].as_str().unwrap_or("").starts_with("none seen by this bridge"),+ s(h)+ );+}++/// A PRIME WHOSE CLOSE FAILS names the bridge's own scratch part, where it is,+/// and how to drain it — not just "not empty afterwards".+#[test]+fn a_prime_whose_close_fails_names_the_part_and_how_to_drain_it() {+ let m = Sim::new("quit-prime-closefail", json!({"a": "exitedIfEverHadDocument"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ std::fs::write(b.scratch.join("mock-closedoc-fails"), "").unwrap();+ let r = b.call("swx_stop", authorized("a"));+ let tries = r["rungsTried"].as_array().cloned().unwrap_or_default();+ let detail = tries.first().and_then(|t| t["detail"].as_str()).unwrap_or("").to_string();+ check!(+ b,+ "swx_stop: the prime stopped, saying the bridge's own part is still open, where, and to drain it",+ r["stopped"] == false+ && detail.contains("could NOT be closed")+ && detail.contains(".SLDPRT")+ && detail.contains("swx_reset_workspace"),+ s(&r)+ );+}++/// THE WINDOW-MESSAGE RUNGS DO NOT WAIT FOR READINESS. Under the user's+/// authorization, a plan that reaches (c)/(d) runs while SOLIDWORKS is still+/// starting, with the COM rungs skipped; a plan that does not is refused+/// `host_app_starting`, and the refusal names the way out.+#[test]+fn the_window_message_rungs_run_during_startup_with_the_com_rungs_skipped() {+ let m = Sim::new("quit-startup-window", json!({"c": "exited"}));+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ std::fs::write(b.scratch.join("mock-startup-incomplete"), "").unwrap();+ let refused = b.call("swx_stop", authorized("a"));+ check!(+ b,+ "swx_stop maxRung a during startup: refused, naming the window-message rungs",+ refused["errorCode"] == "host_app_starting" && refused["windowMessageRungs"].is_string(),+ s(&refused)+ );+ let r = b.call("swx_stop", authorized("d"));+ let tries = r["rungsTried"].as_array().cloned().unwrap_or_default();+ check!(+ b,+ "swx_stop maxRung d during startup: a and b skipped (no COM), c quits",+ r["stopped"] == true+ && tries.len() == 3+ && tries[0]["result"] == "skipped"+ && tries[1]["result"] == "skipped"+ && tries[2]["rung"] == "c"+ && tries[2]["result"] == "quit",+ s(&r)+ );+ let noauth = b.call("swx_stop", full_args("d"));+ check!(b, "...never without the user's authorization", noauth["stopped"] != true, s(&noauth));+}
tests/readiness.rs+265−4@@ -102,13 +102,17 @@ fn document_verbs_wait_for_solidworks_to_finish_starting() { s(&r) ); }- let docs = b.call("swx_docs", json!({}));- check!(b, "swx_docs: ...and nothing was sent: no document appeared", docs["count"] == 0, s(&docs));+ // A READ verb is gated too while the start is VERIFIED incomplete (0.9.5:+ // every verb not on STARTUP_EXEMPT waits).+ let early = b.call("swx_docs", json!({}));+ check!(b, "swx_docs: a COM read waits too while SOLIDWORKS is verifiably starting", early["errorCode"] == "host_app_starting", s(&early)); // SOLIDWORKS FINISHES STARTING. std::fs::remove_file(scratch.join("mock-startup-incomplete")).unwrap(); let again = b.call("swx_start", json!({})); check!(b, "swx_start: now `ready`", again["stage"] == "ready" && again["ready"] == true, s(&again));+ let docs = b.call("swx_docs", json!({}));+ check!(b, "swx_docs: ...and nothing was sent while it was starting: no document appeared", docs["count"] == 0, s(&docs)); let made = b.call("swx_new_document", json!({"type": "part"})); check!(b, "swx_new_document: runs once SOLIDWORKS is ready", made["success"] == true, s(&made)); }@@ -137,7 +141,7 @@ fn the_performance_log_is_a_second_independent_witness() { "swx_new_document: refused while the log shows add-ins still loading, though COM says complete", r["errorCode"] == "host_app_starting" && r["readiness"]["startupProcessCompleted"] == true- && r["readiness"]["performanceLog"]["complete"] == false+ && r["readiness"]["performanceLog"]["verdict"] == "loading" && r["readiness"]["basedOn"].as_array().map(|a| a.len() == 2).unwrap_or(false), s(&r) );@@ -163,7 +167,7 @@ fn the_performance_log_is_a_second_independent_witness() { b2, "swx_start: a log without a 3DEXPERIENCE marker is not a signal — COM alone decides, and says so", st["ready"] == true- && st["readiness"]["performanceLog"]["complete"].is_null()+ && st["readiness"]["performanceLog"]["verdict"] == "unavailable" && st["readiness"]["basedOn"] == json!(["StartupProcessCompleted"]), s(&st) );@@ -322,3 +326,260 @@ fn a_notification_does_not_stop_a_sweep_and_is_reported() { s(&r["notifications"]) ); }++/// THE SESSION'S DOCUMENT HISTORY IS IN swx_status (0.9.5), so a caller can+/// predict whether a quit will need priming: null with its basis for a session+/// the bridge did not watch from its start, true once a document has been open.+#[test]+fn swx_status_reports_the_sessions_document_history() {+ let scratch = TempDir::new("history-status");+ session(&scratch, json!([]));+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({}));+ let st = b.call("swx_status", json!({}));+ let h = st["solidworks"]["sessionHistory"].clone();+ check!(+ b,+ "swx_status: a session the bridge attached to has an UNKNOWN history, and says why",+ h.get("everHadDocument").map(|v| v.is_null()).unwrap_or(false)+ && h["basis"].as_str().unwrap_or("").contains("did not watch"),+ s(&st["solidworks"])+ );+ let made = b.call("swx_new_document", json!({"type": "part"}));+ assert!(made["success"] == true, "precondition: a document was made: {}", s(&made));+ let st = b.call("swx_status", json!({}));+ check!(+ b,+ "swx_status: ...and once a document has been open in it, everHadDocument is true",+ st["solidworks"]["sessionHistory"]["everHadDocument"] == true,+ s(&st["solidworks"]["sessionHistory"])+ );+}++// ── 0.9.5: GATED BY DEFAULT, THE LOG BOUND TO THE SESSION (adversary pass 42) ─++/// The exemption list, name by name. A verb not here waits for SOLIDWORKS to+/// finish starting — a NEW verb included — so adding one here is a decision+/// this test makes visible. tests/wiring.rs pins the same list against the+/// source.+const EXEMPT: &[&str] = &[+ "swx_status", "swx_readiness", "swx_start", "swx_describe", "swx_progress",+ "swx_action_ledger", "swx_verb_times", "swx_uninstall", "swx_find_files",+ "swx_recover_list", "swx_window_info", "swx_dismiss_blocking_dialogs",+ "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill",+];++#[test]+fn every_verb_not_exempt_waits_for_startup_by_default() {+ let scratch = TempDir::new("ready-default");+ session(&scratch, json!([]));+ std::fs::write(scratch.join("mock-startup-incomplete"), "").unwrap();+ let machine = a_visible_machine("ready-default-machine");+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+ b.call("swx_start", json!({}));+ let verbs: Vec<String> = b.call("swx_describe", json!({}))["verbs"]+ .as_array()+ .unwrap()+ .iter()+ .map(|v| v["verb"].as_str().unwrap().to_string())+ .collect();+ check!(b, "the catalog is the whole surface", verbs.len() > 60, format!("{verbs:?}"));+ let mut through = Vec::new();+ for v in verbs.iter().filter(|v| !EXEMPT.contains(&v.as_str())) {+ let r = b.call(v, json!({}));+ if r["errorCode"] != "host_app_starting" {+ through.push(format!("{v}: {}", s(&r)));+ }+ }+ check!(b, "every verb not on the exemption list is held at the gate while SOLIDWORKS starts", through.is_empty(), through.join("\n"));+ let mut held = Vec::new();+ for v in EXEMPT.iter().filter(|v| !matches!(**v, "swx_uninstall" | "swx_start")) {+ let r = b.call(v, json!({}));+ if r["errorCode"] == "host_app_starting" {+ held.push(v.to_string());+ }+ }+ check!(b, "...and no exempt verb is", held.is_empty(), format!("{held:?}"));+}++/// THE GRACE UNLOCKS READS, NEVER DOCUMENT COMMANDS. With no signal that can+/// verify the start, a read verb runs once the grace is over; a verb that+/// creates a document still refuses — "cannot verify startup completed".+#[test]+fn the_grace_lets_reads_run_and_never_a_document_command() {+ let scratch = TempDir::new("ready-grace-reads");+ session(&scratch, json!([]));+ std::fs::write(scratch.join("mock-startup-unreadable"), "").unwrap();+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_READINESS_GRACE_SECS", "0")]);+ b.call("swx_start", json!({}));+ let docs = b.call("swx_docs", json!({}));+ check!(b, "swx_docs: a READ runs once the grace is over", docs["success"] != false && docs["count"] == 0, s(&docs));+ let r = b.call("swx_new_document", json!({"type": "part"}));+ check!(+ b,+ "swx_new_document: still refused — the clock never authorises a document command",+ r["errorCode"] == "host_app_starting"+ && r["startingUnverifiable"] == true+ && r["_hint"].as_str().unwrap_or("").contains("Cannot verify startup completed")+ && r["readiness"]["state"] == "unverifiable"+ && r["readiness"]["readsAllowed"] == true,+ s(&r)+ );+ let st = b.call("swx_status", json!({}));+ check!(b, "swx_status: `ready` is false on an unverifiable session", st["solidworks"]["ready"] == false, s(&st["solidworks"]));++ // Before the grace is over, even a read waits.+ let scratch2 = TempDir::new("ready-grace-early");+ session(&scratch2, json!([]));+ std::fs::write(scratch2.join("mock-startup-unreadable"), "").unwrap();+ let b2 = Bridge::start_in(TempDir::borrowed(&scratch2.0), &[]);+ b2.call("swx_start", json!({}));+ let early = b2.call("swx_docs", json!({}));+ check!(b2, "swx_docs: inside the grace a read waits too", early["errorCode"] == "host_app_starting", s(&early));+}++fn write_log(scratch: &TempDir, text: &str, age_secs: u64) -> String {+ let log = scratch.join("SolidWorksPerformance.log");+ std::fs::write(&log, text).unwrap();+ std::fs::File::options()+ .write(true)+ .open(&log)+ .unwrap()+ .set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age_secs))+ .unwrap();+ log.to_string_lossy().to_string()+}++/// COM ALONE IS NEVER ENOUGH WHILE THE MACHINE WRITES MARKERS. Measured+/// 2026-10-01: StartupProcessCompleted was TRUE before the marker. A log whose+/// marker predates this process cannot vouch for it — and does not hand the+/// verdict to COM either.+#[test]+fn com_alone_never_vouches_for_a_session_with_no_marker_of_its_own() {+ // The log's last write predates the (hour-old) process: an earlier session.+ let scratch = TempDir::new("ready-com-alone");+ session(&scratch, json!([]));+ let logp = write_log(&scratch, "<LOAD_ADDIN> 3DEXPERIENCE\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n", 7200);+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_new_document", json!({"type": "part"}));+ check!(+ b,+ "swx_new_document: COM true + a log from before this process => refused, unverifiable",+ r["errorCode"] == "host_app_starting"+ && r["readiness"]["startupProcessCompleted"] == true+ && r["readiness"]["performanceLog"]["verdict"] == "notThisSession"+ && r["startingUnverifiable"] == true,+ s(&r)+ );++ // This session is writing the log, the machine is known to write markers,+ // and none of this session's is in it yet: still loading.+ let scratch2 = TempDir::new("ready-no-marker-yet");+ session(&scratch2, json!([]));+ std::fs::write(scratch2.join("perflog-markers-seen"), "1").unwrap();+ let logp2 = write_log(&scratch2, "<LOAD_ADDIN> SolidWorks Toolbox\n<LOAD_ADDIN> 3DEXPERIENCE\n", 0);+ let b2 = Bridge::start_in(TempDir::borrowed(&scratch2.0), &[("SWX_PERF_LOG", &logp2)]);+ b2.call("swx_start", json!({}));+ let r2 = b2.call("swx_new_document", json!({"type": "part"}));+ check!(+ b2,+ "swx_new_document: a machine that writes markers, none yet in this session => loading, refused",+ r2["errorCode"] == "host_app_starting" && r2["readiness"]["performanceLog"]["verdict"] == "loading",+ s(&r2)+ );+}++/// AN ON-DEMAND ADD-IN IS TOLD FROM A START BY QUIET AND BY WHICH ADD-IN. A+/// LOAD_ADDIN after the marker in a log written moments ago is a start still+/// loading; one that names the 3DEXPERIENCE add-in is a START (wedged, however+/// quiet), never an on-demand load.+#[test]+fn an_on_demand_add_in_needs_quiet_and_is_never_the_3dexperience_add_in() {+ for (tag, text, age, why) in [+ (+ "ready-ondemand-fresh",+ "<LOAD_ADDIN> 3DEXPERIENCE\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n<LOAD_ADDIN> SOLIDWORKS Simulation\n",+ 0u64,+ "a LOAD_ADDIN written moments ago",+ ),+ (+ "ready-ondemand-3dx",+ "<LOAD_ADDIN> 3DEXPERIENCE\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n<LOAD_ADDIN> SolidWorks Toolbox\n<LOAD_ADDIN> 3DEXPERIENCE\n",+ 300,+ "a quiet log whose LOAD_ADDIN after the marker names the 3DEXPERIENCE add-in",+ ),+ ] {+ let scratch = TempDir::new(tag);+ session(&scratch, json!([]));+ let logp = write_log(&scratch, text, age);+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_new_document", json!({"type": "part"}));+ check!(+ b,+ &format!("swx_new_document: {why} is a start still loading, refused"),+ r["errorCode"] == "host_app_starting" && r["readiness"]["performanceLog"]["verdict"] == "loading",+ s(&r)+ );+ }+ // An on-demand-looking log with COM UNABLE to answer: the on-demand rule+ // needs COM's "completed" as well, so this stays unverifiable.+ let scratch = TempDir::new("ready-ondemand-nocom");+ session(&scratch, json!([]));+ std::fs::write(scratch.join("mock-startup-unreadable"), "").unwrap();+ let logp = write_log(+ &scratch,+ "<LOAD_ADDIN> 3DEXPERIENCE\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n<LOAD_ADDIN> SOLIDWORKS Simulation\n",+ 120,+ );+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_new_document", json!({"type": "part"}));+ check!(+ b,+ "swx_new_document: an on-demand-looking log without COM's word is not a verified start",+ r["errorCode"] == "host_app_starting" && r["startingUnverifiable"] == true,+ s(&r)+ );+ // ...and the on-demand case itself, with the rule's basis in the answer.+ let scratch = TempDir::new("ready-ondemand-ok");+ session(&scratch, json!([]));+ let logp = write_log(+ &scratch,+ "<LOAD_ADDIN> 3DEXPERIENCE\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n<LOAD_ADDIN> SOLIDWORKS Simulation\n",+ 120,+ );+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ let st = b.call("swx_start", json!({}));+ check!(+ b,+ "swx_start: COM true + marker + a quiet non-3DEXPERIENCE LOAD_ADDIN => ready, saying why",+ st["ready"] == true+ && st["readiness"]["performanceLog"]["verdict"] == "onDemandAddInAfterComplete"+ && st["readiness"]["performanceLog"]["why"].as_str().unwrap_or("").contains("on demand"),+ s(&st)+ );+}++/// A DOCUMENT SEEN AT ANY POLL IS RECORDED. The user's own document, open when+/// the bridge first looks, never passed through a verb that "made one" — the+/// readiness poll's GetDocumentCount records it.+#[test]+fn a_document_seen_at_any_poll_marks_the_session() {+ let scratch = TempDir::new("ready-poll-marks");+ session(+ &scratch,+ json!([{"docType": "part", "path": "C:\\Users\\drew\\work\\bracket.SLDPRT",+ "title": "bracket.SLDPRT", "saved": true, "dirty": false}]),+ );+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+ b.call("swx_start", json!({}));+ let st = b.call("swx_status", json!({}));+ check!(+ b,+ "swx_status: the session has held a document, recorded by the poll",+ st["solidworks"]["sessionHistory"]["everHadDocument"] == true,+ s(&st["solidworks"])+ );+}
tests/wiring.rs+64@@ -128,6 +128,10 @@ const GUARDS: &[(&str, &str)] = &[ "the listing and the migration still read every location older bridges wrote to"), ("refuse_until_ready", "no document command reaches a SOLIDWORKS that has not finished starting (crash measured 2026-10-01)"),+ ("is_startup_exempt",+ "every verb not on the exemption list waits for SOLIDWORKS to finish starting (adversary pass 42)"),+ ("stop_window_rungs_during_startup",+ "an authorized quit reaches the window-message rungs during startup, with the COM rungs skipped"), ("prime_session", "a declined quit on a never-had-a-document session is primed only when empty and authorized"), ("solidworks_gone",@@ -1009,3 +1013,63 @@ fn the_quit_gates_status_claim_fails_on_a_declined_quit() { String::from_utf8_lossy(&out.stderr) ); }++/// THE LAUNCH GATE'S OPTIONAL CAPTURE NEVER ABORTS IT (2026-10-01: the read-only+/// UIA capture, its last step, failed on every run and made the gate exit 3), and+/// every gate script is wrapped so a failure prints its own cause.+#[test]+fn the_launch_gates_optional_capture_cannot_abort_it() {+ let root = Path::new(env!("CARGO_MANIFEST_DIR"));+ let out = match std::process::Command::new("python3")+ .arg(root.join("tools").join("launch-gate.py"))+ .arg("--self-test")+ .output()+ {+ Ok(o) => o,+ Err(_) => return,+ };+ let text = String::from_utf8_lossy(&out.stdout).to_string();+ assert!(+ out.status.success() && text.contains("SELF-TEST PASSED"),+ "an optional capture's failure must be a NOTE, not an abort, and scripts must be \+ wrapped to report their own errors:\n{}{}",+ text,+ String::from_utf8_lossy(&out.stderr)+ );+}++/// THE STARTUP GATE IS A DEFAULT, AND ITS EXEMPTIONS ARE A PINNED LIST (0.9.5,+/// adversary pass 42). A fifteen-name GATE list let swx_show_*, the export verbs+/// and swx_com_eval through during add-in load; the shape is now inverted, and a+/// verb is exempt only by being named here AND in `STARTUP_EXEMPT`.+#[test]+fn the_startup_exemption_list_is_pinned_name_by_name() {+ let src = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/verbs.rs")).unwrap();+ assert!(!src.contains("STARTUP_GATED"), "the gate list is back: the gate must be the default");+ let start = src.find("pub const STARTUP_EXEMPT").expect("STARTUP_EXEMPT is gone");+ let end = start + src[start..].find("];").unwrap();+ let mut found: Vec<String> = src[start..end]+ .lines()+ .filter_map(|l| l.trim().strip_prefix("(\"").and_then(|r| r.split('"').next()).map(String::from))+ .collect();+ found.sort();+ let mut pinned: Vec<&str> = vec![+ "swx_status", "swx_readiness", "swx_start", "swx_describe", "swx_progress",+ "swx_action_ledger", "swx_verb_times", "swx_uninstall", "swx_find_files",+ "swx_recover_list", "swx_window_info", "swx_dismiss_blocking_dialogs",+ "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill",+ ];+ pinned.sort();+ assert_eq!(+ found, pinned,+ "STARTUP_EXEMPT changed. An exempt verb runs while SOLIDWORKS is still loading add-ins — \+ the window in which document commands crashed it on 2026-10-01. Exempt a verb only if it \+ never reaches the com host, or must work on a wedged start; then update this list and \+ tests/readiness.rs EXEMPT."+ );+ // The gate sits BEFORE the handler table: arms that `return` directly+ // (swx_show_*, the export verbs) were never seen by a gate placed after it.+ let gate = src.find("if is_known(command) && !is_startup_exempt(command)").expect("the gate is gone");+ let table = src.find("let handler: fn(&Ctx, &Value) -> Value = match command").unwrap();+ assert!(gate < table, "the startup gate must come before the handler table");+}
tools/drain-gate.py+37−9@@ -117,8 +117,30 @@ class Refused(Exception): """The preconditions were not met. Nothing was created or closed.""" +# EVERY SCRIPT SAYS WHY IT FAILED (2026-10-01). A shell_execute that exits 1 with+# empty stdout AND stderr was read for two days as a "transient" and retried; it+# was never transient — on 09-30 it was curl.exe against the dead loopback port+# (curl exit 7 surfacing as an empty exit 1), and on 10-01 it was the launch+# gate's UIA probe, every single time. So every script now runs inside a wrapper+# that PRINTS its own failure to stdout: a trap for terminating errors, and on a+# non-zero native exit the exit code and every error PowerShell recorded. Empty+# stderr cannot hide a failure again. ($ErrorActionPreference is deliberately left+# at its default: making it Stop would turn the non-terminating errors the+# measured scripts already tolerate into new failures.)+def _wrap(script):+ return (+ "$Error.Clear(); "+ "trap { [Console]::Out.WriteLine('PS-ERROR: ' + ($_ | Out-String).Trim()); exit 1 }; "+ + script ++ "\n$__code = $LASTEXITCODE; "+ "if ($__code) { [Console]::Out.WriteLine('PS-NATIVE-EXIT: ' + $__code); "+ "foreach ($__e in $Error) { [Console]::Out.WriteLine('PS-ERROR: ' + ($__e | Out-String).Trim()) }; "+ "exit $__code }"+ )++ def ps(script, reason, timeout=120, _attempt=0):- enc = base64.b64encode(script.encode("utf-16le")).decode()+ enc = base64.b64encode(_wrap(script).encode("utf-16le")).decode() r = subprocess.run( [ "adom-bridge", "--target", "adom_drew2", "--ai-thread", THREAD, "shell_execute",@@ -136,13 +158,15 @@ def ps(script, reason, timeout=120, _attempt=0): raise Unreadable("adom-bridge did not return JSON: %s%s" % (r.stdout[:200], (" / " + r.stderr[:200]) if r.stderr else "")) if payload.get("success") is False:- # MEASURED 2026-09-30 (adom-bridge 2.1.163): shell_execute sometimes fails- # FAST (exit 1, under 3 s, empty stdout AND stderr) and the identical- # command succeeds immediately after. MEASURED AGAIN 2026-10-01: it- # happened TWICE IN A ROW (260 ms each). That shape is 'could not ask',- # not an answer, so it is retried up to THREE times, 2 s apart, and every- # retry is printed. Any other failure shape, or a fourth failure, is still- # Unreadable.+ # A FAST, EMPTY exit 1 (under 5 s, nothing on stdout or stderr). It was+ # called "transient" and it was not: on 2026-09-30 it was curl.exe against+ # the dead loopback port (exit 7, surfacing as an empty exit 1), and on+ # 2026-10-01 it was the launch gate's UIA probe, deterministically — four+ # attempts in a row. Since `_wrap`, a script that fails prints WHY, so a+ # truly empty failure means the script never ran at all; the bounded+ # retry (three times, 2 s apart, each printed) stays for that case only.+ # Any other failure, or a fourth, is Unreadable — with the script's own+ # error text. fast_empty = (payload.get("exitCode") == 1 and not (payload.get("stdout") or "") and not (payload.get("stderr") or "") and (payload.get("durationMs") or 99999) < 5000) if fast_empty and _attempt < PS_FAST_EMPTY_RETRIES:@@ -150,7 +174,11 @@ def ps(script, reason, timeout=120, _attempt=0): % (_attempt + 1, PS_FAST_EMPTY_RETRIES, PS_RETRY_BACKOFF_SECS)) time.sleep(PS_RETRY_BACKOFF_SECS) return ps(script, reason, timeout, _attempt=_attempt + 1)- raise Unreadable("shell_execute failed: %s" % json.dumps(payload)[:300])+ said = "\n".join(l for l in (payload.get("stdout") or "").splitlines()+ if l.startswith("PS-ERROR: ") or l.startswith("PS-NATIVE-EXIT: "))+ raise Unreadable("shell_execute failed (exit %s): %s%s"+ % (payload.get("exitCode"), said or "no error text at all — the script never ran",+ "" if said else " / " + json.dumps(payload)[:200])) out = payload.get("stdout") if out is None: raise Unreadable("shell_execute returned no stdout: %s" % json.dumps(payload)[:300])
tools/launch-gate.py+103−13@@ -82,6 +82,28 @@ class Unreadable(Exception): """ +# EVERY SCRIPT SAYS WHY IT FAILED (2026-10-01). A shell_execute that exits 1 with+# empty stdout AND stderr was read for two days as a "transient" and retried; it+# was never transient — on 09-30 it was curl.exe against the dead loopback port+# (curl exit 7 surfacing as an empty exit 1), and on 10-01 it was the launch+# gate's UIA probe, every single time. So every script now runs inside a wrapper+# that PRINTS its own failure to stdout: a trap for terminating errors, and on a+# non-zero native exit the exit code and every error PowerShell recorded. Empty+# stderr cannot hide a failure again. ($ErrorActionPreference is deliberately left+# at its default: making it Stop would turn the non-terminating errors the+# measured scripts already tolerate into new failures.)+def _wrap(script):+ return (+ "$Error.Clear(); "+ "trap { [Console]::Out.WriteLine('PS-ERROR: ' + ($_ | Out-String).Trim()); exit 1 }; "+ + script ++ "\n$__code = $LASTEXITCODE; "+ "if ($__code) { [Console]::Out.WriteLine('PS-NATIVE-EXIT: ' + $__code); "+ "foreach ($__e in $Error) { [Console]::Out.WriteLine('PS-ERROR: ' + ($__e | Out-String).Trim()) }; "+ "exit $__code }"+ )++ def ps(script, reason, timeout=120, _attempt=0): """Run PowerShell on Drew's desktop and return its stdout. @@ -93,7 +115,7 @@ def ps(script, reason, timeout=120, _attempt=0): log directory, and `curl.exe` to the bridge's loopback port. There is no kill, no write outside $env:TEMP, and no path outside %APPDATA%\\DassaultSystemes. """- enc = base64.b64encode(script.encode("utf-16le")).decode()+ enc = base64.b64encode(_wrap(script).encode("utf-16le")).decode() r = subprocess.run( [ "adom-bridge", "--target", "adom_drew2", "--ai-thread", THREAD, "shell_execute",@@ -111,13 +133,15 @@ def ps(script, reason, timeout=120, _attempt=0): raise Unreadable("adom-bridge did not return JSON: %s%s" % (r.stdout[:200], (" / " + r.stderr[:200]) if r.stderr else "")) if payload.get("success") is False:- # MEASURED 2026-09-30 (adom-bridge 2.1.163): shell_execute sometimes fails- # FAST (exit 1, under 3 s, empty stdout AND stderr) and the identical- # command succeeds immediately after. MEASURED AGAIN 2026-10-01: it- # happened TWICE IN A ROW (260 ms each). That shape is 'could not ask',- # not an answer, so it is retried up to THREE times, 2 s apart, and every- # retry is printed. Any other failure shape, or a fourth failure, is still- # Unreadable.+ # A FAST, EMPTY exit 1 (under 5 s, nothing on stdout or stderr). It was+ # called "transient" and it was not: on 2026-09-30 it was curl.exe against+ # the dead loopback port (exit 7, surfacing as an empty exit 1), and on+ # 2026-10-01 it was the launch gate's UIA probe, deterministically — four+ # attempts in a row. Since `_wrap`, a script that fails prints WHY, so a+ # truly empty failure means the script never ran at all; the bounded+ # retry (three times, 2 s apart, each printed) stays for that case only.+ # Any other failure, or a fourth, is Unreadable — with the script's own+ # error text. fast_empty = (payload.get("exitCode") == 1 and not (payload.get("stdout") or "") and not (payload.get("stderr") or "") and (payload.get("durationMs") or 99999) < 5000) if fast_empty and _attempt < PS_FAST_EMPTY_RETRIES:@@ -125,7 +149,11 @@ def ps(script, reason, timeout=120, _attempt=0): % (_attempt + 1, PS_FAST_EMPTY_RETRIES, PS_RETRY_BACKOFF_SECS)) time.sleep(PS_RETRY_BACKOFF_SECS) return ps(script, reason, timeout, _attempt=_attempt + 1)- raise Unreadable("shell_execute failed: %s" % json.dumps(payload)[:300])+ said = "\n".join(l for l in (payload.get("stdout") or "").splitlines()+ if l.startswith("PS-ERROR: ") or l.startswith("PS-NATIVE-EXIT: "))+ raise Unreadable("shell_execute failed (exit %s): %s%s"+ % (payload.get("exitCode"), said or "no error text at all — the script never ran",+ "" if said else " / " + json.dumps(payload)[:200])) out = payload.get("stdout") if out is None: raise Unreadable("shell_execute returned no stdout: %s" % json.dumps(payload)[:300])@@ -248,6 +276,52 @@ def check(ok, claim, detail=""): return ok +def optional_step(name, fn):+ """AN OPTIONAL CAPTURE NEVER ABORTS A GATE whose claims passed (2026-10-01:+ the UIA capture — the gate's last, read-only step — made every run exit 3).+ A failure is printed with its reason and recorded as a NOTE, not a FAIL."""+ try:+ fn()+ return True+ except Unreadable as e:+ print(" %s could not be captured: %s" % (name, e))+ NOTES.append("%s (optional, read-only) could not be captured: %s" % (name, e))+ return False+++def self_test():+ """Check the gate's own plumbing against canned failures — no machine."""+ def boom():+ raise Unreadable("shell_execute failed (exit 1): PS-ERROR: simulated")+ before = len(FAILURES)+ ok = optional_step("UIA reply capture", boom) is False and len(FAILURES) == before \+ and any("UIA reply capture" in n for n in NOTES)+ # And ps() really sends the WRAPPED script: capture what it would run.+ captured = {}++ class _Reply:+ stdout = json.dumps({"success": True, "exitCode": 0, "stdout": "ok"})+ stderr = ""+ returncode = 0++ real_run = subprocess.run++ def _fake(args, **_kw):+ captured["args"] = args+ return _Reply()++ subprocess.run = _fake+ try:+ ps("Get-Date", "launch gate self-test")+ finally:+ subprocess.run = real_run+ sent = base64.b64decode(json.loads(captured["args"][-1])["command"].split()[-1]).decode("utf-16le")+ ok = ok and "trap" in sent and "PS-NATIVE-EXIT" in sent and "Get-Date" in sent+ print("\n" + "=" * 72)+ print("SELF-TEST PASSED" if ok else "SELF-TEST FAILED")+ return 0 if ok else 1++ def probe_find_control(): """CAPTURE the reply shape of desktop_find_control. Read-only: it looks for a control and reports what came back. Nothing is clicked, ever."""@@ -271,11 +345,25 @@ def probe_find_control(): "command": "desktop_find_control", "args": {"hwnd": target.get("hwnd"), "contains": "File", "role": "menuitem"}, })+ # SAYS WHY, ALWAYS (2026-10-01). This step failed deterministically — exit 1,+ # empty output, ~300 ms, on every run — and the gate could not say why. The+ # script now reports its own failure: no ab direct-API address in this+ # shell, curl's exit code and output, or the exception. An empty answer is a+ # valid answer and is said as one. script = (+ "try { "+ "if (-not $env:ADOM_DIRECT_API_URL) { "+ "'UIA-CAPTURE: ADOM_DIRECT_API_URL is not set in this shell - ab''s direct API is "+ "not reachable from shell_execute, so desktop_find_control cannot be asked from here'; "+ "exit 0 }; " "$b=@'\n" + body + "\n'@; $p=\"$env:TEMP\\swx_uia.json\"; " "[IO.File]::WriteAllText($p,$b); "- "curl.exe -s --max-time 40 -H \"Content-Type: application/json\" "- "--data-binary \"@$p\" $env:ADOM_DIRECT_API_URL/command"+ "$o = curl.exe -s --max-time 40 -H \"Content-Type: application/json\" "+ "--data-binary \"@$p\" \"$env:ADOM_DIRECT_API_URL/command\" 2>&1 | Out-String; "+ "if ($LASTEXITCODE -ne 0) { 'UIA-CAPTURE: curl exit ' + $LASTEXITCODE + ': ' + $o; exit 0 }; "+ "if (-not $o.Trim()) { 'UIA-CAPTURE: an empty reply (no matching control)'; exit 0 }; "+ "$o "+ "} catch { 'UIA-CAPTURE: ' + $_.Exception.GetType().Name + ': ' + $_.Exception.Message; exit 0 }" ) out = ps(script, "SOLIDWORKS launch gate: capturing ab's desktop_find_control reply shape " "(read-only; nothing is clicked)", 60)@@ -373,8 +461,10 @@ def route_table(launch): def main(): baseline_only = "--baseline" in sys.argv+ if "--self-test" in sys.argv:+ return self_test() if "--probe-uia" in sys.argv:- probe_find_control()+ optional_step("UIA reply capture", probe_find_control) return 0 if "--probe-handle-launches" in sys.argv: which_world()@@ -554,7 +644,7 @@ def main(): if attributable and final.get("attributed") is False: NOTES.append("the pre-check saw a root but the finished attempt reports none — the route " "that ran was not the one the pre-check probed")- probe_find_control()+ optional_step("UIA reply capture", probe_find_control) ledger = verb("swx_action_ledger", {"limit": 60}, 40) for a in ledger.get("actions", []): if str(a.get("action", "")).startswith("solidworks_launch") or "crash" in str(a.get("action", "")):
tools/quit-gate.py+37−9@@ -113,6 +113,28 @@ class Refused(Exception): """The human preconditions were not met. Nothing was attempted.""" +# EVERY SCRIPT SAYS WHY IT FAILED (2026-10-01). A shell_execute that exits 1 with+# empty stdout AND stderr was read for two days as a "transient" and retried; it+# was never transient — on 09-30 it was curl.exe against the dead loopback port+# (curl exit 7 surfacing as an empty exit 1), and on 10-01 it was the launch+# gate's UIA probe, every single time. So every script now runs inside a wrapper+# that PRINTS its own failure to stdout: a trap for terminating errors, and on a+# non-zero native exit the exit code and every error PowerShell recorded. Empty+# stderr cannot hide a failure again. ($ErrorActionPreference is deliberately left+# at its default: making it Stop would turn the non-terminating errors the+# measured scripts already tolerate into new failures.)+def _wrap(script):+ return (+ "$Error.Clear(); "+ "trap { [Console]::Out.WriteLine('PS-ERROR: ' + ($_ | Out-String).Trim()); exit 1 }; "+ + script ++ "\n$__code = $LASTEXITCODE; "+ "if ($__code) { [Console]::Out.WriteLine('PS-NATIVE-EXIT: ' + $__code); "+ "foreach ($__e in $Error) { [Console]::Out.WriteLine('PS-ERROR: ' + ($__e | Out-String).Trim()) }; "+ "exit $__code }"+ )++ def ps(script, reason, timeout=120, _attempt=0): """Run PowerShell on Drew's desktop and return stdout. @@ -122,7 +144,7 @@ def ps(script, reason, timeout=120, _attempt=0): READ functions), and `curl.exe` to the bridge's loopback port. There is no process termination and no window message anywhere in it. """- enc = base64.b64encode(script.encode("utf-16le")).decode()+ enc = base64.b64encode(_wrap(script).encode("utf-16le")).decode() r = subprocess.run( [ "adom-bridge", "--target", "adom_drew2", "--ai-thread", THREAD, "shell_execute",@@ -140,13 +162,15 @@ def ps(script, reason, timeout=120, _attempt=0): raise Unreadable("adom-bridge did not return JSON: %s%s" % (r.stdout[:200], (" / " + r.stderr[:200]) if r.stderr else "")) if payload.get("success") is False:- # MEASURED 2026-09-30 (adom-bridge 2.1.163): shell_execute sometimes fails- # FAST (exit 1, under 3 s, empty stdout AND stderr) and the identical- # command succeeds immediately after. MEASURED AGAIN 2026-10-01: it- # happened TWICE IN A ROW (260 ms each). That shape is 'could not ask',- # not an answer, so it is retried up to THREE times, 2 s apart, and every- # retry is printed. Any other failure shape, or a fourth failure, is still- # Unreadable.+ # A FAST, EMPTY exit 1 (under 5 s, nothing on stdout or stderr). It was+ # called "transient" and it was not: on 2026-09-30 it was curl.exe against+ # the dead loopback port (exit 7, surfacing as an empty exit 1), and on+ # 2026-10-01 it was the launch gate's UIA probe, deterministically — four+ # attempts in a row. Since `_wrap`, a script that fails prints WHY, so a+ # truly empty failure means the script never ran at all; the bounded+ # retry (three times, 2 s apart, each printed) stays for that case only.+ # Any other failure, or a fourth, is Unreadable — with the script's own+ # error text. fast_empty = (payload.get("exitCode") == 1 and not (payload.get("stdout") or "") and not (payload.get("stderr") or "") and (payload.get("durationMs") or 99999) < 5000) if fast_empty and _attempt < PS_FAST_EMPTY_RETRIES:@@ -154,7 +178,11 @@ def ps(script, reason, timeout=120, _attempt=0): % (_attempt + 1, PS_FAST_EMPTY_RETRIES, PS_RETRY_BACKOFF_SECS)) time.sleep(PS_RETRY_BACKOFF_SECS) return ps(script, reason, timeout, _attempt=_attempt + 1)- raise Unreadable("shell_execute failed: %s" % json.dumps(payload)[:300])+ said = "\n".join(l for l in (payload.get("stdout") or "").splitlines()+ if l.startswith("PS-ERROR: ") or l.startswith("PS-NATIVE-EXIT: "))+ raise Unreadable("shell_execute failed (exit %s): %s%s"+ % (payload.get("exitCode"), said or "no error text at all — the script never ran",+ "" if said else " / " + json.dumps(payload)[:200])) out = payload.get("stdout") if out is None: raise Unreadable("shell_execute returned no stdout: %s" % json.dumps(payload)[:300])