diff --git a/tests/event_stall_fixture.cpp b/tests/event_stall_fixture.cpp new file mode 100644 index 0000000..20d8407 --- /dev/null +++ b/tests/event_stall_fixture.cpp @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include +#include +#include +#include +#include "../menu_bridge.h" + +static BOOL editor_mode, stalled, getter_mode, background_mode, popup_mode; +static HWND editor, button, provider; +static WNDPROC native_provider_proc; +static unsigned step; +static const WCHAR *cls_name = L"WineEventPopupFixture"; +static void marker(const WCHAR *variable) { + WCHAR path[4096]; + if (!GetEnvironmentVariableW(variable, path, 4096)) return; + HANDLE file = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (file != INVALID_HANDLE_VALUE) CloseHandle(file); +} +static void stall_provider() { + if (stalled) return; + stalled = TRUE; + marker(L"WINE_ACCESS_STALL_READY"); + Sleep(8000); + marker(L"WINE_ACCESS_STALL_DONE"); +} + + +static LRESULT CALLBACK property_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + if (message == WM_GETTEXT || message == WM_GETTEXTLENGTH) stall_provider(); + return CallWindowProcW(native_provider_proc, window, message, wp, lp); +} + +static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + if (message == WM_GETOBJECT && (LONG)lp == OBJID_CLIENT && !editor_mode && !stalled) { + if (getter_mode) return DefWindowProcW(window, message, wp, lp); + stall_provider(); + } + if (message == WM_TIMER) { + if (!editor_mode) { + KillTimer(window, 1); + if (getter_mode) { + SetFocus(provider); + NotifyWinEvent(EVENT_OBJECT_FOCUS, provider, OBJID_CLIENT, CHILDID_SELF); + } else NotifyWinEvent(EVENT_OBJECT_FOCUS, window, OBJID_CLIENT, CHILDID_SELF); + } else if (++step == 1) { + SetForegroundWindow(window); SetFocus(editor); + PostMessageW(editor, WM_CHAR, 'a', 0); + PostMessageW(editor, WM_CHAR, 'a', 0); + } else { + KillTimer(window, 1); + WCHAR text[16]; DWORD start, end; + GetWindowTextW(editor, text, 16); + SendMessageW(editor, EM_GETSEL, (WPARAM)&start, (LPARAM)&end); + if (lstrcmpW(text, L"aa") || start != 2 || end != 2) ExitProcess(2); + marker(L"WINE_ACCESS_EDITOR_CHECKED"); + SetFocus(button); + NotifyWinEvent(EVENT_OBJECT_FOCUS, button, OBJID_CLIENT, CHILDID_SELF); + if (popup_mode) { + HWND popup = CreateWindowExW(WS_EX_NOACTIVATE, cls_name, L"Owned popup provider", + WS_POPUP | WS_VISIBLE, 200, 200, 200, 80, window, NULL, GetModuleHandleW(NULL), NULL); + // A bare X server can activate a newly mapped Wine popup despite + // WS_EX_NOACTIVATE. Establish the intended owner focus explicitly + // before testing its independent accessible focus notification. + SetForegroundWindow(window); SetFocus(button); + NotifyWinEvent(EVENT_OBJECT_FOCUS, popup, OBJID_CLIENT, CHILDID_SELF); + if (GetForegroundWindow() != window || GetFocus() != button) { + fprintf(stderr, "popup focus changed: foreground=%p expected=%p focus=%p expected=%p\n", + GetForegroundWindow(), window, GetFocus(), button); + ExitProcess(4); + } + } + } + return 0; + } + if (message == WM_DESTROY) { PostQuitMessage(0); return 0; } + return DefWindowProcW(window, message, wp, lp); +} +int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) { + (void)previous; (void)show; + if (strstr(args, "quit-helper")) { + HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL); + DWORD thread = GetWindowThreadProcessId(bridge, NULL); + return thread && PostThreadMessageW(thread, WM_QUIT, 0, 0) ? 0 : 3; + } + if (FAILED(CoInitializeEx(NULL, COINIT_APARTMENTTHREADED))) return 5; + editor_mode = strstr(args, "editor") != NULL; + getter_mode = strstr(args, "getter") != NULL; + background_mode = strstr(args, "background") != NULL; + popup_mode = strstr(args, "popup") != NULL; + WNDCLASSW cls = {}; + cls.hInstance = instance; cls.lpfnWndProc = window_proc; cls.lpszClassName = L"WineEventStallFixture"; + RegisterClassW(&cls); + HWND window = CreateWindowW(cls.lpszClassName, + editor_mode ? L"Responsive editor fixture" : L"Stalled provider fixture", + WS_OVERLAPPEDWINDOW, 100, 100, 500, 300, NULL, NULL, instance, NULL); + if (editor_mode) { + editor = CreateWindowW(L"Edit", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 10, 10, 300, 30, window, NULL, instance, NULL); + button = CreateWindowW(L"Button", L"Responsive button", WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 10, 60, 200, 30, window, NULL, instance, NULL); + } + if (getter_mode) { + provider = CreateWindowW(L"Static", L"Stalled property provider", WS_CHILD | WS_VISIBLE, + 10, 10, 300, 30, window, NULL, instance, NULL); + native_provider_proc = reinterpret_cast(SetWindowLongPtrW(provider, GWLP_WNDPROC, + reinterpret_cast(property_proc))); + } + WNDCLASSW popupClass = cls; + popupClass.lpszClassName = cls_name; popupClass.lpfnWndProc = DefWindowProcW; + RegisterClassW(&popupClass); + ShowWindow(window, background_mode ? SW_SHOWNOACTIVATE : SW_SHOW); + if (!background_mode) SetForegroundWindow(window); + if (editor_mode) SetFocus(editor); + SetTimer(window, 1, 500, NULL); + MSG message; + while (GetMessageW(&message, NULL, 0, 0) > 0) { + TranslateMessage(&message); DispatchMessageW(&message); + } + CoUninitialize(); + return 0; +} diff --git a/tests/event_stall_integration.py b/tests/event_stall_integration.py new file mode 100644 index 0000000..b30727e --- /dev/null +++ b/tests/event_stall_integration.py @@ -0,0 +1,151 @@ +"""Check event feedback and normal shutdown while a Wine MSAA provider stalls. + +Run on a private X display. Uses a disposable prefix and a recording D-Bus +service; no speech reaches the user's reader or physical braille display. +""" +import argparse +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +from gi.repository import Gio, GLib + +parser = argparse.ArgumentParser() +parser.add_argument("--build-dir", default="build") +parser.add_argument("--32-bit", dest="is32Bit", action="store_true") +parser.add_argument("--reader", choices=("cthulhu", "orca"), default="cthulhu") +parser.add_argument("--session", action="store_true") +parser.add_argument("--getter", action="store_true") +parser.add_argument("--background", action="store_true") +parser.add_argument("--popup", action="store_true") +args = parser.parse_args() +build = Path(args.build_dir).resolve() +if not args.session: + sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__, + "--session", "--build-dir", str(build), "--reader", args.reader, + *(["--32-bit"] if args.is32Bit else []), + *(["--getter"] if args.getter else []), + *(["--background"] if args.background else []), + *(["--popup"] if args.popup else [])])) + +interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service" +objectPath = "/" + interface.replace(".", "/") +xml = '' +xml += ''.join('' for kind in ["s"] * 7 + ["i", "i", "s"]) +xml += '' +events = [] + + +def record(connection, sender, path, iface, method, parameters, invocation): + events.append(parameters.unpack()) + invocation.return_value(GLib.Variant("(b)", (True,))) + + +connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) +connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None) +connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "RequestName", GLib.Variant("(su)", (interface, 0)), None, + Gio.DBusCallFlags.NONE, 1000, None) + + +def pump_until(predicate, seconds): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + while GLib.MainContext.default().iteration(False): + pass + if predicate(): + return + time.sleep(.005) + raise AssertionError(f"condition timed out; events: {events}") + + +with tempfile.TemporaryDirectory(prefix="wine-event-stall-") as temporary: + root = Path(temporary) + env = os.environ.copy() + env.update(WINEPREFIX=str(root / "prefix"), WINEDEBUG="-all", + WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1") + ready, done, checked = root / "ready", root / "done", root / "checked" + for key, path in (("WINE_ACCESS_STALL_READY", ready), ("WINE_ACCESS_STALL_DONE", done), + ("WINE_ACCESS_EDITOR_CHECKED", checked)): + env[key] = "Z:" + str(path).replace("/", "\\") + fixture = root / "fixture.exe" + compiler = "i686-w64-mingw32-g++" if args.is32Bit else "x86_64-w64-mingw32-g++" + subprocess.run([compiler, "-Wall", "-Wextra", "-mwindows", "-static", "-o", str(fixture), + str(Path(__file__).with_name("event_stall_fixture.cpp")), "-luser32", "-loleacc", "-lole32", "-loleaut32", "-luuid"], check=True) + children = [] + try: + subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45) + helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller", + "--reader", args.reader], env=env) + children.append(helper) + time.sleep(1) + modes = ["getter"] if args.getter else [] + if args.background: modes.append("background") + if args.background or args.popup: + editor = subprocess.Popen(["wine", str(fixture), "editor", *(["popup"] if args.popup else [])], env=env) + children.append(editor) + if args.popup: + pump_until(lambda: editor.poll() is not None or any(e[2] == "Owned popup provider" for e in events), 3) + assert editor.poll() is None, f"popup fixture failed: {editor.returncode}" + assert any(e[2] == "Owned popup provider" for e in events) + print("PASS: accessible owned popup preserves foreground and native focus", flush=True) + sys.exit(0) + pump_until(lambda: any(e[1] == "text" and e[2] == "a" for e in events), 3) + stall = subprocess.Popen(["wine", str(fixture), *modes], env=env) + children.append(stall) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + while GLib.MainContext.default().iteration(False): + pass + time.sleep(.005) + assert not ready.exists(), "background provider was queried" + assert any(e[1] == "focus" and e[2] == "Responsive button" and not e[0].endswith(":standard") for e in events), "background event starved foreground MSAA" + print("PASS: background provider not queried; ordinary foreground MSAA feedback retained", flush=True) + sys.exit(0) + stall = subprocess.Popen(["wine", str(fixture), *modes], env=env) + children.append(stall) + pump_until(ready.exists, 10) + events.clear() + started = time.monotonic() + editor = subprocess.Popen(["wine", str(fixture), "editor"], env=env) + children.append(editor) + pump_until(lambda: sum(e[1] == "text" and e[2] == "a" for e in events) >= 2, 2) + elapsed = time.monotonic() - started + assert not done.exists(), "typing waited for the blocked provider" + assert helper.poll() is None, "helper exited" + print(f"PASS: repeated typing while MSAA stalled ({elapsed:.3f} s)", flush=True) + pump_until(lambda: any(e[1] == "focus" and e[2] == "Responsive button" for e in events), 2) + assert checked.exists(), "native text, caret or selection changed" + assert not done.exists(), "native button feedback waited for the blocked provider" + print("PASS: native button fallback; unchanged native text/caret/selection", flush=True) + pump_until(done.exists, 10) + # Drain enough time for an incorrectly queued late result to surface. + deadline = time.monotonic() + .5 + while time.monotonic() < deadline: + while GLib.MainContext.default().iteration(False): + pass + time.sleep(.005) + assert not any(e[9] == "Stalled provider fixture" for e in events), "late stale focus escaped" + print("PASS: stale MSAA focus suppressed", flush=True) + ready.unlink(); done.unlink() + second = subprocess.Popen(["wine", str(fixture), *modes], env=env) + children.append(second) + pump_until(ready.exists, 10) + started = time.monotonic() + subprocess.run(["wine", str(fixture), "quit-helper"], env=env, check=True, timeout=3) + pump_until(lambda: helper.poll() is not None, 2) + assert helper.returncode == 0, f"normal helper shutdown failed: {helper.returncode}" + assert not done.exists(), "normal shutdown waited for the provider" + print(f"PASS: normal shutdown during MSAA stall ({time.monotonic() - started:.3f} s)", flush=True) + finally: + for child in reversed(children): + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=3) + except subprocess.TimeoutExpired: + child.kill(); child.wait(timeout=3) + subprocess.run(["wineserver", "-k"], env=env, timeout=5)