From 5dfe5aea1999fd79eb93c172cfe7ad63d35dd814 Mon Sep 17 00:00:00 2001 From: SufficientDaikon Date: Sat, 28 Mar 2026 14:40:52 +0200 Subject: [PATCH 1/7] Fix -WindowStyle Hidden console window flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use consoleAllocationPolicy=detached manifest and AllocConsoleWithOptions to prevent the OS from auto-allocating a visible console window on newer Windows. On older Windows the manifest is ignored and behavior is unchanged. On Windows 11 build 26100+, the detached policy stops the OS from creating a console window before any code runs. PowerShell now allocates the console itself at the earliest point in startup — visibly for interactive use, or invisibly via AllocConsoleWithOptions(NoWindow) when -WindowStyle Hidden is specified. This approach was recommended by @DHowett (Windows Console team) in https://github.com/PowerShell/PowerShell/issues/3028#issuecomment-675714111 Fix #3028 --- assets/pwsh.manifest | 5 + .../host/msh/ConsoleControl.cs | 9 +- .../host/msh/ManagedEntrance.cs | 109 ++++++++++++++++++ .../Windows/AllocConsoleWithOptions.cs | 39 +++++++ .../engine/NativeCommandProcessor.cs | 41 +++++-- .../Host/WindowStyleHidden.Tests.ps1 | 93 +++++++++++++++ 6 files changed, 283 insertions(+), 13 deletions(-) create mode 100644 src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs create mode 100644 test/powershell/Host/WindowStyleHidden.Tests.ps1 diff --git a/assets/pwsh.manifest b/assets/pwsh.manifest index 428ea914360..2fcfdd63861 100644 --- a/assets/pwsh.manifest +++ b/assets/pwsh.manifest @@ -23,4 +23,9 @@ + + + detached + + diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleControl.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleControl.cs index 7bda4bc5688..0fd6021d7db 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleControl.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleControl.cs @@ -420,7 +420,14 @@ internal enum KeyboardFlag : uint internal static void SetConsoleMode(ProcessWindowStyle style) { IntPtr hwnd = GetConsoleWindow(); - Dbg.Assert(hwnd != IntPtr.Zero, "Console handle should never be zero"); + if (hwnd == IntPtr.Zero) + { + // No console window to modify. This can happen when running with + // consoleAllocationPolicy=detached before the console is allocated, + // or in a GUI-hosted scenario. + return; + } + switch (style) { case ProcessWindowStyle.Hidden: diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs index 6dfd5d54e6f..c4f7cf1f821 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs @@ -51,6 +51,15 @@ public static int Start([MarshalAs(UnmanagedType.LPArray, ArraySubType = Unmanag { ArgumentNullException.ThrowIfNull(args); +#if !UNIX + // On Windows with consoleAllocationPolicy=detached in the manifest, + // no console is auto-allocated by the OS. We must allocate one ourselves + // before anything touches CONOUT$/CONIN$ handles. + // On older Windows the manifest element is ignored and this is a no-op + // (AllocConsole returns false when a console already exists). + EarlyConsoleInit(args); +#endif + #if DEBUG if (args.Length > 0 && !string.IsNullOrEmpty(args[0]) && args[0]!.Equals("-isswait", StringComparison.OrdinalIgnoreCase)) { @@ -120,5 +129,105 @@ public static int Start([MarshalAs(UnmanagedType.LPArray, ArraySubType = Unmanag return exitCode; } + +#if !UNIX + /// + /// Allocates a console early in startup to support consoleAllocationPolicy=detached. + /// On newer Windows (with the detached policy active), the OS does not auto-allocate + /// a console for CUI apps. On older Windows, AllocConsole() returns false (no-op). + /// + private static void EarlyConsoleInit(string[] args) + { + nint existingConsole = Interop.Windows.GetConsoleWindow(); + if (existingConsole != nint.Zero) + { + // Console already exists (inherited from parent or auto-allocated on older Windows). + // If -WindowStyle Hidden was requested, hide the window at the earliest possible moment + // to minimize the flash on older Windows where the detached policy is not supported. + if (EarlyCheckForHiddenWindowStyle(args)) + { + Interop.Windows.ShowWindow(existingConsole, Interop.Windows.SW_HIDE); + } + + return; + } + + // No console exists. This means the detached policy is active (newer Windows) + // and we were launched without console inheritance (e.g. from Explorer, Task Scheduler). + if (EarlyCheckForHiddenWindowStyle(args)) + { + // Hidden: allocate an invisible console session so CONOUT$/CONIN$ work + // (Write-Host, native commands, etc.) but no window is ever shown. + if (!TryAllocConsoleNoWindow()) + { + // Fallback (should not happen since we only reach here on newer Windows, + // but be defensive): alloc + hide. + Interop.Windows.AllocConsole(); + nint hwnd = Interop.Windows.GetConsoleWindow(); + if (hwnd != nint.Zero) + { + Interop.Windows.ShowWindow(hwnd, Interop.Windows.SW_HIDE); + } + } + } + else + { + // Normal interactive launch: allocate a visible console. + Interop.Windows.AllocConsole(); + } + } + + /// + /// Minimal early scan for -WindowStyle Hidden in command line args. + /// Matches any unambiguous prefix of "windowstyle" starting from "w" + /// (e.g. -w, -wi, -win, ..., -windowstyle) followed by "hidden". + /// + private static bool EarlyCheckForHiddenWindowStyle(string[] args) + { + for (int i = 0; i < args.Length - 1; i++) + { + string arg = args[i]; + if (arg.Length >= 2 && (arg[0] == '-' || arg[0] == '/')) + { + string key = arg.Substring(1); + if (key.Length >= 1 + && key.Length <= "windowstyle".Length + && "windowstyle".StartsWith(key, StringComparison.OrdinalIgnoreCase)) + { + if (args[i + 1].Equals("hidden", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + + return false; + } + + /// + /// Attempts to allocate a console without a visible window using AllocConsoleWithOptions. + /// Returns false if the API is not available (older Windows). + /// + private static bool TryAllocConsoleNoWindow() + { + try + { + var options = new Interop.Windows.AllocConsoleOptions + { + Mode = Interop.Windows.AllocConsoleMode.NoWindow, + UseShowWindow = 0, + ShowWindow = 0, + }; + + int hr = Interop.Windows.AllocConsoleWithOptions(ref options, out _); + return hr >= 0; // S_OK + } + catch (EntryPointNotFoundException) + { + return false; + } + } +#endif } } diff --git a/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs b/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs new file mode 100644 index 00000000000..7b16f8bc941 --- /dev/null +++ b/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System.Runtime.InteropServices; + +internal static partial class Interop +{ + internal static unsafe partial class Windows + { + internal enum AllocConsoleMode : int + { + Default = 0, + NewWindow = 1, + NoWindow = 2, + } + + internal enum AllocConsoleResult : int + { + NoConsole = 0, + NewConsole = 1, + ExistingConsole = 2, + } + + [StructLayout(LayoutKind.Sequential)] + internal struct AllocConsoleOptions + { + public AllocConsoleMode Mode; + public int UseShowWindow; + public ushort ShowWindow; + } + + [LibraryImport("kernel32.dll")] + internal static partial int AllocConsoleWithOptions( + ref AllocConsoleOptions allocOptions, + out AllocConsoleResult result); + } +} diff --git a/src/System.Management.Automation/engine/NativeCommandProcessor.cs b/src/System.Management.Automation/engine/NativeCommandProcessor.cs index 145fe968fda..e94e8284660 100644 --- a/src/System.Management.Automation/engine/NativeCommandProcessor.cs +++ b/src/System.Management.Automation/engine/NativeCommandProcessor.cs @@ -2489,30 +2489,47 @@ internal static bool AllocateHiddenConsole() // save the foreground window since allocating a console window might remove focus from it IntPtr savedForeground = Interop.Windows.GetForegroundWindow(); - // Since there is no console window, allocate and then hide it... - // Suppress the PreFAST warning about not using Marshal.GetLastWin32Error() to - // get the error code. - Interop.Windows.AllocConsole(); - hwnd = Interop.Windows.GetConsoleWindow(); + // Try AllocConsoleWithOptions with NoWindow mode first to avoid the flash + // that the AllocConsole() + ShowWindow(SW_HIDE) pattern causes. + bool allocated = false; + try + { + var options = new Interop.Windows.AllocConsoleOptions + { + Mode = Interop.Windows.AllocConsoleMode.NoWindow, + UseShowWindow = 0, + ShowWindow = 0, + }; - bool returnValue; - if (hwnd == nint.Zero) + int hr = Interop.Windows.AllocConsoleWithOptions(ref options, out _); + allocated = hr >= 0; + } + catch (EntryPointNotFoundException) { - returnValue = false; + // AllocConsoleWithOptions not available on this Windows version. } - else + + if (!allocated) { - returnValue = true; + // Fallback for older Windows: allocate and then hide. + Interop.Windows.AllocConsole(); + hwnd = Interop.Windows.GetConsoleWindow(); + if (hwnd == nint.Zero) + { + return false; + } + Interop.Windows.ShowWindow(hwnd, Interop.Windows.SW_HIDE); - AlwaysCaptureApplicationIO = true; } + AlwaysCaptureApplicationIO = true; + if (savedForeground != nint.Zero && Interop.Windows.GetForegroundWindow() != savedForeground) { Interop.Windows.SetForegroundWindow(savedForeground); } - return returnValue; + return true; #endif } } diff --git a/test/powershell/Host/WindowStyleHidden.Tests.ps1 b/test/powershell/Host/WindowStyleHidden.Tests.ps1 new file mode 100644 index 00000000000..c561890511a --- /dev/null +++ b/test/powershell/Host/WindowStyleHidden.Tests.ps1 @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'WindowStyle Hidden console flash fix (Issue #3028)' -Tag 'CI' { + + BeforeAll { + $powershell = Join-Path -Path $PSHOME -ChildPath 'pwsh' + } + + Context 'Manifest contains consoleAllocationPolicy' { + It 'pwsh.manifest declares consoleAllocationPolicy as detached' { + $manifestPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.manifest' + if (Test-Path $manifestPath) { + $content = Get-Content $manifestPath -Raw + $content | Should -Match 'consoleAllocationPolicy' + $content | Should -Match 'detached' + } else { + # Manifest is embedded in the binary at build time; skip file check. + Set-ItResult -Skipped -Because 'manifest is embedded in binary' + } + } + } + + Context 'WindowStyle Hidden produces correct output' -Skip:(!$IsWindows) { + It 'captures output from -WindowStyle Hidden -Command' { + $output = & $powershell -NoProfile -WindowStyle Hidden -Command "'hello'" + $output | Should -Be 'hello' + } + + It 'captures pipeline output from -WindowStyle Hidden' { + $output = & $powershell -NoProfile -WindowStyle Hidden -Command '1..3 | ForEach-Object { $_ * 2 }' + $output.Count | Should -Be 3 + $output[0] | Should -Be 2 + $output[1] | Should -Be 4 + $output[2] | Should -Be 6 + } + + It 'Write-Host works under -WindowStyle Hidden' { + # Write-Host writes to the information stream; capture via 6>&1. + $output = & $powershell -NoProfile -WindowStyle Hidden -Command 'Write-Host "test-output"' 6>&1 + ($output | Out-String) | Should -Match 'test-output' + } + + It 'exits with correct exit code under -WindowStyle Hidden' { + & $powershell -NoProfile -WindowStyle Hidden -Command 'exit 42' + $LASTEXITCODE | Should -Be 42 + } + } + + Context 'AllocConsoleWithOptions API availability' -Skip:(!$IsWindows) { + It 'detects AllocConsoleWithOptions on supported Windows builds' { + $code = @' +using System; +using System.Runtime.InteropServices; +public static class ConsoleApiProbe { + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); + [DllImport("kernel32.dll")] + public static extern IntPtr GetModuleHandle(string lpModuleName); + public static bool IsAllocConsoleWithOptionsAvailable() { + IntPtr k32 = GetModuleHandle("kernel32.dll"); + if (k32 == IntPtr.Zero) return false; + IntPtr addr = GetProcAddress(k32, "AllocConsoleWithOptions"); + return addr != IntPtr.Zero; + } +} +'@ + Add-Type -TypeDefinition $code + $available = [ConsoleApiProbe]::IsAllocConsoleWithOptionsAvailable() + + # On Windows 11 26100+, the API should be available. + $build = [System.Environment]::OSVersion.Version.Build + if ($build -ge 26100) { + $available | Should -BeTrue + } else { + # On older builds, just verify the probe doesn't crash. + $available | Should -BeOfType [bool] + } + } + } + + Context 'Normal startup is unaffected' -Skip:(!$IsWindows) { + It 'starts and runs a command without -WindowStyle' { + $output = & $powershell -NoProfile -Command '$PSVersionTable.PSEdition' + $output | Should -Be 'Core' + } + + It 'handles -WindowStyle Normal without error' { + $output = & $powershell -NoProfile -WindowStyle Normal -Command "'normal-test'" + $output | Should -Be 'normal-test' + } + } +} From 4b4a8ef1d179aa98b2ef00deed1ce1bccbaef90c Mon Sep 17 00:00:00 2001 From: SufficientDaikon Date: Sat, 28 Mar 2026 15:19:12 +0200 Subject: [PATCH 2/7] Address review: zero-alloc arg scan, shared helper, test cleanup - Replace Substring(1) with AsSpan(1) for zero-alloc early arg parsing - Extract TryAllocConsoleNoWindow() into Interop.Windows (DRY) - Add XML doc comments to AllocConsoleWithOptions enums and struct - Document colon-syntax and false-positive behavior in arg scanner - Change test tag from CI to Feature (matches existing WindowStyle tests) - Remove hardcoded build number from API probe test - Add -ErrorAction Stop to Add-Type in tests - Use double quotes consistently in test file --- .../host/msh/ManagedEntrance.cs | 34 ++------- .../Windows/AllocConsoleWithOptions.cs | 43 +++++++++++ .../engine/NativeCommandProcessor.cs | 18 +---- .../Host/WindowStyleHidden.Tests.ps1 | 73 +++++++++---------- 4 files changed, 86 insertions(+), 82 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs index c4f7cf1f821..430c2f48e84 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs @@ -158,7 +158,7 @@ private static void EarlyConsoleInit(string[] args) { // Hidden: allocate an invisible console session so CONOUT$/CONIN$ work // (Write-Host, native commands, etc.) but no window is ever shown. - if (!TryAllocConsoleNoWindow()) + if (!Interop.Windows.TryAllocConsoleNoWindow()) { // Fallback (should not happen since we only reach here on newer Windows, // but be defensive): alloc + hide. @@ -181,6 +181,11 @@ private static void EarlyConsoleInit(string[] args) /// Minimal early scan for -WindowStyle Hidden in command line args. /// Matches any unambiguous prefix of "windowstyle" starting from "w" /// (e.g. -w, -wi, -win, ..., -windowstyle) followed by "hidden". + /// This is a best-effort check that runs before the full parser. False positives + /// (e.g. a hypothetical future -w parameter) are acceptable because the worst case + /// is allocating a hidden console that the full parser would later show. The colon + /// syntax (-windowstyle:hidden) is intentionally not handled here; the full parser + /// handles it later and the existing ShowWindow(SW_HIDE) path covers that case. /// private static bool EarlyCheckForHiddenWindowStyle(string[] args) { @@ -189,10 +194,10 @@ private static bool EarlyCheckForHiddenWindowStyle(string[] args) string arg = args[i]; if (arg.Length >= 2 && (arg[0] == '-' || arg[0] == '/')) { - string key = arg.Substring(1); + ReadOnlySpan key = arg.AsSpan(1); if (key.Length >= 1 && key.Length <= "windowstyle".Length - && "windowstyle".StartsWith(key, StringComparison.OrdinalIgnoreCase)) + && "windowstyle".AsSpan().StartsWith(key, StringComparison.OrdinalIgnoreCase)) { if (args[i + 1].Equals("hidden", StringComparison.OrdinalIgnoreCase)) { @@ -205,29 +210,6 @@ private static bool EarlyCheckForHiddenWindowStyle(string[] args) return false; } - /// - /// Attempts to allocate a console without a visible window using AllocConsoleWithOptions. - /// Returns false if the API is not available (older Windows). - /// - private static bool TryAllocConsoleNoWindow() - { - try - { - var options = new Interop.Windows.AllocConsoleOptions - { - Mode = Interop.Windows.AllocConsoleMode.NoWindow, - UseShowWindow = 0, - ShowWindow = 0, - }; - - int hr = Interop.Windows.AllocConsoleWithOptions(ref options, out _); - return hr >= 0; // S_OK - } - catch (EntryPointNotFoundException) - { - return false; - } - } #endif } } diff --git a/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs b/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs index 7b16f8bc941..a5ce759ffff 100644 --- a/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs +++ b/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs @@ -3,31 +3,50 @@ #nullable enable +using System; using System.Runtime.InteropServices; internal static partial class Interop { internal static unsafe partial class Windows { + /// Console allocation mode for AllocConsoleWithOptions. internal enum AllocConsoleMode : int { + /// Allocate only if the parent process requested it. Default = 0, + + /// Force allocation of a console with a visible window. NewWindow = 1, + + /// Allocate console I/O handles without creating a visible window. NoWindow = 2, } + /// Result of an AllocConsoleWithOptions call. internal enum AllocConsoleResult : int { + /// No console was allocated. NoConsole = 0, + + /// A new console session was created. NewConsole = 1, + + /// An existing console session was attached. ExistingConsole = 2, } + /// Options struct passed to AllocConsoleWithOptions. [StructLayout(LayoutKind.Sequential)] internal struct AllocConsoleOptions { + /// The allocation mode (Default, NewWindow, or NoWindow). public AllocConsoleMode Mode; + + /// If non-zero, the ShowWindow field is used as the initial show state. public int UseShowWindow; + + /// The initial show state (e.g. SW_HIDE) when UseShowWindow is set. public ushort ShowWindow; } @@ -35,5 +54,29 @@ internal struct AllocConsoleOptions internal static partial int AllocConsoleWithOptions( ref AllocConsoleOptions allocOptions, out AllocConsoleResult result); + + /// + /// Attempts to allocate a console without a visible window using AllocConsoleWithOptions. + /// Returns false if the API is not available (older Windows) or the call fails. + /// + internal static bool TryAllocConsoleNoWindow() + { + try + { + var options = new AllocConsoleOptions + { + Mode = AllocConsoleMode.NoWindow, + UseShowWindow = 0, + ShowWindow = 0, + }; + + int hr = AllocConsoleWithOptions(ref options, out _); + return hr >= 0; // S_OK + } + catch (EntryPointNotFoundException) + { + return false; + } + } } } diff --git a/src/System.Management.Automation/engine/NativeCommandProcessor.cs b/src/System.Management.Automation/engine/NativeCommandProcessor.cs index e94e8284660..1259eb6828a 100644 --- a/src/System.Management.Automation/engine/NativeCommandProcessor.cs +++ b/src/System.Management.Automation/engine/NativeCommandProcessor.cs @@ -2491,23 +2491,7 @@ internal static bool AllocateHiddenConsole() // Try AllocConsoleWithOptions with NoWindow mode first to avoid the flash // that the AllocConsole() + ShowWindow(SW_HIDE) pattern causes. - bool allocated = false; - try - { - var options = new Interop.Windows.AllocConsoleOptions - { - Mode = Interop.Windows.AllocConsoleMode.NoWindow, - UseShowWindow = 0, - ShowWindow = 0, - }; - - int hr = Interop.Windows.AllocConsoleWithOptions(ref options, out _); - allocated = hr >= 0; - } - catch (EntryPointNotFoundException) - { - // AllocConsoleWithOptions not available on this Windows version. - } + bool allocated = Interop.Windows.TryAllocConsoleNoWindow(); if (!allocated) { diff --git a/test/powershell/Host/WindowStyleHidden.Tests.ps1 b/test/powershell/Host/WindowStyleHidden.Tests.ps1 index c561890511a..cee43e5c188 100644 --- a/test/powershell/Host/WindowStyleHidden.Tests.ps1 +++ b/test/powershell/Host/WindowStyleHidden.Tests.ps1 @@ -1,55 +1,55 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'WindowStyle Hidden console flash fix (Issue #3028)' -Tag 'CI' { +Describe "WindowStyle Hidden console flash fix (Issue #3028)" -Tag "Feature" { BeforeAll { - $powershell = Join-Path -Path $PSHOME -ChildPath 'pwsh' + $powershell = Join-Path -Path $PSHOME -ChildPath "pwsh" } - Context 'Manifest contains consoleAllocationPolicy' { - It 'pwsh.manifest declares consoleAllocationPolicy as detached' { - $manifestPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.manifest' + Context "Manifest contains consoleAllocationPolicy" { + It "pwsh.manifest declares consoleAllocationPolicy as detached" -Skip:(!$IsWindows) { + # The manifest is embedded into the PE at build time. Check the source file + # if available; otherwise read the embedded manifest via System.Reflection. + $manifestPath = Join-Path -Path $PSHOME -ChildPath "pwsh.manifest" if (Test-Path $manifestPath) { $content = Get-Content $manifestPath -Raw - $content | Should -Match 'consoleAllocationPolicy' - $content | Should -Match 'detached' + $content | Should -Match "consoleAllocationPolicy" + $content | Should -Match "detached" } else { - # Manifest is embedded in the binary at build time; skip file check. - Set-ItResult -Skipped -Because 'manifest is embedded in binary' + Set-ItResult -Skipped -Because "manifest is embedded in binary and cannot be inspected" } } } - Context 'WindowStyle Hidden produces correct output' -Skip:(!$IsWindows) { - It 'captures output from -WindowStyle Hidden -Command' { + Context "WindowStyle Hidden produces correct output" -Skip:(!$IsWindows) { + It "captures output from -WindowStyle Hidden -Command" { $output = & $powershell -NoProfile -WindowStyle Hidden -Command "'hello'" - $output | Should -Be 'hello' + $output | Should -Be "hello" } - It 'captures pipeline output from -WindowStyle Hidden' { - $output = & $powershell -NoProfile -WindowStyle Hidden -Command '1..3 | ForEach-Object { $_ * 2 }' + It "captures pipeline output from -WindowStyle Hidden" { + $output = & $powershell -NoProfile -WindowStyle Hidden -Command "1..3 | ForEach-Object { `$_ * 2 }" $output.Count | Should -Be 3 $output[0] | Should -Be 2 $output[1] | Should -Be 4 $output[2] | Should -Be 6 } - It 'Write-Host works under -WindowStyle Hidden' { - # Write-Host writes to the information stream; capture via 6>&1. - $output = & $powershell -NoProfile -WindowStyle Hidden -Command 'Write-Host "test-output"' 6>&1 - ($output | Out-String) | Should -Match 'test-output' + It "Write-Host works under -WindowStyle Hidden" { + $output = & $powershell -NoProfile -WindowStyle Hidden -Command "Write-Host 'test-output'" 6>&1 + ($output | Out-String) | Should -Match "test-output" } - It 'exits with correct exit code under -WindowStyle Hidden' { - & $powershell -NoProfile -WindowStyle Hidden -Command 'exit 42' + It "exits with correct exit code under -WindowStyle Hidden" { + & $powershell -NoProfile -WindowStyle Hidden -Command "exit 42" $LASTEXITCODE | Should -Be 42 } } - Context 'AllocConsoleWithOptions API availability' -Skip:(!$IsWindows) { - It 'detects AllocConsoleWithOptions on supported Windows builds' { - $code = @' + Context "AllocConsoleWithOptions API probe" -Skip:(!$IsWindows) { + It "detects AllocConsoleWithOptions availability without error" { + $code = @" using System; using System.Runtime.InteropServices; public static class ConsoleApiProbe { @@ -64,30 +64,25 @@ public static class ConsoleApiProbe { return addr != IntPtr.Zero; } } -'@ - Add-Type -TypeDefinition $code +"@ + Add-Type -TypeDefinition $code -ErrorAction Stop $available = [ConsoleApiProbe]::IsAllocConsoleWithOptionsAvailable() - # On Windows 11 26100+, the API should be available. - $build = [System.Environment]::OSVersion.Version.Build - if ($build -ge 26100) { - $available | Should -BeTrue - } else { - # On older builds, just verify the probe doesn't crash. - $available | Should -BeOfType [bool] - } + # Verify the probe returns a valid result; the actual availability + # depends on the Windows build running the test. + $available | Should -BeOfType [bool] } } - Context 'Normal startup is unaffected' -Skip:(!$IsWindows) { - It 'starts and runs a command without -WindowStyle' { - $output = & $powershell -NoProfile -Command '$PSVersionTable.PSEdition' - $output | Should -Be 'Core' + Context "Normal startup is unaffected" -Skip:(!$IsWindows) { + It "starts and runs a command without -WindowStyle" { + $output = & $powershell -NoProfile -Command "`$PSVersionTable.PSEdition" + $output | Should -Be "Core" } - It 'handles -WindowStyle Normal without error' { + It "handles -WindowStyle Normal without error" { $output = & $powershell -NoProfile -WindowStyle Normal -Command "'normal-test'" - $output | Should -Be 'normal-test' + $output | Should -Be "normal-test" } } } From 1a86fe68aed3157efc6a3f87be6fcc792143c852 Mon Sep 17 00:00:00 2001 From: SufficientDaikon Date: Sat, 28 Mar 2026 18:41:19 +0200 Subject: [PATCH 3/7] Use AllocConsoleWithOptions(Default) for normal launches Per DHowett's feedback: plain AllocConsole() overrides DETACHED_PROCESS from the parent's CreateProcess call. AllocConsoleWithOptions with Default mode respects it. Extract shared TryAllocConsoleWithMode() and add TryAllocConsoleDefault() alongside TryAllocConsoleNoWindow(). Co-Authored-By: Claude Opus 4.6 --- .../host/msh/ManagedEntrance.cs | 8 +++++++- .../Interop/Windows/AllocConsoleWithOptions.cs | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs index 430c2f48e84..0d1aa333034 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs @@ -173,7 +173,13 @@ private static void EarlyConsoleInit(string[] args) else { // Normal interactive launch: allocate a visible console. - Interop.Windows.AllocConsole(); + // Use AllocConsoleWithOptions(Default) when available — it respects + // DETACHED_PROCESS from the parent's CreateProcess call, whereas + // plain AllocConsole() would override it and force-create a console. + if (!Interop.Windows.TryAllocConsoleDefault()) + { + Interop.Windows.AllocConsole(); + } } } diff --git a/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs b/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs index a5ce759ffff..bef0cde3be4 100644 --- a/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs +++ b/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs @@ -60,12 +60,28 @@ internal static partial int AllocConsoleWithOptions( /// Returns false if the API is not available (older Windows) or the call fails. /// internal static bool TryAllocConsoleNoWindow() + { + return TryAllocConsoleWithMode(AllocConsoleMode.NoWindow); + } + + /// + /// Attempts to allocate a console using AllocConsoleWithOptions with Default mode. + /// Default mode respects DETACHED_PROCESS from the parent's CreateProcess call, + /// whereas plain AllocConsole() would override it and force-create a console. + /// Returns false if the API is not available (older Windows) or the call fails. + /// + internal static bool TryAllocConsoleDefault() + { + return TryAllocConsoleWithMode(AllocConsoleMode.Default); + } + + private static bool TryAllocConsoleWithMode(AllocConsoleMode mode) { try { var options = new AllocConsoleOptions { - Mode = AllocConsoleMode.NoWindow, + Mode = mode, UseShowWindow = 0, ShowWindow = 0, }; From 86735adfd87f20866d409a03826ce1f2d5f90a24 Mon Sep 17 00:00:00 2001 From: SufficientDaikon Date: Sat, 28 Mar 2026 20:20:42 +0200 Subject: [PATCH 4/7] Handle --windowstyle double-dash in early scan, expand tests The early arg scan only stripped one leading dash, so --windowstyle hidden was not detected (the key became "-windowstyle" with length 12, failing the <= "windowstyle".Length check). Add double-dash stripping to match the full parser's GetSwitchKey behavior. Remove misleading comment claiming colon syntax is handled by the full parser (GetSwitchKey does not split on colons for windowstyle). Rewrite manifest test to extract embedded manifest from PE binary instead of checking a source file that doesn't exist in $PSHOME. Add 5 early arg scan variant tests: -w, -win, --windowstyle, /windowstyle, UPPERCASE. Total: 12 Pester tests. Co-Authored-By: Claude Opus 4.6 --- .../host/msh/ManagedEntrance.cs | 16 +++-- .../Host/WindowStyleHidden.Tests.ps1 | 69 ++++++++++++++++--- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs index 0d1aa333034..7bec59b5622 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs @@ -186,12 +186,10 @@ private static void EarlyConsoleInit(string[] args) /// /// Minimal early scan for -WindowStyle Hidden in command line args. /// Matches any unambiguous prefix of "windowstyle" starting from "w" - /// (e.g. -w, -wi, -win, ..., -windowstyle) followed by "hidden". + /// (e.g. -w, -wi, -win, ..., -windowstyle, --windowstyle) followed by "hidden". /// This is a best-effort check that runs before the full parser. False positives /// (e.g. a hypothetical future -w parameter) are acceptable because the worst case - /// is allocating a hidden console that the full parser would later show. The colon - /// syntax (-windowstyle:hidden) is intentionally not handled here; the full parser - /// handles it later and the existing ShowWindow(SW_HIDE) path covers that case. + /// is allocating a hidden console that the full parser would later show. /// private static bool EarlyCheckForHiddenWindowStyle(string[] args) { @@ -200,7 +198,15 @@ private static bool EarlyCheckForHiddenWindowStyle(string[] args) string arg = args[i]; if (arg.Length >= 2 && (arg[0] == '-' || arg[0] == '/')) { - ReadOnlySpan key = arg.AsSpan(1); + int start = 1; + + // Strip second dash for --windowstyle (matches full parser behavior). + if (arg.Length >= 3 && arg[0] == '-' && arg[1] == '-') + { + start = 2; + } + + ReadOnlySpan key = arg.AsSpan(start); if (key.Length >= 1 && key.Length <= "windowstyle".Length && "windowstyle".AsSpan().StartsWith(key, StringComparison.OrdinalIgnoreCase)) diff --git a/test/powershell/Host/WindowStyleHidden.Tests.ps1 b/test/powershell/Host/WindowStyleHidden.Tests.ps1 index cee43e5c188..5e9ef65dab7 100644 --- a/test/powershell/Host/WindowStyleHidden.Tests.ps1 +++ b/test/powershell/Host/WindowStyleHidden.Tests.ps1 @@ -7,17 +7,41 @@ Describe "WindowStyle Hidden console flash fix (Issue #3028)" -Tag "Feature" { $powershell = Join-Path -Path $PSHOME -ChildPath "pwsh" } - Context "Manifest contains consoleAllocationPolicy" { - It "pwsh.manifest declares consoleAllocationPolicy as detached" -Skip:(!$IsWindows) { - # The manifest is embedded into the PE at build time. Check the source file - # if available; otherwise read the embedded manifest via System.Reflection. - $manifestPath = Join-Path -Path $PSHOME -ChildPath "pwsh.manifest" - if (Test-Path $manifestPath) { - $content = Get-Content $manifestPath -Raw + Context "Manifest contains consoleAllocationPolicy" -Skip:(!$IsWindows) { + It "pwsh.exe embedded manifest declares consoleAllocationPolicy as detached" { + # Extract the embedded manifest from the PE binary using .NET reflection. + $pwshExe = Join-Path -Path $PSHOME -ChildPath "pwsh.exe" + $manifest = [System.Reflection.Assembly]::LoadFile($pwshExe).GetManifestResourceStream("pwsh.exe.manifest") + if ($null -eq $manifest) { + # Fall back to reading the raw manifest via mt.exe-style extraction. + $tempFile = [System.IO.Path]::GetTempFileName() + try { + $proc = Start-Process -FilePath "cmd.exe" -ArgumentList "/c","mt.exe -inputresource:`"$pwshExe`" -out:`"$tempFile`"" -Wait -PassThru -NoNewWindow 2>$null + if ((Test-Path $tempFile) -and (Get-Item $tempFile).Length -gt 0) { + $content = Get-Content $tempFile -Raw + $content | Should -Match "consoleAllocationPolicy" + $content | Should -Match "detached" + } else { + # If mt.exe is unavailable, check the source manifest as last resort. + $srcManifest = Join-Path -Path (Split-Path $PSHOME) -ChildPath "assets/pwsh.manifest" + if (Test-Path $srcManifest) { + $content = Get-Content $srcManifest -Raw + $content | Should -Match "consoleAllocationPolicy" + $content | Should -Match "detached" + } else { + Set-ItResult -Skipped -Because "cannot extract embedded manifest (mt.exe unavailable)" + } + } + } finally { + Remove-Item $tempFile -ErrorAction SilentlyContinue + } + } else { + $reader = [System.IO.StreamReader]::new($manifest) + $content = $reader.ReadToEnd() + $reader.Dispose() + $manifest.Dispose() $content | Should -Match "consoleAllocationPolicy" $content | Should -Match "detached" - } else { - Set-ItResult -Skipped -Because "manifest is embedded in binary and cannot be inspected" } } } @@ -47,6 +71,33 @@ Describe "WindowStyle Hidden console flash fix (Issue #3028)" -Tag "Feature" { } } + Context "Early arg scan handles all prefix variants" -Skip:(!$IsWindows) { + It "handles -w hidden (shortest prefix)" { + $output = & $powershell -NoProfile -w Hidden -Command "'short-prefix'" + $output | Should -Be "short-prefix" + } + + It "handles -win hidden (partial prefix)" { + $output = & $powershell -NoProfile -win Hidden -Command "'partial-prefix'" + $output | Should -Be "partial-prefix" + } + + It "handles --windowstyle hidden (double-dash)" { + $output = & $powershell -NoProfile --windowstyle Hidden -Command "'double-dash'" + $output | Should -Be "double-dash" + } + + It "handles /windowstyle hidden (forward-slash)" { + $output = & $powershell -NoProfile /windowstyle Hidden -Command "'forward-slash'" + $output | Should -Be "forward-slash" + } + + It "is case insensitive" { + $output = & $powershell -NoProfile -WINDOWSTYLE HIDDEN -Command "'case-test'" + $output | Should -Be "case-test" + } + } + Context "AllocConsoleWithOptions API probe" -Skip:(!$IsWindows) { It "detects AllocConsoleWithOptions availability without error" { $code = @" From 87933d353ae1abdeb5ac38c738eb419d021b8fb4 Mon Sep 17 00:00:00 2001 From: SufficientDaikon Date: Mon, 30 Mar 2026 21:23:02 +0200 Subject: [PATCH 5/7] Fix Pester 4 compat: move -Skip from Context to It blocks CI uses Pester 4.x which does not support -Skip on Context blocks, causing 'A parameter cannot be found that matches parameter name Skip' on all three platforms. Move -Skip:(!$IsWindows) to individual It blocks (standard pattern). Also addresses Copilot review feedback: - Add CI tag so tests run in standard CI (not just Others) - Remove Assembly::LoadFile path (pwsh.exe is native, not managed) - Use repo-relative path via $PSScriptRoot for manifest fallback - Add return after Set-ItResult -Skipped per Pester guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Host/WindowStyleHidden.Tests.ps1 | 84 +++++++++---------- 1 file changed, 38 insertions(+), 46 deletions(-) diff --git a/test/powershell/Host/WindowStyleHidden.Tests.ps1 b/test/powershell/Host/WindowStyleHidden.Tests.ps1 index 5e9ef65dab7..9a2f26f44e3 100644 --- a/test/powershell/Host/WindowStyleHidden.Tests.ps1 +++ b/test/powershell/Host/WindowStyleHidden.Tests.ps1 @@ -1,58 +1,50 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe "WindowStyle Hidden console flash fix (Issue #3028)" -Tag "Feature" { +Describe "WindowStyle Hidden console flash fix (Issue #3028)" -Tag @('CI','Feature') { BeforeAll { $powershell = Join-Path -Path $PSHOME -ChildPath "pwsh" } - Context "Manifest contains consoleAllocationPolicy" -Skip:(!$IsWindows) { - It "pwsh.exe embedded manifest declares consoleAllocationPolicy as detached" { - # Extract the embedded manifest from the PE binary using .NET reflection. + Context "Manifest contains consoleAllocationPolicy" { + It "pwsh.exe embedded manifest declares consoleAllocationPolicy as detached" -Skip:(!$IsWindows) { + # Extract the embedded manifest from the PE binary. + # pwsh.exe is a native binary, so use Win32 resource extraction. $pwshExe = Join-Path -Path $PSHOME -ChildPath "pwsh.exe" - $manifest = [System.Reflection.Assembly]::LoadFile($pwshExe).GetManifestResourceStream("pwsh.exe.manifest") - if ($null -eq $manifest) { - # Fall back to reading the raw manifest via mt.exe-style extraction. - $tempFile = [System.IO.Path]::GetTempFileName() - try { - $proc = Start-Process -FilePath "cmd.exe" -ArgumentList "/c","mt.exe -inputresource:`"$pwshExe`" -out:`"$tempFile`"" -Wait -PassThru -NoNewWindow 2>$null - if ((Test-Path $tempFile) -and (Get-Item $tempFile).Length -gt 0) { - $content = Get-Content $tempFile -Raw + $tempFile = [System.IO.Path]::GetTempFileName() + try { + $proc = Start-Process -FilePath "cmd.exe" -ArgumentList "/c","mt.exe -inputresource:`"$pwshExe`" -out:`"$tempFile`"" -Wait -PassThru -NoNewWindow 2>$null + if ((Test-Path $tempFile) -and (Get-Item $tempFile).Length -gt 0) { + $content = Get-Content $tempFile -Raw + $content | Should -Match "consoleAllocationPolicy" + $content | Should -Match "detached" + } else { + # If mt.exe is unavailable, check the source manifest relative to the repo root. + $repoRoot = Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent + $srcManifest = Join-Path -Path $repoRoot -ChildPath "assets/pwsh.manifest" + if (Test-Path $srcManifest) { + $content = Get-Content $srcManifest -Raw $content | Should -Match "consoleAllocationPolicy" $content | Should -Match "detached" } else { - # If mt.exe is unavailable, check the source manifest as last resort. - $srcManifest = Join-Path -Path (Split-Path $PSHOME) -ChildPath "assets/pwsh.manifest" - if (Test-Path $srcManifest) { - $content = Get-Content $srcManifest -Raw - $content | Should -Match "consoleAllocationPolicy" - $content | Should -Match "detached" - } else { - Set-ItResult -Skipped -Because "cannot extract embedded manifest (mt.exe unavailable)" - } + Set-ItResult -Skipped -Because "cannot extract embedded manifest (mt.exe unavailable and source manifest not found)" + return } - } finally { - Remove-Item $tempFile -ErrorAction SilentlyContinue } - } else { - $reader = [System.IO.StreamReader]::new($manifest) - $content = $reader.ReadToEnd() - $reader.Dispose() - $manifest.Dispose() - $content | Should -Match "consoleAllocationPolicy" - $content | Should -Match "detached" + } finally { + Remove-Item $tempFile -ErrorAction SilentlyContinue } } } - Context "WindowStyle Hidden produces correct output" -Skip:(!$IsWindows) { - It "captures output from -WindowStyle Hidden -Command" { + Context "WindowStyle Hidden produces correct output" { + It "captures output from -WindowStyle Hidden -Command" -Skip:(!$IsWindows) { $output = & $powershell -NoProfile -WindowStyle Hidden -Command "'hello'" $output | Should -Be "hello" } - It "captures pipeline output from -WindowStyle Hidden" { + It "captures pipeline output from -WindowStyle Hidden" -Skip:(!$IsWindows) { $output = & $powershell -NoProfile -WindowStyle Hidden -Command "1..3 | ForEach-Object { `$_ * 2 }" $output.Count | Should -Be 3 $output[0] | Should -Be 2 @@ -60,46 +52,46 @@ Describe "WindowStyle Hidden console flash fix (Issue #3028)" -Tag "Feature" { $output[2] | Should -Be 6 } - It "Write-Host works under -WindowStyle Hidden" { + It "Write-Host works under -WindowStyle Hidden" -Skip:(!$IsWindows) { $output = & $powershell -NoProfile -WindowStyle Hidden -Command "Write-Host 'test-output'" 6>&1 ($output | Out-String) | Should -Match "test-output" } - It "exits with correct exit code under -WindowStyle Hidden" { + It "exits with correct exit code under -WindowStyle Hidden" -Skip:(!$IsWindows) { & $powershell -NoProfile -WindowStyle Hidden -Command "exit 42" $LASTEXITCODE | Should -Be 42 } } - Context "Early arg scan handles all prefix variants" -Skip:(!$IsWindows) { - It "handles -w hidden (shortest prefix)" { + Context "Early arg scan handles all prefix variants" { + It "handles -w hidden (shortest prefix)" -Skip:(!$IsWindows) { $output = & $powershell -NoProfile -w Hidden -Command "'short-prefix'" $output | Should -Be "short-prefix" } - It "handles -win hidden (partial prefix)" { + It "handles -win hidden (partial prefix)" -Skip:(!$IsWindows) { $output = & $powershell -NoProfile -win Hidden -Command "'partial-prefix'" $output | Should -Be "partial-prefix" } - It "handles --windowstyle hidden (double-dash)" { + It "handles --windowstyle hidden (double-dash)" -Skip:(!$IsWindows) { $output = & $powershell -NoProfile --windowstyle Hidden -Command "'double-dash'" $output | Should -Be "double-dash" } - It "handles /windowstyle hidden (forward-slash)" { + It "handles /windowstyle hidden (forward-slash)" -Skip:(!$IsWindows) { $output = & $powershell -NoProfile /windowstyle Hidden -Command "'forward-slash'" $output | Should -Be "forward-slash" } - It "is case insensitive" { + It "is case insensitive" -Skip:(!$IsWindows) { $output = & $powershell -NoProfile -WINDOWSTYLE HIDDEN -Command "'case-test'" $output | Should -Be "case-test" } } - Context "AllocConsoleWithOptions API probe" -Skip:(!$IsWindows) { - It "detects AllocConsoleWithOptions availability without error" { + Context "AllocConsoleWithOptions API probe" { + It "detects AllocConsoleWithOptions availability without error" -Skip:(!$IsWindows) { $code = @" using System; using System.Runtime.InteropServices; @@ -125,13 +117,13 @@ public static class ConsoleApiProbe { } } - Context "Normal startup is unaffected" -Skip:(!$IsWindows) { - It "starts and runs a command without -WindowStyle" { + Context "Normal startup is unaffected" { + It "starts and runs a command without -WindowStyle" -Skip:(!$IsWindows) { $output = & $powershell -NoProfile -Command "`$PSVersionTable.PSEdition" $output | Should -Be "Core" } - It "handles -WindowStyle Normal without error" { + It "handles -WindowStyle Normal without error" -Skip:(!$IsWindows) { $output = & $powershell -NoProfile -WindowStyle Normal -Command "'normal-test'" $output | Should -Be "normal-test" } From 6214139d21ff889f57f673f8fcc74d76dd3782ed Mon Sep 17 00:00:00 2001 From: SufficientDaikon Date: Tue, 31 Mar 2026 08:52:30 +0200 Subject: [PATCH 6/7] Address reviewer feedback from daxian-dbw and DHowett MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EarlyConsoleInit: remove early ShowWindow(SW_HIDE) for existing consoles per daxian-dbw — leave -WindowStyle handling to the existing SetConsoleMode code path. EarlyConsoleInit now only handles the no-console case. TryAllocConsoleWithMode: check AllocConsoleResult instead of discarding it. Return false when result is NoConsole (DETACHED_PROCESS respected) so callers know a console was not actually allocated. NativeCommandProcessor: add comment clarifying foreground window restore runs for both NoWindow and fallback paths. Manifest: add XML comment explaining why asm.v3 xmlns is required on the application element (distinct from root asm.v1 namespace). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- assets/pwsh.manifest | 1 + .../host/msh/ManagedEntrance.cs | 62 +++++++++---------- .../Windows/AllocConsoleWithOptions.cs | 15 +++-- .../engine/NativeCommandProcessor.cs | 1 + 4 files changed, 40 insertions(+), 39 deletions(-) diff --git a/assets/pwsh.manifest b/assets/pwsh.manifest index 2fcfdd63861..21f12b30a8f 100644 --- a/assets/pwsh.manifest +++ b/assets/pwsh.manifest @@ -23,6 +23,7 @@ + detached diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs index 7bec59b5622..471889282bf 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ManagedEntrance.cs @@ -55,8 +55,8 @@ public static int Start([MarshalAs(UnmanagedType.LPArray, ArraySubType = Unmanag // On Windows with consoleAllocationPolicy=detached in the manifest, // no console is auto-allocated by the OS. We must allocate one ourselves // before anything touches CONOUT$/CONIN$ handles. - // On older Windows the manifest element is ignored and this is a no-op - // (AllocConsole returns false when a console already exists). + // On older Windows the manifest element is ignored and the OS auto-allocates + // a console, so GetConsoleWindow() != 0 and EarlyConsoleInit is a no-op. EarlyConsoleInit(args); #endif @@ -134,7 +134,8 @@ public static int Start([MarshalAs(UnmanagedType.LPArray, ArraySubType = Unmanag /// /// Allocates a console early in startup to support consoleAllocationPolicy=detached. /// On newer Windows (with the detached policy active), the OS does not auto-allocate - /// a console for CUI apps. On older Windows, AllocConsole() returns false (no-op). + /// a console for CUI apps. On older Windows the manifest is ignored, so the OS + /// auto-allocates a console and GetConsoleWindow() returns non-zero (early return). /// private static void EarlyConsoleInit(string[] args) { @@ -142,44 +143,39 @@ private static void EarlyConsoleInit(string[] args) if (existingConsole != nint.Zero) { // Console already exists (inherited from parent or auto-allocated on older Windows). - // If -WindowStyle Hidden was requested, hide the window at the earliest possible moment - // to minimize the flash on older Windows where the detached policy is not supported. - if (EarlyCheckForHiddenWindowStyle(args)) - { - Interop.Windows.ShowWindow(existingConsole, Interop.Windows.SW_HIDE); - } - + // Leave -WindowStyle handling to the existing SetConsoleMode code path. return; } - // No console exists. This means the detached policy is active (newer Windows) - // and we were launched without console inheritance (e.g. from Explorer, Task Scheduler). + // No console exists (GetConsoleWindow() == 0). This means either: + // (a) The detached manifest policy is active (newer Windows), or + // (b) DETACHED_PROCESS — no console at all, or + // (c) CREATE_NO_WINDOW — console session exists but no window. + // + // For (c), AllocConsoleWithOptions returns ExistingConsole (no-op). + // For (a) and (b), behavior depends on the mode: + // Default mode: allocates if the parent would have given us a console + // on prior Windows versions, returns NoConsole for DETACHED_PROCESS. + // NoWindow mode: always creates a console session (overrides DETACHED). + // + // When -WindowStyle Hidden is specified, we intentionally use NoWindow + // even though it overrides DETACHED_PROCESS — the user explicitly asked + // for invisible PowerShell with working I/O, and the alternative (no + // console, crashing on stdin/stdout access) is strictly worse. + // + // If the API is not available (older Windows), TryAlloc* returns false. + // On older Windows the manifest is ignored and the OS auto-allocates + // a console, so GetConsoleWindow() would have returned non-zero above. + // The only older-Windows path here is DETACHED_PROCESS, where the + // existing behavior is no console I/O; we preserve that by not + // falling back to plain AllocConsole() (per DHowett's guidance). if (EarlyCheckForHiddenWindowStyle(args)) { - // Hidden: allocate an invisible console session so CONOUT$/CONIN$ work - // (Write-Host, native commands, etc.) but no window is ever shown. - if (!Interop.Windows.TryAllocConsoleNoWindow()) - { - // Fallback (should not happen since we only reach here on newer Windows, - // but be defensive): alloc + hide. - Interop.Windows.AllocConsole(); - nint hwnd = Interop.Windows.GetConsoleWindow(); - if (hwnd != nint.Zero) - { - Interop.Windows.ShowWindow(hwnd, Interop.Windows.SW_HIDE); - } - } + Interop.Windows.TryAllocConsoleNoWindow(); } else { - // Normal interactive launch: allocate a visible console. - // Use AllocConsoleWithOptions(Default) when available — it respects - // DETACHED_PROCESS from the parent's CreateProcess call, whereas - // plain AllocConsole() would override it and force-create a console. - if (!Interop.Windows.TryAllocConsoleDefault()) - { - Interop.Windows.AllocConsole(); - } + Interop.Windows.TryAllocConsoleDefault(); } } diff --git a/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs b/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs index bef0cde3be4..087ce41e417 100644 --- a/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs +++ b/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs @@ -57,7 +57,8 @@ internal static partial int AllocConsoleWithOptions( /// /// Attempts to allocate a console without a visible window using AllocConsoleWithOptions. - /// Returns false if the API is not available (older Windows) or the call fails. + /// Returns false if the API is not available (older Windows), the call fails, + /// or no console was allocated (e.g. process was started with DETACHED_PROCESS). /// internal static bool TryAllocConsoleNoWindow() { @@ -66,9 +67,11 @@ internal static bool TryAllocConsoleNoWindow() /// /// Attempts to allocate a console using AllocConsoleWithOptions with Default mode. - /// Default mode respects DETACHED_PROCESS from the parent's CreateProcess call, - /// whereas plain AllocConsole() would override it and force-create a console. - /// Returns false if the API is not available (older Windows) or the call fails. + /// Default mode respects DETACHED_PROCESS from the parent's CreateProcess call: + /// it returns NoConsole if the parent intended this process to run without a console, + /// whereas plain AllocConsole() would override that and force-create a console. + /// Returns false if the API is not available (older Windows), the call fails, + /// or no console was allocated. /// internal static bool TryAllocConsoleDefault() { @@ -86,8 +89,8 @@ private static bool TryAllocConsoleWithMode(AllocConsoleMode mode) ShowWindow = 0, }; - int hr = AllocConsoleWithOptions(ref options, out _); - return hr >= 0; // S_OK + int hr = AllocConsoleWithOptions(ref options, out AllocConsoleResult result); + return hr >= 0 && result != AllocConsoleResult.NoConsole; } catch (EntryPointNotFoundException) { diff --git a/src/System.Management.Automation/engine/NativeCommandProcessor.cs b/src/System.Management.Automation/engine/NativeCommandProcessor.cs index 1259eb6828a..86fbbec93c8 100644 --- a/src/System.Management.Automation/engine/NativeCommandProcessor.cs +++ b/src/System.Management.Automation/engine/NativeCommandProcessor.cs @@ -2508,6 +2508,7 @@ internal static bool AllocateHiddenConsole() AlwaysCaptureApplicationIO = true; + // Restore foreground window if focus changed during console allocation. if (savedForeground != nint.Zero && Interop.Windows.GetForegroundWindow() != savedForeground) { Interop.Windows.SetForegroundWindow(savedForeground); From 6b447011bafe3546c635e2d22f31013367e1b514 Mon Sep 17 00:00:00 2001 From: SufficientDaikon Date: Wed, 22 Jul 2026 17:39:11 +0300 Subject: [PATCH 7/7] Address review feedback on console allocation Cache AllocConsoleWithOptions availability in a static field so later calls skip the P/Invoke and its exception once the API is known to be missing, matching the s_WNetApiNotAvailable pattern in WNetGetConnection.cs. Restore the foreground window on the AllocateHiddenConsole early-return path. AllocConsole() can shift focus even when GetConsoleWindow() returns zero, so the restore now runs on every path that captured it. Remove the AllocConsoleWithOptions probe test. It asserted only that a bool-returning method returns a bool, so it passed regardless of the fix. --- .../Windows/AllocConsoleWithOptions.cs | 10 +++++++ .../engine/NativeCommandProcessor.cs | 16 ++++++++--- .../Host/WindowStyleHidden.Tests.ps1 | 27 ------------------- 3 files changed, 22 insertions(+), 31 deletions(-) diff --git a/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs b/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs index 087ce41e417..a11a41567ee 100644 --- a/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs +++ b/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs @@ -10,6 +10,10 @@ internal static partial class Interop { internal static unsafe partial class Windows { + // Set once the API is found to be missing (Windows builds before AllocConsoleWithOptions + // shipped) so subsequent calls skip the P/Invoke and the resulting exception. + private static bool s_allocConsoleWithOptionsNotAvailable; + /// Console allocation mode for AllocConsoleWithOptions. internal enum AllocConsoleMode : int { @@ -80,6 +84,11 @@ internal static bool TryAllocConsoleDefault() private static bool TryAllocConsoleWithMode(AllocConsoleMode mode) { + if (s_allocConsoleWithOptionsNotAvailable) + { + return false; + } + try { var options = new AllocConsoleOptions @@ -94,6 +103,7 @@ private static bool TryAllocConsoleWithMode(AllocConsoleMode mode) } catch (EntryPointNotFoundException) { + s_allocConsoleWithOptionsNotAvailable = true; return false; } } diff --git a/src/System.Management.Automation/engine/NativeCommandProcessor.cs b/src/System.Management.Automation/engine/NativeCommandProcessor.cs index 86fbbec93c8..8fa1351753b 100644 --- a/src/System.Management.Automation/engine/NativeCommandProcessor.cs +++ b/src/System.Management.Automation/engine/NativeCommandProcessor.cs @@ -2500,6 +2500,9 @@ internal static bool AllocateHiddenConsole() hwnd = Interop.Windows.GetConsoleWindow(); if (hwnd == nint.Zero) { + // AllocConsole() can still move focus even when no console window + // handle comes back, so restore it before bailing out. + RestoreForegroundWindow(savedForeground); return false; } @@ -2508,13 +2511,18 @@ internal static bool AllocateHiddenConsole() AlwaysCaptureApplicationIO = true; + RestoreForegroundWindow(savedForeground); + + return true; + // Restore foreground window if focus changed during console allocation. - if (savedForeground != nint.Zero && Interop.Windows.GetForegroundWindow() != savedForeground) + static void RestoreForegroundWindow(IntPtr previousForeground) { - Interop.Windows.SetForegroundWindow(savedForeground); + if (previousForeground != nint.Zero && Interop.Windows.GetForegroundWindow() != previousForeground) + { + Interop.Windows.SetForegroundWindow(previousForeground); + } } - - return true; #endif } } diff --git a/test/powershell/Host/WindowStyleHidden.Tests.ps1 b/test/powershell/Host/WindowStyleHidden.Tests.ps1 index 9a2f26f44e3..92599c60a74 100644 --- a/test/powershell/Host/WindowStyleHidden.Tests.ps1 +++ b/test/powershell/Host/WindowStyleHidden.Tests.ps1 @@ -90,33 +90,6 @@ Describe "WindowStyle Hidden console flash fix (Issue #3028)" -Tag @('CI','Featu } } - Context "AllocConsoleWithOptions API probe" { - It "detects AllocConsoleWithOptions availability without error" -Skip:(!$IsWindows) { - $code = @" -using System; -using System.Runtime.InteropServices; -public static class ConsoleApiProbe { - [DllImport("kernel32.dll", SetLastError = true)] - public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); - [DllImport("kernel32.dll")] - public static extern IntPtr GetModuleHandle(string lpModuleName); - public static bool IsAllocConsoleWithOptionsAvailable() { - IntPtr k32 = GetModuleHandle("kernel32.dll"); - if (k32 == IntPtr.Zero) return false; - IntPtr addr = GetProcAddress(k32, "AllocConsoleWithOptions"); - return addr != IntPtr.Zero; - } -} -"@ - Add-Type -TypeDefinition $code -ErrorAction Stop - $available = [ConsoleApiProbe]::IsAllocConsoleWithOptionsAvailable() - - # Verify the probe returns a valid result; the actual availability - # depends on the Windows build running the test. - $available | Should -BeOfType [bool] - } - } - Context "Normal startup is unaffected" { It "starts and runs a command without -WindowStyle" -Skip:(!$IsWindows) { $output = & $powershell -NoProfile -Command "`$PSVersionTable.PSEdition"