# Redirected-output hang lab

A Windows PowerShell script launches a native command and captures its stdout.
The command exits, but `StandardOutput.ReadToEnd()` keeps waiting because a quiet
helper inherited the pipe writer. Closing that writer completes the read while
the helper remains alive.

Use Windows 11 x64 and **Windows PowerShell 5.1**. No administrator access or
service installation is needed. The helper exits automatically after 15 minutes.
It never changes machine policy or terminates other processes.

The lab ZIP includes a prebuilt x64 executable and its matching private PDB. Go
straight to **Reproduce** to use those files; MSVC is only needed to rebuild.
The nine files are this README, `ScriptHangLab.cpp`, `Build-Lab.ps1`,
`Run-Lab.ps1`, `Control-Lab.ps1`, `Invoke-WithFileLogs.ps1`, `FileLogRunner.cs`,
`bin\ScriptHangLab.exe`, and `bin\ScriptHangLab.pdb`.

## Rebuild optionally

Install Visual Studio Build Tools with the C++ desktop workload, then run:

```powershell
.\Build-Lab.ps1
```

The build produces `bin\ScriptHangLab.exe` and its matching private PDB, using
`/W4 /WX /Od /Ob0 /Zi /DEBUG:FULL`. Keep the executable and PDB together.
An optional `-OutputDirectory` selects a separate build destination. Either copy
its `bin` directory beside the scripts, or pass `Run-Lab.ps1 -ExecutablePath` the
full path to the resulting executable. Control uses the path saved in that run.

If Windows has marked a downloaded ZIP as coming from the Internet, unblock the
ZIP in its Properties before extracting. Follow your organisation's script policy.

## Reproduce

Open Windows PowerShell in this directory:

```powershell
.\Run-Lab.ps1 -Mode inherited-writer
```

It prints its wrapper PID, a unique run directory and a cleanup command, then
stays at `Collecting command output...`. Only one short line is written to the
pipe, so this case does not fill a pipe buffer. Stderr stays on the console.

For the investigation, start with that wrapper PID. The lab records JSON files
for control and verification, but the debugger route below does not need them.

## Find the command and candidate helper

Follow the blocked managed reader in the PowerShell process using SOS. `!gcroot`
on its `StreamReader`, followed by `!do` on the owning `System.Diagnostics.Process`,
connects that reader to the direct command. On the tested .NET Framework build,
the relevant Process fields are `standardOutput` and `processId`; another runtime
can use different names and layouts.

Use the recovered command PID to query `Win32_Process` through `Get-CimInstance`
for surviving processes with that `ParentProcessId`. Check creation times,
executable paths and command lines. Parent PIDs can be reused; the matching
number alone does not establish which process holds a writer.

Attach to a candidate without loading the lab PDB. `!peb` and the symbol-described
process-parameters structure lead to its current `StandardOutput` handle, which
you can inspect with `!handle`. This locates a candidate writer. It does not prove
that a reader and writer refer to the same pipe or enumerate every duplicate.
A process can replace its standard-handle slot while keeping another copy open.

The article contains the full commands and captured output. Use matching symbols
rather than fixed offsets. Recover the handle from the object or standard-handle
slot rather than assuming an argument register still holds the original value
deep inside `ReadFile`.

### Optional: confirm the native process handle

The `Process` object recovered from the blocked reader was
`0000022065e16f28` in our capture. Its `m_processHandle` field pointed to a
`SafeProcessHandle` at `0000022065e62968`. Follow that object to its native handle:

```text
!do 0000022065e16f28
!do 0000022065e62968
!handle 744 f
```

The second `!do` showed the SafeHandle's `handle` field as `744` (hexadecimal).
The relevant part of `!handle` was:

```text
Handle 744
  Type          Process
  Object Specific Information
    Process Id  24368
    Parent Process  24864
```

That ties the managed object to the command and its wrapper, independently of
the lab's JSON files. The addresses and handle values will differ on another run.
A retained process handle can remain valid after the process exits.

Don't use the private `exited` field as a real process check. It was still false
in this object even though the command had exited: it is cached state, and the
wrapper had not refreshed it by querying the process. In application code, use
the public `HasExited` property or a finite `WaitForExit` on the process you
launched. The handle output above establishes identity; it does not replace that
exit check.

## Close the writer without exiting the helper

After the debugger investigation, open a second Windows PowerShell window and
use the exact run directory printed by the first command:

```powershell
.\Control-Lab.ps1 -RunDirectory 'YOUR-EXACT-RUN-DIRECTORY' -Action CloseWriter
```

The helper acknowledges the close using a named event. The original script can
finish collecting output. Run `Status` again: `WriterClosed=true`, and the helper
is still running. Stop only this lab helper when finished:

```powershell
.\Control-Lab.ps1 -RunDirectory 'YOUR-EXACT-RUN-DIRECTORY' -Action Stop
```

Control waits briefly for startup metadata to become readable, then validates the
executable path, process command line and run GUID before sending an event.
`Stop` requests a graceful exit; there is no forced-kill path. Evidence directories
are kept.

After discovery, `run.json` and `child.json` let you check the process relationships;
`helper.json` records the original writer handle and flags; `wrapper.json` records
the reader handle. Handle values belong to the process that owns them.

## Compare the fixed launch

If the broken run's helper is still running, stop it first using the run directory
printed earlier:

```powershell
$brokenRun = 'YOUR-EXACT-BROKEN-RUN-DIRECTORY'
.\Control-Lab.ps1 -RunDirectory $brokenRun -Action Stop
```

Give the fixed replay its own directory so each run's control commands are
unambiguous:

```powershell
$fixedRun = Join-Path $env:LOCALAPPDATA ('ScriptHangLab\fixed-' + [Guid]::NewGuid().ToString('D'))
.\Run-Lab.ps1 -Mode fixed -RunDirectory $fixedRun
.\Control-Lab.ps1 -RunDirectory $fixedRun -Action Status
.\Control-Lab.ps1 -RunDirectory $fixedRun -Action Stop
```

The native command starts the same quiet helper with handle inheritance disabled.
The recorded fixed runs returned the same short output line and exit code 0,
while Status still showed the helper running:

```text
Output collection completed.
The native command wrote one line and exited.
Native exit code: 0
```

The helper has no output pipe writer. The fixed wrapper can therefore collect
the output without waiting for that helper to exit.

If a real helper needs other inherited handles, use
`PROC_THREAD_ATTRIBUTE_HANDLE_LIST` in a `STARTUPINFOEX` on the call that launches
the helper, including only the handles it needs and configuring its standard I/O
accordingly. Handles in that list must be inheritable, and `CreateProcessW` still
requires `bInheritHandles=TRUE`; the list restricts which handles are inherited.
Pass `EXTENDED_STARTUPINFO_PRESENT` for the extended startup structure. An
allowlist on the wrapper's own child launch does not control what that child
later passes to a helper.

`-Diagnostic` additionally prints the direct child's PID and confirms its exit
using a finite `WaitForExit` before calling `ReadToEnd`:

```powershell
.\Run-Lab.ps1 -Mode inherited-writer -Diagnostic
```

The normal demonstration omits that precheck. Both modes permit an explicit new
or empty `-RunDirectory`; existing evidence is never overwritten.

## Run an executable whose code you cannot change

`Invoke-WithFileLogs.ps1` loads `FileLogRunner.cs` through Windows PowerShell's
`Add-Type`. Keep those files together; no compiler installation is needed.
The runner supplies real file handles for stdout/stderr, NUL for stdin, and an
explicit inherited-handle list. It waits on the direct process handle with a
finite timeout. It does not collect text through a pipe or wait for descendants.

Substitute your vendor executable and its arguments:

```powershell
$run = .\Invoke-WithFileLogs.ps1 `
    -FilePath 'C:\Tools\Vendor\agent.exe' `
    -ArgumentString '--status' `
    -LogDirectory 'C:\Lab\vendor-status' `
    -TimeoutSeconds 60
$run | Format-List
```

| Parameter | Meaning |
| --- | --- |
| `FilePath` | Existing executable path, supplied separately to `CreateProcessW`. |
| `ArgumentString` | Exact Windows argument string, passed unchanged after the quoted executable name. Quote arguments for the target's parser; no shell expansion occurs. |
| `WorkingDirectory` | Existing directory; defaults to the caller's current directory. |
| `LogDirectory` | New or empty directory for `stdout.log` and `stderr.log`; defaults to a unique directory under LocalAppData. Existing logs are never overwritten. |
| `TimeoutSeconds` | Finite direct-process wait from 1 to 86,400 seconds; defaults to 60. |

The result includes `Status`, `Exited`, `TimedOut`, `ProcessId`, creation time,
nullable `ExitCode`, and both log paths. `Exited` means the direct process exited.
It does not mean its descendants finished or the logs are complete. The caller
decides which native exit codes are acceptable; restart-request codes are
preserved too.

A timeout returns `TimedOut=true`, `Exited=false` and no exit code. The target may
still be running. The runner does not kill it or its descendants. Launch/setup
errors throw instead of producing an exit result.

Descendants can keep the file handles and write after the runner returns.
`CompletionScope=DirectProcessOnly` and `LogsMayStillChange=true` state that limit.
The files contain the target's original bytes; use its output encoding when
reading them. If you need a background operation to finish, use the vendor's
documented status or completion signal.

### Optional: follow the file runner's native calls

The implementation is `FileLogRunner.Run` in [FileLogRunner.cs](https://www.chadduffey.com/assets/demos/script-hang-lab/FileLogRunner.cs.txt).
The web preview uses a plain-text alias; the ZIP contains the original
`FileLogRunner.cs` filename.
The calls are small enough to follow in order:

| Call | What it does here |
| --- | --- |
| `CreateFileW` | Creates stdout/stderr with `CREATE_NEW`, refusing to overwrite an existing file, and opens NUL for stdin. The logs permit readers while the target writes. |
| `InitializeProcThreadAttributeList` / `UpdateProcThreadAttribute` | Builds the `STARTUPINFOEX` handle list containing only those three handles. |
| `CreateProcessW` | Starts the requested executable with the real file handles in its standard-I/O slots and `EXTENDED_STARTUPINFO_PRESENT`. The runner then closes its own file-handle copies. |
| `WaitForSingleObject` | Waits a finite interval on the direct process handle. `GetExitCodeProcess` is called only after that wait reports the process signalled. |

`WAIT_TIMEOUT` produces the timeout result described above. Cleanup releases the
runner's process handle; closing that handle does not terminate the target.
There is no output-reader thread whose completion depends on pipe EOF.

An additional native fixture tested the case where a descendant writes after
the direct process exits. The runner returned while the helper remained alive;
both stdout and stderr log files then grew when the helper wrote again. That is
why returning a direct-process exit code cannot promise final log contents.
A separate fast-exit test preserved exit code **3010**, its quoted argument with
spaces, and stderr output.

### Try file logging with the same broken executable

This launches the same `inherited-writer` mode used in the hung script:

```powershell
$id = [Guid]::NewGuid().ToString('D')
$logs = Join-Path $env:LOCALAPPDATA ('ScriptHangLab\file-run-' + $id)
$run = .\Invoke-WithFileLogs.ps1 `
    -FilePath .\bin\ScriptHangLab.exe `
    -ArgumentString ('child inherited-writer ' + $id + ' "' + $logs + '"') `
    -LogDirectory $logs `
    -TimeoutSeconds 10
$run | Format-List
Get-Content -LiteralPath $run.StandardOutputPath
```

The direct command can exit and the runner can return while the helper still
holds its inherited file writer. Stop this particular lab helper directly:

```powershell
& .\bin\ScriptHangLab.exe control $id stop
```

This direct launch does not create the `run.json` expected by `Control-Lab.ps1`.
Use the native control command above for this demonstration's cleanup.

## Source and debugger landmarks

`LaunchHelper` shows the broken and fixed `CreateProcessW` calls. Both helpers
are detached and quiet. Broken mode explicitly forwards stdout and uses NUL for
stdin/stderr; fixed mode inherits no handles and needs no standard I/O.

The broken mode accepts either a pipe or a disk-file stdout, so the same native
executable can demonstrate both the hang and file logging. After discovery,
the matching private PDB makes the source comparison convenient:

```text
~* k
dq ScriptHangLab!gOutputWriter L1
!handle poi(ScriptHangLab!gOutputWriter) f
uf ScriptHangLab!LaunchHelper
uf ScriptHangLab!CloseOutputWriter
```

`WaitForHelperCommands` waits on the close-writer and stop events. `CloseOutputWriter`
invalidates the standard-handle slot, closes the actual writer and signals the
acknowledgement event. Setting a standard-handle slot alone does not close it.
The helper keeps waiting for Stop after closing the writer.

All four named events include the run GUID and use the session's `Local` namespace.
Ready and WriterClosed acknowledgements coordinate the experiment without sleeps.
Metadata supplies an optional cross-check after discovering the relevant
processes and handles through the debugger.

## Limits and useful references

The lab demonstrates one inherited writer on a local Windows anonymous pipe.
Async output reading still needs an output-completion policy. The file runner
chooses direct-process exit; a different command may need its own completion
signal.

Managed API waits vary: parameterless `WaitForExit` can wait for active async
output-event processing, and current runtime source also joins those readers in
`WaitForExitAsync`. Record the actual runtime when adapting the example.

- [Pipe handle inheritance](https://learn.microsoft.com/en-us/windows/win32/ipc/pipe-handle-inheritance)
- [ReadFile pipe closure](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-readfile#pipes)
- [Process.StandardOutput](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.standardoutput)
- [.NET team's handle-inheritance explanation](https://devblogs.microsoft.com/dotnet/process-api-improvements-in-dotnet-11/#handle-inheritance)
- [Explicit inherited-handle lists](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute)
- [CreateFileW](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew)
- [Waiting on a process handle](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject)
