Skip to content

Commit 0096a23

Browse files
committed
fix: bound process command-line inspection drain
WaitForExit returns when PowerShell exits, but ReadToEnd only finishes after the write end of stdout closes. A descendant that inherited the pipe left GetResult hanging past the 5s timeout. Drain the leftover budget (same pattern as the MXC probe) and reuse the helper from setup and WSL keepalive copies. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent fc9add7 commit 0096a23

4 files changed

Lines changed: 80 additions & 45 deletions

File tree

src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,20 +53,59 @@ public static WindowsTcpListenerSnapshotResult Capture()
5353
return null;
5454

5555
var readTask = process.StandardOutput.ReadToEndAsync();
56-
if (!process.WaitForExit(5_000))
56+
var output = AwaitRedirectedOutput(process, readTask, timeoutMs: 5_000);
57+
return output?.Trim();
58+
}
59+
catch
60+
{
61+
return null;
62+
}
63+
}
64+
65+
/// <summary>
66+
/// Wait for the child, then drain redirected stdout with the leftover
67+
/// timeout. WaitForExit returns when the child exits, but ReadToEnd
68+
/// completes only after the write end of the pipe closes. A descendant
69+
/// that inherited stdout can keep the pipe open, so unbounded
70+
/// GetResult() would hang past the inspection timeout.
71+
/// </summary>
72+
internal static string? AwaitRedirectedOutput(Process process, Task<string> readTask, int timeoutMs)
73+
{
74+
const int minDrainMs = 250;
75+
var sw = Stopwatch.StartNew();
76+
if (!process.WaitForExit(timeoutMs))
77+
{
78+
try { process.Kill(entireProcessTree: true); } catch { }
79+
ObserveQuietly(readTask);
80+
return null;
81+
}
82+
83+
var elapsedMs = (int)Math.Min(sw.ElapsedMilliseconds, timeoutMs);
84+
var drainBudgetMs = Math.Max(timeoutMs - elapsedMs, minDrainMs);
85+
try
86+
{
87+
if (!readTask.Wait(drainBudgetMs))
5788
{
5889
try { process.Kill(entireProcessTree: true); } catch { }
90+
ObserveQuietly(readTask);
5991
return null;
6092
}
61-
62-
return readTask.GetAwaiter().GetResult().Trim();
6393
}
64-
catch
94+
catch (AggregateException)
6595
{
6696
return null;
6797
}
98+
99+
return readTask.Status == TaskStatus.RanToCompletion ? readTask.Result : null;
68100
}
69101

102+
private static void ObserveQuietly(Task task) =>
103+
_ = task.ContinueWith(
104+
static t => { _ = t.Exception; },
105+
CancellationToken.None,
106+
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
107+
TaskScheduler.Default);
108+
70109
private static bool CaptureIpv4(List<WindowsTcpListenerInfo> destination)
71110
{
72111
return CaptureTable(

src/OpenClaw.SetupEngine/SetupSteps.cs

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4248,19 +4248,7 @@ internal static bool IsKeepaliveCommandLine(string? commandLine, string distro)
42484248
{
42494249
try
42504250
{
4251-
// Use WMI to get the command line
4252-
var psi = new System.Diagnostics.ProcessStartInfo("powershell.exe",
4253-
$"-NoProfile -Command \"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine\"")
4254-
{
4255-
RedirectStandardOutput = true,
4256-
UseShellExecute = false,
4257-
CreateNoWindow = true
4258-
};
4259-
using var p = System.Diagnostics.Process.Start(psi);
4260-
if (p == null) return null;
4261-
var output = p.StandardOutput.ReadToEnd();
4262-
p.WaitForExit(5000);
4263-
return output.Trim();
4251+
return WindowsTcpListenerSnapshot.GetProcessCommandLine(pid);
42644252
}
42654253
catch (Exception ex)
42664254
{

src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -316,34 +316,7 @@ private static void DeleteKeepAliveMarker(string markerDir, string distroName)
316316
}
317317

318318
private static string? GetProcessCommandLine(int pid)
319-
{
320-
try
321-
{
322-
var psi = new System.Diagnostics.ProcessStartInfo("powershell.exe",
323-
$"-NoProfile -Command \"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine\"")
324-
{
325-
RedirectStandardOutput = true,
326-
UseShellExecute = false,
327-
CreateNoWindow = true
328-
};
329-
using var p = System.Diagnostics.Process.Start(psi);
330-
if (p == null) return null;
331-
332-
// Drain stdout asynchronously so a large command line cannot deadlock the fixed-size pipe,
333-
// and bound the whole inspection: WaitForExit(5000) returns before ReadToEnd could block
334-
// forever on a hung CIM/PowerShell. On timeout, kill and report indeterminate (null).
335-
var readTask = p.StandardOutput.ReadToEndAsync();
336-
if (!p.WaitForExit(5000))
337-
{
338-
// slopwatch-ignore: SW003 Best-effort kill of a stuck inspection process; failure cannot improve caller state.
339-
try { p.Kill(entireProcessTree: true); } catch { }
340-
return null;
341-
}
342-
343-
return readTask.GetAwaiter().GetResult()?.Trim();
344-
}
345-
catch { return null; }
346-
}
319+
=> WindowsTcpListenerSnapshot.GetProcessCommandLine(pid);
347320

348321
private static string ResolveWslExePath()
349322
{
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System.Diagnostics;
2+
3+
namespace OpenClaw.Connection.Tests;
4+
5+
public sealed class WindowsTcpListenerSnapshotTests
6+
{
7+
[Fact]
8+
public void GetProcessCommandLine_InvalidPid_ReturnsNull()
9+
{
10+
Assert.Null(WindowsTcpListenerSnapshot.GetProcessCommandLine(0));
11+
Assert.Null(WindowsTcpListenerSnapshot.GetProcessCommandLine(-1));
12+
}
13+
14+
[Fact]
15+
public async Task AwaitRedirectedOutput_ReturnsNullWhenStdoutNeverCloses()
16+
{
17+
using var process = Process.Start(new ProcessStartInfo
18+
{
19+
FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh",
20+
Arguments = OperatingSystem.IsWindows() ? "/c exit 0" : "-c \"exit 0\"",
21+
RedirectStandardOutput = true,
22+
UseShellExecute = false,
23+
CreateNoWindow = true,
24+
});
25+
Assert.NotNull(process);
26+
27+
var never = new TaskCompletionSource<string>().Task;
28+
var helper = Task.Run(() =>
29+
WindowsTcpListenerSnapshot.AwaitRedirectedOutput(process, never, timeoutMs: 400));
30+
31+
var completed = await Task.WhenAny(helper, Task.Delay(TimeSpan.FromSeconds(3)));
32+
Assert.Same(helper, completed);
33+
Assert.Null(await helper);
34+
}
35+
}

0 commit comments

Comments
 (0)