From de6bc586096633d0145838151c8409adbacb7d92 Mon Sep 17 00:00:00 2001 From: Luke Butters Date: Mon, 18 May 2026 12:10:52 +1000 Subject: [PATCH 1/4] Add failing repro for grandchild-pipe hang on sync Execute Demonstrates that ShellfishProcess.WaitForExit falls through to the non-cancellable parameterless Process.WaitForExit() when a grandchild process inherits the redirected stdout/stderr pipes and outlives the immediate child. The async readers never see EOF and Execute() blocks forever. The test backgrounds a `sleep` from bash so the orphaned sleep keeps the write-end of the pipe open after bash exits cleanly. It is guarded by a 15s task timeout so the runner reports a failure rather than hanging. Co-Authored-By: Claude Opus 4.7 (1M context) --- source/Tests/Plumbing/NixFactAttribute.cs | 24 +++++ .../ShellCommandFixture.GrandchildPipes.cs | 92 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 source/Tests/Plumbing/NixFactAttribute.cs create mode 100644 source/Tests/ShellCommandFixture.GrandchildPipes.cs diff --git a/source/Tests/Plumbing/NixFactAttribute.cs b/source/Tests/Plumbing/NixFactAttribute.cs new file mode 100644 index 0000000..d5d7f6c --- /dev/null +++ b/source/Tests/Plumbing/NixFactAttribute.cs @@ -0,0 +1,24 @@ +using System; +using System.Runtime.InteropServices; +using Xunit; + +namespace Tests.Plumbing +{ + [AttributeUsage(AttributeTargets.Method)] + public sealed class NixFactAttribute : FactAttribute + { + public NixFactAttribute() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) Skip = "This test only runs on Linux/macOS"; + } + } + + [AttributeUsage(AttributeTargets.Method)] + public sealed class NixTheoryAttribute : TheoryAttribute + { + public NixTheoryAttribute() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) Skip = "This test only runs on Linux/macOS"; + } + } +} diff --git a/source/Tests/ShellCommandFixture.GrandchildPipes.cs b/source/Tests/ShellCommandFixture.GrandchildPipes.cs new file mode 100644 index 0000000..6fb63bf --- /dev/null +++ b/source/Tests/ShellCommandFixture.GrandchildPipes.cs @@ -0,0 +1,92 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Octopus.Shellfish; +using Tests.Plumbing; +using Xunit; + +namespace Tests; + +// Reproduces the "grandchild inherits redirected pipes and outlives the immediate child" hang +// on the SYNCHRONOUS Execute path. +// +// Scenario: bash spawns a background `sleep` (with `&`). The sleep inherits bash's stdout pipe +// (which is the redirected pipe Shellfish set up). bash then exits with 0. The Process.Exited +// event fires, ShellfishProcess.WaitForExit unblocks past `exitedEvent.Wait`, and then calls +// the parameterless `process.WaitForExit()` to drain the async stream readers. That call +// blocks forever — the orphaned `sleep` still holds the write-end of the pipe so the readers +// never see EOF. The cancellation token does not help here because the parameterless overload +// does not accept one. +// +// The async path (ExecuteAsync) passes the token through to WaitForExitAsync which IS +// cancellable, so the bug only manifests in synchronous Execute. +// +// The test guards against an actual infinite hang by running Execute on a Task and timing +// out, so we report a test failure rather than blocking the runner forever. +public class ShellCommandFixture_GrandchildPipes +{ + static readonly TimeSpan HangGuardTimeout = TimeSpan.FromSeconds(15); + + [NixFact] + public void Execute_WhenGrandchildHoldsRedirectedPipes_ShouldNotHang() + { + var grandchildPidFile = Path.Combine(Path.GetTempPath(), $"shellfish-grandchild-{Guid.NewGuid():N}.pid"); + + // sleep is backgrounded with `&` so it inherits bash's redirected stdout but bash exits + // immediately. The grandchild PID is captured so we can clean it up afterwards. + var script = $"sleep 30 & echo $! > {grandchildPidFile}; exit 0"; + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + + var executor = new ShellCommand("bash") + .WithArguments(new[] { "-c", script }) + .WithStdOutTarget(stdOut) + .WithStdErrTarget(stdErr); + + try + { + var executeTask = Task.Run(() => executor.Execute(CancellationToken.None)); + + executeTask.Wait(HangGuardTimeout) + .Should() + .BeTrue($"Execute() should return within {HangGuardTimeout.TotalSeconds}s even when a grandchild inherits the redirected pipes; if this assertion fails, the parameterless Process.WaitForExit() in ShellfishProcess.WaitForExit is hanging on the async-reader drain."); + } + finally + { + TryKillGrandchild(grandchildPidFile); + } + } + + static void TryKillGrandchild(string pidFile) + { + try + { + if (!File.Exists(pidFile)) return; + var pidText = File.ReadAllText(pidFile).Trim(); + if (!int.TryParse(pidText, out var pid)) return; + + try + { + var p = Process.GetProcessById(pid); + p.Kill(); + } + catch + { + // already gone + } + } + catch + { + // best-effort cleanup + } + finally + { + try { if (File.Exists(pidFile)) File.Delete(pidFile); } catch { /* ignore */ } + } + } +} From ca8920acd268b863257d4dd7064a0eeddc7b0052 Mon Sep 17 00:00:00 2001 From: Luke Butters Date: Mon, 18 May 2026 12:13:54 +1000 Subject: [PATCH 2/4] Add Windows repro for grandchild-pipe hang on sync Execute Mirrors the [NixFact] repro using the same chain pattern as the Tentacle SilentProcessRunner test: PowerShell -> cmd -> ping, where cmd backgrounds ping via `start /b` then exits. ping inherits the redirected pipes; PowerShell exits cleanly; the parameterless process.WaitForExit() in ShellfishProcess.WaitForExit hangs waiting for the async readers to drain. Renamed the existing Nix test to ..._WhenUnixGrandchildHolds... so the platform is obvious next to the new Windows counterpart. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ShellCommandFixture.GrandchildPipes.cs | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/source/Tests/ShellCommandFixture.GrandchildPipes.cs b/source/Tests/ShellCommandFixture.GrandchildPipes.cs index 6fb63bf..72c2bc1 100644 --- a/source/Tests/ShellCommandFixture.GrandchildPipes.cs +++ b/source/Tests/ShellCommandFixture.GrandchildPipes.cs @@ -32,7 +32,7 @@ public class ShellCommandFixture_GrandchildPipes static readonly TimeSpan HangGuardTimeout = TimeSpan.FromSeconds(15); [NixFact] - public void Execute_WhenGrandchildHoldsRedirectedPipes_ShouldNotHang() + public void Execute_WhenUnixGrandchildHoldsRedirectedPipes_ShouldNotHang() { var grandchildPidFile = Path.Combine(Path.GetTempPath(), $"shellfish-grandchild-{Guid.NewGuid():N}.pid"); @@ -62,6 +62,78 @@ public void Execute_WhenGrandchildHoldsRedirectedPipes_ShouldNotHang() } } + [WindowsFact] + public void Execute_WhenWindowsGrandchildHoldsRedirectedPipes_ShouldNotHang() + { + // Windows equivalent of the [NixFact] above. We need a grandchild that inherits the + // redirected stdout/stderr write-ends and outlives the immediate child. + // + // Chain: PowerShell (immediate child) -> cmd.exe -> ping.exe (grandchild). + // 1. PowerShell spawns cmd via System.Diagnostics.Process. Setting + // RedirectStandardInput on the ProcessStartInfo is what flips + // bInheritHandles=true in .NET's Process.Start — so cmd inherits PowerShell's + // stdout/stderr, which are themselves our redirected pipes. + // 2. cmd runs `start /b ping ...`, spawning ping with inherited handles, then exits. + // 3. PowerShell finds ping's PID via WMI (for cleanup) and exits cleanly. + // + // From Shellfish's POV: the immediate child (PowerShell) exited normally so + // `exitedEvent` is released and ShellfishProcess.WaitForExit falls through to + // `process.WaitForExit()` (the non-cancellable parameterless overload) to drain the + // async readers. ping still holds the pipe write-ends, so the readers never EOF and + // the call hangs forever. + var grandchildPidFile = Path.Combine(Path.GetTempPath(), $"shellfish-grandchild-{Guid.NewGuid():N}.pid"); + + var psScript = @" +$pidFile = 'PIDFILE_PLACEHOLDER' +$pingPath = Join-Path $env:WINDIR 'System32\PING.EXE' +$psi = New-Object System.Diagnostics.ProcessStartInfo +$psi.FileName = Join-Path $env:WINDIR 'System32\cmd.exe' +# -n 60000 just makes ping long-running enough to outlive the test. +$psi.Arguments = '/c start /b """" ""' + $pingPath + '"" -n 60000 127.0.0.1' +$psi.UseShellExecute = $false +$psi.CreateNoWindow = $true +# Redirecting any stream flips bInheritHandles=true in .NET's Process.Start, +# so non-redirected streams pass through via GetStdHandle to the child. +$psi.RedirectStandardInput = $true +$cmd = [System.Diagnostics.Process]::Start($psi) +$cmd.StandardInput.Close() +$cmdPid = $cmd.Id +# Wait for cmd to exit — by the time PowerShell itself exits we want ping to be orphaned. +$cmd.WaitForExit() +# Poll until ping appears in WMI — there's a lag between cmd exiting and the +# orphaned ping becoming visible. +$deadline = (Get-Date).AddSeconds(10) +while ((Get-Date) -lt $deadline) { + $p = Get-CimInstance Win32_Process -Filter ""ParentProcessId=$cmdPid AND Name='PING.EXE'"" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($p) { Set-Content -Path $pidFile -Value $p.ProcessId; break } + Start-Sleep -Milliseconds 100 +} +"; + psScript = psScript.Replace("PIDFILE_PLACEHOLDER", grandchildPidFile); + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(psScript)); + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + + var executor = new ShellCommand("powershell.exe") + .WithArguments(new[] { "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded }) + .WithStdOutTarget(stdOut) + .WithStdErrTarget(stdErr); + + try + { + var executeTask = Task.Run(() => executor.Execute(CancellationToken.None)); + + executeTask.Wait(HangGuardTimeout) + .Should() + .BeTrue($"Execute() should return within {HangGuardTimeout.TotalSeconds}s even when a grandchild inherits the redirected pipes; if this assertion fails, the parameterless Process.WaitForExit() in ShellfishProcess.WaitForExit is hanging on the async-reader drain."); + } + finally + { + TryKillGrandchild(grandchildPidFile); + } + } + static void TryKillGrandchild(string pidFile) { try From b15401ee08d46f8651bf73d9bc2b5cd35c2529c8 Mon Sep 17 00:00:00 2001 From: Luke Butters Date: Mon, 18 May 2026 12:18:04 +1000 Subject: [PATCH 3/4] Mirror Tentacle's cancellation-based grandchild-pipe repro Both tests now follow the same shape as the Tentacle SilentProcessRunner tests: spawn a child that backgrounds a grandchild holding the redirected pipes, wait for the grandchild to come up, then cancel the CancellationToken and assert Execute returns within the deadline. On Shellfish the cancellation can't rescue Execute: once the immediate child has exited, ShellfishProcess.WaitForExit is already past the cancellable exitedEvent.Wait and inside the non-cancellable parameterless Process.WaitForExit(), which blocks on async-reader drain that never EOFs because the grandchild still holds the pipes. Adds a Windows variant using the PowerShell -> cmd -> ping chain that mirrors the Tentacle Windows test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ShellCommandFixture.GrandchildPipes.cs | 158 +++++++++++------- 1 file changed, 95 insertions(+), 63 deletions(-) diff --git a/source/Tests/ShellCommandFixture.GrandchildPipes.cs b/source/Tests/ShellCommandFixture.GrandchildPipes.cs index 72c2bc1..8f1ac6c 100644 --- a/source/Tests/ShellCommandFixture.GrandchildPipes.cs +++ b/source/Tests/ShellCommandFixture.GrandchildPipes.cs @@ -11,50 +11,64 @@ namespace Tests; -// Reproduces the "grandchild inherits redirected pipes and outlives the immediate child" hang -// on the SYNCHRONOUS Execute path. +// Mirrors the Tentacle SilentProcessRunner tests for the +// "grandchild inherits redirected pipes and outlives the immediate child" hang. // -// Scenario: bash spawns a background `sleep` (with `&`). The sleep inherits bash's stdout pipe -// (which is the redirected pipe Shellfish set up). bash then exits with 0. The Process.Exited -// event fires, ShellfishProcess.WaitForExit unblocks past `exitedEvent.Wait`, and then calls -// the parameterless `process.WaitForExit()` to drain the async stream readers. That call -// blocks forever — the orphaned `sleep` still holds the write-end of the pipe so the readers -// never see EOF. The cancellation token does not help here because the parameterless overload -// does not accept one. +// Scenario in both tests: +// 1. Shellfish launches a child with redirected stdout/stderr. +// 2. The child spawns a grandchild that inherits the redirected pipe write-ends. +// 3. The child exits, leaving the grandchild orphaned and still holding the pipes. +// 4. We cancel the CancellationToken. +// 5. We expect Execute() to return promptly — kill the tree, release the pipes, +// and propagate OperationCanceledException. // -// The async path (ExecuteAsync) passes the token through to WaitForExitAsync which IS -// cancellable, so the bug only manifests in synchronous Execute. -// -// The test guards against an actual infinite hang by running Execute on a Task and timing -// out, so we report a test failure rather than blocking the runner forever. +// If the underlying Process.WaitForExit() drains the async readers without honouring +// the token, the call hangs forever because the readers never see EOF. The test +// guards against an actual infinite hang by running Execute on a Task with a deadline, +// so we report a failure rather than blocking the runner. public class ShellCommandFixture_GrandchildPipes { - static readonly TimeSpan HangGuardTimeout = TimeSpan.FromSeconds(15); + static readonly TimeSpan HangGuardTimeout = TimeSpan.FromSeconds(30); + static readonly TimeSpan GrandchildSpawnTimeout = TimeSpan.FromSeconds(30); [NixFact] - public void Execute_WhenUnixGrandchildHoldsRedirectedPipes_ShouldNotHang() + public void Execute_WhenUnixGrandchildHoldsRedirectedPipes_ShouldNotHangAfterCancellation() { var grandchildPidFile = Path.Combine(Path.GetTempPath(), $"shellfish-grandchild-{Guid.NewGuid():N}.pid"); - // sleep is backgrounded with `&` so it inherits bash's redirected stdout but bash exits - // immediately. The grandchild PID is captured so we can clean it up afterwards. - var script = $"sleep 30 & echo $! > {grandchildPidFile}; exit 0"; + // sh -c "sleep 600 & echo $! > pidfile; exit 0" + // sh backgrounds sleep (which inherits sh's redirected stdout), writes the PID, exits. + // sleep is reparented to init/launchd and keeps the pipe open. + var script = $"sleep 600 & echo $! > '{grandchildPidFile}'; exit 0"; var stdOut = new StringBuilder(); var stdErr = new StringBuilder(); - var executor = new ShellCommand("bash") + var executor = new ShellCommand("/bin/sh") .WithArguments(new[] { "-c", script }) .WithStdOutTarget(stdOut) .WithStdErrTarget(stdErr); try { - var executeTask = Task.Run(() => executor.Execute(CancellationToken.None)); + using var cts = new CancellationTokenSource(); + var executeTask = Task.Run(() => + { + try { executor.Execute(cts.Token); } + catch (OperationCanceledException) { /* expected */ } + }); + + WaitForGrandchildSpawn(grandchildPidFile, GrandchildSpawnTimeout); - executeTask.Wait(HangGuardTimeout) - .Should() - .BeTrue($"Execute() should return within {HangGuardTimeout.TotalSeconds}s even when a grandchild inherits the redirected pipes; if this assertion fails, the parameterless Process.WaitForExit() in ShellfishProcess.WaitForExit is hanging on the async-reader drain."); + var sw = Stopwatch.StartNew(); + cts.Cancel(); + + var completed = executeTask.Wait(HangGuardTimeout); + sw.Stop(); + + completed.Should().BeTrue( + $"Execute() should return shortly after cancellation even when a Unix grandchild (reparented to init/launchd) " + + $"holds the redirected pipes. Elapsed since cancel: {sw.Elapsed.TotalSeconds:F1}s"); } finally { @@ -63,24 +77,18 @@ public void Execute_WhenUnixGrandchildHoldsRedirectedPipes_ShouldNotHang() } [WindowsFact] - public void Execute_WhenWindowsGrandchildHoldsRedirectedPipes_ShouldNotHang() + public void Execute_WhenWindowsGrandchildHoldsRedirectedPipes_ShouldNotHangAfterCancellation() { - // Windows equivalent of the [NixFact] above. We need a grandchild that inherits the - // redirected stdout/stderr write-ends and outlives the immediate child. - // // Chain: PowerShell (immediate child) -> cmd.exe -> ping.exe (grandchild). - // 1. PowerShell spawns cmd via System.Diagnostics.Process. Setting - // RedirectStandardInput on the ProcessStartInfo is what flips - // bInheritHandles=true in .NET's Process.Start — so cmd inherits PowerShell's - // stdout/stderr, which are themselves our redirected pipes. - // 2. cmd runs `start /b ping ...`, spawning ping with inherited handles, then exits. - // 3. PowerShell finds ping's PID via WMI (for cleanup) and exits cleanly. - // - // From Shellfish's POV: the immediate child (PowerShell) exited normally so - // `exitedEvent` is released and ShellfishProcess.WaitForExit falls through to - // `process.WaitForExit()` (the non-cancellable parameterless overload) to drain the - // async readers. ping still holds the pipe write-ends, so the readers never EOF and - // the call hangs forever. + // 1. PowerShell spawns cmd via System.Diagnostics.Process. + // Setting RedirectStandardInput is what flips bInheritHandles=true in .NET's + // Process.Start, so cmd inherits PowerShell's stdout/stderr — themselves our + // redirected pipes. + // 2. cmd runs `start /b ping ...`, spawning ping with inherited handles, then + // exits. cmd exiting before we cancel breaks the PPID chain so Kill(true) can't + // find ping by tree-walk. + // 3. PowerShell finds ping's PID via WMI (for cleanup) and waits to be killed by + // cancellation. var grandchildPidFile = Path.Combine(Path.GetTempPath(), $"shellfish-grandchild-{Guid.NewGuid():N}.pid"); var psScript = @" @@ -88,19 +96,17 @@ public void Execute_WhenWindowsGrandchildHoldsRedirectedPipes_ShouldNotHang() $pingPath = Join-Path $env:WINDIR 'System32\PING.EXE' $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = Join-Path $env:WINDIR 'System32\cmd.exe' -# -n 60000 just makes ping long-running enough to outlive the test. $psi.Arguments = '/c start /b """" ""' + $pingPath + '"" -n 60000 127.0.0.1' $psi.UseShellExecute = $false $psi.CreateNoWindow = $true -# Redirecting any stream flips bInheritHandles=true in .NET's Process.Start, -# so non-redirected streams pass through via GetStdHandle to the child. +# Redirecting any stream flips bInheritHandles=true in .NET's Process.Start. $psi.RedirectStandardInput = $true $cmd = [System.Diagnostics.Process]::Start($psi) $cmd.StandardInput.Close() $cmdPid = $cmd.Id -# Wait for cmd to exit — by the time PowerShell itself exits we want ping to be orphaned. +# Wait for cmd to exit — breaks the PPID chain so Kill(true) misses ping. $cmd.WaitForExit() -# Poll until ping appears in WMI — there's a lag between cmd exiting and the +# Poll until ping appears in WMI; there's a lag between cmd exiting and the # orphaned ping becoming visible. $deadline = (Get-Date).AddSeconds(10) while ((Get-Date) -lt $deadline) { @@ -108,6 +114,9 @@ public void Execute_WhenWindowsGrandchildHoldsRedirectedPipes_ShouldNotHang() if ($p) { Set-Content -Path $pidFile -Value $p.ProcessId; break } Start-Sleep -Milliseconds 100 } +# Park PowerShell so it's still alive when we cancel — we want the cancel path +# (kill + return) to exercise the hang, not a clean exit. +Start-Sleep -Seconds 600 "; psScript = psScript.Replace("PIDFILE_PLACEHOLDER", grandchildPidFile); var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(psScript)); @@ -122,11 +131,24 @@ public void Execute_WhenWindowsGrandchildHoldsRedirectedPipes_ShouldNotHang() try { - var executeTask = Task.Run(() => executor.Execute(CancellationToken.None)); + using var cts = new CancellationTokenSource(); + var executeTask = Task.Run(() => + { + try { executor.Execute(cts.Token); } + catch (OperationCanceledException) { /* expected */ } + }); - executeTask.Wait(HangGuardTimeout) - .Should() - .BeTrue($"Execute() should return within {HangGuardTimeout.TotalSeconds}s even when a grandchild inherits the redirected pipes; if this assertion fails, the parameterless Process.WaitForExit() in ShellfishProcess.WaitForExit is hanging on the async-reader drain."); + WaitForGrandchildSpawn(grandchildPidFile, GrandchildSpawnTimeout); + + var sw = Stopwatch.StartNew(); + cts.Cancel(); + + var completed = executeTask.Wait(HangGuardTimeout); + sw.Stop(); + + completed.Should().BeTrue( + $"Execute() should return shortly after cancellation even when a Windows grandchild " + + $"holds the redirected pipes. Elapsed since cancel: {sw.Elapsed.TotalSeconds:F1}s"); } finally { @@ -134,28 +156,38 @@ public void Execute_WhenWindowsGrandchildHoldsRedirectedPipes_ShouldNotHang() } } + static void WaitForGrandchildSpawn(string pidFile, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (File.Exists(pidFile) && int.TryParse(SafelyReadAllText(pidFile).Trim(), out var pid) && pid > 0) + return; + Thread.Sleep(50); + } + throw new TimeoutException( + $"Test setup failed: the grandchild PID was never written to '{pidFile}'. " + + $"The grandchild-pipe scenario is not being exercised."); + } + + static string SafelyReadAllText(string path) + { + try { return File.ReadAllText(path); } + catch { return string.Empty; } + } + static void TryKillGrandchild(string pidFile) { try { if (!File.Exists(pidFile)) return; - var pidText = File.ReadAllText(pidFile).Trim(); + var pidText = SafelyReadAllText(pidFile).Trim(); if (!int.TryParse(pidText, out var pid)) return; - try - { - var p = Process.GetProcessById(pid); - p.Kill(); - } - catch - { - // already gone - } - } - catch - { - // best-effort cleanup + try { Process.GetProcessById(pid).Kill(); } + catch { /* already gone */ } } + catch { /* best-effort cleanup */ } finally { try { if (File.Exists(pidFile)) File.Delete(pidFile); } catch { /* ignore */ } From 8a6e7647e791ed13aa02d05f186430591dbbcc43 Mon Sep 17 00:00:00 2001 From: Luke Butters Date: Thu, 28 May 2026 09:03:32 +1000 Subject: [PATCH 4/4] works in async --- .../ShellCommandFixture.GrandchildPipes.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/source/Tests/ShellCommandFixture.GrandchildPipes.cs b/source/Tests/ShellCommandFixture.GrandchildPipes.cs index 8f1ac6c..546fdc8 100644 --- a/source/Tests/ShellCommandFixture.GrandchildPipes.cs +++ b/source/Tests/ShellCommandFixture.GrandchildPipes.cs @@ -75,6 +75,51 @@ public void Execute_WhenUnixGrandchildHoldsRedirectedPipes_ShouldNotHangAfterCan TryKillGrandchild(grandchildPidFile); } } + + [NixFact] + public void Execute_WhenUnixGrandchildHoldsRedirectedPipes_ShouldNotHangAfterCancellation_Async() + { + var grandchildPidFile = Path.Combine(Path.GetTempPath(), $"shellfish-grandchild-{Guid.NewGuid():N}.pid"); + + // sh -c "sleep 600 & echo $! > pidfile; exit 0" + // sh backgrounds sleep (which inherits sh's redirected stdout), writes the PID, exits. + // sleep is reparented to init/launchd and keeps the pipe open. + var script = $"sleep 600 & echo $! > '{grandchildPidFile}'; exit 0"; + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + + var executor = new ShellCommand("/bin/sh") + .WithArguments(new[] { "-c", script }) + .WithStdOutTarget(stdOut) + .WithStdErrTarget(stdErr); + + try + { + using var cts = new CancellationTokenSource(); + var executeTask = Task.Run(() => + { + try { var f = executor.ExecuteAsync(cts.Token); } + catch (OperationCanceledException) { /* expected */ } + }); + + WaitForGrandchildSpawn(grandchildPidFile, GrandchildSpawnTimeout); + + var sw = Stopwatch.StartNew(); + cts.Cancel(); + + var completed = executeTask.Wait(HangGuardTimeout); + sw.Stop(); + + completed.Should().BeTrue( + $"Execute() should return shortly after cancellation even when a Unix grandchild (reparented to init/launchd) " + + $"holds the redirected pipes. Elapsed since cancel: {sw.Elapsed.TotalSeconds:F1}s"); + } + finally + { + TryKillGrandchild(grandchildPidFile); + } + } [WindowsFact] public void Execute_WhenWindowsGrandchildHoldsRedirectedPipes_ShouldNotHangAfterCancellation()