From 31cf96e64115bf00a357b7ffc556cd28871ad284 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sun, 4 Oct 2026 22:34:44 -0700 Subject: [PATCH] Prevent GDI+ startup from blocking Windows shell Initialize optional GDI+ rendering on a detached worker with mutex-protected publication and plain-GDI fallback, and add an exact 11-key child-process native-window regression. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Tests/GdiplusStartup.Live.Tests.ps1 | 128 ++++++++++++++++++ Tools/windows/Tests/WindowsShell.Tests.ps1 | 13 +- graphcode-windows/src/App.zig | 4 +- graphcode-windows/src/GdiplusAA.zig | 79 +++++++++-- .../src/GdiplusStartupLiveRunner.zig | 60 ++++++++ 5 files changed, 273 insertions(+), 11 deletions(-) create mode 100644 Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 create mode 100644 graphcode-windows/src/GdiplusStartupLiveRunner.zig diff --git a/Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 b/Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 new file mode 100644 index 00000000..baad5c88 --- /dev/null +++ b/Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 @@ -0,0 +1,128 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $ZigExecutable, + [Parameter(Mandatory)][string] $WinghosttyRoot, + [string] $ScratchRoot = (Join-Path $env:TEMP "graphcode-gdiplus-startup") +) + +$ErrorActionPreference = "Stop" + +if (-not ("GraphCodeGdiplusStartupNative" -as [type])) { + Add-Type @' +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class GraphCodeGdiplusStartupNative { + public delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr window, StringBuilder value, int capacity); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window); + + public static IntPtr Find(uint processId, string className) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId || !IsWindowVisible(window)) return true; + var actual = new StringBuilder(128); + GetClassName(window, actual, actual.Capacity); + if (!String.Equals(actual.ToString(), className, StringComparison.Ordinal)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } +} +'@ +} + +$shellRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..\graphcode-windows") +$scratch = [IO.Path]::GetFullPath($ScratchRoot) +$bin = Join-Path $scratch "bin" +$profile = Join-Path $scratch "profile" +$temp = Join-Path $scratch "temp" +$executable = Join-Path $bin "gdiplus-startup-live.exe" +if (Test-Path -LiteralPath $scratch) { + Remove-Item -LiteralPath $scratch -Recurse -Force +} +New-Item -ItemType Directory -Force -Path ` + $bin,$profile,(Join-Path $profile "AppData\Local"),(Join-Path $profile "AppData\Roaming"),$temp | + Out-Null + +$include = Join-Path $WinghosttyRoot "include" +Push-Location $shellRoot +try { + & $ZigExecutable build-exe src\GdiplusStartupLiveRunner.zig ` + -target x86_64-windows-msvc -OReleaseSafe -lc ` + -luser32 -lgdi32 -lgdiplus "-I$include" "-femit-bin=$executable" + if ($LASTEXITCODE -ne 0) { + throw "GDI+ startup live runner build failed with exit code $LASTEXITCODE" + } +} finally { + Pop-Location +} + +$system32 = Join-Path $env:SystemRoot "System32" +$powerShell = Join-Path $system32 "WindowsPowerShell\v1.0" +$start = [Diagnostics.ProcessStartInfo]::new() +$start.FileName = $executable +$start.WorkingDirectory = $bin +$start.UseShellExecute = $false +$start.Environment.Clear() +$values = [ordered]@{ + SystemRoot = $env:SystemRoot + windir = $env:WINDIR + USERPROFILE = $profile + LOCALAPPDATA = Join-Path $profile "AppData\Local" + APPDATA = Join-Path $profile "AppData\Roaming" + TEMP = $temp + TMP = $temp + ProgramData = $env:ProgramData + HOMEDRIVE = [IO.Path]::GetPathRoot($profile).TrimEnd("\") + HOMEPATH = $profile.Substring([IO.Path]::GetPathRoot($profile).Length - 1) + PATH = "$system32;$powerShell;$bin" +} +foreach ($name in $values.Keys) { $start.Environment[$name] = [string]$values[$name] } + +$process = [Diagnostics.Process]::new() +$process.StartInfo = $start +$observations = 0 +$window = [IntPtr]::Zero +try { + if (-not $process.Start()) { throw "GDI+ startup live runner did not start" } + [void]$process.Handle + $deadline = [DateTime]::UtcNow.AddSeconds(5) + do { + $observations++ + $process.Refresh() + if ($process.HasExited) { + throw ("GDI+ startup live runner exited before creating a native window: " + + "exitCode=$($process.ExitCode); observations=$observations") + } + $window = [GraphCodeGdiplusStartupNative]::Find( + [uint32]$process.Id, + "GraphCodeGdiplusStartupTest" + ) + if ($window -ne [IntPtr]::Zero) { break } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + + if ($observations -le 0) { throw "GDI+ startup live runner made zero observations" } + if ($window -eq [IntPtr]::Zero) { + throw ("GDI+ startup live runner remained alive but no native window appeared: " + + "observations=$observations; environmentKeys=$( + @($start.Environment.Keys | Sort-Object) -join ',')") + } + Write-Output ("GDIPLUS_STARTUP_LIVE: PASS; executed=1; observations=$observations; " + + "window=$($window.ToInt64()); environmentKeys=$( + @($start.Environment.Keys | Sort-Object) -join ',')") +} finally { + if ($process -and -not $process.HasExited) { + Stop-Process -Id $process.Id -Force + [void]$process.WaitForExit(10000) + } + if ($process) { $process.Dispose() } + Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index e224e3f3..4c9163fe 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -292,7 +292,9 @@ Assert-Contract ($appSource -match 'SetMapMode\(hdc, c\.MM_ANISOTROPIC\)' -and "custom main-window painting, input, and child layout must share one DPI-scaled coordinate system" Assert-Contract ($appSource -match 'const uia_gate_hook = envFlag\("GRAPHCODE_UIA_GATE"\);' -and - $appSource -match 'if \(!daemon_supervisor_test_hook and !uia_gate_hook\) GdiplusAA\.init\(\);') ` + $appSource -match 'const use_gdiplus = !daemon_supervisor_test_hook and !uia_gate_hook;' -and + $appSource -match 'if \(use_gdiplus\) GdiplusAA\.init\(\);' -and + $appSource -match 'defer if \(use_gdiplus\) GdiplusAA\.deinit\(\);') ` "GDI+ helper-window startup must remain outside daemon-handoff and UIA automation hooks" Assert-Contract ($appSource -match '(?s)app\.smoke_tick >= 16 and\s*app\.client\.connectionState\(\) == \.connected and\s*app\.currentProject\(\) != null and app\.model\.selected\(\) != null and\s*!app\.smoke_action_requested') ` @@ -718,6 +720,15 @@ Invoke-Native "Onboarding executable tests" { -lc -luser32 -lgdi32 "-I$include" } finally { Pop-Location } } +Invoke-Native "GDI+ asynchronous startup live test" { + $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") + if (-not $winghosttyRoot) { + throw "GRAPHCODE_WINGHOSTTY_ROOT is required for the GDI+ startup live test" + } + & (Join-Path $PSScriptRoot "GdiplusStartup.Live.Tests.ps1") ` + -ZigExecutable $zig ` + -WinghosttyRoot $winghosttyRoot +} Invoke-Native "Product Settings executable tests" { $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 7c5489fd..27ad3380 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1299,7 +1299,9 @@ pub const App = struct { // and UIA live tests depend on deterministic top-level window and // foreground behavior, so keep that visual-only subsystem disabled // for both explicit automation hooks. - if (!daemon_supervisor_test_hook and !uia_gate_hook) GdiplusAA.init(); + const use_gdiplus = !daemon_supervisor_test_hook and !uia_gate_hook; + if (use_gdiplus) GdiplusAA.init(); + defer if (use_gdiplus) GdiplusAA.deinit(); try self.window.create(self, &onWindowMessage, title.ptr); self.window.key_callback = &onShellKey; try self.revalidateWorkspaceIdentity(); diff --git a/graphcode-windows/src/GdiplusAA.zig b/graphcode-windows/src/GdiplusAA.zig index 3428a1b5..fedb9da0 100644 --- a/graphcode-windows/src/GdiplusAA.zig +++ b/graphcode-windows/src/GdiplusAA.zig @@ -66,19 +66,68 @@ extern "gdiplus" fn GdipFillPath(graphics: *GpGraphics, brush: *GpBrush, path: * extern "gdiplus" fn GdipCreateSolidFill(color: u32, brush: **GpBrush) callconv(.winapi) GpStatus; extern "gdiplus" fn GdipDeleteBrush(brush: *GpBrush) callconv(.winapi) GpStatus; +var startup_mutex: std.Thread.Mutex = .{}; var startup_token: usize = 0; var available: bool = false; var start_attempted: bool = false; +var shutdown_requested: bool = false; -/// Attempts to start GDI+ once for the process lifetime. Safe to call -/// repeatedly (a no-op after the first call). All draw functions below -/// check `available` and gracefully return false (do nothing) if this -/// never succeeded, so callers must keep their plain-GDI fallback. +const SystemStartup = struct { + pub fn run() ?usize { + var token: usize = 0; + var input = GdiplusStartupInput{}; + if (GdiplusStartup(&token, &input, null) != Ok) return null; + return token; + } +}; + +/// Starts optional GDI+ initialization away from the UI thread. Until the +/// worker succeeds, every draw call returns false and the caller uses plain GDI. pub fn init() void { - if (start_attempted) return; + initWith(SystemStartup); +} + +pub fn initWith(comptime Startup: type) void { + startup_mutex.lock(); + if (start_attempted or shutdown_requested) { + startup_mutex.unlock(); + return; + } start_attempted = true; - var input = GdiplusStartupInput{}; - available = GdiplusStartup(&startup_token, &input, null) == Ok; + startup_mutex.unlock(); + + const Worker = struct { + fn run() void { + const token = Startup.run() orelse return; + startup_mutex.lock(); + if (shutdown_requested) { + startup_mutex.unlock(); + GdiplusShutdown(token); + return; + } + startup_token = token; + available = true; + startup_mutex.unlock(); + } + }; + var worker = std.Thread.spawn(.{}, Worker.run, .{}) catch return; + worker.detach(); +} + +/// Prevents new GDI+ sessions and releases a completed process-global startup. +/// A worker still blocked inside Windows remains detached and cannot delay exit. +pub fn deinit() void { + startup_mutex.lock(); + shutdown_requested = true; + if (!available) { + startup_mutex.unlock(); + return; + } + available = false; + const token = startup_token; + startup_token = 0; + startup_mutex.unlock(); + GdiplusShutdown(token); } fn colorrefToArgb(colorref: u32) u32 { @@ -94,16 +143,25 @@ const Session = struct { }; fn beginSession(hdc: c.HDC, colorref: u32, width: f32) ?Session { - if (!available) return null; + startup_mutex.lock(); + if (!available) { + startup_mutex.unlock(); + return null; + } var graphics: *GpGraphics = undefined; - if (GdipCreateFromHDC(hdc, &graphics) != Ok) return null; + if (GdipCreateFromHDC(hdc, &graphics) != Ok) { + startup_mutex.unlock(); + return null; + } if (GdipSetSmoothingMode(graphics, SmoothingModeAntiAlias) != Ok) { _ = GdipDeleteGraphics(graphics); + startup_mutex.unlock(); return null; } var pen: *GpPen = undefined; if (GdipCreatePen1(colorrefToArgb(colorref), width, UnitPixel, &pen) != Ok) { _ = GdipDeleteGraphics(graphics); + startup_mutex.unlock(); return null; } return .{ .graphics = graphics, .pen = pen }; @@ -112,6 +170,7 @@ fn beginSession(hdc: c.HDC, colorref: u32, width: f32) ?Session { fn endSession(session: Session) void { _ = GdipDeletePen(session.pen); _ = GdipDeleteGraphics(session.graphics); + startup_mutex.unlock(); } /// Draws an anti-aliased straight line. Returns false (drawing nothing) @@ -174,6 +233,8 @@ pub fn drawRoundedRect( border_colorref: u32, border_width: f32, ) bool { + startup_mutex.lock(); + defer startup_mutex.unlock(); if (!available) return false; var graphics: *GpGraphics = undefined; if (GdipCreateFromHDC(hdc, &graphics) != Ok) return false; diff --git a/graphcode-windows/src/GdiplusStartupLiveRunner.zig b/graphcode-windows/src/GdiplusStartupLiveRunner.zig new file mode 100644 index 00000000..21ae9f07 --- /dev/null +++ b/graphcode-windows/src/GdiplusStartupLiveRunner.zig @@ -0,0 +1,60 @@ +const std = @import("std"); +const GdiplusAA = @import("GdiplusAA.zig"); +const c = @import("Win32.zig").c; + +const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeGdiplusStartupTest"); +const title = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode GDI+ startup test"); + +const BlockingStartup = struct { + pub fn run() ?usize { + while (true) std.Thread.sleep(std.time.ns_per_s); + } +}; + +fn windowProc( + hwnd: c.HWND, + message: c.UINT, + wparam: c.WPARAM, + lparam: c.LPARAM, +) callconv(.winapi) c.LRESULT { + if (message == c.WM_DESTROY) { + c.PostQuitMessage(0); + return 0; + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); +} + +pub fn main() !void { + GdiplusAA.initWith(BlockingStartup); + + const instance = c.GetModuleHandleW(null); + var klass: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + klass.lpfnWndProc = &windowProc; + klass.hInstance = instance; + klass.lpszClassName = class_name.ptr; + if (c.RegisterClassW(&klass) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.WindowClassRegistrationFailed; + + const hwnd = c.CreateWindowExW( + 0, + class_name.ptr, + title.ptr, + c.WS_OVERLAPPEDWINDOW, + c.CW_USEDEFAULT, + c.CW_USEDEFAULT, + 640, + 480, + null, + null, + instance, + null, + ) orelse return error.WindowCreationFailed; + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.UpdateWindow(hwnd); + + var message: c.MSG = undefined; + while (c.GetMessageW(&message, null, 0, 0) > 0) { + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } +}