diff --git a/changelogs/fragments/win-async-refactor.yml b/changelogs/fragments/win-async-refactor.yml new file mode 100644 index 00000000000..f86e5cf4b29 --- /dev/null +++ b/changelogs/fragments/win-async-refactor.yml @@ -0,0 +1,3 @@ +minor_changes: + - >- + Windows - refactor the async implementation to better handle errors during bootstrapping and avoid WMI when possible. diff --git a/lib/ansible/executor/powershell/async_watchdog.ps1 b/lib/ansible/executor/powershell/async_watchdog.ps1 index c2138e35914..ee35fb76ab8 100644 --- a/lib/ansible/executor/powershell/async_watchdog.ps1 +++ b/lib/ansible/executor/powershell/async_watchdog.ps1 @@ -1,117 +1,103 @@ -# (c) 2018 Ansible Project +# (c) 2025 Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) -param( - [Parameter(Mandatory = $true)][System.Collections.IDictionary]$Payload -) +using namespace Microsoft.Win32.SafeHandles +using namespace System.Collections +using namespace System.Text +using namespace System.Threading -# help with debugging errors as we don't have visibility of this running process -trap { - $watchdog_path = "$($env:TEMP)\ansible-async-watchdog-error-$(Get-Date -Format "yyyy-MM-ddTHH-mm-ss.ffffZ").txt" - $error_msg = "Error while running the async exec wrapper`r`n$(Format-AnsibleException -ErrorRecord $_)" - Set-Content -Path $watchdog_path -Value $error_msg - break -} +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [IDictionary] + $Payload +) $ErrorActionPreference = "Stop" -Write-AnsibleLog "INFO - starting async_watchdog" "async_watchdog" - # pop 0th action as entrypoint $payload.actions = $payload.actions[1..99] $actions = $Payload.actions $entrypoint = $payload.($actions[0]) -$entrypoint = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($entrypoint)) +$entrypoint = [Encoding]::UTF8.GetString([Convert]::FromBase64String($entrypoint)) -$resultfile_path = $payload.async_results_path -$max_exec_time_sec = $payload.async_timeout_sec +$resultPath = $payload.async_results_path +$timeoutSec = $payload.async_timeout_sec +$waitHandleId = $payload.async_wait_handle_id -Write-AnsibleLog "INFO - deserializing existing result file args at: '$resultfile_path'" "async_watchdog" -if (-not (Test-Path -Path $resultfile_path)) { - $msg = "result file at '$resultfile_path' does not exist" - Write-AnsibleLog "ERROR - $msg" "async_watchdog" - throw $msg +if (-not (Test-Path -LiteralPath $resultPath)) { + throw "result file at '$resultPath' does not exist" } -$result_json = Get-Content -Path $resultfile_path -Raw -Write-AnsibleLog "INFO - result file json is: $result_json" "async_watchdog" -$result = ConvertFrom-AnsibleJson -InputObject $result_json - -Write-AnsibleLog "INFO - creating async runspace" "async_watchdog" -$rs = [RunspaceFactory]::CreateRunspace() -$rs.Open() +$resultJson = Get-Content -LiteralPath $resultPath -Raw +$result = ConvertFrom-AnsibleJson -InputObject $resultJson -Write-AnsibleLog "INFO - creating async PowerShell pipeline" "async_watchdog" $ps = [PowerShell]::Create() -$ps.Runspace = $rs # these functions are set in exec_wrapper -Write-AnsibleLog "INFO - adding global functions to PowerShell pipeline script" "async_watchdog" $ps.AddScript($script:common_functions).AddStatement() > $null $ps.AddScript($script:wrapper_functions).AddStatement() > $null -$function_params = @{ +$functionParams = @{ Name = "common_functions" Value = $script:common_functions Scope = "script" } -$ps.AddCommand("Set-Variable").AddParameters($function_params).AddStatement() > $null +$ps.AddCommand("Set-Variable").AddParameters($functionParams).AddStatement() > $null -Write-AnsibleLog "INFO - adding $($actions[0]) to PowerShell pipeline script" "async_watchdog" $ps.AddScript($entrypoint).AddArgument($payload) > $null -Write-AnsibleLog "INFO - async job start, calling BeginInvoke()" "async_watchdog" -$job_async_result = $ps.BeginInvoke() - -Write-AnsibleLog "INFO - waiting '$max_exec_time_sec' seconds for async job to complete" "async_watchdog" -$job_async_result.AsyncWaitHandle.WaitOne($max_exec_time_sec * 1000) > $null -$result.finished = 1 - -if ($job_async_result.IsCompleted) { - Write-AnsibleLog "INFO - async job completed, calling EndInvoke()" "async_watchdog" - - $job_output = $ps.EndInvoke($job_async_result) - $job_error = $ps.Streams.Error - - Write-AnsibleLog "INFO - raw module stdout:`r`n$($job_output | Out-String)" "async_watchdog" - if ($job_error) { - Write-AnsibleLog "WARN - raw module stderr:`r`n$($job_error | Out-String)" "async_watchdog" - } - - # write success/output/error to result object - # TODO: cleanse leading/trailing junk - try { - Write-AnsibleLog "INFO - deserializing Ansible stdout" "async_watchdog" - $module_result = ConvertFrom-AnsibleJson -InputObject $job_output +# Signals async_wrapper that we are ready to start the job and to stop waiting +$waitHandle = [SafeWaitHandle]::new([IntPtr]$waitHandleId, $true) +$waitEvent = [ManualResetEvent]::new($false) +$waitEvent.SafeWaitHandle = $waitHandle +$null = $waitEvent.Set() + +$jobOutput = $null +$jobError = $null +try { + $jobAsyncResult = $ps.BeginInvoke() + $jobAsyncResult.AsyncWaitHandle.WaitOne($timeoutSec * 1000) > $null + $result.finished = 1 + + if ($jobAsyncResult.IsCompleted) { + $jobOutput = $ps.EndInvoke($jobAsyncResult) + $jobError = $ps.Streams.Error + + # write success/output/error to result object + # TODO: cleanse leading/trailing junk + $moduleResult = ConvertFrom-AnsibleJson -InputObject $jobOutput # TODO: check for conflicting keys - $result = $result + $module_result - } - catch { - $result.failed = $true - $result.msg = "failed to parse module output: $($_.Exception.Message)" - # return output back to Ansible to help with debugging errors - $result.stdout = $job_output | Out-String - $result.stderr = $job_error | Out-String + $result = $result + $moduleResult } + else { + $ps.BeginStop($null, $null) > $null # best effort stop - $result_json = ConvertTo-Json -InputObject $result -Depth 99 -Compress - Set-Content -Path $resultfile_path -Value $result_json - - Write-AnsibleLog "INFO - wrote output to $resultfile_path" "async_watchdog" + throw "timed out waiting for module completion" + } } -else { - Write-AnsibleLog "ERROR - reached timeout on async job, stopping job" "async_watchdog" - $ps.BeginStop($null, $null) > $null # best effort stop - - # write timeout to result object +catch { + $exception = @( + "$_" + "$($_.InvocationInfo.PositionMessage)" + "+ CategoryInfo : $($_.CategoryInfo)" + "+ FullyQualifiedErrorId : $($_.FullyQualifiedErrorId)" + "" + "ScriptStackTrace:" + "$($_.ScriptStackTrace)" + + if ($_.Exception.StackTrace) { + "$($_.Exception.StackTrace)" + } + ) -join ([Environment]::NewLine) + + $result.exception = $exception $result.failed = $true - $result.msg = "timed out waiting for module completion" - $result_json = ConvertTo-Json -InputObject $result -Depth 99 -Compress - Set-Content -Path $resultfile_path -Value $result_json - - Write-AnsibleLog "INFO - wrote timeout to '$resultfile_path'" "async_watchdog" + $result.msg = "failure during async watchdog: $_" + # return output back, if available, to Ansible to help with debugging errors + $result.stdout = $jobOutput | Out-String + $result.stderr = $jobError | Out-String +} +finally { + $resultJson = ConvertTo-Json -InputObject $result -Depth 99 -Compress + Set-Content -LiteralPath $resultPath -Value $resultJson -Encoding UTF8 } - -# in the case of a hung pipeline, this will cause the process to stay alive until it's un-hung... -#$rs.Close() | Out-Null - -Write-AnsibleLog "INFO - ending async_watchdog" "async_watchdog" diff --git a/lib/ansible/executor/powershell/async_wrapper.ps1 b/lib/ansible/executor/powershell/async_wrapper.ps1 index dd5a9becc5b..18ba06c3312 100644 --- a/lib/ansible/executor/powershell/async_wrapper.ps1 +++ b/lib/ansible/executor/powershell/async_wrapper.ps1 @@ -1,174 +1,254 @@ -# (c) 2018 Ansible Project +# (c) 2025 Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) -param( - [Parameter(Mandatory = $true)][System.Collections.IDictionary]$Payload +#AnsibleRequires -CSharpUtil Ansible._Async + +using namespace System.Collections +using namespace System.ComponentModel +using namespace System.Diagnostics +using namespace System.IO +using namespace System.IO.Pipes +using namespace System.Text +using namespace System.Threading + +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [IDictionary] + $Payload ) $ErrorActionPreference = "Stop" -Write-AnsibleLog "INFO - starting async_wrapper" "async_wrapper" +$utf8 = [UTF8Encoding]::new($false) +$newTmp = [Environment]::ExpandEnvironmentVariables($Payload.module_args["_ansible_remote_tmp"]) +$asyncDef = $utf8.GetString([Convert]::FromBase64String($Payload.csharp_utils["Ansible._Async"])) + +# Ansible.ModuleUtils.AddType handles this but has some extra overhead, as we +# don't need any of the extra checks we just use Add-Type manually here. +$addTypeParams = @{ + TypeDefinition = $asyncDef +} +if ($PSVersionTable.PSVersion -ge '6.0') { + $addTypeParams.CompilerOptions = '/unsafe' +} +else { + $referencedAssemblies = @( + [Win32Exception].Assembly.Location + ) + $addTypeParams.CompilerParameters = [CodeDom.Compiler.CompilerParameters]@{ + CompilerOptions = "/unsafe" + TempFiles = [CodeDom.Compiler.TempFileCollection]::new($newTmp, $false) + } + $addTypeParams.CompilerParameters.ReferencedAssemblies.AddRange($referencedAssemblies) +} +$origLib = $env:LIB +$env:LIB = $null +Add-Type @addTypeParams 5>$null +$env:LIB = $origLib if (-not $Payload.environment.ContainsKey("ANSIBLE_ASYNC_DIR")) { Write-AnsibleError -Message "internal error: the environment variable ANSIBLE_ASYNC_DIR is not set and is required for an async task" $host.SetShouldExit(1) return } -$async_dir = [System.Environment]::ExpandEnvironmentVariables($Payload.environment.ANSIBLE_ASYNC_DIR) - -# calculate the result path so we can include it in the worker payload -$jid = $Payload.async_jid -$local_jid = $jid + "." + $pid - -$results_path = [System.IO.Path]::Combine($async_dir, $local_jid) - -Write-AnsibleLog "INFO - creating async results path at '$results_path'" "async_wrapper" - -$Payload.async_results_path = $results_path -[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($results_path)) > $null - -# we use Win32_Process to escape the current process job, CreateProcess with a -# breakaway flag won't work for psrp as the psrp process does not have breakaway -# rights. Unfortunately we can't read/write to the spawned process as we can't -# inherit the handles. We use a locked down named pipe to send the exec_wrapper -# payload. Anonymous pipes won't work as the spawned process will not be a child -# of the current one and will not be able to inherit the handles - -# pop the async_wrapper action so we don't get stuck in a loop and create new -# exec_wrapper for our async process -$Payload.actions = $Payload.actions[1..99] -$payload_json = ConvertTo-Json -InputObject $Payload -Depth 99 -Compress - -# -$exec_wrapper = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Payload.exec_wrapper)) -$exec_wrapper += "`0`0`0`0" + $payload_json -$payload_bytes = [System.Text.Encoding]::UTF8.GetBytes($exec_wrapper) -$pipe_name = "ansible-async-$jid-$([guid]::NewGuid())" - -# template the async process command line with the payload details -$bootstrap_wrapper = { - # help with debugging errors as we loose visibility of the process output - # from here on - trap { - $wrapper_path = "$($env:TEMP)\ansible-async-wrapper-error-$(Get-Date -Format "yyyy-MM-ddTHH-mm-ss.ffffZ").txt" - $error_msg = "Error while running the async exec wrapper`r`n$($_ | Out-String)`r`n$($_.ScriptStackTrace)" - Set-Content -Path $wrapper_path -Value $error_msg - break +$asyncDir = [Environment]::ExpandEnvironmentVariables($Payload.environment.ANSIBLE_ASYNC_DIR) +if (-not [Directory]::Exists($asyncDir)) { + $null = [Directory]::CreateDirectory($asyncDir) +} + +$parentProcessId = 0 +$parentProcessHandle = $stdoutReader = $stderrReader = $stdinPipe = $stdoutPipe = $stderrPipe = $asyncProcess = $waitHandle = $null +try { + $stdinPipe = [AnonymousPipeServerStream]::new([PipeDirection]::Out, [HandleInheritability]::Inheritable) + $stdoutPipe = [AnonymousPipeServerStream]::new([PipeDirection]::In, [HandleInheritability]::Inheritable) + $stderrPipe = [AnonymousPipeServerStream]::new([PipeDirection]::In, [HandleInheritability]::Inheritable) + $stdoutReader = [StreamReader]::new($stdoutPipe, $utf8, $false) + $stderrReader = [StreamReader]::new($stderrPipe, $utf8, $false) + $clientWaitHandle = $waitHandle = [Ansible._Async.AsyncUtil]::CreateInheritableEvent() + + $stdinHandle = $stdinPipe.ClientSafePipeHandle + $stdoutHandle = $stdoutPipe.ClientSafePipeHandle + $stderrHandle = $stderrPipe.ClientSafePipeHandle + + $executable = if ($PSVersionTable.PSVersion -lt '6.0') { + 'powershell.exe' } + else { + 'pwsh.exe' + } + $executablePath = Join-Path -Path $PSHome -ChildPath $executable + + # We need to escape the job of the current process to allow the async + # process to outlive the Windows job. If the current process is not part of + # a job or job allows us to breakaway we can spawn the process directly. + # Otherwise we use WMI Win32_Process.Create to create a process as our user + # outside the job and use that as the async process parent. The winrm and + # ssh connection plugin allows breaking away from the job but psrp does not. + if (-not [Ansible._Async.AsyncUtil]::CanCreateBreakawayProcess()) { + # We hide the console window and suspend the process to avoid it running + # anything. We only need the process to be created outside the job and not + # for it to run. + $psi = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ + CreateFlags = [uint32]4 # CREATE_SUSPENDED + ShowWindow = [uint16]0 # SW_HIDE + } + $procInfo = Invoke-CimMethod -ClassName Win32_Process -Name Create -Arguments @{ + CommandLine = $executablePath + ProcessStartupInformation = $psi + } + $rc = $procInfo.ReturnValue + if ($rc -ne 0) { + $msg = switch ($rc) { + 2 { "Access denied" } + 3 { "Insufficient privilege" } + 8 { "Unknown failure" } + 9 { "Path not found" } + 21 { "Invalid parameter" } + default { "Other" } + } + throw "Failed to start async parent process: $rc $msg" + } - &chcp.com 65001 > $null + # WMI returns a UInt32, we want the signed equivalent of those bytes. + $parentProcessId = [Convert]::ToInt32( + [Convert]::ToString($procInfo.ProcessId, 16), + 16) + + $parentProcessHandle = [Ansible._Async.AsyncUtil]::OpenProcessAsParent($parentProcessId) + $clientWaitHandle = [Ansible._Async.AsyncUtil]::DuplicateHandleToProcess($waitHandle, $parentProcessHandle) + $stdinHandle = [Ansible._Async.AsyncUtil]::DuplicateHandleToProcess($stdinHandle, $parentProcessHandle) + $stdoutHandle = [Ansible._Async.AsyncUtil]::DuplicateHandleToProcess($stdoutHandle, $parentProcessHandle) + $stderrHandle = [Ansible._Async.AsyncUtil]::DuplicateHandleToProcess($stderrHandle, $parentProcessHandle) + $stdinPipe.DisposeLocalCopyOfClientHandle() + $stdoutPipe.DisposeLocalCopyOfClientHandle() + $stderrPipe.DisposeLocalCopyOfClientHandle() + } - # store the pipe name and no. of bytes to read, these are populated before - # before the process is created - do not remove or changed - $pipe_name = "" - $bytes_length = 0 + $localJid = "$($Payload.async_jid).$pid" + $resultsPath = [Path]::Combine($asyncDir, $localJid) - $input_bytes = New-Object -TypeName byte[] -ArgumentList $bytes_length - $pipe = New-Object -TypeName System.IO.Pipes.NamedPipeClientStream -ArgumentList @( - ".", # localhost - $pipe_name, - [System.IO.Pipes.PipeDirection]::In, - [System.IO.Pipes.PipeOptions]::None, - [System.Security.Principal.TokenImpersonationLevel]::Anonymous - ) - try { - $pipe.Connect() - $pipe.Read($input_bytes, 0, $bytes_length) > $null - } - finally { - $pipe.Close() - } - $exec = [System.Text.Encoding]::UTF8.GetString($input_bytes) - $exec_parts = $exec.Split(@("`0`0`0`0"), 2, [StringSplitOptions]::RemoveEmptyEntries) - Set-Variable -Name json_raw -Value $exec_parts[1] - $exec = [ScriptBlock]::Create($exec_parts[0]) - &$exec -} + $Payload.async_results_path = $resultsPath + $Payload.async_wait_handle_id = [Int64]$clientWaitHandle.DangerousGetHandle() + $Payload.actions = $Payload.actions[1..99] + $payloadJson = ConvertTo-Json -InputObject $Payload -Depth 99 -Compress -$bootstrap_wrapper = $bootstrap_wrapper.ToString().Replace('$pipe_name = ""', "`$pipe_name = `"$pipe_name`"") -$bootstrap_wrapper = $bootstrap_wrapper.Replace('$bytes_length = 0', "`$bytes_length = $($payload_bytes.Count)") -$encoded_command = [System.Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($bootstrap_wrapper)) -$pwsh_path = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -$exec_args = "`"$pwsh_path`" -NonInteractive -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded_command" - -# create a named pipe that is set to allow only the current user read access -$current_user = ([Security.Principal.WindowsIdentity]::GetCurrent()).User -$pipe_sec = New-Object -TypeName System.IO.Pipes.PipeSecurity -$pipe_ar = New-Object -TypeName System.IO.Pipes.PipeAccessRule -ArgumentList @( - $current_user, - [System.IO.Pipes.PipeAccessRights]::Read, - [System.Security.AccessControl.AccessControlType]::Allow -) -$pipe_sec.AddAccessRule($pipe_ar) - -Write-AnsibleLog "INFO - creating named pipe '$pipe_name'" "async_wrapper" -$pipe = New-Object -TypeName System.IO.Pipes.NamedPipeServerStream -ArgumentList @( - $pipe_name, - [System.IO.Pipes.PipeDirection]::Out, - 1, - [System.IO.Pipes.PipeTransmissionMode]::Byte, - [System.IO.Pipes.PipeOptions]::Asynchronous, - 0, - 0, - $pipe_sec -) + # We can't use our normal bootstrap_wrapper.ps1 as it uses $input. We need + # to use [Console]::In.ReadToEnd() to ensure it respects the codepage set + # at the start of the script. As we are spawning this process with an + # explicit new console we can guarantee there is a console present. + $bootstrapWrapper = { + [Console]::InputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) -try { - Write-AnsibleLog "INFO - creating async process '$exec_args'" "async_wrapper" - $process = Invoke-CimMethod -ClassName Win32_Process -Name Create -Arguments @{CommandLine = $exec_args } - $rc = $process.ReturnValue - - Write-AnsibleLog "INFO - return value from async process exec: $rc" "async_wrapper" - if ($rc -ne 0) { - $error_msg = switch ($rc) { - 2 { "Access denied" } - 3 { "Insufficient privilege" } - 8 { "Unknown failure" } - 9 { "Path not found" } - 21 { "Invalid parameter" } - default { "Other" } - } - throw "Failed to start async process: $rc ($error_msg)" + $inData = [Console]::In.ReadToEnd() + $execWrapper, $json_raw = $inData.Split(@("`0`0`0`0"), 2, [StringSplitOptions]::RemoveEmptyEntries) + & ([ScriptBlock]::Create($execWrapper)) } - $watchdog_pid = $process.ProcessId - Write-AnsibleLog "INFO - created async process PID: $watchdog_pid" "async_wrapper" - - # populate initial results before we send the async data to avoid result race + $execWrapper = $utf8.GetString([Convert]::FromBase64String($Payload.exec_wrapper)) + + $encCommand = [Convert]::ToBase64String([Encoding]::Unicode.GetBytes($bootstrapWrapper)) + $asyncCommand = "`"$executablePath`" -NonInteractive -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encCommand" + $asyncInput = "$execWrapper`0`0`0`0$payloadJson" + + $asyncProcess = [Ansible._Async.AsyncUtil]::CreateAsyncProcess( + $executablePath, + $asyncCommand, + $stdinHandle, + $stdoutHandle, + $stderrHandle, + $clientWaitHandle, + $parentProcessHandle, + $stdoutReader, + $stderrReader) + + # We need to write the result file before the process is started to ensure + # it can read the file. $result = @{ started = 1 finished = 0 - results_file = $results_path - ansible_job_id = $local_jid + results_file = $resultsPath + ansible_job_id = $localJid _ansible_suppress_tmpdir_delete = $true - ansible_async_watchdog_pid = $watchdog_pid + ansible_async_watchdog_pid = $asyncProcess.ProcessId + } + $resultJson = ConvertTo-Json -InputObject $result -Depth 99 -Compress + [File]::WriteAllText($resultsPath, $resultJson, $utf8) + + if ($parentProcessHandle) { + [Ansible._Async.AsyncUtil]::CloseHandleInProcess($stdinHandle, $parentProcessHandle) + [Ansible._Async.AsyncUtil]::CloseHandleInProcess($stdoutHandle, $parentProcessHandle) + [Ansible._Async.AsyncUtil]::CloseHandleInProcess($stderrHandle, $parentProcessHandle) + [Ansible._Async.AsyncUtil]::CloseHandleInProcess($clientWaitHandle, $parentProcessHandle) + } + else { + $stdinPipe.DisposeLocalCopyOfClientHandle() + $stdoutPipe.DisposeLocalCopyOfClientHandle() + $stderrPipe.DisposeLocalCopyOfClientHandle() + } + + [Ansible._Async.AsyncUtil]::ResumeThread($asyncProcess.Thread) + + # If writing to the pipe fails the process has already ended. + $procAlive = $true + $procIn = [StreamWriter]::new($stdinPipe, $utf8) + try { + $procIn.WriteLine($asyncInput) + $procIn.Flush() + $procIn.Dispose() + } + catch [IOException] { + $procAlive = $false } - Write-AnsibleLog "INFO - writing initial async results to '$results_path'" "async_wrapper" - $result_json = ConvertTo-Json -InputObject $result -Depth 99 -Compress - Set-Content $results_path -Value $result_json - - $np_timeout = $Payload.async_startup_timeout * 1000 - Write-AnsibleLog "INFO - waiting for async process to connect to named pipe for $np_timeout milliseconds" "async_wrapper" - $wait_async = $pipe.BeginWaitForConnection($null, $null) - $wait_async.AsyncWaitHandle.WaitOne($np_timeout) > $null - if (-not $wait_async.IsCompleted) { - $msg = "Ansible encountered a timeout while waiting for the async task to start and connect to the named" - $msg += "pipe. This can be affected by the performance of the target - you can increase this timeout using" - $msg += "WIN_ASYNC_STARTUP_TIMEOUT or just for this host using the win_async_startup_timeout hostvar if " - $msg += "this keeps happening." - throw $msg + if ($procAlive) { + # Wait for the process to signal it has started the async task or if it + # has ended early/timed out. + $startupTimeout = [TimeSpan]::FromSeconds($Payload.async_startup_timeout) + $handleIdx = [WaitHandle]::WaitAny( + @( + [Ansible._Async.ManagedWaitHandle]::new($waitHandle), + [Ansible._Async.ManagedWaitHandle]::new($asyncProcess.Process) + ), + $startupTimeout) + if ($handleIdx -eq [WaitHandle]::WaitTimeout) { + $msg = -join @( + "Ansible encountered a timeout while waiting for the async task to start and signal it has started. " + "This can be affected by the performance of the target - you can increase this timeout using " + "WIN_ASYNC_STARTUP_TIMEOUT or just for this host using the ansible_win_async_startup_timeout hostvar " + "if this keeps happening." + ) + throw $msg + } + $procAlive = $handleIdx -eq 0 } - $pipe.EndWaitForConnection($wait_async) - Write-AnsibleLog "INFO - writing exec_wrapper and payload to async process" "async_wrapper" - $pipe.Write($payload_bytes, 0, $payload_bytes.Count) - $pipe.Flush() - $pipe.WaitForPipeDrain() + if ($procAlive) { + $resultJson + } + else { + # If the process had ended before it signaled it was ready, we return + # back the raw output and hope it contains an error. + Remove-Item -LiteralPath $resultsPath -ErrorAction SilentlyContinue + + $stdout = $asyncProcess.StdoutReader.GetAwaiter().GetResult() + $stderr = $asyncProcess.StderrReader.GetAwaiter().GetResult() + $rc = [Ansible._Async.AsyncUtil]::GetProcessExitCode($asyncProcess.Process) + + $host.UI.WriteLine($stdout) + $host.UI.WriteErrorLine($stderr) + $host.SetShouldExit($rc) + } } finally { - $pipe.Close() + if ($parentProcessHandle) { $parentProcessHandle.Dispose() } + if ($parentProcessId) { + Stop-Process -Id $parentProcessId -Force -ErrorAction SilentlyContinue + } + if ($stdoutReader) { $stdoutReader.Dispose() } + if ($stderrReader) { $stderrReader.Dispose() } + if ($stdinPipe) { $stdinPipe.Dispose() } + if ($stdoutPipe) { $stdoutPipe.Dispose() } + if ($stderrPipe) { $stderrPipe.Dispose() } + if ($asyncProcess) { $asyncProcess.Dispose() } + if ($waitHandle) { $waitHandle.Dispose() } } - -Write-AnsibleLog "INFO - outputting initial async result: $result_json" "async_wrapper" -Write-Output -InputObject $result_json -Write-AnsibleLog "INFO - ending async_wrapper" "async_wrapper" diff --git a/lib/ansible/module_utils/csharp/Ansible._Async.cs b/lib/ansible/module_utils/csharp/Ansible._Async.cs new file mode 100644 index 00000000000..b62e2f8f7bb --- /dev/null +++ b/lib/ansible/module_utils/csharp/Ansible._Async.cs @@ -0,0 +1,516 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Security; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +// Used by async_wrapper.ps1, not for general use. + +namespace Ansible._Async +{ + internal class NativeHelpers + { + public const int CREATE_SUSPENDED = 0x00000004; + public const int CREATE_NEW_CONSOLE = 0x00000010; + public const int CREATE_UNICODE_ENVIRONMENT = 0x00000400; + public const int EXTENDED_STARTUPINFO_PRESENT = 0x00080000; + public const int CREATE_BREAKAWAY_FROM_JOB = 0x01000000; + + public const int DUPLICATE_CLOSE_SOURCE = 0x00000001; + public const int DUPLICATE_SAME_ACCESS = 0x00000002; + + public const int JobObjectBasicLimitInformation = 2; + + public const int JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x00000800; + + public const int PROCESS_DUP_HANDLE = 0x00000040; + public const int PROCESS_CREATE_PROCESS = 0x00000080; + + public const int PROC_THREAD_ATTRIBUTE_PARENT_PROCESS = 0x00020000; + public const int PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002; + + public const int STARTF_USESHOWWINDOW = 0x00000001; + public const int STARTF_USESTDHANDLES = 0x00000100; + + public const short SW_HIDE = 0; + + [StructLayout(LayoutKind.Sequential)] + public struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public int LimitFlags; + public IntPtr MinimumWorkingSetSize; + public IntPtr MaximumWorkingSetSize; + public int ActiveProcessLimit; + public UIntPtr Affinity; + public int PriorityClass; + public int SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SECURITY_ATTRIBUTES + { + public int nLength; + public IntPtr lpSecurityDescriptor; + public int bInheritHandle; + } + + [StructLayout(LayoutKind.Sequential)] + public struct STARTUPINFO + { + public int cb; + public IntPtr lpReserved; + public IntPtr lpDesktop; + public IntPtr lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + public struct STARTUPINFOEX + { + public STARTUPINFO startupInfo; + public IntPtr lpAttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + } + + internal class NativeMethods + { + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr CreateEventW( + ref NativeHelpers.SECURITY_ATTRIBUTES lpEventAttributes, + bool bManualReset, + bool bInitialState, + IntPtr lpName); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CreateProcessW( + [MarshalAs(UnmanagedType.LPWStr)] string lpApplicationName, + StringBuilder lpCommandLine, + IntPtr lpProcessAttributes, + IntPtr lpThreadAttributes, + bool bInheritHandles, + int dwCreationFlags, + IntPtr lpEnvironment, + IntPtr lpCurrentDirectory, + ref NativeHelpers.STARTUPINFOEX lpStartupInfo, + out NativeHelpers.PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll")] + public static extern void DeleteProcThreadAttributeList( + IntPtr lpAttributeList); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool DuplicateHandle( + IntPtr hSourceProcessHandle, + IntPtr hSourceHandle, + IntPtr hTargetProcessHandle, + out IntPtr lpTargetHandle, + int dwDesiredAccess, + bool bInheritHandle, + int dwOptions); + + [DllImport("kernel32.dll")] + public static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GetExitCodeProcess( + IntPtr hProcess, + out int lpExitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool InitializeProcThreadAttributeList( + IntPtr lpAttributeList, + int dwAttributeCount, + int dwFlags, + ref IntPtr lpSize); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool IsProcessInJob( + IntPtr ProcessHandle, + IntPtr JobHandle, + out bool Result); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr OpenProcess( + Int32 dwDesiredAccess, + bool bInheritHandle, + Int32 dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool QueryInformationJobObject( + IntPtr hJob, + int JobObjectInformationClass, + ref NativeHelpers.JOBOBJECT_BASIC_LIMIT_INFORMATION lpJobObjectInformation, + int cbJobObjectInformationLength, + IntPtr lpReturnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern int ResumeThread( + IntPtr hThread); + + [DllImport("kernel32.dll", SetLastError = true)] + public static unsafe extern bool UpdateProcThreadAttribute( + SafeProcThreadAttrList lpAttributeList, + int dwFlags, + UIntPtr Attribute, + void* lpValue, + UIntPtr cbSize, + IntPtr lpPreviousValue, + IntPtr lpReturnSize); + } + + public class ProcessInformation : IDisposable + { + public SafeWaitHandle Process { get; private set; } + public SafeWaitHandle Thread { get; private set; } + public int ProcessId { get; private set; } + public int ThreadId { get; private set; } + public Task StdoutReader { get; private set; } + public Task StderrReader { get; private set; } + + public ProcessInformation( + SafeWaitHandle process, + SafeWaitHandle thread, + int processId, + int threadId, + Task stdoutReader, + Task stderrReader) + { + Process = process; + Thread = thread; + ProcessId = processId; + ThreadId = threadId; + StdoutReader = stdoutReader; + StderrReader = stderrReader; + } + + public void Dispose() + { + Process.Dispose(); + Thread.Dispose(); + GC.SuppressFinalize(this); + } + ~ProcessInformation() { Dispose(); } + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2} - 0x{2:X8})", message, base.Message, errorCode); + } + + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + public class ManagedWaitHandle : WaitHandle + { + public ManagedWaitHandle(SafeWaitHandle handle) + { + SafeWaitHandle = handle; + } + } + + internal sealed class SafeProcThreadAttrList : SafeHandle + { + public SafeProcThreadAttrList(IntPtr handle) : base(handle, true) { } + + public override bool IsInvalid { get { return handle == IntPtr.Zero; } } + + protected override bool ReleaseHandle() + { + NativeMethods.DeleteProcThreadAttributeList(handle); + Marshal.FreeHGlobal(handle); + return true; + } + } + + public class AsyncUtil + { + public static bool CanCreateBreakawayProcess() + { + bool isInJob; + if (!NativeMethods.IsProcessInJob(NativeMethods.GetCurrentProcess(), IntPtr.Zero, out isInJob)) + { + throw new Win32Exception("IsProcessInJob() failed"); + } + + if (!isInJob) + { + return true; + } + + NativeHelpers.JOBOBJECT_BASIC_LIMIT_INFORMATION jobInfo = new NativeHelpers.JOBOBJECT_BASIC_LIMIT_INFORMATION(); + bool jobRes = NativeMethods.QueryInformationJobObject( + IntPtr.Zero, + NativeHelpers.JobObjectBasicLimitInformation, + ref jobInfo, + Marshal.SizeOf(), + IntPtr.Zero); + if (!jobRes) + { + throw new Win32Exception("QueryInformationJobObject() failed"); + } + + return (jobInfo.LimitFlags & NativeHelpers.JOB_OBJECT_LIMIT_BREAKAWAY_OK) != 0; + } + + public static ProcessInformation CreateAsyncProcess( + string applicationName, + string commandLine, + SafeHandle stdin, + SafeHandle stdout, + SafeHandle stderr, + SafeHandle mutexHandle, + SafeHandle parentProcess, + StreamReader stdoutReader, + StreamReader stderrReader) + { + StringBuilder commandLineBuffer = new StringBuilder(commandLine); + int creationFlags = NativeHelpers.CREATE_NEW_CONSOLE | + NativeHelpers.CREATE_SUSPENDED | + NativeHelpers.CREATE_UNICODE_ENVIRONMENT | + NativeHelpers.EXTENDED_STARTUPINFO_PRESENT; + if (parentProcess == null) + { + creationFlags |= NativeHelpers.CREATE_BREAKAWAY_FROM_JOB; + } + + NativeHelpers.STARTUPINFOEX si = new NativeHelpers.STARTUPINFOEX(); + si.startupInfo.cb = Marshal.SizeOf(typeof(NativeHelpers.STARTUPINFOEX)); + si.startupInfo.dwFlags = NativeHelpers.STARTF_USESHOWWINDOW | NativeHelpers.STARTF_USESTDHANDLES; + si.startupInfo.wShowWindow = NativeHelpers.SW_HIDE; + si.startupInfo.hStdInput = stdin.DangerousGetHandle(); + si.startupInfo.hStdOutput = stdout.DangerousGetHandle(); + si.startupInfo.hStdError = stderr.DangerousGetHandle(); + + int attrCount = 1; + IntPtr rawParentProcessHandle = IntPtr.Zero; + if (parentProcess != null) + { + attrCount++; + rawParentProcessHandle = parentProcess.DangerousGetHandle(); + } + + using (SafeProcThreadAttrList attrList = CreateProcThreadAttribute(attrCount)) + { + si.lpAttributeList = attrList.DangerousGetHandle(); + + IntPtr[] handlesToInherit = new IntPtr[4] + { + stdin.DangerousGetHandle(), + stdout.DangerousGetHandle(), + stderr.DangerousGetHandle(), + mutexHandle.DangerousGetHandle() + }; + unsafe + { + fixed (IntPtr* handlesToInheritPtr = &handlesToInherit[0]) + { + UpdateProcThreadAttribute( + attrList, + NativeHelpers.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + handlesToInheritPtr, + IntPtr.Size * 4); + + if (rawParentProcessHandle != IntPtr.Zero) + { + UpdateProcThreadAttribute( + attrList, + NativeHelpers.PROC_THREAD_ATTRIBUTE_PARENT_PROCESS, + &rawParentProcessHandle, + IntPtr.Size); + } + + NativeHelpers.PROCESS_INFORMATION pi = new NativeHelpers.PROCESS_INFORMATION(); + bool res = NativeMethods.CreateProcessW( + applicationName, + commandLineBuffer, + IntPtr.Zero, + IntPtr.Zero, + true, + (int)creationFlags, + IntPtr.Zero, + IntPtr.Zero, + ref si, + out pi); + if (!res) + { + throw new Win32Exception("CreateProcessW() failed"); + } + + return new ProcessInformation( + new SafeWaitHandle(pi.hProcess, true), + new SafeWaitHandle(pi.hThread, true), + pi.dwProcessId, + pi.dwThreadId, + Task.Run(() => stdoutReader.ReadToEnd()), + Task.Run(() => stderrReader.ReadToEnd())); + } + } + } + } + + public static SafeWaitHandle CreateInheritableEvent() + { + NativeHelpers.SECURITY_ATTRIBUTES sa = new NativeHelpers.SECURITY_ATTRIBUTES(); + sa.nLength = Marshal.SizeOf(sa); + sa.bInheritHandle = 1; + + IntPtr hEvent = NativeMethods.CreateEventW(ref sa, true, false, IntPtr.Zero); + if (hEvent == IntPtr.Zero) + { + throw new Win32Exception("CreateEventW() failed"); + } + return new SafeWaitHandle(hEvent, true); + } + + public static SafeHandle DuplicateHandleToProcess( + SafeHandle handle, + SafeHandle targetProcess) + { + IntPtr targetHandle; + bool res = NativeMethods.DuplicateHandle( + NativeMethods.GetCurrentProcess(), + handle.DangerousGetHandle(), + targetProcess.DangerousGetHandle(), + out targetHandle, + 0, + true, + NativeHelpers.DUPLICATE_SAME_ACCESS); + if (!res) + { + throw new Win32Exception("DuplicateHandle() failed"); + } + + // This will not dispose the handle, it is assumed + // the caller will close it manually with CloseHandleInProcess. + return new SafeWaitHandle(targetHandle, false); + } + + public static void CloseHandleInProcess( + SafeHandle handle, + SafeHandle targetProcess) + { + IntPtr _ = IntPtr.Zero; + bool res = NativeMethods.DuplicateHandle( + targetProcess.DangerousGetHandle(), + handle.DangerousGetHandle(), + IntPtr.Zero, + out _, + 0, + false, + NativeHelpers.DUPLICATE_CLOSE_SOURCE); + if (!res) + { + throw new Win32Exception("DuplicateHandle() failed to close handle"); + } + } + + public static int GetProcessExitCode(SafeHandle process) + { + int exitCode; + bool res = NativeMethods.GetExitCodeProcess(process.DangerousGetHandle(), out exitCode); + if (!res) + { + throw new Win32Exception("GetExitCodeProcess() failed"); + } + return exitCode; + } + + public static SafeHandle OpenProcessAsParent(int processId) + { + IntPtr hProcess = NativeMethods.OpenProcess( + NativeHelpers.PROCESS_DUP_HANDLE | NativeHelpers.PROCESS_CREATE_PROCESS, + false, + processId); + if (hProcess == IntPtr.Zero) + { + throw new Win32Exception("OpenProcess() failed"); + } + return new SafeWaitHandle(hProcess, true); + } + + public static void ResumeThread(SafeHandle thread) + { + int res = NativeMethods.ResumeThread(thread.DangerousGetHandle()); + if (res == -1) + { + throw new Win32Exception("ResumeThread() failed"); + } + } + + private static SafeProcThreadAttrList CreateProcThreadAttribute(int count) + { + IntPtr attrSize = IntPtr.Zero; + NativeMethods.InitializeProcThreadAttributeList(IntPtr.Zero, count, 0, ref attrSize); + + IntPtr attributeList = Marshal.AllocHGlobal((int)attrSize); + try + { + if (!NativeMethods.InitializeProcThreadAttributeList(attributeList, count, 0, ref attrSize)) + { + throw new Win32Exception("InitializeProcThreadAttributeList() failed"); + } + + return new SafeProcThreadAttrList(attributeList); + } + catch + { + Marshal.FreeHGlobal(attributeList); + throw; + } + } + + private static unsafe void UpdateProcThreadAttribute( + SafeProcThreadAttrList attributeList, + int attribute, + void* value, + int size) + { + bool res = NativeMethods.UpdateProcThreadAttribute( + attributeList, + 0, + (UIntPtr)attribute, + value, + (UIntPtr)size, + IntPtr.Zero, + IntPtr.Zero); + if (!res) + { + string msg = string.Format("UpdateProcThreadAttribute() failed to set attribute 0x{0:X8}", attribute); + throw new Win32Exception(msg); + } + } + } +} diff --git a/test/sanity/ignore.txt b/test/sanity/ignore.txt index f25396b0797..69cb5d65acc 100644 --- a/test/sanity/ignore.txt +++ b/test/sanity/ignore.txt @@ -1,7 +1,5 @@ .github/ISSUE_TEMPLATE/internal_issue.md pymarkdown!skip lib/ansible/config/base.yml no-unwanted-files -lib/ansible/executor/powershell/async_watchdog.ps1 pslint:PSCustomUseLiteralPath -lib/ansible/executor/powershell/async_wrapper.ps1 pslint:PSCustomUseLiteralPath lib/ansible/executor/powershell/exec_wrapper.ps1 pslint:PSCustomUseLiteralPath lib/ansible/keyword_desc.yml no-unwanted-files lib/ansible/modules/apt.py validate-modules:parameter-invalid