diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs
index 004b3ea14..76b490afd 100644
--- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs
+++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs
@@ -53,20 +53,72 @@ public static WindowsTcpListenerSnapshotResult Capture()
return null;
var readTask = process.StandardOutput.ReadToEndAsync();
- if (!process.WaitForExit(5_000))
+ var output = AwaitRedirectedOutput(process, readTask, timeoutMs: 5_000);
+ return output?.Trim();
+ }
+ catch (Exception ex)
+ {
+ Trace.WriteLine(
+ $"Windows process command-line lookup failed for PID {processId}: " +
+ $"{ex.GetType().Name}: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Wait for the child, then drain redirected stdout with the leftover
+ /// timeout. WaitForExit returns when the child exits, but ReadToEnd
+ /// completes only after the write end of the pipe closes. A descendant
+ /// that inherited stdout can keep the pipe open, so unbounded
+ /// GetResult() would hang past the inspection timeout.
+ ///
+ internal static string? AwaitRedirectedOutput(Process process, Task readTask, int timeoutMs)
+ {
+ const int minDrainMs = 250;
+ var sw = Stopwatch.StartNew();
+ if (!process.WaitForExit(timeoutMs))
+ {
+ Trace.WriteLine(
+ $"Windows process command-line lookup timed out waiting for PID {process.Id}.");
+ try { process.Kill(entireProcessTree: true); } catch { }
+ AbandonRead(process, readTask);
+ return null;
+ }
+
+ var elapsedMs = (int)Math.Min(sw.ElapsedMilliseconds, timeoutMs);
+ var drainBudgetMs = Math.Max(timeoutMs - elapsedMs, minDrainMs);
+ try
+ {
+ if (!readTask.Wait(drainBudgetMs))
{
+ Trace.WriteLine(
+ $"Windows process command-line lookup timed out draining PID {process.Id} stdout.");
try { process.Kill(entireProcessTree: true); } catch { }
+ AbandonRead(process, readTask);
return null;
}
-
- return readTask.GetAwaiter().GetResult().Trim();
}
- catch
+ catch (AggregateException)
{
return null;
}
+
+ return readTask.Status == TaskStatus.RanToCompletion ? readTask.Result : null;
}
+ private static void AbandonRead(Process process, Task readTask)
+ {
+ ObserveQuietly(readTask);
+ try { process.StandardOutput.Dispose(); } catch { }
+ }
+
+ private static void ObserveQuietly(Task task) =>
+ _ = task.ContinueWith(
+ static t => { _ = t.Exception; },
+ CancellationToken.None,
+ TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+
private static bool CaptureIpv4(List destination)
{
return CaptureTable(
diff --git a/src/OpenClaw.SetupEngine/KeepaliveProcessRuntime.cs b/src/OpenClaw.SetupEngine/KeepaliveProcessRuntime.cs
index 9388d0163..e7cddea5b 100644
--- a/src/OpenClaw.SetupEngine/KeepaliveProcessRuntime.cs
+++ b/src/OpenClaw.SetupEngine/KeepaliveProcessRuntime.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using OpenClaw.Connection;
namespace OpenClaw.SetupEngine;
@@ -19,9 +20,7 @@ internal interface IKeepaliveProcessRuntime
///
/// Best-effort command-line lookup for a PID. Returns null if the process is gone or the
- /// lookup otherwise fails to produce a value. May throw for exceptional OS failures — callers
- /// in catch per-call so one failure doesn't abort a
- /// broader scan.
+ /// lookup otherwise fails to produce a value. The production runtime does not throw.
///
string? GetCommandLine(int pid);
@@ -48,9 +47,8 @@ internal sealed record KeepaliveProcessStartSpec(string FileName, IReadOnlyList<
///
/// Production backed by
-/// and a WMI/CIM command-line lookup via a spawned powershell.exe helper (unchanged from the
-/// pre-extraction inline implementation). Every wrapper obtained here is
-/// disposed before the method returns.
+/// and the shared bounded WMI/CIM command-line lookup. Every wrapper obtained
+/// here is disposed before the method returns.
///
internal sealed class ProcessKeepaliveRuntime : IKeepaliveProcessRuntime
{
@@ -60,21 +58,8 @@ public bool IsProcessAlive(int pid)
return !process.HasExited;
}
- public string? GetCommandLine(int pid)
- {
- var psi = new ProcessStartInfo("powershell.exe",
- $"-NoProfile -Command \"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine\"")
- {
- RedirectStandardOutput = true,
- UseShellExecute = false,
- CreateNoWindow = true
- };
- using var p = Process.Start(psi);
- if (p == null) return null;
- var output = p.StandardOutput.ReadToEnd();
- p.WaitForExit(5000);
- return output.Trim();
- }
+ public string? GetCommandLine(int pid) =>
+ WindowsTcpListenerSnapshot.GetProcessCommandLine(pid);
public IReadOnlyList EnumerateProcessIds(string processName)
{
diff --git a/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs b/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs
index 6d613a54c..bb4e037d5 100644
--- a/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs
@@ -316,34 +316,7 @@ private static void DeleteKeepAliveMarker(string markerDir, string distroName)
}
private static string? GetProcessCommandLine(int pid)
- {
- try
- {
- var psi = new System.Diagnostics.ProcessStartInfo("powershell.exe",
- $"-NoProfile -Command \"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine\"")
- {
- RedirectStandardOutput = true,
- UseShellExecute = false,
- CreateNoWindow = true
- };
- using var p = System.Diagnostics.Process.Start(psi);
- if (p == null) return null;
-
- // Drain stdout asynchronously so a large command line cannot deadlock the fixed-size pipe,
- // and bound the whole inspection: WaitForExit(5000) returns before ReadToEnd could block
- // forever on a hung CIM/PowerShell. On timeout, kill and report indeterminate (null).
- var readTask = p.StandardOutput.ReadToEndAsync();
- if (!p.WaitForExit(5000))
- {
- // slopwatch-ignore: SW003 Best-effort kill of a stuck inspection process; failure cannot improve caller state.
- try { p.Kill(entireProcessTree: true); } catch { }
- return null;
- }
-
- return readTask.GetAwaiter().GetResult()?.Trim();
- }
- catch { return null; }
- }
+ => WindowsTcpListenerSnapshot.GetProcessCommandLine(pid);
private static string ResolveWslExePath()
{
diff --git a/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs b/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs
new file mode 100644
index 000000000..958d9f1a6
--- /dev/null
+++ b/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs
@@ -0,0 +1,107 @@
+using System.Diagnostics;
+
+namespace OpenClaw.Connection.Tests;
+
+public sealed class WindowsTcpListenerSnapshotTests
+{
+ [Fact]
+ public void GetProcessCommandLine_InvalidPid_ReturnsNull()
+ {
+ Assert.Null(WindowsTcpListenerSnapshot.GetProcessCommandLine(0));
+ Assert.Null(WindowsTcpListenerSnapshot.GetProcessCommandLine(-1));
+ }
+
+ [Fact]
+ public async Task AwaitRedirectedOutput_ReturnsNullWhenStdoutNeverCloses()
+ {
+ using var process = Process.Start(new ProcessStartInfo
+ {
+ FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh",
+ Arguments = OperatingSystem.IsWindows() ? "/c exit 0" : "-c \"exit 0\"",
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ });
+ Assert.NotNull(process);
+
+ var never = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously).Task;
+ var helper = Task.Run(() =>
+ WindowsTcpListenerSnapshot.AwaitRedirectedOutput(process, never, timeoutMs: 400));
+
+ var completed = await Task.WhenAny(helper, Task.Delay(TimeSpan.FromSeconds(3)));
+ Assert.Same(helper, completed);
+ Assert.Null(await helper);
+ }
+
+ [Fact]
+ public void AwaitRedirectedOutput_PreservesOutputCompletedDuringDrainGrace()
+ {
+ using var process = StartExitingProcess();
+ Assert.True(process.WaitForExit(3_000));
+ var outputTask = Task.Run(async () =>
+ {
+ await Task.Delay(100);
+ return "complete output";
+ });
+
+ var output = WindowsTcpListenerSnapshot.AwaitRedirectedOutput(
+ process,
+ outputTask,
+ timeoutMs: 400);
+
+ Assert.Equal("complete output", output);
+ }
+
+ [Fact]
+ public async Task AwaitRedirectedOutput_ReturnsNullWhenDescendantKeepsStdoutOpen()
+ {
+ var (fileName, arguments) = DescendantPipeHolderCommand();
+ using var process = Process.Start(new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ });
+ Assert.NotNull(process);
+
+ var readTask = process.StandardOutput.ReadToEndAsync();
+ var stopwatch = Stopwatch.StartNew();
+ var output = WindowsTcpListenerSnapshot.AwaitRedirectedOutput(
+ process,
+ readTask,
+ timeoutMs: 400);
+
+ Assert.Null(output);
+ Assert.InRange(stopwatch.Elapsed, TimeSpan.Zero, TimeSpan.FromSeconds(2));
+ await readTask.WaitAsync(TimeSpan.FromSeconds(5));
+ }
+
+ [Fact]
+ public void GetProcessCommandLine_CurrentProcess_IsBoundedOnWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ return;
+
+ var stopwatch = Stopwatch.StartNew();
+ _ = WindowsTcpListenerSnapshot.GetProcessCommandLine(Environment.ProcessId);
+
+ Assert.InRange(stopwatch.Elapsed, TimeSpan.Zero, TimeSpan.FromSeconds(7));
+ }
+
+ private static Process StartExitingProcess() =>
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh",
+ Arguments = OperatingSystem.IsWindows() ? "/d /c exit 0" : "-c \"exit 0\"",
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ })!;
+
+ private static (string FileName, string Arguments) DescendantPipeHolderCommand() =>
+ OperatingSystem.IsWindows()
+ ? ("cmd.exe", "/d /s /c \"start /b ping 127.0.0.1 -n 3\"")
+ : ("/bin/sh", "-c \"sleep 2 & exit 0\"");
+}
diff --git a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs
index 85abcd05e..977936530 100644
--- a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs
@@ -160,6 +160,32 @@ public void SetupKeepaliveRuntime_DisposesProcessWrapperAfterStartingDetachedPro
"return proc?.Id;");
}
+ [Fact]
+ public void SetupKeepaliveRuntime_UsesSharedBoundedCommandLineLookup()
+ {
+ var source = ReadSource("src", "OpenClaw.SetupEngine", "KeepaliveProcessRuntime.cs");
+
+ Assert.Contains(
+ "WindowsTcpListenerSnapshot.GetProcessCommandLine(pid)",
+ source);
+ Assert.DoesNotContain("StandardOutput.ReadToEnd", source);
+ }
+
+ [Fact]
+ public void TrayKeepaliveService_UsesSharedBoundedCommandLineLookup()
+ {
+ var source = ReadSource(
+ "src",
+ "OpenClaw.Tray.WinUI",
+ "Services",
+ "WslGatewayKeepAliveService.cs");
+
+ Assert.Contains(
+ "WindowsTcpListenerSnapshot.GetProcessCommandLine(pid)",
+ source);
+ Assert.DoesNotContain("StandardOutput.ReadToEnd", source);
+ }
+
[Fact]
public void SetupKeepaliveManager_WritesMarkerAfterSuccessfulStart()
{