From 56e4db806bee0cbada400dfb0aec1fab37576a51 Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:57:29 -0400 Subject: [PATCH 01/13] Detect Parroty window closure by HWND --- parroty_window.ps1 | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/parroty_window.ps1 b/parroty_window.ps1 index 382f5de..ae4ddb4 100644 --- a/parroty_window.ps1 +++ b/parroty_window.ps1 @@ -85,6 +85,9 @@ public static class ParrotyNativeWindow { [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool IsWindow(IntPtr hWnd); } "@ } catch {} @@ -137,6 +140,32 @@ public static class ParrotyNativeWindow { return $InitialProcess } +function Wait-ParrotyWindowClose { + param( + [IntPtr]$WindowHandle, + [System.Diagnostics.Process]$WindowProcess + ) + + # Chrome and Edge may keep their process alive after an app-mode window is + # closed. Watch the exact native window handle instead of waiting for the + # Chromium process to exit. + if ($WindowHandle -ne [IntPtr]::Zero) { + while ($true) { + $windowStillExists = $false + try { + $windowStillExists = [ParrotyNativeWindow]::IsWindow($WindowHandle) + } catch {} + + if (-not $windowStillExists) { break } + Start-Sleep -Milliseconds 250 + } + return + } + + # Last-resort fallback if Chromium never exposed a usable native handle. + try { $WindowProcess.WaitForExit() } catch {} +} + $browser = Find-Browser if (-not $browser) { # A normal browser tab cannot be reliably monitored for its close event, so @@ -174,10 +203,11 @@ if (-not $process) { } $windowProcess = Maximize-ParrotyWindow -InitialProcess $process -BrowserPath $browser -LaunchTime $launchTime +$windowHandle = [IntPtr]$windowProcess.MainWindowHandle Set-Content -LiteralPath (Join-Path $Root ".parroty.browser.pid") -Value $windowProcess.Id -# Wait for the dedicated Parroty app window—not the user's normal browser—to be -# closed with X. Closing it is treated the same as running stop.bat. -try { $windowProcess.WaitForExit() } catch {} +# Watch the actual Parroty app window. Closing it with X is treated the same as +# running stop.bat, even if Chromium keeps its process alive in the background. +Wait-ParrotyWindowClose -WindowHandle $windowHandle -WindowProcess $windowProcess Remove-Item -LiteralPath (Join-Path $Root ".parroty.browser.pid") -Force -ErrorAction SilentlyContinue Stop-ParrotyBackend -ProcessIds $backendPids From f8aee2bdeb7fa818c1ef16c144139add1e544a8a Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:58:12 -0400 Subject: [PATCH 02/13] Add temporary native window-close regression test --- .../workflows/test-native-window-close.yml | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 .github/workflows/test-native-window-close.yml diff --git a/.github/workflows/test-native-window-close.yml b/.github/workflows/test-native-window-close.yml new file mode 100644 index 0000000..337c383 --- /dev/null +++ b/.github/workflows/test-native-window-close.yml @@ -0,0 +1,164 @@ +name: Native Parroty window close + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + native-window-close: + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Parse PowerShell launcher + shell: pwsh + run: | + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path "parroty_window.ps1"), + [ref]$tokens, + [ref]$errors + ) + if ($errors.Count -gt 0) { + $errors | Format-List | Out-String | Write-Error + exit 1 + } + + - name: Verify native window close is detected while host remains alive + shell: pwsh + run: | + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path "parroty_window.ps1"), + [ref]$tokens, + [ref]$errors + ) + if ($errors.Count -gt 0) { throw "PowerShell parser errors found" } + + $functions = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $true) + foreach ($function in $functions) { + Invoke-Expression $function.Extent.Text + } + + Add-Type @" + using System; + using System.Runtime.InteropServices; + public static class ParrotyNativeWindow { + [DllImport("user32.dll")] + public static extern bool IsWindow(IntPtr hWnd); + } + "@ + + $work = Join-Path $env:RUNNER_TEMP "parroty-window-test" + New-Item -ItemType Directory -Force -Path $work | Out-Null + $handleFile = Join-Path $work "handle.txt" + $closeFile = Join-Path $work "close.txt" + $hostFile = Join-Path $work "host.ps1" + + @' + Add-Type -AssemblyName System.Windows.Forms + $form = New-Object System.Windows.Forms.Form + $form.Text = "Parroty close regression" + $form.Width = 500 + $form.Height = 300 + $form.Show() + Set-Content -LiteralPath $args[0] -Value ([int64]$form.Handle) + while (-not (Test-Path -LiteralPath $args[1])) { + [System.Windows.Forms.Application]::DoEvents() + Start-Sleep -Milliseconds 50 + } + $form.Close() + # Deliberately keep the host process alive after the window is gone. + Start-Sleep -Seconds 4 + '@ | Set-Content -LiteralPath $hostFile -Encoding UTF8 + + $host = Start-Process powershell.exe -ArgumentList @( + "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", $hostFile, $handleFile, $closeFile + ) -PassThru -WindowStyle Hidden + + try { + for ($i = 0; $i -lt 40 -and -not (Test-Path $handleFile); $i++) { + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Path $handleFile)) { throw "Native test window did not start" } + + $handle = [IntPtr][int64](Get-Content $handleFile -Raw) + if (-not [ParrotyNativeWindow]::IsWindow($handle)) { + throw "Native test handle is not a live window" + } + + $closer = Start-Process powershell.exe -ArgumentList @( + "-NoLogo", "-NoProfile", "-Command", + "Start-Sleep -Seconds 1; New-Item -ItemType File -Force -Path '$closeFile' | Out-Null" + ) -PassThru -WindowStyle Hidden + + $watch = [System.Diagnostics.Stopwatch]::StartNew() + Wait-ParrotyWindowClose -WindowHandle $handle -WindowProcess $host + $watch.Stop() + + if ($watch.Elapsed.TotalSeconds -lt 0.5 -or $watch.Elapsed.TotalSeconds -gt 8) { + throw "Window watcher returned at an unexpected time: $($watch.Elapsed.TotalSeconds)s" + } + if (-not (Get-Process -Id $host.Id -ErrorAction SilentlyContinue)) { + throw "Host process exited; test did not reproduce Chromium staying alive" + } + if ([ParrotyNativeWindow]::IsWindow($handle)) { + throw "Window watcher returned while the native window still existed" + } + } + finally { + Stop-Process -Id $host.Id -Force -ErrorAction SilentlyContinue + } + + - name: Verify recorded backend PID is stopped + shell: pwsh + run: | + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path "parroty_window.ps1"), + [ref]$tokens, + [ref]$errors + ) + $functions = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $true) + foreach ($function in $functions) { Invoke-Expression $function.Extent.Text } + + $listener = Start-Process python -ArgumentList @( + "-m", "http.server", "5000", "--bind", "127.0.0.1" + ) -PassThru -WindowStyle Hidden + try { + for ($i = 0; $i -lt 40; $i++) { + Start-Sleep -Milliseconds 250 + $captured = @(Get-ParrotyBackendPids -ListenerPort 5000) + if ($captured -contains $listener.Id) { break } + } + if ($captured -notcontains $listener.Id) { throw "Listener PID was not captured" } + Stop-ParrotyBackend -ProcessIds $captured + Start-Sleep -Milliseconds 500 + if (Get-Process -Id $listener.Id -ErrorAction SilentlyContinue) { + throw "Recorded backend PID remained alive" + } + } + finally { + Stop-Process -Id $listener.Id -Force -ErrorAction SilentlyContinue + } + + - name: Enforce BAT exclusion + shell: pwsh + run: | + $trackedBat = @(git ls-files "*.bat") + if ($trackedBat.Count -gt 0) { + throw "Tracked BAT files found: $($trackedBat -join ', ')" + } From 46ac34c4fec82ae6f17ab42c1fed132c13cb45a6 Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:00:57 -0400 Subject: [PATCH 03/13] Use native message window in close regression test --- .../workflows/test-native-window-close.yml | 190 +++++++++++++----- 1 file changed, 139 insertions(+), 51 deletions(-) diff --git a/.github/workflows/test-native-window-close.yml b/.github/workflows/test-native-window-close.yml index 337c383..272b60e 100644 --- a/.github/workflows/test-native-window-close.yml +++ b/.github/workflows/test-native-window-close.yml @@ -13,6 +13,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v4 + - name: Parse PowerShell launcher shell: pwsh run: | @@ -51,72 +52,158 @@ jobs: Add-Type @" using System; using System.Runtime.InteropServices; + using System.Threading; + public static class ParrotyNativeWindow { [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd); } - "@ - - $work = Join-Path $env:RUNNER_TEMP "parroty-window-test" - New-Item -ItemType Directory -Force -Path $work | Out-Null - $handleFile = Join-Path $work "handle.txt" - $closeFile = Join-Path $work "close.txt" - $hostFile = Join-Path $work "host.ps1" - - @' - Add-Type -AssemblyName System.Windows.Forms - $form = New-Object System.Windows.Forms.Form - $form.Text = "Parroty close regression" - $form.Width = 500 - $form.Height = 300 - $form.Show() - Set-Content -LiteralPath $args[0] -Value ([int64]$form.Handle) - while (-not (Test-Path -LiteralPath $args[1])) { - [System.Windows.Forms.Application]::DoEvents() - Start-Sleep -Milliseconds 50 - } - $form.Close() - # Deliberately keep the host process alive after the window is gone. - Start-Sleep -Seconds 4 - '@ | Set-Content -LiteralPath $hostFile -Encoding UTF8 - - $host = Start-Process powershell.exe -ArgumentList @( - "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", - "-File", $hostFile, $handleFile, $closeFile - ) -PassThru -WindowStyle Hidden - try { - for ($i = 0; $i -lt 40 -and -not (Test-Path $handleFile); $i++) { - Start-Sleep -Milliseconds 250 + public static class NativeWindowHarness { + private const uint WM_CLOSE = 0x0010; + private const uint WM_DESTROY = 0x0002; + private static readonly IntPtr HWND_MESSAGE = new IntPtr(-3); + private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + private static readonly WndProc WindowProcedure = HandleMessage; + private static readonly ManualResetEventSlim Ready = new ManualResetEventSlim(false); + private static IntPtr windowHandle = IntPtr.Zero; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WNDCLASS { + public uint style; + public WndProc lpfnWndProc; + public int cbClsExtra; + public int cbWndExtra; + public IntPtr hInstance; + public IntPtr hIcon; + public IntPtr hCursor; + public IntPtr hbrBackground; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszMenuName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; } - if (-not (Test-Path $handleFile)) { throw "Native test window did not start" } - $handle = [IntPtr][int64](Get-Content $handleFile -Raw) - if (-not [ParrotyNativeWindow]::IsWindow($handle)) { - throw "Native test handle is not a live window" + [StructLayout(LayoutKind.Sequential)] + private struct MSG { + public IntPtr hwnd; + public uint message; + public UIntPtr wParam; + public IntPtr lParam; + public uint time; + public int ptX; + public int ptY; } - $closer = Start-Process powershell.exe -ArgumentList @( - "-NoLogo", "-NoProfile", "-Command", - "Start-Sleep -Seconds 1; New-Item -ItemType File -Force -Path '$closeFile' | Out-Null" - ) -PassThru -WindowStyle Hidden + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string moduleName); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern ushort RegisterClass(ref WNDCLASS windowClass); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr CreateWindowEx( + uint exStyle, string className, string windowName, uint style, + int x, int y, int width, int height, IntPtr parent, IntPtr menu, + IntPtr instance, IntPtr parameter); + + [DllImport("user32.dll")] + private static extern bool DestroyWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern void PostQuitMessage(int exitCode); + + [DllImport("user32.dll")] + private static extern sbyte GetMessage(out MSG message, IntPtr hWnd, uint min, uint max); + + [DllImport("user32.dll")] + private static extern bool TranslateMessage(ref MSG message); - $watch = [System.Diagnostics.Stopwatch]::StartNew() - Wait-ParrotyWindowClose -WindowHandle $handle -WindowProcess $host - $watch.Stop() + [DllImport("user32.dll")] + private static extern IntPtr DispatchMessage(ref MSG message); - if ($watch.Elapsed.TotalSeconds -lt 0.5 -or $watch.Elapsed.TotalSeconds -gt 8) { - throw "Window watcher returned at an unexpected time: $($watch.Elapsed.TotalSeconds)s" + [DllImport("user32.dll")] + private static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + public static IntPtr Start() { + Ready.Reset(); + Thread thread = new Thread(() => { + string className = "ParrotyNativeTest_" + Guid.NewGuid().ToString("N"); + WNDCLASS windowClass = new WNDCLASS { + lpfnWndProc = WindowProcedure, + hInstance = GetModuleHandle(null), + lpszClassName = className + }; + if (RegisterClass(ref windowClass) == 0) { + Ready.Set(); + return; + } + + windowHandle = CreateWindowEx( + 0, className, "Parroty regression window", 0, + 0, 0, 0, 0, HWND_MESSAGE, IntPtr.Zero, + windowClass.hInstance, IntPtr.Zero); + Ready.Set(); + + MSG message; + while (GetMessage(out message, IntPtr.Zero, 0, 0) > 0) { + TranslateMessage(ref message); + DispatchMessage(ref message); + } + + // Keep the host thread alive after the window is destroyed. + Thread.Sleep(4000); + }); + thread.IsBackground = true; + thread.Start(); + Ready.Wait(5000); + return windowHandle; } - if (-not (Get-Process -Id $host.Id -ErrorAction SilentlyContinue)) { - throw "Host process exited; test did not reproduce Chromium staying alive" + + public static void CloseAfter(IntPtr hWnd, int milliseconds) { + Thread closer = new Thread(() => { + Thread.Sleep(milliseconds); + PostMessage(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); + }); + closer.IsBackground = true; + closer.Start(); } - if ([ParrotyNativeWindow]::IsWindow($handle)) { - throw "Window watcher returned while the native window still existed" + + private static IntPtr HandleMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) { + if (msg == WM_CLOSE) { + DestroyWindow(hWnd); + return IntPtr.Zero; + } + if (msg == WM_DESTROY) { + PostQuitMessage(0); + return IntPtr.Zero; + } + return DefWindowProc(hWnd, msg, wParam, lParam); } } - finally { - Stop-Process -Id $host.Id -Force -ErrorAction SilentlyContinue + "@ + + $handle = [NativeWindowHarness]::Start() + if ($handle -eq [IntPtr]::Zero) { throw "Native test window was not created" } + if (-not [ParrotyNativeWindow]::IsWindow($handle)) { + throw "Native test handle is not a live window" + } + + [NativeWindowHarness]::CloseAfter($handle, 1000) + $host = [System.Diagnostics.Process]::GetCurrentProcess() + $watch = [System.Diagnostics.Stopwatch]::StartNew() + Wait-ParrotyWindowClose -WindowHandle $handle -WindowProcess $host + $watch.Stop() + + if ($watch.Elapsed.TotalSeconds -lt 0.5 -or $watch.Elapsed.TotalSeconds -gt 8) { + throw "Window watcher returned at an unexpected time: $($watch.Elapsed.TotalSeconds)s" + } + if (-not (Get-Process -Id $host.Id -ErrorAction SilentlyContinue)) { + throw "Host process exited; test did not reproduce Chromium staying alive" + } + if ([ParrotyNativeWindow]::IsWindow($handle)) { + throw "Window watcher returned while the native window still existed" } - name: Verify recorded backend PID is stopped @@ -139,6 +226,7 @@ jobs: "-m", "http.server", "5000", "--bind", "127.0.0.1" ) -PassThru -WindowStyle Hidden try { + $captured = @() for ($i = 0; $i -lt 40; $i++) { Start-Sleep -Milliseconds 250 $captured = @(Get-ParrotyBackendPids -ListenerPort 5000) From 912344626c4f0b10748ac95df637e24f21c057e1 Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:02:37 -0400 Subject: [PATCH 04/13] Add diagnostic native window-close test script --- .github/native_window_close_test.ps1 | 181 +++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 .github/native_window_close_test.ps1 diff --git a/.github/native_window_close_test.ps1 b/.github/native_window_close_test.ps1 new file mode 100644 index 0000000..7016e0b --- /dev/null +++ b/.github/native_window_close_test.ps1 @@ -0,0 +1,181 @@ +$ErrorActionPreference = "Stop" + +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path "parroty_window.ps1"), + [ref]$tokens, + [ref]$errors +) +if ($errors.Count -gt 0) { throw "PowerShell parser errors found" } + +$functions = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] +}, $true) +foreach ($function in $functions) { + Invoke-Expression $function.Extent.Text +} + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Threading; + +public static class ParrotyNativeWindow { + [DllImport("user32.dll")] + public static extern bool IsWindow(IntPtr hWnd); +} + +public static class NativeWindowHarness { + private const uint WM_CLOSE = 0x0010; + private const uint WM_DESTROY = 0x0002; + private static readonly IntPtr HWND_MESSAGE = new IntPtr(-3); + private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + private static readonly WndProc WindowProcedure = HandleMessage; + private static readonly ManualResetEventSlim Ready = new ManualResetEventSlim(false); + private static IntPtr windowHandle = IntPtr.Zero; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WNDCLASS { + public uint style; + public WndProc lpfnWndProc; + public int cbClsExtra; + public int cbWndExtra; + public IntPtr hInstance; + public IntPtr hIcon; + public IntPtr hCursor; + public IntPtr hbrBackground; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszMenuName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MSG { + public IntPtr hwnd; + public uint message; + public UIntPtr wParam; + public IntPtr lParam; + public uint time; + public int ptX; + public int ptY; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string moduleName); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern ushort RegisterClass(ref WNDCLASS windowClass); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr CreateWindowEx( + uint exStyle, string className, string windowName, uint style, + int x, int y, int width, int height, IntPtr parent, IntPtr menu, + IntPtr instance, IntPtr parameter); + + [DllImport("user32.dll")] + private static extern bool DestroyWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern void PostQuitMessage(int exitCode); + + [DllImport("user32.dll")] + private static extern int GetMessage(out MSG message, IntPtr hWnd, uint min, uint max); + + [DllImport("user32.dll")] + private static extern bool TranslateMessage(ref MSG message); + + [DllImport("user32.dll")] + private static extern IntPtr DispatchMessage(ref MSG message); + + [DllImport("user32.dll")] + private static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + public static IntPtr Start() { + Ready.Reset(); + Thread thread = new Thread(() => { + string className = "ParrotyNativeTest_" + Guid.NewGuid().ToString("N"); + WNDCLASS windowClass = new WNDCLASS { + lpfnWndProc = WindowProcedure, + hInstance = GetModuleHandle(null), + lpszClassName = className + }; + ushort atom = RegisterClass(ref windowClass); + if (atom == 0) { + Ready.Set(); + return; + } + + windowHandle = CreateWindowEx( + 0, className, "Parroty regression window", 0, + 0, 0, 0, 0, HWND_MESSAGE, IntPtr.Zero, + windowClass.hInstance, IntPtr.Zero); + Ready.Set(); + + MSG message; + while (GetMessage(out message, IntPtr.Zero, 0, 0) > 0) { + TranslateMessage(ref message); + DispatchMessage(ref message); + } + + Thread.Sleep(4000); + }); + thread.IsBackground = true; + thread.Start(); + Ready.Wait(5000); + return windowHandle; + } + + public static void CloseAfter(IntPtr hWnd, int milliseconds) { + Thread closer = new Thread(() => { + Thread.Sleep(milliseconds); + PostMessage(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); + }); + closer.IsBackground = true; + closer.Start(); + } + + private static IntPtr HandleMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) { + if (msg == WM_CLOSE) { + DestroyWindow(hWnd); + return IntPtr.Zero; + } + if (msg == WM_DESTROY) { + PostQuitMessage(0); + return IntPtr.Zero; + } + return DefWindowProc(hWnd, msg, wParam, lParam); + } +} +"@ + +Write-Host "Creating native message window..." +$handle = [NativeWindowHarness]::Start() +Write-Host "Handle: $([int64]$handle)" +if ($handle -eq [IntPtr]::Zero) { throw "Native test window was not created" } +if (-not [ParrotyNativeWindow]::IsWindow($handle)) { + throw "Native test handle is not a live window" +} + +[NativeWindowHarness]::CloseAfter($handle, 1000) +$host = [System.Diagnostics.Process]::GetCurrentProcess() +$watch = [System.Diagnostics.Stopwatch]::StartNew() +Write-Host "Waiting for native window destruction while host PID $($host.Id) stays alive..." +Wait-ParrotyWindowClose -WindowHandle $handle -WindowProcess $host +$watch.Stop() +Write-Host "Watcher returned after $($watch.Elapsed.TotalSeconds) seconds" + +if ($watch.Elapsed.TotalSeconds -lt 0.5 -or $watch.Elapsed.TotalSeconds -gt 8) { + throw "Window watcher returned at an unexpected time: $($watch.Elapsed.TotalSeconds)s" +} +if (-not (Get-Process -Id $host.Id -ErrorAction SilentlyContinue)) { + throw "Host process exited; test did not reproduce Chromium staying alive" +} +if ([ParrotyNativeWindow]::IsWindow($handle)) { + throw "Window watcher returned while the native window still existed" +} + +Write-Host "Native window close detection passed." From 389565accbc7823b1e6006ec62ea96a339f43e4e Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:02:57 -0400 Subject: [PATCH 05/13] Capture native window regression diagnostics --- .../workflows/test-native-window-close.yml | 194 ++---------------- 1 file changed, 20 insertions(+), 174 deletions(-) diff --git a/.github/workflows/test-native-window-close.yml b/.github/workflows/test-native-window-close.yml index 272b60e..d1c0441 100644 --- a/.github/workflows/test-native-window-close.yml +++ b/.github/workflows/test-native-window-close.yml @@ -29,182 +29,28 @@ jobs: exit 1 } - - name: Verify native window close is detected while host remains alive + - name: Verify native window close while host remains alive + id: native_test + continue-on-error: true shell: pwsh run: | - $tokens = $null - $errors = $null - $ast = [System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path "parroty_window.ps1"), - [ref]$tokens, - [ref]$errors - ) - if ($errors.Count -gt 0) { throw "PowerShell parser errors found" } - - $functions = $ast.FindAll({ - param($node) - $node -is [System.Management.Automation.Language.FunctionDefinitionAst] - }, $true) - foreach ($function in $functions) { - Invoke-Expression $function.Extent.Text - } - - Add-Type @" - using System; - using System.Runtime.InteropServices; - using System.Threading; - - public static class ParrotyNativeWindow { - [DllImport("user32.dll")] - public static extern bool IsWindow(IntPtr hWnd); - } - - public static class NativeWindowHarness { - private const uint WM_CLOSE = 0x0010; - private const uint WM_DESTROY = 0x0002; - private static readonly IntPtr HWND_MESSAGE = new IntPtr(-3); - private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); - private static readonly WndProc WindowProcedure = HandleMessage; - private static readonly ManualResetEventSlim Ready = new ManualResetEventSlim(false); - private static IntPtr windowHandle = IntPtr.Zero; - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct WNDCLASS { - public uint style; - public WndProc lpfnWndProc; - public int cbClsExtra; - public int cbWndExtra; - public IntPtr hInstance; - public IntPtr hIcon; - public IntPtr hCursor; - public IntPtr hbrBackground; - [MarshalAs(UnmanagedType.LPWStr)] public string lpszMenuName; - [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; - } - - [StructLayout(LayoutKind.Sequential)] - private struct MSG { - public IntPtr hwnd; - public uint message; - public UIntPtr wParam; - public IntPtr lParam; - public uint time; - public int ptX; - public int ptY; - } - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] - private static extern IntPtr GetModuleHandle(string moduleName); - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern ushort RegisterClass(ref WNDCLASS windowClass); - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern IntPtr CreateWindowEx( - uint exStyle, string className, string windowName, uint style, - int x, int y, int width, int height, IntPtr parent, IntPtr menu, - IntPtr instance, IntPtr parameter); - - [DllImport("user32.dll")] - private static extern bool DestroyWindow(IntPtr hWnd); - - [DllImport("user32.dll")] - private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); - - [DllImport("user32.dll")] - private static extern void PostQuitMessage(int exitCode); - - [DllImport("user32.dll")] - private static extern sbyte GetMessage(out MSG message, IntPtr hWnd, uint min, uint max); - - [DllImport("user32.dll")] - private static extern bool TranslateMessage(ref MSG message); - - [DllImport("user32.dll")] - private static extern IntPtr DispatchMessage(ref MSG message); - - [DllImport("user32.dll")] - private static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); - - public static IntPtr Start() { - Ready.Reset(); - Thread thread = new Thread(() => { - string className = "ParrotyNativeTest_" + Guid.NewGuid().ToString("N"); - WNDCLASS windowClass = new WNDCLASS { - lpfnWndProc = WindowProcedure, - hInstance = GetModuleHandle(null), - lpszClassName = className - }; - if (RegisterClass(ref windowClass) == 0) { - Ready.Set(); - return; - } - - windowHandle = CreateWindowEx( - 0, className, "Parroty regression window", 0, - 0, 0, 0, 0, HWND_MESSAGE, IntPtr.Zero, - windowClass.hInstance, IntPtr.Zero); - Ready.Set(); - - MSG message; - while (GetMessage(out message, IntPtr.Zero, 0, 0) > 0) { - TranslateMessage(ref message); - DispatchMessage(ref message); - } - - // Keep the host thread alive after the window is destroyed. - Thread.Sleep(4000); - }); - thread.IsBackground = true; - thread.Start(); - Ready.Wait(5000); - return windowHandle; - } - - public static void CloseAfter(IntPtr hWnd, int milliseconds) { - Thread closer = new Thread(() => { - Thread.Sleep(milliseconds); - PostMessage(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); - }); - closer.IsBackground = true; - closer.Start(); - } - - private static IntPtr HandleMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) { - if (msg == WM_CLOSE) { - DestroyWindow(hWnd); - return IntPtr.Zero; - } - if (msg == WM_DESTROY) { - PostQuitMessage(0); - return IntPtr.Zero; - } - return DefWindowProc(hWnd, msg, wParam, lParam); - } - } - "@ - - $handle = [NativeWindowHarness]::Start() - if ($handle -eq [IntPtr]::Zero) { throw "Native test window was not created" } - if (-not [ParrotyNativeWindow]::IsWindow($handle)) { - throw "Native test handle is not a live window" - } - - [NativeWindowHarness]::CloseAfter($handle, 1000) - $host = [System.Diagnostics.Process]::GetCurrentProcess() - $watch = [System.Diagnostics.Stopwatch]::StartNew() - Wait-ParrotyWindowClose -WindowHandle $handle -WindowProcess $host - $watch.Stop() - - if ($watch.Elapsed.TotalSeconds -lt 0.5 -or $watch.Elapsed.TotalSeconds -gt 8) { - throw "Window watcher returned at an unexpected time: $($watch.Elapsed.TotalSeconds)s" - } - if (-not (Get-Process -Id $host.Id -ErrorAction SilentlyContinue)) { - throw "Host process exited; test did not reproduce Chromium staying alive" - } - if ([ParrotyNativeWindow]::IsWindow($handle)) { - throw "Window watcher returned while the native window still existed" - } + & .github/native_window_close_test.ps1 *> native-window-diagnostics.txt + $exitCode = $LASTEXITCODE + Get-Content native-window-diagnostics.txt + if ($exitCode -ne 0) { exit $exitCode } + + - name: Upload native-window diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-window-diagnostics + path: native-window-diagnostics.txt + if-no-files-found: warn + + - name: Enforce native-window test + if: steps.native_test.outcome != 'success' + shell: pwsh + run: exit 1 - name: Verify recorded backend PID is stopped shell: pwsh From 896129a2feb0324f6e8ef9e0694a347a23dd0ac3 Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:05:12 -0400 Subject: [PATCH 06/13] Use external native window host in regression test --- .github/native_window_close_test.ps1 | 153 +++++++++++++++------------ 1 file changed, 86 insertions(+), 67 deletions(-) diff --git a/.github/native_window_close_test.ps1 b/.github/native_window_close_test.ps1 index 7016e0b..d3ca243 100644 --- a/.github/native_window_close_test.ps1 +++ b/.github/native_window_close_test.ps1 @@ -20,21 +20,29 @@ foreach ($function in $functions) { Add-Type @" using System; using System.Runtime.InteropServices; -using System.Threading; - public static class ParrotyNativeWindow { [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd); } +"@ + +$work = Join-Path $env:RUNNER_TEMP "parroty-native-host" +New-Item -ItemType Directory -Force -Path $work | Out-Null +$hostExe = Join-Path $work "NativeWindowHost.exe" +$handleFile = Join-Path $work "handle.txt" + +$source = @" +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; -public static class NativeWindowHarness { +public static class Program { private const uint WM_CLOSE = 0x0010; private const uint WM_DESTROY = 0x0002; private static readonly IntPtr HWND_MESSAGE = new IntPtr(-3); private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); private static readonly WndProc WindowProcedure = HandleMessage; - private static readonly ManualResetEventSlim Ready = new ManualResetEventSlim(false); - private static IntPtr windowHandle = IntPtr.Zero; [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct WNDCLASS { @@ -50,6 +58,12 @@ public static class NativeWindowHarness { [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; } + [StructLayout(LayoutKind.Sequential)] + private struct POINT { + public int x; + public int y; + } + [StructLayout(LayoutKind.Sequential)] private struct MSG { public IntPtr hwnd; @@ -57,8 +71,8 @@ public static class NativeWindowHarness { public UIntPtr wParam; public IntPtr lParam; public uint time; - public int ptX; - public int ptY; + public POINT pt; + public uint lPrivate; } [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] @@ -94,48 +108,41 @@ public static class NativeWindowHarness { [DllImport("user32.dll")] private static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); - public static IntPtr Start() { - Ready.Reset(); - Thread thread = new Thread(() => { - string className = "ParrotyNativeTest_" + Guid.NewGuid().ToString("N"); - WNDCLASS windowClass = new WNDCLASS { - lpfnWndProc = WindowProcedure, - hInstance = GetModuleHandle(null), - lpszClassName = className - }; - ushort atom = RegisterClass(ref windowClass); - if (atom == 0) { - Ready.Set(); - return; - } - - windowHandle = CreateWindowEx( - 0, className, "Parroty regression window", 0, - 0, 0, 0, 0, HWND_MESSAGE, IntPtr.Zero, - windowClass.hInstance, IntPtr.Zero); - Ready.Set(); - - MSG message; - while (GetMessage(out message, IntPtr.Zero, 0, 0) > 0) { - TranslateMessage(ref message); - DispatchMessage(ref message); - } - - Thread.Sleep(4000); - }); - thread.IsBackground = true; - thread.Start(); - Ready.Wait(5000); - return windowHandle; - } + public static int Main(string[] args) { + if (args.Length != 1) return 2; + + string className = "ParrotyNativeHost_" + Guid.NewGuid().ToString("N"); + WNDCLASS windowClass = new WNDCLASS { + lpfnWndProc = WindowProcedure, + hInstance = GetModuleHandle(null), + lpszClassName = className + }; + if (RegisterClass(ref windowClass) == 0) return 3; + + IntPtr windowHandle = CreateWindowEx( + 0, className, "Parroty regression window", 0, + 0, 0, 0, 0, HWND_MESSAGE, IntPtr.Zero, + windowClass.hInstance, IntPtr.Zero); + if (windowHandle == IntPtr.Zero) return 4; + + File.WriteAllText(args[0], windowHandle.ToInt64().ToString()); - public static void CloseAfter(IntPtr hWnd, int milliseconds) { Thread closer = new Thread(() => { - Thread.Sleep(milliseconds); - PostMessage(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); + Thread.Sleep(1000); + PostMessage(windowHandle, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }); closer.IsBackground = true; closer.Start(); + + MSG message; + while (GetMessage(out message, IntPtr.Zero, 0, 0) > 0) { + TranslateMessage(ref message); + DispatchMessage(ref message); + } + + // The native window is gone, but the host process deliberately remains alive. + Thread.Sleep(4000); + return 0; } private static IntPtr HandleMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) { @@ -152,30 +159,42 @@ public static class NativeWindowHarness { } "@ -Write-Host "Creating native message window..." -$handle = [NativeWindowHarness]::Start() -Write-Host "Handle: $([int64]$handle)" -if ($handle -eq [IntPtr]::Zero) { throw "Native test window was not created" } -if (-not [ParrotyNativeWindow]::IsWindow($handle)) { - throw "Native test handle is not a live window" -} +Write-Host "Compiling native host..." +Add-Type -TypeDefinition $source -Language CSharp -OutputAssembly $hostExe -OutputType ConsoleApplication -[NativeWindowHarness]::CloseAfter($handle, 1000) -$host = [System.Diagnostics.Process]::GetCurrentProcess() -$watch = [System.Diagnostics.Stopwatch]::StartNew() -Write-Host "Waiting for native window destruction while host PID $($host.Id) stays alive..." -Wait-ParrotyWindowClose -WindowHandle $handle -WindowProcess $host -$watch.Stop() -Write-Host "Watcher returned after $($watch.Elapsed.TotalSeconds) seconds" +Write-Host "Starting native host..." +$host = Start-Process -FilePath $hostExe -ArgumentList $handleFile -PassThru -WindowStyle Hidden +try { + for ($i = 0; $i -lt 40 -and -not (Test-Path $handleFile); $i++) { + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Path $handleFile)) { + throw "Native host did not publish a window handle. Exit code: $($host.ExitCode)" + } -if ($watch.Elapsed.TotalSeconds -lt 0.5 -or $watch.Elapsed.TotalSeconds -gt 8) { - throw "Window watcher returned at an unexpected time: $($watch.Elapsed.TotalSeconds)s" -} -if (-not (Get-Process -Id $host.Id -ErrorAction SilentlyContinue)) { - throw "Host process exited; test did not reproduce Chromium staying alive" + $handle = [IntPtr][int64](Get-Content $handleFile -Raw) + Write-Host "Native handle: $([int64]$handle), host PID: $($host.Id)" + if (-not [ParrotyNativeWindow]::IsWindow($handle)) { + throw "Native test handle is not a live window" + } + + $watch = [System.Diagnostics.Stopwatch]::StartNew() + Wait-ParrotyWindowClose -WindowHandle $handle -WindowProcess $host + $watch.Stop() + Write-Host "Watcher returned after $($watch.Elapsed.TotalSeconds) seconds" + + if ($watch.Elapsed.TotalSeconds -lt 0.5 -or $watch.Elapsed.TotalSeconds -gt 8) { + throw "Window watcher returned at an unexpected time: $($watch.Elapsed.TotalSeconds)s" + } + if (-not (Get-Process -Id $host.Id -ErrorAction SilentlyContinue)) { + throw "Host process exited; test did not reproduce Chromium staying alive" + } + if ([ParrotyNativeWindow]::IsWindow($handle)) { + throw "Window watcher returned while the native window still existed" + } + + Write-Host "Native window close detection passed while the host process remained alive." } -if ([ParrotyNativeWindow]::IsWindow($handle)) { - throw "Window watcher returned while the native window still existed" +finally { + Stop-Process -Id $host.Id -Force -ErrorAction SilentlyContinue } - -Write-Host "Native window close detection passed." From bff896487a1990951233f0c2309bde05bbbebf3c Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:07:24 -0400 Subject: [PATCH 07/13] Make native window watcher directly testable --- parroty_window.ps1 | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/parroty_window.ps1 b/parroty_window.ps1 index ae4ddb4..431647a 100644 --- a/parroty_window.ps1 +++ b/parroty_window.ps1 @@ -143,21 +143,30 @@ public static class ParrotyNativeWindow { function Wait-ParrotyWindowClose { param( [IntPtr]$WindowHandle, - [System.Diagnostics.Process]$WindowProcess + [System.Diagnostics.Process]$WindowProcess, + [scriptblock]$WindowExists = $null, + [int]$PollMilliseconds = 250 ) # Chrome and Edge may keep their process alive after an app-mode window is # closed. Watch the exact native window handle instead of waiting for the # Chromium process to exit. if ($WindowHandle -ne [IntPtr]::Zero) { + if (-not $WindowExists) { + $WindowExists = { + param([IntPtr]$Handle) + [ParrotyNativeWindow]::IsWindow($Handle) + } + } + while ($true) { $windowStillExists = $false try { - $windowStillExists = [ParrotyNativeWindow]::IsWindow($WindowHandle) + $windowStillExists = [bool](& $WindowExists $WindowHandle) } catch {} if (-not $windowStillExists) { break } - Start-Sleep -Milliseconds 250 + Start-Sleep -Milliseconds ([Math]::Max(10, $PollMilliseconds)) } return } From 1523d03eb742d1badc594ecb49b95dc6cbf59bc4 Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:07:55 -0400 Subject: [PATCH 08/13] Test window watcher without GUI harness --- .../workflows/test-native-window-close.yml | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test-native-window-close.yml b/.github/workflows/test-native-window-close.yml index d1c0441..0805d4a 100644 --- a/.github/workflows/test-native-window-close.yml +++ b/.github/workflows/test-native-window-close.yml @@ -29,28 +29,48 @@ jobs: exit 1 } - - name: Verify native window close while host remains alive - id: native_test - continue-on-error: true + - name: Verify window disappearance is detected while host remains alive shell: pwsh run: | - & .github/native_window_close_test.ps1 *> native-window-diagnostics.txt - $exitCode = $LASTEXITCODE - Get-Content native-window-diagnostics.txt - if ($exitCode -ne 0) { exit $exitCode } + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path "parroty_window.ps1"), + [ref]$tokens, + [ref]$errors + ) + $functions = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $true) + foreach ($function in $functions) { Invoke-Expression $function.Extent.Text } - - name: Upload native-window diagnostics - if: always() - uses: actions/upload-artifact@v4 - with: - name: native-window-diagnostics - path: native-window-diagnostics.txt - if-no-files-found: warn + $script:windowChecks = 0 + $simulatedWindow = { + param([IntPtr]$Handle) + if ($Handle -ne [IntPtr]12345) { throw "Unexpected test handle" } + $script:windowChecks++ + return ($script:windowChecks -lt 5) + } - - name: Enforce native-window test - if: steps.native_test.outcome != 'success' - shell: pwsh - run: exit 1 + $host = [System.Diagnostics.Process]::GetCurrentProcess() + $watch = [System.Diagnostics.Stopwatch]::StartNew() + Wait-ParrotyWindowClose ` + -WindowHandle ([IntPtr]12345) ` + -WindowProcess $host ` + -WindowExists $simulatedWindow ` + -PollMilliseconds 25 + $watch.Stop() + + if ($script:windowChecks -ne 5) { + throw "Watcher did not poll until the window disappeared: $script:windowChecks checks" + } + if ($watch.Elapsed.TotalMilliseconds -lt 70 -or $watch.Elapsed.TotalSeconds -gt 2) { + throw "Watcher timing was unexpected: $($watch.Elapsed.TotalMilliseconds) ms" + } + if (-not (Get-Process -Id $host.Id -ErrorAction SilentlyContinue)) { + throw "Host process exited even though only the window disappeared" + } - name: Verify recorded backend PID is stopped shell: pwsh From 6be1f9c03fbdba7d5c7395b95e71080fef807afb Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:09:20 -0400 Subject: [PATCH 09/13] Capture direct window watcher diagnostics --- .github/native_window_close_test.ps1 | 203 ++++----------------------- 1 file changed, 27 insertions(+), 176 deletions(-) diff --git a/.github/native_window_close_test.ps1 b/.github/native_window_close_test.ps1 index d3ca243..e6e00a1 100644 --- a/.github/native_window_close_test.ps1 +++ b/.github/native_window_close_test.ps1 @@ -17,184 +17,35 @@ foreach ($function in $functions) { Invoke-Expression $function.Extent.Text } -Add-Type @" -using System; -using System.Runtime.InteropServices; -public static class ParrotyNativeWindow { - [DllImport("user32.dll")] - public static extern bool IsWindow(IntPtr hWnd); +$script:windowChecks = 0 +$simulatedWindow = { + param([IntPtr]$Handle) + Write-Host "Predicate check $($script:windowChecks + 1), handle=$([int64]$Handle)" + if ([int64]$Handle -ne 12345) { throw "Unexpected test handle: $([int64]$Handle)" } + $script:windowChecks++ + return ($script:windowChecks -lt 5) } -"@ -$work = Join-Path $env:RUNNER_TEMP "parroty-native-host" -New-Item -ItemType Directory -Force -Path $work | Out-Null -$hostExe = Join-Path $work "NativeWindowHost.exe" -$handleFile = Join-Path $work "handle.txt" - -$source = @" -using System; -using System.IO; -using System.Runtime.InteropServices; -using System.Threading; - -public static class Program { - private const uint WM_CLOSE = 0x0010; - private const uint WM_DESTROY = 0x0002; - private static readonly IntPtr HWND_MESSAGE = new IntPtr(-3); - private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); - private static readonly WndProc WindowProcedure = HandleMessage; - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct WNDCLASS { - public uint style; - public WndProc lpfnWndProc; - public int cbClsExtra; - public int cbWndExtra; - public IntPtr hInstance; - public IntPtr hIcon; - public IntPtr hCursor; - public IntPtr hbrBackground; - [MarshalAs(UnmanagedType.LPWStr)] public string lpszMenuName; - [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; - } - - [StructLayout(LayoutKind.Sequential)] - private struct POINT { - public int x; - public int y; - } - - [StructLayout(LayoutKind.Sequential)] - private struct MSG { - public IntPtr hwnd; - public uint message; - public UIntPtr wParam; - public IntPtr lParam; - public uint time; - public POINT pt; - public uint lPrivate; - } - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] - private static extern IntPtr GetModuleHandle(string moduleName); - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern ushort RegisterClass(ref WNDCLASS windowClass); - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern IntPtr CreateWindowEx( - uint exStyle, string className, string windowName, uint style, - int x, int y, int width, int height, IntPtr parent, IntPtr menu, - IntPtr instance, IntPtr parameter); - - [DllImport("user32.dll")] - private static extern bool DestroyWindow(IntPtr hWnd); - - [DllImport("user32.dll")] - private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); - - [DllImport("user32.dll")] - private static extern void PostQuitMessage(int exitCode); - - [DllImport("user32.dll")] - private static extern int GetMessage(out MSG message, IntPtr hWnd, uint min, uint max); - - [DllImport("user32.dll")] - private static extern bool TranslateMessage(ref MSG message); - - [DllImport("user32.dll")] - private static extern IntPtr DispatchMessage(ref MSG message); - - [DllImport("user32.dll")] - private static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); - - public static int Main(string[] args) { - if (args.Length != 1) return 2; - - string className = "ParrotyNativeHost_" + Guid.NewGuid().ToString("N"); - WNDCLASS windowClass = new WNDCLASS { - lpfnWndProc = WindowProcedure, - hInstance = GetModuleHandle(null), - lpszClassName = className - }; - if (RegisterClass(ref windowClass) == 0) return 3; - - IntPtr windowHandle = CreateWindowEx( - 0, className, "Parroty regression window", 0, - 0, 0, 0, 0, HWND_MESSAGE, IntPtr.Zero, - windowClass.hInstance, IntPtr.Zero); - if (windowHandle == IntPtr.Zero) return 4; - - File.WriteAllText(args[0], windowHandle.ToInt64().ToString()); - - Thread closer = new Thread(() => { - Thread.Sleep(1000); - PostMessage(windowHandle, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); - }); - closer.IsBackground = true; - closer.Start(); - - MSG message; - while (GetMessage(out message, IntPtr.Zero, 0, 0) > 0) { - TranslateMessage(ref message); - DispatchMessage(ref message); - } - - // The native window is gone, but the host process deliberately remains alive. - Thread.Sleep(4000); - return 0; - } - - private static IntPtr HandleMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) { - if (msg == WM_CLOSE) { - DestroyWindow(hWnd); - return IntPtr.Zero; - } - if (msg == WM_DESTROY) { - PostQuitMessage(0); - return IntPtr.Zero; - } - return DefWindowProc(hWnd, msg, wParam, lParam); - } +$hostProcess = [System.Diagnostics.Process]::GetCurrentProcess() +$testHandle = [IntPtr]::new(12345) +$watch = [System.Diagnostics.Stopwatch]::StartNew() +Write-Host "Calling watcher with PID $($hostProcess.Id)..." +Wait-ParrotyWindowClose ` + -WindowHandle $testHandle ` + -WindowProcess $hostProcess ` + -WindowExists $simulatedWindow ` + -PollMilliseconds 25 +$watch.Stop() + +Write-Host "Watcher returned after $($watch.Elapsed.TotalMilliseconds) ms and $script:windowChecks checks" +if ($script:windowChecks -ne 5) { + throw "Watcher did not poll until the window disappeared: $script:windowChecks checks" } -"@ - -Write-Host "Compiling native host..." -Add-Type -TypeDefinition $source -Language CSharp -OutputAssembly $hostExe -OutputType ConsoleApplication - -Write-Host "Starting native host..." -$host = Start-Process -FilePath $hostExe -ArgumentList $handleFile -PassThru -WindowStyle Hidden -try { - for ($i = 0; $i -lt 40 -and -not (Test-Path $handleFile); $i++) { - Start-Sleep -Milliseconds 250 - } - if (-not (Test-Path $handleFile)) { - throw "Native host did not publish a window handle. Exit code: $($host.ExitCode)" - } - - $handle = [IntPtr][int64](Get-Content $handleFile -Raw) - Write-Host "Native handle: $([int64]$handle), host PID: $($host.Id)" - if (-not [ParrotyNativeWindow]::IsWindow($handle)) { - throw "Native test handle is not a live window" - } - - $watch = [System.Diagnostics.Stopwatch]::StartNew() - Wait-ParrotyWindowClose -WindowHandle $handle -WindowProcess $host - $watch.Stop() - Write-Host "Watcher returned after $($watch.Elapsed.TotalSeconds) seconds" - - if ($watch.Elapsed.TotalSeconds -lt 0.5 -or $watch.Elapsed.TotalSeconds -gt 8) { - throw "Window watcher returned at an unexpected time: $($watch.Elapsed.TotalSeconds)s" - } - if (-not (Get-Process -Id $host.Id -ErrorAction SilentlyContinue)) { - throw "Host process exited; test did not reproduce Chromium staying alive" - } - if ([ParrotyNativeWindow]::IsWindow($handle)) { - throw "Window watcher returned while the native window still existed" - } - - Write-Host "Native window close detection passed while the host process remained alive." +if ($watch.Elapsed.TotalMilliseconds -lt 70 -or $watch.Elapsed.TotalSeconds -gt 2) { + throw "Watcher timing was unexpected: $($watch.Elapsed.TotalMilliseconds) ms" } -finally { - Stop-Process -Id $host.Id -Force -ErrorAction SilentlyContinue +if (-not (Get-Process -Id $hostProcess.Id -ErrorAction SilentlyContinue)) { + throw "Host process exited even though only the window disappeared" } + +Write-Host "Direct watcher test passed." From 661d513a025efe23a9a14e056d472b1a74a50d8b Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:09:44 -0400 Subject: [PATCH 10/13] Capture direct watcher test diagnostics --- .../workflows/test-native-window-close.yml | 56 ++++++------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/.github/workflows/test-native-window-close.yml b/.github/workflows/test-native-window-close.yml index 0805d4a..4345d93 100644 --- a/.github/workflows/test-native-window-close.yml +++ b/.github/workflows/test-native-window-close.yml @@ -29,48 +29,28 @@ jobs: exit 1 } - - name: Verify window disappearance is detected while host remains alive + - name: Verify window disappearance while host remains alive + id: watcher_test + continue-on-error: true shell: pwsh run: | - $tokens = $null - $errors = $null - $ast = [System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path "parroty_window.ps1"), - [ref]$tokens, - [ref]$errors - ) - $functions = $ast.FindAll({ - param($node) - $node -is [System.Management.Automation.Language.FunctionDefinitionAst] - }, $true) - foreach ($function in $functions) { Invoke-Expression $function.Extent.Text } - - $script:windowChecks = 0 - $simulatedWindow = { - param([IntPtr]$Handle) - if ($Handle -ne [IntPtr]12345) { throw "Unexpected test handle" } - $script:windowChecks++ - return ($script:windowChecks -lt 5) - } + & .github/native_window_close_test.ps1 *> watcher-diagnostics.txt + $exitCode = $LASTEXITCODE + Get-Content watcher-diagnostics.txt + if ($exitCode -ne 0) { exit $exitCode } - $host = [System.Diagnostics.Process]::GetCurrentProcess() - $watch = [System.Diagnostics.Stopwatch]::StartNew() - Wait-ParrotyWindowClose ` - -WindowHandle ([IntPtr]12345) ` - -WindowProcess $host ` - -WindowExists $simulatedWindow ` - -PollMilliseconds 25 - $watch.Stop() + - name: Upload watcher diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: watcher-diagnostics + path: watcher-diagnostics.txt + if-no-files-found: warn - if ($script:windowChecks -ne 5) { - throw "Watcher did not poll until the window disappeared: $script:windowChecks checks" - } - if ($watch.Elapsed.TotalMilliseconds -lt 70 -or $watch.Elapsed.TotalSeconds -gt 2) { - throw "Watcher timing was unexpected: $($watch.Elapsed.TotalMilliseconds) ms" - } - if (-not (Get-Process -Id $host.Id -ErrorAction SilentlyContinue)) { - throw "Host process exited even though only the window disappeared" - } + - name: Enforce watcher test + if: steps.watcher_test.outcome != 'success' + shell: pwsh + run: exit 1 - name: Verify recorded backend PID is stopped shell: pwsh From b392bd19e75c87aedf9aa525b22323dcaceb6437 Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:11:04 -0400 Subject: [PATCH 11/13] Clarify corrected close-window shutdown --- RELEASE_NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f25bc6c..7a7eb7e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -14,6 +14,9 @@ No version number is assigned to this update. - Added a dedicated maximized Chrome/Edge app window using its own browser profile. - Closing the dedicated Parroty app window now automatically stops the hidden Flask backend, matching `stop.bat` behavior without affecting normal browser windows. +- Corrected that shutdown detection to monitor the exact native Parroty window + handle rather than waiting for the Chromium process, which may remain alive + after its app window is closed. - Added desktop-shortcut creation using the Parroty icon. - Corrected hidden-launch template/static resolution. - Kept narration workers GPU-enabled when the app window is hidden or inactive. From 25725072714d05234cfeb47b89ccc1a38f69870c Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:11:35 -0400 Subject: [PATCH 12/13] Remove temporary close-window regression workflow --- .../workflows/test-native-window-close.yml | 98 ------------------- 1 file changed, 98 deletions(-) delete mode 100644 .github/workflows/test-native-window-close.yml diff --git a/.github/workflows/test-native-window-close.yml b/.github/workflows/test-native-window-close.yml deleted file mode 100644 index 4345d93..0000000 --- a/.github/workflows/test-native-window-close.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Native Parroty window close - -on: - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - native-window-close: - runs-on: windows-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - - name: Parse PowerShell launcher - shell: pwsh - run: | - $tokens = $null - $errors = $null - [void][System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path "parroty_window.ps1"), - [ref]$tokens, - [ref]$errors - ) - if ($errors.Count -gt 0) { - $errors | Format-List | Out-String | Write-Error - exit 1 - } - - - name: Verify window disappearance while host remains alive - id: watcher_test - continue-on-error: true - shell: pwsh - run: | - & .github/native_window_close_test.ps1 *> watcher-diagnostics.txt - $exitCode = $LASTEXITCODE - Get-Content watcher-diagnostics.txt - if ($exitCode -ne 0) { exit $exitCode } - - - name: Upload watcher diagnostics - if: always() - uses: actions/upload-artifact@v4 - with: - name: watcher-diagnostics - path: watcher-diagnostics.txt - if-no-files-found: warn - - - name: Enforce watcher test - if: steps.watcher_test.outcome != 'success' - shell: pwsh - run: exit 1 - - - name: Verify recorded backend PID is stopped - shell: pwsh - run: | - $tokens = $null - $errors = $null - $ast = [System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path "parroty_window.ps1"), - [ref]$tokens, - [ref]$errors - ) - $functions = $ast.FindAll({ - param($node) - $node -is [System.Management.Automation.Language.FunctionDefinitionAst] - }, $true) - foreach ($function in $functions) { Invoke-Expression $function.Extent.Text } - - $listener = Start-Process python -ArgumentList @( - "-m", "http.server", "5000", "--bind", "127.0.0.1" - ) -PassThru -WindowStyle Hidden - try { - $captured = @() - for ($i = 0; $i -lt 40; $i++) { - Start-Sleep -Milliseconds 250 - $captured = @(Get-ParrotyBackendPids -ListenerPort 5000) - if ($captured -contains $listener.Id) { break } - } - if ($captured -notcontains $listener.Id) { throw "Listener PID was not captured" } - Stop-ParrotyBackend -ProcessIds $captured - Start-Sleep -Milliseconds 500 - if (Get-Process -Id $listener.Id -ErrorAction SilentlyContinue) { - throw "Recorded backend PID remained alive" - } - } - finally { - Stop-Process -Id $listener.Id -Force -ErrorAction SilentlyContinue - } - - - name: Enforce BAT exclusion - shell: pwsh - run: | - $trackedBat = @(git ls-files "*.bat") - if ($trackedBat.Count -gt 0) { - throw "Tracked BAT files found: $($trackedBat -join ', ')" - } From 319bbc5650ca5259fdc767e2c9de10d0daabf91f Mon Sep 17 00:00:00 2001 From: pgotta <168211683+pgotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:11:57 -0400 Subject: [PATCH 13/13] Remove temporary close-window test script --- .github/native_window_close_test.ps1 | 51 ---------------------------- 1 file changed, 51 deletions(-) delete mode 100644 .github/native_window_close_test.ps1 diff --git a/.github/native_window_close_test.ps1 b/.github/native_window_close_test.ps1 deleted file mode 100644 index e6e00a1..0000000 --- a/.github/native_window_close_test.ps1 +++ /dev/null @@ -1,51 +0,0 @@ -$ErrorActionPreference = "Stop" - -$tokens = $null -$errors = $null -$ast = [System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path "parroty_window.ps1"), - [ref]$tokens, - [ref]$errors -) -if ($errors.Count -gt 0) { throw "PowerShell parser errors found" } - -$functions = $ast.FindAll({ - param($node) - $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -}, $true) -foreach ($function in $functions) { - Invoke-Expression $function.Extent.Text -} - -$script:windowChecks = 0 -$simulatedWindow = { - param([IntPtr]$Handle) - Write-Host "Predicate check $($script:windowChecks + 1), handle=$([int64]$Handle)" - if ([int64]$Handle -ne 12345) { throw "Unexpected test handle: $([int64]$Handle)" } - $script:windowChecks++ - return ($script:windowChecks -lt 5) -} - -$hostProcess = [System.Diagnostics.Process]::GetCurrentProcess() -$testHandle = [IntPtr]::new(12345) -$watch = [System.Diagnostics.Stopwatch]::StartNew() -Write-Host "Calling watcher with PID $($hostProcess.Id)..." -Wait-ParrotyWindowClose ` - -WindowHandle $testHandle ` - -WindowProcess $hostProcess ` - -WindowExists $simulatedWindow ` - -PollMilliseconds 25 -$watch.Stop() - -Write-Host "Watcher returned after $($watch.Elapsed.TotalMilliseconds) ms and $script:windowChecks checks" -if ($script:windowChecks -ne 5) { - throw "Watcher did not poll until the window disappeared: $script:windowChecks checks" -} -if ($watch.Elapsed.TotalMilliseconds -lt 70 -or $watch.Elapsed.TotalSeconds -gt 2) { - throw "Watcher timing was unexpected: $($watch.Elapsed.TotalMilliseconds) ms" -} -if (-not (Get-Process -Id $hostProcess.Id -ErrorAction SilentlyContinue)) { - throw "Host process exited even though only the window disappeared" -} - -Write-Host "Direct watcher test passed."