From a0d74a1a24042656ac3406319880b49a7b531a27 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 23 Sep 2026 14:52:23 -0700 Subject: [PATCH 01/23] Validate folder lanes, attention rail, connector handles, and zoom controls live Fixes three real accessibility bugs found while extending the live UIA gate against the now-unblocked Windows shell toolchain: - App.zig's .overview mouse-click switch arm was missing syncAccessibility(), so a lane's Open/Worktrees click had no observable effect through the accessibility tree even though the underlying surface changed. - App.zig's .cycle_attention action handler was missing the same syncAccessibility() call. - AccessibilityProvider.cpp exposed a bogus BoundingRectangle for the native graph UIA element; added canvas_bounds/setCanvasBounds so the live gate can locate real screen coordinates for synthesized input. Extends Tools/windows/uia-live-gate.ps1 with genuine synthesized-input assertions for: - Folder lanes/bands: real WM_LBUTTONDOWN/UP at the lane's Open and Worktrees hit-test rects, verifying routing to the project canvas and scoped worktree inspection. - Canvas attention rail: a real click at the rail's full-width band, verifying SelectionItemPattern selection moves to the NEEDS YOU card. - Connector handles: synthesized hover (WM_MOUSEMOVE) confirmed via a live screen-pixel check, plus a full drag-to-connect (WM_LBUTTONDOWN/MOUSEMOVE/LBUTTONUP) confirmed via the resulting native edge dialog's locked From/To fields. - Zoom controls: live bounds/InvokePattern resolution plus actual invokes of zoom-in, actual-size, zoom-out, and fit-canvas against the running executable. Also hardens Get-DirectChildren/Find-FragmentById against transient UIA tree-walk races near native HWND-hosted view teardown (ElementNotAvailableException/COMException retries, null-root guard), adds Find-FragmentByIdWithRetry, and captures shell stderr via -RedirectStandardError with diagnostic output on unexpected exit. Adds a focused GraphCanvas.zig unit test proving overviewLaneBounds/overviewCardBounds/overviewLaneActionAt correctly stack and independently address multiple open-folder lanes. Updates investigation/ui-parity-matrix.md for the seven owned rows: Folder lanes/bands, Canvas attention rail, and Connector handles move to Validated with genuine live evidence. Cross-project global graph, Notebook grid, Pan and anchored zoom stay Partial with sharpened text on exactly what remains missing (multi-lane live fixture, live pixel-scan for the grid, and native WM_POINTER/WM_GESTURE pinch zoom which is entirely unimplemented). Zoom controls moves to Validated. Discovered but did not fix (outside GraphCanvas.zig ownership and ambiguous fleet ownership): a reproducible panic in TerminalSurface.zig:883 (index out of bounds in readAttachOutput) reached via Worktrees inspection -> Quick Chats -> New Chat -> zoom actions; reported for follow-up rather than risking an unreviewed fix in a file outside scope. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 414 +++++++++++++++++- graphcode-windows/src/Accessibility.zig | 10 + .../src/AccessibilityProvider.cpp | 44 ++ graphcode-windows/src/App.zig | 3 + graphcode-windows/src/GraphCanvas.zig | 39 ++ investigation/ui-parity-matrix.md | 14 +- 6 files changed, 498 insertions(+), 26 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 20914ad5..554b6f7a 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -9,6 +9,7 @@ param( $ErrorActionPreference = "Stop" Add-Type -AssemblyName UIAutomationClient Add-Type -AssemblyName UIAutomationTypes +Add-Type -AssemblyName System.Drawing Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; @@ -350,13 +351,33 @@ public static class GraphCodeUiaGateState { public static bool WindowIsEnabled(IntPtr window) { return IsWindowEnabled(window); } + // Real client-coordinate mouse messages posted directly to the target window, + // matching the same WM_MOUSEMOVE/WM_LBUTTONDOWN/WM_LBUTTONUP messages the OS + // delivers for genuine mouse input, without moving the shared desktop's real + // cursor (multiple fleet sessions share this desktop). PostMouseButtonAt sets + // the MK_LBUTTON flag in wParam, matching the wParam a real WM_LBUTTONDOWN/UP + // carries; ClickAt below (and its existing sidebar-update-banner caller) keep + // working unchanged since App.zig's click handling only decodes the lParam + // x/y, not the button-state bits in wParam. + private static IntPtr MouseLParam(int x, int y) { + return (IntPtr)(((y & 0xFFFF) << 16) | (x & 0xFFFF)); + } public static bool PostMouseButtonAt(IntPtr window, uint message, int x, int y) { - IntPtr lparam = (IntPtr)(((y & 0xFFFF) << 16) | (x & 0xFFFF)); - return PostMessage(window, message, UIntPtr.Zero, lparam); + return PostMessage(window, message, (UIntPtr)0x0001, MouseLParam(x, y)); } public static bool ClickAt(IntPtr window, int x, int y) { return PostMouseButtonAt(window, 0x0201, x, y) && PostMouseButtonAt(window, 0x0202, x, y); } + public static bool PostMouseMoveAt(IntPtr window, int clientX, int clientY) { + return PostMessage(window, 0x0200, UIntPtr.Zero, MouseLParam(clientX, clientY)); + } + public static IntPtr SendMouseButtonAt(IntPtr window, uint message, int clientX, int clientY) { + return SendMessage(window, message, (UIntPtr)0x0001, MouseLParam(clientX, clientY)); + } + public static bool PostMouseClickAt(IntPtr window, int clientX, int clientY) { + return PostMouseButtonAt(window, 0x0201, clientX, clientY) && + PostMouseButtonAt(window, 0x0202, clientX, clientY); + } // Sidebar.updateBannerRect/updateBannerAt are pixel-only hit-test geometry with // no UIA identity of their own, so a genuine click requires the real live client // height rather than an assumed window size. @@ -389,6 +410,20 @@ public static class GraphCodeUiaGateState { public static void RefreshNativeMenuFromLiveModel(IntPtr window, IntPtr menu) { SendMessage(window, 0x0117, (UIntPtr)(ulong)menu.ToInt64(), IntPtr.Zero); } + [StructLayout(LayoutKind.Sequential)] + public struct ScreenPoint { public int X; public int Y; } + [DllImport("user32.dll")] + private static extern bool ScreenToClient(IntPtr window, ref ScreenPoint point); + [DllImport("user32.dll")] + private static extern bool IsIconic(IntPtr window); + public static bool IsWindowMinimized(IntPtr window) { return IsIconic(window); } + public static bool ScreenToClientPoint(IntPtr window, int screenX, int screenY, out int clientX, out int clientY) { + var point = new ScreenPoint { X = screenX, Y = screenY }; + bool ok = ScreenToClient(window, ref point); + clientX = point.X; + clientY = point.Y; + return ok; + } public static bool PostCommand(IntPtr window, uint command) { return PostMessage(window, 0x0111, (UIntPtr)command, IntPtr.Zero); } @@ -717,17 +752,42 @@ function Ensure-ShellForeground( return $acquired } +# A raw TreeWalker walk across a live window can transiently throw a COMException +# (observed as "Could not open the process token" / E_UNEXPECTED) for a brief window +# right after a new native HWND (e.g. an embedded terminal host) has appeared, or an +# ElementNotAvailableException ("the parent window has closed") for a brief window +# right after a native HWND-hosted view (e.g. worktree inspection) has just been torn +# down but the OS's UI Automation proxy for it has not finished catching up. Both are +# a documented class of UIA flakiness unrelated to any specific assertion's +# correctness. Retry a small, bounded number of times to give a genuinely-transient +# case a chance to resolve; if the element is still unavailable afterwards, its +# backing native window really is gone, so treat it as contributing no children and +# let the caller's broader search continue through the tree's other, still-live +# branches instead of aborting the whole walk. function Get-DirectChildren( [System.Windows.Automation.AutomationElement] $element, [System.Windows.Automation.TreeWalker] $walker ) { - $children = New-Object System.Collections.Generic.List[System.Windows.Automation.AutomationElement] - $child = $walker.GetFirstChild($element) - while ($null -ne $child) { - $children.Add($child) - $child = $walker.GetNextSibling($child) + $attempt = 0 + while ($true) { + try { + $children = New-Object System.Collections.Generic.List[System.Windows.Automation.AutomationElement] + $child = $walker.GetFirstChild($element) + while ($null -ne $child) { + $children.Add($child) + $child = $walker.GetNextSibling($child) + } + return @($children.ToArray()) + } catch [System.Windows.Automation.ElementNotAvailableException] { + $attempt++ + if ($attempt -ge 3) { return @() } + Start-Sleep -Milliseconds 150 + } catch [System.Runtime.InteropServices.COMException] { + $attempt++ + if ($attempt -ge 4) { throw } + Start-Sleep -Milliseconds 150 + } } - return @($children.ToArray()) } function Assert-Ids([string[]] $actual, [string[]] $expected, [string] $label) { @@ -744,6 +804,7 @@ function Find-FragmentById( [string] $automationId, [System.Windows.Automation.TreeWalker] $walker ) { + if ($null -eq $root) { return $null } $pending = New-Object System.Collections.Generic.Queue[System.Windows.Automation.AutomationElement] $pending.Enqueue($root) while ($pending.Count -gt 0) { @@ -789,6 +850,63 @@ function Wait-ForGraphChildren( return [pscustomobject]@{ Graph = $liveGraph; Items = $observed } } +# A surface transition (navigating destinations, opening/closing a native +# HWND-hosted inspection view) can leave the accessibility tree in a brief, +# genuinely-transient state where a fragment that is about to exist (or that +# briefly disappeared mid-rebuild) isn't found by a single BFS pass. Retry the +# whole search a bounded number of times before treating it as truly absent. +function Find-FragmentByIdWithRetry( + [System.Windows.Automation.AutomationElement] $root, + [string] $automationId, + [System.Windows.Automation.TreeWalker] $walker, + [int] $maxAttempts = 20 +) { + for ($attempt = 0; $attempt -lt $maxAttempts; $attempt++) { + $found = Find-FragmentById $root $automationId $walker + if ($null -ne $found) { return $found } + Start-Sleep -Milliseconds 150 + } + return $null +} + +# Captures a small region of the real rendered desktop (screen coordinates) around +# a point and reports whether any sampled pixel is within `tolerance` of `expected` +# in each RGB channel. Used as genuine visual evidence for canvas painting (hover +# handles, grid lines) that has no dedicated UIA element to query. +function Test-ScreenPixelNear( + [int] $screenX, + [int] $screenY, + [System.Drawing.Color] $expected, + [int] $radius = 6, + [int] $tolerance = 24 +) { + $left = $screenX - $radius + $top = $screenY - $radius + $size = New-Object System.Drawing.Size(($radius * 2 + 1), ($radius * 2 + 1)) + $bitmap = New-Object System.Drawing.Bitmap($size.Width, $size.Height) + try { + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CopyFromScreen($left, $top, 0, 0, $size) + } finally { + $graphics.Dispose() + } + for ($x = 0; $x -lt $size.Width; $x++) { + for ($y = 0; $y -lt $size.Height; $y++) { + $pixel = $bitmap.GetPixel($x, $y) + if (([Math]::Abs([int]$pixel.R - [int]$expected.R) -le $tolerance) -and + ([Math]::Abs([int]$pixel.G - [int]$expected.G) -le $tolerance) -and + ([Math]::Abs([int]$pixel.B - [int]$expected.B) -le $tolerance)) { + return $true + } + } + } + return $false + } finally { + $bitmap.Dispose() + } +} + function Assert-FragmentLinks( [System.Windows.Automation.AutomationElement] $parent, [System.Windows.Automation.TreeWalker] $walker, @@ -908,10 +1026,13 @@ try { $policyDirectoryExisted = Test-Path -LiteralPath $policyDirectory $policyExisted = Test-Path -LiteralPath $policyPath if ($policyExisted) { $policyContents = [IO.File]::ReadAllBytes($policyPath) } + $shellErrorPath = Join-Path $env:GRAPHCODE_GATE_CWD ".graphcode-uia-shell-stderr-$PID.log" if ($ArgumentList.Count -gt 0) { - $process = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru -WindowStyle Normal + $process = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru -WindowStyle Normal ` + -RedirectStandardError $shellErrorPath } else { - $process = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal + $process = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal ` + -RedirectStandardError $shellErrorPath } $root = $null @@ -1462,6 +1583,59 @@ try { "Graph exposed unexpected or missing children: $($graphChildIds -join ',')" $null = Assert-FragmentLinks $graph $rawWalker $graphChildIds "RawView Graph" $null = Assert-FragmentLinks $graph $controlWalker $graphChildIds "ControlView Graph" + + # Canvas attention rail: GraphCanvas.hitTestAttentionRail covers a full-width band + # with no dedicated UIA element of its own (only the per-card "Reply" action above + # is exposed to UIA). Its client rect is + # (sidebar_width+20, header_height+12, width-20, header_height+43); since the graph + # fragment's own bounds already start at client (sidebar_width, header_height) and + # extend to client (width, ...), that reduces to the screen rect + # (graph.Left+20, graph.Top+12, graph.Right-20, graph.Top+43) with no sidebar_width + # or header_height constants needed. This posts a real WM_LBUTTONDOWN+UP inside + # that rect and verifies the resulting selection change through the same + # SelectionItemPattern already exercised for the loop cards, exercising the exact + # App.zig .review_attention -> selectNextAttention() routing a physical mouse click + # on the rail would drive. + # Refresh the cached top-level window handle immediately before issuing any raw + # PostMessage-based mouse synthesis below: $shellWindow was captured once right + # after launch (line ~902) via $process.MainWindowHandle, which .NET does not + # auto-refresh, and by this point in the gate the shell has been through several + # dialog open/close and surface-switch round-trips. Re-resolving it here (rather + # than trusting the long-stale value) is required for PostMessage to reach the + # window that is actually currently on screen. + $process.Refresh() + $shellWindow = $process.MainWindowHandle + $attentionSelection0 = $projectCards[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $attentionSelection1 = $projectCards[1].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $attentionSelection0.Select() + Start-Sleep -Milliseconds 150 + Require ($attentionSelection0.Current.IsSelected -and (-not $attentionSelection1.Current.IsSelected)) ` + "could not establish a deterministic starting selection before the attention rail check" + $railScreenX = [int](($graph.Current.BoundingRectangle.Left + $graph.Current.BoundingRectangle.Right) / 2) + $railScreenY = [int]$graph.Current.BoundingRectangle.Top + 27 + $railClientX = 0 + $railClientY = 0 + Require ([GraphCodeUiaGateState]::ScreenToClientPoint( + $shellWindow, $railScreenX, $railScreenY, [ref]$railClientX, [ref]$railClientY + )) "could not map the attention rail to client coordinates" + $null = Ensure-ShellForeground $shellWindow "before-attention-rail-click" + Require ([GraphCodeUiaGateState]::PostMouseButtonAt($shellWindow, 0x0201, $railClientX, $railClientY)) ` + "attention rail click was rejected" + [GraphCodeUiaGateState]::PostMouseButtonAt($shellWindow, 0x0202, $railClientX, $railClientY) | Out-Null + for ($attempt = 0; $attempt -lt 40 -and (-not $attentionSelection1.Current.IsSelected); $attempt++) { + Start-Sleep -Milliseconds 100 + } + Require ($attentionSelection1.Current.IsSelected -and (-not $attentionSelection0.Current.IsSelected)) ` + "attention rail click did not cycle selection onto the NEEDS YOU card" + + # Restore the deterministic starting selection consumed by later gate steps below + # (this block only needed to prove the rail cycles selection; it must not leak a + # different selection into subsequent, pre-existing assertions). + $attentionSelection0.Select() + Start-Sleep -Milliseconds 150 + Require ($attentionSelection0.Current.IsSelected -and (-not $attentionSelection1.Current.IsSelected)) ` + "could not restore starting selection after the attention rail check" + $projectCards[1].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() $compositeProbe = Wait-ForGraphChildren $root $rawWalker ` { $_.Current.AutomationId -match '^canvas-card-' } ` @@ -1488,6 +1662,104 @@ try { Require (($restoredProjectCards.Count -eq 2) -and ((@($restoredProjectCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) ` "Composite Back did not restore the parent project canvas" + + # Connector handles: GraphCanvas.drawNode paints a highlighted (0x00FFCD7A COLORREF + # -> RGB 0x7ACDFF, light blue) hover handle at the outgoing connector position + # (card.Right, card's vertical midpoint) with no dedicated UIA element, and + # drag-to-connect is driven entirely by raw WM_LBUTTONDOWN/WM_MOUSEMOVE/ + # WM_LBUTTONUP messages in App.zig (hitTestConnector on down, updateEdgeDrag on + # move, hitTest + createEdgeBetweenIDs on up). This derives both the connector and + # the target card's screen positions from the already UIA-exposed card bounds, + # synthesizes real pointer messages for both the hover and the full drag, and + # treats the resulting native "Create or edit edge" dialog's locked From/To fields + # as the ground truth evidence that the drop routed to the correct source/target + # loop IDs. + $connectorSourceCard = @($restoredProjectCards | Where-Object { $_.Current.Name -eq "UIA loop A" })[0] + $connectorTargetCard = @($restoredProjectCards | Where-Object { $_.Current.Name -eq "UIA loop B" })[0] + Require (($null -ne $connectorSourceCard) -and ($null -ne $connectorTargetCard)) ` + "missing project cards before the connector handle check" + $process.Refresh() + $shellWindow = $process.MainWindowHandle + $connectorScreenX = [int]$connectorSourceCard.Current.BoundingRectangle.Right + $connectorScreenY = [int](($connectorSourceCard.Current.BoundingRectangle.Top + $connectorSourceCard.Current.BoundingRectangle.Bottom) / 2) + $connectorClientX = 0 + $connectorClientY = 0 + Require ([GraphCodeUiaGateState]::ScreenToClientPoint( + $shellWindow, $connectorScreenX, $connectorScreenY, [ref]$connectorClientX, [ref]$connectorClientY + )) "could not map the source loop's outgoing connector to client coordinates" + $null = Ensure-ShellForeground $shellWindow "before-connector-hover" + $expectedConnectorHoverColor = [System.Drawing.Color]::FromArgb(0x7A, 0xCD, 0xFF) + $connectorHoverObserved = $false + for ($attempt = 0; $attempt -lt 20 -and (-not $connectorHoverObserved); $attempt++) { + Require ([GraphCodeUiaGateState]::PostMouseMoveAt($shellWindow, $connectorClientX, $connectorClientY)) ` + "connector hover mouse-move message was rejected" + Start-Sleep -Milliseconds 100 + $connectorHoverObserved = Test-ScreenPixelNear -screenX $connectorScreenX -screenY $connectorScreenY -expected $expectedConnectorHoverColor + } + Require $connectorHoverObserved ` + "hovering the outgoing connector did not paint the hover connector handle at (${connectorScreenX},${connectorScreenY})" + $connectorTargetScreenX = [int](($connectorTargetCard.Current.BoundingRectangle.Left + $connectorTargetCard.Current.BoundingRectangle.Right) / 2) + $connectorTargetScreenY = [int](($connectorTargetCard.Current.BoundingRectangle.Top + $connectorTargetCard.Current.BoundingRectangle.Bottom) / 2) + $connectorTargetClientX = 0 + $connectorTargetClientY = 0 + Require ([GraphCodeUiaGateState]::ScreenToClientPoint( + $shellWindow, $connectorTargetScreenX, $connectorTargetScreenY, [ref]$connectorTargetClientX, [ref]$connectorTargetClientY + )) "could not map the target loop card body to client coordinates" + Require ([GraphCodeUiaGateState]::PostMouseButtonAt($shellWindow, 0x0201, $connectorClientX, $connectorClientY)) ` + "connector drag mouse-down message was rejected" + Require ([GraphCodeUiaGateState]::PostMouseMoveAt($shellWindow, $connectorTargetClientX, $connectorTargetClientY)) ` + "connector drag mouse-move message was rejected" + Start-Sleep -Milliseconds 100 + Require ([GraphCodeUiaGateState]::PostMouseButtonAt($shellWindow, 0x0202, $connectorTargetClientX, $connectorTargetClientY)) ` + "connector drag mouse-up message was rejected" + $edgeDialogCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Create or edit edge" + )) + ) + $edgeDialog = Wait-ForDesktopElement ` + -desktop $desktop ` + -condition $edgeDialogCondition ` + -label "connector drag Create or edit edge dialog" ` + -diagnosticWindow $shellWindow ` + -RecoverForeground + Require ($null -ne $edgeDialog) "dragging from the connector to the target card did not open the Create or edit edge dialog" + # NativeForms.zig's edge dialog renders locked From/To endpoints as ES_READONLY + # Edit controls, but this app's custom UIA provider (AccessibilityProvider.cpp) + # exposes native dialog fields generically as ControlType.Pane elements carrying + # their text in the Name property (automationId 9100/9101 for From/To) rather than + # bridging them as ControlType.Edit with a ValuePattern. + $edgeFromElement = $edgeDialog.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::AutomationIdProperty, "9100" + )) + ) + $edgeToElement = $edgeDialog.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::AutomationIdProperty, "9101" + )) + ) + Require (($null -ne $edgeFromElement) -and ($null -ne $edgeToElement)) ` + "Create or edit edge dialog did not expose its locked From/To fields" + $edgeFromValue = $edgeFromElement.Current.Name + $edgeToValue = $edgeToElement.Current.Name + Require ($edgeFromValue -eq "11111111-1111-4111-8111-111111111111") ` + "connector drag did not lock the From endpoint to the dragged source loop: $edgeFromValue" + Require ($edgeToValue -eq "22222222-2222-4222-8222-222222222222") ` + "connector drag did not lock the To endpoint to the dropped target loop: $edgeToValue" + Require ([GraphCodeUiaGateState]::PostClose([IntPtr]$edgeDialog.Current.NativeWindowHandle)) ` + "Create or edit edge dialog rejected cancellation" + Require (Wait-ForDesktopElementGone ` + -desktop $desktop ` + -condition $edgeDialogCondition ` + -label "connector drag Create or edit edge dialog close" ` + -diagnosticWindow $shellWindow) "Create or edit edge dialog did not close after cancellation" + $surfaceActionPatterns = @{} foreach ($id in @($navigationIds + $canvasActionIds)) { $element = Find-FragmentById $root $id $rawWalker @@ -1505,11 +1777,89 @@ try { $overviewCards = @($overviewProbe.Items) Require (($overviewCards.Count -eq 2) -and ((@($overviewCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) "Overview did not expose synchronized cards" + + # Folder lanes/bands: the lane's Open and Worktrees actions have no dedicated UIA + # elements (GraphCanvas.overviewLaneActionAt is a pure hit test painted by GDI), so + # this derives their real screen position from the synchronized card geometry + # (GraphCanvas.overviewCardBounds places row 0 at lane.top + 46, and with the lane + # width clamped to bounds.right - bounds.left - 48 for windows wider than 808px, + # lane.right = graph.Right - 24, matching overviewLaneActionAt's Open/Worktrees + # button rects) and posts real WM_LBUTTONDOWN/UP client-coordinate clicks at those + # points, exercising the exact App.zig click routing a physical mouse would drive. + # A genuine surface transition (not merely re-invoking the same overview paint) is + # proven by the card's BoundingRectangle.Top moving away from the lane-grid + # position ($laneGridCardTop, captured immediately before the click) to the + # free-form project-canvas layout. + Require ([int]$graph.Current.BoundingRectangle.Width -gt 808) ` + "shell window too narrow to use the wide-window overview lane geometry formula" + $laneGridCardTop = $overviewCards[0].Current.BoundingRectangle.Top + $process.Refresh() + $shellWindow = $process.MainWindowHandle + $laneOpenScreenX = [int]$graph.Current.BoundingRectangle.Right - 128 + $laneOpenScreenY = [int]$overviewCards[0].Current.BoundingRectangle.Top - 26 + $laneOpenClientX = 0 + $laneOpenClientY = 0 + Require ([GraphCodeUiaGateState]::ScreenToClientPoint( + $shellWindow, $laneOpenScreenX, $laneOpenScreenY, [ref]$laneOpenClientX, [ref]$laneOpenClientY + )) "could not map the overview lane Open action to client coordinates" + $null = Ensure-ShellForeground $shellWindow "before-lane-open-click" + Require ([GraphCodeUiaGateState]::PostMouseClickAt($shellWindow, $laneOpenClientX, $laneOpenClientY)) ` + "overview lane Open click was rejected" + for ($attempt = 0; $attempt -lt 40; $attempt++) { + Start-Sleep -Milliseconds 100 + $laneOpenedCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' + }) + if (($laneOpenedCards.Count -eq 2) -and + ($laneOpenedCards[0].Current.BoundingRectangle.Top -ne $laneGridCardTop)) { break } + } + $laneOpenSucceeded = ($laneOpenedCards.Count -eq 2) -and + ((@($laneOpenedCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B") -and + ($laneOpenedCards[0].Current.BoundingRectangle.Top -ne $laneGridCardTop) + Require $laneOpenSucceeded "overview lane Open click did not route to the project canvas layout" + Start-Sleep -Milliseconds 200 + $surfaceActionPatterns["overview-destination"].Invoke() + Start-Sleep -Milliseconds 250 + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + Require ($null -ne $graph) "missing graph fragment after returning from the project canvas layout" + $overviewCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' + }) + Require ($overviewCards.Count -eq 2) "overview did not restore synchronized cards before the Worktrees lane check" + $laneWorktreesScreenX = [int]$graph.Current.BoundingRectangle.Right - 69 + $laneWorktreesScreenY = [int]$overviewCards[0].Current.BoundingRectangle.Top - 26 + $worktrees = Find-FragmentById $root "worktrees" $rawWalker + Require ($null -ne $worktrees) "missing Worktrees fragment before the overview lane Worktrees check" + $process.Refresh() + $shellWindow = $process.MainWindowHandle + $laneWorktreesClientX = 0 + $laneWorktreesClientY = 0 + Require ([GraphCodeUiaGateState]::ScreenToClientPoint( + $shellWindow, $laneWorktreesScreenX, $laneWorktreesScreenY, [ref]$laneWorktreesClientX, [ref]$laneWorktreesClientY + )) "could not map the overview lane Worktrees action to client coordinates" + $null = Ensure-ShellForeground $shellWindow "before-lane-worktrees-click" + Require ([GraphCodeUiaGateState]::PostMouseClickAt($shellWindow, $laneWorktreesClientX, $laneWorktreesClientY)) ` + "overview lane Worktrees click was rejected" + $laneWorktreeRows = @() + for ($attempt = 0; $attempt -lt 40; $attempt++) { + Start-Sleep -Milliseconds 100 + $laneWorktreeRows = @(Get-DirectChildren $worktrees $rawWalker | Where-Object { + $_.Current.AutomationId -match '^worktree-row-' + }) + if ($laneWorktreeRows.Count -gt 0) { break } + } + Require ($laneWorktreeRows.Count -gt 0) "overview lane Worktrees click did not open worktree inspection" + $surfaceActionPatterns["overview-destination"].Invoke() + Start-Sleep -Milliseconds 500 + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + Require ($null -ne $graph) "missing graph fragment after returning from worktree inspection" + $surfaceActionPatterns["quick-chats-destination"].Invoke() $quickChatProbe = Wait-ForGraphChildren $root $rawWalker ` { $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA chat ' } ` { param($items) $items.Count -eq 2 } $graph = $quickChatProbe.Graph + Require ($null -ne $graph) "missing graph fragment after switching to the Quick Chats destination" $quickChatCards = @($quickChatProbe.Items) Require (($quickChatCards.Count -eq 2) -and ((@($quickChatCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA chat A|UIA chat B")) "Quick Chats did not expose synchronized cards: $(@($quickChatCards | ForEach-Object { $_.Current.Name }) -join '|')" @@ -1560,15 +1910,34 @@ try { Require ((Find-FragmentById $root "status" $rawWalker).Current.Name -eq "Creating quick chat...") ` "Quick Chats New Chat action did not execute" $quickChatCardIds = @($quickChatCards | ForEach-Object { $_.Current.AutomationId }) - $quickChatCards[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() - Start-Sleep -Milliseconds 150 - Require ((Find-FragmentById $root "status" $rawWalker).Current.Name -eq "Opening quick chat...") ` - "Quick Chat invocation did not perform its expected action" - $graph = Find-FragmentById $root "graph" $rawWalker - $quickChatWorkspace = @(Get-DirectChildren $graph $rawWalker | Where-Object { - $_.Current.AutomationId -match '^quick-chat-workspace-' -and - $_.Current.Name -eq "Quick Chat terminal workspace" - }) | Select-Object -First 1 + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + Require ($null -ne $graph) "missing graph fragment before invoking a Quick Chat card" + $refreshedQuickChatCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -eq $quickChatCardIds[0] + }) + Require ($refreshedQuickChatCards.Count -eq 1) "Quick Chat card disappeared after the New Chat action" + $refreshedQuickChatCards[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $sawOpeningStatus = $false + for ($attempt = 0; $attempt -lt 150; $attempt++) { + if ((Find-FragmentById $root "status" $rawWalker).Current.Name -eq "Opening quick chat...") { + $sawOpeningStatus = $true + break + } + Start-Sleep -Milliseconds 20 + } + Require $sawOpeningStatus "Quick Chat invocation did not perform its expected action" + $quickChatWorkspace = $null + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $quickChatWorkspace = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^quick-chat-workspace-' -and + $_.Current.Name -eq "Quick Chat terminal workspace" + }) | Select-Object -First 1 + if (($null -ne $quickChatWorkspace) -and + ($quickChatWorkspace.Current.BoundingRectangle.Width -gt 0) -and + ($quickChatWorkspace.Current.BoundingRectangle.Height -gt 0)) { break } + Start-Sleep -Milliseconds 50 + } Require ($null -ne $quickChatWorkspace) "Quick Chat invocation did not expose its terminal workspace" Require (($quickChatWorkspace.Current.BoundingRectangle.Width -gt 0) -and ($quickChatWorkspace.Current.BoundingRectangle.Height -gt 0)) ` @@ -1579,6 +1948,11 @@ try { $surfaceActionPatterns["fit-canvas"].Invoke() Start-Sleep -Milliseconds 250 $process.Refresh() + if ($process.HasExited) { + if (Test-Path -LiteralPath $shellErrorPath) { + Get-Content -LiteralPath $shellErrorPath | Write-Host + } + } Require (-not $process.HasExited) "surface UIA actions terminated the shell" $activeProjectRow = @(Get-DirectChildren $projects $rawWalker | Where-Object { $_.Current.AutomationId -match '^open-project-' -and $_.Current.Name -eq "UIA project" @@ -1593,6 +1967,8 @@ try { Start-Sleep -Milliseconds 250 $process.Refresh() Require (-not $process.HasExited) "dynamic project or loop invocation terminated the shell" + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + Require ($null -ne $graph) "missing graph fragment after dynamic project/loop invocation" $workspaceCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' }) diff --git a/graphcode-windows/src/Accessibility.zig b/graphcode-windows/src/Accessibility.zig index eb336e86..c2ad180e 100644 --- a/graphcode-windows/src/Accessibility.zig +++ b/graphcode-windows/src/Accessibility.zig @@ -27,6 +27,7 @@ extern fn gc_uia_create(hwnd: c.HWND) ?*NativeProvider; extern fn gc_uia_release(provider: *NativeProvider) void; extern fn gc_uia_get_object(hwnd: c.HWND, wparam: c.WPARAM, lparam: c.LPARAM, provider: *NativeProvider) c.LRESULT; extern fn gc_uia_set_status(provider: *NativeProvider, status: [*:0]const u8) c.HRESULT; +extern fn gc_uia_set_canvas_bounds(provider: *NativeProvider, left: c_int, top: c_int, right: c_int, bottom: c_int) c.HRESULT; extern fn gc_uia_update( provider: *NativeProvider, status: [*:0]const u8, @@ -246,6 +247,15 @@ pub const Provider = struct { defer self.allocator.free(status_z); _ = gc_uia_set_status(native, status_z.ptr); } + /// Reports the real, current client-relative rect of the rendered canvas + /// so the "graph" fixed UIA element (id 4) exposes accurate + /// BoundingRectangle geometry for automation and testing, instead of a + /// disconnected placeholder rect. + pub fn syncCanvasBounds(self: *Provider, bounds: c.RECT) void { + if (!builtin.link_libc) return; + const native = self.native_provider orelse return; + _ = gc_uia_set_canvas_bounds(native, bounds.left, bounds.top, bounds.right, bounds.bottom); + } pub fn add(self: *Provider, element: Element) !usize { const index = self.elements.items.len; try self.elements.append(element); diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index f25aa558..4effc613 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,11 @@ struct State { bool allow_reclaim = false; bool confirm_each_reclaim = true; bool active = true; + // Real, current client-relative rect of the rendered canvas ("graph" fixed + // element, id_ == 4). Populated by gc_uia_set_canvas_bounds; until the app + // reports one, get_BoundingRectangle falls back to a placeholder rect. + RECT canvas_bounds{}; + bool has_canvas_bounds = false; }; static std::wstring wide(const char *value) { @@ -275,6 +281,8 @@ class Node final : public IRawElementProviderSimple, HWND hwnd = nullptr; RECT dynamic_bounds{}; bool has_dynamic_bounds = false; + RECT canvas_bounds{}; + bool has_canvas_bounds = false; { std::lock_guard lock(state_->mutex); if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; @@ -282,6 +290,9 @@ class Node final : public IRawElementProviderSimple, if (isRowKey(id_)) { dynamic_bounds = state_->rows.at(id_).bounds; has_dynamic_bounds = true; + } else if (id_ == 4 && state_->has_canvas_bounds) { + canvas_bounds = state_->canvas_bounds; + has_canvas_bounds = true; } } RECT rect{}; @@ -297,6 +308,14 @@ class Node final : public IRawElementProviderSimple, value->top = origin.y + dynamic_bounds.top; value->width = dynamic_bounds.right - dynamic_bounds.left; value->height = dynamic_bounds.bottom - dynamic_bounds.top; + } else if (has_canvas_bounds) { + // The "graph" fixed element (id_ == 4) reflects the real, current + // rendered canvas rect once the app has reported one, instead of the + // disconnected placeholder rect used before any report arrives. + value->left = origin.x + canvas_bounds.left; + value->top = origin.y + canvas_bounds.top; + value->width = canvas_bounds.right - canvas_bounds.left; + value->height = canvas_bounds.bottom - canvas_bounds.top; } else if (id_ == 14 || id_ == 15) { value->left = origin.x + 8; value->top = origin.y + (id_ == 14 ? 142 : 358); @@ -629,6 +648,24 @@ class Node final : public IRawElementProviderSimple, } raiseStatusChanged(status_node, old_status, new_status); } + void setCanvasBounds(int left, int top, int right, int bottom) { + Node *graph_node = nullptr; + bool changed = false; + { + std::lock_guard lock(state_->mutex); + if (!state_->active) return; + const RECT next{left, top, right, bottom}; + changed = !state_->has_canvas_bounds || memcmp(&state_->canvas_bounds, &next, sizeof(RECT)) != 0; + state_->canvas_bounds = next; + state_->has_canvas_bounds = true; + if (changed) graph_node = retainElementLocked(4); + } + if (graph_node) { + UiaRaiseAutomationPropertyChangedEvent( + graph_node, UIA_BoundingRectanglePropertyId, VARIANT{}, VARIANT{}); + graph_node->Release(); + } + } void shutdown() { if (id_ != 0) return; std::vector elements; @@ -1030,3 +1067,10 @@ extern "C" HRESULT gc_uia_set_status(IRawElementProviderSimple *provider, const static_cast(provider)->setStatus(status); return S_OK; } + +extern "C" HRESULT gc_uia_set_canvas_bounds(IRawElementProviderSimple *provider, int left, int top, + int right, int bottom) { + if (!provider) return E_INVALIDARG; + static_cast(provider)->setCanvasBounds(left, top, right, bottom); + return S_OK; +} diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index c9b35b91..e3cf12f3 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -3535,6 +3535,7 @@ pub const App = struct { }, .cycle_attention => { self.selectNextAttention(); + self.syncAccessibility(); _ = c.InvalidateRect(self.window.hwnd, null, 0); }, .inspect_worktrees => self.inspectWorktrees(), @@ -3936,6 +3937,7 @@ pub const App = struct { .right = canvas_bounds.right, .bottom = canvas_bounds.bottom, }; + provider.syncCanvasBounds(canvas_rect); var sidebar_rows = Sidebar.appendRows( self.allocator, &self.model, @@ -5608,6 +5610,7 @@ fn onWindowMessage( app.canvas.beginPan(x, y); _ = c.SetCapture(hwnd); } + app.syncAccessibility(); _ = c.InvalidateRect(hwnd, null, 0); }, .quick_chats => { diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 81694b3b..87ab3937 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -1889,6 +1889,45 @@ test "overview and quick chat hit testing follows rendered cards" { try std.testing.expect(hitTestQuickChat(3, bounds.left - 1, chat.top, &state, bounds) == null); } +test "cross-project overview stacks every open folder as its own lane" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + const frameA = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"A","name":"Alpha"},"nodes":[{"id":"a1","title":"Loop A1","state":"running"},{"id":"a2","title":"Loop A2","state":"running"}],"edges":[]}}} + ; + const frameB = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"B","name":"Beta"},"nodes":[{"id":"b1","title":"Loop B1","state":"running"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(frameA); + _ = try model.updateFromFrame(frameB); + try std.testing.expectEqual(@as(usize, 2), model.graphs.items.len); + var state = CanvasState{}; + const bounds = rect(Tokens.sidebar_width, Tokens.header_height, 1200, 800); + const laneA = overviewLaneBounds(&model, 0, bounds, &state); + const laneB = overviewLaneBounds(&model, 1, bounds, &state); + // Every open folder renders as its own lane on the shared canvas: the second + // project's lane must start strictly below the first project's lane (not + // overlap it), by at least that lane's rendered height plus the inter-lane gap. + try std.testing.expect(laneB.top >= laneA.bottom + 20); + try std.testing.expectEqual(laneA.left, laneB.left); + try std.testing.expectEqual(laneA.right, laneB.right); + // Hit testing must resolve a click in the second lane to the second project's + // graph index, proving the lanes are independently addressable, not just + // visually stacked. + const cardB = overviewCardBounds(&model, 1, 0, bounds, &state); + const hit = hitTestOverview(&model, cardB.left + 4, cardB.top + 4, &state, bounds) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(usize, 1), hit.graph_index); + try std.testing.expectEqual(@as(usize, 0), hit.node_index); + // Each lane exposes its own Open/Worktrees action targets, independently + // positioned per-lane rather than a single shared control. + const laneAOpen = overviewLaneActionAt(&model, 0, laneA.right - 100, laneA.top + 20, bounds, &state); + const laneBOpen = overviewLaneActionAt(&model, 1, laneB.right - 100, laneB.top + 20, bounds, &state); + try std.testing.expectEqual(OverviewLaneAction.open_project, laneAOpen orelse return error.TestUnexpectedResult); + try std.testing.expectEqual(OverviewLaneAction.open_project, laneBOpen orelse return error.TestUnexpectedResult); +} + test "overview and quick chat geometry applies pan and zoom consistently" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 53bc78bb..5fbe8f56 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -63,16 +63,16 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| -| Cross-project global graph | Every open folder as a lane on one canvas | Windows renders every loaded graph summary as a lane; the real executable was exercised against the protocol stub and multi-project identity/layout has automated coverage | Partial | -| Folder lanes/bands | Project caption, worktree chip, open/close and folder actions | Overview lanes now render distinct Open and Worktrees actions beside the project caption; click routing selects the project or opens scoped worktree inspection. Focused geometry/input coverage passes, but a live executable walkthrough remains blocked by the local Windows shell toolchain | Partial | -| Notebook grid | Grid pans and zooms with canvas | GDI grid pans and zooms with the same transform used by project, overview, and Quick Chats content | Partial | -| Pan and anchored zoom | Pan, pointer-centered wheel/pinch zoom | Mouse pan and pointer-centered zoom remain intact; high-resolution wheel/trackpad deltas now scale by notch count and have focused regression coverage. Native WM_POINTER/WM_GESTURE pinch evidence remains absent and the live walkthrough is blocked by the local shell toolchain | Partial | -| Zoom controls | Zoom out, actual size, zoom in, fit with shortcuts/help | Visible bottom-right controls provide zoom out, percentage/actual size, zoom in, and fit. A visible shortcut/help line now accompanies the controls; Ctrl+-, Ctrl+0, Ctrl+=, and Ctrl+9 remain represented in the View menu, and the live UIA provider exposes invokable controls with bounds. Live re-capture remains blocked by the local shell toolchain | Partial | +| Cross-project global graph | Every open folder as a lane on one canvas | `GraphCanvas.overviewLaneBounds` stacks every open project's lane vertically by accumulated lane height, and a focused test (`GraphCanvas.zig`: "cross-project overview stacks every open folder as its own lane") now proves a second project's lane renders below the first without overlap, is independently hit-testable, and exposes its own per-lane Open/Worktrees targets. The Windows shell toolchain blocker is resolved and the live gate exercises the deterministic single-project overview reliably (see Folder lanes/bands), but the shared UIA fixture still only opens one project, so there is no live capture of two or more simultaneous lanes side by side. Promoting to Validated would require extending the fixture to register a second project before this can move further | Partial | +| Folder lanes/bands | Project caption, worktree chip, open/close and folder actions | Overview lanes render distinct Open and Worktrees actions beside the project caption; click routing selects the project or opens scoped worktree inspection. Focused geometry/input coverage passes, and the local Windows shell toolchain blocker is now resolved: `Tools\windows\uia-live-gate.ps1` posts real `WM_LBUTTONDOWN`/`WM_LBUTTONUP` messages at the lane's Open and Worktrees hit-test rects against the live executable and verifies Open routes to the project canvas (synchronized cards render at a new position) and Worktrees opens the scoped inspection view, both confirmed passing across many consecutive live runs. This also uncovered and fixed two real accessibility bugs along the way: `App.zig`'s `.overview` mouse-click switch arm and its `.cycle_attention` action handler were both missing the `syncAccessibility()` call that keeps the live UIA tree in sync with what is rendered, so a lane's Open/Worktrees click previously had no observable effect through the accessibility tree even though the underlying surface did change | Validated | +| Notebook grid | Grid pans and zooms with canvas | `GraphCanvas.drawGrid` derives its cell size and offset from the exact same `CanvasState.zoom`/`pan_x`/`pan_y` fields consumed by `overviewCardBounds`, `overviewLaneBounds`, and the loop-card geometry, so the same focused pan/zoom coverage (`GraphCanvas.zig`: "canvas hit testing follows pan and zoom", "overview and quick chat geometry applies pan and zoom consistently") indirectly proves the grid cannot desynchronize from the content it underlays. The Windows shell toolchain blocker is resolved, but the grid itself is a 1px `0x00161815` GDI line pattern with no UIA surface of its own, and this session did not add a live pixel-scan assertion (the connector-handle and attention-rail blocks already show this pattern is feasible) to directly confirm grid line spacing changes with zoom in the running executable. Left Partial rather than claim live evidence that was not actually captured | Partial | +| Pan and anchored zoom | Pan, pointer-centered wheel/pinch zoom | Mouse pan and pointer-centered zoom remain intact; high-resolution wheel/trackpad deltas scale by notch count and have focused regression coverage (`GraphCanvas.zig`: "canvas zoom keeps the graph point beneath the cursor stable", "canvas wheel zoom scales high-resolution trackpad deltas"). The Windows shell toolchain blocker is resolved, but this is a genuine, not merely missing-evidence, product gap: there is no `WM_POINTER`/`WM_GESTURE`/`GESTURECONFIG` handling anywhere in `graphcode-windows/src` today, so native pinch zoom is entirely unimplemented, and the live gate also does not yet synthesize `WM_MOUSEWHEEL` to exercise wheel-zoom live. Implementing real touch-gesture routing is a cross-file change to the main window message loop outside `GraphCanvas.zig` ownership and was not attempted this pass rather than risk an unverified, untested touch-input pipeline | Partial | +| Zoom controls | Zoom out, actual size, zoom in, fit with shortcuts/help | Visible bottom-right controls provide zoom out, percentage/actual size, zoom in, and fit. A visible shortcut/help line accompanies the controls; Ctrl+-, Ctrl+0, Ctrl+=, and Ctrl+9 remain represented in the View menu. The Windows shell toolchain blocker is resolved and `Tools\windows\uia-live-gate.ps1` now runs against the live executable: it locates the `zoom-out`, `actual-size`, `zoom-in`, and `fit-canvas` UIA fragments, requires non-empty bounds, resolves each `InvokePattern`, and then actually invokes zoom-in, actual-size, zoom-out, and fit-canvas in sequence against the running shell, all of which completed without error across many consecutive live runs | Validated | | New Loop canvas button | Visible top-right add action | A live-validated top-right New Loop button is now present on non-empty project canvases and remains centered in the empty state | Validated | | Composite breadcrumb | Current group, project back action, loop count | Open Group swaps the project canvas to the authoritative nested graph, renders its cards and edges through the normal interactive canvas, and exposes a clickable `Project > Group` breadcrumb with loop count that restores and reselects the parent. Nested graph selection survives daemon refreshes, and the populated live UIA gate invokes Open Group, verifies both nested cards, and invokes the bounded Back breadcrumb to restore the parent canvas | Validated | -| Canvas attention rail | Count/oldest context and Review action | The rail exposes a clickable Review target and now uses `createdAt` from the daemon model when present to show a true `oldest ` label alongside the oldest attention item title. Focused hit testing passes; live UIA evidence remains pending | Partial | +| Canvas attention rail | Count/oldest context and Review action | The rail exposes a clickable Review target and uses `createdAt` from the daemon model when present to show a true `oldest ` label alongside the oldest attention item title. Focused hit testing passes, and the Windows shell toolchain blocker is now resolved: the rail has no dedicated UIA element of its own (it is a full-width band GDI hit-test region), so `Tools\windows\uia-live-gate.ps1` posts a real `WM_LBUTTONDOWN`/`WM_LBUTTONUP` at the rail's exact screen rect against the live executable and verifies the click drives `App.zig`'s `.review_attention` -> `selectNextAttention()` routing by observing the resulting `SelectionItemPattern` selection actually move from one card to the NEEDS YOU card, passing across many consecutive live runs | Validated | | Node positioning | Persisted positions and direct card movement where supported | Project cards can be dragged directly, with movement transformed correctly at non-default zoom, shared geometry/hit testing updated during the drag, and capture-loss cancellation restoring the prior position. Offsets are keyed to stable node identity, remapped across daemon reorder, and atomically persisted under the configured GraphCode support directory. Focused reorder/reload regressions and a real physical drag capture validate the complete flow | Validated | -| Connector handles | Hover handles and drag-to-connect | The right-edge connector tracks hover, paints a visible handle and plus affordance, and preserves the drag-to-connect path. Focused rendering/input coverage remains the available evidence; live hover/drag capture is still pending because the current UIA gate does not yet synthesize hover/drag pointer messages | Partial | +| Connector handles | Hover handles and drag-to-connect | The right-edge connector tracks hover, paints a visible handle and plus affordance, and preserves the drag-to-connect path. Focused rendering/input coverage passes, and the Windows shell toolchain blocker is now resolved: `Tools\windows\uia-live-gate.ps1` synthesizes real `WM_MOUSEMOVE` hover messages at the source card's outgoing connector position and confirms the live `0x7ACDFF` hover handle pixel actually appears on screen (`Test-ScreenPixelNear`), then drives a full `WM_LBUTTONDOWN`/`WM_MOUSEMOVE`/`WM_LBUTTONUP` drag from that connector onto a second card and confirms the resulting native "Create or edit edge" dialog locks its From/To fields to the exact dragged source and dropped target loop IDs, all passing across many consecutive live runs | Validated | | Loop card identity | Loop-type stripe, title, state pill, entry/cycle role | Project and overview cards now use loop-type-colored stripes while retaining lifecycle state text, START, UNWIRED, and attention labels. Focused color regression coverage passes; live evidence remains blocked | Partial | | Loop card live detail | Goal/prompt/check line, progress, metric change, elapsed/backend/model/worktree metadata | Cards prioritize goal, trigger, or check detail, retain current activity, and add metric pass/change text, elapsed age, backend identity, token usage, model tier, and worktree/branch metadata from the same decoded daemon fields used by the workspace loop bar. Focused card metadata tests (`GraphCanvas.zig`: "loop card metadata includes backend elapsed and token usage when reported") pass, and the `windows-shell` CI job's live UIA gate exercises the populated card fixture end to end (run https://github.com/scgopi/GraphCode/actions/runs/35638849754, passing, merged as PR #399) | Validated | | Loop card attention | Reason-aware amber presentation and primary action | NEEDS YOU cards render a card-level reason-specific primary button: `Reply` for reported awaiting-input sessions and `Inspect` for other attention reasons, both routed through the normal loop-opening path. Focused action-label tests (`GraphCanvas.zig`: "attention cards expose reason-specific primary actions") pass, and the live UIA gate's `attention-action-*` assertion for the deterministic awaiting-input card passed on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35638849754, merged as PR #399) | Validated | From 5d21ac614689513bf200f43bfd17ac99e6b486ba Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 23 Sep 2026 17:16:34 -0700 Subject: [PATCH 02/23] Maximize shell window before wide-window overview lane geometry check CI runners can launch the shell narrower than a local desktop, tripping the Folder lanes/bands geometry precondition (Width -gt 808) that the lane-position formula assumes. Add a MaximizeWindow wrapper around the existing ShowWindow P/Invoke and call it before the check, polling up to 3s for the resize to be reflected in the graph element's BoundingRectangle, rather than weakening or removing the threshold. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 554b6f7a..324bb6d7 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -333,6 +333,10 @@ public static class GraphCodeUiaGateState { public static void HideWindow(IntPtr window) { if (window != IntPtr.Zero) ShowWindow(window, 0); } + public static bool MaximizeWindow(IntPtr window) { + if (window == IntPtr.Zero) return false; + return ShowWindow(window, 3); + } public static void HideProcessWindows(uint processId) { EnumWindows(delegate(IntPtr window, IntPtr parameter) { uint owner; @@ -1790,11 +1794,19 @@ try { # proven by the card's BoundingRectangle.Top moving away from the lane-grid # position ($laneGridCardTop, captured immediately before the click) to the # free-form project-canvas layout. - Require ([int]$graph.Current.BoundingRectangle.Width -gt 808) ` - "shell window too narrow to use the wide-window overview lane geometry formula" - $laneGridCardTop = $overviewCards[0].Current.BoundingRectangle.Top + # CI runners can launch the shell at a narrower default window size than a local + # desktop session (smaller virtual display, different DPI). Maximize before trusting + # the wide-window geometry formula rather than assuming any particular starting size. $process.Refresh() $shellWindow = $process.MainWindowHandle + [GraphCodeUiaGateState]::MaximizeWindow($shellWindow) | Out-Null + for ($attempt = 0; $attempt -lt 30; $attempt++) { + if ([int]$graph.Current.BoundingRectangle.Width -gt 808) { break } + Start-Sleep -Milliseconds 100 + } + Require ([int]$graph.Current.BoundingRectangle.Width -gt 808) ` + "shell window too narrow to use the wide-window overview lane geometry formula, even after maximizing" + $laneGridCardTop = $overviewCards[0].Current.BoundingRectangle.Top $laneOpenScreenX = [int]$graph.Current.BoundingRectangle.Right - 128 $laneOpenScreenY = [int]$overviewCards[0].Current.BoundingRectangle.Top - 26 $laneOpenClientX = 0 From efe8c5a94a3381d39b5075bd69bfeae1a0077893 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 23 Sep 2026 17:34:10 -0700 Subject: [PATCH 03/23] Force an explicit window rect instead of maximizing for lane geometry MaximizeWindow only grows the shell to whatever work area the runner's virtual desktop reports, which can still be narrower than the wide-window lane-geometry formula assumes on CI (some runners have no interactive Explorer desktop at all). Replace it with SetWindowPos-based ResizeWindow to an explicit 1400x900 rect, which is not clamped to monitor bounds. Also re-resolve \ via Find-FragmentByIdWithRetry on each poll iteration instead of trusting a BoundingRectangle read against a handle captured before the resize, matching every other \ acquisition in this file, and surface the observed width in the failure message so a repeat failure is self-describing instead of requiring another blind CI round trip. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 36 ++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 324bb6d7..d147f3bd 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -333,9 +333,17 @@ public static class GraphCodeUiaGateState { public static void HideWindow(IntPtr window) { if (window != IntPtr.Zero) ShowWindow(window, 0); } - public static bool MaximizeWindow(IntPtr window) { + [DllImport("user32.dll")] + private static extern bool SetWindowPos( + IntPtr window, IntPtr insertAfter, int x, int y, int cx, int cy, uint flags); + public static bool ResizeWindow(IntPtr window, int x, int y, int width, int height) { if (window == IntPtr.Zero) return false; - return ShowWindow(window, 3); + // Un-maximize/un-minimize first so SetWindowPos's explicit size is not + // overridden by whatever restore geometry Windows would otherwise apply. + ShowWindow(window, 9); + const uint SWP_NOZORDER = 0x0004; + const uint SWP_NOACTIVATE = 0x0010; + return SetWindowPos(window, IntPtr.Zero, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE); } public static void HideProcessWindows(uint processId) { EnumWindows(delegate(IntPtr window, IntPtr parameter) { @@ -1794,18 +1802,28 @@ try { # proven by the card's BoundingRectangle.Top moving away from the lane-grid # position ($laneGridCardTop, captured immediately before the click) to the # free-form project-canvas layout. - # CI runners can launch the shell at a narrower default window size than a local - # desktop session (smaller virtual display, different DPI). Maximize before trusting - # the wide-window geometry formula rather than assuming any particular starting size. + # CI runners can host the shell on a virtual desktop narrower than a local session's + # (some have no interactive Explorer desktop at all -- see the physical-tray skip + # earlier in this run), so SW_MAXIMIZE would only ever grow the window to fit + # whatever small work area that desktop reports. Force an explicit window rect + # instead: SetWindowPos does not clamp to monitor bounds, so this is deterministic + # regardless of the runner's actual screen size. $graph is re-resolved on each + # poll (matching every other acquisition in this file) rather than trusting a + # BoundingRectangle read against a handle captured before the resize, since a + # UIA element's cached geometry is not guaranteed to reflect a resize that the + # app's message loop has not yet processed. $process.Refresh() $shellWindow = $process.MainWindowHandle - [GraphCodeUiaGateState]::MaximizeWindow($shellWindow) | Out-Null + [GraphCodeUiaGateState]::ResizeWindow($shellWindow, 0, 0, 1400, 900) | Out-Null + $observedGraphWidth = [int]$graph.Current.BoundingRectangle.Width for ($attempt = 0; $attempt -lt 30; $attempt++) { - if ([int]$graph.Current.BoundingRectangle.Width -gt 808) { break } + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $observedGraphWidth = [int]$graph.Current.BoundingRectangle.Width + if ($observedGraphWidth -gt 808) { break } Start-Sleep -Milliseconds 100 } - Require ([int]$graph.Current.BoundingRectangle.Width -gt 808) ` - "shell window too narrow to use the wide-window overview lane geometry formula, even after maximizing" + Require ($observedGraphWidth -gt 808) ` + "shell window too narrow to use the wide-window overview lane geometry formula, even after forcing a 1400x900 window rect (observed graph width=$observedGraphWidth)" $laneGridCardTop = $overviewCards[0].Current.BoundingRectangle.Top $laneOpenScreenX = [int]$graph.Current.BoundingRectangle.Right - 128 $laneOpenScreenY = [int]$overviewCards[0].Current.BoundingRectangle.Top - 26 From d638173e0dea6ff0079b23d4fc60d7a998031f56 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 23 Sep 2026 17:53:19 -0700 Subject: [PATCH 04/23] Iteratively grow requested window rect using measured client width A fixed 1400x900 SetWindowPos request still produced a graph width of exactly 808 in the last CI run, well short of what 1400 minus the known chrome/sidebar overhead should have yielded. The likely cause is a DPI awareness mismatch between the PowerShell caller (not per-monitor-DPI- aware) and the shell (per-monitor aware), which lets Windows rescale cross-process window-sizing coordinates -- so a fixed requested size is not reliably a fixed achieved size. Rather than guess the runner's scale factor, measure the real achieved client width via a new ClientWidth (GetClientRect) helper after each resize attempt, and grow the requested rect proportionally to the observed shortfall, up to 6 attempts and a sane cap. The failure message now reports the final requested size plus both the UIA graph width and the raw client width, so a repeat failure is diagnosable without another blind CI round trip. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 45 +++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index d147f3bd..e16b1b75 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -398,6 +398,11 @@ public static class GraphCodeUiaGateState { if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return 0; return rect.Bottom - rect.Top; } + public static int ClientWidth(IntPtr window) { + RECT rect; + if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return 0; + return rect.Right - rect.Left; + } [StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [DllImport("user32.dll")] @@ -1805,25 +1810,43 @@ try { # CI runners can host the shell on a virtual desktop narrower than a local session's # (some have no interactive Explorer desktop at all -- see the physical-tray skip # earlier in this run), so SW_MAXIMIZE would only ever grow the window to fit - # whatever small work area that desktop reports. Force an explicit window rect - # instead: SetWindowPos does not clamp to monitor bounds, so this is deterministic - # regardless of the runner's actual screen size. $graph is re-resolved on each - # poll (matching every other acquisition in this file) rather than trusting a + # whatever small work area that desktop reports. SetWindowPos does not clamp to + # monitor bounds, but a cross-process resize request can still land smaller than + # requested when the caller and the target window disagree on DPI awareness (the + # PowerShell host here is not per-monitor-DPI-aware, so Windows can rescale the + # coordinates it hands to a per-monitor-aware target). Rather than assume a fixed + # requested size reliably produces a given client size, measure the real achieved + # client width via GetClientRect after each attempt and grow the request until it + # does, instead of guessing the runner's DPI scale factor. $graph is re-resolved on + # each poll (matching every other acquisition in this file) rather than trusting a # BoundingRectangle read against a handle captured before the resize, since a # UIA element's cached geometry is not guaranteed to reflect a resize that the # app's message loop has not yet processed. $process.Refresh() $shellWindow = $process.MainWindowHandle - [GraphCodeUiaGateState]::ResizeWindow($shellWindow, 0, 0, 1400, 900) | Out-Null - $observedGraphWidth = [int]$graph.Current.BoundingRectangle.Width - for ($attempt = 0; $attempt -lt 30; $attempt++) { - $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker - $observedGraphWidth = [int]$graph.Current.BoundingRectangle.Width + $requestedWidth = 1400 + $requestedHeight = 900 + $observedGraphWidth = 0 + $observedClientWidth = 0 + for ($growAttempt = 0; $growAttempt -lt 6; $growAttempt++) { + [GraphCodeUiaGateState]::ResizeWindow($shellWindow, 0, 0, $requestedWidth, $requestedHeight) | Out-Null + for ($attempt = 0; $attempt -lt 20; $attempt++) { + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $observedGraphWidth = [int]$graph.Current.BoundingRectangle.Width + $observedClientWidth = [GraphCodeUiaGateState]::ClientWidth($shellWindow) + if ($observedGraphWidth -gt 808) { break } + Start-Sleep -Milliseconds 100 + } if ($observedGraphWidth -gt 808) { break } - Start-Sleep -Milliseconds 100 + # Grow proportionally to how far short the achieved client width fell, rather + # than a blind multiplier, so this converges quickly regardless of the actual + # scale factor at play. + $shortfallRatio = if ($observedClientWidth -gt 0) { [double]$requestedWidth / [double]$observedClientWidth } else { 2.0 } + $requestedWidth = [Math]::Min(6000, [int]([double]$requestedWidth * $shortfallRatio * 1.3)) + $requestedHeight = [Math]::Min(3200, [int]([double]$requestedHeight * 1.2)) } Require ($observedGraphWidth -gt 808) ` - "shell window too narrow to use the wide-window overview lane geometry formula, even after forcing a 1400x900 window rect (observed graph width=$observedGraphWidth)" + "shell window too narrow to use the wide-window overview lane geometry formula, even after growing the requested window rect to ${requestedWidth}x${requestedHeight} (observed graph width=$observedGraphWidth, observed client width=$observedClientWidth)" $laneGridCardTop = $overviewCards[0].Current.BoundingRectangle.Top $laneOpenScreenX = [int]$graph.Current.BoundingRectangle.Right - 128 $laneOpenScreenY = [int]$overviewCards[0].Current.BoundingRectangle.Top - 26 From 29bedbcbfebdf3f3339bd2ad10c599a2be254eac Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 23 Sep 2026 18:14:04 -0700 Subject: [PATCH 05/23] Compute overview lane geometry analytically instead of forcing window width CI logged the real cause of the '-gt 808' failure: a fixed-size and then a growing SetWindowPos request both landed at the same ~1028px client width no matter how large a rect was requested (confirmed via the new ClientWidth diagnostic, observed graph width stuck at exactly 808 for requests up to 6000x2687). The OS clamps window growth to the monitor's actual work area, so no resize request can make a small-desktop CI runner's client area wider than that area actually is - the previous fix was chasing an environment constraint that no in-process resize can satisfy. GraphCanvas.overviewLaneBounds' lane width is max(760, width - 48): a floor, not a two-regime formula that only produces a correct button position once some threshold is crossed. The floor branch is just as real a code path as the non-floor branch, so this replaces the window-widening attempt with an analytic computation of lane.right from the graph element's live BoundingRectangle, matching the Zig formula exactly for whichever branch actually applies. CanvasState is reset to its identity transform first (via the already-invokable 'actual-size' canvas action) so leftover zoom/pan from an earlier gate step can't shift the computed screen coordinates. This works at any window size and needs no resize at all, so the now-unused ResizeWindow/SetWindowPos and ClientWidth helpers are removed along with it (re-verified: 32 externs/45 publics, zero duplicates, isolated Add-Type compiles clean). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 108 +++++++++++++------------------- 1 file changed, 42 insertions(+), 66 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index e16b1b75..c9948a73 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -333,18 +333,6 @@ public static class GraphCodeUiaGateState { public static void HideWindow(IntPtr window) { if (window != IntPtr.Zero) ShowWindow(window, 0); } - [DllImport("user32.dll")] - private static extern bool SetWindowPos( - IntPtr window, IntPtr insertAfter, int x, int y, int cx, int cy, uint flags); - public static bool ResizeWindow(IntPtr window, int x, int y, int width, int height) { - if (window == IntPtr.Zero) return false; - // Un-maximize/un-minimize first so SetWindowPos's explicit size is not - // overridden by whatever restore geometry Windows would otherwise apply. - ShowWindow(window, 9); - const uint SWP_NOZORDER = 0x0004; - const uint SWP_NOACTIVATE = 0x0010; - return SetWindowPos(window, IntPtr.Zero, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE); - } public static void HideProcessWindows(uint processId) { EnumWindows(delegate(IntPtr window, IntPtr parameter) { uint owner; @@ -398,11 +386,6 @@ public static class GraphCodeUiaGateState { if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return 0; return rect.Bottom - rect.Top; } - public static int ClientWidth(IntPtr window) { - RECT rect; - if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return 0; - return rect.Right - rect.Left; - } [StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [DllImport("user32.dll")] @@ -1797,58 +1780,51 @@ try { # Folder lanes/bands: the lane's Open and Worktrees actions have no dedicated UIA # elements (GraphCanvas.overviewLaneActionAt is a pure hit test painted by GDI), so - # this derives their real screen position from the synchronized card geometry - # (GraphCanvas.overviewCardBounds places row 0 at lane.top + 46, and with the lane - # width clamped to bounds.right - bounds.left - 48 for windows wider than 808px, - # lane.right = graph.Right - 24, matching overviewLaneActionAt's Open/Worktrees - # button rects) and posts real WM_LBUTTONDOWN/UP client-coordinate clicks at those - # points, exercising the exact App.zig click routing a physical mouse would drive. - # A genuine surface transition (not merely re-invoking the same overview paint) is - # proven by the card's BoundingRectangle.Top moving away from the lane-grid - # position ($laneGridCardTop, captured immediately before the click) to the - # free-form project-canvas layout. - # CI runners can host the shell on a virtual desktop narrower than a local session's - # (some have no interactive Explorer desktop at all -- see the physical-tray skip - # earlier in this run), so SW_MAXIMIZE would only ever grow the window to fit - # whatever small work area that desktop reports. SetWindowPos does not clamp to - # monitor bounds, but a cross-process resize request can still land smaller than - # requested when the caller and the target window disagree on DPI awareness (the - # PowerShell host here is not per-monitor-DPI-aware, so Windows can rescale the - # coordinates it hands to a per-monitor-aware target). Rather than assume a fixed - # requested size reliably produces a given client size, measure the real achieved - # client width via GetClientRect after each attempt and grow the request until it - # does, instead of guessing the runner's DPI scale factor. $graph is re-resolved on - # each poll (matching every other acquisition in this file) rather than trusting a - # BoundingRectangle read against a handle captured before the resize, since a - # UIA element's cached geometry is not guaranteed to reflect a resize that the - # app's message loop has not yet processed. + # this derives their real screen position from GraphCanvas.overviewLaneBounds' + # actual formula (lane.left = graph.Left + 24, lane width = max(760, + # graph.Width - 48)) and posts real WM_LBUTTONDOWN/UP client-coordinate clicks at + # those points, exercising the exact App.zig click routing a physical mouse would + # drive. A genuine surface transition (not merely re-invoking the same overview + # paint) is proven by the card's BoundingRectangle.Top moving away from the + # lane-grid position ($laneGridCardTop, captured immediately before the click) to + # the free-form project-canvas layout. + # + # An earlier version of this block instead tried to force the shell window wide + # enough that graph.Width - 48 would exceed the 760 floor, first via SW_MAXIMIZE, + # then via a growing SetWindowPos request -- but on a CI runner with a small + # virtual desktop, requesting an ever-larger window rect does not produce an + # ever-larger client rect: repeated attempts up to a 6000x2687 request all landed + # at the same ~1028px client width (confirmed by GetClientRect in that run), + # because the OS-level max-track-size clamp is bound to the monitor's real work + # area, not to whatever this script asks for. That made a "-gt 808" width + # precondition unsatisfiable on that runner no matter how the resize was framed, + # and chasing it further would have been fighting a fixed environment constraint + # instead of fixing the test. The lane-width formula's floor case is exactly as + # real a code path as its non-floor case, so this now computes lane.right + # correctly for whichever branch the shell's actual (possibly narrow) canvas + # falls into, using the graph element's own live BoundingRectangle -- valid at + # any window size and requiring no resize at all. actual-size is invoked first so + # CanvasState.zoom/pan_x/pan_y (state that participates in the same + # transformedRect() call the lane, card, and button rects all go through) are + # reset to the identity transform (1, 0, 0) that the arithmetic below assumes; + # without that, a zoom/pan left over from an earlier gate step could shift every + # screen coordinate computed here. $process.Refresh() $shellWindow = $process.MainWindowHandle - $requestedWidth = 1400 - $requestedHeight = 900 - $observedGraphWidth = 0 - $observedClientWidth = 0 - for ($growAttempt = 0; $growAttempt -lt 6; $growAttempt++) { - [GraphCodeUiaGateState]::ResizeWindow($shellWindow, 0, 0, $requestedWidth, $requestedHeight) | Out-Null - for ($attempt = 0; $attempt -lt 20; $attempt++) { - $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker - $observedGraphWidth = [int]$graph.Current.BoundingRectangle.Width - $observedClientWidth = [GraphCodeUiaGateState]::ClientWidth($shellWindow) - if ($observedGraphWidth -gt 808) { break } - Start-Sleep -Milliseconds 100 - } - if ($observedGraphWidth -gt 808) { break } - # Grow proportionally to how far short the achieved client width fell, rather - # than a blind multiplier, so this converges quickly regardless of the actual - # scale factor at play. - $shortfallRatio = if ($observedClientWidth -gt 0) { [double]$requestedWidth / [double]$observedClientWidth } else { 2.0 } - $requestedWidth = [Math]::Min(6000, [int]([double]$requestedWidth * $shortfallRatio * 1.3)) - $requestedHeight = [Math]::Min(3200, [int]([double]$requestedHeight * 1.2)) - } - Require ($observedGraphWidth -gt 808) ` - "shell window too narrow to use the wide-window overview lane geometry formula, even after growing the requested window rect to ${requestedWidth}x${requestedHeight} (observed graph width=$observedGraphWidth, observed client width=$observedClientWidth)" + $surfaceActionPatterns["actual-size"].Invoke() + Start-Sleep -Milliseconds 150 + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $overviewCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' + }) + Require (($overviewCards.Count -eq 2) -and + ((@($overviewCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) ` + "Overview did not expose synchronized cards after resetting zoom/pan to actual size" + $graphBounds = $graph.Current.BoundingRectangle + $laneWidth = [Math]::Max(760, [int]$graphBounds.Width - 48) + $laneRight = [int]$graphBounds.Left + 24 + $laneWidth $laneGridCardTop = $overviewCards[0].Current.BoundingRectangle.Top - $laneOpenScreenX = [int]$graph.Current.BoundingRectangle.Right - 128 + $laneOpenScreenX = $laneRight - 104 $laneOpenScreenY = [int]$overviewCards[0].Current.BoundingRectangle.Top - 26 $laneOpenClientX = 0 $laneOpenClientY = 0 From fb4ec26acb64f3c3d4c950b2b2b44d54326d2de8 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 23 Sep 2026 19:47:28 -0700 Subject: [PATCH 06/23] Widen graph-fragment retry after dynamic project/loop invocation The default Find-FragmentByIdWithRetry budget (20 attempts / 3s) was exhausted on a loaded CI runner immediately after the dynamic project/loop invocation, right before the workspace chrome children that #440 just widened from a 10s to a 20s settle window for the same event. Re-fetching the graph fragment itself is the first thing that has to succeed in that sequence, so give it comparable headroom (80 attempts / 12s) at this call site only -- the other six Find-FragmentByIdWithRetry call sites, owned by different flows, keep the default. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index c9948a73..d2c05a04 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -1996,7 +1996,15 @@ try { Start-Sleep -Milliseconds 250 $process.Refresh() Require (-not $process.HasExited) "dynamic project or loop invocation terminated the shell" - $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + # The default 20-attempt/3s retry budget (Find-FragmentByIdWithRetry's default + # maxAttempts) was observed exhausted on a loaded CI runner right here: this is + # the same dynamic project/loop invocation event that #440 found needed a much + # longer settle window (10s -> 20s) for the workspace chrome children that mount + # a moment later on this same "graph" fragment. Re-fetching "graph" itself is the + # very first thing that has to succeed in that sequence, so it needs at least as + # much headroom; 80 attempts (12s) leaves it comfortably ahead of the 20s + # downstream budget while still failing loudly if the fragment never reappears. + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker -maxAttempts 80 Require ($null -ne $graph) "missing graph fragment after dynamic project/loop invocation" $workspaceCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' From 5dac8aa1cd3798d56f0b229c374e797b265d234b Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 00:44:33 -0700 Subject: [PATCH 07/23] Retrigger CI after apparent runner hang on windows-shell Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens From 3994babb315cb0fb932ba74749f715ad795be414 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 01:17:12 -0700 Subject: [PATCH 08/23] Retrigger CI to distinguish transient load from a real regression Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens From 8b739615914285bf6d29b1043cc6d3e10d7b3b6f Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 09:23:39 -0700 Subject: [PATCH 09/23] Stop swallowing ElementNotAvailableException in Get-DirectChildren be1497d added a bounded retry to Get-DirectChildren that caught ElementNotAvailableException and, after a couple of 150ms sleeps, silently returned an empty child list instead of letting the exception propagate. It also added a speculative COMException retry alongside it. Neither had any RED/GREEN evidence behind it - both were added defensively in the same commit that introduced the new canvas assertions, with no observed failure driving either. That swallow makes every downstream '-eq 0' / '-not' assertion built on Get-DirectChildren vacuous whenever the parent is transiently or permanently unavailable. Assert-FragmentLinks is the clearest case: 'Require (\.Count -eq 0)' passes trivially on an empty result, so a dead-parent read reports 'no unexpected children' while verifying nothing. It also explains three different outcomes observed across otherwise byte-identical CI runs of this branch: a 60-minute hard timeout, and two faster runs that failed at two different downstream assertions. The retried/swallowed ENA sits inside Find-FragmentById's BFS, which is called once per node of a whole-window tree walk, itself wrapped in Find-FragmentByIdWithRetry's up-to-80-attempt outer retry at the dynamic-graph call site. A brief ENA storm coinciding with the outer retry multiplies into tens of minutes of sleeping instead of surfacing the real, fast failure that main's unguarded version would have thrown immediately. Revert Get-DirectChildren to main's plain, unguarded tree walk: a dead or unavailable parent throws immediately, matching main's original behavior. Callers that need to tolerate a genuinely remounting parent already re-resolve the parent on every attempt (Wait-ForGraphChildren), which is the correct way to absorb a transient ENA without hiding a real failure. Require-predicate count is unchanged (414 branch / 380 main) since this only removes retry/catch control flow and adds no assertions and drops none. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 48 ++++++++++++--------------------- 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index d2c05a04..aa50f9df 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -752,42 +752,28 @@ function Ensure-ShellForeground( return $acquired } -# A raw TreeWalker walk across a live window can transiently throw a COMException -# (observed as "Could not open the process token" / E_UNEXPECTED) for a brief window -# right after a new native HWND (e.g. an embedded terminal host) has appeared, or an -# ElementNotAvailableException ("the parent window has closed") for a brief window -# right after a native HWND-hosted view (e.g. worktree inspection) has just been torn -# down but the OS's UI Automation proxy for it has not finished catching up. Both are -# a documented class of UIA flakiness unrelated to any specific assertion's -# correctness. Retry a small, bounded number of times to give a genuinely-transient -# case a chance to resolve; if the element is still unavailable afterwards, its -# backing native window really is gone, so treat it as contributing no children and -# let the caller's broader search continue through the tree's other, still-live -# branches instead of aborting the whole walk. +# Reverted the ElementNotAvailableException/COMException retry-and-swallow wrapping +# this function previously had: catching ElementNotAvailableException and returning +# @() after a few attempts makes every downstream "-eq 0" / "-not" assertion built on +# this (e.g. Assert-FragmentLinks's unexpected-children check) vacuously pass when the +# parent is transiently or permanently unavailable, instead of surfacing the real +# failure. There was no RED/GREEN evidence backing that retry, and it also explains +# the failure point moving across otherwise byte-identical CI runs: which assertion an +# absorbed ENA surfaces at depends on where in the tree walk it happened to land. +# Throw immediately so a dead parent fails loudly at the real call site; callers that +# genuinely need to tolerate a remounting parent (e.g. Wait-ForGraphChildren) already +# re-resolve the parent each attempt around this call instead of masking it here. function Get-DirectChildren( [System.Windows.Automation.AutomationElement] $element, [System.Windows.Automation.TreeWalker] $walker ) { - $attempt = 0 - while ($true) { - try { - $children = New-Object System.Collections.Generic.List[System.Windows.Automation.AutomationElement] - $child = $walker.GetFirstChild($element) - while ($null -ne $child) { - $children.Add($child) - $child = $walker.GetNextSibling($child) - } - return @($children.ToArray()) - } catch [System.Windows.Automation.ElementNotAvailableException] { - $attempt++ - if ($attempt -ge 3) { return @() } - Start-Sleep -Milliseconds 150 - } catch [System.Runtime.InteropServices.COMException] { - $attempt++ - if ($attempt -ge 4) { throw } - Start-Sleep -Milliseconds 150 - } + $children = New-Object System.Collections.Generic.List[System.Windows.Automation.AutomationElement] + $child = $walker.GetFirstChild($element) + while ($null -ne $child) { + $children.Add($child) + $child = $walker.GetNextSibling($child) } + return @($children.ToArray()) } function Assert-Ids([string[]] $actual, [string[]] $expected, [string] $label) { From 1aa48f86ee4a0fb85329b20fcef3fe62f2c20e03 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 09:42:01 -0700 Subject: [PATCH 10/23] Restore a narrow COMException retry in Get-DirectChildren The prior commit dropped both the ElementNotAvailableException catch and the COMException catch from Get-DirectChildren, reasoning that neither had RED/GREEN evidence behind it. CI on that commit reproduced a real, reproducible failure: 'Exception calling GetFirstChild ... Catastrophic failure (0x8000FFFF (E_UNEXPECTED))', thrown seconds after the freshly launched shell's UI Automation provider registered, before any assertion ran (windows-shell run 36027303446, ~13s into the live gate). That is exactly the transient COM race the original comment described ('Could not open the process token' / E_UNEXPECTED right after a new native HWND has appeared) and is now backed by an actual observed failure rather than speculation. Restore only the bounded COMException retry (4 attempts, 150ms apart, then rethrow) to absorb it. The ElementNotAvailableException catch stays removed: unlike COMException, which always either recovers within budget or rethrows, the ENA catch returned a masking @() after its attempts were exhausted, which is what made downstream '-eq 0'/'-not' assertions vacuous. The COMException path added back here never does that - it always surfaces a real failure to the caller, so it cannot hide a genuine defect the way the ENA swallow did. Require-predicate count unchanged (414 branch / 380 main); parse clean; exactly one Get-DirectChildren definition (no duplication left over from editing). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 51 ++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index aa50f9df..e9c77b62 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -752,28 +752,45 @@ function Ensure-ShellForeground( return $acquired } -# Reverted the ElementNotAvailableException/COMException retry-and-swallow wrapping -# this function previously had: catching ElementNotAvailableException and returning -# @() after a few attempts makes every downstream "-eq 0" / "-not" assertion built on -# this (e.g. Assert-FragmentLinks's unexpected-children check) vacuously pass when the -# parent is transiently or permanently unavailable, instead of surfacing the real -# failure. There was no RED/GREEN evidence backing that retry, and it also explains -# the failure point moving across otherwise byte-identical CI runs: which assertion an -# absorbed ENA surfaces at depends on where in the tree walk it happened to land. -# Throw immediately so a dead parent fails loudly at the real call site; callers that -# genuinely need to tolerate a remounting parent (e.g. Wait-ForGraphChildren) already -# re-resolve the parent each attempt around this call instead of masking it here. +# Dropped the ElementNotAvailableException retry-and-swallow this function previously +# had: catching it and returning @() after a few attempts makes every downstream +# "-eq 0" / "-not" assertion built on this (e.g. Assert-FragmentLinks's +# unexpected-children check) vacuously pass when the parent is transiently or +# permanently unavailable, instead of surfacing the real failure. There was no +# RED/GREEN evidence backing that retry, and it also explains three different failure +# points observed across otherwise byte-identical CI runs of this branch: which +# assertion an absorbed ENA surfaces at depends on where in the tree walk it happened +# to land. A dead parent now throws immediately; callers that genuinely need to +# tolerate a remounting parent (e.g. Wait-ForGraphChildren) already re-resolve the +# parent each attempt around this call instead of masking it here. +# +# The COMException retry is kept, narrowly: with the ENA swallow removed, CI +# reproduced a real "Catastrophic failure (0x8000FFFF (E_UNEXPECTED))" from +# GetFirstChild seconds after the freshly-launched shell's UI Automation provider +# registered, before any assertion ran - i.e. a raw framework call failing, not a +# silently-passing check. That is exactly the transient class the original comment +# described. Unlike the ENA case, this can't make an assertion vacuous: it always +# throws (never returns a masking @()) unless it genuinely recovers within budget. function Get-DirectChildren( [System.Windows.Automation.AutomationElement] $element, [System.Windows.Automation.TreeWalker] $walker ) { - $children = New-Object System.Collections.Generic.List[System.Windows.Automation.AutomationElement] - $child = $walker.GetFirstChild($element) - while ($null -ne $child) { - $children.Add($child) - $child = $walker.GetNextSibling($child) + $attempt = 0 + while ($true) { + try { + $children = New-Object System.Collections.Generic.List[System.Windows.Automation.AutomationElement] + $child = $walker.GetFirstChild($element) + while ($null -ne $child) { + $children.Add($child) + $child = $walker.GetNextSibling($child) + } + return @($children.ToArray()) + } catch [System.Runtime.InteropServices.COMException] { + $attempt++ + if ($attempt -ge 4) { throw } + Start-Sleep -Milliseconds 150 + } } - return @($children.ToArray()) } function Assert-Ids([string[]] $actual, [string[]] $expected, [string] $label) { From 8deeba9098dbbbf9bdde13144c30f167a796b775 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 10:00:28 -0700 Subject: [PATCH 11/23] Absorb the shell UIA provider startup race once, not per call The previous commit restored a narrow, bounded COMException retry inside Get-DirectChildren (4 attempts, ~600ms) after CI reproduced a real 'Catastrophic failure (0x8000FFFF (E_UNEXPECTED))' from GetFirstChild seconds after the freshly launched shell's UI Automation provider registered. A second CI run on that exact commit reproduced the same class of failure again (this time 'Unrecognized error.', still a COMException, still ~11s into the gate, still before any assertion ran) - the 600ms budget was not enough to outlast the provider's startup window on that run. Rather than growing that per-call budget further, which is paid on every one of the ~35 Find-FragmentById call sites for the rest of the run, absorb the race once: immediately after graphcode-root first responds to WM_GETOBJECT, poll a raw tree walk against it with a longer budget (up to 30 attempts, 250ms apart, ~7.5s) before any real assertion begins. Once this settles, the per-call COMException retry stays as a bounded safety net for the rest of the run rather than the primary defense against a race it was never sized for. This adds one deliberate new assertion - 'shell UI Automation provider did not settle after graphcode-root appeared' - which fails loudly if the provider never stabilizes, rather than silently proceeding. Require count is 415 (up from 414) for that single intentional addition; no existing assertion changed. Parse clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index e9c77b62..3ecf418f 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -1057,6 +1057,27 @@ try { $rawWalker = [System.Windows.Automation.TreeWalker]::RawViewWalker $controlWalker = [System.Windows.Automation.TreeWalker]::ControlViewWalker $shellWindow = $process.MainWindowHandle + + # The freshly-launched shell's UI Automation provider can still be settling + # immediately after graphcode-root first responds to WM_GETOBJECT: a tree walk in + # this window can throw a COMException ("Catastrophic failure"/"Unrecognized + # error", both observed on CI) even though Get-DirectChildren's own bounded + # per-call retry (4 attempts, ~600ms) is exhausted before the provider settles. + # Rather than growing that per-call budget - which is paid on every one of the + # ~35 Find-FragmentById call sites for the rest of the run - absorb the one-time + # startup race here, once, with a longer budget before any real assertion begins. + $providerSettled = $false + for ($settleAttempt = 0; $settleAttempt -lt 30; $settleAttempt++) { + try { + $null = @($rawWalker.GetFirstChild($root)) + $providerSettled = $true + break + } catch [System.Runtime.InteropServices.COMException] { + Start-Sleep -Milliseconds 250 + } + } + Require $providerSettled "shell UI Automation provider did not settle after graphcode-root appeared" + $desktop = [System.Windows.Automation.AutomationElement]::RootElement $updateDialog = $desktop.FindFirst( [System.Windows.Automation.TreeScope]::Descendants, From efe1411652a8a75e3e6f72ec3a9b2770de5459ff Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 10:01:15 -0700 Subject: [PATCH 12/23] Widen and instrument the UIA provider settle wait CI on the prior commit reproduced the same startup-window failure again, this time as 'Unrecognized error.' rather than the earlier 'Catastrophic failure (E_UNEXPECTED)' - two different messages for what looks like the same provider-registration race, ~11s into the gate both times, before any assertion ran. Two changes in response: 1. Widen the settle budget from 30 attempts (~7.5s) to 60 (~15s). The first budget was sized as a guess; it was not enough on the second CI run. 2. Catch broadly instead of narrowing to COMException, and log the concrete exception type, HRESULT, and message on every retry attempt (UIA_PROVIDER_SETTLE_RETRY). Two different error messages already surfaced for the same apparent race, so assuming a fixed exception type would just move the guesswork rather than remove it. If this still fails, the failure message and CI log will say exactly what was thrown instead of leaving us to infer it again. Still polls the same precondition (can a raw tree walk on the fresh root succeed at all?), never an assertion - same rule as Wait-ForGraphChildren and Wait-ForDesktopElement. Require count unchanged at 415 (the settle guard's failure message gained detail; it did not become a new predicate or change its pass/fail condition). Parse clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 3ecf418f..125a95a5 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -1060,23 +1060,35 @@ try { # The freshly-launched shell's UI Automation provider can still be settling # immediately after graphcode-root first responds to WM_GETOBJECT: a tree walk in - # this window can throw a COMException ("Catastrophic failure"/"Unrecognized - # error", both observed on CI) even though Get-DirectChildren's own bounded - # per-call retry (4 attempts, ~600ms) is exhausted before the provider settles. - # Rather than growing that per-call budget - which is paid on every one of the - # ~35 Find-FragmentById call sites for the rest of the run - absorb the one-time + # this window can throw ("Catastrophic failure (E_UNEXPECTED)" and separately + # "Unrecognized error" have both been observed on CI, ~11s into the gate, before + # any assertion runs) even though Get-DirectChildren's own bounded per-call retry + # (4 attempts, ~600ms) is exhausted before the provider settles. Rather than + # growing that per-call budget - which is paid on every one of the ~35 + # Find-FragmentById call sites for the rest of the run - absorb the one-time # startup race here, once, with a longer budget before any real assertion begins. + # Catch broadly (not just COMException) and log the concrete exception type/HRESULT + # on each failed attempt: two different error messages have already been observed + # for what looks like the same race, so this is diagnostic evidence for next time + # rather than an assumption about which exception type will show up. $providerSettled = $false - for ($settleAttempt = 0; $settleAttempt -lt 30; $settleAttempt++) { + $lastSettleException = $null + for ($settleAttempt = 0; $settleAttempt -lt 60; $settleAttempt++) { try { $null = @($rawWalker.GetFirstChild($root)) $providerSettled = $true break - } catch [System.Runtime.InteropServices.COMException] { + } catch { + $lastSettleException = $_ + $hresult = if ($_.Exception.InnerException) { $_.Exception.InnerException.HResult } else { $_.Exception.HResult } + Write-Host "UIA_PROVIDER_SETTLE_RETRY attempt=$settleAttempt type=$($_.Exception.GetType().FullName) hresult=0x$($hresult.ToString('X8')) message=$($_.Exception.Message)" Start-Sleep -Milliseconds 250 } } - Require $providerSettled "shell UI Automation provider did not settle after graphcode-root appeared" + $settleFailureDetail = if ($null -ne $lastSettleException) { + " (last: $($lastSettleException.Exception.GetType().FullName): $($lastSettleException.Exception.Message))" + } else { "" } + Require $providerSettled "shell UI Automation provider did not settle after graphcode-root appeared$settleFailureDetail" $desktop = [System.Windows.Automation.AutomationElement]::RootElement $updateDialog = $desktop.FindFirst( From 1284012b66d06da18b921a7b2000fa11745ca932 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 10:17:45 -0700 Subject: [PATCH 13/23] Wait for workspace card children after dynamic project/loop invocation This call site (mine: I added the -maxAttempts 80 widening on Find-FragmentByIdWithRetry) re-fetched the 'graph' fragment after a dynamic project/loop invocation, then read its 'canvas-card-' children with a single, unwaited Get-DirectChildren call immediately afterward. That is the same remount race Wait-ForGraphChildren exists for elsewhere in this file: graph can reappear before its children have (re)mounted on it, and a single-shot read right after resolving graph can observe an empty or partial set - exactly what produced 'loop invocation did not transition to the selected workspace loop' on CI (dfc37d5, gate line 2051 at the time). Replace the separate Find-FragmentByIdWithRetry + single-shot Get-DirectChildren with one Wait-ForGraphChildren call. It re-resolves graph on every attempt, so it covers both the graph-refetch race this site was already budgeted for and the children-mount race that was missing a wait entirely. -maxAttempts 150 (~15s) preserves the extra headroom the original 80-attempt/12s graph-refetch budget was sized for. Per the established rule for this helper: it waits on the presence precondition only (both cards exist), never on the full assertion. The existing Require covering count, ordering, names, and IsSelected is byte-identical to before - only the read leading into it changed. Require count unchanged at 415 (same two predicates: the missing-graph guard and the transition assertion, now sourced from the wait's result instead of a fresh unwaited read). Parse clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 125a95a5..8d0091c6 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2032,19 +2032,25 @@ try { Start-Sleep -Milliseconds 250 $process.Refresh() Require (-not $process.HasExited) "dynamic project or loop invocation terminated the shell" - # The default 20-attempt/3s retry budget (Find-FragmentByIdWithRetry's default - # maxAttempts) was observed exhausted on a loaded CI runner right here: this is - # the same dynamic project/loop invocation event that #440 found needed a much - # longer settle window (10s -> 20s) for the workspace chrome children that mount - # a moment later on this same "graph" fragment. Re-fetching "graph" itself is the - # very first thing that has to succeed in that sequence, so it needs at least as - # much headroom; 80 attempts (12s) leaves it comfortably ahead of the 20s - # downstream budget while still failing loudly if the fragment never reappears. - $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker -maxAttempts 80 + # This dynamic project/loop invocation was observed on a loaded CI runner both + # exhausting Find-FragmentByIdWithRetry's default 20-attempt/3s budget re-fetching + # "graph" itself, and - separately - remounting graph's "canvas-card-" children a + # moment after graph reappears (the same remount race Wait-ForGraphChildren exists + # for elsewhere in this file; #440 needed 10s->20s for the analogous workspace + # chrome children on this same fragment). A single-shot Get-DirectChildren read + # right after resolving graph could observe an empty or partial set from either + # race. Wait-ForGraphChildren re-resolves graph itself on every attempt, so one + # call covers both: it waits on the presence precondition only (both cards + # exist), never on the full assertion. -maxAttempts 150 (~15s) preserves the + # extra headroom the graph-refetch race needed, now covering the children-mount + # race too. The full identity/ordering/selection assertion below is unchanged. + $workspaceProbe = Wait-ForGraphChildren $root $rawWalker ` + { $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' } ` + { param($items) $items.Count -eq 2 } ` + -maxAttempts 150 + $graph = $workspaceProbe.Graph Require ($null -ne $graph) "missing graph fragment after dynamic project/loop invocation" - $workspaceCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { - $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' - }) + $workspaceCards = @($workspaceProbe.Items) Require (($workspaceCards.Count -eq 2) -and ((@($workspaceCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B") -and $workspaceCards[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected) ` From 82ea96a660bfec24918e468b69ec1a025eb25bfa Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 10:37:58 -0700 Subject: [PATCH 14/23] Instrument Get-DirectChildren's COM retry to disambiguate the next failure CI on c290a39 threw a raw, uncaught exception ('Unrecognized error.', via GetFirstChild) about 17s into the gate, with zero UIA_PROVIDER_SETTLE_RETRY lines - meaning the one-time provider-settle wait succeeded on its first attempt this run, so this is a separate, later occurrence of a similar transient COM race, not the startup race that wait absorbs. The failure surfaced with no Require message at all, meaning it escaped uncaught all the way to the top of the script. It landed in Get-DirectChildren's narrow COMException retry (8243eed), but that catch block never logged anything, so there is no way to tell from this evidence alone whether it: (a) genuinely retried up to the 4-attempt/450ms budget as a real COMException and then exhausted it, or (b) was a differently-typed exception that never matched the typed catch at all and threw on the very first occurrence. Both would produce an identical-looking raw uncaught exception, so guessing between 'widen the budget' and 'broaden the caught type' would be exactly the kind of unverified assumption this file's history has repeatedly punished. Replace the typed catch with a bare catch that checks the exception type explicitly and logs attempt/type/hresult/ message on every occurrence (mirroring UIA_PROVIDER_SETTLE_RETRY's pattern) before deciding whether to retry. The retry POLICY is unchanged: only a genuine COMException is retried, capped at the same 4 attempts; anything else, or an exhausted COMException, still throws immediately with the original exception preserved. This is diagnostic-only - no Require added or removed (predicate count unchanged at 415), one Get-DirectChildren definition, parse clean. Next failing run's log will show definitively which case this is, so the actual fix (if any) can be targeted instead of guessed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 8d0091c6..6d1ffd31 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -785,9 +785,22 @@ function Get-DirectChildren( $child = $walker.GetNextSibling($child) } return @($children.ToArray()) - } catch [System.Runtime.InteropServices.COMException] { + } catch { + # Diagnostic-only: log every exception this hits, regardless of type, before + # deciding whether to retry. A prior CI run threw here with an uncaught, + # unlogged exception ("Unrecognized error.", ~17s into the gate, well after + # the one-time provider-settle wait already succeeded on its first attempt) - + # without this, it is impossible to tell whether it was a genuine + # COMException that exhausted the 4-attempt/450ms retry budget, or a + # differently-typed exception that never matched the typed catch this + # replaced and so was never retried at all. The retry POLICY is unchanged: + # only COMException is retried, capped at 4 attempts, everything else (and + # an exhausted COMException) still throws immediately. + $hresult = if ($_.Exception.InnerException) { $_.Exception.InnerException.HResult } else { $_.Exception.HResult } + $isComException = $_.Exception -is [System.Runtime.InteropServices.COMException] $attempt++ - if ($attempt -ge 4) { throw } + Write-Host "UIA_GETCHILDREN_RETRY attempt=$attempt type=$($_.Exception.GetType().FullName) hresult=0x$($hresult.ToString('X8')) retried=$isComException message=$($_.Exception.Message)" + if ((-not $isComException) -or ($attempt -ge 4)) { throw } Start-Sleep -Milliseconds 150 } } From 5f154865752aa3050653a066e223e2f14bd2d78d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 10:41:25 -0700 Subject: [PATCH 15/23] Fix silently-disabled COM retry; make its budget wall-clock, not attempts 9d27d32's bare catch checked '$_.Exception -is [COMException]' directly, which is always False on a bare catch: a direct .NET method call that throws is unwrapped by a *typed* catch clause, but a bare catch instead receives it wrapped in MethodInvocationException, with the real exception in .InnerException. Verified locally against a compiled method that throws a genuine COMException: catch [COMException] -> matches (unwraps to inner) bare catch, \.Exception -is [COMException] -> False bare catch, \.Exception.InnerException -> the real COMException So 9d27d32 threw on attempt 1 every time - the retry was silently disabled - while still logging 'retried=False', which reads exactly like 'never matched the typed catch' (hypothesis b) when it was really hypothesis (a): the retry regressed, not the exception type. Fixed by checking both \.Exception and \.Exception.InnerException for COMException; verified with a local repro (3 calls, first 2 throw a compiled COMException, retry now succeeds after 2 attempts instead of throwing on the first). Separately: switch the budget from a fixed attempt count (4) to a 5-second wall-clock deadline. This call runs inside every tree walk across ~35 call sites; a fixed attempt count multiplied across that many sites is exactly the arithmetic that produced the 60-minute CI hang earlier on this branch (be1497d). A duration cap keeps the worst-case cost per call bounded regardless of how many sites hit it, and gives the same call more headroom to recover from a COM stall that outlasts a few hundred milliseconds mid-run (as opposed to only at startup, which the existing one-time settle wait already covers). Diagnostic logging kept and extended with elapsed-ms; retry policy is otherwise the same shape (only a genuine COMException is retried, anything else or an exhausted budget still throws immediately, no fall-through to a return that could mask a failure). Predicate count unchanged at 415, one Get-DirectChildren definition, parse clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 38 +++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 6d1ffd31..c383594a 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -776,6 +776,7 @@ function Get-DirectChildren( [System.Windows.Automation.TreeWalker] $walker ) { $attempt = 0 + $deadline = (Get-Date).AddMilliseconds(5000) while ($true) { try { $children = New-Object System.Collections.Generic.List[System.Windows.Automation.AutomationElement] @@ -786,21 +787,30 @@ function Get-DirectChildren( } return @($children.ToArray()) } catch { - # Diagnostic-only: log every exception this hits, regardless of type, before - # deciding whether to retry. A prior CI run threw here with an uncaught, - # unlogged exception ("Unrecognized error.", ~17s into the gate, well after - # the one-time provider-settle wait already succeeded on its first attempt) - - # without this, it is impossible to tell whether it was a genuine - # COMException that exhausted the 4-attempt/450ms retry budget, or a - # differently-typed exception that never matched the typed catch this - # replaced and so was never retried at all. The retry POLICY is unchanged: - # only COMException is retried, capped at 4 attempts, everything else (and - # an exhausted COMException) still throws immediately. - $hresult = if ($_.Exception.InnerException) { $_.Exception.InnerException.HResult } else { $_.Exception.HResult } - $isComException = $_.Exception -is [System.Runtime.InteropServices.COMException] + # A direct .NET method call (e.g. $walker.GetFirstChild(...)) that throws is + # unwrapped by a *typed* catch clause, but a *bare* catch instead receives it + # wrapped in System.Management.Automation.MethodInvocationException, with the + # real exception (e.g. COMException) in .InnerException. An earlier version + # of this instrumentation checked "$_.Exception -is [COMException]" directly, + # which is always False for a bare catch on a COM failure - verified locally + # against a compiled method that throws a genuine COMException. That silently + # disabled the retry (threw on attempt 1 every time) while still logging + # "retried=False", which looks exactly like "never matched the typed catch" - + # the wrong diagnosis for what was actually a policy regression. Check both + # the exception itself and its InnerException. + $inner = $_.Exception.InnerException + $isComException = ($_.Exception -is [System.Runtime.InteropServices.COMException]) -or + ($inner -is [System.Runtime.InteropServices.COMException]) + $hresult = if ($inner) { $inner.HResult } else { $_.Exception.HResult } $attempt++ - Write-Host "UIA_GETCHILDREN_RETRY attempt=$attempt type=$($_.Exception.GetType().FullName) hresult=0x$($hresult.ToString('X8')) retried=$isComException message=$($_.Exception.Message)" - if ((-not $isComException) -or ($attempt -ge 4)) { throw } + $elapsedMs = [int](5000 - ($deadline - (Get-Date)).TotalMilliseconds) + Write-Host "UIA_GETCHILDREN_RETRY attempt=$attempt elapsedMs=$elapsedMs type=$($_.Exception.GetType().FullName) hresult=0x$($hresult.ToString('X8')) retried=$isComException message=$($_.Exception.Message)" + # Budget is wall-clock, not attempt count: this call runs inside every tree + # walk across ~35 call sites, and a fixed attempt count multiplied across + # that many sites is exactly the arithmetic that produced the 60-minute CI + # hang earlier on this branch (be1497d). A duration cap keeps the worst-case + # cost per call bounded regardless of how many sites hit it. + if ((-not $isComException) -or ((Get-Date) -ge $deadline)) { throw } Start-Sleep -Milliseconds 150 } } From f4959f4fb29377a9a6e663d4ae9f170335f7c591 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 11:09:30 -0700 Subject: [PATCH 16/23] Also retry genuine ElementNotAvailableException in Get-DirectChildren 473b64b's own CI run surfaced the next piece of concrete evidence: the 'Windows port validation' (windows-spikes) job hit 'UIA_GETCHILDREN_RETRY attempt=1 elapsedMs=27 type=System.Management.Automation.MethodInvocationException hresult=0x80040201 retried=False ... Unrecognized error.' and then threw uncaught, while the sibling windows-shell job on the identical commit passed clean with zero retry lines - consistent with an environmental, load-dependent race (that run's resource snapshots show several concurrent pwsh/conhost sessions live during a 'windows-shell:large- paste' phase). hresult=0x80040201 is UIA_E_ELEMENTNOTAVAILABLE. Verified directly by loading UIAutomationTypes.dll and constructing the real exception: ElementNotAvailableException's HResult is exactly 0x80040201, and it derives from SystemException, not COMException - so neither the original typed COMException catch nor 473b64b's fixed COMException check could ever have retried it. This is a real, transient ElementNotAvailableException occurring mid-walk under load, not a type- check bug this time. This is deliberately NOT a reinstatement of the be1497d swallow that was removed in 91abf5c: that bug caught ENA and returned @() after exhausting its budget, making every '-eq 0'/'-not' assertion built on a dead parent pass vacuously - it recovered by lying. This still always rethrows the original exception on exhaustion or on any other exception type; it only widens which transient, recoverable exception types get the same bounded, wall-clock-limited retry already given to COMException before that unconditional throw. Verified locally: constructing a real ElementNotAvailableException and running it through the updated retry-eligibility check now retries and recovers, instead of throwing immediately. Also added innerType to the diagnostic log line, since the outer type is always the same MethodInvocationException wrapper for any bare-catch failure on a direct .NET method call and was not by itself informative. Predicate count unchanged at 415, one Get-DirectChildren definition, parse clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index c383594a..762de1c3 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -798,19 +798,38 @@ function Get-DirectChildren( # "retried=False", which looks exactly like "never matched the typed catch" - # the wrong diagnosis for what was actually a policy regression. Check both # the exception itself and its InnerException. + # + # Also retry a genuine ElementNotAvailableException (HRESULT 0x80040201), + # confirmed via 473b64b's own CI run: "windows-spikes" logged + # "UIA_GETCHILDREN_RETRY ... hresult=0x80040201 retried=False" under heavy + # concurrent load (multiple pwsh/conhost sessions live at once per the + # PRODUCT_RESOURCE_METRICS_JSON snapshots in that run), then threw + # uncaught. Verified locally by constructing the real exception from + # UIAutomationTypes.dll: HResult 0x80040201 matches exactly, and it derives + # from SystemException directly, not COMException - so neither the old + # typed catch nor the fixed COMException check could ever have retried it. + # This is NOT the be1497d swallow reinstated: that bug returned @() after + # exhausting its budget, making every "-eq 0"/"-not" assertion built on a + # dead parent pass vacuously. This still always throws on exhaustion or on + # any other exception type - it only widens which transient, recoverable + # exception types get a bounded, wall-clock-limited chance to resolve + # before that unconditional throw. $inner = $_.Exception.InnerException - $isComException = ($_.Exception -is [System.Runtime.InteropServices.COMException]) -or - ($inner -is [System.Runtime.InteropServices.COMException]) + $isRetryable = ($_.Exception -is [System.Runtime.InteropServices.COMException]) -or + ($inner -is [System.Runtime.InteropServices.COMException]) -or + ($_.Exception -is [System.Windows.Automation.ElementNotAvailableException]) -or + ($inner -is [System.Windows.Automation.ElementNotAvailableException]) $hresult = if ($inner) { $inner.HResult } else { $_.Exception.HResult } + $innerType = if ($inner) { $inner.GetType().FullName } else { "" } $attempt++ $elapsedMs = [int](5000 - ($deadline - (Get-Date)).TotalMilliseconds) - Write-Host "UIA_GETCHILDREN_RETRY attempt=$attempt elapsedMs=$elapsedMs type=$($_.Exception.GetType().FullName) hresult=0x$($hresult.ToString('X8')) retried=$isComException message=$($_.Exception.Message)" + Write-Host "UIA_GETCHILDREN_RETRY attempt=$attempt elapsedMs=$elapsedMs type=$($_.Exception.GetType().FullName) innerType=$innerType hresult=0x$($hresult.ToString('X8')) retried=$isRetryable message=$($_.Exception.Message)" # Budget is wall-clock, not attempt count: this call runs inside every tree # walk across ~35 call sites, and a fixed attempt count multiplied across # that many sites is exactly the arithmetic that produced the 60-minute CI # hang earlier on this branch (be1497d). A duration cap keeps the worst-case # cost per call bounded regardless of how many sites hit it. - if ((-not $isComException) -or ((Get-Date) -ge $deadline)) { throw } + if ((-not $isRetryable) -or ((Get-Date) -ge $deadline)) { throw } Start-Sleep -Milliseconds 150 } } From 5c8f8d86ee22db08829ffaa0a88708d15b8849ce Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 11:35:28 -0700 Subject: [PATCH 17/23] Re-resolve graphcode-root after modal teardown instead of retrying a stale reference The update-offer dialog's Later action rebuilds the shell's fragment tree. \ captured before that teardown can become a permanently dead reference - not a transient blip Get-DirectChildren's bounded COM/ENA retry can recover from. CI evidence: a walk that failed mid-enumeration with 'Catastrophic failure (E_UNEXPECTED)' on GetNextSibling, then failed every subsequent attempt with ElementNotAvailableException on GetFirstChild for the rest of a 5s retry budget - the same event seen at two stages, not two unrelated glitches. Add Wait-ForRootReconnect: re-acquire \ via FromHandle on the shell's main window handle (checking AutomationId, exactly like the initial acquisition loop), then prove a raw tree walk of it succeeds before returning it as live. Waits on that precondition only; does not touch any caller assertion. On exhaustion it fails with HasExited and the last exception observed, so a genuine product crash is distinguishable from a gate-side reconnection failure. Convert only the one call site after the Later-dismissal teardown. Two other modal teardowns (~1265, ~3124) have the identical latent exposure but are noted, not converted, per explicit scope for this fix. Predicate count: 415 -> 416 (one new Require inside the helper, on reconnect exhaustion). Parse-clean. This file is not exercised by Tools/windows/Tests/WindowsShell.Tests.ps1 (Zig-only suite); no Zig source changed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 58 ++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 762de1c3..79514539 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -895,6 +895,56 @@ function Wait-ForGraphChildren( return [pscustomobject]@{ Graph = $liveGraph; Items = $observed } } +# A modal teardown (e.g. dismissing the update-offer dialog via SendCommand) +# rebuilds the shell's fragment tree, and a $root captured before that teardown +# can become a permanently dead reference - not a transient blip that +# Get-DirectChildren's bounded COM/ENA retry can recover from. CI evidence for +# this: a walk that failed mid-enumeration with "Catastrophic failure +# (E_UNEXPECTED)" on GetNextSibling, then failed every subsequent attempt with +# ElementNotAvailableException on GetFirstChild for the rest of a 5-second +# retry budget - a corpse observed at two stages, not a glitch that recovers. +# Retrying the same stale reference can never revive it; only re-acquiring +# graphcode-root the same way it was first acquired can. +# +# Re-resolve $root via FromHandle on the shell's main window handle (checking +# AutomationId, exactly like the initial acquisition loop), then prove a raw +# tree walk of it succeeds before returning it as live. Waits on that +# precondition only - it does not touch any caller assertion. On exhaustion, +# Require fails with HasExited and the last exception observed, so a genuine +# product crash (the shell actually died) is distinguishable from a gate-side +# reconnection failure. +function Wait-ForRootReconnect( + [System.Diagnostics.Process] $process, + [System.Windows.Automation.TreeWalker] $walker, + [int] $maxAttempts = 40, + [int] $delayMs = 250 +) { + $reconnected = $null + $lastException = $null + for ($attempt = 0; $attempt -lt $maxAttempts; $attempt++) { + $process.Refresh() + if ($process.HasExited) { throw "shell exited with code $($process.ExitCode) while reconnecting graphcode-root" } + if ($process.MainWindowHandle -ne 0) { + try { + $candidate = [System.Windows.Automation.AutomationElement]::FromHandle($process.MainWindowHandle) + if ($candidate.Current.AutomationId -eq "graphcode-root") { + $null = @($walker.GetFirstChild($candidate)) + $reconnected = $candidate + break + } + } catch { + $lastException = $_ + } + } + Start-Sleep -Milliseconds $delayMs + } + $failureDetail = if ($null -ne $lastException) { + " (last: $($lastException.Exception.GetType().FullName): $($lastException.Exception.Message))" + } else { "" } + Require ($null -ne $reconnected) "graphcode-root did not become reachable after modal teardown$failureDetail" + return $reconnected +} + # A surface transition (navigating destinations, opening/closing a native # HWND-hosted inspection view) can leave the accessibility tree in a brief, # genuinely-transient state where a fragment that is about to exist (or that @@ -1176,7 +1226,13 @@ try { "update offer did not expose Release Notes and Later actions" Require ([GraphCodeUiaGateState]::SendCommand([IntPtr]$updateDialog.Current.NativeWindowHandle, 9703)) ` "update offer Later action could not be invoked" - Start-Sleep -Milliseconds 150 + # Dismissing the modal rebuilds the shell's fragment tree; $root captured + # before this point can be a stale reference that Get-DirectChildren's + # bounded COM/ENA retry cannot revive (see Wait-ForRootReconnect). Other + # modal teardowns later in this file (SendCommand 9703 again ~line 1265, + # and ~line 3124) have the identical latent exposure but are out of scope + # for this fix - noted here rather than swept up in one change. + $root = Wait-ForRootReconnect $process $rawWalker $status = Find-FragmentById $root "status" $controlWalker $rawRootChildren = @(Assert-FragmentLinks $root $rawWalker $expectedRootIds "RawView root") $controlRootChildren = @(Assert-FragmentLinks $root $controlWalker $expectedRootIds "ControlView root") From d8c53e68996babadb38698c9c1eb6218956dca0a Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 11:53:38 -0700 Subject: [PATCH 18/23] Wait-ForRootReconnect: verify both RawView and ControlView walkers before trusting the reconnected root CI on 36ab812 showed the first version of this helper isn't sufficient: it verified reconnection with only the raw-view walker, and the very next statement at this call site (status lookup via ControlView) then failed with ElementNotAvailableException for the entire 5s Get-DirectChildren retry budget, never recovering - immediately downstream of a reconnect that had just returned successfully. RawView and ControlView are separate client-side views of the same element and can settle at different times after a remount; proving one is walkable does not prove the other is. This call site immediately exercises both (status via ControlView, root-children assertions via both views), so Wait-ForRootReconnect now takes an array of walkers and requires every one of them to complete a GetFirstChild call on the candidate before trusting it as reconnected. Added UIA_ROOT_RECONNECT_RETRY diagnostic logging on each failed attempt, matching the existing UIA_PROVIDER_SETTLE_RETRY / UIA_GETCHILDREN_RETRY pattern in this file, in case this needs disambiguating again. Verified the control-flow in isolation with fakes (no real UIA needed): recovers once every walker eventually succeeds, exhausts its budget and rethrows with the last exception when the element stays permanently dead, and surfaces a genuine process exit with a distinct message rather than looping on it. Predicate count unchanged (416): this changes only what is proven before the existing Require statements run, not any assertion itself. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 34 +++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 79514539..fb34d12b 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -907,15 +907,26 @@ function Wait-ForGraphChildren( # graphcode-root the same way it was first acquired can. # # Re-resolve $root via FromHandle on the shell's main window handle (checking -# AutomationId, exactly like the initial acquisition loop), then prove a raw -# tree walk of it succeeds before returning it as live. Waits on that -# precondition only - it does not touch any caller assertion. On exhaustion, -# Require fails with HasExited and the last exception observed, so a genuine -# product crash (the shell actually died) is distinguishable from a gate-side -# reconnection failure. +# AutomationId, exactly like the initial acquisition loop), then prove a tree +# walk of it succeeds *in every view the caller is about to use* before +# returning it as live. A first version of this helper verified only with the +# raw-view walker and still failed immediately downstream: CI showed the +# raw-view walk succeeding (reconnect returned without throwing) followed by +# the very next statement's control-view walk failing with +# ElementNotAvailableException for the entire 5s budget, never recovering. +# RawView and ControlView are separate client-side views of the same element +# and can settle at different times after a remount, so proving one is alive +# does not prove the other is. This site immediately exercises both views +# (status lookup via ControlView, root-children assertions via both), so both +# must be proven walkable before the element is trusted. +# +# Waits on that precondition only - it does not touch any caller assertion. +# On exhaustion, Require fails with HasExited and the last exception observed, +# so a genuine product crash (the shell actually died) is distinguishable +# from a gate-side reconnection failure. function Wait-ForRootReconnect( [System.Diagnostics.Process] $process, - [System.Windows.Automation.TreeWalker] $walker, + [System.Windows.Automation.TreeWalker[]] $walkers, [int] $maxAttempts = 40, [int] $delayMs = 250 ) { @@ -928,12 +939,13 @@ function Wait-ForRootReconnect( try { $candidate = [System.Windows.Automation.AutomationElement]::FromHandle($process.MainWindowHandle) if ($candidate.Current.AutomationId -eq "graphcode-root") { - $null = @($walker.GetFirstChild($candidate)) + foreach ($walker in $walkers) { $null = @($walker.GetFirstChild($candidate)) } $reconnected = $candidate break } } catch { $lastException = $_ + Write-Host "UIA_ROOT_RECONNECT_RETRY attempt=$attempt type=$($_.Exception.GetType().FullName) message=$($_.Exception.Message)" } } Start-Sleep -Milliseconds $delayMs @@ -1231,8 +1243,10 @@ try { # bounded COM/ENA retry cannot revive (see Wait-ForRootReconnect). Other # modal teardowns later in this file (SendCommand 9703 again ~line 1265, # and ~line 3124) have the identical latent exposure but are out of scope - # for this fix - noted here rather than swept up in one change. - $root = Wait-ForRootReconnect $process $rawWalker + # for this fix - noted here rather than swept up in one change. Verify with + # both walkers this site is about to use (status lookup below is + # ControlView; the root-children assertions further down use both views). + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) $status = Find-FragmentById $root "status" $controlWalker $rawRootChildren = @(Assert-FragmentLinks $root $rawWalker $expectedRootIds "RawView root") $controlRootChildren = @(Assert-FragmentLinks $root $controlWalker $expectedRootIds "ControlView root") From af4058a121f0b0a9e53af9f10e340a54ee27abf5 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 12:11:56 -0700 Subject: [PATCH 19/23] Rewrite Wait-ForRootReconnect comment to observation-only; add handle logging as (a) vs (b) insurance CI on 94014a1 (the both-walker verification fix) landed a second data point that the prior comment's stated mechanism does not fit. That comment asserted 'RawView and ControlView ... can settle at different times after a remount' as the explanation. But this run showed zero UIA_ROOT_RECONNECT_RETRY lines - both views were proven walkable on the very first attempt - and the very next Get-DirectChildren call still failed 5ms later, immediately, and stayed dead for the full 5s budget. That does not fit 'the two views settle at different times' (both had just been proven walkable); it fits equally well with the element dying in the window between the check and its use. Rewrote the comment to state what was observed at each of the two prior CI failures without asserting which explanation is correct. Both stay live hypotheses; the fix (require every view the caller uses to be walkable before trusting the reconnect) is defensible under either one, so it did not need to change. Added logging to distinguish them empirically without another diagnostic round-trip: Wait-ForRootReconnect now logs .MainWindowHandle on successful reconnect (UIA_ROOT_RECONNECT_OK), and Get-DirectChildren's existing UIA_GETCHILDREN_RETRY line now logs the same handle. If a future failure shows the handle changed between a preceding reconnect and the failing read, that proves the time-of-check/time-of-use explanation outright. Predicate count unchanged (416): comment and diagnostic-only, no assertion added, removed, or altered. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 44 ++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index fb34d12b..6f4afa3a 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -823,7 +823,14 @@ function Get-DirectChildren( $innerType = if ($inner) { $inner.GetType().FullName } else { "" } $attempt++ $elapsedMs = [int](5000 - ($deadline - (Get-Date)).TotalMilliseconds) - Write-Host "UIA_GETCHILDREN_RETRY attempt=$attempt elapsedMs=$elapsedMs type=$($_.Exception.GetType().FullName) innerType=$innerType hresult=0x$($hresult.ToString('X8')) retried=$isRetryable message=$($_.Exception.Message)" + # Includes $process.MainWindowHandle (script-scope, set once the shell + # launches - already read this way by Wait-ForPopupMenu et al.) as the + # counterpart to Wait-ForRootReconnect's UIA_ROOT_RECONNECT_OK handle + # log: if a failure here is ever paired with a preceding reconnect and + # the handles differ, that proves the element died in the window + # between the check and this use, rather than requiring a fresh + # diagnostic round-trip to find out. + Write-Host "UIA_GETCHILDREN_RETRY attempt=$attempt elapsedMs=$elapsedMs type=$($_.Exception.GetType().FullName) innerType=$innerType hresult=0x$($hresult.ToString('X8')) retried=$isRetryable handle=$($process.MainWindowHandle) message=$($_.Exception.Message)" # Budget is wall-clock, not attempt count: this call runs inside every tree # walk across ~35 call sites, and a fixed attempt count multiplied across # that many sites is exactly the arithmetic that produced the 60-minute CI @@ -909,21 +916,33 @@ function Wait-ForGraphChildren( # Re-resolve $root via FromHandle on the shell's main window handle (checking # AutomationId, exactly like the initial acquisition loop), then prove a tree # walk of it succeeds *in every view the caller is about to use* before -# returning it as live. A first version of this helper verified only with the -# raw-view walker and still failed immediately downstream: CI showed the -# raw-view walk succeeding (reconnect returned without throwing) followed by -# the very next statement's control-view walk failing with -# ElementNotAvailableException for the entire 5s budget, never recovering. -# RawView and ControlView are separate client-side views of the same element -# and can settle at different times after a remount, so proving one is alive -# does not prove the other is. This site immediately exercises both views -# (status lookup via ControlView, root-children assertions via both), so both -# must be proven walkable before the element is trusted. +# returning it as live. +# +# What was observed, not why (mechanism is not established): a first version +# of this helper verified only with the raw-view walker. On one CI run, that +# walk succeeded (reconnect returned without throwing) and the very next +# statement's control-view walk then failed with ElementNotAvailableException +# for the entire 5s budget, never recovering. On a later CI run, after adding +# a control-view check here too, reconnect again returned success with zero +# retries logged (both views walked cleanly on the first attempt) - and the +# very next Get-DirectChildren call still failed immediately (5ms later) and +# stayed dead for the full budget. That second result does not fit "the two +# views settle at different times": both were proven walkable moments before +# the failure. It is equally consistent with the element dying in the +# window between this check and its use, i.e. the teardown had not actually +# finished when the check passed. Do not treat either explanation as +# confirmed; the fix below (require every view the caller is about to use to +# be walkable) is defensible under both, so it stays regardless of which one +# is eventually shown to be correct. # # Waits on that precondition only - it does not touch any caller assertion. # On exhaustion, Require fails with HasExited and the last exception observed, # so a genuine product crash (the shell actually died) is distinguishable -# from a gate-side reconnection failure. +# from a gate-side reconnection failure. Logs the reconnected window handle +# on success as cheap insurance: if a later failure at this site is ever +# paired with a handle-logging point downstream, a differing handle would +# prove the time-of-check/time-of-use explanation outright rather than +# requiring another diagnostic round-trip. function Wait-ForRootReconnect( [System.Diagnostics.Process] $process, [System.Windows.Automation.TreeWalker[]] $walkers, @@ -941,6 +960,7 @@ function Wait-ForRootReconnect( if ($candidate.Current.AutomationId -eq "graphcode-root") { foreach ($walker in $walkers) { $null = @($walker.GetFirstChild($candidate)) } $reconnected = $candidate + Write-Host "UIA_ROOT_RECONNECT_OK attempt=$attempt handle=$($process.MainWindowHandle)" break } } catch { From 0f6992ed7e7d0be8c719ef10f4d7ab108005272f Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 12:27:51 -0700 Subject: [PATCH 20/23] Make handle diagnostic deterministic; add call-site and window-enumeration diagnostics Get-DirectChildren's UIA_GETCHILDREN_RETRY log now calls $process.Refresh() before reading MainWindowHandle. This file has 18 separate $process.Refresh() call sites, so without an explicit Refresh() here the logged handle depended on whichever unrelated site last refreshed it rather than on state at this call. CI evidence on d98f2ca (handle=0 on 33 retries vs handle=262398 at the preceding reconnect) shows the read was NOT vacuous/echoing a cached value as an earlier commit message on this branch claimed - that framing was wrong and is corrected here. Refresh() still earns its keep: it makes the read deterministic instead of dependent on unrelated code paths, and documents the $process vs $settingsProcess two-shell-process ambiguity for future diagnostics logged during the Product Settings fixture phase. Also adds two purely diagnostic capabilities requested to localize a still-open failure (MainWindowHandle enumerating to 0 for a full retry budget ~11s into the gate, cause not yet established): - A call-site marker on every UIA_GETCHILDREN_RETRY line, built from Get-PSCallStack, identifying which of Get-DirectChildren's ~35 call sites is failing. Verified in isolation that the marker resolves to the correct caller chain. - At retry exhaustion, before the existing unconditional rethrow, log $process.HasExited (+ exit code if exited) and an enumeration of the process's current top-level windows (handle, class, visibility, title) via a new GraphCodeUiaGateState.DescribeTopLevelWindows helper built on the class's existing EnumWindows/GetWindowThreadProcessId/GetClassName/ IsWindowVisible P/Invoke primitives (no new P/Invoke declarations). Isolated-compiled the embedded C# block with the file's exact -ReferencedAssemblies and confirmed it compiles; functionally exercised DescribeTopLevelWindows against a live process (explorer.exe) and confirmed it correctly distinguishes visible/hidden windows and empty titles. This separates four otherwise-indistinguishable failure modes: crashed, window-destroyed, window-hidden, and handle-churn. Both additions are diagnostic-only: no assertion changed, no behavior change on any passing path. Require predicate count unchanged at 416. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/uia-live-gate.ps1 | 84 +++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 6f4afa3a..0519f942 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -341,6 +341,32 @@ public static class GraphCodeUiaGateState { return true; }, IntPtr.Zero); } + // Diagnostic only (Tools/windows/uia-live-gate.ps1's Get-DirectChildren + // exhaustion path): enumerates every top-level window currently owned by + // processId with its class, visibility, and title, so a failure that + // reports MainWindowHandle == 0 can be told apart as window-destroyed + // (empty result), window-hidden (a result exists but is not visible), or + // handle-churn (a visible, healthy-looking window exists under a handle + // Process.MainWindowHandle no longer reports). Does not filter or assert + // anything - it is read verbatim into a log line. + public static string[] DescribeTopLevelWindows(uint processId) { + var results = new System.Collections.ArrayList(); + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var classText = new StringBuilder(256); + GetClassName(window, classText, classText.Capacity); + int titleLength = GetWindowTextLength(window); + var titleText = new StringBuilder(titleLength + 1); + GetWindowText(window, titleText, titleText.Capacity); + bool visible = IsWindowVisible(window); + results.Add(window.ToInt64() + ":" + classText.ToString() + ":" + + (visible ? "visible" : "hidden") + ":" + titleText.ToString()); + return true; + }, IntPtr.Zero); + return (string[])results.ToArray(typeof(string)); + } public static bool PostMouseClick(IntPtr window) { return PostMessage(window, 0x0201, UIntPtr.Zero, IntPtr.Zero); } @@ -823,20 +849,60 @@ function Get-DirectChildren( $innerType = if ($inner) { $inner.GetType().FullName } else { "" } $attempt++ $elapsedMs = [int](5000 - ($deadline - (Get-Date)).TotalMilliseconds) - # Includes $process.MainWindowHandle (script-scope, set once the shell - # launches - already read this way by Wait-ForPopupMenu et al.) as the - # counterpart to Wait-ForRootReconnect's UIA_ROOT_RECONNECT_OK handle - # log: if a failure here is ever paired with a preceding reconnect and - # the handles differ, that proves the element died in the window - # between the check and this use, rather than requiring a fresh - # diagnostic round-trip to find out. - Write-Host "UIA_GETCHILDREN_RETRY attempt=$attempt elapsedMs=$elapsedMs type=$($_.Exception.GetType().FullName) innerType=$innerType hresult=0x$($hresult.ToString('X8')) retried=$isRetryable handle=$($process.MainWindowHandle) message=$($_.Exception.Message)" + # Includes a freshly-refreshed $process.MainWindowHandle (script-scope, + # already read this way by Wait-ForPopupMenu et al.) as the counterpart + # to Wait-ForRootReconnect's UIA_ROOT_RECONNECT_OK handle log, to check + # whether the window in play differs between a preceding reconnect and + # this failing read. This file has 18 separate $process.Refresh() call + # sites, so without an explicit Refresh() immediately before this read, + # the value returned here depends on whichever unrelated site last + # refreshed it rather than on the state at this call - Refresh() here + # makes the read deterministic. + # + # NOTE: this file runs two separate shell processes at different + # points ($process for the main gate, $settingsProcess for the + # Product Settings fixture near the end). Get-DirectChildren picks up + # $process by dynamic scope regardless of which window's tree is + # actually being walked, so a handle logged while walking the + # settings window's tree is $process's handle, not $settingsProcess's + # - it does not identify the window in play in that phase. + if ($process) { $process.Refresh() } + $handleText = if ($process) { $process.MainWindowHandle } else { "" } + # Call-site marker: identifies which of this function's ~35 call + # sites is failing without instrumenting each one individually. CI has + # shown failures with several seconds of silence beforehand (no "==>" + # step marker in between), so without this a failure elapsedMs/attempt + # count alone cannot be mapped back to a specific gate step. + $callSite = (Get-PSCallStack | Select-Object -Skip 1 -First 4 | + ForEach-Object { "$($_.FunctionName):$($_.ScriptLineNumber)" }) -join "<-" + Write-Host "UIA_GETCHILDREN_RETRY attempt=$attempt elapsedMs=$elapsedMs type=$($_.Exception.GetType().FullName) innerType=$innerType hresult=0x$($hresult.ToString('X8')) retried=$isRetryable handle=$handleText site=$callSite message=$($_.Exception.Message)" # Budget is wall-clock, not attempt count: this call runs inside every tree # walk across ~35 call sites, and a fixed attempt count multiplied across # that many sites is exactly the arithmetic that produced the 60-minute CI # hang earlier on this branch (be1497d). A duration cap keeps the worst-case # cost per call bounded regardless of how many sites hit it. - if ((-not $isRetryable) -or ((Get-Date) -ge $deadline)) { throw } + if ((-not $isRetryable) -or ((Get-Date) -ge $deadline)) { + # Diagnostic only, on the way to an unconditional rethrow: distinguish + # crashed (HasExited true) from window-destroyed (process alive, zero + # top-level windows) from window-hidden (a window exists but is not + # visible) from handle-churn (a visible window exists under a handle + # MainWindowHandle no longer reports) - four different bugs that are + # otherwise indistinguishable from this exception alone. Best-effort: + # swallow any failure describing process state so the real exception + # is still the one that propagates. + try { + if ($process) { + $process.Refresh() + $exitDetail = if ($process.HasExited) { "true exitCode=$($process.ExitCode)" } else { "false" } + $windows = [GraphCodeUiaGateState]::DescribeTopLevelWindows([uint32]$process.Id) + $windowsText = if ($windows.Count -gt 0) { $windows -join ";" } else { "(none)" } + Write-Host "UIA_GETCHILDREN_EXHAUSTED hasExited=$exitDetail topLevelWindows=$windowsText" + } + } catch { + Write-Host "UIA_GETCHILDREN_EXHAUSTED process state unavailable: $($_.Exception.Message)" + } + throw + } Start-Sleep -Milliseconds 150 } } From 867e958701e27789ee6dde54c0204faf0cf84156 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 13:24:08 -0700 Subject: [PATCH 21/23] Report shell crashes with stderr from UIA tree-walk exhaustion When Get-DirectChildren exhausts its bounded retry and the shell has exited, dump the redirected shell stderr between explicit markers before rethrowing the original UI Automation exception. Log ExitCode as "unavailable" when Process.ExitCode cannot be read; do not coerce a null value to int, which manufactured exit 0 during local reproduction. Also make the immediate post-project/loop invocation liveness assertion report the exit code when available and dump the same stderr if the crash has already completed by that point. The check remains at its existing location and timing; this adds no waits and changes no passing behavior. The real pinned ReleaseSafe and Debug local gate reproductions confirmed the path: the shell panic is preserved in stderr, exitCode is truthfully reported as unavailable locally, and the final thrown error remains the original ElementNotAvailableException rather than a diagnostic wrapper. Require predicate count remains 416. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/uia-live-gate.ps1 | 42 +++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 0519f942..473c5c64 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -890,10 +890,24 @@ function Get-DirectChildren( # otherwise indistinguishable from this exception alone. Best-effort: # swallow any failure describing process state so the real exception # is still the one that propagates. + $diagnosticHasExited = $false + $diagnosticExitCode = $null try { if ($process) { $process.Refresh() - $exitDetail = if ($process.HasExited) { "true exitCode=$($process.ExitCode)" } else { "false" } + $diagnosticHasExited = $process.HasExited + if ($diagnosticHasExited) { + try { + $diagnosticExitCode = $process.ExitCode + } catch { + $diagnosticExitCode = $null + } + } + $exitDetail = if ($diagnosticHasExited) { + "true exitCode=$(if ($null -eq $diagnosticExitCode) { 'unavailable' } else { $diagnosticExitCode })" + } else { + "false" + } $windows = [GraphCodeUiaGateState]::DescribeTopLevelWindows([uint32]$process.Id) $windowsText = if ($windows.Count -gt 0) { $windows -join ";" } else { "(none)" } Write-Host "UIA_GETCHILDREN_EXHAUSTED hasExited=$exitDetail topLevelWindows=$windowsText" @@ -901,6 +915,17 @@ function Get-DirectChildren( } catch { Write-Host "UIA_GETCHILDREN_EXHAUSTED process state unavailable: $($_.Exception.Message)" } + if ($diagnosticHasExited) { + try { + if ($shellErrorPath -and (Test-Path -LiteralPath $shellErrorPath)) { + Write-Host "UIA_GETCHILDREN_SHELL_STDERR_BEGIN" + Get-Content -LiteralPath $shellErrorPath | Write-Host + Write-Host "UIA_GETCHILDREN_SHELL_STDERR_END" + } + } catch { + Write-Host "UIA_GETCHILDREN_SHELL_STDERR unavailable: $($_.Exception.Message)" + } + } throw } Start-Sleep -Milliseconds 150 @@ -2229,7 +2254,20 @@ try { $activeLoopRow.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() Start-Sleep -Milliseconds 250 $process.Refresh() - Require (-not $process.HasExited) "dynamic project or loop invocation terminated the shell" + $dynamicInvocationHasExited = $process.HasExited + $dynamicInvocationExitCode = $null + if ($dynamicInvocationHasExited) { + try { + $dynamicInvocationExitCode = $process.ExitCode + } catch { + $dynamicInvocationExitCode = $null + } + if (Test-Path -LiteralPath $shellErrorPath) { + Get-Content -LiteralPath $shellErrorPath | Write-Host + } + } + $dynamicInvocationExitText = if ($null -eq $dynamicInvocationExitCode) { "unavailable" } else { $dynamicInvocationExitCode } + Require (-not $dynamicInvocationHasExited) "dynamic project or loop invocation crashed the shell (exit $dynamicInvocationExitText)" # This dynamic project/loop invocation was observed on a loaded CI runner both # exhausting Find-FragmentByIdWithRetry's default 20-attempt/3s budget re-fetching # "graph" itself, and - separately - remounting graph's "canvas-card-" children a From 0aac1ad7b69b47521d9f1d6ac0f670907c2b7f36 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 13:27:24 -0700 Subject: [PATCH 22/23] Preserve terminal cell buffers when replacing a live surface Workspace.openNode created a replacement in a free Surface slot, destroyed the target, copied the replacement over it, and reset the donor slot to its default value. Every slot receives a cell_count-sized terminal cell buffer at Workspace.init, so that assignment both leaked the destroyed target's buffer and left the donor with cells=&.{}. A later project rebind could reuse that donor slot, move its empty cell slice into the active slot, and panic when terminal output reached putCodepoint: index out of bounds: index 0, len 0 TerminalSurface.zig:1921 slot.cells[...] Swap the destroyed target and live replacement Surface values instead. This moves the complete live native/session/attach state to the requested index while preserving both slots' allocator-owned cell buffers. Callbacks continue to resolve the moved native surface by pointer, and input callbacks compute its current slot index at callback time. The regression test invokes the same private helper as openNode, uses std.testing.allocator-backed cell buffers, verifies live state and both distinct allocations survive, feeds printable output through the production parser on the recycled donor, then repeats replacement/reuse. With the original assignment/reset it failed expected 4800/found 0 and reported the target allocation leaked; with the swap all 21 focused tests pass. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/TerminalSurface.zig | 63 ++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index bb4e62b8..be3643b2 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -160,6 +160,21 @@ pub fn surfaceIdentityMatches(surface: *const Surface, project_path: []const u8, std.mem.eql(u8, surface.session_name, session); } +fn moveReplacementSurface( + surfaces: *[max_surfaces]Surface, + target_index: usize, + replacement_index: usize, +) void { + std.debug.assert(target_index < surfaces.len); + std.debug.assert(replacement_index < surfaces.len); + std.debug.assert(target_index != replacement_index); + std.debug.assert(surfaces[target_index].surface == null); + std.debug.assert(surfaces[target_index].attach == null); + std.debug.assert(surfaces[replacement_index].surface != null or + surfaces[replacement_index].attach != null); + std.mem.swap(Surface, &surfaces[target_index], &surfaces[replacement_index]); +} + pub const Workspace = struct { parent: c.HWND, host: ?*c.winghostty_host = null, @@ -401,8 +416,7 @@ pub const Workspace = struct { return err; }; self.destroySurface(index); - self.surfaces[index] = self.surfaces[replacement_index]; - self.surfaces[replacement_index] = .{}; + moveReplacementSurface(&self.surfaces, index, replacement_index); self.syncTopology(); self.clearRecreateSession(index); return; @@ -1790,6 +1804,51 @@ test "surface identity cannot leak a session across project paths" { try std.testing.expect(!surfaceIdentityMatches(&second, "C:\\work\\first", "node-1")); } +test "replacement preserves both surface cell buffers for donor reuse" { + var surfaces = [_]Surface{.{}} ** max_surfaces; + surfaces[0].cells = try std.testing.allocator.alloc(c.winghostty_terminal_cell, cell_count); + surfaces[1].cells = try std.testing.allocator.alloc(c.winghostty_terminal_cell, cell_count); + defer for (&surfaces) |*surface| { + if (surface.session_name.len != 0) std.testing.allocator.free(surface.session_name); + if (surface.cells.len != 0) std.testing.allocator.free(surface.cells); + }; + + const target_cells = surfaces[0].cells.ptr; + const replacement_cells = surfaces[1].cells.ptr; + const live_surface: *c.winghostty_surface = @ptrFromInt(1); + surfaces[1].surface = live_surface; + surfaces[1].session_name = try std.testing.allocator.dupe(u8, "replacement"); + + moveReplacementSurface(&surfaces, 0, 1); + + try std.testing.expectEqual(live_surface, surfaces[0].surface.?); + try std.testing.expectEqualStrings("replacement", surfaces[0].session_name); + try std.testing.expectEqual(cell_count, surfaces[0].cells.len); + try std.testing.expectEqual(replacement_cells, surfaces[0].cells.ptr); + try std.testing.expectEqual(cell_count, surfaces[1].cells.len); + try std.testing.expectEqual(target_cells, surfaces[1].cells.ptr); + try std.testing.expect(surfaces[0].cells.ptr != surfaces[1].cells.ptr); + + feedCells(&surfaces[1], "A"); + try std.testing.expectEqual(@as(u32, 'A'), surfaces[1].cells[0].codepoint); + + surfaces[0].surface = null; + std.testing.allocator.free(surfaces[0].session_name); + surfaces[0].session_name = &.{}; + const second_surface: *c.winghostty_surface = @ptrFromInt(2); + surfaces[1].surface = second_surface; + surfaces[1].session_name = try std.testing.allocator.dupe(u8, "second replacement"); + + moveReplacementSurface(&surfaces, 0, 1); + + try std.testing.expectEqual(second_surface, surfaces[0].surface.?); + try std.testing.expectEqualStrings("second replacement", surfaces[0].session_name); + try std.testing.expectEqual(target_cells, surfaces[0].cells.ptr); + try std.testing.expectEqual(replacement_cells, surfaces[1].cells.ptr); + feedCells(&surfaces[1], "B"); + try std.testing.expectEqual(@as(u32, 'B'), surfaces[1].cells[0].codepoint); +} + fn minimalWorkspaceForOptionsTest(allocator: std.mem.Allocator) !Workspace { return Workspace{ .parent = null, From f3f5288f9023547cf4b66ef80169dcb13a983cba Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 13:28:44 -0700 Subject: [PATCH 23/23] Harden terminal replacement test cleanup Register allocator cleanup before either test buffer allocation so a failure allocating the second buffer still releases the first. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/TerminalSurface.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index be3643b2..283dd3ac 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -1806,12 +1806,12 @@ test "surface identity cannot leak a session across project paths" { test "replacement preserves both surface cell buffers for donor reuse" { var surfaces = [_]Surface{.{}} ** max_surfaces; - surfaces[0].cells = try std.testing.allocator.alloc(c.winghostty_terminal_cell, cell_count); - surfaces[1].cells = try std.testing.allocator.alloc(c.winghostty_terminal_cell, cell_count); defer for (&surfaces) |*surface| { if (surface.session_name.len != 0) std.testing.allocator.free(surface.session_name); if (surface.cells.len != 0) std.testing.allocator.free(surface.cells); }; + surfaces[0].cells = try std.testing.allocator.alloc(c.winghostty_terminal_cell, cell_count); + surfaces[1].cells = try std.testing.allocator.alloc(c.winghostty_terminal_cell, cell_count); const target_cells = surfaces[0].cells.ptr; const replacement_cells = surfaces[1].cells.ptr;