Skip to content

Commit c37e5c5

Browse files
KenM76claude
authored andcommitted
v0.10.2: fix run-script .csx hang on OpenDoc6 (Roslyn await was bouncing script body across ThreadPool workers)
Closes FR_runscript_opendoc6_hangs_nonpumping_apartment.md — bug reported by SWFormat 2026-07-16, reproduced live against SW 2026 rev 34.2.1. Bug: a .csx calling swApp.OpenDoc6(diskPath, ...) via run-script hung indefinitely. Doc opened on SW's side, but call never returned. Same OpenDoc6 from typed plugin commands (list-configs) worked fine. Root cause: Program.cs's Main has no [STAThread] — combridge is MTA default. ScriptHost.RunAsync had: var state = await script.RunAsync(globals); Roslyn's Script<T>.RunAsync returns a Task whose continuations use TaskScheduler.Default (ThreadPool) when no SynchronizationContext is set. So the script body could execute on any ThreadPool worker — NOT the thread that constructed the SolidWorks RCW earlier in plugin.CreateGlobals(comRoot). OpenDoc6 is thread-affine: SW's out-of-proc server invokes callbacks back to the calling thread during the call. Wrong thread = callback lost = both sides wait forever. Plugin commands don't hit this because their RunAsync methods have no awaits before their COM calls — everything stays on Main's thread. Fix: change the inner Roslyn call to synchronous: var state = script.RunAsync(globals).GetAwaiter().GetResult(); Forces the script body to run on the caller's thread — same thread as Main = same thread as the RCW = no thread-affinity mismatch = no deadlock. ScriptHost.RunAsync's outer async signature unchanged. Scope: plugin-agnostic ScriptHost code, so every Windows plugin's run-script benefits. Simple reads (RevisionNumber, GetFirstDocument) had worked before because they don't callback — this is why the bug was elusive. Any COM API with progress UI or save dialogs (OpenDoc6, Save3, SaveAs3, PrintOut4, Workbooks.Open, etc.) was silently affected until now. VBScript path unaffected (IActiveScript is synchronous by design). Verified live: same reproducer that hit 30s timeout on v0.10.1 now exits 0 with correct output on v0.10.2 (opens the part, closes it, returns). Regression check: list-configs / list-components / active-config still work; run-script still honors ScriptArgs / Stdin / exit-code propagation from v0.9.0 and v0.8.2; VBScript engine unaffected. Mirrored to D:\Dev\ScripTree\lib\combridge\ and R:\ScripTree\lib\combridge\ per the CLAUDE.md deployment mandate. FR moved to Complete/. Lesson captured at C:/personal_rag/claude_code/lesson_20260716_roslyn_script_runasync_await_bounces_thread_breaks_com_affinity.md so the next Roslyn-hosting Windows tool doesn't re-derive the same 30-second-timeout diagnostic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 26427d9 commit c37e5c5

2 files changed

Lines changed: 109 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,104 @@ All notable changes to this project will be documented in this file.
44
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
55
versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.10.2] — Fix: `run-script` `.csx` no longer hangs on `OpenDoc6` (Roslyn `await` was bouncing script body across ThreadPool workers)
8+
9+
Closes `FR_runscript_opendoc6_hangs_nonpumping_apartment.md`. Bug
10+
reported by the SWFormat project 2026-07-16; reproduced live against
11+
SW 2026 rev 34.2.1.
12+
13+
### What was wrong
14+
15+
A `.csx` calling `swApp.OpenDoc6(diskPath, ...)` from within a
16+
`run-script` invocation hung indefinitely. The document DID open on
17+
SW's side (a separate `active-doc` probe confirmed it), but the call
18+
never returned control to the script — combridge sat at zero output
19+
until killed. Identical `OpenDoc6` from combridge's own typed plugin
20+
commands (`list-configs`, `list-components`) worked cleanly in ~3s.
21+
22+
### Root cause
23+
24+
`Program.cs`'s `Main` has no `[STAThread]` attribute — combridge runs
25+
on the default MTA. `ScriptHost.RunAsync` had:
26+
27+
```csharp
28+
var state = await script.RunAsync(globals);
29+
```
30+
31+
Roslyn's `Script<T>.RunAsync` returns a Task whose continuations
32+
follow ambient async dispatch. In a console app with no
33+
`SynchronizationContext.Current`, that's `TaskScheduler.Default`
34+
**ThreadPool**. So the script body could execute on any ThreadPool
35+
worker, not the thread that constructed the SolidWorks RCW.
36+
37+
SolidWorks' out-of-proc COM server tracks which thread it received
38+
the interface pointer on. `OpenDoc6` specifically invokes callbacks
39+
back into the client during the call. When the script's `OpenDoc6`
40+
originates from a different thread than the RCW's owner, SW's
41+
callback goes to the wrong thread and both sides wait forever for a
42+
reply — classic COM thread-affinity deadlock.
43+
44+
Typed plugin commands don't hit this because their `RunAsync` methods
45+
have no awaits before their COM calls — everything stays on the
46+
thread that dispatched to them (which is the RCW-owning thread from
47+
`Main`).
48+
49+
### Fix
50+
51+
Replace the `await` with a blocking `.GetAwaiter().GetResult()`:
52+
53+
```csharp
54+
var state = script.RunAsync(globals).GetAwaiter().GetResult();
55+
```
56+
57+
Forces Roslyn to run the script body synchronously on the caller's
58+
thread. No ThreadPool bouncing at the script layer. COM thread
59+
affinity preserved. Since a CLI process running one script has
60+
nothing else to do concurrently, the blocking behavior is fine.
61+
62+
### Who's affected
63+
64+
- **All `run-script` `.csx` scripts calling thread-affine COM APIs**
65+
benefit — the fix is in the plugin-agnostic script host.
66+
- **SolidWorks specifically**: `OpenDoc6`, `Save3`, `SaveAs3`,
67+
`PrintOut4`, anything showing progress UI, anything with a save
68+
dialog. In-proc / fire-and-forget calls (`RevisionNumber`,
69+
`Visible`, most getters) worked fine before too.
70+
- **Office (Excel, Word, PowerPoint, Outlook)**: same principle
71+
applies; any Office API that invokes callbacks during the call
72+
(`Workbooks.Open` with progress, `PrintPreview`, etc.) may have
73+
been silently affected. Users who reported "combridge worked for
74+
simple reads but hangs for complex operations" from a `.csx`
75+
probably hit this.
76+
- **VBScript path is unaffected** — the IActiveScript engine invokes
77+
script code synchronously by design.
78+
79+
### Verified
80+
81+
Same repro that hung on v0.10.1 now runs cleanly:
82+
83+
```
84+
$ combridge solidworks --session pid:9584 run-script hang.csx out.txt
85+
exit code: 0
86+
stdout: "before open" / "after open err=0 warn=0" / "closed"
87+
```
88+
89+
All other run-script scenarios still work (typed globals reads,
90+
`NewDocument`, `ScriptArgs`/`Stdin` channels from v0.9.0, exit-code
91+
propagation from v0.8.2, VBScript engine from v0.8.0).
92+
93+
### Files
94+
95+
Changed:
96+
- `src/ComBridge.Core/ScriptHost.cs` — one-line change +
97+
explanatory comment. Signature of `RunAsync` unchanged; it's still
98+
`async Task<int>`, but the inner Roslyn call is now synchronous.
99+
100+
FR moved to `Complete\` with implementation log. Lesson captured at
101+
`C:\personal_rag\claude_code\lesson_20260716_roslyn_script_runasync_await_bounces_thread_breaks_com_affinity.md`
102+
so the next implementer of a Roslyn-hosting Windows tool doesn't
103+
re-derive the same 30-second-timeout diagnostic.
104+
7105
## [0.10.1] — Fix: filter invisible/utility windows out of MRU Z-order ranking
8106

9107
Closes `FR_sessionpicker_mru_filter_invisible_windows.md` (a bug

src/ComBridge.Core/ScriptHost.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,17 @@ public static async Task<int> RunAsync(
190190
foreach (var d in errors) output.WriteLine(AugmentOfficeDiagnostic(d.ToString()));
191191
return 3;
192192
}
193-
var state = await script.RunAsync(globals);
193+
// Run the script SYNCHRONOUSLY on the current thread. Using
194+
// `await script.RunAsync(globals)` here lets Roslyn's internal
195+
// async state machine bounce continuations onto arbitrary
196+
// ThreadPool workers, breaking cross-apartment COM calls for
197+
// out-of-proc servers that expect thread affinity (notably
198+
// SolidWorks' OpenDoc6, which hangs forever if the caller's
199+
// thread doesn't match the RCW's originating thread). Plugin
200+
// commands don't hit this because they run synchronously on
201+
// whatever thread invoked them; the script path used to bounce.
202+
// See FR_runscript_opendoc6_hangs_nonpumping_apartment.md.
203+
var state = script.RunAsync(globals).GetAwaiter().GetResult();
194204
if (state.Exception is not null)
195205
{
196206
output.WriteLine("SCRIPT EXCEPTION: " + state.Exception);

0 commit comments

Comments
 (0)