Repository: ansible/ansible
Pull request #84712: windows async - refactor async wrapper code
Author: login: jborean93
Labels: name: feature
description: This issue/PR relates to a feature request.
Created: 2025-02-13T20:49:13Z | Merged: 2025-03-18T18:47:33Z

##### SUMMARY
Refactor the async wrapper and watchdog scripts for Windows. This attempts to avoid WMI on connection plugins that allow breaking away from a job like winrm and ssh as an optimisation and changes how WMI is used so that we can get the error details on a failed process more easily.

These changes are being made also in preparation for the WDAC implementation that requires this new execution model where input needs to be provided through stdin.

##### ISSUE TYPE
- Feature Pull Request


Diff:
diff --git a/changelogs/fragments/win-async-refactor.yml b/changelogs/fragments/win-async-refactor.yml
new file mode 100644
index 00000000000000..f86e5cf4b29fc7
--- /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 c2138e35914326..ee35fb76ab8f9e 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 dd5a9becc5ba6b..18ba06c33127aa 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 00000000000000..b62e2f8f7bb8ca
--- /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<string> StdoutReader { get; private set; }
+        public Task<string> StderrReader { get; private set; }
+
+        public ProcessInformation(
+            SafeWaitHandle process,
+            SafeWaitHandle thread,
+            int processId,
+            int threadId,
+            Task<string> stdoutReader,
+            Task<string> 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<NativeHelpers.JOBOBJECT_BASIC_LIMIT_INFORMATION>(),
+                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 b8bac0a1e42fcd..1d7816d0c51f15 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


Reviews:
user: login: nitzmahone
body: Looks like a nice cleanup overall- a couple nits to consider or ignore, and looking forward to the next round with the additional parser context added back in.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T05:04:59Z
user: login: jborean93
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T19:47:55Z
user: login: jborean93
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T19:53:49Z
user: login: jborean93
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T20:04:36Z
user: login: jborean93
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T02:18:28Z
user: login: nitzmahone
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T18:44:19Z
user: login: nitzmahone
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T18:44:49Z

Review comments:
diff_hunk: @@ -1,174 +1,259 @@
-# (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)
+}
 
-    &chcp.com 65001 > $null
+$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()
 
-    # 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
+    $stdinHandle = $stdinPipe.ClientSafePipeHandle
+    $stdoutHandle = $stdoutPipe.ClientSafePipeHandle
+    $stderrHandle = $stderrPipe.ClientSafePipeHandle
 
-    $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
+    $executablePath = [Process]::GetCurrentProcess().MainModule.FileName
path: lib/ansible/executor/powershell/async_wrapper.ps1
user: login: nitzmahone
body: This feels potentially brittle- would it make more sense to just say if it's anything but pwsh.exe/powershell.exe, or some other way to determine that the current code is in a non-standard host that we don't want to propagate?
created_at: 2025-03-14T04:20:23Z
author_association: MEMBER
diff_hunk: @@ -1,174 +1,259 @@
-# (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)
+}
 
-    &chcp.com 65001 > $null
+$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()
 
-    # 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
+    $stdinHandle = $stdinPipe.ClientSafePipeHandle
+    $stdoutHandle = $stdoutPipe.ClientSafePipeHandle
+    $stderrHandle = $stderrPipe.ClientSafePipeHandle
 
-    $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
+    $executablePath = [Process]::GetCurrentProcess().MainModule.FileName
+    # wsmprovhost is used in a PSRP WSMan PSRemoting target, we need to change that
+    # to the proper executable.
+    if ($executablePath -eq "$env:SystemRoot\system32\wsmprovhost.exe") {
+        $executable = if (Get-Variable -Name IsCoreCLR -ValueOnly -ErrorAction SilentlyContinue) {
+            'pwsh.exe'
+        }
+        else {
+            'powershell.exe'
+        }
+        $executablePath = Join-Path -Path $PSHome -ChildPath $executable
     }
-    finally {
-        $pipe.Close()
+
+    # 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"
+        }
+
+        # WMI returns a UInt32, we want the signed equivalent of those bytes.
path: lib/ansible/executor/powershell/async_wrapper.ps1
user: login: nitzmahone
body: Minor nit, but for any valid PID value, aren't the signed and unsigned values going to be the same anyway? Or does something downstream actually require the signed value so we have to convert it?
created_at: 2025-03-14T04:25:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,499 @@
+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(
+            IntPtr 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<string> StdoutReader { get; private set; }
+        public Task<string> StderrReader { get; private set; }
+
+        public ProcessInformation(
+            SafeWaitHandle process,
+            SafeWaitHandle thread,
+            int processId,
+            int threadId,
+            Task<string> stdoutReader,
+            Task<string> 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;
+        }
+    }
+
+    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<NativeHelpers.JOBOBJECT_BASIC_LIMIT_INFORMATION>(),
+                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();
+            }
+
+            IntPtr attrSize = IntPtr.Zero;
+            NativeMethods.InitializeProcThreadAttributeList(IntPtr.Zero, attrCount, 0, ref attrSize);
+
+            bool freeThreadAttributes = false;
+            si.lpAttributeList = Marshal.AllocHGlobal((int)attrSize);
+            try
+            {
+                if (!NativeMethods.InitializeProcThreadAttributeList(si.lpAttributeList, attrCount, 0, ref attrSize))
+                {
+                    throw new Win32Exception("InitializeProcThreadAttributeList() failed");
+                }
+                freeThreadAttributes = true;
path: lib/ansible/module_utils/csharp/Ansible._Async.cs
user: login: nitzmahone
body: Feels like there should be a simple way to wrap this up so it's automatically handled in a finalizer or something- this feels very Go-ish :laughing: 
created_at: 2025-03-14T05:00:37Z
author_association: MEMBER
diff_hunk: @@ -1,174 +1,259 @@
-# (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)
+}
 
-    &chcp.com 65001 > $null
+$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()
 
-    # 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
+    $stdinHandle = $stdinPipe.ClientSafePipeHandle
+    $stdoutHandle = $stdoutPipe.ClientSafePipeHandle
+    $stderrHandle = $stderrPipe.ClientSafePipeHandle
 
-    $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
+    $executablePath = [Process]::GetCurrentProcess().MainModule.FileName
path: lib/ansible/executor/powershell/async_wrapper.ps1
user: login: jborean93
body: Yea I'm not sure why I went with this route, we could do something like this instead

```powershell
$executable = if ($IsCoreCLR) {
    'pwsh.exe'
}
else {
    'powershell.exe'
}
$executablePath = Join-Path -Path $PSHome -ChildPath $executable
```
created_at: 2025-03-16T19:47:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,174 +1,259 @@
-# (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)
+}
 
-    &chcp.com 65001 > $null
+$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()
 
-    # 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
+    $stdinHandle = $stdinPipe.ClientSafePipeHandle
+    $stdoutHandle = $stdoutPipe.ClientSafePipeHandle
+    $stderrHandle = $stderrPipe.ClientSafePipeHandle
 
-    $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
+    $executablePath = [Process]::GetCurrentProcess().MainModule.FileName
+    # wsmprovhost is used in a PSRP WSMan PSRemoting target, we need to change that
+    # to the proper executable.
+    if ($executablePath -eq "$env:SystemRoot\system32\wsmprovhost.exe") {
+        $executable = if (Get-Variable -Name IsCoreCLR -ValueOnly -ErrorAction SilentlyContinue) {
+            'pwsh.exe'
+        }
+        else {
+            'powershell.exe'
+        }
+        $executablePath = Join-Path -Path $PSHome -ChildPath $executable
     }
-    finally {
-        $pipe.Close()
+
+    # 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"
+        }
+
+        # WMI returns a UInt32, we want the signed equivalent of those bytes.
path: lib/ansible/executor/powershell/async_wrapper.ps1
user: login: jborean93
body: I could have made the C# def for `OpenProcessAsParent` accept a UInt32 but I find it best to keep all the managed code as a signed integer to avoid confusion as to whether things need to be signed or unsigned. While it's also not a problem in any way technically the unsigned variants are not CLS compliant I don't think there are any .NET languages that don't support them and ultimately it doesn't matter here.

The reason why I need to use this weird conversion code is that pwsh will not cast a uint to int like C# does but rather it'll try and convert the number value to the new type

```powershell
$a = [uint32]'2147483648'

# This fails
[int]$a
# InvalidArgument: Cannot convert value "2147483648" to type "System.Int32". Error: "Value was either too large or too small for an Int32."

# This will produce the twos complement value of the uint
[Convert]::ToInt32(
    [Convert]::ToString($a, 16),
    16)
# -2147483648
```
created_at: 2025-03-16T19:53:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,499 @@
+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(
+            IntPtr 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<string> StdoutReader { get; private set; }
+        public Task<string> StderrReader { get; private set; }
+
+        public ProcessInformation(
+            SafeWaitHandle process,
+            SafeWaitHandle thread,
+            int processId,
+            int threadId,
+            Task<string> stdoutReader,
+            Task<string> 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;
+        }
+    }
+
+    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<NativeHelpers.JOBOBJECT_BASIC_LIMIT_INFORMATION>(),
+                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();
+            }
+
+            IntPtr attrSize = IntPtr.Zero;
+            NativeMethods.InitializeProcThreadAttributeList(IntPtr.Zero, attrCount, 0, ref attrSize);
+
+            bool freeThreadAttributes = false;
+            si.lpAttributeList = Marshal.AllocHGlobal((int)attrSize);
+            try
+            {
+                if (!NativeMethods.InitializeProcThreadAttributeList(si.lpAttributeList, attrCount, 0, ref attrSize))
+                {
+                    throw new Win32Exception("InitializeProcThreadAttributeList() failed");
+                }
+                freeThreadAttributes = true;
path: lib/ansible/module_utils/csharp/Ansible._Async.cs
user: login: jborean93
body: The thread attributes are a bit annoying as there are two things that need to be cleaned up

+ The allocated unmanaged memory
+ The structure values after `InitializeProcThreadAttributeList` is called successfully

We could use a single `SafeHandle` for this but we need still need a way to indicate that it should call `DeleteProcThreadAttributeList` only after a successful call to `InitializeProcThreadAttributeList` was done.

```csharp
IntPtr attrSize = IntPtr.Zero;
NativeMethods.InitializeProcThreadAttributeList(IntPtr.Zero, attrCount, 0, ref attrSize);

using (var attrPtr = new SafeProcThreadAttrList(attrSize))
{
    si.lpAttributeList = attrPtr.DangerousGetHandle();

    if (!NativeMethods.InitializeProcThreadAttributeList(attrPtr, attrCount, 0, ref attrSize))
    {
        throw new Win32Exception("InitializeProcThreadAttributeList() failed");
    }

    // Need some way to indicate that the SafeHandle should also call FreeProcThreadAttributeList
    attrPtr.FreeProcThreadAttrList = true; 

    unsafe
    {
        fixed (...)
        {
            ...
        }
    }
}
```

If we used two SafeHandles; one for the memory, the other for the value itself, it would require more indentation and with the unsafe/fixed indents it is already quite indented.


```csharp
IntPtr attrSize = IntPtr.Zero;
NativeMethods.InitializeProcThreadAttributeList(IntPtr.Zero, attrCount, 0, ref attrSize);

using (var attrPtr = new SafeMemoryHandle(attrSize))  // Calls Marshal.FreeHGlobal
{
    si.lpAttributeList = attrPtr.DangerousGetHandle();

    if (!NativeMethods.InitializeProcThreadAttributeList(attrPtr, attrCount, 0, ref attrSize))
    {
        throw new Win32Exception("InitializeProcThreadAttributeList() failed");
    }

    using (var _ = new SafeProcThreadAttrList(attrPtr))  // Calls FreeProcThreadAttributeList
    {
        unsafe
        {
            fixed (...)
            {
                ...
            }
        }
    }
}
```
created_at: 2025-03-16T20:04:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,499 @@
+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(
+            IntPtr 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<string> StdoutReader { get; private set; }
+        public Task<string> StderrReader { get; private set; }
+
+        public ProcessInformation(
+            SafeWaitHandle process,
+            SafeWaitHandle thread,
+            int processId,
+            int threadId,
+            Task<string> stdoutReader,
+            Task<string> 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;
+        }
+    }
+
+    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<NativeHelpers.JOBOBJECT_BASIC_LIMIT_INFORMATION>(),
+                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();
+            }
+
+            IntPtr attrSize = IntPtr.Zero;
+            NativeMethods.InitializeProcThreadAttributeList(IntPtr.Zero, attrCount, 0, ref attrSize);
+
+            bool freeThreadAttributes = false;
+            si.lpAttributeList = Marshal.AllocHGlobal((int)attrSize);
+            try
+            {
+                if (!NativeMethods.InitializeProcThreadAttributeList(si.lpAttributeList, attrCount, 0, ref attrSize))
+                {
+                    throw new Win32Exception("InitializeProcThreadAttributeList() failed");
+                }
+                freeThreadAttributes = true;
path: lib/ansible/module_utils/csharp/Ansible._Async.cs
user: login: jborean93
body: I went with a helper method that returned a fully initialised SafeHandle so the caller can just do `using (...)`.  The helper method will ensure the allocated memory is cleaned up safely on a failure as well.
created_at: 2025-03-18T02:18:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,174 +1,259 @@
-# (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)
+}
 
-    &chcp.com 65001 > $null
+$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()
 
-    # 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
+    $stdinHandle = $stdinPipe.ClientSafePipeHandle
+    $stdoutHandle = $stdoutPipe.ClientSafePipeHandle
+    $stderrHandle = $stderrPipe.ClientSafePipeHandle
 
-    $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
+    $executablePath = [Process]::GetCurrentProcess().MainModule.FileName
+    # wsmprovhost is used in a PSRP WSMan PSRemoting target, we need to change that
+    # to the proper executable.
+    if ($executablePath -eq "$env:SystemRoot\system32\wsmprovhost.exe") {
+        $executable = if (Get-Variable -Name IsCoreCLR -ValueOnly -ErrorAction SilentlyContinue) {
+            'pwsh.exe'
+        }
+        else {
+            'powershell.exe'
+        }
+        $executablePath = Join-Path -Path $PSHome -ChildPath $executable
     }
-    finally {
-        $pipe.Close()
+
+    # 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"
+        }
+
+        # WMI returns a UInt32, we want the signed equivalent of those bytes.
path: lib/ansible/executor/powershell/async_wrapper.ps1
user: login: nitzmahone
body: Makes sense, I forgot about PS' "helpful" conversion in that case :roll_eyes: .
created_at: 2025-03-18T18:44:19Z
author_association: MEMBER

PR comments:
user: login: webknjaz
created_at: 2025-03-17T14:27:53Z
body: /azp run
author_association: MEMBER
user: login: azure-pipelines[bot]
created_at: 2025-03-17T14:28:06Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE