Migrate SilentProcessRunner to async#1236
Conversation
Replaces the sync WaitForExit() with await WaitForExitAsync(cancel). The cancel token is passed directly so the existing cancel semantics are preserved: cancel firing throws OCE from the await and unwinds. DoOurBestToCleanUp continues to fire on cancel via cancel.Register exactly as it did in the sync version. Adds a net48 polyfill for WaitForExitAsync using Process.Exited + TaskCompletionSource. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ISilentProcessRunner, SilentProcessRunnerWrapper, and the SilentProcessRunnerExtended helpers now return Task<int> and call SilentProcessRunner.ExecuteCommandAsync directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CommandLineRunner.Execute is consumed by Octopus.Manager.Tentacle (a WPF app), so the public method stays sync. It now blocks on the new SilentProcessRunner.ExecuteCommandAsync via GetAwaiter().GetResult() — safe because the WPF callers dispatch through ThreadPool.QueueUserWorkItem, which has no synchronisation context. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RunningScript now awaits SilentProcessRunner.ExecuteCommandAsync through a new RunScriptAsync helper. The monitored-startup path also awaits the async helper inside Task.Run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The six immediate sync callers of SilentProcessRunner now go through ExecuteCommandAsync(...).GetAwaiter().GetResult(): - Octopus.Manager.Tentacle PowerShellPrerequisite (WPF installer) - KubernetesDirectoryInformationProvider (IMemoryCache factory) - SystemCtlHelper (2 sites — start and sudo retry) - LinuxServiceConfigurator (3 sites — chmod, systemctl probe, sudo probe) - WindowsServiceConfigurator (sc.exe wrapper) Each site gets a comment explaining why it must be sync and why blocking on a thread-pool worker is deadlock-safe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates Kubernetes integration test setup helpers, PowerShell startup-detection tests, integration support, and Linux test fixtures to call the new async ExecuteCommandAsync API. Tests that don't await directly (NUnit static helpers, cache factories) block on .GetAwaiter().GetResult() and document why it's deadlock-safe on the test threadpool. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the six sync-boundary comments to name the canonical pattern
("sync-over-async"), link Stephen Cleary's "Don't Block on Async Code"
reference, and keep the "we are here / we do this / safe because"
structure. Removes em-dashes per style preference.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| process.BeginErrorReadLine(); | ||
|
|
||
| process.WaitForExit(); | ||
| #if NETFRAMEWORK |
There was a problem hiding this comment.
Remove all changes from this method implementation, this should be a signature change only in this class
| } | ||
| } | ||
|
|
||
| #if NETFRAMEWORK |
There was a problem hiding this comment.
Remove this and put it in the next PR
|
|
||
| var exitCode = SilentProcessRunner.ExecuteCommand( | ||
| // Dispose() cannot be made async; .GetAwaiter().GetResult() is safe here | ||
| // because this runs in test teardown (not inside an async context with a sync-blocking SynchronizationContext). |
There was a problem hiding this comment.
This doesn't match our lighthouse example comment on the wpf installer. Specifically where it calls out the pattern and links the blog post
| public void Dispose() | ||
| { | ||
| var exitCode = SilentProcessRunner.ExecuteCommand( | ||
| // Dispose() cannot be made async; .GetAwaiter().GetResult() is safe here |
There was a problem hiding this comment.
This also doesn't follow our lighthouse comment example
|
|
||
| var chmodCmd = new CommandLineInvocation("/bin/bash", $"-c \"chmod 777 {scriptPath}\""); | ||
| chmodCmd.ExecuteCommand(); | ||
| // Safe: sync test helper, no synchronisation context. |
There was a problem hiding this comment.
Does not follow our lighthouse comment example
There was a problem hiding this comment.
Also there are many instances of this in this class. Can we create a helper method to dry it up or push this sync to async transition higher?
| log, | ||
| CancellationToken.None); | ||
| cancel: CancellationToken.None) | ||
| // Safe: static void helper, no synchronisation context. |
There was a problem hiding this comment.
Does not follow our lighthouse comment example
| { | ||
| var commandLineInvocation = new CommandLineInvocation("/bin/bash", arguments); | ||
| var result = commandLineInvocation.ExecuteCommand(); | ||
| // Safe: constructor-time helper, no synchronisation context. |
There was a problem hiding this comment.
Does not follow our lighthouse comment example
| } | ||
|
|
||
| // Sync-over-async is safe here: NUnit runs tests on a plain ThreadPool thread with no | ||
| // synchronisation context, so there is no risk of deadlock. |
There was a problem hiding this comment.
Does not follow our lighthouse comment example and is not at the actual call site
| var result = commandLineInvocation.ExecuteCommand(); | ||
| // We're in WriteUnitFile, called from IServiceConfigurator.ConfigureService implementations | ||
| // which are sync (called from the Tentacle service-management CLI), so we block on the | ||
| // async call with .GetAwaiter().GetResult(). |
There was a problem hiding this comment.
ITs not clear why these things have to be sync?
| // We're in WriteUnitFile, called from IServiceConfigurator.ConfigureService implementations | ||
| // which are sync (called from the Tentacle service-management CLI), so we block on the | ||
| // async call with .GetAwaiter().GetResult(). | ||
| // This is sync-over-async but is safe because the CLI dispatches us on a plain |
There was a problem hiding this comment.
How do we know the cli dispatches us on a thread pool worker?
| var commandLineInvocation = new CommandLineInvocation("/bin/bash", "-c \"command -v systemctl >/dev/null\""); | ||
| var result = commandLineInvocation.ExecuteCommand(); | ||
| // Same sync-over-async boundary as WriteUnitFile: CheckSystemPrerequisites is called | ||
| // from the sync ConfigureService path, on a plain thread-pool worker. |
There was a problem hiding this comment.
Does not follow our lighthouse comment example
| { | ||
| var commandLineInvocation = new CommandLineInvocation("/bin/bash", "-c \"sudo -vn 2> /dev/null\""); | ||
| var result = commandLineInvocation.ExecuteCommand(); | ||
| // Same sync-over-async boundary as IsSystemdInstalled. |
There was a problem hiding this comment.
Does not follow our lighthouse comment example
…, lighthouse comments, async tests - Strip SilentProcessRunner.cs to signature-only async change (move polyfill, EnableRaisingEvents, and await WaitForExitAsync to #1226 abandon PR) - Apply lighthouse comment pattern uniformly to all sync-over-async sites including test scaffolding and the second/third sites in LinuxServiceConfigurator - Improve LinuxServiceConfigurator justification: name the sync interface chain (IServiceConfigurator -> AbstractCommand -> ICommand -> Topshelf) and the no-SynchronizationContext property of Main/Topshelf worker threads - Move SilentProcessRunnerFixture sync-over-async comment to actual call site - Convert LinuxConfigureServiceHelperFixture test methods to async Task, eliminating sync-over-async entirely in that file Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| // synchronously, so we block on the async call with .GetAwaiter().GetResult(). | ||
| // This is sync-over-async but is safe because the installer dispatches us on a | ||
| // plain thread-pool worker. No captured SynchronizationContext, so no deadlock. | ||
| // See https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html |
There was a problem hiding this comment.
@LukeButters do you have enough knowledge of this repo to verify Claude claims on each of these? Or shall I organise us a sync to go through them one by one
There was a problem hiding this comment.
Maybe @sburmanoctopus knows about this, he has done UI programming.
There was a problem hiding this comment.
From my WPF days, blocking would cause the UI to freeze, which wasn't great.
When async/await came along, it was amazing, because you could do async stuff as long as you added .ConfigureAwait(true) to the end. This would ensure the logic returned to the calling thread, which was the UI thread. This freed up the UI thread for other things while async code was running, and didn't block the UI thread anymore!
So by default, I would suggest using .ConfigureAwait(true).
However, in saying that, I have no context on where this code is being called, how it's being called etc. It's possible something else is dealing with the thread locking issue, or this is being called from a place where we must block the UI thread.
| // This is sync-over-async but is safe because the cache factory runs on a plain | ||
| // thread-pool worker. No captured SynchronizationContext, so no deadlock. | ||
| // See https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html | ||
| var exitCode = silentProcessRunner.ExecuteCommandAsync("du", $"-s -B 1 {directoryPath}", "/", stdOut.Add, stdErr.Add) |
There was a problem hiding this comment.
@jimmyp Might want check with modern deployments on this one or push the async.
Summary
Makes
SilentProcessRunner.ExecuteCommandasync. Required foundation for the EFT-3295 script-abandonment feature (PR #1226, which stacks on top of this) but merging it on its own makes the next PR much easier to review.What this PR does
SilentProcessRunner.ExecuteCommand→ExecuteCommandAsync(and the matching interfaces and helpers).GetAwaiter().GetResult()with a comment explaining the call context and why blocking on a thread-pool worker is deadlock-safeGenerated with Claude Code