@@ -10,42 +10,70 @@ public static async Task Launch(string path1, string path2, bool quiet = false)
1010
1111 var job = JobObject . Create ( ) ;
1212
13+ // Snapshot existing Word PIDs before creating the COM instance so we can
14+ // identify the new WINWORD.EXE process immediately after creation and assign
15+ // it to the Job Object before any document operations that could throw.
16+ // Previously, assignment happened after opening the first document, leaving
17+ // a window where exceptions would orphan the Word process.
18+ var existingPids = GetWordProcessIds ( ) ;
1319 dynamic word = Activator . CreateInstance ( wordType ) ! ;
20+ var process = FindNewWordProcess ( existingPids ) ;
21+ if ( process != null )
22+ {
23+ JobObject . AssignProcess ( job , process . Handle ) ;
24+ }
1425
15- // WdAlertLevel.wdAlertsNone = 0
16- word . DisplayAlerts = 0 ;
26+ try
27+ {
28+ // WdAlertLevel.wdAlertsNone = 0
29+ word . DisplayAlerts = 0 ;
1730
18- // Disable AutoRecover to prevent "serious error" recovery dialogs
19- word . Options . SaveInterval = 0 ;
31+ // Disable AutoRecover to prevent "serious error" recovery dialogs
32+ word . Options . SaveInterval = 0 ;
2033
21- var doc1 = Open ( word , path1 ) ;
34+ var doc1 = Open ( word , path1 ) ;
2235
23- // Get process from Word's window handle and assign to job
24- var hwnd = ( IntPtr ) word . ActiveWindow . Hwnd ;
25- GetWindowThreadProcessId ( hwnd , out var processId ) ;
26- using var process = Process . GetProcessById ( processId ) ;
27- JobObject . AssignProcess ( job , process . Handle ) ;
36+ // Fallback: if process snapshot didn't find the new process, get it via window handle
37+ if ( process == null )
38+ {
39+ var hwnd = ( IntPtr ) word . ActiveWindow . Hwnd ;
40+ GetWindowThreadProcessId ( hwnd , out var processId ) ;
41+ process = Process . GetProcessById ( processId ) ;
42+ JobObject . AssignProcess ( job , process . Handle ) ;
43+ }
2844
29- var doc2 = Open ( word , path2 ) ;
45+ var doc2 = Open ( word , path2 ) ;
3046
31- var compare = LaunchCompare ( word , doc1 , doc2 ) ;
47+ var compare = LaunchCompare ( word , doc1 , doc2 ) ;
3248
33- word . Visible = true ;
49+ word . Visible = true ;
3450
35- ApplyQuiet ( quiet , word ) ;
51+ ApplyQuiet ( quiet , word ) ;
3652
37- HideNavigationPane ( word ) ;
53+ HideNavigationPane ( word ) ;
3854
39- MinimizeRibbon ( word ) ;
55+ MinimizeRibbon ( word ) ;
4056
41- // Bring Word to the foreground
42- SetForegroundWindow ( hwnd ) ;
57+ // Bring Word to the foreground
58+ SetForegroundWindow ( ( IntPtr ) word . ActiveWindow . Hwnd ) ;
4359
44- await process . WaitForExitAsync ( ) ;
60+ await process . WaitForExitAsync ( ) ;
4561
46- Marshal . ReleaseComObject ( compare ) ;
47- Marshal . ReleaseComObject ( word ) ;
48- JobObject . Close ( job ) ;
62+ Marshal . ReleaseComObject ( compare ) ;
63+ }
64+ catch
65+ {
66+ // If setup fails (e.g. invalid file path), gracefully quit Word
67+ // then force-kill as a fallback to prevent zombie processes.
68+ QuitAndKill ( word , process ) ;
69+ throw ;
70+ }
71+ finally
72+ {
73+ Marshal . ReleaseComObject ( word ) ;
74+ process ? . Dispose ( ) ;
75+ JobObject . Close ( job ) ;
76+ }
4977
5078 RestoreRibbon ( wordType ) ;
5179 }
@@ -126,9 +154,21 @@ static void MinimizeRibbon(dynamic word)
126154 }
127155 }
128156
157+ // RestoreRibbon creates a temporary Word instance solely to un-minimize the
158+ // ribbon so the user's next normal Word session isn't affected. This instance
159+ // is assigned to its own Job Object and has a kill fallback to prevent zombies
160+ // (previously it had neither, making it the primary source of leaked processes).
129161 static void RestoreRibbon ( Type wordType )
130162 {
163+ var job = JobObject . Create ( ) ;
164+ var existingPids = GetWordProcessIds ( ) ;
131165 dynamic word = Activator . CreateInstance ( wordType ) ! ;
166+ var process = FindNewWordProcess ( existingPids ) ;
167+ if ( process != null )
168+ {
169+ JobObject . AssignProcess ( job , process . Handle ) ;
170+ }
171+
132172 try
133173 {
134174 word . DisplayAlerts = 0 ;
@@ -145,10 +185,64 @@ static void RestoreRibbon(Type wordType)
145185
146186 word . Quit ( ) ;
147187 }
188+ catch
189+ {
190+ QuitAndKill ( word , process ) ;
191+ }
148192 finally
149193 {
150194 Marshal . ReleaseComObject ( word ) ;
195+ process ? . Dispose ( ) ;
196+ JobObject . Close ( job ) ;
197+ }
198+ }
199+
200+ // Attempts a graceful COM Quit, then force-kills the process as a fallback.
201+ // All exceptions are swallowed because this runs in error/cleanup paths where
202+ // COM may already be disconnected or the process may have exited.
203+ internal static void QuitAndKill ( dynamic word , Process ? process )
204+ {
205+ try { word . Quit ( SaveChanges : false ) ; }
206+ catch { /* COM may already be disconnected */ }
207+
208+ if ( process is { HasExited : false } )
209+ {
210+ try { process . Kill ( ) ; }
211+ catch { /* Process may have exited between check and kill */ }
212+ }
213+ }
214+
215+ // Snapshots current WINWORD PIDs. Used with FindNewWordProcess to identify
216+ // the process created by Activator.CreateInstance without needing a window handle.
217+ internal static HashSet < int > GetWordProcessIds ( )
218+ {
219+ var pids = new HashSet < int > ( ) ;
220+ foreach ( var p in Process . GetProcessesByName ( "WINWORD" ) )
221+ {
222+ pids . Add ( p . Id ) ;
223+ p . Dispose ( ) ;
224+ }
225+ return pids ;
226+ }
227+
228+ // Finds the WINWORD process that appeared after the snapshot was taken.
229+ // If multiple new processes appear (rare race condition), keeps the last one found.
230+ internal static Process ? FindNewWordProcess ( HashSet < int > existingPids )
231+ {
232+ Process ? found = null ;
233+ foreach ( var p in Process . GetProcessesByName ( "WINWORD" ) )
234+ {
235+ if ( ! existingPids . Contains ( p . Id ) )
236+ {
237+ found ? . Dispose ( ) ;
238+ found = p ;
239+ }
240+ else
241+ {
242+ p . Dispose ( ) ;
243+ }
151244 }
245+ return found ;
152246 }
153247
154248 [ LibraryImport ( "user32.dll" ) ]
0 commit comments