SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
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
- 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 byGetActiveObject. - v1 cannot tell "declined" from "quit but pinned by our own pointer." The
com host holds a live
IDispatchon SOLIDWORKS at the exact moment it asks whether SOLIDWORKS went away, and the documented meaning ofUserControlis that an automation client's outstanding reference is what keeps the app alive. Some fraction of the observed decline may be self-inflicted. - If
ExitAppreally is declined at 0 documents, the strongest evidence points atWM_CLOSEto the main frame. This machine's previous session ended at 15:24:05 with the journal recordingswApp.ExitApp+End Suband the 3DEXPERIENCE helper loggingDisconnect— 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. - 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);RunMacro2of a two-line shipped.swpthat callsExitAppfrom inside the process (this edition restricts add-ins but runs macros, and the VBA host is already loaded); and the Restart Manager (RmShutdownwithRmForceShutdownomitted) — the OS's own documented polite shutdown, targeted by pid and creation time, which never terminates anything and returnsERROR_FAIL_SHUTDOWNwith a per-process reason when refused. It goes last, becauseENDSESSION_CLOSEAPPtells 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. - 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, every3DEXPERIENCELauncher*) still alive, because those survive a graceful exit by design;swxJRNL.swjending inswApp.ExitApp+End Suband its lock released; no new top-level window; no Error Report dialog. %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.- The precondition rule in the code today is the wrong one.
swx_stopgates onunsavedDocuments == 0, not on the open-document count — so right now, with five of Drew's clean documents open, it would try to quit. v2 requiresopenDocuments == Some(0), andforcemay waive only the unsaved question, never the count, the dialog census, busy, hung or identity. - 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),
RmForceShutdownis 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.) - 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_stophas all five, refuses on any unknown, and writes a ledger line per attempt — including the refusals v1 records nowhere. - Biggest unknown: whether
ExitAppis 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 dirtyPart1open (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.ExitAppat the end of that file is the application's own exit code path running — the same path File ▸ Exit runs — andEnd Subafter it means the journal was closed properly, i.e. the process was not killed. - The 3DEXPERIENCE helper logged
Disconnectand 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_shutdownthen removed a stray empty instance (pid 145188), after whichtasklistshowed nosldworks.exeat all.
ExitAppis 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
voidin 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
ExitAppappears 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 "
ExitAppdid nothing" is therefore not a different call, it is releasing the reference: the canonical answer isExitApp()followed byMarshal.FinalReleaseComObject(swApp)rather than merely dropping the variable, and xCAD.NET'sRelease()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 ittrueonly 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 setVisible = falseas 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 ofmyModelView.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 throughswx_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 plainSendMessage(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" — soSetLastError(ERROR_SUCCESS)first, then read it.PostMessagereturns immediately and never blocks — correct for theSC_CLOSErung, where the measurement is the process census anyway. It is also exactly whatProcess.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_CLOSEreturnsTRUEand does nothing. Every message rung mustSetLastError(0)first and check forERROR_ACCESS_DENIED (5), and report a UIPI block as its own outcome — never as "declined". On this machine both processes run as Drew andsldworks.exeis 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:
- 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).
- 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.
- 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→neverAutoAnswerbyabwin::classifytoday, 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.) - 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
.SWARfiles must be found by hand. - A network licence can stay checked out until the SNL service is restarted or
a FlexLM
TIMEOUTexpires. (Connected uses platform licensing rather than SNL, so this is background rather than our case.) - An orphaned
SLDWORKS.exesuppresses auto-save in the next session and produces the "another session running" warning; the folk fix is renamingHKCU\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_killon 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\CATSettingsfor 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'sTERMINABLE_IMAGES/NEVER_TERMINATEcalculus 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
.swpVBA macro can callswApp.ExitAppand 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_econtainsswCommands_App_Exit = 2825(cross-checked intdsmith/swharness/swcommands.py, alongsideswCommands_FileClose = 2789), usable viaISldWorks::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.exekeeps running, so developers reach forCloseMainWindow()thenKill();taskkill /f /imin 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 distinctiveMainWindowTitleon 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.exeas the remedy for a scheduler that will not stop. NotesldProcMon.exeis 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:
- Do not treat a missing helper
Disconnectas a failed quit. In a bridge-launched session there is no helper to write one. The platform-side signal available is the3dexperience-log.jsontail — not the exit ofEdmServerV6.exe/ENOPLMCSAClient.exe, which are the 3DEXPERIENCE PLM Services tray apps and are documented to survive a graceful exit (§2.6).ViewServerV6.exeis undocumented: record it, conclude nothing. - 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.exepid, 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 Suband the helper'sDisconnect— the very next launch was the bridge's own, at 15:35, and it worked: launcher log 15:35:36,sldworks.exeat 15:35:41,Run End, ledgersolidworks_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 atSecurityContext:with noRun 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, withGetSaveTo3DExperienceOptions/SaveTo3DExperienceandGetFilePLMIDon the storage side. ExitAppis not on any published discontinued-for-Connected list, and this pass found no public report ofExitAppbeing 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:
- It gates on
unsavedDocuments, not on the OPEN document count. Right now this session has 5 clean documents of Drew's open andunsavedDocuments:0— so a bareswx_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 isSome(0). - The verification is done while we still hold a reference to the object.
let sw = self.app(...)is a liveIDispatchfor the whole match arm;self.sw = Nonedrops the cached clone but not the local.GetActiveObjectis then asked "are you still there?" while our own com host is one of the reasons the answer could be yes. Per the documentedUserControlsemantics (§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 inMarshal. FinalReleaseComObject; we have no equivalent. GetActiveObjectis 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 whatswx_statusalready reads (solidworks.processes[],responding).- There is exactly one rung.
ExitAppor nothing. When it is declined the verb reports honestly (which is right) and then the only thing left in the bridge isswx_kill(which is wrong). - 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_statuson the dev bridge reportsabAvailable:false, and ab's close isdesktop_ui_window {action:"close"}— a UIAWindowPattern.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 thewindowscrate and already runs on an STA thread with the app's hwnd in hand;SendMessageTimeout/PostMessagethere 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 anEnumWindows+GetWindowThreadProcessIdcensus 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 makeswx_stoppermanently impossible. - Reads the escape hatch cannot currently make.
swx_com_eval's allowlist (src/comhost/expr.rs) admits onlyREAD_MEMBERSandget*/is*/has*/can*/enum*prefixes, soUserControl,UserControlBackground,Visible,FrameState,CommandInProgressandApplicationType— every property this design needs to observe — are refused as "not a read". They are plain property gets. Add them toREAD_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 = 2825goes 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 that6622700fits. 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/RmEndSessionfromrstrtmgr— present in thewindowscrate behind a feature.RM_UNIQUE_PROCESSwants the pid and the process creation time as aFILETIME, which is exactly what P7 already captures.RmForceShutdownmust not be reachable from the code at all — not a runtime flag, not an argument: a constant0at 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:truecannot pass P1, P3, P4, P5 or P7.a_dialog_stops_the_ladder_and_is_never_answered— asave_changeswindow 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 torungsTried.a_refusal_is_recorded_in_the_ledger—solidworks_quit_refusedwith 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 reachestaskkill,TerminateProcess,swx_kill, orRmForceShutdown.a_blocked_message_is_reported_as_blocked_not_declined— a simulatedERROR_ACCESS_DENIEDon a message rung →result:"uipiBlocked", ladder stops. (UIPI failures return success, so an uncheckedGetLastErrorwould report a quit that never happened — §2.3.)the_expected_survivors_are_not_treated_as_a_failure— a census in whichENOPLMCSAClient/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 byswx_describeand listed inbridge.json.
5.10 Two other things this investigation found in the existing code
Neither is about quitting, both belong to the same posture:
swx_killkills 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'sMainWindowTitlefirst and kill only the match.- The com host disowns every bridge-created document BEFORE it checks whether
the quit worked. In
com.rs's"stop"arm the order isExitApp→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 nextswx_save/swx_close_docon 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'sowned-docs.jsonis correctly left alone —verbs.rsonly 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
- Never a kill. There is no terminate call anywhere in the stop path. Rung
(f) passes literal
0whereRmForceShutdownwould go, andtests/quit.rspins the absence: every rung's action string is asserted free of kill/terminate/force wording (forceis allowed only in the phrase "RmForceShutdown OMITTED", which is the assertion that it is not passed). - Never answer a dialog. A new top-level
#32770stops the ladder permanently and becomesblockingDialogin the response; the hint sends the caller toswx_state's background screenshot and to the user. Nothing is clicked.src/abwin.rs::classifywas widened so "Save Modified Documents" — the real title, which the classifier had been returningunknownfor — classifies assave_changes. - Nothing is disowned until the process is verifiably gone. The scratch
registry,
owned-docs.jsonand the com host's owncreatedset are cleared only on the success arm, afterprocess_goneisSome(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) firedRunCommand(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-usableStopEvidence::sweepand runs at the top of every iteration; a mid-ladder failure refuses with the same codes the front gate uses, carriesrefusedMidLadder:trueand the rungs already tried, and its ledger line recordsafterRungs. 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
ExitAppreturns 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 asdeclined, 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'sdetailsays how many reads it took.SWX_QUIT_EXIT_WAIT_SECSshortens the wait for the suite and the gate. The simulator gainedexitAfterMs, 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 ofsldworks.exewith 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_pidsnow returns the SOLIDWORKS pid plus everyswCefSubProc.exewhose 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 reportsdialogCensusPids. - P1 accepted
Some(0)without the cross-check the Python gate already did (Q-4).GetDocumentCountandGetDocumentsare two different COM reads;check_preconditionsnow 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_CLOSEcould land on somebody else's window. The com host now re-verifiesGetWindowThreadProcessId(hwnd) == pidand 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
- Do not run any of this yet. As of writing, Drew is working in the session
(
openDocs 5, journal recordingSave3andLoadFile2calls minutes ago). - 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. - Make the state observable, and fix the handle: add the property gets to
expr.rs::READ_MEMBERS(§5.6), fix theIsPLMConnectedexample (that member does not exist on this build), and switchFrame().GetHWnd()toFrame().GetHWndx64()read as an i64 — every window rung aims at that handle. - Build
swx_stopv2 pinned atmaxRung:"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. - Write
tools/quit-gate.pyto the shape oftools/launch-gate.py, and take its--baselineread-only. - 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.
- Only then arm rungs (b)–(e), one per run, in that order.
- Update
SKILL.md(swx_stop's documented behaviour),README.mdanddocs/ECOSYSTEM-TABLE.mdrow 1 with whatever the gate actually proves — and note that the row's current claim ("ExitAppDECLINED 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
GetTypefails 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.sldrecovername (recoverySignatureOkandrecoveryBytesin 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 carry00 00 00 04at 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.
SaveAs3withswSaveAsOptions_SilentALONE 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:
- The route. The gate used to reach the bridge through
shell_execute+curl.exeto a side-loaded dev copy on127.0.0.1:24695. adom-bridge 2.1.163 terminates a secondswx-bridge.exewithin 30 s, so that route is dead. Every gate (tools/{launch,drain,quit}-gate.py, andtools/swx-live.py) now callsadom-bridge --target adom_drew2 --ai-thread <thread> <verb> <json>, and unwraps the routed reply (result, when present — ab merges top-level fields and adds anoutputstring).SWX_BRIDGE_URLforces the old loopback route. A fastshell_executefailure 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 wascurl.exeagainst 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. - The grant. The gate passed
allowUserWrites+confirm, and was refuseduser_writes_disabledwith attribution intact: the bridge ab hosts never carriesSWX_ALLOW_USER_WRITES, and it should not — the write gate is not what authorizes a quit. It now passesuserAuthorized:truewith a reason, which is Drew's standing authorization (2026-09-11) asswx_stopreads 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.jsonin the data root — whether it has ever held a document, whenever a verb shows one open.everHadDocumentisfalseonly for a session the bridge launched and watched from its start, and UNKNOWN for one it attached to. - Rung (a), only under
userAuthorizedand only on an EMPTY session with no dialog: if ExitApp is declined andeverHadDocumentis 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, ledgeredquit_prime) — and asks ExitApp once more. Both attempts are inrungsTried, the secondprimed: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 bytests/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
runningwas true. It now requiresrunning:false, andtools/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_stopquit 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_startafter 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_statusnow exposessolidworks.sessionHistory(everHadDocumenttrue/false/null with itsbasis) 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-zeroGetDocumentCountat any readiness poll, which every gated verb andswx_statusmake), andprime_sessionREADS it. Removing either the guard or the recording fails that test.everHadDocument:falseis 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_workspacecloses only bridge-created documents) — not just "not empty afterwards". - The quit during startup.
swx_stopis 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 underuserAuthorized:truea plan reaching them runs during startup with rungs (a), (b) and (e) recordedskipped("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 carrieswindowMessageRungsnaming 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_windowread 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
#32770is 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, andswx_stoprequires zero unsaved documents (P2; onlyforcewaives 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_stoprung (a) DECLINED (30.7 s),rungsTriedONE entry,primed:false,host_app_stop_declined— twice.swx_status.solidworks.sessionHistorysaideverHadDocument:true, "a document was seen open in this session" — FALSE. 0.9.5's readiness poll had recorded a non-zeroGetDocumentCountread 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 perswx_stopcall.sessionHistoryis 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
trueis fixed. Every poll records WHAT it saw (count, time, source) undersessionHistory.observations. A count read while the session is starting is never recorded as evidence; after the start, a count0 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 bya_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.pyrefused its own baseline on the 3DEXPERIENCE "Connected" NOTIFICATION (title '') because it counted every visible#32770as a dialog. It now takes the BRIDGE's verdict —swx_window_info.dialogCensus(blocking+notifications, judged byquit::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-testpins 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, quitprimed:true3.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 ishost_app_startingwith no rung tried — never the window-message rungs. - Internal arguments (F5): every underscore-prefixed argument is stripped
from caller input at the dispatcher, so
_startupNotVerifiedcannot be used on a ready session to skip the gentle COM rungs. - One classifier (F6):
quit::classify_wordsreads a window's title AND its words;judge_windowandswx_dismiss_blocking_dialogsboth use it. Dismiss reads a window's words before closing it: a save question under a "Warning" title is refused assave_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 returnedsuccess:false, closed 1,documentCountAfter1,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 withownerDisabledTransiently:trueandownerReads. 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_stopon the relaunched, empty-looking session returnedstop_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 refusedopen_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_activewith 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 sessiontakenOverByUserpersistently for its pid + creation time — the bridge then never ends or sweeps it withoutuserPresentOverride: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 reportuserPresent.swx_user_presencereads 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_safelywithout 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_stopquit 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:4walked 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 againstbatch); one that is no longer open when its turn comes — or a preserved document gone once the session is empty — is closedclosedByParent; no progress means a pass that tried every preserved document closed none, and anything still deferred then is reported as a close failure.batchnow 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_workspaceandswx_stophad no declared budget.bridge.jsontimeouts.verbsnow declares 600 / 900 / 600 / 600 / 420 s; a sweep's own ceiling is 540 s (SWEEP_MAX_SECS), so its reply —done:falseif it ran out — arrives first.swx_progressanswered{}during that sweep (not reproduced here: the bridge serves requests on their own threads); it now carries the sweep's stage and counts indetail. - 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 (ownerEnablednull → blocking: "whether its owner frame is enabled could not be read"),text: []withcontentsReadable: true— false: its body is aDirectUIHWNDtask 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 aDirectUIHWNDchild (or no children) iscontentsReadable:false,textnull, withcontentsUnreadableWhy; it is judged as unread (blocking). Dialogs can QUEUE: closing one may reveal an identical one beneath it.
# 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:**
0. **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).
1. **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.
2. **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.)
3. **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`:
```csharp
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
```json
{
"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`](https://learn.microsoft.com/en-us/windows/win32/winmsg/wm-close) ·
[`WM_SYSCOMMAND`](https://learn.microsoft.com/en-us/windows/win32/menurc/wm-syscommand) ·
[`WM_QUIT`](https://learn.microsoft.com/en-us/windows/win32/winmsg/wm-quit) ·
[`SendMessage`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessage) ·
[`SendMessageTimeout`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessagetimeouta) ·
[`PostMessage`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-postmessagea) ·
[`PostThreadMessage`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-postthreadmessagea) ·
[`DestroyWindow`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-destroywindow) ·
[`SendInput`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendinput) ·
[`SetForegroundWindow`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow) ·
[`EndTask`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-endtask) ·
[`ShutdownBlockReasonCreate`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-shutdownblockreasoncreate) ·
[UIPI](https://learn.microsoft.com/en-us/archive/blogs/luisdem/uipi-user-interface-privilege-isolation) ·
[`Process.CloseMainWindow`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.closemainwindow) ·
[its implementation](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Windows.cs) ·
[`MainWindowHandle`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.mainwindowhandle) ·
[Chen — you can't simulate keyboard input with PostMessage](https://devblogs.microsoft.com/oldnewthing/20250319-00/?p=110979)
**MFC shutdown path** (this is what makes `WM_CLOSE` == File ▸ Exit):
[TN022 Standard Commands Implementation](https://learn.microsoft.com/en-us/cpp/mfc/tn022-standard-commands-implementation?view=msvc-170) ·
[Destroying Frame Windows](https://learn.microsoft.com/en-us/cpp/mfc/destroying-frame-windows?view=msvc-170) ·
[`CWinApp::SaveAllModified`](https://learn.microsoft.com/en-us/cpp/mfc/reference/cwinapp-class?view=msvc-170) ·
[`CDocument::CanCloseFrame` / `SaveModified`](https://learn.microsoft.com/en-us/cpp/mfc/reference/cdocument-class?view=msvc-170) ·
[TN001 window class registration](https://learn.microsoft.com/en-us/cpp/mfc/tn001-window-class-registration?view=msvc-170) ·
[KB Q77135 — Different Ways to Close an Application](https://jeffpar.github.io/kbarchive/kb/077/Q77135/)
**Restart Manager** (rung f):
[Guidelines for Applications](https://learn.microsoft.com/en-us/windows/win32/rstmgr/guidelines-for-applications) ·
[`RmRegisterResources`](https://learn.microsoft.com/en-us/windows/win32/api/restartmanager/nf-restartmanager-rmregisterresources) ·
[`RmShutdown`](https://learn.microsoft.com/en-us/windows/win32/api/restartmanager/nf-restartmanager-rmshutdown) ·
[`WM_QUERYENDSESSION`](https://learn.microsoft.com/en-us/windows/win32/shutdown/wm-queryendsession) ·
[`WM_ENDSESSION`](https://learn.microsoft.com/en-us/windows/win32/shutdown/wm-endsession) ·
[Task Scheduler `AllowHardTerminate`](https://learn.microsoft.com/en-us/windows/win32/taskschd/tasksettings-allowhardterminate) (the OS's own WM_CLOSE-then-terminate pattern)
**The frame handle:**
[`IFrame::GetHWnd`](https://help.solidworks.com/2023/english/api/sldworksapi/SolidWorks.Interop.sldworks~SolidWorks.Interop.sldworks.IFrame~GetHWnd.html) ·
[`IFrame::GetHWndx64`](http://help.solidworks.com/2014/English/api/sldworksapi/SolidWorks.Interop.sldworks~SolidWorks.Interop.sldworks.IFrame~GetHWndx64.html) ·
[CodeStack — the Frame object](https://www.codestack.net/solidworks-api/application/frame/) ·
[CodeStack — driving PropertyManager windows with Win32](https://www.codestack.net/solidworks-api/application/frame/property-manager-page-windows-api/) ·
[CodeStack — missing API command, use WM_COMMAND](https://blog.codestack.net/missing-solidworks-api-command)
**Connected exit behaviour:**
[Saving Modified Documents (SWConnected help)](https://help.solidworks.com/2025/english/SWConnected/swdotworks/c_saving_modified_documents.htm) ·
[Save Modified Documents dialog (`HIDD_NEW_CLOSEALL_DLG`)](https://help.solidworks.com/2018/English/SolidWorks/sldworks/HIDD_NEW_CLOSEALL_DLG.htm) ·
[Save to 3DEXPERIENCE dialog](https://help.solidworks.com/2025/english/SWConnected/swdotworks/r_SaveDialogBox.htm) ·
[Status Feedback (SWConnected)](https://help.solidworks.com/2025/english/SWConnected/swdotworks/c_status_feedback.htm) ·
[Error Report dialog (SWConnected)](https://help.solidworks.com/2025/English/SWConnected/swdotworks/c_SW_error_report_db.htm) ·
[Sign out of the 3DEXPERIENCE platform](https://help.solidworks.com/2018/english/swconnection/t_sign_out_of_3DEXPERIENCE_platform.htm) ·
[GoEngineer — the 3DEXPERIENCE login box](https://www.goengineer.com/blog/understanding-the-3dexperience-login-box-in-solidworks) ·
[MySession architecture SolidPractices](https://3dswym.3dexperience.3ds.com/wiki/solidworks-news-info/understanding-solidworks-my-session-architecture-and-optimizing-performance-solidpractices_bJFmXC4ZTxedM2WKrd0kUw) ·
[3DEXPERIENCE Launcher services](https://www.3ds.com/support/documentation/resource-library/3dexperience-launcher-services) ·
[Makers-support wiki — errors and installation issues](https://3dswym.3dexperience.3ds.com/wiki/makers-support/comprehensible-guide-to-3dexperience-solidworks-for-makers-sw-design-errors-and-installation-issues_zEjFNZPPQh2K8AxvHJETbQ) ·
[Locking and unlocking files](https://help.solidworks.com/2018/English/swconnection/t_locking_and_unlocking_files.htm)
**Costs of a non-graceful exit:**
[GoEngineer — top three causes of the journal-file warning](https://www.goengineer.com/blog/top-three-causes-solidworks-journal-file-warning) ·
[Solid Solutions — recovering files after a crash](https://www.solidsolutions.co.uk/blog/2023/04/how-to-recover-solidworks-files-after-a-crash/) ·
[GoEngineer — recovering a hung network licence](https://www.goengineer.com/blog/solidworks-recovering-hung-network-license) ·
[Background Downloader persists by design](https://help.solidworks.com/2024/english/Installation/install_guide/r_background_downloader.htm) ·
[Blue Byte — SOLIDWORKS command-line switches (incl. the undocumented `/SWDisableExitApp`)](https://bluebyte.biz/solidworks/solidworks2022/how-to-launch-solidworks-3d-using-the-solidworks-command-line/)
**Official API help** (JS shell to every fetcher; quoted via search snippets):
[`ISldWorks::ExitApp`](https://help.solidworks.com/2022/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~exitapp.html) ·
[`ISldWorks::UserControl`](https://help.solidworks.com/2023/English/api/sldworksapi/SOLIDWORKS.Interop.sldworks~SOLIDWORKS.Interop.sldworks.ISldWorks~UserControl.html) ·
[`ISldWorks::RunCommand`](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~runcommand.html) ·
[`swCommands_e`](https://help.solidworks.com/2025/english/api/swcommands/solidworks.interop.swcommands~solidworks.interop.swcommands.swcommands_e.html) ·
[SOLIDWORKS Connected API guide](https://help.solidworks.com/2026/english/api/sldworksapiprogguide/Overview/SOLIDWORKS_Connected.htm) ·
[`ISldWorks::CloseAllDocuments`](https://help.solidworks.com/2023/english/api/sldworksapi/SolidWorks.Interop.sldworks~SolidWorks.Interop.sldworks.ISldWorks~CloseAllDocuments.html) ·
[`ISldWorks::QuitDoc`](https://help.solidworks.com/2016/english/api/sldworksapi/SOLIDWORKS.Interop.sldworks~SOLIDWORKS.Interop.sldworks.ISldWorks~QuitDoc.html) ·
[Task Scheduler overview](https://help.solidworks.com/2026/English/SolidWorks/sldworks/c_SOLIDWORKS_Task_Scheduler_Overview.htm)
**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`](https://github.com/tdsmith/swharness/blob/master/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"](https://3dswym.3dexperience.3ds.com/question/solidworks-user-forum/why-i-can-t-shut-down-the-sldworks-process-using-exitapp-method-isldworks_RXWbgG0RQXGTbutM63BajA) ·
[3DSwym — "cancel exit SW"](https://3dswym.3dexperience.3ds.com/question/solidworks-user-forum/cancel-exit-sw_SN-g84inS9q5reGweuAr4w) (an add-in cannot veto shutdown) ·
[myCAD — "Kill SLDWorks.exe -Embedding"](https://forum.mycad.visiativ.com/t/tuer-sldworks-exe-embedding/106333?locale=en) (ExitApp alone leaves the process; title-tagging is the mature targeted-kill pattern) ·
[CATI — "Killing the SOLIDWORKS Task Scheduler!"](https://www.cati.com/blog/killing-the-task-scheduler/)
**Ecosystem code, read directly:**
[xarial/xcad `SwApplication.cs`](https://github.com/xarial/xcad/blob/master/src/SolidWorks/SwApplication.cs) (the correct exit ordering) ·
[CAD-Booster/solidworks-api](https://github.com/CAD-Booster/solidworks-api) (in-process code must not dispose the app) ·
[eyfel/mcp-server-solidworks](https://github.com/eyfel/mcp-server-solidworks) ·
[vespo92/SolidworksMCP-TS](https://github.com/vespo92/SolidworksMCP-TS) ·
[andrewbartels1/SolidworksMCP-python](https://github.com/andrewbartels1/SolidworksMCP-python) ·
[alisamsam/Solidworks-MCP](https://github.com/alisamsam/Solidworks-MCP) ·
[Adam-Schildkraut/solidworks-mcp](https://github.com/Adam-Schildkraut/solidworks-mcp) ·
[deloarts/pyswx](https://github.com/deloarts/pyswx) (`exit_app`, `quit_doc`, `user_control`) ·
[ros/solidworks_urdf_exporter `SwAddin.cs`](https://github.com/ros/solidworks_urdf_exporter/blob/master/SW2URDF/SW/SwAddin.cs) ·
[codestack — stand-alone applications](https://www.codestack.net/solidworks-api/getting-started/stand-alone/)
**Connected specifics:** [CADmunity — Using SOLIDWORKS APIs with SOLIDWORKS Connected](https://forum.cadmunity.com/t/using-solidworks-apis-with-solidworks-connected-3d-experience/5439) ·
[swyftsol — How to Exit 3DEXPERIENCE PLM Services](https://support.swyftsol.com/knowledge-base/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:
```text
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.