diff --git a/assets/pwsh.manifest b/assets/pwsh.manifest index 428ea914360..21f12b30a8f 100644 --- a/assets/pwsh.manifest +++ b/assets/pwsh.manifest @@ -23,4 +23,10 @@ + + + + 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 acfdea07153..e2139de8dba 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 the OS auto-allocates + // a console, so GetConsoleWindow() != 0 and EarlyConsoleInit is a no-op. + EarlyConsoleInit(args); +#endif + #if DEBUG if (args.Length > 0 && !string.IsNullOrEmpty(args[0]) && args[0]!.Equals("-isswait", StringComparison.OrdinalIgnoreCase)) { @@ -120,5 +129,95 @@ 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 the manifest is ignored, so the OS + /// auto-allocates a console and GetConsoleWindow() returns non-zero (early return). + /// + 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). + // Leave -WindowStyle handling to the existing SetConsoleMode code path. + return; + } + + // 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)) + { + Interop.Windows.TryAllocConsoleNoWindow(); + } + else + { + Interop.Windows.TryAllocConsoleDefault(); + } + } + + /// + /// 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, --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. + /// + 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] == '/')) + { + 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)) + { + if (args[i + 1].Equals("hidden", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + + 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..a11a41567ee --- /dev/null +++ b/src/System.Management.Automation/engine/Interop/Windows/AllocConsoleWithOptions.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Runtime.InteropServices; + +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 + { + /// 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; + } + + [LibraryImport("kernel32.dll")] + 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), the call fails, + /// or no console was allocated (e.g. process was started with DETACHED_PROCESS). + /// + 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: + /// 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() + { + return TryAllocConsoleWithMode(AllocConsoleMode.Default); + } + + private static bool TryAllocConsoleWithMode(AllocConsoleMode mode) + { + if (s_allocConsoleWithOptionsNotAvailable) + { + return false; + } + + try + { + var options = new AllocConsoleOptions + { + Mode = mode, + UseShowWindow = 0, + ShowWindow = 0, + }; + + int hr = AllocConsoleWithOptions(ref options, out AllocConsoleResult result); + return hr >= 0 && result != AllocConsoleResult.NoConsole; + } + 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 145fe968fda..8fa1351753b 100644 --- a/src/System.Management.Automation/engine/NativeCommandProcessor.cs +++ b/src/System.Management.Automation/engine/NativeCommandProcessor.cs @@ -2489,30 +2489,40 @@ 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 = Interop.Windows.TryAllocConsoleNoWindow(); - bool returnValue; - if (hwnd == nint.Zero) + if (!allocated) { - returnValue = false; - } - else - { - returnValue = true; + // Fallback for older Windows: allocate and then hide. + Interop.Windows.AllocConsole(); + 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; + } + Interop.Windows.ShowWindow(hwnd, Interop.Windows.SW_HIDE); - AlwaysCaptureApplicationIO = true; } - if (savedForeground != nint.Zero && Interop.Windows.GetForegroundWindow() != savedForeground) + AlwaysCaptureApplicationIO = true; + + RestoreForegroundWindow(savedForeground); + + return true; + + // Restore foreground window if focus changed during console allocation. + static void RestoreForegroundWindow(IntPtr previousForeground) { - Interop.Windows.SetForegroundWindow(savedForeground); + if (previousForeground != nint.Zero && Interop.Windows.GetForegroundWindow() != previousForeground) + { + Interop.Windows.SetForegroundWindow(previousForeground); + } } - - return returnValue; #endif } } diff --git a/test/powershell/Host/WindowStyleHidden.Tests.ps1 b/test/powershell/Host/WindowStyleHidden.Tests.ps1 new file mode 100644 index 00000000000..92599c60a74 --- /dev/null +++ b/test/powershell/Host/WindowStyleHidden.Tests.ps1 @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe "WindowStyle Hidden console flash fix (Issue #3028)" -Tag @('CI','Feature') { + + BeforeAll { + $powershell = Join-Path -Path $PSHOME -ChildPath "pwsh" + } + + 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" + $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 { + Set-ItResult -Skipped -Because "cannot extract embedded manifest (mt.exe unavailable and source manifest not found)" + return + } + } + } finally { + Remove-Item $tempFile -ErrorAction SilentlyContinue + } + } + } + + 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" -Skip:(!$IsWindows) { + $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" -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" -Skip:(!$IsWindows) { + & $powershell -NoProfile -WindowStyle Hidden -Command "exit 42" + $LASTEXITCODE | Should -Be 42 + } + } + + 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)" -Skip:(!$IsWindows) { + $output = & $powershell -NoProfile -win Hidden -Command "'partial-prefix'" + $output | Should -Be "partial-prefix" + } + + 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)" -Skip:(!$IsWindows) { + $output = & $powershell -NoProfile /windowstyle Hidden -Command "'forward-slash'" + $output | Should -Be "forward-slash" + } + + It "is case insensitive" -Skip:(!$IsWindows) { + $output = & $powershell -NoProfile -WINDOWSTYLE HIDDEN -Command "'case-test'" + $output | Should -Be "case-test" + } + } + + 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" -Skip:(!$IsWindows) { + $output = & $powershell -NoProfile -WindowStyle Normal -Command "'normal-test'" + $output | Should -Be "normal-test" + } + } +}