Skip to content

Commit f78aaf2

Browse files
authored
Snapshot existing Word PIDs before creating the COM instance (#10)
1 parent d0eb921 commit f78aaf2

2 files changed

Lines changed: 221 additions & 22 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
public class ProcessCleanupTests
2+
{
3+
[Test]
4+
public async Task GetWordProcessIds_ReturnsHashSet()
5+
{
6+
var pids = Word.GetWordProcessIds();
7+
await Assert.That(pids).IsNotNull();
8+
}
9+
10+
[Test]
11+
public async Task FindNewWordProcess_WhenNoNewProcesses_ReturnsNull()
12+
{
13+
var existingPids = Word.GetWordProcessIds();
14+
var result = Word.FindNewWordProcess(existingPids);
15+
await Assert.That(result).IsNull();
16+
}
17+
18+
[Test]
19+
public void QuitAndKill_WithNullProcess_DoesNotThrow() =>
20+
Word.QuitAndKill((dynamic)new object(), null);
21+
22+
[Test]
23+
public void QuitAndKill_WithExitedProcess_DoesNotThrow()
24+
{
25+
var process = Process.Start(new ProcessStartInfo
26+
{
27+
FileName = "cmd.exe",
28+
Arguments = "/c exit 0",
29+
CreateNoWindow = true,
30+
UseShellExecute = false
31+
})!;
32+
process.WaitForExit();
33+
34+
Word.QuitAndKill((dynamic)new object(), process);
35+
process.Dispose();
36+
}
37+
38+
[Test]
39+
public async Task QuitAndKill_WithRunningProcess_KillsProcess()
40+
{
41+
var process = Process.Start(new ProcessStartInfo
42+
{
43+
FileName = "ping",
44+
Arguments = "-n 60 127.0.0.1",
45+
CreateNoWindow = true,
46+
UseShellExecute = false
47+
})!;
48+
49+
await Assert.That(process.HasExited).IsFalse();
50+
51+
Word.QuitAndKill((dynamic)new object(), process);
52+
53+
process.WaitForExit(5000);
54+
await Assert.That(process.HasExited).IsTrue()
55+
.Because("QuitAndKill should kill running processes");
56+
process.Dispose();
57+
}
58+
59+
[Test]
60+
[Explicit]
61+
public async Task Launch_WithInvalidPath_DoesNotLeaveZombieProcess()
62+
{
63+
var wordType = Type.GetTypeFromProgID("Word.Application");
64+
if (wordType == null)
65+
{
66+
Skip.Test("Microsoft Word is not installed");
67+
}
68+
69+
var beforePids = Word.GetWordProcessIds();
70+
71+
try
72+
{
73+
await Word.Launch(
74+
@"C:\nonexistent\file1.docx",
75+
@"C:\nonexistent\file2.docx");
76+
}
77+
catch
78+
{
79+
// Expected - invalid file paths
80+
}
81+
82+
// Give Word a moment to fully shut down
83+
await Task.Delay(3000);
84+
85+
var afterPids = Word.GetWordProcessIds();
86+
afterPids.ExceptWith(beforePids);
87+
88+
// Clean up any zombie processes (safety net)
89+
foreach (var pid in afterPids)
90+
{
91+
try
92+
{
93+
using var p = Process.GetProcessById(pid);
94+
p.Kill();
95+
}
96+
catch
97+
{
98+
// Process may have already exited
99+
}
100+
}
101+
102+
await Assert.That(afterPids.Count).IsEqualTo(0)
103+
.Because("No zombie Word processes should remain after a failed Launch");
104+
}
105+
}

src/MsWordDiff/Word.cs

Lines changed: 116 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)