Merge branch 'deedra'

Resolve tests/README.md by retaining both UIA notification and MSAA/reader reply stall guidance. Preserve master's notification hooks and Deedra's responsiveness fixes.

Validation: CMake configure/build, all four CTest checks, nine stall integration variants, 64-bit and 32-bit UIA notification checks, menus, 64-bit and 32-bit editing, actual Wine Notepad, and git diff --check. Live speech and physical braille acceptance remain pending.
This commit is contained in:
Storm Dragon committed 2026-10-13 19:34:31 -04:00
commit 95cb78d3e4
12 files changed
+984 -67

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ add_custom_command(
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl"
VERBATIM)
add_executable(cthulhu-wine-access main.cpp foreground_focus.cpp list_view.cpp native_items.cpp review_capture.cpp review_x11.cpp review_service.cpp "${generated_server}")
add_executable(cthulhu-wine-access reader_events.cpp msaa_events.cpp main.cpp foreground_focus.cpp list_view.cpp native_items.cpp review_capture.cpp review_x11.cpp review_service.cpp "${generated_server}")
add_dependencies(cthulhu-wine-access wine-access-menu wine-access-menu32)
target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20)
target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}")
+22
View File
@@ -254,6 +254,28 @@ contains stable comma-separated state labels decoded from the MSAA bitmask;
protected values are excluded. Readers should filter name/state changes to
their focused source and distinguish these labels from editor diagnostics.
Application-owned MSAA queries run on one separate COM worker, with bounded
pending requests and copied results. A stalled provider cannot block the
helper's menu/editor event thread or normal shutdown. Focus/foreground changes
retire previous results; event results expire after 500 ms. If focus queries
do not finish, native control feedback uses the existing bounded fallback.
Unrelated background events are rejected before querying providers; visible
popups owned by the foreground window retain their accessible event path.
A provider that never completes still occupies the one MSAA worker until
helper exit; native menus, editing, focus fallback and controller requests
remain available. No extra workers accumulate behind blocked providers.
Reader acknowledgments also run on a separate worker. Menu, editor and control
events enter a bounded FIFO so a delayed D-Bus reply cannot block reception of
the hooks' `WM_COPYDATA` messages. The event thread's success means queued;
controller RPC requests still return the reader's actual status. At most 128
events wait behind the active call. Each reply has a 500 ms timeout, and normal
shutdown cancels the call. Failed exchanges and queue overflow produce concise
stderr diagnostics without event text. After a failed exchange, editor actions
re-establish their focus dependency; timed-out text actions are never replayed.
An unavailable reader can still cause feedback loss or queue overflow.
Standard `SysListView32` controls now report the current row's text columns,
selection and position, including 32-bit installers driven by the 64-bit
helper. Wine can expose these controls only as a generic client named `List1`,
+66 -66
View File
@@ -10,6 +10,8 @@
#include "review_service.h"
#include "foreground_focus.h"
#include "reader_endpoint.h"
#include "msaa_events.h"
#include "reader_events.h"
#include <commctrl.h>
#include <gio/gio.h>
@@ -33,6 +35,10 @@ constexpr int DBUS_TIMEOUT_MS = 500;
GDBusConnection *connection = nullptr;
ReviewIdentityStore reviewIdentities;
ReviewService reviewService;
MsaaEventReader msaaEvents;
ReaderEventQueue readerEvents;
uint64_t msaaGeneration = 0;
MsaaEvent pendingMsaaFocus; // Event-thread owned; retired by focus changes, results or timeout.
std::deque<WineAccessMenuEvent> menuEvents;
std::deque<WineAccessTextEvent> textEvents;
std::deque<WineAccessNotificationEvent> notificationEvents;
@@ -156,31 +162,6 @@ bool reader_is_available() {
return owned == TRUE;
}
std::string variant_text(const VARIANT &value) {
if (value.vt == VT_BSTR)
return to_utf8(value.bstrVal);
if (value.vt == VT_I4) {
wchar_t buffer[128] = {};
if (GetRoleTextW(value.lVal, buffer, 128) > 0)
return to_utf8(buffer);
return std::to_string(value.lVal);
}
return {};
}
std::string accessible_property(
IAccessible *accessible,
HRESULT (STDMETHODCALLTYPE IAccessible::*getter)(VARIANT, BSTR *),
const VARIANT &child) {
BSTR value = nullptr;
const HRESULT result = (accessible->*getter)(child, &value);
if (FAILED(result) || value == nullptr)
return {};
const std::string text = to_utf8(value);
SysFreeString(value);
return text;
}
// Standard-control queries follow the LGPL wine-a11y reader published by
// Mudb0y, while presentation uses the selected reader's D-Bus interface.
void strip_mnemonics(wchar_t *text) {
@@ -289,13 +270,12 @@ bool present_accessible_event(const std::string &sourceId,
// Readers can update their focus owner even when they suppress its output.
if (std::string(eventType) == "focus" && sourceId != source_id(lastTextControl, "text"))
lastTextControl = nullptr;
return call_boolean("PresentAccessibleEvent",
g_variant_new(
return readerEvents.submit(g_variant_new(
"(sssssssiis)", sourceId.c_str(), eventType,
control.name.c_str(), control.role.c_str(),
control.value.c_str(), control.states.c_str(),
control.description.c_str(), control.position,
control.count, windowTitle.c_str())) == ERROR_SUCCESS;
control.count, windowTitle.c_str()));
}
ControlDescription describe_standard_control(HWND window) {
@@ -785,6 +765,14 @@ HANDLE start_menu_host32(HWND bridge) {
void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
LONG objectId, LONG childId, DWORD, DWORD) {
const bool presentationEvent = event == EVENT_OBJECT_FOCUS || event == EVENT_OBJECT_NAMECHANGE ||
event == EVENT_OBJECT_VALUECHANGE || event == EVENT_OBJECT_SELECTION || event == EVENT_OBJECT_STATECHANGE;
if (presentationEvent && !msaa_event_in_foreground(window)) return;
if (event == EVENT_OBJECT_FOCUS || event == EVENT_SYSTEM_FOREGROUND) {
pendingMsaaFocus = {};
++msaaGeneration;
msaaEvents.invalidate(msaaGeneration);
}
if (event == EVENT_OBJECT_DESTROY) reviewIdentities.destroyed(window,objectId,childId);
else if (event == EVENT_OBJECT_REORDER) reviewIdentities.reordered(window,objectId);
if (event == EVENT_OBJECT_FOCUS || event == EVENT_SYSTEM_FOREGROUND)
@@ -824,46 +812,38 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
else poll_control_focus(event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE);
return;
}
IAccessible *accessible = nullptr;
VARIANT child{};
VariantInit(&child);
if (FAILED(AccessibleObjectFromEvent(window, objectId, childId, &accessible,
&child)) ||
accessible == nullptr) {
if (event == EVENT_OBJECT_FOCUS)
present_standard_focus(window);
DWORD process = 0;
DWORD thread = GetWindowThreadProcessId(window, &process);
MsaaEvent request{window, event, process, thread, objectId, childId,
msaaGeneration, GetTickCount64() + 500};
if (event == EVENT_OBJECT_FOCUS) pendingMsaaFocus = request;
msaaEvents.submit(request);
}
bool current_msaa_source(const MsaaEvent &source) {
DWORD process = 0;
DWORD thread = GetWindowThreadProcessId(source.window, &process);
return source.generation == msaaGeneration && IsWindow(source.window) &&
process == source.process && thread == source.thread &&
msaa_event_in_foreground(source.window);
}
void present_msaa_result(const MsaaResult &result) {
const auto &source = result.source;
if (!current_msaa_source(source) || GetTickCount64() >= source.deadline) return;
if (source.event == EVENT_OBJECT_FOCUS && pendingMsaaFocus.generation == source.generation)
pendingMsaaFocus = {};
HWND window = source.window;
DWORD event = source.event;
LONG objectId = source.object, childId = source.child;
if (!result.available) {
if (event == EVENT_OBJECT_FOCUS) present_standard_focus(window);
return;
}
const std::string name =
accessible_property(accessible, &IAccessible::get_accName, child);
const std::string value =
accessible_property(accessible, &IAccessible::get_accValue, child);
const std::string description =
accessible_property(accessible, &IAccessible::get_accDescription, child);
VARIANT role{};
VariantInit(&role);
std::string roleText;
LONG roleId = 0;
if (SUCCEEDED(accessible->get_accRole(child, &role)))
roleText = variant_text(role);
if (role.vt == VT_I4) roleId = role.lVal;
VariantClear(&role);
VARIANT state{};
VariantInit(&state);
std::string stateText;
bool password = false;
if (SUCCEEDED(accessible->get_accState(child, &state)) && state.vt == VT_I4) {
stateText = msaa_states(static_cast<DWORD>(state.lVal), roleId);
password = state.lVal & STATE_SYSTEM_PROTECTED;
}
VariantClear(&state);
LONG childCount = 0;
accessible->get_accChildCount(&childCount);
const bool nativeClient = child.vt == VT_I4 && child.lVal == CHILDID_SELF &&
(roleText.empty() || roleId == ROLE_SYSTEM_CLIENT);
accessible->Release();
VariantClear(&child);
const auto &name = result.name, &value = result.value, &description = result.description;
const auto &roleText = result.role, &stateText = result.states;
bool password = result.password, nativeClient = result.native_client;
LONG childCount = result.count;
if (event == EVENT_OBJECT_FOCUS && name.empty() && value.empty() &&
description.empty() && (roleText.empty() || roleText == "client") &&
@@ -1083,7 +1063,17 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
}
HWND window = nullptr;
if (dialogReaderEnabled && !readerEvents.start(connection, readerEndpoint)) {
std::fprintf(stderr, "wine-access: reader event worker unavailable\n");
CoUninitialize();
g_object_unref(connection);
CloseHandle(singleton);
return 1;
}
reviewService.start(connection,reviewIdentities,readerEndpoint);
if (dialogReaderEnabled && !msaaEvents.start()) {
std::fprintf(stderr, "wine-access: MSAA event worker unavailable\n");
}
bool controllerActive = false;
if (controllerEnabled &&
FindWindowW(L"wxWindowClassNR", L"NVDA") == nullptr) {
@@ -1148,6 +1138,14 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
if (listTimer && message.message == WM_TIMER && message.hwnd == nullptr &&
message.wParam == listTimer) {
MsaaResult result;
for (unsigned handled = 0; handled < 32 && msaaEvents.take(result); ++handled)
present_msaa_result(result);
if (pendingMsaaFocus.window && GetTickCount64() >= pendingMsaaFocus.deadline) {
const auto source = pendingMsaaFocus;
pendingMsaaFocus = {};
if (current_msaa_source(source)) present_standard_focus(source.window);
}
restore_virtual_desktop_focus();
poll_control_focus();
reviewService.focus_changed(reviewIdentities.observe_foreground());
@@ -1157,6 +1155,8 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
DispatchMessageW(&message);
}
if (listTimer) KillTimer(nullptr, listTimer);
readerEvents.stop();
msaaEvents.stop();
reviewService.stop();
if (hook != nullptr)
UnhookWinEvent(hook);
+172
View File
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "msaa_events.h"
#include "msaa_states.h"
#include <oleacc.h>
#include <condition_variable>
#include <mutex>
#include <deque>
#include <atomic>
#include <utility>
namespace {
// MSAA notifications describe transient state, unlike ordered editor actions.
// Bound pending work and favor current focus if an application floods events.
constexpr size_t capacity = 32;
std::string text(BSTR value) {
if (!value) return {};
int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value, -1, nullptr, 0, nullptr, nullptr);
if (size <= 1) return {};
std::string result(size, '\0');
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value, -1, result.data(), size, nullptr, nullptr);
result.resize(size - 1);
return result;
}
std::string property(IAccessible *object,
HRESULT (STDMETHODCALLTYPE IAccessible::*getter)(VARIANT, BSTR *), VARIANT child) {
BSTR value = nullptr;
HRESULT hr = (object->*getter)(child, &value);
auto result = SUCCEEDED(hr) ? text(value) : std::string{};
SysFreeString(value);
return result;
}
struct Accessible {
IAccessible *object = nullptr;
VARIANT child{};
~Accessible() { if (object) object->Release(); VariantClear(&child); }
};
}
bool msaa_event_in_foreground(HWND window) {
HWND foreground = GetForegroundWindow();
HWND root = GetAncestor(window, GA_ROOT);
if (!foreground || !root) return false;
if (root == foreground) return true;
if (!IsWindowVisible(root) || !(GetWindowLongPtrW(root, GWL_STYLE) & WS_POPUP)) return false;
// Bound traversal even if a malformed owner chain cycles. Owned popups can
// expose MSAA focus while the foreground owner retains native keyboard focus.
for (unsigned depth = 0; root && depth < 32; ++depth) {
root = GetWindow(root, GW_OWNER);
if (root == foreground) return true;
}
return false;
}
struct MsaaEventReader::State {
std::mutex mutex;
std::condition_variable changed;
std::deque<MsaaEvent> pending;
std::deque<MsaaResult> ready;
std::atomic<bool> stopping{false};
uint64_t generation = 0;
bool current(const MsaaEvent &event) {
if (stopping || GetTickCount64() >= event.deadline ||
!msaa_event_in_foreground(event.window)) return false;
std::lock_guard<std::mutex> lock(mutex);
return generation == event.generation;
}
MsaaResult read(const MsaaEvent &event) {
MsaaResult result; result.source = event;
Accessible accessible;
if (!current(event) || FAILED(AccessibleObjectFromEvent(event.window, event.object,
event.child, &accessible.object, &accessible.child)) || !accessible.object)
return result;
if (!current(event)) return result;
VARIANT role{};
LONG roleId = 0;
if (SUCCEEDED(accessible.object->get_accRole(accessible.child, &role))) {
if (role.vt == VT_I4) {
roleId = role.lVal;
WCHAR label[128]{};
if (GetRoleTextW(roleId, label, 128)) result.role = text(label);
else result.role = std::to_string(roleId);
} else if (role.vt == VT_BSTR) result.role = text(role.bstrVal);
}
VariantClear(&role);
if (!current(event)) return result;
VARIANT state{};
if (SUCCEEDED(accessible.object->get_accState(accessible.child, &state)) && state.vt == VT_I4) {
result.states = msaa_states(static_cast<DWORD>(state.lVal), roleId);
result.password = state.lVal & STATE_SYSTEM_PROTECTED;
}
VariantClear(&state);
if (!current(event)) return result;
result.name = property(accessible.object, &IAccessible::get_accName, accessible.child);
if (!current(event)) return result;
// Protected values must be excluded before querying the provider.
if (!result.password) result.value = property(accessible.object, &IAccessible::get_accValue, accessible.child);
if (!current(event)) return result;
result.description = property(accessible.object, &IAccessible::get_accDescription, accessible.child);
if (!current(event)) return result;
accessible.object->get_accChildCount(&result.count);
result.native_client = accessible.child.vt == VT_I4 && accessible.child.lVal == CHILDID_SELF &&
(result.role.empty() || roleId == ROLE_SYSTEM_CLIENT);
result.available = true;
return result;
}
static DWORD WINAPI work(LPVOID pointer) {
std::unique_ptr<std::shared_ptr<State>> retained(static_cast<std::shared_ptr<State>*>(pointer));
auto state = *retained;
const HRESULT com = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
for (;;) {
MsaaEvent event;
{
std::unique_lock<std::mutex> lock(state->mutex);
state->changed.wait(lock, [&] { return state->stopping || !state->pending.empty(); });
if (state->stopping) break;
event = state->pending.front(); state->pending.pop_front();
}
auto result = SUCCEEDED(com) ? state->read(event) : MsaaResult{};
result.source = event;
if (!state->current(event)) continue;
std::lock_guard<std::mutex> lock(state->mutex);
if (state->stopping || event.generation != state->generation) continue;
if (state->ready.size() == capacity) state->ready.pop_front();
state->ready.push_back(std::move(result));
}
if (SUCCEEDED(com)) CoUninitialize();
return 0;
}
};
MsaaEventReader::~MsaaEventReader() { stop(); }
bool MsaaEventReader::start() {
if (state_) return false;
state_ = std::make_shared<State>();
auto retained = new std::shared_ptr<State>(state_);
worker_ = CreateThread(nullptr, 0, State::work, retained, 0, nullptr);
if (!worker_) { delete retained; state_.reset(); return false; }
return true;
}
void MsaaEventReader::invalidate(uint64_t generation) {
if (!state_) return;
std::lock_guard<std::mutex> lock(state_->mutex);
state_->generation = generation;
state_->pending.clear(); state_->ready.clear();
}
void MsaaEventReader::submit(const MsaaEvent &event) {
if (!state_) return;
std::lock_guard<std::mutex> lock(state_->mutex);
if (state_->stopping || state_->generation != event.generation) return;
if (state_->pending.size() == capacity) state_->pending.pop_front();
state_->pending.push_back(event); state_->changed.notify_one();
}
bool MsaaEventReader::take(MsaaResult &result) {
if (!state_) return false;
std::lock_guard<std::mutex> lock(state_->mutex);
if (state_->ready.empty()) return false;
result = std::move(state_->ready.front()); state_->ready.pop_front();
return true;
}
void MsaaEventReader::stop() {
if (!state_) return;
{
std::lock_guard<std::mutex> lock(state_->mutex);
state_->stopping = true; state_->pending.clear(); state_->ready.clear();
state_->changed.notify_one();
}
// A hung COM call cannot be safely terminated. It retains only its own
// shared state and native references until completion or process exit.
WaitForSingleObject(worker_, 100);
CloseHandle(worker_); worker_ = nullptr;
state_.reset();
}
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <windows.h>
#include <cstdint>
#include <memory>
#include <string>
// Native focus owners can keep keyboard focus while their visible popup
// reports accessible item focus. Unrelated windows are never event sources.
bool msaa_event_in_foreground(HWND window);
struct MsaaEvent {
HWND window = nullptr;
DWORD event = 0, process = 0, thread = 0;
LONG object = 0, child = 0;
uint64_t generation = 0, deadline = 0;
};
struct MsaaResult {
MsaaEvent source;
bool available = false, native_client = false, password = false;
std::string name, role, value, states, description;
LONG count = 0;
};
// One COM worker owns native references. The event thread exchanges only
// copied requests/results, and never waits for a provider to finish.
class MsaaEventReader {
public:
~MsaaEventReader();
bool start();
void invalidate(uint64_t generation);
void submit(const MsaaEvent &event);
bool take(MsaaResult &result);
void stop();
private:
struct State;
std::shared_ptr<State> state_;
HANDLE worker_ = nullptr;
};
+136
View File
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "reader_events.h"
#include <condition_variable>
#include <cstdio>
#include <deque>
#include <mutex>
#include <string>
struct ReaderEventQueue::State {
State(GDBusConnection *bus, const ReaderEndpoint &reader)
: connection(G_DBUS_CONNECTION(g_object_ref(bus))), endpoint(reader),
calls(g_cancellable_new()) {}
~State() {
clear();
g_object_unref(calls);
g_object_unref(connection);
}
GDBusConnection *connection;
const ReaderEndpoint endpoint;
GCancellable *calls;
std::mutex mutex;
std::condition_variable changed;
std::deque<GVariant *> pending;
bool stopping = false, overflowReported = false;
void clear() {
for (auto *parameters : pending) g_variant_unref(parameters);
pending.clear();
}
bool send(GVariant *parameters, unsigned &failures) {
GError *error = nullptr;
GVariant *reply = g_dbus_connection_call_sync(
connection, endpoint.busName, endpoint.objectPath,
endpoint.interfaceName, "PresentAccessibleEvent", parameters,
G_VARIANT_TYPE("(b)"), G_DBUS_CALL_FLAGS_NONE, 500, calls, &error);
// A false reply can simply mean filtered/duplicate feedback. Only a
// failed exchange warrants diagnostics. Never include event contents.
const bool delivered = reply != nullptr;
if (!delivered && !g_cancellable_is_cancelled(calls)) {
if (++failures == 1)
std::fprintf(stderr, "wine-access: reader event reply failed (domain %s, code %d)\n",
error ? g_quark_to_string(error->domain) : "unknown", error ? error->code : 0);
} else if (delivered && failures) {
std::fprintf(stderr, "wine-access: reader event replies resumed after %u failed calls\n", failures);
failures = 0;
}
if (reply) g_variant_unref(reply);
if (error) g_error_free(error);
return delivered;
}
static DWORD WINAPI work(LPVOID pointer) {
std::unique_ptr<std::shared_ptr<State>> retained(
static_cast<std::shared_ptr<State> *>(pointer));
auto state = *retained;
unsigned failures = 0;
std::unique_ptr<GVariant, decltype(&g_variant_unref)> focus(nullptr, g_variant_unref);
std::string focusSource;
bool focusDelivered = false;
for (;;) {
GVariant *parameters;
{
std::unique_lock<std::mutex> lock(state->mutex);
state->changed.wait(lock, [&] { return state->stopping || !state->pending.empty(); });
if (state->stopping) break;
parameters = state->pending.front();
state->pending.pop_front();
}
const char *source = nullptr, *kind = nullptr;
g_variant_get_child(parameters, 0, "&s", &source);
g_variant_get_child(parameters, 1, "&s", &kind);
if (std::string(kind) == "focus") {
focus.reset(g_variant_ref(parameters));
focusSource = source;
focusDelivered = state->send(parameters, failures);
} else {
// A lost focus exchange must not strand later editor actions. Replay
// only the focus dependency, never a timed-out ordered text action.
if (std::string(kind) == "text" && !focusDelivered && focus && focusSource == source)
focusDelivered = state->send(focus.get(), failures);
if (std::string(kind) != "text" || focusDelivered) {
if (!state->send(parameters, failures)) focusDelivered = false;
}
}
g_variant_unref(parameters);
}
return 0;
}
};
ReaderEventQueue::~ReaderEventQueue() { stop(); }
bool ReaderEventQueue::start(GDBusConnection *connection, const ReaderEndpoint &endpoint) {
if (state_) return false;
state_ = std::make_shared<State>(connection, endpoint);
auto retained = new std::shared_ptr<State>(state_);
worker_ = CreateThread(nullptr, 0, State::work, retained, 0, nullptr);
if (!worker_) { delete retained; state_.reset(); return false; }
return true;
}
bool ReaderEventQueue::submit(GVariant *parameters) {
parameters = g_variant_ref_sink(parameters);
if (state_) {
std::lock_guard<std::mutex> lock(state_->mutex);
// Keep memory bounded if the reader remains unavailable. Do not reorder
// actions or evict the focus on which queued editor actions depend.
if (!state_->stopping && state_->pending.size() < 128) {
state_->pending.push_back(parameters);
state_->changed.notify_one();
return true;
}
if (!state_->stopping && !state_->overflowReported) {
std::fprintf(stderr, "wine-access: reader event queue full; feedback dropped\n");
state_->overflowReported = true;
}
}
g_variant_unref(parameters);
return false;
}
void ReaderEventQueue::stop() {
if (!state_) return;
{
std::lock_guard<std::mutex> lock(state_->mutex);
state_->stopping = true;
state_->clear();
state_->changed.notify_one();
}
g_cancellable_cancel(state_->calls);
// The worker retains its connection and queue state if cancellation has
// not completed yet; it never references event-thread globals.
WaitForSingleObject(worker_, 100);
CloseHandle(worker_);
worker_ = nullptr;
state_.reset();
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include "reader_endpoint.h"
#include <gio/gio.h>
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <memory>
// Event callbacks enqueue copied data. One worker preserves event order while
// waiting for reader acknowledgments, leaving WM_COPYDATA reception available.
class ReaderEventQueue {
public:
~ReaderEventQueue();
bool start(GDBusConnection *connection, const ReaderEndpoint &endpoint);
bool submit(GVariant *parameters);
void stop();
private:
struct State;
std::shared_ptr<State> state_;
HANDLE worker_ = nullptr;
};
+63
View File
@@ -1,5 +1,7 @@
# Wine control integration
## UIA notifications
UIA notification routing uses the actual native application hooks and a private
recording reader, without sending speech to the live reader:
@@ -23,6 +25,67 @@ in Arcadelux, verify the introduction and repeated Up/Down menu announcements,
including switching away and back. Also check existing NVDA/Tolk speech and
ordinary Wine menus and Edit controls before submitting the change.
## Reader reply stalls
Run sequentially on a private X display:
```sh
xvfb-run -a python3 tests/reader_stall_integration.py --build-dir build
xvfb-run -a python3 tests/reader_stall_integration.py --build-dir build --32-bit
xvfb-run -a python3 tests/reader_stall_integration.py --build-dir build --timeout
xvfb-run -a python3 tests/reader_stall_integration.py --build-dir build --focus-error
```
The recording reader delays an editor focus reply for 350 ms while its Wine
control types `abbacdef`. All eight actions, including repeated letters, must
arrive in order with focus established first, and native text and caret must
remain correct. `--timeout` extends the delay past the helper's D-Bus deadline;
`--focus-error` rejects the initial focus exchange and requires recovery before
later text. A second withheld reply checks normal helper shutdown. The fixture
waits for editor-hook attachment before typing, including the 32-bit host.
The original synchronous helper dropped six of the eight characters during
the delayed-reply check. These private recordings establish that shared failure
path; they do not establish the cause of every live MUSHclient speech pause.
After installing the helper and restarting it, check command typing, incoming
plugin speech, output-buffer arrows, menus, return from Linux applications and
reader restart in MUSHclient. Check ordinary Notepad editing and password
exclusion too. Audible speech and physical braille need separate live checks.
## MSAA event stalls
Run sequentially on a private X display, for example with `xvfb-run -a`:
```sh
python3 tests/event_stall_integration.py --build-dir build
python3 tests/event_stall_integration.py --build-dir build --32-bit
python3 tests/event_stall_integration.py --build-dir build --getter
python3 tests/event_stall_integration.py --build-dir build --background
python3 tests/event_stall_integration.py --build-dir build --popup
```
The fixture needs the matching MinGW C++ compiler. It uses a disposable prefix
and recording reader service. It stalls either `WM_GETOBJECT` or MSAA
text-property retrieval for eight seconds. A separate foreground
application must deliver both repeated typed characters and native button
feedback before that stall ends, preserving native text, caret and selection.
The old focus must not surface afterward. A second stall checks normal
`WM_QUIT` shutdown before the provider completes. Background focus events must
neither query unrelated providers nor invalidate foreground feedback, and an
owned nonactivating accessible popup must retain reading without moving native
focus. Use `--reader orca` to check the alternate recording endpoint.
On October 12 the supplied Cthulhu log showed successful reader D-Bus calls,
continued Xorg input and clean reader shutdown; it did not establish a reader
crash. Source inspection found synchronous MSAA calls on the helper event
thread. The original helper failed the blocked-provider typing check; the same
editor fixture without a blocked provider passed. These synthetic checks
establish the shared hang path, not the exact cause of every live MUSHclient
failure. Recheck MUSHclient command typing, incoming plugin output, menus,
plugin output-buffer arrows and keypad review after installing the helper and
restarting the reader. Listen to speech and check physical braille separately.
## Reader endpoint selection
After building, run these checks sequentially on private displays:
+121
View File
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include <windows.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#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<WNDPROC>(SetWindowLongPtrW(provider, GWLP_WNDPROC,
reinterpret_cast<LONG_PTR>(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;
}
+151
View File
@@ -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 = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">'
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in ["s"] * 7 + ["i", "i", "s"])
xml += '<arg type="b" direction="out"/></method></interface></node>'
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)
+66
View File
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include <windows.h>
#include "../menu_bridge.h"
static HWND editor;
static BOOL typed;
static BOOL marker_exists(const WCHAR *variable) {
WCHAR path[4096];
return GetEnvironmentVariableW(variable, path, 4096) &&
GetFileAttributesW(path) != INVALID_FILE_ATTRIBUTES;
}
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 LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
if (message == WM_TIMER && !typed && marker_exists(L"WINE_ACCESS_READER_WAITING")) {
typed = TRUE;
const WCHAR *text = L"abbacdef";
for (const WCHAR *cursor = text; *cursor; ++cursor)
SendMessageW(editor, WM_CHAR, *cursor, 1);
WCHAR actual[32];
DWORD start = 0, end = 0;
GetWindowTextW(editor, actual, 32);
SendMessageW(editor, EM_GETSEL, (WPARAM)&start, (LPARAM)&end);
if (lstrcmpW(actual, text) || start != 8 || end != 8) ExitProcess(2);
marker(L"WINE_ACCESS_READER_TYPED");
return 0;
}
if (message == WM_TIMER && typed && marker_exists(L"WINE_ACCESS_READER_RESUME")) {
KillTimer(window, 1);
SendMessageW(editor, WM_CHAR, 'z', 1);
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 (lstrcmpA(args, "quit-helper") == 0) {
DWORD thread = GetWindowThreadProcessId(FindWindowW(WINE_ACCESS_MENU_CLASS, NULL), NULL);
return thread && PostThreadMessageW(thread, WM_QUIT, 0, 0) ? 0 : 3;
}
WNDCLASSW cls = {0};
cls.hInstance = instance; cls.lpfnWndProc = window_proc;
cls.lpszClassName = L"WineReaderStallFixture";
RegisterClassW(&cls);
HWND window = CreateWindowW(cls.lpszClassName, L"Reader stall fixture",
WS_OVERLAPPEDWINDOW, 100, 100, 500, 300, NULL, NULL, instance, NULL);
editor = CreateWindowW(L"Edit", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP,
10, 10, 300, 30, window, NULL, instance, NULL);
ShowWindow(window, SW_SHOW); SetForegroundWindow(window); SetFocus(editor);
SetTimer(window, 1, 10, NULL);
MSG message;
while (GetMessageW(&message, NULL, 0, 0) > 0) {
TranslateMessage(&message); DispatchMessageW(&message);
}
return 0;
}
+124
View File
@@ -0,0 +1,124 @@
"""Check ordered editor feedback while the reader delays or loses replies.
Uses a private D-Bus service, disposable Wine prefix and private X 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("--timeout", action="store_true")
parser.add_argument("--focus-error", action="store_true")
parser.add_argument("--session", 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),
*(["--32-bit"] if args.is32Bit else []),
*(["--timeout"] if args.timeout else []),
*(["--focus-error"] if args.focus_error else [])]))
interface = "org.stormux.Cthulhu1.Service"
object_path = "/org/stormux/Cthulhu1/Service"
xml = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">'
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in ["s"] * 7 + ["i", "i", "s"])
xml += '<arg type="b" direction="out"/></method></interface></node>'
events, held = [], []
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-reader-stall-") as temporary:
root = Path(temporary)
waiting, typed, resume = (root / name for name in ("waiting", "typed", "resume"))
env = os.environ.copy()
env.update(WINEPREFIX=str(root / "prefix"), WINEDEBUG="-all",
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
for key, path in (("WAITING", waiting), ("TYPED", typed), ("RESUME", resume)):
env["WINE_ACCESS_READER_" + key] = "Z:" + str(path).replace("/", "\\")
def record(connection, sender, path, iface, method, parameters, invocation):
event = parameters.unpack()
first_focus = not waiting.exists() and event[1] == "focus" and event[0].endswith(":text")
if first_focus and args.focus_error:
waiting.touch()
invocation.return_dbus_error("org.freedesktop.DBus.Error.Failed", "Reader temporarily unavailable")
return
events.append(event)
if first_focus:
held.append(invocation)
waiting.touch()
elif event[1] == "text" and event[2] == "z":
held.append(invocation)
else:
invocation.return_value(GLib.Variant("(b)", (True,)))
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
connection.register_object(object_path, 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)
fixture = root / "fixture.exe"
compiler = "i686-w64-mingw32-gcc" if args.is32Bit else "x86_64-w64-mingw32-gcc"
subprocess.run([compiler, "-Wall", "-Wextra", "-mwindows", "-o", str(fixture),
str(Path(__file__).with_name("reader_stall_fixture.c")), "-luser32"], 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"], env=env)
children.append(helper)
time.sleep(1)
app = subprocess.Popen(["wine", str(fixture)], env=env)
children.append(app)
pump_until(waiting.exists, 5)
# Longer than the hook's 50 ms deadline, shorter than the bus timeout.
if not args.focus_error:
GLib.timeout_add(700 if args.timeout else 350,
lambda: (held.pop(0).return_value(GLib.Variant("(b)", (True,))), False)[1])
pump_until(typed.exists, 3)
pump_until(lambda: len([e for e in events if e[1] == "text"]) >= 8, 3)
actions = [e[2] for e in events if e[1] == "text"]
assert actions == list("abbacdef"), f"editor feedback lost or reordered: {actions}"
focused = None
for event in events:
if event[1] == "focus":
focused = event[0]
elif event[1] == "text":
assert event[0] == focused, "text forwarded before its focus"
print("PASS: delayed reader preserves repeated typing, order, native text and caret", flush=True)
pump_until(lambda: not held, 2)
resume.touch()
pump_until(lambda: bool(held), 2)
started = time.monotonic()
quit_client = subprocess.Popen(["wine", str(fixture), "quit-helper"], env=env)
children.append(quit_client)
pump_until(lambda: helper.poll() is not None, .4)
assert helper.returncode == 0, f"helper shutdown failed: {helper.returncode}"
print(f"PASS: normal shutdown while reader withholds reply ({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, check=False)