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() {