|
| 1 | +// Copyright (c) Microsoft Corporation. |
| 2 | +// Licensed under the MIT License. |
| 3 | + |
| 4 | +#[cfg(test)] |
| 5 | +mod invoke_command { |
| 6 | + use dsc_lib::{dscresources::command_resource::invoke_command, types::ExitCodesMap}; |
| 7 | + |
| 8 | + /// Verifies that when `invoke_command` is called with `input = None`, the child process |
| 9 | + /// receives an immediate EOF on stdin (i.e., stdin is set to null) rather than inheriting |
| 10 | + /// the parent's stdin handle. |
| 11 | + /// |
| 12 | + /// This is a regression test for the hang introduced in DSC 3.2.0 when the PowerShell |
| 13 | + /// adapter changed from `"config": "full"` to `"config": "single"`. In single mode, the |
| 14 | + /// adapter's export operation is called with no input, leaving stdin unset in the previous |
| 15 | + /// code. Child processes that read from stdin would then block indefinitely when the parent |
| 16 | + /// process itself had an open stdin handle — either a TTY in a terminal or a pipe in CI. |
| 17 | + /// |
| 18 | + /// The test uses a timed async read rather than a blocking read so that the child process |
| 19 | + /// always exits within a bounded time. If stdin is null (the fix), `ReadAsync` completes |
| 20 | + /// immediately returning 0 bytes (EOF), which maps to -1. If stdin is inherited (the bug), |
| 21 | + /// `ReadAsync` blocks until the timeout fires and the test receives -2, which fails the |
| 22 | + /// assertion. |
| 23 | + #[test] |
| 24 | + fn no_input_does_not_block_on_stdin() { |
| 25 | + let exit_codes = ExitCodesMap::default(); |
| 26 | + |
| 27 | + // Use PowerShell's own async timeout so the child process always exits within ~5s, |
| 28 | + // regardless of fix status. We never leave a hanging thread: |
| 29 | + // byte:-1 → ReadAsync got EOF immediately → stdin was null → PASS |
| 30 | + // byte:-2 → ReadAsync timed out (5 s) → stdin was NOT null → FAIL |
| 31 | + let ps_command = concat!( |
| 32 | + "$reader = [Console]::OpenStandardInput();", |
| 33 | + "$buf = [byte[]]::new(1);", |
| 34 | + "$task = $reader.ReadAsync($buf, 0, 1);", |
| 35 | + "$completed = $task.Wait(5000);", |
| 36 | + "$b = if ($completed) { if ($task.Result -eq 0) { -1 } else { $buf[0] } } else { -2 };", |
| 37 | + "Write-Output \"byte:$b\"" |
| 38 | + ); |
| 39 | + |
| 40 | + let result = invoke_command( |
| 41 | + "pwsh", |
| 42 | + Some(vec![ |
| 43 | + "-NonInteractive".to_string(), |
| 44 | + "-NoProfile".to_string(), |
| 45 | + "-Command".to_string(), |
| 46 | + ps_command.to_string(), |
| 47 | + ]), |
| 48 | + None, // no input — the scenario that caused the hang |
| 49 | + None, |
| 50 | + None, |
| 51 | + &exit_codes, |
| 52 | + ).expect("invoke_command should succeed"); |
| 53 | + |
| 54 | + let (exit_code, stdout, _stderr) = result; |
| 55 | + assert_eq!(exit_code, 0, "Command should exit 0"); |
| 56 | + // -1 means ReadAsync got EOF immediately, confirming stdin was set to null. |
| 57 | + // -2 means stdin was open (inherited) and the read timed out after 5s. |
| 58 | + assert!( |
| 59 | + stdout.contains("byte:-1"), |
| 60 | + "Expected EOF (byte:-1) from null stdin, got: {stdout:?}\n\ |
| 61 | + 'byte:-2' means stdin was inherited from the parent rather than set to null." |
| 62 | + ); |
| 63 | + } |
| 64 | +} |
0 commit comments