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"
+ }
+ }
+}