Files
plezy/windows/runner/mpv/mpv_player_property_contract_test.cpp
T
edde746 9e8cfbc7dd fix(windows): keep video-child input on the Flutter view
Create the mpv host window with WS_DISABLED so Windows skips the video subtree
when it picks the window that owns a contact and hands the input to the parent
Flutter view instead. Touch over the video never reached Flutter before: mpv's
inner window owns the contact from its own thread, and neither relay worked
from there - Flutter resolves WM_POINTER with GetPointerInfo, which only
answers for a message the calling thread retrieved, and the system discards a
cross-thread pointer send outright. WS_EX_TRANSPARENT and an HTTRANSPARENT
WM_NCHITTEST reply are both same-thread-only, so disabling the subtree is the
one hit-test opt-out that applies across threads. The mouse relay stays for
input that still reaches mpv's window.

Repair the contract test that covers this. It drove its pointer assertions with
cross-thread sends that Windows drops, so every touch assertion had been dead
since it was added and the suite fails "primary touch must press once" on main.
Relaying those sends through the window's own thread runs all eight tests, and
injected mouse and touch presses over the disabled host now assert delivery to
the parent view; removing WS_DISABLED fails the suite.

close #1556
2026-07-26 19:04:36 +02:00

817 lines
38 KiB
C++

#include <windowsx.h>
#include <atomic>
#include <cstdlib>
#include <functional>
#include <future>
#include <iostream>
#include <thread>
#include <utility>
#include "mpv_player.h"
namespace mpv {
class MpvPlayerPropertyContractTestPeer {
public:
static void RegisterPendingPropertyWrite(MpvPlayer& player, MpvPlayer::StatusCallback callback) {
player.pending_requests_.RegisterStatus(std::move(callback));
}
static void RegisterPendingPropertyRead(MpvPlayer& player, MpvPlayer::GetPropertyCallback callback) {
player.pending_requests_.RegisterProperty(std::move(callback));
}
static void ConfigureInnerSubclass(MpvPlayer& player, HWND host, HWND target) {
player.hwnd_ = host;
player.forward_target_view_ = target;
}
static void EnsureInnerSubclass(MpvPlayer& player) { player.EnsureMpvInnerSubclassed(); }
static void DetachInnerSubclass(MpvPlayer& player) { player.DetachMpvInnerSubclass(); }
static const void* InnerSubclassIdentity(const MpvPlayer& player) { return player.inner_subclass_.get(); }
static void ReleaseTestWindows(MpvPlayer& player) {
player.DetachMpvInnerSubclass();
player.hwnd_ = nullptr;
player.forward_target_view_ = nullptr;
}
};
namespace {
void Check(bool condition, const char* message) {
if (!condition) {
std::cerr << "mpv_player_property_contract_test: " << message << '\n';
std::exit(1);
}
}
std::atomic<int> g_forwarded_mouse_messages{0};
std::atomic<int> g_forwarded_touch_moves{0};
std::atomic<int> g_forwarded_mouse_down_messages{0};
std::atomic<int> g_forwarded_mouse_up_messages{0};
std::atomic<int> g_forwarded_pointer_messages{0};
std::atomic<LPARAM> g_forwarded_mouse_down_position{0};
std::atomic<LPARAM> g_forwarded_mouse_up_position{0};
std::atomic<WPARAM> g_forwarded_mouse_down_flags{0};
std::atomic<WPARAM> g_forwarded_mouse_up_flags{0};
constexpr UINT kBlockWindowThreadMessage = WM_APP + 0x0505;
std::atomic<HANDLE> g_block_entered{nullptr};
std::atomic<HANDLE> g_block_release{nullptr};
std::atomic<HANDLE> g_block_exited{nullptr};
// A WM_POINTER message cannot be handed to a window owned by another thread:
// the system drops a cross-thread send and refuses the post outright with
// ERROR_MESSAGE_SYNC_ONLY, because pointer data belongs to the queue of the
// thread that retrieved it. Driving mpv's window from the test thread therefore
// delivers nothing at all. This relays the send through the window's own thread,
// which is also where the real messages arrive in production.
constexpr UINT kDispatchOnOwnerThreadMessage = WM_APP + 0x0506;
struct OwnerThreadDispatch {
HWND target;
UINT message;
WPARAM wparam;
LPARAM lparam;
};
std::atomic<HWND> g_routing_view{nullptr};
std::atomic<int> g_routing_view_presses{0};
std::atomic<int> g_routing_child_presses{0};
LRESULT CALLBACK CountingWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
if (message == kBlockWindowThreadMessage) {
const HANDLE entered = g_block_entered.load(std::memory_order_acquire);
const HANDLE release = g_block_release.load(std::memory_order_acquire);
const HANDLE exited = g_block_exited.load(std::memory_order_acquire);
if (entered && release && exited) {
::SetEvent(entered);
::WaitForSingleObject(release, INFINITE);
::SetEvent(exited);
}
return 0;
}
if (message == kDispatchOnOwnerThreadMessage) {
const auto* dispatch = reinterpret_cast<const OwnerThreadDispatch*>(lparam);
return ::SendMessageW(dispatch->target, dispatch->message, dispatch->wparam, dispatch->lparam);
}
if (message == WM_MOUSEMOVE) {
if ((wparam & MK_LBUTTON) != 0) {
g_forwarded_touch_moves.fetch_add(1, std::memory_order_relaxed);
} else {
g_forwarded_mouse_messages.fetch_add(1, std::memory_order_relaxed);
}
return 0;
}
if (message == WM_LBUTTONDOWN) {
g_forwarded_mouse_down_position.store(lparam, std::memory_order_relaxed);
g_forwarded_mouse_down_flags.store(wparam, std::memory_order_relaxed);
g_forwarded_mouse_down_messages.fetch_add(1, std::memory_order_relaxed);
return 0;
}
if (message == WM_LBUTTONUP) {
g_forwarded_mouse_up_position.store(lparam, std::memory_order_relaxed);
g_forwarded_mouse_up_flags.store(wparam, std::memory_order_relaxed);
g_forwarded_mouse_up_messages.fetch_add(1, std::memory_order_relaxed);
return 0;
}
if (message >= WM_POINTERUPDATE && message <= WM_POINTERCAPTURECHANGED) {
g_forwarded_pointer_messages.fetch_add(1, std::memory_order_relaxed);
return 0;
}
return ::DefWindowProcW(hwnd, message, wparam, lparam);
}
// Sends |message| to |target| from the thread that owns |owner_window|, so a
// WM_POINTER message reaches the window procedure instead of being discarded as
// a cross-thread send. |owner_window| must be handled by CountingWindowProc and
// live on the same thread as |target|.
LRESULT SendFromOwnerThread(HWND owner_window, HWND target, UINT message, WPARAM wparam, LPARAM lparam) {
OwnerThreadDispatch dispatch{target, message, wparam, lparam};
return ::SendMessageW(owner_window, kDispatchOnOwnerThreadMessage, 0, reinterpret_cast<LPARAM>(&dispatch));
}
// Attributes a real button press to the window that received it. Installed on
// the stand-in view and on every window of the video subtree below it. The
// STATIC class answers WM_NCHITTEST with HTTRANSPARENT, which would take these
// windows out of the hit test for a reason other than the one under test, so
// every one of them claims its client area here.
LRESULT CALLBACK RoutingWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
if (message == WM_NCHITTEST) {
return HTCLIENT;
}
if (message == WM_LBUTTONDOWN || message == WM_LBUTTONDBLCLK) {
if (hwnd == g_routing_view.load(std::memory_order_acquire)) {
g_routing_view_presses.fetch_add(1, std::memory_order_relaxed);
} else {
g_routing_child_presses.fetch_add(1, std::memory_order_relaxed);
}
return 0;
}
return ::DefWindowProcW(hwnd, message, wparam, lparam);
}
// Pumps this thread's queue until |predicate| holds or the budget runs out.
bool PumpUntil(const std::function<bool()>& predicate, DWORD timeout_ms) {
const ULONGLONG deadline = ::GetTickCount64() + timeout_ms;
for (;;) {
MSG message;
while (::PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
::TranslateMessage(&message);
::DispatchMessageW(&message);
}
if (predicate()) return true;
if (::GetTickCount64() >= deadline) return false;
::Sleep(5);
}
}
bool SendAbsoluteMouse(POINT screen_point, DWORD flags) {
const LONG width = ::GetSystemMetrics(SM_CXSCREEN);
const LONG height = ::GetSystemMetrics(SM_CYSCREEN);
if (width < 2 || height < 2) return false;
INPUT input = {};
input.type = INPUT_MOUSE;
input.mi.dx = static_cast<LONG>((static_cast<LONG64>(screen_point.x) * 65535) / (width - 1));
input.mi.dy = static_cast<LONG>((static_cast<LONG64>(screen_point.y) * 65535) / (height - 1));
input.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | flags;
return ::SendInput(1, &input, sizeof(input)) == 1;
}
bool InjectClick(POINT screen_point) {
return SendAbsoluteMouse(screen_point, MOUSEEVENTF_MOVE) && SendAbsoluteMouse(screen_point, MOUSEEVENTF_LEFTDOWN) &&
SendAbsoluteMouse(screen_point, MOUSEEVENTF_LEFTUP);
}
// A real touch contact at |screen_point|, for the touch half of the routing
// test. The contact is held across a few frames rather than sent as a bare
// down/up pair, because a contact that lifts in the same frame it landed is
// short enough for a consumer to discard as noise.
bool InjectTap(POINT screen_point) {
POINTER_TOUCH_INFO contact = {};
contact.pointerInfo.pointerType = PT_TOUCH;
contact.pointerInfo.pointerId = 0;
contact.pointerInfo.ptPixelLocation = screen_point;
contact.touchFlags = TOUCH_FLAG_NONE;
contact.touchMask = TOUCH_MASK_CONTACTAREA | TOUCH_MASK_ORIENTATION | TOUCH_MASK_PRESSURE;
contact.rcContact.left = screen_point.x - 2;
contact.rcContact.top = screen_point.y - 2;
contact.rcContact.right = screen_point.x + 2;
contact.rcContact.bottom = screen_point.y + 2;
contact.orientation = 90;
contact.pressure = 32000;
contact.pointerInfo.pointerFlags = POINTER_FLAG_DOWN | POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT;
if (!::InjectTouchInput(1, &contact)) return false;
contact.pointerInfo.pointerFlags = POINTER_FLAG_UPDATE | POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT;
for (int frame = 0; frame < 5; ++frame) {
::Sleep(20);
if (!::InjectTouchInput(1, &contact)) return false;
}
contact.pointerInfo.pointerFlags = POINTER_FLAG_UP;
return ::InjectTouchInput(1, &contact) != FALSE;
}
void TestUnavailablePropertyWriteFails() {
MpvPlayer player;
int callback_count = 0;
int status = MPV_ERROR_SUCCESS;
player.SetPropertyAsync("pause", "yes", [&](int error) {
++callback_count;
status = error;
});
Check(callback_count == 1, "a property write without an mpv handle must complete exactly once");
Check(status == MPV_ERROR_UNINITIALIZED, "a property write without an mpv handle must fail as uninitialized");
}
void TestPendingPropertyWriteFailsOnDispose() {
MpvPlayer player;
int callback_count = 0;
int status = MPV_ERROR_SUCCESS;
MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyWrite(player, [&](int error) {
++callback_count;
status = error;
});
player.Dispose();
Check(callback_count == 1, "dispose must complete a pending property write exactly once");
Check(status == MPV_ERROR_UNINITIALIZED, "dispose must cancel a pending property write as uninitialized");
player.Dispose();
Check(callback_count == 1, "repeated dispose must not complete a property write twice");
}
void TestPendingRequestTypesRemainDistinctOnDispose() {
MpvPlayer player;
int write_count = 0;
int read_count = 0;
std::string read_value = "unexpected";
MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyWrite(player, [&](int error) {
Check(error < 0, "cancelled property write must receive an error");
++write_count;
});
MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyRead(player, [&](int error, const std::string& value) {
Check(error < 0, "cancelled property read must receive an error");
++read_count;
read_value = value;
});
player.Dispose();
Check(write_count == 1, "dispose must complete the typed write request exactly once");
Check(read_count == 1, "dispose must complete the typed read request exactly once");
Check(read_value.empty(), "cancelled property reads must not manufacture a value");
}
void TestInnerSubclassOwnershipIsSerializedAndDetached() {
struct TestWindows {
HWND target;
HWND host;
HWND inner;
WNDPROC inner_original;
DWORD owner_thread;
};
std::promise<TestWindows> windows_created;
auto windows_future = windows_created.get_future();
std::thread window_owner([&]() {
HWND target =
::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr);
HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr);
HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr);
const auto target_original = reinterpret_cast<WNDPROC>(
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(CountingWindowProc)));
const auto inner_original = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(inner, GWLP_WNDPROC));
windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()});
MSG message;
while (::GetMessageW(&message, nullptr, 0, 0) > 0) {
::TranslateMessage(&message);
::DispatchMessageW(&message);
}
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(target_original));
::DestroyWindow(inner);
::DestroyWindow(host);
::DestroyWindow(target);
});
const TestWindows windows = windows_future.get();
Check(windows.target && windows.host && windows.inner, "test windows must be created");
MpvPlayer player;
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(player, windows.host, windows.target);
std::thread first([&]() { MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player); });
std::thread second([&]() { MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player); });
first.join();
second.join();
const auto installed = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC));
Check(installed && installed != windows.inner_original, "exactly one subclass procedure must be installed");
::SendMessageW(windows.inner, WM_NULL, 0, 0);
::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(4, 7));
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_messages.load(std::memory_order_relaxed) < 1; ++attempt) {
::Sleep(10);
}
Check(g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 1, "active generation must forward mouse input");
constexpr UINT32 kPrimaryPointerId = 7;
constexpr UINT32 kSecondaryPointerId = 8;
constexpr UINT32 kCancelledPointerId = 9;
constexpr UINT32 kDetachedPointerId = 10;
constexpr UINT32 kDestroyedPointerId = 11;
constexpr UINT32 kPointerDownFlags = POINTER_MESSAGE_FLAG_NEW | POINTER_MESSAGE_FLAG_INRANGE |
POINTER_MESSAGE_FLAG_INCONTACT | POINTER_MESSAGE_FLAG_FIRSTBUTTON |
POINTER_MESSAGE_FLAG_PRIMARY;
constexpr UINT32 kPointerMoveFlags = POINTER_MESSAGE_FLAG_INRANGE | POINTER_MESSAGE_FLAG_INCONTACT |
POINTER_MESSAGE_FLAG_FIRSTBUTTON | POINTER_MESSAGE_FLAG_PRIMARY;
constexpr UINT32 kPointerUpFlags = POINTER_MESSAGE_FLAG_INRANGE | POINTER_MESSAGE_FLAG_PRIMARY;
POINT down_position = {14, 18};
::ClientToScreen(windows.inner, &down_position);
POINT expected_down_position = down_position;
::ScreenToClient(windows.target, &expected_down_position);
POINT up_position = {30, 36};
::ClientToScreen(windows.inner, &up_position);
POINT expected_up_position = up_position;
::ScreenToClient(windows.target, &expected_up_position);
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kPrimaryPointerId, kPointerDownFlags),
MAKELPARAM(down_position.x, down_position.y));
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERUPDATE, MAKEWPARAM(kPrimaryPointerId, kPointerMoveFlags),
MAKELPARAM(up_position.x, up_position.y));
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERUP, MAKEWPARAM(kPrimaryPointerId, kPointerUpFlags),
MAKELPARAM(up_position.x, up_position.y));
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 1; ++attempt) {
::Sleep(10);
}
Check(g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 1, "primary touch must press once");
Check(g_forwarded_touch_moves.load(std::memory_order_relaxed) == 1, "primary touch movement must drag once");
Check(g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 1, "primary touch must release once");
Check(
g_forwarded_pointer_messages.load(std::memory_order_relaxed) == 0, "raw pointer messages must not reach Flutter");
Check(
g_forwarded_mouse_down_flags.load(std::memory_order_relaxed) == MK_LBUTTON,
"touch down must hold the mouse button");
Check(g_forwarded_mouse_up_flags.load(std::memory_order_relaxed) == 0, "touch up must release the mouse button");
const LPARAM forwarded_down_position = g_forwarded_mouse_down_position.load(std::memory_order_relaxed);
Check(
GET_X_LPARAM(forwarded_down_position) == expected_down_position.x &&
GET_Y_LPARAM(forwarded_down_position) == expected_down_position.y,
"touch down must use Flutter-view client coordinates");
const LPARAM forwarded_up_position = g_forwarded_mouse_up_position.load(std::memory_order_relaxed);
Check(
GET_X_LPARAM(forwarded_up_position) == expected_up_position.x &&
GET_Y_LPARAM(forwarded_up_position) == expected_up_position.y,
"touch up must use Flutter-view client coordinates");
const WPARAM secondary_down = MAKEWPARAM(kSecondaryPointerId, kPointerDownFlags & ~POINTER_MESSAGE_FLAG_PRIMARY);
const WPARAM secondary_up = MAKEWPARAM(kSecondaryPointerId, kPointerUpFlags & ~POINTER_MESSAGE_FLAG_PRIMARY);
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERDOWN, secondary_down, MAKELPARAM(down_position.x, down_position.y));
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERUP, secondary_up, MAKELPARAM(up_position.x, up_position.y));
::Sleep(30);
Check(
g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 1 &&
g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 1,
"secondary touch must not synthesize another click");
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kCancelledPointerId, kPointerDownFlags),
MAKELPARAM(down_position.x, down_position.y));
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERCAPTURECHANGED, MAKEWPARAM(kCancelledPointerId, kPointerUpFlags),
reinterpret_cast<LPARAM>(windows.host));
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 2; ++attempt) {
::Sleep(10);
}
Check(
g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 2 &&
g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 2,
"capture loss must release an active synthetic mouse press");
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kDetachedPointerId, kPointerDownFlags),
MAKELPARAM(down_position.x, down_position.y));
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) < 3;
++attempt) {
::Sleep(10);
}
MpvPlayerPropertyContractTestPeer::DetachInnerSubclass(player);
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
"detach must restore the original procedure before window destruction");
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 3; ++attempt) {
::Sleep(10);
}
Check(
g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 3 &&
g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 3,
"detach must release an active synthetic mouse press");
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERUP, MAKEWPARAM(kDetachedPointerId, kPointerUpFlags),
MAKELPARAM(up_position.x, up_position.y));
::Sleep(30);
Check(
g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 3,
"a late pointer up after detach must not release twice");
::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(8, 9));
::Sleep(30);
Check(
g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 1,
"a callback after detaching the old generation must be ignored");
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player);
const auto replacement = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC));
Check(replacement && replacement != windows.inner_original, "a replacement generation must install cleanly");
::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(10, 11));
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_messages.load(std::memory_order_relaxed) < 2; ++attempt) {
::Sleep(10);
}
Check(
g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 2,
"replacement generation must own forwarding after installation");
MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(player);
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
"replacement detach must restore the original procedure");
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(player, windows.host, windows.target);
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player);
const auto destroy_generation = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC));
Check(
destroy_generation && destroy_generation != windows.inner_original,
"destroy test must install a fresh subclass generation");
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kDestroyedPointerId, kPointerDownFlags),
MAKELPARAM(down_position.x, down_position.y));
::SendMessageW(windows.inner, WM_CLOSE, 0, 0);
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 4; ++attempt) {
::Sleep(10);
}
Check(!::IsWindow(windows.inner), "destroy test must close the inner window");
Check(
g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 4 &&
g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 4,
"window destruction must release an active synthetic mouse press");
MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(player);
::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0);
window_owner.join();
g_forwarded_mouse_messages.store(0, std::memory_order_relaxed);
g_forwarded_touch_moves.store(0, std::memory_order_relaxed);
g_forwarded_mouse_down_messages.store(0, std::memory_order_relaxed);
g_forwarded_mouse_up_messages.store(0, std::memory_order_relaxed);
g_forwarded_pointer_messages.store(0, std::memory_order_relaxed);
}
void TestTimedOutSubclassDetachCanBeAdopted() {
struct TestWindows {
HWND target;
HWND host;
HWND inner;
WNDPROC inner_original;
DWORD owner_thread;
};
std::promise<TestWindows> windows_created;
auto windows_future = windows_created.get_future();
std::thread window_owner([&]() {
HWND target =
::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr);
HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr);
HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr);
const auto target_original = reinterpret_cast<WNDPROC>(
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(CountingWindowProc)));
const auto inner_original = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(inner, GWLP_WNDPROC));
windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()});
MSG message;
while (::GetMessageW(&message, nullptr, 0, 0) > 0) {
::TranslateMessage(&message);
::DispatchMessageW(&message);
}
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(target_original));
::DestroyWindow(inner);
::DestroyWindow(host);
::DestroyWindow(target);
});
const TestWindows windows = windows_future.get();
Check(windows.target && windows.host && windows.inner, "detach-timeout test windows must be created");
const HANDLE block_entered = ::CreateEventW(nullptr, TRUE, FALSE, nullptr);
const HANDLE block_release = ::CreateEventW(nullptr, TRUE, FALSE, nullptr);
const HANDLE block_exited = ::CreateEventW(nullptr, TRUE, FALSE, nullptr);
Check(block_entered && block_release && block_exited, "detach-timeout synchronization events must be created");
g_block_entered.store(block_entered, std::memory_order_release);
g_block_release.store(block_release, std::memory_order_release);
g_block_exited.store(block_exited, std::memory_order_release);
g_forwarded_mouse_messages.store(0, std::memory_order_relaxed);
{
MpvPlayer original;
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(original, windows.host, windows.target);
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(original);
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) != windows.inner_original,
"the initial generation must be installed before forcing detach timeout");
const void* retained_generation = MpvPlayerPropertyContractTestPeer::InnerSubclassIdentity(original);
Check(retained_generation != nullptr, "the initial generation must have live state");
Check(
::PostMessageW(windows.target, kBlockWindowThreadMessage, 0, 0) != FALSE,
"the owner-thread blocking message must be posted");
Check(
::WaitForSingleObject(block_entered, 1000) == WAIT_OBJECT_0,
"the owner thread must enter the deterministic blocking message");
MpvPlayerPropertyContractTestPeer::DetachInnerSubclass(original);
MpvPlayer replacement;
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(replacement, windows.host, windows.target);
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(replacement);
Check(
MpvPlayerPropertyContractTestPeer::InnerSubclassIdentity(replacement) == retained_generation,
"replacement must atomically adopt the retained generation");
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) != windows.inner_original,
"replacement must adopt the retained installed generation without duplicate subclassing");
::SetEvent(block_release);
Check(
::WaitForSingleObject(block_exited, 1000) == WAIT_OBJECT_0,
"the owner thread must leave the deterministic blocking message");
::SendMessageW(windows.inner, WM_NULL, 0, 0);
::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(12, 13));
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_messages.load(std::memory_order_relaxed) < 1; ++attempt) {
::Sleep(10);
}
Check(
g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 1,
"the adopted generation must resume input forwarding");
MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(replacement);
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
"adopted generation cleanup must eventually restore the original procedure");
}
g_block_entered.store(nullptr, std::memory_order_release);
g_block_release.store(nullptr, std::memory_order_release);
g_block_exited.store(nullptr, std::memory_order_release);
::CloseHandle(block_entered);
::CloseHandle(block_release);
::CloseHandle(block_exited);
::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0);
window_owner.join();
g_forwarded_mouse_messages.store(0, std::memory_order_relaxed);
}
void TestTimedOutSubclassInstallCannotOutliveItsState() {
struct TestWindows {
HWND target;
HWND host;
HWND inner;
WNDPROC inner_original;
DWORD owner_thread;
};
std::promise<TestWindows> windows_created;
auto windows_future = windows_created.get_future();
std::promise<void> begin_dispatch;
auto begin_dispatch_future = begin_dispatch.get_future();
std::thread window_owner([&]() {
HWND target =
::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr);
HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr);
HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr);
const auto inner_original = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(inner, GWLP_WNDPROC));
windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()});
// Keep the owning thread alive but unavailable long enough for
// SendMessageTimeoutW to cancel the cross-thread ownership action.
begin_dispatch_future.wait();
MSG message;
while (::GetMessageW(&message, nullptr, 0, 0) > 0) {
::TranslateMessage(&message);
::DispatchMessageW(&message);
}
::DestroyWindow(inner);
::DestroyWindow(host);
::DestroyWindow(target);
});
const TestWindows windows = windows_future.get();
Check(windows.target && windows.host && windows.inner, "timeout test windows must be created");
{
MpvPlayer player;
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(player, windows.host, windows.target);
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player);
MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(player);
}
// The action and its subclass reference data have now left caller scope.
// Dispatching the timed-out message must neither install late nor touch the
// destroyed caller state.
begin_dispatch.set_value();
::SendMessageW(windows.inner, WM_NULL, 0, 0);
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
"a timed-out action must remain cancelled after the window thread resumes");
::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0);
window_owner.join();
}
// The video child must never own a contact: mpv's window lives on mpv's own
// thread, where the cross-thread hit-test opt-outs (WS_EX_TRANSPARENT, an
// HTTRANSPARENT WM_NCHITTEST reply) do not apply. Disabling the host is what
// keeps mouse, touch, and pen on the Flutter view. The window mpv creates
// inside the host afterwards needs no state of its own — targeting skips the
// host without descending into it — so only the host's own state is asserted
// here (a disabled parent never clears its children's WS_DISABLED bit).
void TestDisabledVideoHostKeepsInputOnTheFlutterView() {
HWND view = ::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 400, 400, nullptr, nullptr, nullptr, nullptr);
Check(view != nullptr, "video host test needs a stand-in Flutter view window");
RECT client = {};
Check(::GetClientRect(view, &client) != FALSE, "the stand-in view must report a client area");
Check(client.right > 2 && client.bottom > 2, "the stand-in view must have a usable client area");
HWND host = ::CreateWindowExW(
WS_EX_NOPARENTNOTIFY, L"STATIC", L"", kVideoHostWindowStyle, 0, 0, client.right, client.bottom, view, nullptr,
nullptr, nullptr);
Check(host != nullptr, "the video host window must be created");
Check(!::IsWindowEnabled(host), "the video host must be created disabled");
const POINT contact = {client.right / 2, client.bottom / 2};
Check(
::ChildWindowFromPointEx(view, contact, CWP_SKIPDISABLED) == view,
"input over the disabled video host must target the Flutter view");
::EnableWindow(host, TRUE);
Check(
::ChildWindowFromPointEx(view, contact, CWP_SKIPDISABLED) == host,
"the same contact must reach an enabled host, so the skip is the disabled state and not the geometry");
::DestroyWindow(host);
::DestroyWindow(view);
}
// The assertions above only prove that USER32's own child lookup honors
// CWP_SKIPDISABLED. What the video child actually depends on is the input hit
// test: a press over the disabled host has to reach the parent view, and must
// not be swallowed — swallowing is what the previous implementation reported
// when it tried disabling this subtree, and no relay can recover from it
// because mpv's window never sees the message either.
//
// A control press with the video subtree hidden runs first. If that one does
// not arrive, injection is unavailable here and the test skips. Once it has
// landed, silence from the press over the disabled host is a failure, not an
// environment problem.
void TestDisabledVideoHostRoutesRealPressesToParentView() {
HWND view = ::CreateWindowExW(
0, L"STATIC", L"", WS_OVERLAPPEDWINDOW | WS_VISIBLE, 100, 100, 400, 400, nullptr, nullptr, nullptr, nullptr);
Check(view != nullptr, "input routing test needs a stand-in Flutter view window");
RECT client = {};
Check(::GetClientRect(view, &client) != FALSE, "the stand-in view must report a client area");
Check(client.right > 2 && client.bottom > 2, "the stand-in view must have a usable client area");
HWND host = ::CreateWindowExW(
WS_EX_NOPARENTNOTIFY, L"STATIC", L"", kVideoHostWindowStyle | WS_VISIBLE, 0, 0, client.right, client.bottom, view,
nullptr, nullptr, nullptr);
Check(host != nullptr, "the video host window must be created");
HWND inner = ::CreateWindowExW(
0, L"STATIC", L"", WS_CHILD | WS_VISIBLE, 0, 0, client.right, client.bottom, host, nullptr, nullptr, nullptr);
Check(inner != nullptr, "the mpv inner window stand-in must be created");
g_routing_view.store(view, std::memory_order_release);
g_routing_view_presses.store(0, std::memory_order_relaxed);
g_routing_child_presses.store(0, std::memory_order_relaxed);
const auto view_original =
reinterpret_cast<WNDPROC>(::SetWindowLongPtrW(view, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(RoutingWindowProc)));
const auto host_original =
reinterpret_cast<WNDPROC>(::SetWindowLongPtrW(host, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(RoutingWindowProc)));
const auto inner_original = reinterpret_cast<WNDPROC>(
::SetWindowLongPtrW(inner, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(RoutingWindowProc)));
::SetForegroundWindow(view);
PumpUntil([]() { return false; }, 100);
POINT contact = {client.right / 2, client.bottom / 2};
::ClientToScreen(view, &contact);
POINT restore_cursor = {};
const bool cursor_known = ::GetCursorPos(&restore_cursor) != FALSE;
// Control: with the video subtree hidden, this press can only land on the
// view. It establishes that injection reaches this process at all.
::ShowWindow(host, SW_HIDE);
PumpUntil([]() { return false; }, 50);
bool control_landed = false;
if (::WindowFromPoint(contact) == view && InjectClick(contact)) {
control_landed = PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000);
}
if (!control_landed) {
std::cout << "mpv_player_property_contract_test: skipped injected input routing (no usable desktop)\n";
} else {
g_routing_view_presses.store(0, std::memory_order_relaxed);
g_routing_child_presses.store(0, std::memory_order_relaxed);
::ShowWindow(host, SW_SHOWNA);
PumpUntil([]() { return false; }, 50);
Check(
::WindowFromPoint(contact) == view,
"the input hit test must skip the disabled video subtree and resolve to the Flutter view");
Check(InjectClick(contact), "the control press injected, so the press over the video host must inject too");
PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000);
Check(
g_routing_child_presses.load(std::memory_order_relaxed) == 0,
"a disabled video subtree must not receive the press itself");
Check(
g_routing_view_presses.load(std::memory_order_relaxed) == 1,
"a press over the disabled video host must reach the parent Flutter view instead of being swallowed");
// Touch is the contact kind issue #1556 was reported for, and it takes a
// different path into the system than the mouse press above: the injection
// below produces a real touch contact, so the system's own hit test picks
// the target and promotes it to the legacy mouse stream the counters watch.
// A machine without a digitizer can still inject, so this is not gated on
// SM_DIGITIZER - only on the injection API being usable at all.
if (!::InitializeTouchInjection(1, TOUCH_FEEDBACK_NONE)) {
std::cout << "mpv_player_property_contract_test: skipped injected touch routing (injection unavailable)\n";
} else {
g_routing_view_presses.store(0, std::memory_order_relaxed);
g_routing_child_presses.store(0, std::memory_order_relaxed);
::ShowWindow(host, SW_HIDE);
PumpUntil([]() { return false; }, 50);
// Same control as the mouse pass: with the video subtree hidden the tap
// can only land on the view, which proves touch injection works here
// before silence over the disabled host is treated as a failure.
bool touch_control_landed = false;
if (InjectTap(contact)) {
touch_control_landed =
PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000);
}
if (!touch_control_landed) {
std::cout << "mpv_player_property_contract_test: skipped injected touch routing (no usable desktop)\n";
} else {
g_routing_view_presses.store(0, std::memory_order_relaxed);
g_routing_child_presses.store(0, std::memory_order_relaxed);
::ShowWindow(host, SW_SHOWNA);
PumpUntil([]() { return false; }, 50);
Check(InjectTap(contact), "the control tap injected, so the tap over the video host must inject too");
PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000);
Check(
g_routing_child_presses.load(std::memory_order_relaxed) == 0,
"a disabled video subtree must not receive the touch itself");
Check(
g_routing_view_presses.load(std::memory_order_relaxed) == 1,
"a touch over the disabled video host must reach the parent Flutter view instead of being swallowed");
}
}
}
if (cursor_known) ::SetCursorPos(restore_cursor.x, restore_cursor.y);
::SetWindowLongPtrW(inner, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(inner_original));
::SetWindowLongPtrW(host, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(host_original));
::SetWindowLongPtrW(view, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(view_original));
g_routing_view.store(nullptr, std::memory_order_release);
::DestroyWindow(inner);
::DestroyWindow(host);
::DestroyWindow(view);
}
} // namespace
} // namespace mpv
int main() {
mpv::TestUnavailablePropertyWriteFails();
mpv::TestPendingPropertyWriteFailsOnDispose();
mpv::TestPendingRequestTypesRemainDistinctOnDispose();
mpv::TestInnerSubclassOwnershipIsSerializedAndDetached();
mpv::TestTimedOutSubclassDetachCanBeAdopted();
mpv::TestTimedOutSubclassInstallCannotOutliveItsState();
mpv::TestDisabledVideoHostKeepsInputOnTheFlutterView();
mpv::TestDisabledVideoHostRoutesRealPressesToParentView();
std::cout << "mpv_player_property_contract_test: PASS\n";
return 0;
}