Skip to content

Commit a5f9bfa

Browse files
JohnMcPMSCopilot
authored andcommitted
Fix stdin inherited by child processes when no input provided
When invoke_command is called with input = None, the child process previously inherited the parent's stdin handle. In CI environments where the parent has a redirected pipe for stdin, this caused PowerShell child processes to block indefinitely on `$Input` (reading from the inherited pipe that never closes). The fix sets stdin to Stdio::null() when no input is provided, ensuring the child process sees an immediate EOF rather than inheriting whatever stdin handle the parent holds. Root cause: commit 1af2c8b changed the PowerShell adapter from "config": "full" to "config": "single", which routes export through a code path that calls invoke_command with no input. Previously, the full-config path always provided input so stdin was always piped. Also adds: - Integration test that detects this regression in all environments (terminal and CI) without leaving hanging threads - A -RustTestFilter parameter to build.ps1 / Test-RustProject to allow running a specific Rust test by name via the build script Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 023d50c commit a5f9bfa

5 files changed

Lines changed: 78 additions & 2 deletions

File tree

build.ps1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ param(
8989
[switch]$Test,
9090
[string[]]$Project,
9191
[switch]$ExcludeRustTests,
92+
[string]$RustTestFilter,
9293
[switch]$ExcludePesterTests,
9394
[ValidateSet("dsc", "adapters", "extensions", "grammars", "resources")]
9495
[string[]]$PesterTestGroup,
@@ -266,6 +267,9 @@ process {
266267
Architecture = $Architecture
267268
Release = $Release
268269
}
270+
if (-not [string]::IsNullOrEmpty($RustTestFilter)) {
271+
$rustTestParams.TestFilter = $RustTestFilter
272+
}
269273
Write-BuildProgress @progressParams -Status "Testing Rust projects"
270274
Test-RustProject @rustTestParams @VerboseParam
271275
}

helpers.build.psm1

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1798,7 +1798,8 @@ function Test-RustProject {
17981798
[ValidateSet('current','aarch64-pc-windows-msvc','x86_64-pc-windows-msvc','aarch64-apple-darwin','x86_64-apple-darwin','aarch64-unknown-linux-gnu','aarch64-unknown-linux-musl','x86_64-unknown-linux-gnu','x86_64-unknown-linux-musl')]
17991799
$Architecture = 'current',
18001800
[switch]$Release,
1801-
[switch]$Docs
1801+
[switch]$Docs,
1802+
[string]$TestFilter
18021803
)
18031804

18041805
begin {
@@ -1828,7 +1829,11 @@ function Test-RustProject {
18281829
} else {
18291830
Write-Verbose -Verbose "Testing rust projects: [$members]"
18301831
}
1831-
cargo test @flags
1832+
if (-not [string]::IsNullOrEmpty($TestFilter)) {
1833+
cargo test @flags -- $TestFilter
1834+
} else {
1835+
cargo test @flags
1836+
}
18321837

18331838
if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) {
18341839
Write-Error "Last exit code is $LASTEXITCODE, rust tests failed"

lib/dsc-lib/src/dscresources/command_resource.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,8 @@ async fn run_process_async(executable: &str, args: Option<Vec<String>>, input: O
767767
let mut command = Command::new(executable);
768768
if input.is_some() {
769769
command.stdin(Stdio::piped());
770+
} else {
771+
command.stdin(Stdio::null());
770772
}
771773
command.stdout(Stdio::piped());
772774
command.stderr(Stdio::piped());
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
}

lib/dsc-lib/tests/integration/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@
1313
//! minimize compilation times. If we defined the tests one level higher in the `tests` folder,
1414
//! Rust would generate numerous binaries to execute our tests.
1515
16+
#[cfg(test)] mod command_resource;
1617
#[cfg(test)] mod schemas;
1718
#[cfg(test)] mod types;

0 commit comments

Comments
 (0)