diff --git a/CMakeLists.txt b/CMakeLists.txt index 3e8ee3f..0b06040 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,6 +99,11 @@ if(BUILD_TESTING) target_compile_features(msaa-states-test PRIVATE cxx_std_20) target_compile_options(msaa-states-test PRIVATE -Wall -Wextra) add_wine_test(msaa-states 35 "${CMAKE_CURRENT_BINARY_DIR}/msaa-states-test.exe") + add_executable(text-limits-test tests/text_limits_test.cpp reader_events.cpp) + target_compile_features(text-limits-test PRIVATE cxx_std_20) + target_compile_options(text-limits-test PRIVATE -Wall -Wextra) + target_link_libraries(text-limits-test PRIVATE PkgConfig::GIO user32) + add_wine_test(text-limits 35 dbus-run-session -- "${CMAKE_CURRENT_BINARY_DIR}/text-limits-test.exe") add_executable(review-capture-test tests/review_capture_test.cpp) target_compile_features(review-capture-test PRIVATE cxx_std_20) target_compile_options(review-capture-test PRIVATE -Wall -Wextra) @@ -120,14 +125,15 @@ if(BUILD_TESTING) add_custom_target(hook-safety-fixture${arch} ALL DEPENDS "${hook_fixture}") set(review_fixture "${CMAKE_CURRENT_BINARY_DIR}/review-fixture${arch}.exe") add_custom_command(OUTPUT "${review_fixture}" - COMMAND "${fixture_compiler}" -std=c11 -Wall -Wextra -mwindows + COMMAND "${fixture_compiler}" -std=c11 -Wall -Wextra -mwindows -Wl,--stack,1048576 -o "${review_fixture}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/review_fixture.c" -luser32 -lcomctl32 - DEPENDS tests/review_fixture.c VERBATIM) + DEPENDS tests/review_fixture.c edit_hook.h VERBATIM) add_custom_target(review-fixture${arch} ALL DEPENDS "${review_fixture}") add_dependencies(review-capture-test review-fixture${arch}) add_wine_test(review-capture${arch} 35 "${CMAKE_CURRENT_BINARY_DIR}/review-capture-test.exe" --fixture "${review_fixture}") endforeach() + add_dependencies(review-capture-test wine-access-menu wine-access-menu32) add_wine_test(hook-safety 100 python3 "${CMAKE_CURRENT_SOURCE_DIR}/tests/hook_safety_test.py" "${CMAKE_CURRENT_BINARY_DIR}") endif() diff --git a/README.md b/README.md index dfccea8..a613c82 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,32 @@ imports remain valid across reader restarts. Close and reopen applications after updating Wine Access to load the new hooks. `--no-dialog-reader` disables these hooks along with standard control reading. +Long-running text processing is bounded at the helper boundary. MSAA properties +retain at most 4096 UTF-16 code units each, without splitting a surrogate pair. +The reader queue accepts at most 128 events, 128 KiB per serialized event, and +1 MiB of serialized pending events. When full it drops new feedback and reports +the condition once. NVDA/Tolk speech, braille and SSML requests longer than +65536 UTF-16 code units return `ERROR_INVALID_PARAMETER` before UTF-8 conversion. +These limits apply per request, not to the lifetime number of processed lines. + +Window review reads large standard Edit controls through a native hook query, +which avoids sending their text buffers through Wine's message-hook stack. +Review can attach the foreground UI thread's hook to an output editor without +moving keyboard focus there. +The existing aggregate source limit of one million UTF-16 code units still +applies. Editors without the updated hook use a conservative 16384-unit fallback; +larger sources report `limited` until an updated hook is attached. Restart both +the helper and the affected Wine application after upgrading. Programmatic Edit +replacements defer snapshot refresh until an editor action needs it, background +updates do not copy the document, and snapshot buffers reuse bounded capacity. + +Native list, tree and tab queries retain target-process buffers until their +completion callbacks arrive. A late query prevents additional remote allocations +on that reader thread, while ordinary event handling continues. Failed reads are +retried instead of being cached as complete. These protections do not establish +the cause of a reported whole-machine lockup or bound a reader's downstream +speech backlog. + The executable is named `wine-access.exe`. Its launcher, Winelib sidecar, and native hooks share a reader-independent installation directory. diff --git a/distro-packages/ArchLinux/wine-access-git/PKGBUILD b/distro-packages/ArchLinux/wine-access-git/PKGBUILD index a1b63b6..7c2d6d7 100644 --- a/distro-packages/ArchLinux/wine-access-git/PKGBUILD +++ b/distro-packages/ArchLinux/wine-access-git/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Storm Dragon pkgname=wine-access-git -pkgver=r58.gdbf3ceb +pkgver=r60.gde31e5b pkgrel=1 pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for screen readers' arch=('x86_64') diff --git a/edit_hook.c b/edit_hook.c index ae2470a..1d750e3 100644 --- a/edit_hook.c +++ b/edit_hook.c @@ -19,6 +19,7 @@ typedef struct EditState { BOOL history_key, history_replaced; WCHAR pending_high; DWORD pending_caret; + DWORD capacity; } EditState; static void selection(HWND window, DWORD *start, DWORD *end) { @@ -37,22 +38,61 @@ static LRESULT query_text(HWND window, UINT message, WPARAM wp, LPARAM lp) { } static void snapshot(HWND window, EditState *state) { + LRESULT length = query_text(window, WM_GETTEXTLENGTH, 0, 0); + state->length = length >= 0 && (ULONGLONG)length <= MAXDWORD ? (DWORD)length : 0; + if (length < 0 || (ULONGLONG)length > MAX_SNAPSHOT_UNITS) goto discard; + if (state->capacity <= state->length) { + DWORD capacity = state->length + 1; + // Grow geometrically rather than allocating another transcript on every + // appended line. Never retain more than the original snapshot budget. + if (capacity < 4096) capacity = 4096; + if (capacity < state->capacity * 2) capacity = state->capacity * 2; + if (capacity > MAX_SNAPSHOT_UNITS + 1) capacity = MAX_SNAPSHOT_UNITS + 1; + WCHAR *text = realloc(state->text, (size_t)capacity * sizeof(WCHAR)); + if (!text) goto discard; + state->text = text; + state->capacity = capacity; + } + LRESULT copied = query_text(window, WM_GETTEXT, state->length + 1, (LPARAM)state->text); + if (copied < 0 || (ULONGLONG)copied > state->length) goto discard; + state->length = copied; + state->text[state->length] = 0; + return; +discard: free(state->text); state->text = NULL; - state->length = query_text(window, WM_GETTEXTLENGTH, 0, 0); - if (state->length <= MAX_SNAPSHOT_UNITS) { - state->text = calloc((size_t)state->length + 1, sizeof(WCHAR)); - if (state->text) { - LRESULT copied = query_text(window, WM_GETTEXT, state->length + 1, (LPARAM)state->text); - if (copied >= 0 && (ULONGLONG)copied <= state->length) { - state->length = copied; - } else { - // A failed query must never become a length used to index this buffer. - free(state->text); - state->text = NULL; - } - } + state->capacity = 0; +} + +static LRESULT read_review_text(HWND window, EditState *state, WineAccessEditText *request) { + if (!request || !request->capacity || request->capacity > MAX_SNAPSHOT_UNITS + 1 || + (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD)) return 0; + state->busy = TRUE; + LRESULT length = query_text(window, WM_GETTEXTLENGTH, 0, 0); + LRESULT copied = -1; + if (!state->destroyed && !(GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) && length >= 0) { + if ((ULONGLONG)length >= request->capacity) request->status = 2; + else copied = query_text(window, WM_GETTEXT, request->capacity, (LPARAM)request->text); } + if (state->destroyed) { + free(state->text); free(state); + return 0; + } + BOOL password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0; + if (password) { + // A provider can enable protection during the busy text query, bypassing + // handle_edit's ordinary password-change cleanup. + free(state->text); state->text = NULL; state->capacity = 0; + state->known = FALSE; state->pending_high = 0; + } + if (copied == length && copied >= 0 && (ULONGLONG)copied < request->capacity && + !password) { + request->copied = copied; + request->text[copied] = 0; + request->status = 1; + } + state->busy = FALSE; + return request->status; } static void copy_range(WCHAR *output, const WCHAR *text, DWORD start, DWORD end) { @@ -126,6 +166,20 @@ static __attribute__((noinline)) LRESULT handle_edit(HWND window, UINT message, state->history_key = state->history_replaced = FALSE; BOOL history_replacement = message == WM_SETTEXT && state->history_key; + // Programmatic output updates have no editor action to announce. Rebuild + // lazily on navigation/typing/focus, not on every incoming transcript line. + // Pretranslated command-history replacements still need immediate feedback. + BOOL programmatic = message == EM_REPLACESEL || message == EM_SETHANDLE || + message == EM_FMTLINES || (message == WM_SETTEXT && !history_replacement); + if ((!focus && GetFocus() != window) || (changing && programmatic)) { + if (GetFocus() != window) { + free(state->text); state->text = NULL; state->capacity = 0; + } + state->known = FALSE; + state->pending_high = 0; + return DefSubclassProc(window, message, wp, lp); + } + state->busy = TRUE; WCHAR pending_high = state->pending_high; DWORD pending_caret = state->pending_caret; @@ -159,7 +213,7 @@ static __attribute__((noinline)) LRESULT handle_edit(HWND window, UINT message, selection(window, &start, &end); password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0; if (password) { - free(state->text); state->text = NULL; state->known = FALSE; + free(state->text); state->text = NULL; state->capacity = 0; state->known = FALSE; if (focus) notify_text(window, WINE_ACCESS_TEXT_PASSWORD, L"", start, end); state->busy = FALSE; return result; @@ -265,11 +319,14 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l if (message == WM_NCDESTROY) { RemoveWindowSubclass(window, edit_proc, id); RemovePropW(window, EDIT_STATE_PROPERTY); + RemovePropW(window, EDIT_REVIEW_PROPERTY); state->destroyed = TRUE; if (!state->busy) { free(state->text); free(state); } return DefSubclassProc(window, message, wp, lp); } if (state->busy) return DefSubclassProc(window, message, wp, lp); + if (message == RegisterWindowMessageW(EDIT_REVIEW_MESSAGE)) + return read_review_text(window, state, (WineAccessEditText *)lp); if (message == WM_KILLFOCUS) state->history_key = state->history_replaced = FALSE; BOOL relevant = FALSE; @@ -313,7 +370,10 @@ static BOOL watch_edit(HWND window, EditState **edit_state) { if (!SetWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID, (DWORD_PTR)state)) { free(state); return FALSE; } - if (!SetPropW(window, EDIT_STATE_PROPERTY, state)) { + if (!SetPropW(window, EDIT_STATE_PROPERTY, state) || + !SetPropW(window, EDIT_REVIEW_PROPERTY, (HANDLE)1)) { + RemovePropW(window, EDIT_STATE_PROPERTY); + RemovePropW(window, EDIT_REVIEW_PROPERTY); RemoveWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID); free(state); return FALSE; } diff --git a/edit_hook.h b/edit_hook.h index ed72510..de15f00 100644 --- a/edit_hook.h +++ b/edit_hook.h @@ -3,6 +3,14 @@ #include #define EDIT_STATE_PROPERTY L"CthulhuWineEditState" #define EDIT_FOCUS_MESSAGE L"CthulhuWineEditFocus" +#define EDIT_REVIEW_MESSAGE L"CthulhuWineEditReviewTextV1" +#define EDIT_REVIEW_PROPERTY L"CthulhuWineEditReviewV1" +// Fixed-width layout shared with 32-bit applications. The allocation lives +// in the application's address space until the message has really completed. +typedef struct WineAccessEditText { + DWORD capacity, copied, status; // status: 0 unavailable, 1 complete, 2 limited + WCHAR text[1]; +} WineAccessEditText; BOOL wine_access_watch_edit(HWND window); BOOL wine_access_watch_scintilla(HWND window); void wine_access_scintilla_input(HWND window, const MSG *message); diff --git a/list_view.cpp b/list_view.cpp index e8ba593..8079d5c 100644 --- a/list_view.cpp +++ b/list_view.cpp @@ -60,8 +60,10 @@ int list_view_index(HWND window, int *count) { ListViewRow read_list_view(HWND window) { ListViewRow row; row.index = list_view_index(window, &row.count); - if (row.index < 0 || row.index >= row.count) + if (row.index < 0 || row.index >= row.count) { + row.available = row.count == 0; return row; + } DWORD_PTR state = 0; if (query(window, LVM_GETITEMSTATE, row.index, LVIS_SELECTED, &state)) row.selected = state & LVIS_SELECTED; @@ -77,6 +79,7 @@ ListViewRow read_list_view(HWND window) { } // Keep provider work and the protocol payload bounded even for huge tables. if (columnCount > 8) columnCount = 8; + bool complete = true; for (DWORD_PTR column = 0; column < columnCount; ++column) { BYTE buffer[bufferSize] = {}; const auto textAddress = remote.address(textOffset); @@ -92,9 +95,11 @@ ListViewRow read_list_view(HWND window) { DWORD_PTR result = 0; wchar_t text[textCapacity] = {}; if (!remote.write(buffer, sizeof(buffer)) || - !query(window, LVM_GETITEMTEXTW, row.index, static_cast(remote.address()), &result) || - !remote.read(text, sizeof(text), textOffset)) + !remote.query(window, LVM_GETITEMTEXTW, row.index, &result) || + !remote.read(text, sizeof(text), textOffset)) { + complete = false; break; + } text[textCapacity - 1] = L'\0'; if (!text[0]) continue; char utf8[textCapacity * 4] = {}; @@ -103,6 +108,7 @@ ListViewRow read_list_view(HWND window) { if (!row.text.empty()) row.text += ", "; row.text += utf8; } + row.available = complete; return row; } diff --git a/list_view.h b/list_view.h index a3ed630..7a959e3 100644 --- a/list_view.h +++ b/list_view.h @@ -4,6 +4,7 @@ #include struct ListViewRow { + bool available = false; std::string text; int index = -1; int count = 0; diff --git a/main.cpp b/main.cpp index bd2f2a8..44d8f70 100644 --- a/main.cpp +++ b/main.cpp @@ -16,6 +16,7 @@ #include "bootstrapper_controls.h" #include "bootstrapper_hook.h" #include "version.h" +#include "text_limits.h" #include #include @@ -411,11 +412,13 @@ ControlDescription describe_standard_control(HWND window) { if (!control.states.empty()) control.states += ", "; control.states += row.checked ? "checked" : "not checked"; } - lastListControl = window; - lastListIndex = row.index; - lastListCount = row.count; - lastListSelected = row.selected; - lastListChecked = row.checked; + if (row.available) { + lastListControl = window; + lastListIndex = row.index; + lastListCount = row.count; + lastListSelected = row.selected; + lastListChecked = row.checked; + } else lastListControl = nullptr; // Retry after a late native reply. return control; } @@ -426,8 +429,10 @@ ControlDescription describe_standard_control(HWND window) { control.states = item.states; control.position = item.position; control.count = item.count; - lastNativeControl = window; - lastNativeItem = item.key; + if (item.available) { + lastNativeControl = window; + lastNativeItem = item.key; + } else lastNativeControl = nullptr; return control; } @@ -1067,6 +1072,7 @@ extern "C" error_status_t __stdcall testIfRunning(void) { return reader_is_available() ? ERROR_SUCCESS : ERROR_SERVICE_NOT_ACTIVE; } extern "C" error_status_t __stdcall speakText(const wchar_t *text) { + if (!controller_text_fits(text)) return ERROR_INVALID_PARAMETER; const std::string utf8 = to_utf8(text); // Tolk calls cancelSpeech separately when its interrupt argument is true. return call_boolean("SpeakText", g_variant_new("(sb)", utf8.c_str(), FALSE)); @@ -1075,6 +1081,7 @@ extern "C" error_status_t __stdcall cancelSpeech(void) { return call_boolean("CancelSpeech", nullptr); } extern "C" error_status_t __stdcall brailleMessage(const wchar_t *text) { + if (!controller_text_fits(text)) return ERROR_INVALID_PARAMETER; const std::string utf8 = to_utf8(text); return call_boolean("BrailleMessage", g_variant_new("(s)", utf8.c_str())); } @@ -1086,6 +1093,7 @@ extern "C" error_status_t __stdcall getProcessId(ULONG *pid) { } extern "C" error_status_t __stdcall speakSsml(const wchar_t *ssml, SYMBOL_LEVEL, SPEECH_PRIORITY, boolean) { + if (!controller_text_fits(ssml)) return ERROR_INVALID_PARAMETER; const std::string utf8 = to_utf8(ssml); GError *error = nullptr; GVariant *reply = g_dbus_connection_call_sync( diff --git a/menu_hook.c b/menu_hook.c index 6de6004..d619679 100644 --- a/menu_hook.c +++ b/menu_hook.c @@ -69,7 +69,9 @@ __declspec(dllexport) LRESULT CALLBACK WineAccessMenuHook(int code, WPARAM wp, if (code >= 0) { wine_access_watch_notifications(); const CWPSTRUCT *message = (const CWPSTRUCT *)lp; - if (message->message == WM_SETFOCUS) wine_access_watch_edit(message->hwnd); + if (message->message == WM_SETFOCUS || + (message->message >= 0xc000 && message->message == RegisterWindowMessageW(EDIT_REVIEW_MESSAGE))) + wine_access_watch_edit(message->hwnd); if (message->message == WM_MENUSELECT || message->message == WM_EXITMENULOOP) handle_menu(message); } diff --git a/msaa_events.cpp b/msaa_events.cpp index d710dad..bf19a08 100644 --- a/msaa_events.cpp +++ b/msaa_events.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include "msaa_events.h" #include "msaa_states.h" +#include "text_limits.h" #include #include #include @@ -13,19 +14,13 @@ namespace { // 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; + return bounded_accessible_text(value, max_accessible_units); } 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{}; + auto result = SUCCEEDED(hr) ? bounded_accessible_text(value, value ? SysStringLen(value) : 0) : std::string{}; SysFreeString(value); return result; } @@ -79,7 +74,8 @@ struct MsaaEventReader::State { 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); + } else if (role.vt == VT_BSTR) + result.role = bounded_accessible_text(role.bstrVal, role.bstrVal ? SysStringLen(role.bstrVal) : 0); } VariantClear(&role); if (!current(event)) return result; diff --git a/native_items.cpp b/native_items.cpp index 0262dde..782c213 100644 --- a/native_items.cpp +++ b/native_items.cpp @@ -72,7 +72,7 @@ NativeItem read_native_item(HWND window) { item.role = tab ? "page tab" : "tree"; const auto key = native_item_key(window); item.key = key; - if (!key.item) return item; + if (!key.item) { item.available = true; return item; } native_control::RemoteBuffer remote(window, bufferSize); if (!remote) return item; const bool wow64 = remote.is32Bit(); @@ -95,9 +95,10 @@ NativeItem read_native_item(HWND window) { } DWORD_PTR result = 0; if (remote.write(buffer, sizeof(buffer)) && - query(window, tab ? TCM_GETITEMW : TVM_GETITEMW, tab ? key.item - 1 : 0, - static_cast(remote.address()), &result) && result && + remote.query(window, tab ? TCM_GETITEMW : TVM_GETITEMW, tab ? key.item - 1 : 0, + &result) && result && remote.read(buffer, sizeof(buffer))) { + item.available = true; wchar_t text[textCapacity] = {}; memcpy(text, buffer + textOffset, sizeof(text)); text[textCapacity - 1] = L'\0'; diff --git a/native_items.h b/native_items.h index 5293bd9..1907210 100644 --- a/native_items.h +++ b/native_items.h @@ -11,6 +11,7 @@ struct NativeItemKey { bool operator==(const NativeItemKey &) const = default; }; struct NativeItem { + bool available = false; NativeItemKey key; std::string text; std::string role; diff --git a/native_query.h b/native_query.h index 3d3e6b7..751e299 100644 --- a/native_query.h +++ b/native_query.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace native_control { inline bool query(HWND window, UINT message, WPARAM wp, LPARAM lp, DWORD_PTR *result) { @@ -13,46 +14,86 @@ inline bool query(HWND window, UINT message, WPARAM wp, LPARAM lp, DWORD_PTR *re // Common-control messages above WM_USER do not marshal embedded pointers. // Own the target allocation and process handle together, including failures. class RemoteBuffer { + struct Allocation { + HANDLE process = nullptr; + void *memory = nullptr; + BOOL wow64 = FALSE; + bool pending = false; + LRESULT result = 0; + ~Allocation() { + if (memory) VirtualFreeEx(process, memory, 0, MEM_RELEASE); + if (process) CloseHandle(process); + } + }; + inline static thread_local std::weak_ptr outstanding_; + static void CALLBACK completed(HWND, UINT, ULONG_PTR data, LRESULT result) { + std::unique_ptr> retained( + reinterpret_cast *>(data)); + (*retained)->result = result; + (*retained)->pending = false; + } public: RemoteBuffer(HWND window, size_t size) { + // One late application request may retain its allocation. Do not create + // another on every polling tick while that application is unresponsive. + if (auto previous = outstanding_.lock(); previous && previous->pending) return; + allocation_ = std::make_shared(); DWORD processId = 0; GetWindowThreadProcessId(window, &processId); - process_ = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_READ | + allocation_->process = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId); - if (!process_ || !IsWow64Process(process_, &wow64_)) return; - memory_ = VirtualAllocEx(process_, nullptr, size, + if (!allocation_->process || !IsWow64Process(allocation_->process, &allocation_->wow64)) return; + allocation_->memory = VirtualAllocEx(allocation_->process, nullptr, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); // Every byte of the allocation must be addressable by the target. - if (memory_ && wow64_ && + if (allocation_->memory && allocation_->wow64 && (address() > UINT32_MAX || size - 1 > UINT32_MAX - address())) { - VirtualFreeEx(process_, memory_, 0, MEM_RELEASE); - memory_ = nullptr; + VirtualFreeEx(allocation_->process, allocation_->memory, 0, MEM_RELEASE); + allocation_->memory = nullptr; } } - ~RemoteBuffer() { - if (memory_) VirtualFreeEx(process_, memory_, 0, MEM_RELEASE); - if (process_) CloseHandle(process_); - } RemoteBuffer(const RemoteBuffer &) = delete; RemoteBuffer &operator=(const RemoteBuffer &) = delete; - explicit operator bool() const { return memory_ != nullptr; } - bool is32Bit() const { return wow64_ != FALSE; } + explicit operator bool() const { return allocation_ && allocation_->memory; } + bool is32Bit() const { return allocation_->wow64 != FALSE; } uintptr_t address(size_t offset = 0) const { - return reinterpret_cast(memory_) + offset; + return reinterpret_cast(allocation_->memory) + offset; } bool write(const void *buffer, size_t size) const { - return WriteProcessMemory(process_, memory_, buffer, size, nullptr) != 0; + return !allocation_->pending && WriteProcessMemory(allocation_->process, allocation_->memory, buffer, size, nullptr) != 0; } bool read(void *buffer, size_t size, size_t offset = 0) const { - return ReadProcessMemory(process_, reinterpret_cast(address(offset)), + return !allocation_->pending && ReadProcessMemory(allocation_->process, reinterpret_cast(address(offset)), buffer, size, nullptr) != 0; } + bool query(HWND window, UINT message, WPARAM wp, DWORD_PTR *result) { + if (!*this || allocation_->pending) return false; + allocation_->pending = true; + outstanding_ = allocation_; + auto retained = new std::shared_ptr(allocation_); + if (!SendMessageCallbackW(window, message, wp, static_cast(address()), + completed, reinterpret_cast(retained))) { + allocation_->pending = false; + delete retained; + return false; + } + const ULONGLONG deadline = GetTickCount64() + 100; + while (allocation_->pending && GetTickCount64() < deadline) { + // Dispatch sent-message completions without consuming queued UI work. + // Reentrant polls cannot allocate while outstanding_ remains pending. + MSG message{}; + PeekMessageW(&message, nullptr, 0, 0, PM_NOREMOVE); + if (allocation_->pending) Sleep(1); + } + if (allocation_->pending) return false; + *result = static_cast(allocation_->result); + return true; + } + private: - HANDLE process_ = nullptr; - void *memory_ = nullptr; - BOOL wow64_ = FALSE; + std::shared_ptr allocation_; }; } // namespace native_control diff --git a/reader_events.cpp b/reader_events.cpp index 5a42355..7198042 100644 --- a/reader_events.cpp +++ b/reader_events.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include "reader_events.h" +#include "text_limits.h" #include #include #include @@ -21,11 +22,13 @@ struct ReaderEventQueue::State { std::mutex mutex; std::condition_variable changed; std::deque pending; + size_t pendingBytes = 0; bool stopping = false, overflowReported = false; void clear() { for (auto *parameters : pending) g_variant_unref(parameters); pending.clear(); + pendingBytes = 0; } bool send(GVariant *parameters, unsigned &failures) { GError *error = nullptr; @@ -64,6 +67,7 @@ struct ReaderEventQueue::State { if (state->stopping) break; parameters = state->pending.front(); state->pending.pop_front(); + state->pendingBytes -= g_variant_get_size(parameters); } const char *source = nullptr, *kind = nullptr; g_variant_get_child(parameters, 0, "&s", &source); @@ -100,17 +104,20 @@ bool ReaderEventQueue::start(GDBusConnection *connection, const ReaderEndpoint & bool ReaderEventQueue::submit(GVariant *parameters) { parameters = g_variant_ref_sink(parameters); + const size_t bytes = g_variant_get_size(parameters); if (state_) { std::lock_guard 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) { + if (!state_->stopping && state_->pending.size() < 128 && + bytes <= max_reader_event_bytes && bytes <= max_reader_queue_bytes - state_->pendingBytes) { state_->pending.push_back(parameters); + state_->pendingBytes += bytes; state_->changed.notify_one(); return true; } if (!state_->stopping && !state_->overflowReported) { - std::fprintf(stderr, "wine-access: reader event queue full; feedback dropped\n"); + std::fprintf(stderr, "wine-access: reader event size or queue limit reached; feedback dropped\n"); state_->overflowReported = true; } } diff --git a/review_capture.cpp b/review_capture.cpp index 01fb465..6c7440a 100644 --- a/review_capture.cpp +++ b/review_capture.cpp @@ -4,6 +4,7 @@ #include "native_query.h" #include "review_x11.h" #include "review_query.h" +#include "edit_hook.h" #include #include #include @@ -21,6 +22,7 @@ constexpr DWORD wine_virtual_desktop = 0x80000000; // Wine's import libraries do not export this IID as a Unix data symbol. constexpr GUID accessible_iid{0x618736e0,0x3c3d,0x11cf,{0x81,0x0c,0x00,0xaa,0x00,0x38,0x9b,0x71}}; constexpr size_t max_source = 1000000, max_text = 1048576, max_nodes = 4096; +constexpr size_t max_unhooked_text = 16384; std::string utf8(const WCHAR *text, size_t length) { if (!length) return {}; int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text, @@ -56,8 +58,46 @@ bool remote_query(HWND window, UINT message, WPARAM wp, LPARAM lp, return GetTickCount64() < deadline; } bool password(HWND window); +bool read_hooked_edit(HWND window, uint64_t deadline, size_t limit, + std::vector &text, bool &limited) { + DWORD_PTR answer = 0; + const UINT message = RegisterWindowMessageW(EDIT_REVIEW_MESSAGE); + // The foreground thread's message hook can attach an output editor that + // has never received keyboard focus. First send a scalar attachment probe; + // the following query then enters the newly installed subclass reliably. + if (!GetPropW(window, EDIT_REVIEW_PROPERTY) && + (!query(window, message, 0, 0, deadline, answer) || + !GetPropW(window, EDIT_REVIEW_PROPERTY))) return false; + const size_t offset = offsetof(WineAccessEditText, text); + const size_t bytes = offset + (limit + 1) * sizeof(WCHAR); + native_control::RemoteBuffer remote(window, bytes); + if (!remote) return false; + WineAccessEditText request{}; + request.capacity = limit + 1; + if (!remote.write(&request, offset) || + !remote_query(window, message, 0, + remote.address(), deadline, answer) || + !remote.read(&request, offset) || password(window)) return false; + if (request.status == 2) { limited = true; return false; } + if (request.status != 1 || request.copied > limit) return false; + text.assign(request.copied + 1, 0); + if (!remote.read(text.data(), text.size() * sizeof(WCHAR), offset) || text.back()) return false; + text.resize(request.copied); + return true; +} bool read_text(HWND window, uint64_t deadline, size_t limit, std::vector &text, bool &limited) { + if (password(window)) return false; + if (GetPropW(window, EDIT_REVIEW_PROPERTY)) + return read_hooked_edit(window, deadline, limit, text, limited); + WCHAR class_name[128]{}; + if (GetClassNameW(window, class_name, 128) && wine_access_class_matches(class_name, L"Edit")) { + if (read_hooked_edit(window, deadline, limit, text, limited)) return true; + if (limited || GetPropW(window, EDIT_REVIEW_PROPERTY)) return false; + } + // Unhooked/older editors cannot service the safe native query. Keep their + // ordinary WM_GETTEXT requests far below Wine's callback-stack limit. + limit = std::min(limit, max_unhooked_text); DWORD_PTR size = 0, copied = 0; if (password(window) || !query(window, WM_GETTEXTLENGTH, 0, 0, deadline, size) || password(window)) return false; if (size > limit) { limited = true; return false; } @@ -69,6 +109,23 @@ bool read_text(HWND window, uint64_t deadline, size_t limit, std::vector text.resize(size); return true; } +bool reread_text(HWND window, uint64_t deadline, size_t size, std::vector &text) { + if (password(window)) return false; + if (GetPropW(window, EDIT_REVIEW_PROPERTY)) { + bool limited = false; + return read_hooked_edit(window, deadline, size, text, limited); + } + if (size > max_unhooked_text) return false; + // The original source already supplies a safe capacity. Avoid another + // length/attachment round trip on frame-based applications. + text.assign(size + 1, 0); + DWORD_PTR copied = 0, after = 0; + if (!query(window, WM_GETTEXT, text.size(), reinterpret_cast(text.data()), deadline, copied) || + password(window) || !query(window, WM_GETTEXTLENGTH, 0, 0, deadline, after) || + password(window) || copied != size || after != size) return false; + text.resize(size); + return true; +} std::string caption(HWND window) { // A foreign window's caption is held by USER32. Sending WM_GETTEXT here // could deadlock against an editor hook currently sending WM_COPYDATA to @@ -815,17 +872,9 @@ ReviewCapture capture_review_window(ReviewIdentityStore &store, const ReviewFocu RECT bounds{}; if (password(read.window) || !IsWindowVisible(read.window) || !GetWindowRect(read.window, &bounds) || !EqualRect(&read.bounds, &bounds)) { capture.fail("stale"); break; } - if (read.text.size() > max_source - capture.source_units) { capture.fail("limited"); break; } - capture.source_units += read.text.size(); - // The first read already supplies a bounded capacity. Copy once, then - // verify length, content and protection without another preliminary - // length round trip (growth must still reject a truncated old prefix). - current.assign(read.text.size()+1,0); - DWORD_PTR copied=0,length=0; - if(!query(read.window,WM_GETTEXT,current.size(),reinterpret_cast(current.data()),deadline,copied) || - password(read.window) || !query(read.window,WM_GETTEXTLENGTH,0,0,deadline,length) || - password(read.window) || copied!=read.text.size() || length!=read.text.size())capture.fail("stale"); - current.resize(read.text.size()); + // This is a second copy of an already budgeted source, not another + // document. Charging it twice made large safe snapshots fail review. + if (!reread_text(read.window, deadline, read.text.size(), current)) capture.fail("stale"); if(current!=read.text)capture.fail("stale"); if (read.layout && !capture.failed) { EditLayout layout{}; diff --git a/tests/README.md b/tests/README.md index bea9742..cd49886 100644 --- a/tests/README.md +++ b/tests/README.md @@ -175,6 +175,41 @@ the affected Wine application as well as the helper. ## Reader reply stalls +Long-running text and native-query regressions are also covered by: + +```sh +xvfb-run -a ctest --test-dir build -R 'text-limits|review-capture|hook-safety' --output-on-failure +``` + +`text-limits` checks MSAA truncation, malformed padding, surrogate boundaries, +controller request limits and a private stalled reader. A burst of 100 large +events must reach the serialized byte budget before the 128-event count limit; +an oversized individual event is rejected without consuming queue capacity. + +The review fixtures run with 1 MiB PE stacks in both architectures. They review +a 600000-unit ANSI Edit through the real injected hook and cross-process native +query, preserving visible text without a large `WM_GETTEXT` message. That output +editor starts without a subclass and is attached by review without moving focus. +The ordinary ListView reader is delayed beyond its timeout: its buffer must +remain committed, further remote allocations must pause, and a later successful +read must recover. +The existing delayed capture query remains covered separately. + +The hook-safety fixtures also verify complete native review queries at 516000, +600000 and 1040000 UTF-16 units, and 2000 background transcript replacement, +selection and append cycles without full-text queries. Controller integration +checks that oversized speech, braille and SSML requests return an error without +reaching the recording reader. + +These are bounded regressions, not a multi-hour soak or proof of audible speech +and physical braille. For live acceptance, restart the helper and MUSHclient, +then check command history, typing, selection/deletion, incoming plugin output, +large-output review, menu return, password exclusion and focus restoration. +During a long session record RSS and CPU for MUSHclient, Wine, the helper, reader +and speech processes; distinguish continuing memory growth from allocator reuse. +If the machine locks up again, retain the previous boot's kernel log as well as +reader and Wine diagnostics. + Run sequentially on a private X display: ```sh diff --git a/tests/controller_fixture.c b/tests/controller_fixture.c index 9ff3e29..0be4be3 100644 --- a/tests/controller_fixture.c +++ b/tests/controller_fixture.c @@ -46,6 +46,14 @@ int main(int argc, char **argv) { // The reader's SSML status must reach the caller without conversion. failed |= speakSsml(L"Controller SSML", SYMBOL_LEVEL_ALL, SPEECH_PRIORITY_NORMAL, FALSE) != ERROR_CALL_NOT_IMPLEMENTED; + WCHAR *oversized = malloc(65538 * sizeof(WCHAR)); + if (!oversized) return 1; + for (unsigned i = 0; i < 65537; ++i) oversized[i] = L'x'; + oversized[65537] = 0; + failed |= speakText(oversized) != ERROR_INVALID_PARAMETER; + failed |= brailleMessage(oversized) != ERROR_INVALID_PARAMETER; + failed |= speakSsml(oversized, SYMBOL_LEVEL_ALL, SPEECH_PRIORITY_NORMAL, FALSE) != ERROR_INVALID_PARAMETER; + free(oversized); } RpcBindingFree(&binding); return failed; diff --git a/tests/hook_safety_fixture.c b/tests/hook_safety_fixture.c index 4644364..e3fbdb1 100644 --- a/tests/hook_safety_fixture.c +++ b/tests/hook_safety_fixture.c @@ -11,8 +11,9 @@ #define PROBE_DEPTH 16 static WNDPROC original, outer_original; static uintptr_t deepest, hook_stack; -static BOOL nested, destroy_on_set; +static BOOL nested, destroy_on_set, destroy_on_get, protect_on_length; static unsigned calls, forwarded_sets; +static unsigned text_queries; typedef struct EditStateProbe { WCHAR *text; @@ -33,6 +34,15 @@ static LRESULT CALLBACK passthrough(HWND window, UINT message, WPARAM wp, LPARAM static LRESULT CALLBACK application_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { volatile char marker = 0; ++calls; + if (message == WM_GETTEXT) ++text_queries; + if (message == WM_GETTEXTLENGTH && protect_on_length) { + protect_on_length = FALSE; + SendMessageW(window, EM_SETPASSWORDCHAR, '*', 0); + } + if (message == WM_GETTEXT && destroy_on_get) { + DestroyWindow(window); + return 0; + } if (message == PROBE_MESSAGE) { if (wp) return SendMessageA(window, PROBE_MESSAGE, wp - 1, 0); deepest = (uintptr_t)▮ @@ -134,6 +144,9 @@ int main(int argc, char **argv) { } largeText[largeLength] = 0; if (!SetWindowTextA(window, largeText)) { free(largeText); return 12; } + // Programmatic transcript replacement is lazy. Navigation/focus still + // obtains a complete snapshot through the stack-safe direct query. + SendMessageW(window, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0); EditStateProbe *state = (EditStateProbe *)GetPropW(window, EDIT_STATE_PROPERTY); if (!state || !state->text || state->length != largeLength || state->text[largeLength] != 0) { @@ -148,12 +161,53 @@ int main(int argc, char **argv) { return 13; } } + size_t bytes = offsetof(WineAccessEditText, text) + ((size_t)largeLength + 1) * sizeof(WCHAR); + WineAccessEditText *request = calloc(1, bytes); + if (!request) return 16; + request->capacity = largeLength + 1; + SendMessageW(window, RegisterWindowMessageW(EDIT_REVIEW_MESSAGE), 0, (LPARAM)request); + if (request->status != 1 || request->copied != largeLength || request->text[largeLength]) return 17; + for (DWORD i = 0; i < largeLength; ++i) + if (request->text[i] != (unsigned char)largeText[i]) return 18; + free(request); free(largeText); printf("large ANSI snapshot: %lu UTF-16 units\n", (unsigned long)state->length); } UnhookWindowsHookEx(text_hook); - // Destroy while the accessibility callback is busy; it must defer freeing - // its state until the application's callback has returned. + unsigned queries_before = text_queries; + for (unsigned i = 0; i < 2000; ++i) { + SetWindowTextA(window, "incoming transcript line"); + SendMessageW(window, EM_SETSEL, (WPARAM)-1, (LPARAM)-1); + SendMessageW(window, EM_REPLACESEL, FALSE, (LPARAM)L" another line"); + } + if (text_queries != queries_before) return 19; + printf("background transcript burst: no full-text reads for 2000 updates\n"); + SendMessageW(window, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0); + WineAccessEditText *private_query = calloc(1, offsetof(WineAccessEditText, text) + 64 * sizeof(WCHAR)); + if (!private_query) return 22; + private_query->capacity = 64; + protect_on_length = TRUE; + SendMessageW(window, RegisterWindowMessageW(EDIT_REVIEW_MESSAGE), 0, (LPARAM)private_query); + EditStateProbe *protected_state = (EditStateProbe *)GetPropW(window, EDIT_STATE_PROPERTY); + if (!protected_state || protected_state->text || protected_state->known || private_query->status) return 23; + free(private_query); + SendMessageW(window, EM_SETPASSWORDCHAR, 0, 0); + // Destroy while the native review callback is busy. Its state must survive + // until the application's query returns, then be freed exactly once. + destroy_on_get = TRUE; + WineAccessEditText *destroy_query = calloc(1, offsetof(WineAccessEditText, text) + 64 * sizeof(WCHAR)); + if (!destroy_query) return 20; + destroy_query->capacity = 64; + SendMessageW(window, RegisterWindowMessageW(EDIT_REVIEW_MESSAGE), 0, (LPARAM)destroy_query); + if (IsWindow(window) || destroy_query->status) return 21; + free(destroy_query); + destroy_on_get = FALSE; + // Also retain the application's destruction-during-replacement check. + window = CreateWindowA("Edit", "initial", WS_POPUP | ES_MULTILINE, + 0, 0, 200, 100, NULL, NULL, GetModuleHandleW(NULL), NULL); + original = (WNDPROC)SetWindowLongPtrA(window, GWLP_WNDPROC, (LONG_PTR)application_proc); + focus.hwnd = window; + callback(HC_ACTION, 0, (LPARAM)&focus); destroy_on_set = TRUE; SetWindowTextA(window, "destroy"); if (IsWindow(window)) return 7; diff --git a/tests/review_capture_test.cpp b/tests/review_capture_test.cpp index e016295..19aca85 100644 --- a/tests/review_capture_test.cpp +++ b/tests/review_capture_test.cpp @@ -5,6 +5,9 @@ #include #include #include +#include "../list_view.h" +#include "../native_query.h" +#include "../edit_hook.h" #if __has_include("../review_capture.h") #include "../review_capture.h" #define HAS_CAPTURE 1 @@ -173,6 +176,34 @@ int main(int argc, char **argv) { } if (mode != 4) ok &= check(SendMessageW(window, WM_APP + 11, 0, 0), "capture leaves caret and focus unchanged"); } + SendMessageW(window, WM_APP + 10, 34, 0); + auto started = GetTickCount64(); + auto delayed_row = read_list_view(GetDlgItem(window, 107)); + ok &= check(!delayed_row.available, "timed-out row is not cached as a successful read"); + ok &= check(GetTickCount64() - started < 400, "ordinary native read times out promptly"); + { + native_control::RemoteBuffer another(GetDlgItem(window,107), 128); + ok &= check(!another, "late native request prevents repeated remote allocations"); + } + Sleep(750); + ok &= check(SendMessageW(window,WM_APP + 15,0,0), "ordinary native read retains its remote buffer after timeout"); + MSG completion{}; + PeekMessageW(&completion,nullptr,0,0,PM_NOREMOVE); + { + native_control::RemoteBuffer recovered(GetDlgItem(window,107),128); + ok &= check(static_cast(recovered), "completed native request releases allocation backpressure"); + } + auto recovered_row = read_list_view(GetDlgItem(window,107)); + ok &= check(recovered_row.available && recovered_row.text.find("ready") != std::string::npos, + "ordinary native text recovers after a late reply"); + SendMessageW(window, WM_APP + 10, 35, 0); + ok &= check(GetPropW(GetDlgItem(window,115),EDIT_REVIEW_PROPERTY) == nullptr, + "large output starts without a focus-installed subclass"); + auto large_capture = capture(); + ok &= check(large_capture.status == "ok" && joined(large_capture).find("aaaa") != std::string::npos, + "600000-unit ANSI Edit reviews safely through a real hook on a 1 MiB stack"); + ok &= check(GetPropW(GetDlgItem(window,115),EDIT_REVIEW_PROPERTY) != nullptr, + "large review exercised the native Edit query"); // A local provider avoids cross-process WM_GETTEXT marshalling, which // itself truncates at the first NUL. Its BSTR retains malformed padding. WNDCLASSW local{};local.hInstance=GetModuleHandleW(nullptr); diff --git a/tests/review_fixture.c b/tests/review_fixture.c index 10a960d..18439ff 100644 --- a/tests/review_fixture.c +++ b/tests/review_fixture.c @@ -3,6 +3,7 @@ #include #include #include +#include "../edit_hook.h" #define REVIEW_CONFIG (WM_APP + 10) #define REVIEW_CHECK (WM_APP + 11) @@ -11,6 +12,8 @@ #define REVIEW_PRIVACY_CHECK (WM_APP + 14) #define REVIEW_REMOTE_CHECK (WM_APP + 15) static HWND editor, output, password, hidden; +static HWND ansi_editor; +static HHOOK edit_hook; static HWND mdi, mdi_active, mdi_background; static WCHAR *large; static WNDPROC original_edit; @@ -113,6 +116,8 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM frame_pump=FALSE; delay_batch=FALSE; synthetic_containers=0; + ShowWindow(ansi_editor, SW_HIDE); + ShowWindow(editor, SW_SHOW); ShowWindow(GetDlgItem(window,112),SW_HIDE); ShowWindow(GetDlgItem(window,114),SW_HIDE); ShowWindow(mdi,SW_HIDE); @@ -134,6 +139,34 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM SendMessageW(editor, EM_SETSEL, 0, 0); MoveWindow(editor, 20, 35, 320, 90, TRUE); switch (wp) { + case 35: { + WCHAR path[MAX_PATH]; + GetModuleFileNameW(NULL, path, MAX_PATH); + WCHAR *separator = path; + for (WCHAR *p = path; *p; ++p) if (*p == '\\' || *p == '/') separator = p + 1; +#ifdef _WIN64 + lstrcpyW(separator, L"wine-access-menu.dll"); +#else + lstrcpyW(separator, L"wine-access-menu32.dll"); +#endif + HMODULE module = LoadLibraryW(path); + HOOKPROC callback = module ? (HOOKPROC)GetProcAddress(module, "WineAccessMenuHook") : NULL; + if (!callback) ExitProcess(6); + edit_hook = SetWindowsHookExW(WH_CALLWNDPROC, callback, module, GetCurrentThreadId()); + if (!edit_hook) ExitProcess(7); + char *text = malloc(600001); + if (!text) ExitProcess(8); + for (unsigned i = 0; i < 600000; ++i) text[i] = i % 80 == 79 ? '\n' : 'a'; + text[600000] = 0; + ShowWindow(editor, SW_HIDE); + ShowWindow(ansi_editor, SW_SHOW); + SetFocus(output); // The large output has never received focus. + SendMessageA(ansi_editor, EM_LIMITTEXT, 2000000, 0); + SetWindowTextA(ansi_editor, text); + free(text); + SendMessageW(ansi_editor, EM_SETSEL, 0, 0); + return 1; + } case 33: ShowWindow(GetDlgItem(window,114),SW_SHOW); break; case 32: { WCHAR text[16384]; unsigned used = 0; @@ -222,7 +255,7 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM case 15: replace_list_during_read = TRUE; break; case 17: ShowWindow(GetDlgItem(window,107),SW_SHOW); break; case 18: ShowWindow(GetDlgItem(window,107),SW_SHOW); replace_table_during_read=TRUE; break; - case 21: ShowWindow(GetDlgItem(window,107),SW_SHOW); delay_table_read=TRUE; break; + case 21: case 34: ShowWindow(GetDlgItem(window,107),SW_SHOW); delay_table_read=TRUE; break; case 22: ShowWindow(GetDlgItem(window,109),SW_SHOW); SendMessageW(GetDlgItem(window,109),EM_SETSEL,INT_MAX,INT_MAX); @@ -289,6 +322,8 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) editor = CreateWindowW(L"Edit", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_BORDER | ES_MULTILINE | ES_AUTOVSCROLL | WS_VSCROLL, 20, 35, 320, 90, window, (HMENU)101, instance, NULL); original_edit = (WNDPROC)SetWindowLongPtrW(editor, GWLP_WNDPROC, (LONG_PTR)edit_proc); + ansi_editor = CreateWindowA("Edit", "", WS_CHILD | WS_BORDER | ES_MULTILINE | ES_AUTOVSCROLL | WS_VSCROLL, + 20, 35, 320, 90, window, (HMENU)115, instance, NULL); output = CreateWindowW(L"Edit", L"output first\r\noutput second", WS_CHILD | WS_VISIBLE | ES_MULTILINE | ES_READONLY | WS_BORDER, 20, 205, 320, 80, window, (HMENU)102, instance, NULL); password = CreateWindowW(L"Edit", L"private-review-secret", WS_CHILD | WS_VISIBLE | @@ -349,5 +384,6 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) } else if (GetMessageW(&message,NULL,0,0)<=0)break; TranslateMessage(&message); DispatchMessageW(&message); } + if (edit_hook) UnhookWindowsHookEx(edit_hook); return 0; } diff --git a/tests/text_limits_test.cpp b/tests/text_limits_test.cpp new file mode 100644 index 0000000..219c128 --- /dev/null +++ b/tests/text_limits_test.cpp @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "../text_limits.h" +#include "../reader_events.h" +#include +#include + +static bool check(bool value, const char *message) { + if (!value) std::fprintf(stderr, "FAIL: %s\n", message); + return value; +} + +static GVariant *event(const std::string &text, const char *kind = "value") { + return g_variant_new("(sssssssiis)", "source", kind, text.c_str(), "edit", "", "", "", 0, 0, "test"); +} + +int main() { + bool ok = true; + std::vector large(max_controller_units + 2, L'a'); + large.back() = 0; + ok &= check(!controller_text_fits(large.data()), "oversized controller input rejected"); + large[max_controller_units] = 0; + ok &= check(controller_text_fits(large.data()), "maximum controller input accepted"); + ok &= check(bounded_accessible_text(large.data(), large.size()).size() == max_accessible_units, + "large accessible value is bounded before UTF-8 allocation"); + large[max_accessible_units - 1] = 0xd800; + large[max_accessible_units] = 0xdc00; + ok &= check(bounded_accessible_text(large.data(), large.size()).size() == max_accessible_units - 1, + "truncation does not split a surrogate pair"); + const WCHAR padded[] = {L'o', L'k', 0, 0xd800}; + ok &= check(bounded_accessible_text(padded, 4) == "ok", "malformed BSTR padding excluded"); + + GError *error = nullptr; + auto *connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); + if (!connection) { + std::fprintf(stderr, "FAIL: private session bus: %s\n", error ? error->message : "unavailable"); + if (error) g_error_free(error); + return 1; + } + const char *xml = "" + "" + "" + "" + "" + ""; + auto *info = g_dbus_node_info_new_for_xml(xml, nullptr); + static const GDBusInterfaceVTable table{ + [](GDBusConnection *, const gchar *, const gchar *, const gchar *, const gchar *, + GVariant *, GDBusMethodInvocation *invocation, gpointer) { + g_dbus_method_invocation_return_value(invocation, g_variant_new("(b)", TRUE)); + }, nullptr, nullptr, {nullptr}}; + guint object = g_dbus_connection_register_object(connection, "/test", info->interfaces[0], + &table, nullptr, nullptr, nullptr); + ok &= check(object != 0, "recording reader object registered"); + ReaderEndpoint endpoint{g_dbus_connection_get_unique_name(connection), "/test", "org.stormux.LimitTest"}; + ReaderEventQueue queue; + ok &= check(queue.start(connection, endpoint), "reader queue starts"); + ok &= check(queue.submit(event("focus", "focus")), "focus dependency accepted"); + // Do not dispatch this connection's default GLib context: its first method + // call withholds the reply while the event thread fills the pending queue. + Sleep(20); + ok &= check(!queue.submit(event(std::string(max_reader_event_bytes, 'x'))), + "oversized single event rejected"); + const std::string payload(100000, 'x'); + unsigned accepted = 0; + auto started = GetTickCount64(); + for (unsigned i = 0; i < 100; ++i) accepted += queue.submit(event(payload)); + ok &= check(GetTickCount64() - started < 400, "flood completed before reader reply timeout"); + ok &= check(accepted == 10, "byte budget rejects large events before the 128-event count limit"); + queue.stop(); + if (object) g_dbus_connection_unregister_object(connection, object); + g_dbus_node_info_unref(info); + g_object_unref(connection); + if (ok) std::puts("PASS: bounded MSAA/controller text, Unicode boundaries and reader queue byte budget"); + return ok ? 0 : 1; +} diff --git a/text_limits.h b/text_limits.h new file mode 100644 index 0000000..a34b001 --- /dev/null +++ b/text_limits.h @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include +#include +#include + +constexpr size_t max_accessible_units = 4096; +constexpr size_t max_controller_units = 65536; +constexpr size_t max_reader_event_bytes = 131072; +constexpr size_t max_reader_queue_bytes = 1048576; + +inline std::string bounded_accessible_text(const WCHAR *text, size_t length) { + if (!text) return {}; + length = std::min(length, max_accessible_units); + // MSAA strings can include padding past their first terminating NUL. + size_t used = 0; + while (used < length && text[used]) ++used; + if (used && text[used - 1] >= 0xd800 && text[used - 1] <= 0xdbff) --used; + if (!used) return {}; + int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text, + static_cast(used), nullptr, 0, nullptr, nullptr); + if (size <= 0) return {}; + std::string result(size, '\0'); + WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text, + static_cast(used), result.data(), size, nullptr, nullptr); + return result; +} + +inline bool controller_text_fits(const WCHAR *text) { + if (!text) return false; + for (size_t i = 0; i <= max_controller_units; ++i) + if (!text[i]) return true; + return false; +}