main
Drew Owens 0.9.12 source 97dcc9e 19d ago

Quitting SOLIDWORKS Connected, gracefully — the investigation

Drew, 2026-09-08: "it is critical that we get this right."

Question. How can the bridge reliably and gracefully QUIT SOLIDWORKS Design Professional for Makers 2026 (a SOLIDWORKS Connected / 3DEXPERIENCE R2026x edition) when it has zero open documents — and never by killing the process?

Scope of this pass. Research and design only. Nothing on Drew's desktop was started, stopped, closed, clicked or foregrounded to produce this document; every desktop probe below is a read (process census, window attributes, log files, registry values, and two read-only bridge verbs). SOLIDWORKS was up and in use throughout — openDocs 5 at the time of writing — so the experiment in §5 has not been run. It is written to be run later, on a bridge-launched, 0-document session, with Drew's go-ahead.


Executive summary — the ten lines that matter

  1. The most likely working route is a ladder, and its first rung is not what we have today. UserControl = false → ExitApp() → release every COM reference we hold (v1 does neither the first nor the last), verified by the process census rather than by GetActiveObject.
  2. v1 cannot tell "declined" from "quit but pinned by our own pointer." The com host holds a live IDispatch on SOLIDWORKS at the exact moment it asks whether SOLIDWORKS went away, and the documented meaning of UserControl is that an automation client's outstanding reference is what keeps the app alive. Some fraction of the observed decline may be self-inflicted.
  3. If ExitApp really is declined at 0 documents, the strongest evidence points at WM_CLOSE to the main frame. This machine's previous session ended at 15:24:05 with the journal recording swApp.ExitApp + End Sub and the 3DEXPERIENCE helper logging Disconnect — i.e. the application's own documented exit path ran to completion — and that shutdown is the one Drew attributes to a window-manager close.
  4. Three untried rungs sit around those, and nobody in the public ecosystem uses any of them: RunCommand(swCommands_App_Exit = 2825) (File ▸ Exit inside the app's own dispatcher); RunMacro2 of a two-line shipped .swp that calls ExitApp from inside the process (this edition restricts add-ins but runs macros, and the VBA host is already loaded); and the Restart Manager (RmShutdown with RmForceShutdown omitted) — the OS's own documented polite shutdown, targeted by pid and creation time, which never terminates anything and returns ERROR_FAIL_SHUTDOWN with a per-process reason when refused. It goes last, because ENDSESSION_CLOSEAPP tells an app to save without prompting, and a rung whose failure mode is a silent save must sit below the rungs whose failure mode is a dialog we can see.
  5. A graceful quit is measurable without trusting SOLIDWORKS at all: the pid and its creation time gone; the per-session children (sldProcMon, sldsurfacing, sldimport, swCefSubProc ×10) reaped; the tray/daemon set (ENOPLMCSAClient, EdmServerV6, every 3DEXPERIENCELauncher*) still alive, because those survive a graceful exit by design; swxJRNL.swj ending in swApp.ExitApp + End Sub and its lock released; no new top-level window; no Error Report dialog.
  6. %TEMP%\swx<pid> leftovers are NOT an unclean-exit signal — four dead sessions left theirs behind, including the one that exited cleanly. Ruled out as evidence before anyone builds a check on it.
  7. The precondition rule in the code today is the wrong one. swx_stop gates on unsavedDocuments == 0, not on the open-document count — so right now, with five of Drew's clean documents open, it would try to quit. v2 requires openDocuments == Some(0), and force may waive only the unsaved question, never the count, the dialog census, busy, hung or identity.
  8. No rung ever answers a dialog. A save prompt (including Connected's Save all to This PC / Save all to 3DEXPERIENCE / Don't Save / Cancel) stops the ladder and is reported verbatim; so does an unrecognised window, a licence dialog, and the hang-triggered Status Feedback telemetry modal. And no rung ever escalates to a kill: the ladder ends at (f), RmForceShutdown is unreachable from the code, and there is no rung (g). (Vendor sources settle one worry: there is no sign-out prompt on exit, and closing SOLIDWORKS does not log the user out of the platform.)
  9. The bridge is the right place for this and the classifier is right to refuse the shell. A Process.CloseMainWindow() from the agent's shell has no document count, no dialog census, no attributed identity, no consent record and no audit; swx_stop has all five, refuses on any unknown, and writes a ledger line per attempt — including the refusals v1 records nowhere.
  10. Biggest unknown: whether ExitApp is genuinely declined on a verified zero-document session at all. The ledger on this machine contains no stop event, so the decline we can evidence came from a session with a dirty Part1 open (live run 3, 0.3.0) — which is a save-prompt stall, a different failure with a different fix. Everything downstream of that question is a ladder designed for a fact that has not been measured yet, and §5.7 measures it first.

0. What this machine actually says (measured 2026-09-08, read-only)

This section exists because most of the folklore about quitting SOLIDWORKS is about the classic desktop product, and almost none of it is about a Connected edition with no add-ins and a platform licence. These are facts from Drew's machine, not inferences.

0.1 The session, and who started it

Fact Value How it was read
sldworks.exe pid 107688, started 15:35:41, parent 15536 (gone) Win32_Process
Its image C:\Program Files\Dassault Systemes\SOLIDWORKS 3DEXPERIENCE R2026x\SOLIDWORKS\sldworks.exe Win32_Process.ExecutablePath
Command line (no arguments — no -Embedding, so this is a UI session, not a COM-activated one) Win32_Process.CommandLine
Who launched it the bridge, via the platform shortcut ledger solidworks_launched … route:"platformShortcut" at 15:35:31
Edition connected, version 34.3.0, canLaunchProgrammatically:false swx_status
Open documents now 5, all clean (unsavedDocuments 0), active = Drew's Composer_validation.SLDASM swx_get_app_state
Main frame hwnd 6622700, class Afx:0000000140000000:8:…, visible, enabled, not hung EnumWindows + IsHungAppWindow
SC_CLOSE on that frame GetMenuState(GetSystemMenu(hwnd), 0xF060) = 0x20001000 — MF_GRAYED/MF_DISABLED clear, so Close is ENABLED GetMenuState
Blocking dialogs none — no #32770 top-level window in the process at all window census
Other windows in the process the VBA host (wndclass_desked_gsk, "Microsoft Visual Basic for Applications", hidden) + NVOpenGLPbuffer, PbrsHost, ThunderMain, MsoStdCompMgr, tooltips window census

Two things follow immediately. The frame's Close item is enabled, so the window-manager rungs in §5 are available on this app right now (that was not a given — an app can grey out SC_CLOSE while modal). And the VBA host is loaded, which makes the RunMacro2 rung in §5 real rather than theoretical.

0.2 The 3DEXPERIENCE processes that are alive with SOLIDWORKS

Everything with a Dassault image path, at rest, with 5 documents open:

107688 sldworks.exe                     <- the app
117996 sldProcMon.exe                   ) SOLIDWORKS' own out-of-process
101704 sldsurfacing.exe                 ) servers, started by sldworks.exe
121600 sldimport.exe                    )
 68248 ViewServerV6.exe                 ) 3DEXPERIENCE-side servers. EdmServerV6 +
 34020 EdmServerV6.exe                  ) ENOPLMCSAClient are the "PLM Services"
123724 ENOPLMCSAClient.exe              ) tray apps and SURVIVE a graceful exit
10 x   swCefSubProc.exe                 <- the CEF (browser) subprocesses
 90552 3DEXPERIENCELauncherSysTray.exe  <- PLATFORM daemon: NOT ours, never touch
  6076 3DEXPERIENCELauncher.exe         <- PLATFORM service: NOT ours, never touch

No SWXDesktopLauncher.exe, no CATSTART.exe, and no SWXDesktopHelper.exe are running. The launcher chain exits the moment it has spawned sldworks.exe (its log ends Launched successfully ExitCode: 0 … Run End), so there is no launcher process to notify, and nothing of the launch chain to reap on the way out.

That gives the single most useful measurement in this whole document: a graceful quit should reap the per-session server processes (sldProcMon, sldsurfacing, sldimport, swCefSubProc ×10), while the tray/daemon set must survive untouched: 3DEXPERIENCELauncher*, and — this is documented, see §2.6 — ENOPLMCSAClient.exe and EdmServerV6.exe, which are the 3DEXPERIENCE PLM Services tray apps and are supposed to outlive SOLIDWORKS. (ViewServerV6.exe is undocumented either way: record what it does, conclude nothing.) A kill would leave the first group orphaned. So "did it quit cleanly" is answerable from the process census alone, without trusting anything SOLIDWORKS returned — provided the expected survivors are not mistaken for leaks.

0.3 How the PREVIOUS session ended — the best evidence we have

The session before this one ran from 2026-09-04 17:23 to 2026-09-08 15:24, and three independent artefacts agree on the instant it ended:

Artefact What it says
%APPDATA%\SOLIDWORKS\SOLIDWORKS 2026\swxJRNL.BAK (147 KB, 15:24:05) line 2933 swApp.ExitApp, line 2934 End Sub — the only ExitApp in the file, and the journal closed itself
%APPDATA%\DassaultSystemes\SWXDesktopHelper\20260904172313_….log (15:24:08) a 30-second licence heartbeat (Offline License Count 0) up to 15:23:53, then one final line: 15:24:04 info Disconnect
%TEMP%\swx98960\ left behind, last written 15:24:04

Read that carefully, because it is the closest thing we have to a proven graceful quit on this edition:

  • SOLIDWORKS' journal records UI-driven commands as their VBA equivalents (API calls the bridge makes are not journaled — the current session has 5 bridge-opened documents and its journal never mentions them). So swApp.ExitApp at the end of that file is the application's own exit code path running — the same path File ▸ Exit runs — and End Sub after it means the journal was closed properly, i.e. the process was not killed.
  • The 3DEXPERIENCE helper logged Disconnect and stopped. The platform side was released, in order, one second before the journal closed.
  • And the per-session temp directory survived a clean exit. %TEMP%\swx<pid> leftovers exist for four dead sessions (swx13460, swx98000, swx88556, swx98960) — so a leftover temp dir is not evidence of an unclean exit and must not be used as one.

That 15:24 shutdown is the event Drew describes as "a stuck modal was closed via the window manager and the whole app died". If that attribution is right, then a window-manager close on this edition ran the full documented exit path (journal ExitApp + End Sub, helper Disconnect) — which makes rung (c) in §5 the most promising route, not the most dangerous one. It is stated here as the leading reading of the evidence and not as a proven fact: nothing recorded which gesture produced that shutdown.

0.4 The licence heartbeat is NOT running in the current session

The previous session had a SWXDesktopHelper writing a licence heartbeat every 30 s. This one does not: today's helper logs (20260908153544_….log and …_dslcc.log) are 0 bytes, no helper process exists, and today's launcher log says

15:35:41 info Started from desktop shortcut skipping PLMCSA client start

The only platform record of this session is %APPDATA%\SOLIDWORKS\SOLIDWORKS 2026\3dexperience-log.json, which contains one Login operation (loginEx, setsecuritycontext, getcastransientinfo, 6.1 s, 20:36:51Z) for pid 107688, tenant OI000616940, and nothing since.

Consequence for the experiment: do not expect a helper Disconnect line from a bridge-launched session — there is no helper to write one. The platform-side signal to watch is instead whether 3dexperience-log.json gains a logout / disconnect operation. Not the exit of EdmServerV6 / ENOPLMCSAClient — those are the PLM Services tray apps and are documented to stay (§2.6).

0.5 The ledger has no stop event of any kind

CORRECTED 2026-10-01 (§5.15): the session this section describes is now understood: a decline at zero documents happens on a session that has NEVER HELD a document, and stops happening once one has been opened and closed. The rule is about session HISTORY, not the current document count.

%LOCALAPPDATA%\Adom Bridge\bridges-cache\solidworks\ledger.jsonl (637 bytes, the only ledger on the machine — both the installed bridge on port 49861 and the dev bridge on 24695 share this scratch root) contains exactly three entries:

solidworks_launched  15:35:31  route platformShortcut, edition connected
document_created     16:14:10  Part1
document_closed      16:15:00  Part1, by close_doc, bridgeCreated:true

There is no solidworks_stop_declined and no solidworks_stopped line. So the reported "ExitApp is declined even with 0 documents" observation did not come from swx_stop on this machine (that path always records a ledger line — src/verbs.rs:1078). The measured, ledger-backed decline we do have is live run 3 on 0.3.0, swx_stop {force:true} → success:true, stopped:false, and that session had a dirty Part1 open (docs/FUSION-PARITY-BRIEF.md, §"The gate was closed in the wrong direction").

This is not a quibble, it is the first thing the experiment must settle. "0 documents and ExitApp still declined" and "documents open, so SOLIDWORKS raised a save prompt and the quit stalled" call for completely different ladders. §5 therefore measures rung (a) first, on a genuinely Some(0) session, and records the result in the ledger.

SETTLED, LIVE, 2026-09-17 — it was the save prompt.

On a bridge-launched session emptied by swx_close_all_safely, swx_stop {userAuthorized, maxRung:"a"} quit SOLIDWORKS on rung (a): UserControl=false, ExitApp, every COM reference released, and the process verified absent from the census. swx_shutdown then removed a stray empty instance (pid 145188), after which tasklist showed no sldworks.exe at all.

ExitApp is NOT declined at zero documents on this edition. The long-standing "declined even with 0 documents" belief was a SAVE-PROMPT STALL read as a refusal — exactly the alternative this section said had to be separated first, and exactly the reason the ladder was built pinned at rung (a) rather than escalating. Nothing above rung (a) has ever been needed on this machine.

The practical consequence is the one this whole document was written to reach: the blocker was never the quit, it was the documents. Preserve them first (§5.12) and the quit is one call.


1. The API semantics of quitting

Sourcing note: help.solidworks.com serves a JavaScript shell to every fetcher we have (verified again today — the 2026 ExitApp page returns navigation and no content), so official wording below is quoted from search-result snippets of those same pages and is marked as such. Forum wording is quoted from the pages themselves. Where a claim is folklore, it is labelled folklore.

1.1 ISldWorks::ExitApp

CORRECTED 2026-10-01 (§5.15): "ExitApp is not declined at zero documents" (09-17) and "ExitApp declined at zero documents" (09-08) are both true — of different sessions. MEASURED on one session (pid 20144): declined three times while it had never held a document; quit in 3.4 s once one scratch part had been created and closed in it.

Official help (…api/sldworksapi/…ISldWorks~ExitApp.html, via snippet):

"This method is not normally used with macros (*.swp) because it shuts down your SOLIDWORKS session." … "Calling this method ensures that no SOLIDWORKS processes are left running when your program ends."

Three things the documentation does not say, and all three matter:

  • it does not document a return value that reports refusal (it is a void in the COM sense — our host reads success from the absence of an HRESULT failure, which is why v1 has to verify separately);
  • it does not document any precondition — no "close documents first", no "UserControl must be false";
  • it says nothing about SOLIDWORKS Connected, and ExitApp appears on no published discontinued-for-Connected list (§4.4).

So "declined" is not a documented state. What is documented, repeatedly, is that the surrounding COM ownership decides whether the process actually goes away.

Measured live, 2026-09-17, on SOLIDWORKS Connected: with zero documents open and UserControl set false, ExitApp returned without an HRESULT failure and the process was gone from the census — the first rung, on the first attempt. The "declined" state this section declined to assume was never real; what had been observed under that name was a save prompt on a session that still had documents open. Ownership was the whole story, as the documentation implies and as §1.2 argues.

1.2 ISldWorks::UserControl — the flag that decides who owns the lifetime

Official help (…ISldWorks~UserControl.html, via snippet):

"If the SOLIDWORKS application is started by your program, then the SOLIDWORKS application closes when your program ends. However, if you pass control of the SOLIDWORKS application to the end-user by setting the UserControl property to true, then it remains running after your program ends."

That is the whole mechanism. UserControl = true means "this session outlives its automation client", and it is what every ecosystem project sets (SolidPilot sets it explicitly; the pywin32 recipes all recommend it) precisely so their scripts do not take the user's SOLIDWORKS down with them.

Now apply it to our situation. This session was launched by the bridge via a shortcut, so sldworks.exe runs with no -Embedding (measured, §0.1): it is a normal interactive session that we later attached to with GetActiveObject, not a COM-activated one we own. In that arrangement:

  • SOLIDWORKS is the user's session by construction; the bridge is a guest;
  • our com host holds a live IDispatch (Disp(pub IDispatch), #[derive(Clone)] — a refcounted handle: clone is AddRef, drop is Release) for the whole duration of the "stop" call, including across the verification;
  • the widely-repeated remedy for "ExitApp did nothing" is therefore not a different call, it is releasing the reference: the canonical answer is ExitApp() followed by Marshal.FinalReleaseComObject(swApp) rather than merely dropping the variable, and xCAD.NET's Release() does exactly that in that order (§3.2).

"swApp.ExitApp() does nothing when UserControl is True" is folklore — we found no primary source stating it as a rule. What the primary source does say is the sentence above, from which the folklore is a reasonable (and probably correct) inference: a session flagged as the user's, holding a live client reference, has two independent reasons not to disappear. The practical upshot is the same either way, and it is rung (a):

remember previous UserControl
UserControl = false          // "this session no longer outlives its client"
ExitApp()
release EVERY reference this process holds, to zero
then measure

and, if the quit is declined, restore UserControl to what it was. Leaving a user-launched session flagged UserControl = false is a booby trap: it invites SOLIDWORKS to disappear the next time the com host exits or is respawned, which this bridge does routinely (crash recovery, process_kill of a wedged host). That restore is not optional politeness; it is the difference between a declined quit and a delayed one.

1.3 UserControlBackground, Visible, FrameState, CommandInProgress

  • UserControlBackground — whether the user can interact while a background automation task runs. The bridge sets it true only on the COM-launch path (com.rs:178), which on this edition never runs; an attached session never has it touched. It has no documented bearing on exit. Leave it alone.
  • Visible — hiding a window does not close it, and hiding SOLIDWORKS to "make the quit quieter" would produce exactly the invisible-lingering-process that the batch-tooling threads complain about. Never set Visible = false as part of a quit.
  • FrameState (swWindowState_e) — minimise/maximise/restore of the frame. Irrelevant to quitting, and it is journaled (the journal in §0.3 is full of myModelView.FrameState = swWindowState_e.swWindowMaximized), so touching it writes noise into the very artefact we use as evidence.
  • CommandInProgress — the honest "is the app mid-command" read, and it is precondition P4. It is currently unreadable through swx_com_eval (§5.6).

1.4 CloseAllDocuments, and whether to close first

The API on this build exposes ISldWorks::CloseAllDocuments(includeUnsaved) → bool (what the bridge calls today). We found no CloseAllDocuments2 in the 2026 API and did not assume one; IModelDoc2::Quit does not exist either — the per-document discard primitive is ISldWorks::QuitDoc(name) ("closes the specified document without saving changes"), which pyswx wraps and which our swx_close_doc approximates with CloseDoc.

Should the ladder close documents first? No — and this is a deliberate design decision, not an omission. At openDocuments == Some(0) there is nothing to close, and below that precondition the bridge has no business quitting at all. Folding a CloseAllDocuments(true) into swx_stop would mean the quit verb silently discards documents, which is precisely the coupling swx_close_all_documents was separated out to avoid (it is confirm-gated, and it leaves dirty documents open unless closeUnsaved:true). A caller that wants a clean slate calls that verb, sees what it skipped and why, and then calls swx_stop.

1.5 RunCommand(swCommands_App_Exit) — the UI route, from inside

ISldWorks::RunCommand(commandID, tabName) dispatches a real SOLIDWORKS UI command; the enumeration carries 3000+ ids. The one we want is

swCommands_App_Exit = 2825        // File > Exit
swCommands_FileClose = 2789       // File > Close  (NOT what we want)

verified by fetching tdsmith/swharness/swcommands.py (a machine-extracted dump of swCommands_e) and corroborated by search against the official swCommands_e enumeration page. Do not confuse it with swCommands_FileClose = 2789, or with swCommands_Exit_Sketch = 862.

Three caveats for the experiment. RunCommand is documented as not supported for every command. Whether it returns before or after the command completes could not be established — the official Remarks page is unreadable to every fetcher we have, and CodeStack has a whole article on running commands synchronously precisely because the default is not obviously so; the rung's measurement is therefore the process census after a budget, never the call's return. And a RunCommand that reaches the exit path will tear down the COM server underneath us exactly as ExitApp does, so the rung must tolerate the same RPC-disconnect HRESULTs that com.rs already whitelists.

(The purest form of this gesture is WM_COMMAND with ID_APP_EXIT posted to the frame — on MFC that is literally what the menu item does — but the id has to be harvested with Spy++ on this install, and CodeStack's own warning about the technique is that "sending a message in an unexpected way … might result in unpredictable behavior (including crash)". RunCommand reaches the same dispatcher with a documented signature, so that is the rung.)

1.6 SendMsgToUser2 — never, in any rung

SendMsgToUser2 puts a modal dialog on the user's screen and blocks until someone answers it. It is already a named hazard in the eval allowlist (expr.rs::NAMED_HAZARDS) for exactly that reason. It has no place in a quit path: a bridge that pops a modal in order to close an application has created the wedge it was trying to avoid. Likewise SetUserPreferenceToggle — there are toggles that suppress prompts, and changing a user's SOLIDWORKS preferences permanently in order to make one quit quieter is not a trade this bridge makes. The rule stands: the bridge quits only when there is nothing to prompt about.

1.7 Connected-specific API facts

ISldWorks::ApplicationType (swApplicationType_e, swApplicationType_3DEXPERIENCE) is the official branch point for Connected, per the programming-guide page "SOLIDWORKS Design Connected to the 3DEXPERIENCE Platform", which states that "some SOLIDWORKS APIs have been added, changed, or discontinued for use with SOLIDWORKS Design connected to the 3DEXPERIENCE platform" and that "most working SOLIDWORKS Design macros will also work". The documented deltas are all file-storage-shaped (GetSaveTo3DExperienceOptions, SaveTo3DExperience, GetFilePLMID, restricted open modes). See §4.4.

Measured today on this build, read-only:

Probe Result
$sw.GetProcessID() 107688 — works
$sw.UserControl refused by our own allowlist (eval_member_blocked)
$sw.ApplicationType refused by our own allowlist (eval_member_blocked)
$sw.IsPLMConnected() 0x80020006 Unknown name — this member does not exist on this build, although expr.rs's own test cites it as an allowed read. Worth fixing that example.

IsSWApplicationClosing — sometimes cited as the way to know a shutdown is in progress — is an add-in/event-side notion (the DestroyNotify event and the SldWorks event sink), not something an out-of-process client can poll, and this bridge ships no add-in. It is not available to us and it is not in the design.


2. Window-manager routes, and exactly what each one means

2.1 First: get the HWND from COM, never by class name

ISldWorks::Frame → IFrame::GetHWnd is the documented way to obtain the frame handle so Win32 can be used on it. In 64-bit code the call is IFrame::GetHWndx64 — the help says outright "If your application must be x64 compatible, then you should use IFrame::GetHWndx64." Our com host calls Frame().GetHWnd() (com.rs:2312), reads it as an int, and has been lucky: 6622700 fits in 32 bits. That is a latent truncation bug on this bridge's only identification of the application window, and it should be GetHWndx64 with an i64 read.

And the negative result, which is worth as much: the frame's window class name is not a lookup key. "SW_Main" is folklore with no primary source; SOLIDWORKS is an MFC app whose classes are synthesized by AfxRegisterWndClass from style/cursor/brush/icon (hence the Afx:0000000140000000:8:… we measured) and the toolset number moves with the release (CodeStack drives its PropertyManager children by class AfxWnd140u — MFC 14.0). Treat any class name as diagnostic output, never as a matcher. abwin::is_frame_window is on the right side of this already: it treats anything that is not #32770 as a frame rather than matching Afx: positively.

2.2 The gestures, and what each one really is

Gesture What it really is Semantics against an MFC app like SOLIDWORKS Verdict
WM_CLOSE to the main frame the message a title-bar X produces This is File ▸ Exit, byte for byte. MFC TN022: ID_APP_EXIT → CWinApp::OnAppExit → m_pMainWnd->SendMessage(WM_CLOSE), and "the standard shutting down of the application (prompting for dirty files and so on) is handled by the CFrameWnd implementation." CFrameWnd::OnClose → CWinApp::SaveAllModified → per-document SaveModified prompts → DestroyWindow → the app exits. On this app the journal records that path as swApp.ExitApp (§0.3). the primary window route
WM_SYSCOMMAND / SC_CLOSE (0xF060) the system-menu Close item; also what a real Alt+F4 generates DefWindowProc turns it into a WM_CLOSE, so it lands in the same handler — but through OnSysCommand first, and "applications that modify the window menu must process WM_SYSCOMMAND messages" (SOLIDWORKS customizes heavily), so an app that filters one may still honour the other. Field report: PostMessage(WM_SYSCOMMAND, SC_CLOSE) "works in the scenarios in which CloseMainWindow() fails" — minimized windows, different credentials. Its enabled state is also readable before you send it; on this frame it is enabled (§2.5). Mask wParam with 0xFFF0. secondary route, and the only one whose availability we can check first
Restart Manager RmShutdown without RmForceShutdown the OS's own documented polite-shutdown sequence, targeted at a pid RmStartSession → RmRegisterResources(RM_UNIQUE_PROCESS{pid, creation time}) → RmGetList → RmShutdown(0, cb) sends WM_QUERYENDSESSION(ENDSESSION_CLOSEAPP), then WM_ENDSESSION, "and also sends a WM_CLOSE message for GUI applications that do not shut down on receiving WM_ENDSESSION." Any app returning FALSE cancels the shutdown, and with RmForceShutdown omitted the Restart Manager never terminates anything; a refusal comes back as ERROR_FAIL_SHUTDOWN (351) with per-process AppStatus. It also takes the pid and its creation time, which is the same identity pair our attribution law already requires. a real rung — but LAST, see the hazard below
WM_COMMAND(ID_APP_EXIT) to the frame what the File ▸ Exit menu item literally posts On MFC this is the most faithful possible File ▸ Exit simulation, and it is the community's documented "plan B" for a missing API command (harvest the id with Spy++, then SendMessage swFrame.GetHWnd(), WM_COMMAND, id, 0). Its author's own warning: "sending a message in an unexpected way … might result in unpredictable behavior (including crash)." The id must be harvested from this install. not in the ladder — RunCommand(2825) reaches the same dispatcher through a supported API
Alt+F4 as synthesized input SendInput, or posted WM_SYSKEYDOWN SendInput needs the target focused and foreground, SetForegroundWindow "cannot force a window to the foreground while the user is working with another window", it does not reset currently-held keys (a user holding Shift corrupts it), and — decisively — "this function fails when it is blocked by UIPI. Note that neither GetLastError nor the return value will indicate the failure was caused by UIPI blocking." Posted WM_SYSKEYDOWN is worse: accelerator translation happens in the message pump, so a posted key generally never becomes SC_CLOSE (Raymond Chen, twice). rejected: steals focus, races real input, fails silently
WM_QUIT / PostThreadMessage tells a message loop to stop pumping "Do not post the WM_QUIT message using the PostMessage function" (docs, verbatim); PostQuitMessage is intra-process only. It would drop SOLIDWORKS' loop with zero cleanup — no OnClose, no CanCloseFrame, no SaveAllModified, no ExitInstance — and it is unreliable anyway: a thread in a modal loop never dispatches it, so "the messages will be lost." A soft kill wearing a message's clothes. rejected outright
DestroyWindow destroy the window directly "A thread cannot use DestroyWindow to destroy a window created by a different thread." Impossible cross-process. impossible
EndTask(hwnd, FALSE, FALSE) one call: "only the close with WM_CLOSE is attempted" Equivalent to rung (c) with less control, and documented as "not intended for general use. It may be altered or unavailable in subsequent versions." fForce=TRUE forcibly destroys the window — never. not used
ShutdownBlockReasonCreate how an app blocks shutdown Callable only from the thread that created the window. It is a reason a shutdown might be declined, not a way to cause one. not applicable

The Restart Manager hazard, and why it is last rather than first. ENDSESSION_CLOSEAPP means *"the application must shut down. Any data should be saved automatically without prompting the user."* At openDocuments == Some(0) that is moot — there is nothing to save. But if P1 were ever wrong, the RM path is the one rung that could cause SOLIDWORKS to write the user's files without asking, where WM_CLOSE would raise a prompt that the ladder detects and stops on. Defence in depth means preferring the rung whose failure mode is a visible dialog over the rung whose failure mode is a silent save. So RM goes last, and its precondition sweep is re-run immediately before it.

2.3 SendMessage vs PostMessage vs SendMessageTimeout — and the silent-failure trap

  • SendMessage "does not return until the window procedure has processed the message", and cross-thread "the sending thread is blocked until the receiving thread processes the message." If closing raises a modal, the window procedure does not return until that modal is answered — so a plain SendMessage(WM_CLOSE) can block the com host for as long as the dialog stands.
  • SendMessageTimeout(..., SMTO_ABORTIFHUNG | SMTO_NORMAL, 5000) is the right instrument: same synchronous semantics, bounded, and it gives up early if the target "has not called GetMessage or a similar function within five seconds." On timeout it returns 0 and "does not always call SetLastError" — so SetLastError(ERROR_SUCCESS) first, then read it.
  • PostMessage returns immediately and never blocks — correct for the SC_CLOSE rung, where the measurement is the process census anyway. It is also exactly what Process.CloseMainWindow() does internally.
  • THE TRAP — UIPI failures look like success. Message sending is subject to UIPI: "the thread of a process can send messages only to message queues of threads in processes of lesser or equal integrity level", and "for compatibility reasons, SendMessage and other APIs will return success even if the API was blocked." So a blocked WM_CLOSE returns TRUE and does nothing. Every message rung must SetLastError(0) first and check for ERROR_ACCESS_DENIED (5), and report a UIPI block as its own outcome — never as "declined". On this machine both processes run as Drew and sldworks.exe is not elevated (launched by the bridge's own shortcut chain), so this should not bite — but resellers routinely advise running SOLIDWORKS as administrator, and if Drew ever does, every message rung silently stops working. Corollary: never elevate SOLIDWORKS, and never elevate the bridge.

2.4 Process.CloseMainWindow() — the gesture the classifier denied

Worth spelling out, because it is the thing that was actually tried. Its .NET implementation (Process.Windows.cs) is: read GWL_STYLE; if WS_DISABLED → return false; else PostMessageW(hwnd, WM_CLOSE, 0, 0). That is rung (c) with less control and one extra behaviour: with a modal up, the frame carries WS_DISABLED, so it returns false having sent nothing at all. Its documented limits are explicit — "does not force the application to quit", the app "can refuse to quit", and it is "identical to a user closing an application's main window using the system menu". MainWindowHandle is also a heuristic ("the window opened by the process that currently has the focus", cached, zero for hidden processes), which is one more reason to take the hwnd from IFrame::GetHWndx64 instead. Same gesture, worse instrument, no measurement — see §6 for why its denial from the agent's shell was correct.

2.5 What is true of THIS app, measured

Question Answer Instrument
Is the main frame an MFC frame? yes — class Afx:0000000140000000:8:0000000000010003:… (diagnostic only, see §2.1) GetClassName
Is it visible and enabled? yes and yes — no WS_DISABLED, so no modal owns it IsWindowVisible / IsWindowEnabled
Is it hung? no IsHungAppWindow
Is SC_CLOSE enabled on its system menu? yes — GetMenuState(GetSystemMenu(hwnd,false), 0xF060, MF_BYCOMMAND) = 0x20001000, MF_GRAYED (0x1) and MF_DISABLED (0x2) both clear GetMenuState
Is a modal up? no — the process owns no top-level #32770 at all EnumWindows + GetWindowThreadProcessId
Does GetLastActivePopup point elsewhere? no — the frame's last active popup is itself (6622700): the "no modal owns me" signature GetLastActivePopup
Was SOLIDWORKS launched with /SWDisableExitApp? NO — the launcher log reads ShellExecute …\sldworks.exe with no arguments, and Win32_Process.CommandLine for pid 107688 is the bare exe path launcher log + Win32_Process
Other top-levels in the process the hidden VBA host wndclass_desked_gsk + 4 VBFloatingPalette, NVOpenGLPbuffer ×2, PbrsHost, ListBox "Properties", ThunderMain, MsoStdCompMgr, Chrome_WidgetWin_0, tooltips — all invisible, none a dialog window census

That last-but-one row matters more than it looks: there is an undocumented /SWDisableExitApp switch in circulation (described as suppressing the crash window while debugging, and as used by SOLIDWORKS Rx). If the launcher were passing it, ExitApp being ignored would have a trivial explanation. It is not being passed on this machine, so that explanation is ruled out by measurement.

Everything else in that table is the state the ladder requires (§5.2), and it is the state the app is in at rest.

2.6 What a Connected exit shows on screen — the honest state of knowledge

With modified documents, the dialog has a formal identity and it is documented as the application-exit dialog, in the SOLIDWORKS Connected help set: the "Save Modified Documents" dialog box (help topic id HIDD_NEW_CLOSEALL_DLG) — "…or closing the application with one or more modified documents open, you can save modifications to multiple open documents in the Save Modified Documents dialog box." It appears only for two or more modified documents; a single dirty document gets the ordinary per-document prompt instead — so a quit with one dirty document and a quit with three raise different windows, and the classifier has to catch both. On Connected the save model is two-target (Save to This PC / Save to 3DEXPERIENCE, the latter opening Save as New into a collaborative space), which is where the observed Save all to This PC / Save all to 3DEXPERIENCE / Don't Save / Cancel buttons come from. Corroborating the hazard: the Save-Modified dialog is repeatedly reported as the thing that wedges API-driven work ("SOLIDWORKS does not seem to wait for the dialog box to disappear before running the rest of the code"), and even QuitDoc — documented to close without saving — still prompts for some users.

The exact four-button wording is publicly undocumented. "Save all to This PC", "Save all to 3DEXPERIENCE" and "Don't Save" in an exit context return zero hits across the Connected help set, 3DSwym and the reseller blogs (the only documented "Batch Save to 3DEXPERIENCE" is a Tools-menu bulk utility). Our first-hand sighting is the only record of it; it must be characterised on the machine, not looked up. Two related facts that constrain what that dialog can even do: Save to This PC is what Ctrl+S does on Connected, and the platform Save is greyed out unless the file is Locked — so "Save all to 3DEXPERIENCE" can be an impossible choice at exit. All the more reason the bridge never picks one.

With zero documents, three things are now settled from vendor sources:

  1. With zero open documents, NOTHING is documented to appear. Four independent search angles found no exit prompt of any kind for an empty Connected session. Design accordingly: do not build dialog automation around a phantom, and treat any window that does appear as an unrecognised one to report (§5.5).
  2. There is no sign-out prompt on exit, and exiting does not sign you out. Sign-out is an explicit manual action (3DEXPERIENCE tab → Profile → Sign Out), and "closing SOLIDWORKS or restarting your computer will not log you out" — the token lives 24 h by default, 7 days with "Remember Me", and there is no licence check-in at exit. So no rung needs to answer a sign-out question, and the bridge must never initiate one: the platform session is Drew's, not this app's.
  3. There IS a documented exit-adjacent telemetry modal: the "Status Feedback" dialog, present in the SOLIDWORKS Connected help set — "when you close the SOLIDWORKS software and the dialog box appears, you can send your comments and performance log data to DS SolidWorks Corp". Its documented context is an unresponsive command, so it is hang-triggered rather than routine, but it is a real window a quit can meet. It is classified unknown → neverAutoAnswer by abwin::classify today, which is the correct default: report it, never send telemetry on the user's behalf. (The routine Customer Experience Improvement Program is a System Options checkbox, not an exit prompt — so Status Feedback is the only exit-adjacent feedback modal there is.)
  4. No documented "content not saved to the platform" exit warning, no MySession unsaved-content prompt, no licence check-in prompt, no "SOLIDWORKS is closing" progress dialog. The MySession SolidPractices covers refresh, the PLMCSA client/server split and open/save — and says nothing about session teardown.

And the processes that are SUPPOSED to survive. This is the correction that matters most for §5.4's measurements, because getting it wrong would make a perfect quit look like a failure:

Process What it is Survives a graceful exit?
ENOPLMCSAClient.exe DS SolidPractices: "a helper process that connects SOLIDWORKS to the 3DEXPERIENCE Platform … to save, open and manage SOLIDWORKS data." Has its own tray icon. YES, by design. Every VAR reset procedure reads "close SOLIDWORKS, then right-click and exit PLM services in the tray."
EdmServerV6.exe "3DEXPERIENCE PLM Services" — the XML messaging server behind the PLMCSA client; separate tray app YES, by design (same guidance)
3DEXPERIENCELauncher.exe, …Backbone.exe, …GUI.exe, …SysTray.exe the Platform's launcher service ("a listening port to launch Native apps") + tray YES, by design. Ending one is the documented pre-reinstall step, never a shutdown step — and they are already on this bridge's NEVER_TERMINATE prefixes
sldBgDwld.exe SOLIDWORKS Background Downloader YES, by design
ViewServerV6.exe, CATSTART.exe, CATSysDemon.exe, AsyncSaveinto3DEXPERIENCE.exe Connected-side servers/bootstrap undocumented — measure, do not assert
sldProcMon.exe, sldsurfacing.exe, sldimport.exe, swCefSubProc.exe SOLIDWORKS' own per-session out-of-process servers and CEF children expected to go. These are the reaping signal

So "SOLIDWORKS quit" can never be asserted from "no Dassault process remains" — it is asserted from the SLDWORKS.exe pid and its creation time (§5.4), with the per-session children as the quality signal and the tray/daemon set explicitly excluded from both.

One more asymmetry worth recording: SWXDesktopHelper.exe and SWXDesktopLauncher.exe have no published documentation of function anywhere — not DS, not file.net, not the VAR blogs; the only public facts are that their %APPDATA%\DassaultSystemes\<name> folders are the diagnostic-log directories support asks for. Which means §0.3–0.4's measurements (a 30-second licence heartbeat, a Disconnect line on shutdown, and nothing at all in a shortcut-launched session) are original evidence about what that helper does. Worth keeping.

2.7 What a killed or half-closed SOLIDWORKS actually costs

This is the evidence that a kill is not merely impolite, and it lines up with the launcher trouble in docs/LAUNCH-RESILIENCE.md:

  • The journal file stays locked. GoEngineer's top cause of "A journal file could not be created. Auto recover will not work. Another session of SOLIDWORKS may already be running on this machine." is "a previous instance … When a session doesn't close properly, its journal file stays locked." Our own journal rotated cleanly at 15:36 (swxJRNL.swj → .BAK), which is one more sign the 15:24 exit was clean.
  • Auto-recovery is silently skipped: "in the case that the active session was killed by the user, the recovery window will not appear" — the .SWAR files must be found by hand.
  • A network licence can stay checked out until the SNL service is restarted or a FlexLM TIMEOUT expires. (Connected uses platform licensing rather than SNL, so this is background rather than our case.)
  • An orphaned SLDWORKS.exe suppresses auto-save in the next session and produces the "another session running" warning; the folk fix is renaming HKCU\Software\SolidWorks\SOLIDWORKS 20xx\ExtReferences.
  • On Connected, platform objects you had Locked stay locked to you after a kill, and unlocking is a manual gesture in the task pane / Bookmark Editor (the files do not even have to be open). That is a cost a kill imposes on the user's cloud data, not just their session — and it is the sharpest single argument for the ladder over swx_kill on this edition.
  • On the Connected side, Dassault's own Makers-support wiki lists the processes to end when the launcher insists SOLIDWORKS is already running — SWXDesktopHelper, SWXDesktopLauncher, CATSTART — plus the caches to clear (%TEMP%, %LOCALAPPDATA%\Temp\swcefcache, %LOCALAPPDATA%\DassaultSystemes\CATTemp, and %APPDATA%\DassaultSystemes\CATSettings for login failures). Note that this list is Dassault telling a human how to recover, and that three of those four are already on this bridge's TERMINABLE_IMAGES / NEVER_TERMINATE calculus for good reasons.

2.8 The in-house precedent, and the in-house hazard

Precedent. The Fusion 360 bridge — the gold standard this bridge mirrors — quits its host application exactly this way. fusion_stop's catalog entry reads "Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait", with a skipCleanClose flag (docs/_fusion-inventory.md line 29). A window-manager close of the main frame is not exotic in this codebase; swx is the outlier for having only ExitApp.

Hazard, first-hand, 2026-09-02. Closing a stuck SOLIDWORKS modal through the window manager took the whole application down with it — recorded three times in the source as the reason swx_dismiss_blocking_dialogs reports by default instead of closing (src/verbs3.rs:740, src/abwin.rs:49). That is evidence about dialogs, not about the frame, and it is exactly why the ladder demands an empty dialog census before it touches the frame, and why the frame route must never be used as a dialog route.

One route this design deliberately does not use. ab's close is desktop_ui_window {action:"close"} — a UIA WindowPattern.Close(). It is the very mechanism involved in the 2026-09-02 incident; it is unavailable whenever ab cannot be reached (swx_status.abAvailable:false on the dev bridge today, while the ab-spawned installed instance is still 0.3.3); and it offers no control over sync-vs-post, no timeout, and no GetLastError to check for a UIPI block — which is the entire point of using SendMessageTimeout. The com host already has the hwnd, an STA thread and the windows crate. The message belongs there (§5.6).


3. What every other automation project does (spoiler: nothing)

Surveyed by reading the code, not the READMEs: the five SOLIDWORKS MCP servers in docs/ECOSYSTEM-TABLE.md, the two serious .NET frameworks, the Python wrappers, and the batch/PDM field practice.

3.1 The MCP servers — five for five never quit SOLIDWORKS

Project What it does at shutdown Connected?
SolidPilot (eyfel/mcp-server-solidworks, AGPL-3.0) Nothing. SolidWorksService.cs attaches via Marshal.GetActiveObject, sets UserControl = true, and has no ExitApp, no ReleaseComObject, no IDisposable. Its STA pump (StaExecutor.cs) is IsBackground = true with no shutdown path and no CoUninitialize. README documents ensure_ready ("launches SolidWorks via COM") with no matching teardown tool. not addressed
vespo92/SolidworksMCP-TS (MIT) Explicit refusal. winax-adapter.ts: // Don't close SolidWorks, just disconnect — nulls the references and returns. not addressed
andrewbartels1/SolidworksMCP-python (MIT) Best COM hygiene in the field and still never exits: disconnect() nulls every cached object then pythoncom.CoUninitialize(). not addressed
alisamsam/Solidworks-MCP (MIT) Nothing at all — no CoUninitialize, no __del__, no close. The session ends when Python does. not addressed
Adam-Schildkraut/solidworks-mcp (MIT) No application-lifecycle code exists; it is a VBA-template generator. not addressed

Not one of the five calls ExitApp. Not one kills the process either. They are all safe only because they attach to a session a human started and walk away from it. On the actual problem — quit this application, gracefully, on a Connected edition — the public field has no answer, and no competitor's row in our ecosystem table can be read as one.

3.2 The frameworks: one correct implementation, and the rule for in-process code

xCAD.NET (xarial/xcad) is the reference. SwApplication.cs:

public void Close() { if (!m_IsClosed) { m_IsClosed = true; Sw.ExitApp(); Dispose(); } }

internal void Release(bool close) {
    …
    m_Documents.Dispose(); TagsRegistry.Dispose();
    if (close && !m_IsClosed) Close();
    if (Sw != null && Marshal.IsComObject(Sw)) Marshal.ReleaseComObject(Sw);
}

The ordering is the lesson: dispose your own wrappers → ExitApp() → release the root COM pointer LAST. It also exposes Process => Process.GetProcessById(Sw.GetProcessID()) — it knows the pid and still never calls Kill(). (It additionally knows the command-line switch /SWSafeMode /SWDisabExitApp — a documented flag that disables ExitApp, worth remembering if a launcher ever injects it.)

SolidDNA / CADBooster states the opposite rule for in-process code, and it is also right: SolidWorksApplication.Dispose() carries // NOTE: Don't dispose the application, SolidWorks does that itself. An add-in releases; it never exits — and per 3DSwym it cannot even veto a shutdown (returning False from DisconnectFromSW() does not stop it). The add-in convention (SwCSharpAddinMF, solidworks_urdf_exporter) is Marshal.ReleaseComObject on everything plus GC.Collect() + GC.WaitForPendingFinalizers() twice.

Python. deloarts/pyswx wraps the whole relevant surface — exit_app(), close_all_documents(include_unsaved), close_doc(name), a user_control setter, and quit_doc(name) ("closes the specified document without saving changes"), which is the discard-and-close primitive our swx_close_doc currently emulates with CloseDoc. The universal pywin32 recipe is Dispatch("SldWorks.Application") → work → sw.ExitApp(), with the community folk-rule that Dispatch starts SOLIDWORKS (and therefore owns its lifetime) while GetActiveObject attaches (and therefore does not) — which is exactly the UserControl distinction in §1, arrived at empirically.

3.3 Macros can exit their own host, and there is a command id for File ▸ Exit

Two routes nobody in the ecosystem uses, both directly usable by us:

  • A .swp VBA macro can call swApp.ExitApp and it works — the API help merely discourages it ("not normally used with macros … because it shuts down your SOLIDWORKS session"), and forum practice confirms it stops SOLIDWORKS. This is the in-process rung (e) in §5: the request comes from inside the process, where no external client's reference count is in play. It matters especially here because this edition restricts add-ins but runs macros, and the VBA host is already loaded in the live session (§0.1).
  • swCommands_e contains swCommands_App_Exit = 2825 (cross-checked in tdsmith/swharness/swcommands.py, alongside swCommands_FileClose = 2789), usable via ISldWorks::RunCommand. That is "press File ▸ Exit for me", inside the app's own command dispatcher. Nobody surveyed uses it for exit. It is rung (b).

3.4 Batch and PDM practice: killing is normal, and even there the mature form is targeted

This is where the field does end sessions, and it is not a model to copy:

  • SOLIDWORKS forum thread/34178 ("How Close or Kill Process SLDWORKS.exe c#") — after a program finishes sldworks.exe keeps running, so developers reach for CloseMainWindow() then Kill(); taskkill /f /im in a batch file is described as ordinary practice.
  • myCAD forum, "Kill SLDWorks.exe -Embedding" — the fullest discussion found. Consensus: ExitApp() alone does not prevent the lingering process; killing every SOLIDWORKS process is "a bit brutal" when another session exists; and the accepted refinement is to set a distinctive MainWindowTitle on your own instance first and kill only the process whose title matches. If swx ever needs a last-resort escalation, that is the shape it should take — a self-tagged instance, never an image-name match. It is not in this design.
  • CATI, "Killing the SOLIDWORKS Task Scheduler!" — taskkill /f /im sldProcMon.exe as the remedy for a scheduler that will not stop. Note sldProcMon.exe is one of the per-session children in §0.2 whose reaping we use as a clean-exit signal.
  • Dassault publishes no endorsement of termination. The Task Scheduler help documents task status transitions and says nothing about how a session is ended.

So our never-kill posture is not merely defensible, it is the differentiated position — and the honest way to state it is that the field's alternative is taskkill, which is what the bridge exists to avoid.


4. The 3DEXPERIENCE side: does a clean quit need a sign-out?

Short answer, from this machine: no — and there is nothing of the launch chain left alive to sign out of.

4.1 The launcher is gone long before the quit

CATSTART.exe → SWXDesktopLauncher.exe is a starter, not a supervisor. Today's launcher log ends:

15:35:41 info Login Success
15:35:41 info Writing PlatformInfo to HKCU\Software\Dassault Systemes\SolidWorks Shared\Servers\3DEXPERIENCE
15:35:41 info Started from desktop shortcut skipping PLMCSA client start
15:35:41 info Launching Application: …\SOLIDWORKS\sldworks.exe
15:35:41 info ShellExecute …\sldworks.exe
15:35:41 info Launched successfully ExitCode: 0
15:35:41 info Run End

Both chain processes then exit — neither is in the census (§0.2). The 3DEXPERIENCELauncher* daemons that are alive belong to the Platform, not to this session, are not required for a launch (the 2026-09-04 success ran with no Backbone at all), and are already on the bridge's NEVER_TERMINATE prefix list. Nothing about a quit involves them.

4.2 The licence heartbeat: present in a Platform launch, ABSENT in ours

This is the one asymmetry that matters, and it was measured, not assumed.

Previous session (17:23 on 09-04 → 15:24 on 09-08) Current session (bridge-launched 15:35 on 09-08)
SWXDesktopHelper process ran for four days none
its log 588 KB — token refresh (iam.3dexperience.3ds.com/api/authenticated/user/me), LicenseList=NSR,XWB, Heartbeat failures: none, then Offline License Count 0 every 30 s 0 bytes (both ….log and …_dslcc.log)
at shutdown 15:24:04 info Disconnect, then the process ended n/a
platform record — 3dexperience-log.json: one Login operation (loginEx → setsecuritycontext → getcastransientinfo, 6.1 s), tenant OI000616940, pid 107688, "integration":"SOLIDWORKSConnected", and nothing since

So a Platform launch runs a helper that heartbeats a licence and logs Disconnect when the session ends; a shortcut launch (which is what the bridge does, and what the launcher log calls out: "Started from desktop shortcut skipping PLMCSA client start") does not. Two consequences for the design:

  1. Do not treat a missing helper Disconnect as a failed quit. In a bridge-launched session there is no helper to write one. The platform-side signal available is the 3dexperience-log.json tail — not the exit of EdmServerV6.exe / ENOPLMCSAClient.exe, which are the 3DEXPERIENCE PLM Services tray apps and are documented to survive a graceful exit (§2.6). ViewServerV6.exe is undocumented: record it, conclude nothing.
  2. Do not sign anything out. There is no sign-out step the bridge can take that is its to take: the token belongs to Drew's Platform session, and vendor guidance for the tray agent (3DEXPERIENCE PLM Services) is explicitly a human gesture — "close SOLIDWORKS, then right-click and exit PLM services … in the Windows task tray" (swyftsol KB). PLM Services legitimately outliving SOLIDWORKS is normal and is not a leaked process, which also means "did it quit" can never be asserted from "no Dassault processes remain". It is asserted from the SLDWORKS.exe pid, and only that.

4.3 Does a graceful quit cost the next launch anything?

Unknown, and it is question 6 of the gate (§5.7 step 5). What we have:

  • After the 15:24 shutdown — the one with the journal's ExitApp + End Sub and the helper's Disconnect — the very next launch was the bridge's own, at 15:35, and it worked: launcher log 15:35:36, sldworks.exe at 15:35:41, Run End, ledger solidworks_launched … route:"platformShortcut". One clean exit, one healthy relaunch, eleven minutes apart. That is a single data point and it is in the right direction.
  • After the killed instances (docs/LAUNCH-RESILIENCE.md, FAILURE MODE A) the launcher stalled at SecurityContext: with no Run Begin, 5 of 5 times — but every one of those launches was also issued from a detached, hidden bridge context, which is the confounder the whole launch state machine exists for. The evidence cannot separate "the previous exit was dirty" from "this launch context is wrong", and this document must not pretend otherwise.
  • Four dead sessions left %TEMP%\swx<pid> behind, the clean one included, so that is not a discriminator either (§0.3).

The measurement that would settle it is in the protocol: quit gracefully, then immediately swx_start and record Run Begin latency, sldworks.exe latency and COM-attach latency against the healthy 2026-09-08 baseline (+2 s / +5 s / 1–3 min). If a graceful quit leaves the launcher healthy and a kill does not, that is the strongest possible argument for the ladder — and the strongest possible argument for never using swx_kill.

4.4 What Dassault documents about Connected and the API

  • The API works on Connected; the deltas are file-storage-shaped. The programming-guide page "SOLIDWORKS Design Connected to the 3DEXPERIENCE Platform" says "some SOLIDWORKS APIs have been added, changed, or discontinued for use with SOLIDWORKS Design connected to the 3DEXPERIENCE platform" and "Most working SOLIDWORKS Design macros will also work", and names ISldWorks::ApplicationType (swApplicationType_3DEXPERIENCE) as the official branch point, with GetSaveTo3DExperienceOptions / SaveTo3DExperience and GetFilePLMID on the storage side.
  • ExitApp is not on any published discontinued-for-Connected list, and this pass found no public report of ExitApp being declined on a Connected or Makers edition. If ours reproduces at a verified 0 documents, it is a new result and worth publishing.
  • Add-ins are restricted on this edition; macros run (forum thread/245766) — which is what makes rung (e) available.
  • PDM Pro's API is explicitly out on Connected; the platform supplies its own lifecycle management.
  • CO_E_SERVER_EXEC_FAILURE (0x80080005) on the SldWorks CLSID — our launch refusal — is also the classic signature of COM-activating SOLIDWORKS from a non-interactive context, not proof that Connected forbids activation. Worth knowing, and orthogonal to quitting.

5. The design: swx_stop v2, a measured ladder

5.1 What is wrong with v1

swx_stop today (src/verbs.rs:1008, host side src/comhost/com.rs:1293) is:

gate on unsavedDocuments == Some(0)  (unless force:true)
  -> writes::authorize_in(EndSession)   [reason + confirm + process opt-in]
  -> host "stop": ExitApp, tolerate the RPC-disconnect HRESULTs,
                  clear the created-doc registry,
                  VERIFY with GetActiveObject -> stopped:!still_up
  -> stopped:false  =>  errorCode host_app_stop_declined

Five defects, in the order they bite:

  1. It gates on unsavedDocuments, not on the OPEN document count. Right now this session has 5 clean documents of Drew's open and unsavedDocuments:0 — so a bare swx_stop (with the process opt-in on) would sail past the guard and try to quit a session full of the user's work. The rule Drew stated is the correct one and the code does not implement it: quit only when the verifiable open-doc count is Some(0).
  2. The verification is done while we still hold a reference to the object. let sw = self.app(...) is a live IDispatch for the whole match arm; self.sw = None drops the cached clone but not the local. GetActiveObject is then asked "are you still there?" while our own com host is one of the reasons the answer could be yes. Per the documented UserControl semantics (§1) an automation client's outstanding reference is exactly what keeps a SOLIDWORKS alive, so v1 cannot distinguish "declined" from "quit, but pinned by our own pointer". Every field write-up of this bug ends in Marshal. FinalReleaseComObject; we have no equivalent.
  3. GetActiveObject is the wrong instrument anyway. It answers a question about a ROT registration, not about a process or a window. The measurement that matters — did pid 107688 exit — is available without COM and is what swx_status already reads (solidworks.processes[], responding).
  4. There is exactly one rung. ExitApp or nothing. When it is declined the verb reports honestly (which is right) and then the only thing left in the bridge is swx_kill (which is wrong).
  5. Nothing records the attempt unless the host answered. A refusal at the unsaved/opt-in gate writes no ledger line, so "we tried to quit and were told no" is invisible after the fact.

5.2 The preconditions — all of them, every rung, re-measured

A rung may only run when all of these are true, measured immediately before it (not inherited from the caller, not cached from an earlier verb):

# Precondition How it is measured On unknown
P1 openDocuments == Some(0) documents() — the enumeration that only believes an empty list when GetDocumentCount agrees refuse (open_docs_unknown)
P2 unsavedDocuments == Some(0) GetSaveFlag per document; vacuously true under P1 refuse (unsaved_unknown)
P3 no blocking dialog owned by this pid window census, #32770 top-levels owned by processId (see §5.6 — the com host must read this itself) refuse (dialog_present)
P4 not busy CommandInProgress == false and the host's request mutex is free refuse (host_app_busy)
P5 responding IsHungAppWindow(mainHwnd) == false refuse (host_app_hung)
P6 authorized writes::authorize_in(EndSession): process opt-in + confirm + a human reason refuse (unchanged)
P7 identity captured GetProcessID(), the process creation time, and Frame().GetHWnd() read and stored before the first rung refuse (identity_unknown)

P7 is not bureaucracy: it is the same attribution law the launch path runs on (no handle, no kill — docs/LAUNCH-RESILIENCE.md §8b). Without a pid and its creation time, "the process went away" cannot be told from "a pid was reused", and every rung's success measurement depends on that distinction.

force:true may waive P2 only (the user saying "the open work is disposable"). It must never waive P1, P3, P4, P5 or P7 — those are not questions about the user's data, they are the difference between a quit and a wedge. This is a deliberate narrowing of today's force, which waives the whole front gate.

5.3 The ladder

Each rung: precondition sweep → act → wait → measure → decide. A rung that cannot be measured is a rung that FAILED, never one that worked. Nothing escalates past a dialog: if any rung raises a top-level window, the ladder stops and reports (§5.5).

Rung Action Why it is here Budget
a UserControl = false (remembering the previous value) → ExitApp() → release every reference we hold (drop the local Disp, drop self.sw, leave no IDispatch parked in any cache) → restore UserControl if it declined The documented sequence. UserControl is the flag that decides whether SOLIDWORKS outlives its automation client (§1.2), and the community's compensating step for "ExitApp did nothing" is always the final release (Marshal.FinalReleaseComObject). v1 does neither. 30 s
b RunCommand(swCommands_App_Exit = 2825, "") Ask the UI to do what File ▸ Exit does, through a supported API, inside the app's own command dispatcher. Nobody in the ecosystem uses it. (The purest form is WM_COMMAND(ID_APP_EXIT) to the frame, but that id must be harvested with Spy++ per install and its own author warns it can crash the app — RunCommand reaches the same place with a documented signature.) 30 s
c SendMessageTimeout(mainHwnd, WM_CLOSE, 0, 0, SMTO_ABORTIFHUNG|SMTO_NORMAL, 5000) from the com host, with SetLastError(0) first and a check for ERROR_ACCESS_DENIED What clicking X does, and on MFC literally what File ▸ Exit does (ID_APP_EXIT → OnAppExit → SendMessage(WM_CLOSE), TN022). The 15:24 evidence (§0.3) says this edition answers it by running its documented exit path — journal swApp.ExitApp + End Sub. Timeout-bounded, so a modal cannot wedge the com host. 45 s
d PostMessage(mainHwnd, WM_SYSCOMMAND, SC_CLOSE, 0), same UIPI check The system-menu Close, measured enabled on this frame (§2.5) — the one route whose availability we can read before sending. Posted, so it cannot block. It enters through OnSysCommand before DefWindowProc turns it into a WM_CLOSE, so an app that filters one may honour the other, and it is reported to work where CloseMainWindow() fails (minimized, cross-credential). 45 s
e RunMacro2 of a two-line .swp shipped in resources/macros/ (Sub main() : Application.SldWorks.ExitApp : End Sub) The in-process exit: the request comes from inside the process being asked to quit, where no external client's reference count and no UserControl bookkeeping applies. This edition restricts add-ins but runs macros, the VBA host is already loaded (§0.1), and the API help only discourages ExitApp from a macro — it works. Gated as Kind::RunMacro on top of everything else, and only ever this one shipped, content-hashed macro — never a generated one. 60 s
f Restart Manager: RmStartSession → RmRegisterResources(1 × RM_UNIQUE_PROCESS{pid, creationTime}) → RmGetList → RmShutdown(0, cb) with RmForceShutdown OMITTED The OS's own documented polite shutdown, and the only route that takes the pid and its creation time — the same identity pair our attribution law demands. It sends WM_QUERYENDSESSION(ENDSESSION_CLOSEAPP), then WM_ENDSESSION, "and also sends a WM_CLOSE message for GUI applications that do not shut down on receiving WM_ENDSESSION", and with the force flag omitted it never terminates anything: a refusal returns ERROR_FAIL_SHUTDOWN (351) with per-process AppStatus. Must run in the user's interactive session (it does). 90 s
— STOP. Report host_app_stop_declined with every rung's measurement. There is no rung (g). swx_kill is a separate, differently-gated verb, and swx_stop must never call it, escalate to it, or recommend it in a hint. —

THE LADDER-WIDE INVARIANT: no rung runs while this process holds a COM reference to SOLIDWORKS. Rung (a) ends by releasing everything; rungs (b)–(f) must start from that released state, and the com host must re-attach (GetActiveObject) only to take a measurement, then release again before the next rung. The reason is the failure mode the whole field reports: an automation client's outstanding reference is what keeps a SOLIDWORKS alive, and the classic outcome is a window that closes and a process that does not — the invisible SLDWORKS.exe -Embedding orphan. A WM_CLOSE sent while we still hold an IDispatch is the most direct way to manufacture exactly the half-closed state this design exists to avoid. Equally: any rung that succeeds tears the COM server down underneath us, so (a), (b) and (e) must all tolerate the RPC-disconnect HRESULTs com.rs already whitelists, and the verification must never be a COM call.

Why the Restart Manager is LAST and not first. It is the most sanctioned route on the list, and it is also the only one that can make SOLIDWORKS write to the user's files without asking: ENDSESSION_CLOSEAPP means *"the application must shut down. Any data should be saved automatically without prompting the user."* At openDocuments == Some(0) that is moot. But preconditions can be wrong, and when they are, the rung whose failure mode is a visible dialog we detect and stop on is safer than the rung whose failure mode is a silent save. Defence in depth puts the message rungs ahead of it. (EndTask(hwnd, FALSE, FALSE) — documented as "only the close with WM_CLOSE is attempted" — is rung (c) with less control and is flagged "not intended for general use"; it is not in the ladder. fForce=TRUE and RmForceShutdown are never used, by construction.)

Ordering rationale: (a) first because it is the documented API contract and the only rung with no UI side effects at all; (b) next because a command id stays inside the app's own dispatcher while a window message crosses the process boundary; (c) before (d) because WM_CLOSE is the semantic close and SC_CLOSE is a mechanism that produces one; (e) after the message rungs because it is the only rung that executes code inside the user's SOLIDWORKS; (f) last for the silent-save reason above.

Rungs are opt-in-able and individually disableable — swx_stop {maxRung:"a"} (default "a" until the gate has run) so the live experiment can advance one rung at a time and a deployment can pin the ladder at whatever the gate proved. Shipping the whole ladder armed on day one would be exactly the mistake crash recovery avoided by shipping off.

5.4 The measurements — per rung, all from the machine

Recorded for every rung, whether it worked or not. Nothing in this table is read from a COM return value.

Signal Instrument Meaning
process gone pid and creation time absent from a live census THE success criterion. A pid alone is not enough (reuse).
exit latency seconds from action to census-confirmed absence pace the progress block; feeds swx_verb_times
per-session children reaped sldProcMon / sldsurfacing / sldimport / swCefSubProc* gone clean vs abrupt. A kill orphans them.
the tray/daemon set UNTOUCHED ENOPLMCSAClient, EdmServerV6, 3DEXPERIENCELauncher{,Backbone,GUI,SysTray}, sldBgDwld still alive — all of them survive a graceful exit by design (§2.6) consent. Their absence would be a bug in us, not a cleaner quit. ViewServerV6 / CATSTART / CATSysDemon are undocumented: record, do not judge
journal closed %APPDATA%\SOLIDWORKS\SOLIDWORKS 2026\swxJRNL.swj tail contains swApp.ExitApp followed by End Sub the app ran its own exit path (§0.3). The single best clean-shutdown proof on disk.
platform released 3dexperience-log.json gains a logout/disconnect operation; a helper Disconnect line if a helper is running (§0.4 — it is not, in a bridge-launched session). NOT the exit of EdmServerV6 / ENOPLMCSAClient: those are supposed to stay the 3DEXPERIENCE side let go. Note there is no licence check-in at exit by design, so a released licence is not a signal to look for
no new window window census before vs after: zero new top-level windows owned by the pid a save prompt, a sign-out prompt or a survey appeared → STOP
no crash reporter no SOLIDWORKS Error Report dialog (it has its own page in the Connected help set, so this edition has it too), nothing new under %APPDATA%\SOLIDWORKS the exit was not a crash. (swSendToService.exe is not attested anywhere — do not look for it.)
journal lock released the NEXT launch does not warn "A journal file could not be created … Another session of SOLIDWORKS may already be running on this machine", and swxJRNL.swj rotates to .BAK the documented top cause of that warning is "a previous instance … when a session doesn't close properly, its journal file stays locked" — so this is a direct, next-launch-visible test of cleanliness
platform locks released nothing the session had Locked on the platform is still locked (checked by the user in the task pane, not by the bridge) a kill leaves Connected objects locked to the user, unlockable only by hand (§2.7)
relaunch health the NEXT swx_start: does the launcher reach Run Begin on the first route, and in how long the yesterday-evidence question — does a graceful exit cost the next launch anything
ROT gone GetActiveObject fails after every reference is released kept only as a cross-check; never the criterion (§5.1 defect 3)

5.5 Dialogs: classify, never answer

The census runs before every rung and again after each one. Any top-level #32770 owned by the SOLIDWORKS pid stops the ladder immediately. What is then reported depends on the class, and no class is ever answered by the bridge:

Class Recognised by What swx_stop does
Save prompt — the per-document prompt (1 dirty doc) or the "Save Modified Documents" dialog (≥2 dirty docs), which on Connected offers Save all to This PC / Save all to 3DEXPERIENCE / Don't Save / Cancel classify() → save_changes, neverAutoAnswer REFUSE and REPORT. errorCode:"save_prompt_open", the button labels quoted verbatim, and the instruction that answering it is the user's decision. Never click, never Escape, never WM_CLOSE the dialog. Under P1 this dialog should be impossible; if it appears, our document count was wrong, which is a bug to report, not a dialog to dismiss.
Document Recovery document_recovery refuse + report; swx_check_recovery describes it
Licence / 3DEXPERIENCE sign-in or sign-out / session license (matches licen, activat, 3dexperience) refuse + report. No "documented safe choice" is asserted here — this pass found no vendor documentation of a sign-out-on-exit dialog for this edition, and a sign-out affects the user's platform session, not just this app. Escalate to the user with a background screenshot.
Anything unrecognised unknown → neverAutoAnswer:true refuse + report. An unanswerable question is not permission.

A message rung has one more outcome that is neither "quit" nor "declined": blocked. UIPI failures return success (§2.3), so every message rung reports result:"uipiBlocked" when GetLastError() comes back ERROR_ACCESS_DENIED (5), and the ladder stops there too — an access-denied is a fact about the machine's integrity levels, not about SOLIDWORKS' willingness to quit, and escalating past it would just try the same blocked gesture a different way.

The one thing the bridge may do about a dialog is look: the census, plus swx_screenshot on mainHwnd, whose owned-popup list is the only authoritative attribution of a dialog to SOLIDWORKS.

And the standing hazard, stated in the code already: on 2026-09-02 closing a stuck SOLIDWORKS modal through the window manager took the whole application down. That is precisely why (c)/(d) target the main frame only and only when the census says there is no dialog: the same gesture aimed at a modal is a different act with a different outcome.

5.6 Where the code goes

  • The window messages belong to the com host, not to ab. swx_status on the dev bridge reports abAvailable:false, and ab's close is desktop_ui_window {action:"close"} — a UIA WindowPattern.Close, which is the mechanism that took the app down on 2026-09-02, and which is unavailable whenever ab cannot be reached. The com host already links the windows crate and already runs on an STA thread with the app's hwnd in hand; SendMessageTimeout / PostMessage there are ~20 lines, synchronous, timeout-bounded and measurable. src/abwin.rs's delegation policy ("this bridge owns no window code") is right for screenshots and enumeration and wrong for the one gesture whose exact semantics are the whole point.
  • The census must be readable without ab too. P3 currently depends on desktop_list_windows. Add an EnumWindows + GetWindowThreadProcessId census in the com host (cfg(windows)) so "is a dialog up" has an answer when ab is unreachable — an unanswerable P3 refuses the quit, so an ab outage would otherwise make swx_stop permanently impossible.
  • Reads the escape hatch cannot currently make. swx_com_eval's allowlist (src/comhost/expr.rs) admits only READ_MEMBERS and get*/is*/has*/can*/enum* prefixes, so UserControl, UserControlBackground, Visible, FrameState, CommandInProgress and ApplicationType — every property this design needs to observe — are refused as "not a read". They are plain property gets. Add them to READ_MEMBERS (gets only) so the state can be inspected without shipping a new verb, and so the experiment in §5.7 can be run read-only.
  • swCommands_App_Exit = 2825 goes in a named constant with the source cited, not inline.
  • Frame().GetHWnd() → Frame().GetHWndx64() (com.rs:2312), read as an i64. The help says outright that x64-compatible code should use the x64 form; today's 32-bit read has simply been lucky that 6622700 fits. Every window rung depends on that handle, so a truncated one would aim a close at nothing — or, worse, at some other window.
  • The Restart Manager rung needs RmStartSession / RmRegisterResources / RmGetList / RmShutdown / RmEndSession from rstrtmgr — present in the windows crate behind a feature. RM_UNIQUE_PROCESS wants the pid and the process creation time as a FILETIME, which is exactly what P7 already captures. RmForceShutdown must not be reachable from the code at all — not a runtime flag, not an argument: a constant 0 at the call site, with the reason in a comment.
  • The exit macro ships as a reviewed file under resources/macros/, is matched by content hash before use, and is never generated at runtime.

5.7 The experiment protocol

Preconditions on the human side. Drew says SOLIDWORKS is free — no work in it — and gives the go-ahead. This runs on a bridge-launched session (so a killed one cannot be blamed for a bad relaunch), with 0 documents, and it will END that session. It must not be run while openDocs > 0.

Instrumented by tools/quit-gate.py, built like tools/launch-gate.py: it talks to the bridge over adom-bridge … shell_execute, takes every measurement from the machine rather than from the bridge's return values, and reports UNKNOWN (exit 3) for any probe it could not read rather than passing by default.

tools/quit-gate.py --baseline      # read-only: census + logs + journal size + window list
tools/quit-gate.py --rung a        # ONE rung, then stop and report
tools/quit-gate.py                 # the ladder, rung by rung, stopping at the first success

Step 0 — baseline (read-only). Process census (every image in §0.2, with pids and creation times); the window census for the SOLIDWORKS pid (hwnd, class, visible, enabled, IsHungAppWindow, GetMenuState(SC_CLOSE)); swx_status + swx_get_app_state (assert documentCount == 0, unsavedDocuments == 0, busy:false, responding:true); the size and tail of swxJRNL.swj; the contents of 3dexperience-log.json; %TEMP%\swx* directory list; the current UserControl / Visible / ApplicationType values (needs the §5.6 allowlist change, else record as UNKNOWN); ledger tail.

Step 1 — prove the 0-document claim. swx_start a fresh session if needed, close nothing, and confirm documentCount == 0 from documents() (count-agreed, not merely an empty array). This is the step that settles the open question in §0.5. Record it in the ledger as solidworks_quit_experiment_baseline.

Step 2 — rung (a). swx_stop {maxRung:"a", confirm:true, reason:"…"} with the process opt-in on. Record: what ExitApp returned or which HRESULT it failed with; whether UserControl was readable and what it was before/after; the reference-release order actually executed; then the full §5.4 measurement set at +2 s, +5 s, +10 s, +30 s.

Step 3 — only if (a) left the process alive and raised no window: rung (b), then (c), (d), (e), (f), one call each, full measurement set after each. Stop at the first rung whose process-gone signal is true. Stop permanently at the first new top-level window, and at any uipiBlocked.

Step 4 — the aftermath, whatever happened. Whether or not it quit: journal tail; child-process census; platform log; %TEMP%\swx<pid>; crash-reporter window and artefact check; swx_status (should report running:false).

Step 5 — the relaunch. swx_start and record the launch-state-machine timings: route, launcherStarted, Run Begin latency, sldworks.exe latency, COM-attach latency, and whether any stall was detected. Compare against the 2026-09-08 healthy launch (Run Begin +2 s, sldworks.exe +5 s, attach 1–3 min) and against the stall signature in docs/LAUNCH-RESILIENCE.md §"FAILURE MODE A". This is the rung-quality test that matters most operationally: a quit that costs the next launch is not a graceful quit.

What the gate will settle

# Question If yes If no
1 With Some(0) documents, does ExitApp (+ UserControl=false + full release) quit it? v2 is rung (a) alone; the ladder ships pinned at maxRung:"a" and the rest is dead code we keep the reported decline is real at 0 documents, which is a finding worth publishing — nobody else has reported it
2 Does the process-gone signal ever disagree with GetActiveObject? defect 2/3 are confirmed and v1's "declined" verdicts were partly self-inflicted the ROT check was fine and we lost nothing by replacing it
3 Does rung (b) RunCommand(2825) quit it? the UI command path works where the API does not — a genuinely new result for this edition the app's own dispatcher declines too, which points at the platform session, not the API
4 Does rung (c) WM_CLOSE quit it, and does the journal show ExitApp + End Sub? the §0.3 reading is confirmed: a window close runs the documented exit path on Connected the 15:24 shutdown came from something else and §0.3 must be rewritten
4b Does rung (e), the in-process macro, quit it where every out-of-process route failed? the decline is about who is asking, not about the app — which would be the most interesting result in this document, and would also mean RunMacro2 is live-proven the app itself is refusing, and the cause is the platform session
4c Does rung (f), the Restart Manager without force, quit it — and does it come back ERROR_FAIL_SHUTDOWN with a readable AppStatus if not? there is an OS-sanctioned, never-killing route that works, and the reason for a refusal is machine-readable the app declines the OS's own shutdown request too, which is close to conclusive that only a human gesture will do it
5 Are the per-session children reaped and the platform daemons untouched? "graceful" is measurable and we can assert it whichever rung did that is downgraded, however fast it was
6 Does the next launch behave exactly as a launch after a user-driven exit? quitting is operationally safe to offer it is not, and swx_stop must say so in its hint
7 Does any rung raise a dialog with 0 documents (sign-out, session, survey)? the classification in §5.5 gets its real titles and the refusal path is exercised for real this edition exits silently at 0 documents

What the gate cannot tell us, and must not be read as telling us: that a rung which worked once works always (one machine, one tenant, one hotfix level); that the ladder is safe with documents open (it is never run that way); and whether rung (e) is safe in general (it runs VBA inside the user's session — the shipped macro is two lines and hashed, and that is the whole of the argument).

5.8 The response shape

{
  "success": true, "stopped": true, "rung": "c",
  "rungsTried": [
    {"rung":"a","action":"UserControl=false; ExitApp; released 2 references",
     "result":"declined","processGone":false,"newWindows":0,"elapsedSec":30.4,
     "detail":"ExitApp returned S_OK; pid 107688 still present at +30s"},
    {"rung":"b","action":"RunCommand(swCommands_App_Exit=2825)","result":"declined","…":"…"},
    {"rung":"c","action":"SendMessageTimeout(WM_CLOSE) -> hwnd 6622700","result":"quit",
     "processGone":true,"exitLatencySec":6.2,
     "childrenReaped":["sldProcMon","sldsurfacing","sldimport","swCefSubProc x10"],
     "expectedSurvivorsAlive":["ENOPLMCSAClient","EdmServerV6","3DEXPERIENCELauncherSysTray","3DEXPERIENCELauncher"],
     "journalLockReleased":true,
     "journalClosed":true,"platformReleased":true,"crashReporter":false,"newWindows":0}
  ],
  "preconditions": {"openDocuments":0,"unsavedDocuments":0,"dialogs":0,
                    "busy":false,"responding":true,
                    "processId":107688,"processCreatedAt":1788899741,"mainHwnd":6622700},
  "windowEtiquette":"background",
  "_hint":"…"
}

stopped:false stays a failure with errorCode:"host_app_stop_declined", carrying rungsTried so the caller can see what was actually attempted, and the hint keeps saying what it says today: do not reach for swx_kill, and on a Connected edition a quit is close to a one-way door.

Two error codes are new and load-bearing: open_documents_present (P1 failed — distinct from unsaved_documents, and NOT waivable by force) and save_prompt_open (§5.5).

5.9 What the suite has to prove (before the gate, on this Linux host)

The repeated lesson of this project is "the mock modelled the easy case and hid a real defect" — four times now. So the mock backend must be able to model the hard cases before any of this ships:

Mock capability to add The defect it makes testable
SWX_MOCK_EXITAPP_DECLINES=1 — ExitApp returns cleanly and the app stays up the whole ladder. Today the mock always quits on rung (a), so rungs (b)–(e) can never be reached by a test.
a simulated window census, with an injectable dialog P3, and the "a dialog stops the ladder" rule
openDocuments and unsavedDocuments independently settable, including UNKNOWN P1 vs P2 — the current gap where 5 clean documents pass
a rung-by-rung effect script ("declines a, declines b, quits on c") that rung in the response is the rung that actually worked, and that the ladder stops on first success

Tests that must exist, named for what they prove:

  • a_session_with_open_documents_is_never_quit — 5 clean documents, force:true, → errorCode:"open_documents_present". This is the regression test for today's live posture.
  • force_waives_the_unsaved_question_and_nothing_else — force:true cannot pass P1, P3, P4, P5 or P7.
  • a_dialog_stops_the_ladder_and_is_never_answered — a save_changes window injected between rungs: the ladder stops, reports the titles, closes nothing.
  • the_ladder_stops_at_the_first_rung_that_worked — no rung runs after success.
  • a_quit_that_did_not_quit_is_a_failure_with_every_rung_measured — the v1 honesty property, extended to rungsTried.
  • a_refusal_is_recorded_in_the_ledger — solidworks_quit_refused with the precondition that failed (v1 records nothing).
  • the_verification_releases_every_reference_before_it_asks — a com-host-level test that the reference count our process holds is zero before the census runs.
  • nothing_in_the_stop_path_ever_calls_kill — a source-level and behavioural assertion that no rung reaches taskkill, TerminateProcess, swx_kill, or RmForceShutdown.
  • a_blocked_message_is_reported_as_blocked_not_declined — a simulated ERROR_ACCESS_DENIED on a message rung → result:"uipiBlocked", ladder stops. (UIPI failures return success, so an unchecked GetLastError would report a quit that never happened — §2.3.)
  • the_expected_survivors_are_not_treated_as_a_failure — a census in which ENOPLMCSAClient / EdmServerV6 / 3DEXPERIENCELauncher* are still alive after the pid is gone still reports a clean quit (§2.6).
  • the verb-coverage gate keeps working: any new arg (maxRung) is described by swx_describe and listed in bridge.json.

5.10 Two other things this investigation found in the existing code

Neither is about quitting, both belong to the same posture:

  1. swx_kill kills by IMAGE NAME. taskkill /F /IM SLDWORKS.exe (src/verbs3.rs:648). The launch path spent three adversarial rounds establishing that a pid without a verified creation time is "not ours" and that there must be no image-name kill anywhere — and then the kill verb does exactly that, which on a machine with two SOLIDWORKS sessions would end the wrong one. If the desperate path is kept it should target the attributed pid, and the mature field pattern (myCAD, §3.4) is to tag your own instance's MainWindowTitle first and kill only the match.
  2. The com host disowns every bridge-created document BEFORE it checks whether the quit worked. In com.rs's "stop" arm the order is ExitApp → self.sw = None → self.created.clear() → created_store::clear() → then verify. On a DECLINED quit SOLIDWORKS is still up with the bridge's own documents open, and the host's independent veto now believes it created none of them — so the next swx_save / swx_close_doc on one of them is refused with "this document was not created by this bridge". That is bug #12's shape exactly, reachable through the failure path, and it also puts the two vetoes into disagreement (the server's owned-docs.json is correctly left alone — verbs.rs only clears on the success arm). Clear the host registry after a census-confirmed exit, never before the verification.

5.11 As built — swx_stop v2, shipped in 0.5.2, hardened in 0.5.3 (LIVE-PROVEN on rung (a), 2026-09-17; the full gate cycle again 2026-09-30 through the hosted bridge — §5.14)

Written after the code, from the code. Where the build deviates from §5.1–§5.10 the deviation is named here rather than quietly absorbed, and the doc above is left as it was written — it is the design record, this is the delivery record.

Where the pieces actually live

Piece File Why there
The ladder's rules, as pure functions over measured facts src/quit.rs testable without a machine: the rung table, budgets, SESSION_CHILDREN/EXPECTED_SURVIVORS/UNJUDGED, Identity, process_gone, reap_report, dialog classification, check_preconditions, RungResult
The Windows gestures src/quit.rs::win send_wm_close (SendMessageTimeout), post_sc_close (PostMessage WM_SYSCOMMAND SC_CLOSE), restart_manager_shutdown
The rung executor, inside the COM apartment src/comhost/com.rs::cmd_quit_rung rungs (a) and (b) are COM calls and must run on the STA thread that owns the object
Reference release + accounting src/comhost release_com / com_refs see below: the ladder is meaningless while this process holds a reference
Preconditions, the loop, and the measurement src/verbs.rs::swx_stop ~300 lines, and the only place the two halves meet
The simulated ladder src/comhost/mock.rs (quit-script.json) one branch per rung outcome, so every path in the loop is exercised on Linux
The census / window / liveness seam src/launchos.rs DEVIATION from §5.6, which put the window census in the com host

DEVIATION 1 — the window census is in launchos, not the com host. §5.6 placed windows_of_pid / IsHungAppWindow / GetMenuState beside the COM code. It is in the OS seam instead, for one reason: the whole point of these readings is that they work when COM does not. The com host is exactly the process that is blocked when SOLIDWORKS is wedged — asking it "is there a dialog?" through the same stdio channel that a hung ExitApp is occupying would make the dialog check unanswerable precisely when it matters. In launchos it is also FakeOs-backed, so every dialog branch is testable.

DEVIATION 2 — rung (e) ships as skipped, always. EXIT_MACRO_SHA256 is None and exit_macro_path() resolves nothing, so the rung reports result:"skipped" with the reason. A .swp is a binary OLE container: it cannot be authored, reviewed or hashed on this Linux host, and a rung that runs VBA inside the user's session on the strength of a file nobody has read is not a rung this bridge is willing to take. When a reviewed two-line macro exists, setting that constant is the whole change.

DEVIATION 3 — the precondition ORDER was inverted relative to §5.2. The checks that do not require identifying the process run first:

P1 open documents (and a truncated enumeration) → P2 unsaved → P7 identity → P3 dialog → P4 busy → P5 responding → launch-in-flight

because a fact that is definitely true is more useful to a caller than an unknown. With Drew's 5 documents open and a census that cannot answer, "5 documents are open, close them first" is actionable; "I cannot identify the process" is not, and the first version reported the second.

DEVIATION 4 — error-code names. §5.8 proposed save_prompt_open; shipped as stop_blocked_by_save_prompt, with stop_blocked_by_dialog for every other blocking modal, so a caller can tell "the user's work is in the question" from "SOLIDWORKS is asking something else". open_documents_present shipped as specified. The full set: host_app_not_running, open_documents_present, unsaved_documents, stop_blocked_by_save_prompt, stop_blocked_by_dialog, host_app_busy, host_app_hung, launch_in_flight, stop_preconditions_unknown, unknown_rung, user_writes_disabled, and host_app_stop_declined when every rung was tried and measured and it is still there.

DEVIATION 5 — stillRunning is still in the response. v1 shipped it and callers read it; v2 keeps it, but MEASURED from the final census and null (never false) when the census could not be read. An unreadable census is not proof of an exit — which is the one place in this verb where the empty-to-confident collapse would have been most expensive.

The three laws, as enforced

  1. Never a kill. There is no terminate call anywhere in the stop path. Rung (f) passes literal 0 where RmForceShutdown would go, and tests/quit.rs pins the absence: every rung's action string is asserted free of kill/terminate/force wording (force is allowed only in the phrase "RmForceShutdown OMITTED", which is the assertion that it is not passed).
  2. Never answer a dialog. A new top-level #32770 stops the ladder permanently and becomes blockingDialog in the response; the hint sends the caller to swx_state's background screenshot and to the user. Nothing is clicked. src/abwin.rs::classify was widened so "Save Modified Documents" — the real title, which the classifier had been returning unknown for — classifies as save_changes.
  3. Nothing is disowned until the process is verifiably gone. The scratch registry, owned-docs.json and the com host's own created set are cleared only on the success arm, after process_gone is Some(true). This closes §5.10's second finding: a declined quit used to leave the bridge unable to save or close its own documents.

Release before, release after. Every rung is preceded by release_com and followed by release_com + com_refs; if any reference is still outstanding at the measurement, the rung's detail says so verbatim ("WARNING: N COM reference(s) were still outstanding at the measurement"). A rung that "declined" while this process still held a reference is not evidence about SOLIDWORKS, and the report must not let that be mistaken for one.

How success is measured. Never by GetActiveObject, never by what the rung returned — and a rung that works tears the COM channel down underneath the call that asked for it, so a transport error is explicitly not a verdict. After each rung: release, count references, settle (SWX_QUIT_SETTLE_MS, default 1500 ms), re-take the process census, and ask whether this pid with this creation time is absent. None (census unreadable) is Unmeasured, which is a failure, not a quit.

What the response carries: stopped, stillRunning, winningRung, rungsTried[] (rung, action, result, processGone, elapsedSec, newWindows, dialog, sessionChildrenStillAlive, expectedSurvivorsAlive, undocumentedStillAlive, detail, hostError), preconditions, processCensusBefore / processCensusAfter, expectedSurvivorsAreNotAFailure, blockingDialog, maxRung, elapsedSeconds, windowEtiquette:"background". Every refusal writes a solidworks_quit_refused ledger line, every rung a solidworks_quit_rung, and a verified exit a solidworks_stopped — v1 wrote nothing at its front gate, so "we tried and were told no" was invisible afterwards.

Pinned at rung (a). DEFAULT_MAX_RUNG = "a". Rungs (b)–(f) are reachable only by an explicit maxRung, and (f) additionally by allowRestartManager:true — its OS semantics are save without prompting, the one rung whose failure mode is a silent write rather than a visible dialog.

Adversary pass 7 changed four things about the ladder (branch adversary/0.5.2, commit 8ae06d2). The pass found no way through the measurement discipline itself — process_gone, the null stillRunning, the disown ordering, the reference accounting, the dialog and UIPI stops, and the rung-(f) identity requirement all held — and two things that did not:

  • The preconditions were swept ONCE, before the loop (Q-1), while check_preconditions' own comment promised "immediately before each rung". On a multi-rung ladder the verified-zero-documents invariant therefore held for rung (a) alone: a document opened while rung (a) settled was still open when rung (b) fired RunCommand(swCommands_App_Exit) at the session — which is exactly how the "a save prompt at a verified zero is impossible" reasoning becomes false. The sweep is now a re-usable StopEvidence::sweep and runs at the top of every iteration; a mid-ladder failure refuses with the same codes the front gate uses, carries refusedMidLadder:true and the rungs already tried, and its ledger line records afterRungs. The re-sweep also compares the identity, so a ladder can never continue against a DIFFERENT process (stop_identity_changed).
  • The exit was judged from ONE census read 1.5 s after the rung (Q-2). A real ExitApp returns while SOLIDWORKS is still tearing a session down — ten CEF subprocesses, three out-of-process servers, a journal to close — so a slow but perfectly good exit read as declined, and the ladder's answer to a decline is to ESCALATE: a window message into an application that was already exiting. The measurement now polls at the settle interval up to the rung's own budget, stopping early only on the two answers that end a ladder (gone, or a dialog), and the rung's detail says how many reads it took. SWX_QUIT_EXIT_WAIT_SECS shortens the wait for the suite and the gate. The simulator gained exitAfterMs, so a slow exit is a TESTED path rather than a comment — without it the mock hid the divergence completely.

Three more, all about seeing less than we claimed to:

  • The dialog census could not see a swCef modal (Q-3). The 3DEXPERIENCE sign-in, licence and cloud-save windows are web views, and a web view's hwnd belongs to swCefSubProc.exe — a child of sldworks.exe with its own pid. "No dialog is on screen" was provable while the platform held the one modal most likely to be up. quit::dialog_census_pids now returns the SOLIDWORKS pid plus every swCefSubProc.exe whose ppid is ours (never by class name, so another session's web view is never adopted), the sweep censuses all of them, ONE unreadable pid makes the whole answer UNKNOWN, and the response reports dialogCensusPids.
  • P1 accepted Some(0) without the cross-check the Python gate already did (Q-4). GetDocumentCount and GetDocuments are two different COM reads; check_preconditions now refuses as UNKNOWN when they disagree, because when they disagree neither is evidence and "zero" is the answer we are least entitled to assume.
  • Rungs (c)/(d) sent to an hwnd captured at precondition time (Q-5). Handles are recycled, and the rung immediately before them exists to make that window go away — so WM_CLOSE could land on somebody else's window. The com host now re-verifies GetWindowThreadProcessId(hwnd) == pid and that pid's creation time immediately before sending, and skips the rung if either fact is not exactly right (quit::hwnd_still_owned, unit-tested for the recycled-pid, wrong-owner and unreadable cases).

And two reporting fixes: every rung now carries censusReadable beside its three reap lists (three empty lists otherwise read as "everything was reaped and nothing survived", which is the most reassuring possible rendering of no information at all), and tools/quit-gate.py now proves the session was bridge-launched instead of asserting it in its docstring — launch.attributed and launch.runBeginSeen must both be true and the live sldworks.exe must have been created at or after that attempt's invokedAt, with any unreadable one a refusal.

Adversary pass 8 (branch adversary/0.5.3, commit eccccdc) cleared the quit ladder and the launch machine for the live gate at maxRung "a", and found one thing — in the DRAIN, not the ladder: a %TEMP% mismatch made swx_close_import_temps report a finished drain when it had matched nothing (see docs/IMPORT-LEAK.md). It matters here because the drain is step 2 of the deploy sequence and the quit gate is step 3: a silent no-op at step 2 would have surfaced as this gate refusing on open_documents_present, with the operator believing the session was already empty. The gate's step 1 now also NAMES the open documents that carry the launcher's temp tail — read-only, by applying the pattern to the paths swx_docs already returned, because the only action this gate may take is the swx_stop call.

What is proven, and what is not. 16 tests in tests/quit.rs plus the e2e stop paths cover: every rung outcome branch, every refusal code, the unknown refusals, the disown ordering, dialog classification, the survivor/child partition, the slow-exit path, the per-rung re-sweep, the CEF-inclusive dialog census, and a pin that nothing in the stop path ever kills anything. Not one rung has ever run against a real SOLIDWORKS. The ecosystem table lists graceful quit as unproven live for that reason, and tools/quit-gate.py --baseline is the read-only first half of settling it.


6. This environment: why the BRIDGE is the right place to do this

6.1 What the classifier blocks, and why that is correct

Auto-mode's classifier refuses close/kill-class actions aimed at SOLIDWORKS when they are issued from the agent's shell. Measured today: a Process.CloseMainWindow() over adom-bridge shell_execute was denied. Every read-only probe in §0 went through on the first attempt with a stated reason.

That boundary is not an obstacle to work around, and this document does not propose a way around it. A raw shell command aimed at a live CAD session:

  • carries no preconditions — nothing checked the document count, the dirty flags, the dialog census or whether the app was hung;
  • carries no identity — Get-Process sldworks | … is an image-name match, and the launch path already learned that lesson the expensive way (no handle, no kill, docs/LAUNCH-RESILIENCE.md §8b);
  • is measured by nothing — the shell reports what it sent, never what happened;
  • leaves no audit anywhere the user can see.

A one-line PowerShell that can end a CAD session with unsaved work in it is indistinguishable, from the outside, from the same line typed by mistake. The classifier is right to refuse it, and the fix is not a louder reason string.

6.2 Why the bridge is different

The bridge is not a shortcut past that gate — it is the thing that supplies everything the shell command is missing, and it is the only place on the machine where all of it exists at once:

The shell has none of this The bridge has it, today
a document count you can trust documents() — an empty enumeration is believed only when GetDocumentCount agrees, and an unreadable count refuses (docs_enumeration_failed)
a dirty-flag census GetSaveFlag per document, with "unreadable" treated as dirty
a dialog census with attribution swx_dismiss_blocking_dialogs / swx_window_info, ownerConfirmed only for a proven owner pid, neverAutoAnswer classes
process identity that survives pid reuse pid plus creation time, the same pair the kill allowlist uses
a human's explicit consent writes::authorize_in(Kind::EndSession) — process-level opt-in and confirm:true and a free-text reason, enforced independently in the server and again in the com host
an audit the user can read ledger.jsonl → swx_action_ledger, atomic writes, bounded
honest failure host_app_stop_declined rather than success:true on a quit that did not happen
a bounded blast radius one verb, one gesture, aimed at one hwnd belonging to one attributed pid — never an image-name match

So the request "quit SOLIDWORKS" should reach exactly one place: swx_stop, with a reason, on a session whose state has been re-measured in the same call. The agent's job is to ask for it and to relay the refusal when it comes; the bridge's job is to refuse most of the time.

And the corollary, which matters just as much: swx_stop must not become a way to get a close past the classifier by wrapping it. That is what §5.2's preconditions are for, why force is narrowed to waive P2 only, why the ladder ships pinned at maxRung:"a", and why the ladder stops at (f) with no kill of any kind reachable from it — RmForceShutdown included.

6.3 The audit every quit leaves

One ledger line per attempt, not per success — a refusal is the more interesting event:

Action slug Written when Detail
solidworks_quit_requested the gates pass, before the first rung caller (aiThread/container), reason, pid + creation time, mainHwnd, openDocuments, unsavedDocuments, dialogCount, maxRung
solidworks_quit_rung after every rung rung id, action taken, elapsed, processGone, newWindows, journalClosed, childrenReaped, daemonsUntouched
solidworks_stopped the process is gone, confirmed by census winning rung, exit latency, the full clean-exit signal set
solidworks_stop_declined the ladder ran out every rung's measurement, the last HRESULT, whether a dialog appeared
solidworks_quit_refused a precondition or the write gate said no which precondition, and its measured value (this is the line v1 never writes)

Plus, unchanged: swx_verb_times records the duration so the next quit can be paced honestly, and the ownership registries (owned-docs.json, com-host-created.json) are cleared only on a census-confirmed exit — v1 clears them whenever the host answers, which on a declined quit disowns documents that are still open.

Nothing in this document authorises a quit. The user does, per call, with the opt-in and the reason — and the bridge still refuses if the session it can actually measure is not the one the caller assumed.


7. Next actions, in order

  1. Do not run any of this yet. As of writing, Drew is working in the session (openDocs 5, journal recording Save3 and LoadFile2 calls minutes ago).
  2. Fix the two things that are wrong today regardless of the ladder — the openDocuments == Some(0) precondition (§5.1 defect 1) and the com host disowning documents before it verifies (§5.10.2). Both are small, both are testable on this Linux host with the mock, and the first one is currently a loaded gun.
  3. Make the state observable, and fix the handle: add the property gets to expr.rs::READ_MEMBERS (§5.6), fix the IsPLMConnected example (that member does not exist on this build), and switch Frame().GetHWnd() to Frame().GetHWndx64() read as an i64 — every window rung aims at that handle.
  4. Build swx_stop v2 pinned at maxRung:"a" with the full precondition sweep, the reference-release order, census-based verification and the ledger lines — plus the mock knobs and tests in §5.9.
  5. Write tools/quit-gate.py to the shape of tools/launch-gate.py, and take its --baseline read-only.
  6. Ask Drew for a window when SOLIDWORKS is free, then run steps 1–5 of §5.7 on a bridge-launched, 0-document session. Rung (a) first. Record everything.
  7. Only then arm rungs (b)–(e), one per run, in that order.
  8. Update SKILL.md (swx_stop's documented behaviour), README.md and docs/ECOSYSTEM-TABLE.md row 1 with whatever the gate actually proves — and note that the row's current claim ("ExitApp DECLINED live on Connected") is not yet backed by a ledger entry on this machine (§0.5).

8. Sources

Win32 / .NET semantics (Microsoft Learn, fetched directly): WM_CLOSE · WM_SYSCOMMAND · WM_QUIT · SendMessage · SendMessageTimeout · PostMessage · PostThreadMessage · DestroyWindow · SendInput · SetForegroundWindow · EndTask · ShutdownBlockReasonCreate · UIPI · Process.CloseMainWindow · its implementation · MainWindowHandle · Chen — you can't simulate keyboard input with PostMessage

MFC shutdown path (this is what makes WM_CLOSE == File ▸ Exit): TN022 Standard Commands Implementation · Destroying Frame Windows · CWinApp::SaveAllModified · CDocument::CanCloseFrame / SaveModified · TN001 window class registration · KB Q77135 — Different Ways to Close an Application

Restart Manager (rung f): Guidelines for Applications · RmRegisterResources · RmShutdown · WM_QUERYENDSESSION · WM_ENDSESSION · Task Scheduler AllowHardTerminate (the OS's own WM_CLOSE-then-terminate pattern)

The frame handle: IFrame::GetHWnd · IFrame::GetHWndx64 · CodeStack — the Frame object · CodeStack — driving PropertyManager windows with Win32 · CodeStack — missing API command, use WM_COMMAND

Connected exit behaviour: Saving Modified Documents (SWConnected help) · Save Modified Documents dialog (HIDD_NEW_CLOSEALL_DLG) · Save to 3DEXPERIENCE dialog · Status Feedback (SWConnected) · Error Report dialog (SWConnected) · Sign out of the 3DEXPERIENCE platform · GoEngineer — the 3DEXPERIENCE login box · MySession architecture SolidPractices · 3DEXPERIENCE Launcher services · Makers-support wiki — errors and installation issues · Locking and unlocking files

Costs of a non-graceful exit: GoEngineer — top three causes of the journal-file warning · Solid Solutions — recovering files after a crash · GoEngineer — recovering a hung network licence · Background Downloader persists by design · Blue Byte — SOLIDWORKS command-line switches (incl. the undocumented /SWDisableExitApp)

Official API help (JS shell to every fetcher; quoted via search snippets): ISldWorks::ExitApp · ISldWorks::UserControl · ISldWorks::RunCommand · swCommands_e · SOLIDWORKS Connected API guide · ISldWorks::CloseAllDocuments · ISldWorks::QuitDoc · Task Scheduler overview

Negative results worth recording (searched for, not found — so nobody re-derives them): no primary source for the frame's window class name ("SW_Main" is folklore); no public report of ExitApp being declined on a Connected/Makers edition; no documented exit-time dialog for a zero-document Connected session; no documented four-button "Save all to This PC / Save all to 3DEXPERIENCE" exit dialog; no documented function for SWXDesktopHelper.exe / SWXDesktopLauncher.exe anywhere; swSendToService.exe is not attested and probably does not exist.

Command ids: tdsmith/swharness/swcommands.py — swCommands_App_Exit = 2825, swCommands_FileClose = 2789 (fetched and read directly).

ExitApp not shutting the process down: 3DSwym — "Why I can't Shut down the sldworks process using ExitApp Method" · 3DSwym — "cancel exit SW" (an add-in cannot veto shutdown) · myCAD — "Kill SLDWorks.exe -Embedding" (ExitApp alone leaves the process; title-tagging is the mature targeted-kill pattern) · CATI — "Killing the SOLIDWORKS Task Scheduler!"

Ecosystem code, read directly: xarial/xcad SwApplication.cs (the correct exit ordering) · CAD-Booster/solidworks-api (in-process code must not dispose the app) · eyfel/mcp-server-solidworks · vespo92/SolidworksMCP-TS · andrewbartels1/SolidworksMCP-python · alisamsam/Solidworks-MCP · Adam-Schildkraut/solidworks-mcp · deloarts/pyswx (exit_app, quit_doc, user_control) · ros/solidworks_urdf_exporter SwAddin.cs · codestack — stand-alone applications

Connected specifics: CADmunity — Using SOLIDWORKS APIs with SOLIDWORKS Connected · swyftsol — How to Exit 3DEXPERIENCE PLM Services

5.12 What 0.7.0-0.7.1 changed above this ladder

Everything in §5.1-§5.11 still describes swx_stop, and none of it was relaxed. What changed is that the ladder is no longer the only way a session ends. Drew, 2026-09-11: "I despise every time you ask for help closing. You MUST find/build a way to safely close the docs/program."

swx_stop refuses while documents are open, and that refusal was correct — it is the reason this whole investigation exists — but it made "please close your documents" the bridge's standard answer. The fix is not to weaken the refusal. It is to remove its cause: swx_shutdown preserves every open document to a recovery folder, closes them, and only then enters this ladder, which by that point has zero documents open and every precondition satisfied honestly rather than waived.

The consent question moves; the data-safety guarantees do not. The new sequence carries its own rules, each one checkable and each one mutation-verified:

  • A sweep that will not fit does not start. The session's write size is estimated and compared with free space on the recovery volume before anything is written, and again before every save. A recovery file can be the RIGHT document CUT SHORT — the container marker is at offset 4, so every prefix carries it, and a truncation past 16 KB clears the floor — so a truncation this bridge could not detect afterwards is one it declines to cause.
  • Nothing closes before it is on disk, verified per document, and "on disk" is a STRUCTURAL question: the recovery file must be a recognised SOLIDWORKS container and at least as big as the smallest real document of its KIND — part 24 KB, assembly 80 KB, drawing 256 KB, and the strictest of those (plus the original file's size) when the kind could not be established. The kind is carried from the COM read to the verdict, never re-derived from the recovery file's name: that name is CHOSEN from the kind, so reading it back checks nothing. When GetType fails the document's own PATH is consulted, then its title — both describe the document rather than repeating this sweep's own assumption — and only a pathless document with no extension anywhere is written under the neutral .sldrecover name (recoverySignatureOk and recoveryBytes in the reply). exists() accepts a zero-byte file and a byte floor accepts a truncated one; both are what a full disk leaves behind. The check fails CLOSED. Measured, 2026-09-11: of 166 real documents from three hardware repositories, NONE begins with the OLE compound-document signature the references name for SOLIDWORKS, and all 166 carry 00 00 00 04 at offset 4. Verifying CFBF as originally specified would have refused to close every document on a modern install.
  • A document that could not be written is left OPEN and named in remaining.
  • The user's own file is never written over. SaveAs3 with swSaveAsOptions_Silent ALONE re-points the document at the recovery file, so their original is untouched and the close key read back from the document is the recovery path.
  • Parents close before children, because a component cannot close while its assembly holds it — proven from an ordering journal, not from the end state.
  • A dialog stops the sweep and is never answered (§5.6's rule, unchanged), and the census is taken before EVERY save and before EVERY close — a save can raise a prompt of its own, and a per-batch census could not see it until the closes had already happened. A dialog census that cannot be READ is its own outcome: saves proceed, nothing closes, stoppedBy:"dialogCensusUnknown". UNKNOWN still refuses.

Under userAuthorized:true the ladder also no longer requires a launch attribution — the user may quit a session this bridge did not start — and the window-message rungs become available, which matters when COM is wedged behind a licence dialog. It still never kills anything.

5.13 The licence dialog blocks everything, measured 2026-09-17

The safe-close sweep stopped on a dialog and refused to go further, which is correct. Read afterwards with UI Automation (read-only), the window was:

title:   License Manager
text:    Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional:
         Unable to connect to server. The session will end after the next 5
         unsuccessful attempts.
buttons: OK

It appeared when the 3DEXPERIENCE licence heartbeat failed — it coincided with the desktop dropping off the relay — and while it is up both the sweep and the quit are blocked, because every one of this bridge's rules stops at a dialog and never answers one.

Two consequences worth stating plainly. First, a licence-server outage makes the session unclosable by this bridge until a person dismisses the dialog; the work is not at risk (the sweep preserves before it closes, and it had not closed anything) but the session cannot be ended. Second, the message is a COUNTDOWN — five more failed checks and the session ends by itself — which is exactly the kind of thing a caller must be shown rather than told "a dialog is up". Since 0.8.4 the report carries the dialog's text and buttons for that reason.

This bridge still does not press OK. Whether it ever may, for a dialog whose only control is an acknowledgement, is a policy question for Drew — acknowledgeOnly reports the fact so that decision can be made on evidence, and neverAnswer remains true until it is.

In this repo: docs/LAUNCH-RESILIENCE.md (the attribution law, the kill allowlist, the launch state machine) · docs/FUSION-PARITY-BRIEF.md §"The gate was closed in the wrong direction" (the only ledger-backed decline we have) · docs/_fusion-inventory.md line 29 (fusion_stop = clean close + WM_CLOSE) · docs/ECOSYSTEM-TABLE.md row 1 · src/verbs.rs:1008 · src/comhost/com.rs:1293 · src/abwin.rs · src/comhost/expr.rs.

Machine evidence (all read-only, 2026-09-08, adom_drew2): process census (Win32_Process), window census (EnumWindows/GetClassName/IsHungAppWindow/ GetMenuState), swx_status, swx_get_app_state, four swx_com_eval reads, %APPDATA%\DassaultSystemes\SWXDesktopLauncher\*.log, %APPDATA%\DassaultSystemes\SWXDesktopHelper\*.log, %APPDATA%\SOLIDWORKS\SOLIDWORKS 2026\{swxJRNL.swj,swxJRNL.BAK,3dexperience-log.json,SWStdSessionsData.xml}, %LOCALAPPDATA%\Adom Bridge\bridges-cache\solidworks\ledger.jsonl, %TEMP%\swx*, HKCU\Software\SolidWorks\*.

5.14 LIVE-PROVEN 2026-09-30 — the full gate cycle, through the bridge ab hosts

tools/quit-gate.py ran its whole cycle on Drew's machine against the RELEASED bridge: adom-bridge 2.1.163 had auto-installed 0.8.9 into bridges-cache\solidworks and routes swx_* verbs to it. Every claim below came from the machine, not from the bridge's own report.

Claim Measured
The session was bridge-launched (the gate's precondition) yes — attribution intact
The quit succeeded on rung (a) yes: UserControl=false → ExitApp → every COM reference released
The process is gone (pid AND creation time absent from the census) 29.5 s after the rung
Per-session children reaped, platform daemons left alone yes
Nothing on screen afterwards, no crash reporter yes, both
Every rung attempted is reported yes
The bridge can launch again after a graceful quit SOLIDWORKS relaunched by the bridge 54.7 s later

The same day: the launch gate passed every launch claim, the drain gate passed 5 of 5, and the ownership measurement passed both halves (a pathless Part2 preserved by ownership, 25,959 bytes verified, listed from the manifest; Drew's saved R_0603_1608Metric_1.step-1.sldprt closed with its original untouched — same 32,311 bytes and the same mtime after the sweep). See docs/LAUNCH-RESILIENCE.md "Live proof 2".

Two things the gate had to change to run at all, both measured:

  1. The route. The gate used to reach the bridge through shell_execute + curl.exe to a side-loaded dev copy on 127.0.0.1:24695. adom-bridge 2.1.163 terminates a second swx-bridge.exe within 30 s, so that route is dead. Every gate (tools/{launch,drain,quit}-gate.py, and tools/swx-live.py) now calls 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) 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 authorizes a quit. It now passes userAuthorized:true with a reason, which is Drew's standing authorization (2026-09-11) as swx_stop reads it.

How this bridge is tested on the machine now. The dev deploy pattern — a deployN.py side-load of a dev build onto port 24695 next to the released one — is RETIRED, for the reason in (1). Dev testing is either a PRERELEASE install through ab, or a renamed exe reached with SWX_BRIDGE_URL. Which of those ab tolerates is NOT MEASURED yet: 2.1.163 was seen terminating a second swx-bridge.exe; whether it also terminates a copy under another name on another port has not been tried, and until it is, that route is a candidate, not a method. And the fact that decides how carefully a release is made: the version ab hosts is whatever the published manifest says, so a release IS a deploy to every machine that runs the bridge — there is no separate step at which a bad build can still be stopped.

5.15 The decline is about session HISTORY — measured 2026-10-01

One session, measured cleanly (pid 20144, bridge-launched, attributed, hosted 0.9.2): swx_stop rung (a) — UserControl=false; ExitApp; every COM reference released; UserControl restored — was DECLINED three times, at ~50 s, ~2.5 min and ~8 min old: 0 documents, no dialog, census readable, only the main frame visible, the auto-recover folder empty (ledger: solidworks_quit_rung {"result":"declined","rung":"a"} at 1790869846 and 1790869980). The session had NEVER had a document open. Then, on the SAME session: swx_new_document (a scratch part, identity path) → swx_reset_workspace closed it → swx_stop rung (a) → {"result":"quit","processGone":true,"elapsedSec":3.4}.

That reconciles September: 09-08's "ExitApp declined at zero documents" was a never-had-a-document session; 09-17's "ExitApp is not declined at zero documents" was measured on sessions that had held documents. Startup timing is NOT the cause — the declines spanned eight minutes, long after any start had finished — though 0.9.3 gates the quit on readiness anyway (see LAUNCH-RESILIENCE "Live proof 3").

What 0.9.3 does (src/session_history.rs, prime_session in src/verbs.rs):

  • The bridge records per session — (pid, creation time), in session-history.json in the data root — whether it has ever held a document, whenever a verb shows one open. everHadDocument is false only for a session the bridge launched and watched from its start, and UNKNOWN for one it attached to.
  • Rung (a), only under userAuthorized and only on an EMPTY session with no dialog: if ExitApp is declined and everHadDocument is false or unknown, the ladder PRIMES the session — one bridge-owned scratch part created and closed through the ordinary owned-document path (default posture, registered, ledgered quit_prime) — and asks ExitApp once more. Both attempts are in rungsTried, the second primed:true. Never with a document open: the emptiness is re-read right before priming, and a document that appeared in between stops it. The simulator models a never-had-a-document session (exitedIfEverHadDocument), and both mutations — priming removed, priming with a document open — are caught by tests/quit.rs.
  • The quit gate's "swx_status no longer reports a running SOLIDWORKS" claim was VACUOUS: it passed whenever the quit had been declined, and printed PASS twice while running was true. It now requires running:false, and tools/quit-gate.py --self-test (run by the suite) pins it with canned replies.

The coordinator is still investigating whether a session relaunched after a crash holds document-recovery state; this section does not claim history is the only cause of a decline — only that it is one, measured.

5.16 A notification is not a modal — measured 2026-10-01

The 3DEXPERIENCE "Connected to …" notice (hwnd 331370: #32770, no title, WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_CONTROLPARENT, owner = the main frame and ENABLED for all 20 reads over 95 s, all children Static) appeared ~18 s after a document create and stayed for minutes, and 0.9.3's census — which treated any visible #32770 as blocking — refused every quit while it was up (stop_blocked_by_dialog, rungsTried: []). The precondition "no dialog" was right in spirit and wrong in its instrument: the question is whether the window is MODAL, and that is measurable. Since 0.9.4 (quit::judge_window): blocking = a known blocking kind, OR the owner frame disabled, OR a Button-class control; notification = a tool window, owner enabled, no Button-class control, all 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.

5.20 LIVE 2026-10-01 on 0.9.5 — priming regressed; 0.9.6 makes the history advisory

  • Measured. Session pid 29108, launched by the launch gate, never driven, no document ever opened by anyone: swx_stop rung (a) DECLINED (30.7 s), rungsTried ONE entry, primed:false, host_app_stop_declined — twice. swx_status.solidworks.sessionHistory said everHadDocument:true, "a document was seen open in this session" — FALSE. 0.9.5's readiness poll had recorded a non-zero GetDocumentCount read while SOLIDWORKS was starting (it may hold a hidden document then, or the count is read before it settles), and the guard "a session that has held a document is never primed" (§5.18) blocked the prime. The coordinator primed by hand (new_document → reset_workspace → swx_stop quit in 3.5 s).
  • 0.9.6: the history is ADVISORY. Priming triggers on the measured condition itself — rung (a) declined, the session empty (count 0, re-checked after the prime too), no BLOCKING dialog, userAuthorized — once per swx_stop call. sessionHistory is reported (advisory:true) and read by no decision. Pinned: a_session_that_has_held_a_document_is_still_primed_when_empty (restoring the guard fails it).
  • …and the false true is fixed. Every poll records WHAT it saw (count, time, source) under sessionHistory.observations. A count read while the session is starting is never recorded as evidence; after the start, a count

    0 must persist across two distinct polls (≥ 1 s apart, no zero between) to set everHadDocument; the bridge's own create/open sets it directly. Pinned by a_phantom_document_count_never_records_history_and_priming_still_fires (a count of 1 throughout startup, then a count of 1 on one poll).

  • The quit gate (G2). tools/quit-gate.py refused its own baseline on the 3DEXPERIENCE "Connected" NOTIFICATION (title '') because it counted every visible #32770 as a dialog. It now takes the BRIDGE's verdict — swx_window_info.dialogCensus (blocking + notifications, judged by quit::judge_window, taken without COM) — lists notifications, refuses on a blocking dialog (the only case that tells the reader to hand a screenshot to the user), and refuses on an unreadable census without blaming the user's screen. Its --self-test pins all four verdicts.

5.21 LIVE 2026-10-01 on 0.9.6 — priming fixed, twice; 0.9.7 closes adversary pass 44

  • Priming, live: an empty relaunched session at ≥ 120 s old → rungsTried [declined 30.7 s, quit primed:true 3.4 s] — twice. The QUIT GATE PASSED (11/11, with relaunch) and printed the bridge's dialog census instead of refusing on the Connected notification.
  • MH pinned (F4): swx_stop's startup carve-out needs userAuthorized; without it a quit during startup is host_app_starting with no rung tried — never the window-message rungs.
  • Internal arguments (F5): every underscore-prefixed argument is stripped from caller input at the dispatcher, so _startupNotVerified cannot be used on a ready session to skip the gentle COM rungs.
  • One classifier (F6): quit::classify_words reads a window's title AND its words; judge_window and swx_dismiss_blocking_dialogs both use it. Dismiss reads a window's words before closing it: a save question under a "Warning" title is refused as save_changes "by its text", and a window whose words cannot be read is not closed.

5.22 LIVE 2026-10-01 on 0.9.7 — a notice read while its owner was BUSY; 0.9.8 settles modality

  • Live, all green: launch gate 16/16 (READY 16.7 s, performance log {model:"rewrittenPerStart", headersInTail:1, lengthNow:6449, lengthAtFirstSight:6449, shrankSinceFirstSight:false, verdict:"complete"}); drain 5/5; QUIT GATE 11/11 with relaunch; primed quit of the relaunched empty session at ≥ 120 s: [declined 30.7 s, quit primed 3.4 s].
  • The one new measurement. swx_close_all_safely {batch:1} over Part1 (pathless) + Drew's saved part returned success:false, closed 1, documentCountAfter 1, stopped_by_dialog, on: hwnd 4329196, #32770, style 0x94000044, exstyle 0x90080 (tool window), rect [533,123,834,190] (301x67), owner 856844, ownerEnabled FALSE, title '', nine Static controls (3 visible, 6 hidden), the template placeholder captions — the SAME 3DEXPERIENCE Connected notice measured that morning (§5.16), read at an instant when the main frame was disabled, i.e. while SOLIDWORKS was busy inside the sweep's own close/save. Seconds later the quit gate's own sweep closed the remaining document and passed: the disabled state was transient. Safe direction (stopped, nothing lost, honest count), but a notice could stop a sweep.
  • 0.9.8: modality is a persistent measurement, not one read. When a window is a notification on every other measured fact (tool window, controls read, no Button class, no button-like captions, no save/recovery/import words) and ONLY its owner's enabled state says blocking, the census re-reads the owner (LaunchOs::owner_enabled_of) up to 5 times ~400 ms apart. Disabled (or unreadable) on every read → blocking, as before, with the read history. Enabled on a later read → a notification, reported with ownerDisabledTransiently:true and ownerReads. The settle is never applied to a window with a Button-class child, button-like captions or a save/recovery/import kind (judge_window with the owner taken as enabled still blocks those). The sweep already censuses before EVERY save and EVERY close, between its own COM calls — the live read came right after one close returned and before the next was issued; the settle gives that instant time to pass.

5.23 LIVE 2026-10-01 on 0.9.8 — the USER took over a bridge-launched session; 0.9.9 guards presence

  • Live: launch gate 16/16 (READY 16.7 s); 2 of 3 sweeps completed; drain 5/5; quit gate 11/11.
  • The event that defines 0.9.9. A "leave the machine as found" swx_stop on the relaunched, empty-looking session returned stop_blocked_by_dialog, rungsTried [{result:"dialogAppeared", 7.9 s}]. The dialog was SOLIDWORKS's own File Open dialog (Options…, Open, Cancel, &Help, Open from 3DEXPERIENCE, Re&ferences…, Select Sheets…, a shell file browser inside): DREW HAD STARTED USING THE SESSION. Minutes later it held 34 documents — his crate assembly ASM-CRATE.SLDASM and its parts under C:/Users/Public/adom-crate/, plus a pathless Part1. The retry was correctly refused open_documents_present. Nothing was closed or lost — but only because his dialog happened to be up: an empty-looking session he had just brought to the front would have been quit under him, and a sweep a minute later would have closed his clean documents in place. The bridge had no notion that the USER had taken over a session it launched.
  • 0.9.9: the user-presence guard (src/presence.rs). Before swx_stop, swx_shutdown, swx_close_all_safely, swx_close_all_documents, swx_reset_workspace — and again before priming — presence is MEASURED: SOLIDWORKS (its process or a child) is the FOREGROUND window (GetForegroundWindow → GetWindowThreadProcessId) AND the last user input (GetLastInputInfo) is under 120 s; OR documents are open that were not in the set the bridge last looked at (refreshed after every bridge verb that can change it); OR the session was already taken over. Present → user_is_active with the evidence (foreground window title/class/pid, secondsSinceLastInput, foreignDocuments count + titles). An unreadable measurement is present (fail closed) but never marks a take-over; POSITIVE evidence marks the session takenOverByUser persistently for its pid + creation time — the bridge then never ends or sweeps it without userPresentOverride:true + userAuthorized:true + a reason, even after the user steps away (a sweep under the override still preserves first). Verbs that ADD documents still run on a taken-over session and report userPresent. swx_user_presence reads the same measurement. The quit and drain gates measure it first, refuse on presence, and never pass the override.
  • Not yet explained: one of the three sweeps stopped on the Connected notice with its owner read DISABLED across the whole 0.9.8 settle window (5 reads, ~2 s). The next sweep closed everything. It cannot be measured further today: the user is working in the session.

5.24 0.9.10 — the baseline holds only what the bridge put there (adversary pass 46)

The 0.9.9 guard's document signal was "foreign = open now but not in the remembered set", and the user's own documents got INTO that set three ways — each measured ending a session with the user's documents (2 open → 0 after swx_close_all_safely):

  • B1 a session the USER started: with no baseline nothing was foreign, and a guard that read the user as absent wrote the user's documents in as the baseline — a user-launched session with their assembly open, the user reading its drawing in another app, was swept on the bridge's first look;
  • B2 a document opened between launch-ready and the bridge's first document verb was adopted by that verb's own check — the 10-01 shape exactly;
  • B3 every changing verb re-read the whole open set into the baseline, so everything the user opened since the previous bridge verb was adopted.

The one rule: the baseline may contain ONLY documents the bridge itself put there. It starts EMPTY for every session, bridge-launched or not, and grows only by what a bridge verb opened or created — the difference across that verb — and loses what is no longer open. It is never re-read from the open set. Consequence, stated plainly: in a session the user launched, every document is the user's, so swx_stop, swx_shutdown, the sweeps and the drain refuse user_is_active until the user explicitly asks (userPresentOverride:true + userAuthorized:true + a reason). That is the intended reading of "never end a session the user is working in". A harness step that sweeps the user's own documents on their request (the live "ownership sweep") must now pass the override explicitly; the quit and drain gates never do. The stated limit: a document the user opens DURING a bridge verb is attributed to that verb.

Also: B4 no foreground window (secure desktop — a UAC prompt, the lock screen — or a disconnected session, where the last-input time is stale too) is UNREADABLE, so present; B5 every override ledger line records who passed it (caller.aiThread, containerName, source; self-asserted, recorded, never used to authorize); B6 the re-check before priming is pinned with a mid-verb fixture (presence-after-decline.json: the user comes back between the declined rung and the prime — the prime is refused, the decline stands). The adversary's process notes are kept in tests/adversary44.rs: the simulator adds the user's document only when app state is read, and swx_user_presence itself marks a session taken over, so tests must not call it as a probe.

5.25 LIVE 2026-10-02 on 0.9.11 — the guard proven on Drew's crate session; 0.9.12 fixes what failed on the way

  • Proven live (pid 26924, 34 documents, ~3 h old): the lock-out is fixed — ready, performanceLog.verdict:"onDemandAddInAfterComplete". The presence guard held: swx_close_all_safely without the override → user_is_active (takenOverEarlier, 34 foreign documents, foreground Chrome, last input 167 s). With the override and Drew's words as the reason, the sweep preserved the pathless Part1 (26,617 bytes, verified) and closed 33 documents in place, all 33 re-measured UNTOUCHED; swx_stop quit in 3.5 s.
  • S1 — the sweep could not close an assembly session (availability). The open set listed 28 parts FIRST, then ASM-CRATE.SLDASM (top level) and four sub-assemblies. The default sweep and batch:4 walked the first documents (parts); every close was "still open after CloseDoc" (a component stays loaded while its assembly is open); closed_this_pass == 0 → the no-progress stop ended the sweep with 33 open, success:true, done:false. batch:40 (one pass over everything) worked because the per-pass sort put the assemblies first. 0.9.12: the whole session is preserved before anything closes (a closing assembly unloads its components, so each must be on disk first); closes run in a GLOBAL order — drawings, assemblies with the active document first, parts, unknown; a component or sub-assembly still open after CloseDoc while another assembly is open is DEFERRED (not a failure, not counted against batch); one that is no longer open when its turn comes — or a preserved document gone once the session is empty — is closed closedByParent; no progress means a pass that tried every preserved document closed none, and anything still deferred then is reported as a close failure. batch now paces the closes of a pass.
  • S2 — ab cut the reply at 120 s ("did not respond within ab's 120s budget") while the sweep ran on: swx_close_all_safely, swx_shutdown, swx_close_import_temps, swx_reset_workspace and swx_stop had no declared budget. bridge.json timeouts.verbs now declares 600 / 900 / 600 / 600 / 420 s; a sweep's own ceiling is 540 s (SWEEP_MAX_SECS), so its reply — done:false if it ran out — arrives first. swx_progress answered {} during that sweep (not reproduced here: the bridge serves requests on their own threads); it now carries the sweep's stage and counts in detail.
  • S3 — the License Manager, measured again. Two QUEUED, identical notices — "Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional: Unable to connect to server. The session will end after the next 5 unsuccessful attempts." — OK only. Census record: #32770, owner NULL (ownerEnabled null → blocking: "whether its owner frame is enabled could not be read"), text: [] with contentsReadable: true — false: its body is a DirectUIHWND task dialog, whose words are not in child controls. The coordinator acknowledged both by hand on Drew's "do whatever you need"; the bridge still never answers. 0.9.12: no text and no buttons beside a DirectUIHWND child (or no children) is contentsReadable:false, text null, with contentsUnreadableWhy; it is judged as unread (blocking). Dialogs can QUEUE: closing one may reveal an identical one beneath it.