fix: 原生窗口销毁后宿主事件循环未退出

This commit is contained in:
Codex
2026-10-05 00:51:43 +08:00
parent 568ffc40eb
commit c0eca1d4bd
5 changed files with 125 additions and 7 deletions
+7
View File
@@ -16,6 +16,7 @@
#pragma once
#include "Control.h"
#include <atomic>
#include <string>
#include <vector>
#include <memory>
@@ -23,6 +24,8 @@
class Window
{
friend class Dialog;
// —— 尺寸状态 ——(绘制尺寸与待应用尺寸分离;收口时一次性更新)
int width; // 当前有效宽(已应用到画布/控件的客户区宽)
int height; // 当前有效高(已应用到画布/控件的客户区高)
@@ -40,6 +43,10 @@ class Window
HWND hWnd = NULL; // EasyX 初始化后的窗口句柄
WNDPROC oldWndProc = nullptr; // 保存旧过程(CallWindowProc 回落)
bool procHooked = false; // 避免重复子类化
// EasyX 的 WndProc 与宿主事件循环分属不同线程;只有 WndProc 完成 WM_NCDESTROY
// 转发后才发布 false,避免宿主提前析构仍在执行 WndProcThunk 的 Window 对象。
std::atomic<bool> nativeWindowAlive{ false };
bool hasLiveNativeWindow() const noexcept;
static LRESULT CALLBACK WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l); // 静态过程分发到 this
// —— 绘制相关 ——(是否使用合成双缓冲、窗口标题、背景等)
+43
View File
@@ -105,6 +105,44 @@ function Wait-Window([Diagnostics.Process]$process, [string]$title, [int]$timeou
throw "Window not found: $title"
}
function Assert-NativeCloseExitsProcess([string]$fixturePath, [string]$runDir, [string]$scenario) {
# 模拟标题栏关闭命令。EasyX 的窗口过程位于内部线程,宿主 peekmessage 不保证能看到 WM_CLOSE;
# 无论关闭消息是否进入 EX_WINDOW,原生 HWND 销毁后 fixture 都必须自行结束。
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
# EasyX 在重定向标准输出的子进程中可能返回 0xC000013A;关闭回归不需要捕获输出,
# 因而保持与用户直接运行应用相同的控制台继承方式。
$process = Start-Process -FilePath $fixturePath -ArgumentList @($scenario, $runDir) -PassThru
try {
$h = Wait-Window $process "StellarX.UI.$scenario"
if ($scenario -eq 'modal-close') {
# fixture 在首次绘制后立即进入 showModal;给模态循环一个有界启动窗口。
Start-Sleep -Milliseconds 250
}
if (-not [StellarXUiNative]::PostMessage($h, 0x0112, [IntPtr]0xF060, [IntPtr]::Zero)) {
throw 'Failed to post SC_CLOSE to fixture'
}
if (-not $process.WaitForExit(5000)) {
$process.Kill()
$process.WaitForExit()
throw 'Fixture process remained alive after native window destruction'
}
# EasyX 的 EX_SHOWCONSOLE fixture 由 Start-Process 托管时,原生标题栏关闭在部分
# 控制台宿主中会映射为 STATUS_CONTROL_C_EXIT;它与“进程未退出”是不同问题。
if ($process.ExitCode -notin @(0, -1073741510)) {
throw "Fixture close regression exited with code $($process.ExitCode)"
}
@{ nativeCloseExited = $true; scenario = $scenario; exitCode = $process.ExitCode } |
ConvertTo-Json -Compress | Set-Content -Encoding UTF8 (Join-Path $runDir 'close-result.json')
}
finally {
if (-not $process.HasExited) {
$process.Kill()
$process.WaitForExit()
}
$process.Dispose()
}
}
function Client-Point([IntPtr]$h, [int]$x, [int]$y) {
# 将 fixture 客户区坐标转换为 SendInput 所需的全局屏幕坐标。
return [StellarXUiNative]::GetClientPoint($h, $x, $y)
@@ -298,6 +336,11 @@ try {
$process.Dispose()
}
}
Assert-NativeCloseExitsProcess $fixture `
(Join-Path $sessionRoot "run-$iteration-native-close") 'native-close'
Assert-NativeCloseExitsProcess $fixture `
(Join-Path $sessionRoot "run-$iteration-modal-native-close") 'modal-close'
}
}
finally {
+34 -4
View File
@@ -174,19 +174,43 @@ void Dialog::Show()
if (modal)
{
// 模态对话框需要阻塞当前线程直到对话框关闭
if (!hWnd.hasLiveNativeWindow())
{
show = false;
hWnd.dialogOpen = false;
return;
}
// 记录当前窗口客户区尺寸,供轮询对比
RECT rc0;
GetClientRect(hWnd.getHwnd(), &rc0);
RECT rc0{};
if (!GetClientRect(hWnd.getHwnd(), &rc0))
{
show = false;
hWnd.dialogOpen = false;
return;
}
int lastW = rc0.right - rc0.left;
int lastH = rc0.bottom - rc0.top;
while (show)
{
if (!hWnd.hasLiveNativeWindow())
{
show = false;
hWnd.dialogOpen = false;
break;
}
// 模态对话框保留自己的轮询循环,但消息同样批量处理并在每轮末尾统一绘制。
int processedMessages = 0;
// ① 轮询窗口尺寸(不依赖 WM_SIZE)
RECT rc;
GetClientRect(hWnd.getHwnd(), &rc);
RECT rc{};
if (!GetClientRect(hWnd.getHwnd(), &rc))
{
show = false;
hWnd.dialogOpen = false;
break;
}
const int cw = rc.right - rc.left;
const int ch = rc.bottom - rc.top;
@@ -215,6 +239,12 @@ void Dialog::Show()
return show;
});
processedMessages = static_cast<int>(batch.processed);
if (!hWnd.hasLiveNativeWindow())
{
show = false;
hWnd.dialogOpen = false;
break;
}
// ③ 最后一笔:只画这只模态,保证永远在最上层
if (dirty)
+27 -3
View File
@@ -663,6 +663,15 @@ Window::Window(int w, int h, int mode, COLORREF bk, std::string title)
headline = std::move(title);
}
bool Window::hasLiveNativeWindow() const noexcept
{
// 正常路径由已安装的 WndProcThunk 在 WM_NCDESTROY 完成后发布死亡状态。
// 若子类化失败,则退回 IsWindow,保证事件循环仍有退出兜底。
if (procHooked)
return nativeWindowAlive.load(std::memory_order_acquire);
return hWnd != nullptr && IsWindow(hWnd) != FALSE;
}
Window::~Window()
{
// 先销毁控件树,再关闭图形环境,避免控件析构时访问已关闭的 EasyX 上下文。
@@ -670,7 +679,7 @@ Window::~Window()
dialogs.clear();
controls.clear();
background.reset();
if (hWnd && procHooked && oldWndProc)
if (hWnd && procHooked && oldWndProc && hasLiveNativeWindow())
{
SetWindowLongPtr(hWnd, GWLP_WNDPROC, (LONG_PTR)oldWndProc);
SetWindowLongPtr(hWnd, GWLP_USERDATA, 0);
@@ -777,8 +786,16 @@ LRESULT CALLBACK Window::WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l)
}
// 其它消息:回落到旧过程
return self->oldWndProc ? CallWindowProc(self->oldWndProc, h, m, w, l)
const LRESULT result = self->oldWndProc
? CallWindowProc(self->oldWndProc, h, m, w, l)
: DefWindowProc(h, m, w, l);
if (m == WM_NCDESTROY)
{
// 必须放在旧窗口过程返回之后,并作为本函数最后一次 self 访问。
// 宿主线程看到 false 后即可安全离开 runEventLoop 并析构 Window。
self->nativeWindowAlive.store(false, std::memory_order_release);
}
return result;
}
// ---------------- 绘制 ----------------
@@ -799,6 +816,8 @@ void Window::draw()
{
hWnd = initgraph(width, height, windowMode);
}
nativeWindowAlive.store(hWnd != nullptr && IsWindow(hWnd) != FALSE,
std::memory_order_release);
// 子类化:让我们的 WndProcThunk 接管窗口消息(仅执行一次)
if (!procHooked)
@@ -835,6 +854,8 @@ void Window::draw(std::string imagePath)
{
hWnd = initgraph(width, height, windowMode);
}
nativeWindowAlive.store(hWnd != nullptr && IsWindow(hWnd) != FALSE,
std::memory_order_release);
if (!procHooked)
{
@@ -887,6 +908,9 @@ int Window::runEventLoop()
// 不再引入额外 pendingResize 等状态,避免分叉导致状态不一致。
while (true)
{
if (!hasLiveNativeWindow())
return 0;
// drainMessageBatch 将一轮消息限制在固定上限;批次结束后统一处理 resize、tick 和重绘。
const auto batch = StellarX::Internal::drainMessageBatch(msg,
[](ExMessage& next) { return peekmessage(&next, EX_MOUSE | EX_KEY | EX_WINDOW, true); },
@@ -1003,7 +1027,7 @@ int Window::runEventLoop()
flushPendingMutationsInTree();
return true;
});
if (batch.stopped)
if (batch.stopped || !hasLiveNativeWindow())
return 0;
const ULONGLONG nowMs = GetTickCount64();
+14
View File
@@ -88,6 +88,15 @@ int main(int argc, char** argv)
button->setTooltipText("stationary tip");
window.addControl(std::move(button));
}
else if (scenario == "native-close")
{
// 只创建顶层窗口,由 UI 驱动发送原生关闭命令并验证宿主循环自行退出。
}
else if (scenario == "modal-close")
{
// 窗口首次绘制后立即进入真正的模态循环,由 UI 驱动关闭宿主原生窗口。
// 该场景用于验证模态循环不会在 HWND 已销毁后继续轮询。
}
else if (scenario == "tab")
{
// 两个页签故意使用相同标题,验证索引身份而不是文本匹配决定激活状态。
@@ -166,5 +175,10 @@ int main(int argc, char** argv)
}
window.draw();
if (scenario == "modal-close")
{
(void)StellarX::MessageBox::showModal(
window, "close the native host window", "modal close", StellarX::MessageBoxType::OK);
}
return window.runEventLoop();
}