fix: 原生窗口销毁后宿主事件循环未退出
This commit is contained in:
@@ -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
|
||||
|
||||
// —— 绘制相关 ——(是否使用合成双缓冲、窗口标题、背景等)
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user