diff --git a/.github/workflows/testAndPublish.yml b/.github/workflows/testAndPublish.yml index 6d6669c2b30..c1f48e1e66a 100644 --- a/.github/workflows/testAndPublish.yml +++ b/.github/workflows/testAndPublish.yml @@ -341,6 +341,27 @@ jobs: annotate_only: true report_paths: testOutput/unit/unitTests.xml + cxxUnitTests: + name: Run C++ unit tests + runs-on: ${{ needs.matrix.outputs.defaultRunner }} + needs: matrix + permissions: + contents: read + steps: + - name: Checkout NVDA + # A bare checkout suffices: the C++ tests only need the Visual Studio + # toolchain and Windows SDK on the runner, not submodules or a built tree. + uses: actions/checkout@v6 + - name: Build and run C++ unit tests + shell: cmd + run: runcxxtests.bat + - name: Upload C++ unit test results + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: Cxx unit tests results + path: testOutput/cxx/ + crowdinUpload: name: Upload translations to Crowdin runs-on: ${{ needs.matrix.outputs.defaultRunner }} @@ -615,7 +636,7 @@ jobs: name: Check if all tests pass runs-on: ubuntu-latest if: always() - needs: [buildNVDA, typeCheckPyright, typeCheckTy, checkPo, checkPot, licenseCheck, unitTests, createLauncher, systemTests, createSymbols] + needs: [buildNVDA, typeCheckPyright, typeCheckTy, checkPo, checkPot, licenseCheck, unitTests, cxxUnitTests, createLauncher, systemTests, createSymbols] permissions: contents: read steps: diff --git a/nvdaHelper/archBuild_sconscript b/nvdaHelper/archBuild_sconscript index e8415492c77..aeb682617f4 100644 --- a/nvdaHelper/archBuild_sconscript +++ b/nvdaHelper/archBuild_sconscript @@ -248,6 +248,10 @@ if isNVDAHelperLocalArch: if signExec: env.AddPostAction(UIARemoteLib[0], [signExec]) env.Install(libInstallDir, UIARemoteLib) + winEventLimiterDll = env.SConscript("winEventLimiter/dll/sconscript") + if signExec: + env.AddPostAction(winEventLimiterDll[0], [signExec]) + env.Install(libInstallDir, winEventLimiterDll) clientLib = env.SConscript("client/sconscript") Export("clientLib") diff --git a/nvdaHelper/winEventLimiter/.gitignore b/nvdaHelper/winEventLimiter/.gitignore new file mode 100644 index 00000000000..a6e9c343179 --- /dev/null +++ b/nvdaHelper/winEventLimiter/.gitignore @@ -0,0 +1,12 @@ +# Visual Studio state and build directories specific to this solution. +.vs/ +x64/ +x86/ +[Dd]ebug/ +[Rr]elease/ +ipch/ +[Tt]est[Rr]esults/ +*.suo +*.user +*.VC.db +*.VC.VC.opendb diff --git a/nvdaHelper/winEventLimiter/dll/dllmain.cpp b/nvdaHelper/winEventLimiter/dll/dllmain.cpp new file mode 100644 index 00000000000..143f2a47f8f --- /dev/null +++ b/nvdaHelper/winEventLimiter/dll/dllmain.cpp @@ -0,0 +1,271 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#include "stdafx.h" + +BOOL APIENTRY DllMain( HMODULE hModule, + DWORD ul_reason_for_call, + LPVOID lpReserved + ) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + case DLL_PROCESS_DETACH: + break; + } + return TRUE; +}; + +#include +#include +#include +#include + +#include "winEventLimiterDll.h" +#include +#include +#include +#include +#include + +// These holders are reset on winEventLimiter_stop. Keeping the empty holders +// alive at process exit avoids running thread-owning destructors under the loader lock +// when a host terminates without calling winEventLimiter_stop. +std::unique_ptr& g_doubleBuffer = + *new std::unique_ptr(); +std::unique_ptr& g_limiterThread = + *new std::unique_ptr(); +std::unique_ptr& g_eventNotifier = + *new std::unique_ptr(); +EventBuffer g_out; +EventBuffer g_destroyOut; + +namespace { + +std::mutex g_stateMutex; +bool g_acceptsAddedEvents = false; + +// Tear down all module state so that a future winEventLimiter_start starts from scratch. +// Only call once both threads have been stopped (or never started). +void _resetGlobals() { + // MessagePumpThread's own singleton reference is its single owner. + MessagePumpThread::ResetInstance(); + { + std::scoped_lock lock(g_stateMutex); + g_limiterThread.reset(); + g_acceptsAddedEvents = false; + g_doubleBuffer.reset(); + } + g_eventNotifier.reset(); + g_out.clear(); + g_destroyOut.clear(); +} + +unsigned int _copyEvents( + const EventBuffer& source, + unsigned int eventIndex, + unsigned int maxEvents, + EventData* data +) { + auto eventsReturned = 0u; + for (auto vecIndex = eventIndex; + vecIndex < source.size() && eventsReturned < maxEvents; + ++vecIndex, ++eventsReturned + ) { + data[eventsReturned] = source[vecIndex]; + } + return eventsReturned; +} + +} // namespace + +int winEventLimiter_start( + NotifyCB_T notifyOfNewEventsCallback, + IN const DWORD* eventIds, + IN unsigned int eventIdCount +) { + if (MessagePumpThread::GetExistingInstance()) { + return -1; // No consideration has been given to multiple messagePumps. + } + if (eventIds == nullptr || eventIdCount == 0) { + return -3; + } + int result; + try { + std::vector eventIdsToHook(eventIds, eventIds + eventIdCount); + g_doubleBuffer = std::make_unique(); + g_eventNotifier = std::make_unique( + std::function(notifyOfNewEventsCallback) + ); + { + std::scoped_lock lock(g_stateMutex); + g_limiterThread = std::make_unique(*g_eventNotifier, *g_doubleBuffer); + } + WriteBuffer& buf = *g_doubleBuffer; + auto messagePump = MessagePumpThread::GetInstance(buf, std::move(eventIdsToHook)); + result = messagePump->Start(); + } + catch (...) { + // E.g. a std::thread that could not start. Unwinding into ctypes would surface + // as SEH and skip the cleanup below, stranding whatever was already created. + result = -5; + } + if (result != 0) { + // Leave the process clean so the caller can fall back to in-process hooking. + // The pump thread was already joined by the failed Start. + if (g_eventNotifier) { + g_eventNotifier->Invalidate(); + } + if (g_limiterThread) { + g_limiterThread->Stop(); + } + _resetGlobals(); + } + else { + std::scoped_lock lock(g_stateMutex); + g_acceptsAddedEvents = true; + } + return result; +} + +int winEventLimiter_stop() { + auto ret = 0; + { + std::scoped_lock lock(g_stateMutex); + g_acceptsAddedEvents = false; + } + // Block the callback first, so the client never gets called back mid-teardown. + if (g_eventNotifier) { + g_eventNotifier->Invalidate(); + } + auto messagePump = MessagePumpThread::GetExistingInstance(); + if (!messagePump) { + ret |= 1; + } + else { + messagePump->Stop(); + } + if (!g_limiterThread) { + ret |= 2; + } + else { + g_limiterThread->Stop(); + } + _resetGlobals(); + return ret; +} + +int winEventLimiter_addEvent( + IN DWORD idEvent, + IN HWND hwnd, + IN LONG idObject, + IN LONG idChild, + IN DWORD dwEventThread +) { + std::scoped_lock lock(g_stateMutex); + if (!g_acceptsAddedEvents || !g_doubleBuffer) { + return -1; + } + const EventData event{ + idEvent, + hwnd, + idObject, + idChild, + dwEventThread, + GetTickCount(), + true, // Match direct insertion into the in-process limiter. + }; + g_doubleBuffer->Write(event); + return 0; +} + +int winEventLimiter_flushEvents() { + if (!g_limiterThread) { + g_out.clear(); + return 0; + } + bool deferred = false; + g_out = g_limiterThread->FlushEventLimiter(deferred); + return deferred ? 1 : 0; +} + +unsigned int +winEventLimiter_getEventCount() { + return static_cast(g_out.size()); +} + +unsigned int +winEventLimiter_getEvents( + IN unsigned int eventIndex, + IN unsigned int maxEvents, + OUT EventData * data +) { + return _copyEvents(g_out, eventIndex, maxEvents, data); +} + +void winEventLimiter_flushDestroyEvents() { + if (!g_limiterThread) { + g_destroyOut.clear(); + return; + } + g_destroyOut = g_limiterThread->TakeDestroyEvents(); +} + +unsigned int +winEventLimiter_getDestroyEventCount() { + return static_cast(g_destroyOut.size()); +} + +unsigned int +winEventLimiter_getDestroyEvents( + IN unsigned int eventIndex, + IN unsigned int maxEvents, + OUT EventData * data +) { + return _copyEvents(g_destroyOut, eventIndex, maxEvents, data); +} + +int winEventLimiter_takeLostDestroys() { + bool lost = false; + if (g_doubleBuffer) { + lost |= g_doubleBuffer->TakeLostDestroys(); + } + if (g_limiterThread) { + lost |= g_limiterThread->TakeLostDestroys(); + } + return lost ? 1 : 0; +} + +int winEventLimiter_setAlwaysAllowedObject( + IN HWND hwnd, + IN LONG idObject, + IN LONG idChild +) { + if (!g_limiterThread) { + return -1; + } + g_limiterThread->SetAlwaysAllowedObject(hwnd, idObject, idChild); + return 0; +} + +DWORD winEventLimiter_getConsoleThreadID(IN HWND hwnd) { + std::scoped_lock lock(g_stateMutex); + if (!g_limiterThread) { + return 0; + } + return g_limiterThread->GetConsoleThreadID(hwnd); +} diff --git a/nvdaHelper/winEventLimiter/dll/sconscript b/nvdaHelper/winEventLimiter/dll/sconscript new file mode 100644 index 00000000000..d1e69b9cbfa --- /dev/null +++ b/nvdaHelper/winEventLimiter/dll/sconscript @@ -0,0 +1,35 @@ +### +# This file is a part of the NVDA project. +# URL: http://www.nvda-project.org/ +# Copyright 2019-2026 NV Access Limited, Bill Dengler +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2.0, as published by +# the Free Software Foundation. +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# This license can be found at: +# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +### + +Import( + [ + "env", + ] +) + +winEventLimiterLib = env.SConscript("../lib/sconscript") +winEventLimiterEnv = env.Clone() +winEventLimiterEnv.Prepend(CPPPATH=[".", "../lib"]) +winEventLimiterDll = winEventLimiterEnv.SharedLibrary( + target="winEventLimiter", + source=[ + "winEventLimiter.def", + "dllmain.cpp", + "stdafx.cpp", + ], + LIBS=["user32", "ole32", "oleaut32", winEventLimiterLib], +) + +env.Depends(winEventLimiterDll, winEventLimiterLib) +Return("winEventLimiterDll") diff --git a/nvdaHelper/winEventLimiter/dll/stdafx.cpp b/nvdaHelper/winEventLimiter/dll/stdafx.cpp new file mode 100644 index 00000000000..c914f5ff642 --- /dev/null +++ b/nvdaHelper/winEventLimiter/dll/stdafx.cpp @@ -0,0 +1,15 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#include "stdafx.h" diff --git a/nvdaHelper/winEventLimiter/dll/stdafx.h b/nvdaHelper/winEventLimiter/dll/stdafx.h new file mode 100644 index 00000000000..ab7e6e8aade --- /dev/null +++ b/nvdaHelper/winEventLimiter/dll/stdafx.h @@ -0,0 +1,25 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +// Precompiled header for the winEventLimiter dll shim. +// WIN32_LEAN_AND_MEAN and NOMINMAX must be defined before is first included, +// so every translation unit includes this header first. + +#pragma once + +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +#ifndef NOMINMAX +#define NOMINMAX // Use std::min / std::max +#endif +#include diff --git a/nvdaHelper/winEventLimiter/dll/winEventLimiter.def b/nvdaHelper/winEventLimiter/dll/winEventLimiter.def new file mode 100644 index 00000000000..32dc9f2c5c9 --- /dev/null +++ b/nvdaHelper/winEventLimiter/dll/winEventLimiter.def @@ -0,0 +1,14 @@ +LIBRARY WINEVENTLIMITER +EXPORTS + winEventLimiter_start + winEventLimiter_stop + winEventLimiter_addEvent + winEventLimiter_flushEvents + winEventLimiter_getEventCount + winEventLimiter_getEvents + winEventLimiter_flushDestroyEvents + winEventLimiter_getDestroyEventCount + winEventLimiter_getDestroyEvents + winEventLimiter_takeLostDestroys + winEventLimiter_setAlwaysAllowedObject + winEventLimiter_getConsoleThreadID diff --git a/nvdaHelper/winEventLimiter/dll/winEventLimiterDll.h b/nvdaHelper/winEventLimiter/dll/winEventLimiterDll.h new file mode 100644 index 00000000000..5962372d4b5 --- /dev/null +++ b/nvdaHelper/winEventLimiter/dll/winEventLimiterDll.h @@ -0,0 +1,99 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#pragma once + +#include +#include + +// The flush/event retrieval/setAlwaysAllowed functions below are not thread safe with +// respect to each other: NVDA calls them all from its main thread. +// winEventLimiter_addEvent and winEventLimiter_getConsoleThreadID are thread safe. +extern "C" { + +// Starts receiving and limiting winEvents. +// One hook is registered per entry of eventIds (which is copied before returning). +// Returns 0 on success, -1 if already started, -2 if no hook could be registered, +// -3 on invalid arguments, -4 if the pump's shutdown event could not be created, +// -5 on an internal failure (the module is left reset, like the other error paths). +int winEventLimiter_start( + NotifyCB_T notifyOfNewEventsCallback, + IN const DWORD* eventIds, + IN unsigned int eventIdCount +); + +int winEventLimiter_stop(); + +// Adds an event to the same input buffer as hooked winEvents, preserving their shared +// insertion order before limiting. The event time is captured by this function. +// Returns 0 on success, -1 if the limiter is not running. +int winEventLimiter_addEvent( + IN DWORD idEvent, + IN HWND hwnd, + IN LONG idObject, + IN LONG idChild, + IN DWORD dwEventThread +); + +// Snapshots the limited events for fetching. +// Returns 1 if the flush was deferred because a foreground change is still settling +// (#3831), or a destroy arrived after the client's destroy snapshot: nothing was +// flushed, another pump has already been requested via the notify callback, and the +// client should hold its whole cycle. Returns 0 otherwise. +int winEventLimiter_flushEvents(); + +unsigned int winEventLimiter_getEventCount(); + +unsigned int winEventLimiter_getEvents( + IN unsigned int eventIndex, + IN unsigned int maxEvents, + OUT EventData * data +); + +// Snapshots the pending destroy events for fetching, independently of the regular flush: +// processing a destroy may correct the client's focus, so the client should flush and +// process destroys before deciding this cycle's focused object and flushing the regular +// events. +void winEventLimiter_flushDestroyEvents(); + +// Destroy events snapshotted by the last winEventLimiter_flushDestroyEvents. They are +// delivered raw (no OBJID_CLIENT remap, no invalid window drop; see EventLimiterThread) +// and are never gated by the foreground defer. +unsigned int winEventLimiter_getDestroyEventCount(); + +unsigned int winEventLimiter_getDestroyEvents( + IN unsigned int eventIndex, + IN unsigned int maxEvents, + OUT EventData * data +); + +// Whether a destroy event has been discarded, or its ordering relative to regular +// events could not be preserved at the defer cap, since the last call. Reading clears +// the flag. +int winEventLimiter_takeLostDestroys(); + +// #11520: Exempts events for the given object from the per thread limit. +// All zeros clears the exemption. +// Returns 0 on success, -1 if the limiter is not running. +int winEventLimiter_setAlwaysAllowedObject( + IN HWND hwnd, + IN LONG idObject, + IN LONG idChild +); + +// #10554: The thread a ConsoleWindowClass window was really created in +// (as recorded from its winEvents), or 0 if unknown or not running. +DWORD winEventLimiter_getConsoleThreadID(IN HWND hwnd); + +} diff --git a/nvdaHelper/winEventLimiter/dll/winEventLimiterDll.vcxproj b/nvdaHelper/winEventLimiter/dll/winEventLimiterDll.vcxproj new file mode 100644 index 00000000000..a991657ba1a --- /dev/null +++ b/nvdaHelper/winEventLimiter/dll/winEventLimiterDll.vcxproj @@ -0,0 +1,110 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 18.0 + {FA4E9CA9-96CF-42B4-BA60-6525C8FB94A2} + Win32Proj + winEventLimiterDll + 10.0 + winEventLimiterDll + winEventLimiter + + + + DynamicLibrary + true + v145 + Unicode + + + DynamicLibrary + false + v145 + true + Unicode + + + + + + + + + + + + + + + true + + + false + + + + NotUsing + Level3 + Disabled + true + _WIN32_WINNT=_WIN32_WINNT_WIN10;_DEBUG;WINEVENTLIMITER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + stdcpp20 + $(SolutionDir)\lib;%(AdditionalIncludeDirectories) + + + Windows + true + winEventLimiter.def + $(OutDir);%(AdditionalLibraryDirectories) + winEventLimiterLib.lib;%(AdditionalDependencies) + + + + + NotUsing + Level3 + MaxSpeed + true + true + true + _WIN32_WINNT=_WIN32_WINNT_WIN10;NDEBUG;WINEVENTLIMITER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + stdcpp20 + $(SolutionDir)\lib;%(AdditionalIncludeDirectories) + + + Windows + true + true + true + winEventLimiter.def + $(OutDir);%(AdditionalLibraryDirectories) + winEventLimiterLib.lib;%(AdditionalDependencies) + + + + + + + + + + + + + + + + + diff --git a/nvdaHelper/winEventLimiter/dll/winEventLimiterDll.vcxproj.filters b/nvdaHelper/winEventLimiter/dll/winEventLimiterDll.vcxproj.filters new file mode 100644 index 00000000000..0ba3847193a --- /dev/null +++ b/nvdaHelper/winEventLimiter/dll/winEventLimiterDll.vcxproj.filters @@ -0,0 +1,34 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;hm;inl;inc;ipp;xsd + + + + + Header Files + + + Header Files + + + + + Source Files + + + Source Files + + + + + Header Files + + + \ No newline at end of file diff --git a/nvdaHelper/winEventLimiter/lib/deferredForegroundWindowHandler.cpp b/nvdaHelper/winEventLimiter/lib/deferredForegroundWindowHandler.cpp new file mode 100644 index 00000000000..3684bc35e50 --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/deferredForegroundWindowHandler.cpp @@ -0,0 +1,48 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#include "stdafx.h" +#include "deferredForegroundWindowHandler.h" + +void DeferredForegroundWindowEventHandler::Track(EventData & e) { + if (e.idEvent == EVENT_SYSTEM_FOREGROUND) { + // #3831: Event handling can be deferred if Windows takes a while to change the foreground window. + // See pumpAll for details. + m_deferUntilForegroundWindow = { e.hwnd, 0 }; + } +} + +bool DeferredForegroundWindowEventHandler::ShouldDeferEvents() +{ + auto& deferForHwnd = m_deferUntilForegroundWindow; + if (deferForHwnd.has_value()) { + // #3831: Sometimes, a foreground event is fired, + // but GetForegroundWindow() takes a short while to return this new foreground. + const bool canDeferAgain = deferForHwnd->deferCount < MAX_FOREGROUND_DEFERS; + const bool isWindowInForeground = GetForegroundWindow() == deferForHwnd->hwnd; + + if (canDeferAgain && !isWindowInForeground) { + ++deferForHwnd->deferCount; + return true; + } + else { + // Either the foreground window is now correct + // or we've already had the maximum number of defers. + // (Sometimes, foreground events are fired even when the foreground hasn't actually changed.) + deferForHwnd.reset(); + return false; + } + } + return false; +} diff --git a/nvdaHelper/winEventLimiter/lib/deferredForegroundWindowHandler.h b/nvdaHelper/winEventLimiter/lib/deferredForegroundWindowHandler.h new file mode 100644 index 00000000000..0322f97a0fe --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/deferredForegroundWindowHandler.h @@ -0,0 +1,45 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#pragma once +#include "stdafx.h" +#include +#include "eventData.h" + +// #3831: After a foreground winEvent is fired, GetForegroundWindow() can take a short +// while to actually return the new foreground window. Defer flushing events for up to +// this many pumps until the OS agrees the tracked window is in the foreground, giving it +// time to update. (Sometimes foreground events fire when the foreground never changes, +// hence the cap.) Mirrors internalWinEventHandler._shouldGetEvents in NVDA's in-process +// implementation. +constexpr unsigned int MAX_FOREGROUND_DEFERS = 2; + +// Not internally synchronized: both callers already hold EventLimiterThread's +// m_eventLimiterMutex, which #3831 requires anyway (Track must be atomic with the +// defer check). +class DeferredForegroundWindowEventHandler { + +public: + struct DeferUntilHwndForeground { + HWND hwnd; + unsigned int deferCount; + }; + + void Track(EventData& e); + + bool ShouldDeferEvents(); + +private: + std::optional m_deferUntilForegroundWindow; +}; diff --git a/nvdaHelper/winEventLimiter/lib/eventBufferLimits.h b/nvdaHelper/winEventLimiter/lib/eventBufferLimits.h new file mode 100644 index 00000000000..7ff5364327c --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/eventBufferLimits.h @@ -0,0 +1,74 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2026 Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#pragma once + +#include + +#include "eventData.h" + +namespace eventBufferLimits { + +// All queues use the same bound so an event flood has a consistent retention policy +// at every stage of the external limiter pipeline. +inline constexpr EventBuffer::size_type MAX_BUFFERED_EVENTS = 2000; + +class EventDropBudget { +public: + EventDropBudget( + EventBuffer::size_type expendableEvents, + EventBuffer::size_type preferredEvents + ) + : m_expendableEvents(expendableEvents) + , m_preferredEvents(preferredEvents) + { + } + + bool ShouldDrop(bool isExpendable) { + EventBuffer::size_type& budget = isExpendable + ? m_expendableEvents + : m_preferredEvents; + if (budget == 0) { + return false; + } + --budget; + return true; + } + +private: + EventBuffer::size_type m_expendableEvents; + EventBuffer::size_type m_preferredEvents; +}; + +template +EventDropBudget CalculateDropBudget( + Iterator first, + Iterator last, + EventBuffer::size_type eventsToDrop, + IsExpendable isExpendable +) { + const EventBuffer::size_type expendableCount = static_cast( + std::count_if(first, last, isExpendable) + ); + const EventBuffer::size_type expendableToDrop = std::min( + expendableCount, + eventsToDrop + ); + return EventDropBudget( + expendableToDrop, + eventsToDrop - expendableToDrop + ); +} + +} // namespace eventBufferLimits diff --git a/nvdaHelper/winEventLimiter/lib/eventData.h b/nvdaHelper/winEventLimiter/lib/eventData.h new file mode 100644 index 00000000000..f88981e875e --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/eventData.h @@ -0,0 +1,46 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#pragma once +#include "stdafx.h" +#include +#include + +struct EventData { + DWORD idEvent; + HWND hwnd; + LONG idObject; + LONG idChild; + DWORD dwEventThread; + DWORD dwmsEventTime; + // Events explicitly injected by NVDA have already been selected for delivery and + // must match the in-process limiter, which does not apply hook-event preprocessing. + bool bypassesPreprocessing = false; +}; +using EventBuffer = std::vector; + +// The focused object (#11520): its events are exempt from the per thread limit and are +// preferred survivors of the overflow trims. All consumers treat all zeros as cleared. +struct AlwaysAllowedObject { + HWND hwnd; + LONG idObject; + LONG idChild; +}; + +bool operator == (const EventData& lhs, const EventData & rhs); + +// Hasher for EventData, consistent with operator==: dwmsEventTime is deliberately excluded. +struct EventDataHasher { + std::size_t operator()(const EventData& e) const noexcept; +}; diff --git a/nvdaHelper/winEventLimiter/lib/eventDoubleBuffer.cpp b/nvdaHelper/winEventLimiter/lib/eventDoubleBuffer.cpp new file mode 100644 index 00000000000..9ca6f78f72b --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/eventDoubleBuffer.cpp @@ -0,0 +1,165 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#include "stdafx.h" +#include "eventDoubleBuffer.h" + +#include + +EventDoubleBuffer::EventDoubleBuffer() + : m_doubleBuffer() + , m_writeBuffer(&m_doubleBuffer[0]) + , m_readBuffer(&m_doubleBuffer[1]) + , m_swapMutex() + , m_onWriteCond() + , m_shouldWaitToSwapBuffers(false) + , m_lostDestroys(false) +{ + constexpr unsigned int INITIAL_BUFF_SIZE = 200u; + m_readBuffer->reserve(INITIAL_BUFF_SIZE); + m_writeBuffer->reserve(INITIAL_BUFF_SIZE); +} + +void EventDoubleBuffer::ReleaseBlockingSwap() { + { // scope of lock + // The flag must be cleared while holding m_swapMutex: a waiter that has evaluated + // its predicate but not yet blocked would otherwise miss this notification and + // sleep forever (with no further Write to wake it during shutdown). + std::scoped_lock lock(m_swapMutex); + m_shouldWaitToSwapBuffers = false; + } // end lock + m_onWriteCond.notify_all(); +} + +void EventDoubleBuffer::MakeSwapBlock() { + std::scoped_lock lock(m_swapMutex); + m_shouldWaitToSwapBuffers = true; +} + +void EventDoubleBuffer::SwapEventBuffers() { + m_readBuffer->clear(); + { // scope of lock + std::unique_lock lock(m_swapMutex); + // to prevent churn, only swap if the writeBuffer has items + const bool waitForEvents = m_shouldWaitToSwapBuffers && m_writeBuffer->empty(); + if (waitForEvents) { + m_onWriteCond.wait( + lock, + [this]() { + return _isReadyToSwap(); + } + ); + } + if (!m_writeBuffer->empty()) { + std::swap(m_writeBuffer, m_readBuffer); + } + } // end lock +} + +void EventDoubleBuffer::Write(const EventData & e) { + {// scope of lock + std::unique_lock lock(m_swapMutex); + if (m_writeBuffer->size() >= MAX_BUFFERED_EVENTS) { + _dropOldestExpendableEvents(); + } + m_writeBuffer->emplace_back(e); // e invalidated + } // end lock + m_onWriteCond.notify_all(); +} + +// Caller must hold m_swapMutex. +void EventDoubleBuffer::_dropOldestExpendableEvents() { + // A sustained event flood has left the limiter thread too far behind for old generic + // events to remain useful. The trim therefore drops generic and raw menu events + // before focus, foreground, and destroy events. Losing these preferred events could + // leave NVDA with stale focus or miss a destroy correction. + // If there are too few expendable events to reach the target size, the trim must also + // drop preferred events. Otherwise, a flood of preferred events would keep the buffer + // above the overflow threshold, causing every subsequent write to scan and rebuild an + // ever-growing buffer without reducing it. + // Dropped destroy events are recoverable: TakeLostDestroys tells the client to treat + // every window as potentially destroyed. + const auto target = MAX_BUFFERED_EVENTS / 2; + const auto toDrop = m_writeBuffer->size() - target; + auto dropBudget = eventBufferLimits::CalculateDropBudget( + m_writeBuffer->cbegin(), + m_writeBuffer->cend(), + toDrop, + [this](const EventData& e) { return !_isProtectedFromOverflow(e); } + ); + EventBuffer kept; + kept.reserve(target); + for (const auto& e : *m_writeBuffer) { + if (dropBudget.ShouldDrop(!_isProtectedFromOverflow(e))) { + if (e.idEvent == EVENT_OBJECT_DESTROY) { + m_lostDestroys = true; + } + continue; + } + kept.push_back(e); + } + *m_writeBuffer = std::move(kept); +} + +bool EventDoubleBuffer::TakeLostDestroys() { + std::scoped_lock lock(m_swapMutex); + return std::exchange(m_lostDestroys, false); +} + +EventBuffer & EventDoubleBuffer::Read() { + return *m_readBuffer; +} + +void EventDoubleBuffer::SetAlwaysAllowedObject(HWND hwnd, LONG idObject, LONG idChild) { + std::scoped_lock lock(m_swapMutex); + if (hwnd == nullptr && idObject == 0 && idChild == 0) { + m_alwaysAllowedObject.reset(); + return; + } + m_alwaysAllowedObject = AlwaysAllowedObject{ hwnd, idObject, idChild }; +} + +bool EventDoubleBuffer::_isProtectedFromOverflow(const EventData& e) const { + // #2695: Focus and foreground events feed the limiter's focus cache (which, like the Python + // limiter, never silently drops them), and destroy events are diverted for NVDA's + // cache eviction and focus correction. The overflow trim prefers consuming generic + // events so these survive to reach those protections. Raw menu events remain + // expendable because the limiter coalesces them later; protecting the entire raw + // flood could otherwise displace a focus event before it reaches the limiter. + if (e.idEvent == EVENT_OBJECT_FOCUS + || e.idEvent == EVENT_SYSTEM_FOREGROUND + || e.idEvent == EVENT_OBJECT_DESTROY + ) { + return true; + } + // #11520: events for the always allowed (focused) object must survive too, or they + // would be lost before WinEventLimiter's exemption could see them. + if (!m_alwaysAllowedObject.has_value()) { + return false; + } + const auto& allowed = m_alwaysAllowedObject.value(); + // Events here are raw; apply the same objectID remap the limiter thread's + // preprocessing will (window objectIDs become OBJID_CLIENT), so this comparison + // matches what WinEventLimiter::_matchesAlwaysAllowed would later see. + const LONG idObject = (e.idObject == 0 && e.idChild == 0) ? OBJID_CLIENT : e.idObject; + return e.hwnd == allowed.hwnd + && idObject == allowed.idObject + && e.idChild == allowed.idChild; +} + +bool EventDoubleBuffer::_isReadyToSwap() { + const auto hasEvents = false == m_writeBuffer->empty(); + return hasEvents + || false == m_shouldWaitToSwapBuffers; +} diff --git a/nvdaHelper/winEventLimiter/lib/eventDoubleBuffer.h b/nvdaHelper/winEventLimiter/lib/eventDoubleBuffer.h new file mode 100644 index 00000000000..36050e16236 --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/eventDoubleBuffer.h @@ -0,0 +1,85 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#pragma once +#include "stdafx.h" +#include +#include +#include +#include +#include "eventBufferLimits.h" +#include "eventData.h" + +class WriteBuffer { +public: + virtual ~WriteBuffer() {} + virtual void Write(const EventData& e) = 0; +}; + +class EventDoubleBuffer : public WriteBuffer { +public: + EventDoubleBuffer(); + + void ReleaseBlockingSwap(); + + void MakeSwapBlock(); + + void SwapEventBuffers(); + + void Write(const EventData& e) override; + + EventBuffer& Read(); + + // #11520: The focused object, whose events the overflow trim prefers to keep; + // all zeros clears it. Thread safe. + void SetAlwaysAllowedObject(HWND hwnd, LONG idObject, LONG idChild); + + // Whether the overflow trim has discarded any destroy events since the last call; + // reading clears the flag. The client must then treat every window as potentially + // destroyed, since cache eviction can no longer rely on per window destroy events. + // Thread safe. + bool TakeLostDestroys(); + + // WinEvents can arrive faster than the limiter thread drains them; the write buffer + // must not grow without bound (the in-process implementation was implicitly bounded + // by the OS limit on the hooking thread's message queue). On reaching this size, the + // buffer is shrunk to half, discarding the oldest generic and raw menu events first; + // focus, foreground, destroy, and focused-object events are only dropped when the + // expendable events cannot cover the excess (destroy loss is then reported via + // TakeLostDestroys). + static constexpr EventBuffer::size_type MAX_BUFFERED_EVENTS = + eventBufferLimits::MAX_BUFFERED_EVENTS; + +private: + std::array m_doubleBuffer; + EventBuffer* m_writeBuffer; + EventBuffer* m_readBuffer; + std::mutex m_swapMutex; + std::condition_variable_any m_onWriteCond; + // Guarded by m_swapMutex (see ReleaseBlockingSwap for why a bare atomic is not enough). + bool m_shouldWaitToSwapBuffers; + // Guarded by m_swapMutex. + bool m_lostDestroys; + // Guarded by m_swapMutex. + std::optional m_alwaysAllowedObject; + + bool _isReadyToSwap(); + + // Shrinks the write buffer to half of MAX_BUFFERED_EVENTS, oldest and generic events + // first. Caller must hold m_swapMutex. + void _dropOldestExpendableEvents(); + + // Whether the trim must prefer keeping this event. Caller must hold m_swapMutex. + bool _isProtectedFromOverflow(const EventData& e) const; +}; diff --git a/nvdaHelper/winEventLimiter/lib/eventLimiterThread.cpp b/nvdaHelper/winEventLimiter/lib/eventLimiterThread.cpp new file mode 100644 index 00000000000..8a80b12ceab --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/eventLimiterThread.cpp @@ -0,0 +1,237 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#include "stdafx.h" +#include "eventLimiterThread.h" + +#include +#include + +#include "eventDoubleBuffer.h" +#include "eventNotifier.h" +#include "utils.h" +#include "internalConstants.h" + +namespace { + +bool isEventIn(const DWORD idEvent, const std::vector& eventIds) { + return std::find(eventIds.cbegin(), eventIds.cend(), idEvent) != eventIds.cend(); +} + +} // namespace + +EventLimiterThread::EventLimiterThread( + EventNotifier& eventNotifier, + EventDoubleBuffer& buffer +) + : m_in(buffer) + , m_eventNotifier(eventNotifier) + , m_continue(true) +{ + m_thread = std::thread([this]() { + _pushThroughEventLimiter(); + }); +} + +EventLimiterThread::~EventLimiterThread() { + // Safety net so destruction without a prior Stop() cannot std::terminate on a joinable + // thread. Joining here during DLL_PROCESS_DETACH could deadlock on the loader lock; + // the supported teardown remains winEventLimiter_stop() before unload. + Stop(); +} + +void EventLimiterThread::Stop() { + m_continue = false; + m_in.ReleaseBlockingSwap(); + if (m_thread.joinable()) { + m_thread.join(); + } +} + +// Main loop for this thread +void +EventLimiterThread::_pushThroughEventLimiter() { + m_in.MakeSwapBlock(); // block until new events available + while (m_continue) { + m_in.SwapEventBuffers(); + m_classNameCacheHwnd = nullptr; + bool shouldNotify = false; + bool addedFocusEvent = false; + for (auto& e : m_in.Read()) { + if (e.idEvent == EVENT_OBJECT_DESTROY) { + // Diverted before _preprocessEvent, deliberately raw: NVDA's + // processDestroyWinEvent needs the original objectID (#2695), and the + // invalid window drop must not apply to a window that is being destroyed. + std::scoped_lock destroyLock(m_destroyEventsMutex); + if (m_destroyEvents.size() >= MAX_BUFFERED_DESTROY_EVENTS) { + m_destroyEvents.erase( + m_destroyEvents.begin(), + m_destroyEvents.begin() + + static_cast(MAX_BUFFERED_DESTROY_EVENTS / 2) + ); + m_lostDestroys = true; + } + m_destroyEvents.push_back(e); + shouldNotify = true; + continue; + } + if (!e.bypassesPreprocessing && !_preprocessEvent(e)) continue; + // #3831: Track must be atomic with FlushEventLimiter's defer check (both under + // m_eventLimiterMutex), so a foreground event can never be flushed before it + // was set for deferral. + std::unique_lock lock(m_eventLimiterMutex); + if (!e.bypassesPreprocessing) { + m_deferredTracker.Track(e); + } + auto wasEventAdded = m_eventLimiter.AddEvent(e); + shouldNotify = wasEventAdded || shouldNotify; + // #14928: focus events should be pumped immediately rather than after a + // delay + addedFocusEvent = addedFocusEvent + || (wasEventAdded && e.idEvent == EVENT_OBJECT_FOCUS); + } + if (shouldNotify) { + m_eventNotifier.NotifyClientOfNewEvents(addedFocusEvent); + } + } +} + +bool EventLimiterThread::_preprocessEvent(EventData& e) { + // Keep this filter chain aligned with internalWinEventHandler.winEventCallback. + // Change window objIDs to client objIDs for better reporting of objects + if (e.idObject == 0 && e.idChild == 0) { + e.idObject = OBJID_CLIENT; + } + // Ignore events with invalid window handles + // TODO: this logic can be cleaned up, e should be const. + bool isWnd = IsWindow(e.hwnd); + if (e.hwnd == nullptr || (!isWnd && isEventIn(e.idEvent, VALID_EVENTS_FOR_NON_WINDOWS))) { + e.hwnd = GetDesktopWindow(); + } + else if (!isWnd) { + return false; + } + + if (e.hwnd != m_classNameCacheHwnd) { + m_classNameCache = _getClassName(e.hwnd); + m_classNameCacheHwnd = e.hwnd; + } + const std::wstring& windowClassName = m_classNameCache; + + // #11818: Excel produces UI Automation events which are proxied by Windows into MSAA + // winEvents. In certain builds of Excel 2016, responding to these can freeze NVDA + // for several seconds. NVDA doesn't need these MSAA events for its Excel support, + // so just ignore them early. + if (windowClassName == L"EXCEL7" && e.idObject > 0) { + return false; + } + + if (windowClassName == L"ConsoleWindowClass") { + // #10554: Windows forces the reported thread of console windows to match the + // thread of the first attached process, but NVDA needs the thread the window + // was really created in (inside conhost), e.g. to speak typed characters in + // the correct language. winEvents always carry the true thread ID, so record + // it here; NVDA fetches it via winEventLimiter_getConsoleThreadID. + std::scoped_lock lock(m_consoleThreadIDsMutex); + m_consoleThreadIDs[e.hwnd] = e.dwEventThread; + } + + // Modern IME candidate list windows fire menu events which confuse us + // and can't be used properly in conjunction with input composition support. + if (windowClassName == L"Microsoft.IME.UIManager.CandidateWindow.Host" + && isEventIn(e.idEvent, MENU_EVENTIDS) + ) { + return false; + } + + // We never want to see foreground events for the Program Manager or Shell(task bar) + if (e.idEvent == EVENT_SYSTEM_FOREGROUND + && UNWANTED_FOREGROUND_EVENTS.end() != std::find(UNWANTED_FOREGROUND_EVENTS.begin(), UNWANTED_FOREGROUND_EVENTS.end(), windowClassName) + ) { + return false; + } + + if (windowClassName == L"MSNHiddenWindowClass") { + // HACK : Events get fired by this window in Windows Live Messenger 2009 when it starts. + // If we send a WM_NULL to this window at this point(which happens in accessibleObjectFromEvent), Messenger will silently exit(#677). + // Therefore, completely ignore these events, which is useless to us anyway. + return false; + } + return true; +} + +DWORD EventLimiterThread::GetConsoleThreadID(HWND hwnd) { + std::scoped_lock lock(m_consoleThreadIDsMutex); + const auto itr = m_consoleThreadIDs.find(hwnd); + return itr != m_consoleThreadIDs.end() ? itr->second : 0; +} + + +void EventLimiterThread::SetAlwaysAllowedObject(HWND hwnd, LONG idObject, LONG idChild) { + { // scope of lock + std::unique_lock lock(m_eventLimiterMutex); + m_eventLimiter.SetAlwaysAllowedObject(hwnd, idObject, idChild); + } // end lock + // The double buffer prefers keeping this object's events when its overflow trim + // runs; it takes its own leaf mutex, never nested with m_eventLimiterMutex. + m_in.SetAlwaysAllowedObject(hwnd, idObject, idChild); +} + +EventBuffer +EventLimiterThread::FlushEventLimiter(bool& deferred) { + m_eventNotifier.ResetNotify(); + + deferred = false; + bool hasPendingDestroyEvents = false; + EventBuffer out; + { // scope of lock + // The destroy check and regular flush must be atomic. The limiter thread + // processes its input in order, so holding both locks ensures that a destroy + // cannot be queued while a later regular event enters this flush. + std::scoped_lock lock(m_eventLimiterMutex, m_destroyEventsMutex); + const bool shouldDeferForForeground = m_deferredTracker.ShouldDeferEvents(); + hasPendingDestroyEvents = !m_destroyEvents.empty(); + const bool shouldDeferForDestroy = hasPendingDestroyEvents + && m_destroyDefers < MAX_FOREGROUND_DEFERS; + m_destroyDefers = shouldDeferForDestroy ? m_destroyDefers + 1 : 0; + deferred = shouldDeferForForeground || shouldDeferForDestroy; + if (!deferred) { + if (hasPendingDestroyEvents) { + // A continuous stream of late destroys must not starve regular events, + // but those events may refer to an object whose destroy is still pending. + // Escalate to whole-cache recovery before allowing the capped flush. + m_lostDestroys = true; + } + out = m_eventLimiter.Flush(); + } + } // end lock + if (deferred || hasPendingDestroyEvents) { + // A late destroy still needs another pump when the defer cap lets regular + // events through. Otherwise ResetNotify can consume its only notification. + // The callback may call into Python, so it must run outside any lock. + m_eventNotifier.NotifyClientOfNewEvents(false); + } + return out; +} + +EventBuffer +EventLimiterThread::TakeDestroyEvents() { + std::scoped_lock lock(m_destroyEventsMutex); + return std::exchange(m_destroyEvents, EventBuffer()); +} + +bool EventLimiterThread::TakeLostDestroys() { + std::scoped_lock lock(m_destroyEventsMutex); + return std::exchange(m_lostDestroys, false); +} diff --git a/nvdaHelper/winEventLimiter/lib/eventLimiterThread.h b/nvdaHelper/winEventLimiter/lib/eventLimiterThread.h new file mode 100644 index 00000000000..400fae9a63b --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/eventLimiterThread.h @@ -0,0 +1,94 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#pragma once +#include "stdafx.h" +#include +#include +#include +#include +#include +#include "eventBufferLimits.h" +#include "winEventLimiter.h" +#include "deferredForegroundWindowHandler.h" + +class EventNotifier; +class EventDoubleBuffer; + +class EventLimiterThread +{ +public: + EventLimiterThread(EventNotifier& eventNotifier, EventDoubleBuffer& buffer); + ~EventLimiterThread(); + + void Stop(); + + // Prepares m_out for fetching events from NVDA. + // deferred is set when a foreground change is still settling (#3831), or when a + // destroy arrived after this cycle's TakeDestroyEvents: nothing was flushed, and + // another pump has already been requested via the notifier, so the client can hold + // its whole cycle and process the destroy before later regular events. + EventBuffer FlushEventLimiter(bool& deferred); + + // Hands over the destroy events collected since the last call, so NVDA can process + // them before this cycle's other events. Independent of the foreground defer. + EventBuffer TakeDestroyEvents(); + + // If NVDA's core stalls and stops fetching, the destroy queue must not grow without + // bound; on reaching this size, the oldest half is discarded and the loss is + // reported via TakeLostDestroys. + static constexpr EventBuffer::size_type MAX_BUFFERED_DESTROY_EVENTS = + eventBufferLimits::MAX_BUFFERED_EVENTS; + + // Whether the destroy queue has discarded an event, or a pending destroy reached the + // regular-event defer cap, since the last call; reading clears the flag. The client + // must then treat every window as potentially destroyed, since cache eviction can no + // longer rely on per-window destroy ordering. + bool TakeLostDestroys(); + + // Thread safe access to WinEventLimiter::SetAlwaysAllowedObject. + void SetAlwaysAllowedObject(HWND hwnd, LONG idObject, LONG idChild); + + // #10554: The thread a ConsoleWindowClass window was really created in + // (as recorded from its winEvents), or 0 if unknown. + DWORD GetConsoleThreadID(HWND hwnd); +private: + EventDoubleBuffer& m_in; + EventNotifier& m_eventNotifier; + DeferredForegroundWindowEventHandler m_deferredTracker; + WinEventLimiter m_eventLimiter; + std::mutex m_eventLimiterMutex; + EventBuffer m_destroyEvents; + std::mutex m_destroyEventsMutex; + // Guarded by m_destroyEventsMutex. + bool m_lostDestroys = false; + unsigned int m_destroyDefers = 0; + std::map m_consoleThreadIDs; + std::mutex m_consoleThreadIDsMutex; + // One-entry cache for _preprocessEvent's GetClassNameW calls: floods are typically + // bursts from one window. Limiter thread only; reset each batch (HWND reuse). + HWND m_classNameCacheHwnd = nullptr; + std::wstring m_classNameCache; + std::atomic m_continue; + std::thread m_thread; + + // Main thread loop + // - Swaps the read/write buffers in EventDoubleBuffer + // - Takes events from the read buffer feeding to the WinEventLimiter + void _pushThroughEventLimiter(); + + // Filters and transforms an event before it enters the limiter + // Returns false if the event should be dropped. + bool _preprocessEvent(EventData& e); +}; diff --git a/nvdaHelper/winEventLimiter/lib/eventNotifier.cpp b/nvdaHelper/winEventLimiter/lib/eventNotifier.cpp new file mode 100644 index 00000000000..58b9b5cf3f7 --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/eventNotifier.cpp @@ -0,0 +1,49 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#include "stdafx.h" +#include "eventNotifier.h" + +EventNotifier::EventNotifier(std::function notifyCB) + : m_notifyCB(notifyCB) + , m_hasBeenNotified(false) + , m_hasNotifiedFocus(false) + // Start valid so the callback fires until Invalidate() is called. + , m_commsValid(true) +{} + +void EventNotifier::NotifyClientOfNewEvents(bool includesFocusEvent) { + // don't allow the CB to be invalidated while calling. + std::scoped_lock lock(m_eventCBMutex); + // #14928: Notify at most once until the client flushes. A focus event still upgrades an + // already delivered notification once, so the client can upgrade a pending delayed + // pump to an immediate one. + bool shouldNotify = !m_hasBeenNotified.exchange(true); + if (includesFocusEvent && !m_hasNotifiedFocus.exchange(true)) { + shouldNotify = true; + } + if (m_commsValid && m_notifyCB && shouldNotify) { + m_notifyCB(includesFocusEvent ? 1 : 0); + } +} + +void EventNotifier::ResetNotify() { + m_hasBeenNotified = false; + m_hasNotifiedFocus = false; +} + +void EventNotifier::Invalidate() { + std::scoped_lock lock(m_eventCBMutex); + m_commsValid = false; +} diff --git a/nvdaHelper/winEventLimiter/lib/eventNotifier.h b/nvdaHelper/winEventLimiter/lib/eventNotifier.h new file mode 100644 index 00000000000..8694bf1337e --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/eventNotifier.h @@ -0,0 +1,40 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#pragma once +#include "stdafx.h" +#include +#include +#include + +// #14928: The int argument is non-zero when the new events include a focus event, +// so the client can pump immediately rather than after a delay. +using NotifyCB_T = void(int); + +class EventNotifier { +public: + EventNotifier(std::function notifyCB); + + void NotifyClientOfNewEvents(bool includesFocusEvent); + + void ResetNotify(); + + void Invalidate(); +private: + std::function m_notifyCB; + std::mutex m_eventCBMutex; + std::atomic m_hasBeenNotified; + std::atomic m_hasNotifiedFocus; + bool m_commsValid; +}; diff --git a/nvdaHelper/winEventLimiter/lib/internalConstants.h b/nvdaHelper/winEventLimiter/lib/internalConstants.h new file mode 100644 index 00000000000..33bc41de038 --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/internalConstants.h @@ -0,0 +1,42 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#pragma once + +#include "stdafx.h" +#include +#include + +inline const std::vector VALID_EVENTS_FOR_NON_WINDOWS({ + EVENT_SYSTEM_SWITCHSTART, + EVENT_SYSTEM_SWITCHEND, + EVENT_SYSTEM_MENUEND, + EVENT_SYSTEM_MENUPOPUPEND, + }); +inline const std::vector MENU_EVENTIDS({ + EVENT_SYSTEM_MENUSTART, + EVENT_SYSTEM_MENUEND, + EVENT_SYSTEM_MENUPOPUPSTART, + EVENT_SYSTEM_MENUPOPUPEND, + }); +inline const std::vector MENU_OBJECTS({ + OBJID_SYSMENU, + OBJID_MENU + }); + +// We never want to see foreground events for the Program Manager or Shell(task bar) +inline const std::vector UNWANTED_FOREGROUND_EVENTS({ + L"Progman", + L"Shell_TrayWnd", + }); diff --git a/nvdaHelper/winEventLimiter/lib/messagePump.cpp b/nvdaHelper/winEventLimiter/lib/messagePump.cpp new file mode 100644 index 00000000000..b1fa0d7b01a --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/messagePump.cpp @@ -0,0 +1,262 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#include "stdafx.h" +#include "messagePump.h" + +#include + +#include "eventDoubleBuffer.h" + + +// Deliberately retain the empty holder until process exit. A normal stop resets the +// contained pump; avoiding a static shared_ptr destructor prevents thread teardown +// under the loader lock if the host exits without calling winEventLimiter_stop. +std::shared_ptr& MessagePumpThread::s_instance = + *new std::shared_ptr(); + + +std::shared_ptr +MessagePumpThread::GetInstance(WriteBuffer & buffer, std::vector eventIds) { + if (!s_instance) { + s_instance = std::shared_ptr( + new MessagePumpThread(buffer, std::move(eventIds)) + ); + } + return s_instance; +} + +std::shared_ptr MessagePumpThread::GetExistingInstance() { + return s_instance; +} + +void MessagePumpThread::ResetInstance() { + s_instance.reset(); +} + +MessagePumpThread::MessagePumpThread(WriteBuffer& doubleBuffer, std::vector eventIds) + : m_shouldContinuePump(false) + , m_stopRequested(false) + , m_buffer(doubleBuffer) + , m_eventIds(std::move(eventIds)) + , m_thread() + , m_coInit(false) + , m_stopEvent(CreateEventW(nullptr, TRUE, FALSE, nullptr)) +{ } + +inline int MessagePumpThread::_start() { + // Not in thread + if (m_stopEvent == nullptr || !ResetEvent(m_stopEvent)) { + return -4; + } + m_stopRequested = false; + std::promise resultPromise; + std::future futureResult = resultPromise.get_future(); + m_thread = std::thread( + [this, &resultPromise]() { + _initialise(std::move(resultPromise)); // resultPromise no longer valid after here. + _doBlockingMessagePump(); // Blocks until _stop() is called + _shutdownMSAA(); + } + ); + futureResult.wait(); + const auto result = futureResult.get(); + if (result != 0) { + // Initialisation failed: the pump loop never runs, so the thread is already exiting. + m_thread.join(); + } + return result; +} + +inline int MessagePumpThread::_stop() { + // Not in thread. + m_stopRequested = true; + m_shouldContinuePump = false; // stop the message pump + if (!m_thread.joinable()) { + // Never started, or already joined by a failed _start(). + return 0; + } + SetEvent(m_stopEvent); + m_thread.join(); + return 0; +} + + +inline void MessagePumpThread::_doBlockingMessagePump() { + MSG msg = { 0 }; + while (m_shouldContinuePump) { + const DWORD waitResult = MsgWaitForMultipleObjectsEx( + 1, + &m_stopEvent, + INFINITE, + QS_ALLINPUT, + MWMO_INPUTAVAILABLE + ); + if (waitResult == WAIT_OBJECT_0) { + break; + } + if (waitResult != WAIT_OBJECT_0 + 1) { + // A transient wait failure must not silently remove all hooks. Yield before + // retrying so a persistent failure cannot create a busy loop. + Sleep(10); + continue; + } + while (m_shouldContinuePump && PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { + if (msg.message == WM_QUIT) { + continue; + } + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } +} + +inline void MessagePumpThread::_unhookWinEvents() { + for (const HWINEVENTHOOK hook : m_winEventHooks) { + UnhookWinEvent(hook); + } + m_winEventHooks.clear(); +} + +// Unhooks the winEvents and shuts down COM. +inline int MessagePumpThread::_shutdownMSAA() { + _unhookWinEvents(); + if (m_coInit) { + CoUninitialize(); + m_coInit = false; + } + return 0; +} + +inline void MessagePumpThread::_initialise(std::promise initResult) { + auto res = _initializeMSAA(); + if (res == 0) { + // Must be set before fulfilling the promise: once the caller observes success, + // a concurrent _stop() must not race with this write. + m_shouldContinuePump = true; + } + initResult.set_value(res); +} + +// Initializes COM and sets up the winEvent hooks. + +inline int MessagePumpThread::_initializeMSAA() +{ + const HRESULT hr = CoInitializeEx( + nullptr, + COINIT_MULTITHREADED | COINIT_SPEED_OVER_MEMORY + ); + // S_FALSE (already initialized) still requires a balancing CoUninitialize. + // On RPC_E_CHANGED_MODE the apartment is usable but not ours to uninitialize. + m_coInit = SUCCEEDED(hr); + // One hook per event ID, like NVDA's in-process registration: Windows then only + // delivers the requested events, so no per-event acceptance filtering is needed. + m_winEventHooks.reserve(m_eventIds.size()); + bool hookRegistrationFailed = false; + for (const auto eventId : m_eventIds) { + const auto hook = SetWinEventHook( + eventId, eventId, // Range of events: just this one. + nullptr, // Handle to DLL. + MessagePumpThread::_sHandleWinEvent, // The callback. + 0, 0, // Process and thread IDs of interest (0 = all) + WINEVENT_OUTOFCONTEXT // Flags. + ); + if (hook) { + m_winEventHooks.push_back(hook); + } + else { + hookRegistrationFailed = true; + } + } + const bool noHooksRegistered = m_winEventHooks.empty(); + if (noHooksRegistered || hookRegistrationFailed) { + _unhookWinEvents(); + if (m_coInit) { + CoUninitialize(); + m_coInit = false; + } + return -2; + } + return 0; +} + +inline void MessagePumpThread::_handleWinEvent(EventData & e) { + /* + Note, this function must be re-entrant and thread safe. + Multiple calls to this may happen simultaneously. + Keep this logic aligned with Python internalWinEventHandler.winEventCallback. + */ + // Ignore all object IDs from alert onwards(sound, nativeom etc) as we don't support them + if (e.idObject <= OBJID_ALERT) { + return; + } + // Ignore all locationChange events except ones for the caret + if (e.idEvent == EVENT_OBJECT_LOCATIONCHANGE + && e.idObject != OBJID_CARET) { + return; + } + // Destroy events also travel through the buffer (the limiter thread diverts them to a + // separate fetch queue), so this thread never blocks on the client's runtime (GIL). + m_buffer.Write(e); +} + +int MessagePumpThread::Start() { + return _start(); +} + +int MessagePumpThread::Stop() { + return _stop(); +} + +MessagePumpThread::~MessagePumpThread() { + // Safety net so destruction without a prior Stop() cannot std::terminate on a joinable + // thread. Joining here during DLL_PROCESS_DETACH could deadlock on the loader lock; + // the supported teardown remains winEventLimiter_stop() before unload. + _stop(); + if (m_stopEvent != nullptr) { + CloseHandle(m_stopEvent); + m_stopEvent = nullptr; + } +} + +void MessagePumpThread::_sHandleWinEvent( + HWINEVENTHOOK hook, DWORD idEvent, + HWND hwnd, LONG idObject, + LONG idChild, DWORD dwEventThread, + DWORD dwmsEventTime +) { + /* + Note, this function must be re-entrant and thread safe. + Multiple calls to this may happen simultaneously. + */ + // Copy the singleton reference. Teardown resets s_instance only after the pump thread + // (and with it these hooks) has been stopped, but be defensive anyway. + const auto instance = s_instance; + if (!instance) { + return; + } + if (instance->m_stopRequested) { + // Out-of-context callbacks are dispatched on the pump thread while it retrieves + // messages. Unhook here so an event flood cannot keep that retrieval call alive + // indefinitely after the stop event has been signalled. + instance->_unhookWinEvents(); + return; + } + EventData e{ + idEvent, hwnd, + idObject, idChild, + dwEventThread, dwmsEventTime + }; + instance->_handleWinEvent(e); +} diff --git a/nvdaHelper/winEventLimiter/lib/messagePump.h b/nvdaHelper/winEventLimiter/lib/messagePump.h new file mode 100644 index 00000000000..8cb87f8921d --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/messagePump.h @@ -0,0 +1,86 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#pragma once +#include "stdafx.h" +#include +#include +#include +#include +#include + +#include "eventData.h" + +class WriteBuffer; + +class MessagePumpThread { +public: + // Creates (or returns) the singleton pump, which hooks each of the given winEvent IDs. + static std::shared_ptr GetInstance( + WriteBuffer& buffer, + std::vector eventIds + ); + + // The current instance, or null if none; never creates one. + static std::shared_ptr GetExistingInstance(); + + // Drops the singleton reference so a future GetInstance creates a fresh pump. + // Only call once the pump has been stopped (or failed to start). + static void ResetInstance(); + + int Start(); + + int Stop(); + + ~MessagePumpThread(); +private: + // static vars + static std::shared_ptr& s_instance; + static void CALLBACK _sHandleWinEvent( + HWINEVENTHOOK hook, DWORD idEvent, HWND hwnd, + LONG idObject, LONG idChild, + DWORD dwEventThread, DWORD dwmsEventTime + ); + + // member vars + std::atomic m_shouldContinuePump; + std::atomic m_stopRequested; + WriteBuffer& m_buffer; + const std::vector m_eventIds; + std::vector m_winEventHooks; + std::thread m_thread; + bool m_coInit; + HANDLE m_stopEvent; + + MessagePumpThread(WriteBuffer& doubleBuffer, std::vector eventIds); + + int _start(); + + void _initialise(std::promise initResult); + + // Initializes COM and sets up the idEvent hook. + int _initializeMSAA(); + + void _doBlockingMessagePump(); + + void _handleWinEvent(EventData& e); + + int _stop(); + + // Must run on the pump thread, which registered the hooks. + void _unhookWinEvents(); + + // Unhooks the idEvent and shuts down COM. + int _shutdownMSAA(); +}; diff --git a/nvdaHelper/winEventLimiter/lib/sconscript b/nvdaHelper/winEventLimiter/lib/sconscript new file mode 100644 index 00000000000..30cb18cfca6 --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/sconscript @@ -0,0 +1,28 @@ +### +# This file is a part of the NVDA project. +# URL: http://www.nvda-project.org/ +# Copyright 2019-2026 NV Access Limited, Bill Dengler +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2.0, as published by +# the Free Software Foundation. +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# This license can be found at: +# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +### + +Import( + [ + "env", + ] +) +winEventLimiterLibEnv = env.Clone() +winEventLimiterLib = winEventLimiterLibEnv.StaticLibrary( + target="winEventLimiterLib", + # Keep production and MSBuild test builds in sync by compiling every source in + # this library directory. The vcxproj uses the equivalent *.cpp wildcard. + source=winEventLimiterLibEnv.Glob("*.cpp"), +) + +Return("winEventLimiterLib") diff --git a/nvdaHelper/winEventLimiter/lib/stdafx.cpp b/nvdaHelper/winEventLimiter/lib/stdafx.cpp new file mode 100644 index 00000000000..c914f5ff642 --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/stdafx.cpp @@ -0,0 +1,15 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#include "stdafx.h" diff --git a/nvdaHelper/winEventLimiter/lib/stdafx.h b/nvdaHelper/winEventLimiter/lib/stdafx.h new file mode 100644 index 00000000000..7140be94390 --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/stdafx.h @@ -0,0 +1,28 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +// Precompiled header for the winEventLimiter static library. +// WIN32_LEAN_AND_MEAN and NOMINMAX must be defined before is first included, +// so every translation unit includes this header first. + +#pragma once + +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +#ifndef NOMINMAX +#define NOMINMAX // Use std::min / std::max +#endif +#include + +#include // winEvent and OBJID constants, SetWinEventHook +#include // CoInitializeEx diff --git a/nvdaHelper/winEventLimiter/lib/utils.cpp b/nvdaHelper/winEventLimiter/lib/utils.cpp new file mode 100644 index 00000000000..276e2a2fc6b --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/utils.cpp @@ -0,0 +1,51 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#include "stdafx.h" +#include "utils.h" +#include "eventData.h" +#include +#include + +bool operator== (const EventData& lhs, const EventData& rhs) { + return ( + lhs.idEvent == rhs.idEvent + && lhs.hwnd == rhs.hwnd + && lhs.idObject == rhs.idObject + && lhs.idChild == rhs.idChild + && lhs.dwEventThread == rhs.dwEventThread + // do not compare dwmsEventTime meta information. + ); +} + +std::size_t EventDataHasher::operator()(const EventData& e) const noexcept { + // Combines the same fields operator== compares; dwmsEventTime is meta information. + std::size_t seed = std::hash{}(e.idEvent); + const auto combine = [&seed](std::size_t value) { + seed ^= value + static_cast(0x9e3779b97f4a7c15ull) + + (seed << 6) + (seed >> 2); + }; + combine(std::hash{}(e.hwnd)); + combine(std::hash{}(e.idObject)); + combine(std::hash{}(e.idChild)); + combine(std::hash{}(e.dwEventThread)); + return seed; +} + +std::wstring _getClassName(HWND hwnd) { + constexpr int BUF_SIZE = 512; + WCHAR buf[BUF_SIZE] = { 0 }; + GetClassNameW(hwnd, buf, BUF_SIZE); + return std::wstring(buf); +} diff --git a/nvdaHelper/winEventLimiter/lib/utils.h b/nvdaHelper/winEventLimiter/lib/utils.h new file mode 100644 index 00000000000..d36bdb0b995 --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/utils.h @@ -0,0 +1,21 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#pragma once + +#include "stdafx.h" + +#include + +std::wstring _getClassName(HWND hwnd); diff --git a/nvdaHelper/winEventLimiter/lib/winEventLimiter.cpp b/nvdaHelper/winEventLimiter/lib/winEventLimiter.cpp new file mode 100644 index 00000000000..2ce30aa5363 --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/winEventLimiter.cpp @@ -0,0 +1,316 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#include "stdafx.h" + +#include +#include +#include +#include "winEventLimiter.h" +#include "utils.h" +#include "internalConstants.h" + +// Flood protection: only the newest MAX_EVENTS_FOR_THREAD generic winEvents per source +// thread survive a flush. +// Complex web documents (e.g. Google applications in Firefox) and +// applications starting up can flood NVDA with far more show/hide/nameChange winEvents +// than it could ever usefully process, freezing NVDA for many seconds. +// Focus and foreground events, and the surviving menu event, are not counted. +const int WinEventLimiter::MAX_EVENTS_FOR_THREAD = 10; +// The number of focus changed events allowed to be queued; if more are added then the +// oldest focus event is removed to make space. +const int WinEventLimiter::MAX_FOCUS_ITEMS = 4; +struct cachedEvent { + EventData event; + bool valid; +}; + +WinEventLimiter::WinEventLimiter() + : m_events() + , m_liveEventIndices() + , m_focusEventIndices() + , m_lastMenuEvent() + , m_validEventCount(0) + , m_maxBufferSize(0) +{ +} + +WinEventLimiter::~WinEventLimiter() { +} + +bool WinEventLimiter::_preFilter(const EventData& e) { + const bool isMenuObject = std::find( + MENU_OBJECTS.cbegin(), MENU_OBJECTS.cend(), e.idObject + ) != MENU_OBJECTS.cend(); + if (e.idEvent == EVENT_OBJECT_FOCUS + && isMenuObject && e.idChild == 0) { + // This is a focus event on a menu bar itself, which is just silly. Ignore it. + return false; + } + return true; +} + +void WinEventLimiter::_invalidateMatching(const EventData& e) { + // At most one valid entry can match: duplicates are invalidated when added. + const auto itr = m_liveEventIndices.find(e); + if (itr != m_liveEventIndices.end()) { + _invalidateEventAt(itr->second); + } +} + +void WinEventLimiter::_addFocusEvent(VecSize_t index) { + m_focusEventIndices.push_back(index); + const auto numFocusEvents = m_focusEventIndices.size(); + if (numFocusEvents > MAX_FOCUS_ITEMS) { + const auto oldestFocusEventIndex = m_focusEventIndices.front(); + if (m_events[oldestFocusEventIndex].event.idEvent == EVENT_SYSTEM_FOREGROUND) { + // Like the Python limiter, where a foreground event lives in both the focus and + // generic caches, eviction from the focus cache demotes it to a generic event + // (now subject to the per thread limit) rather than dropping it: an application + // switch must still be announced after a burst of rejected focus events. + m_focusEventIndices.erase(m_focusEventIndices.begin()); + } + else { + // Also erases the index from m_focusEventIndices. + _invalidateEventAt(oldestFocusEventIndex); + } + } +} + +void WinEventLimiter::_invalidateEventAt(VecSize_t index, bool keepIdentity) { + auto& cached = m_events[index]; + if (!cached.valid) { + return; + } + cached.valid = false; + --m_validEventCount; + if (!keepIdentity) { + m_liveEventIndices.erase(cached.event); + } + // Keep focus and menu in sync, so stale entries can never consume the + // MAX_FOCUS_ITEMS budget (evicting still valid focus events) or exempt a dead index + // from the per thread limit. + const auto focusItr = std::find( + m_focusEventIndices.begin(), m_focusEventIndices.end(), index + ); + if (focusItr != m_focusEventIndices.end()) { + m_focusEventIndices.erase(focusItr); + } + if (m_lastMenuEvent == index) { + m_lastMenuEvent.reset(); + } +} + +void WinEventLimiter::_invalidateEquivEvent(const EventData& e, const DWORD eventID){ + EventData equivEvent = e; + equivEvent.idEvent = eventID; + _invalidateMatching(equivEvent); +} + +bool WinEventLimiter::AddEvent(EventData& e) { + if (!_preFilter(e)) { + return false; + } + // Only one event per specific object is kept at a time, though a duplicate pushes it + // further forward in time (the old copy is dropped and the new one appended). + // Equality does not compare the time of the event; multiple events for the same object + // at different times would only add more unnecessary work for NVDA. + const auto index = m_events.size(); + m_events.push_back(cachedEvent({ e, true })); + ++m_validEventCount; + // try_emplace resolves fresh and duplicate identities with one hash of the key, + // where find + erase + insert would hash it three times per duplicate. + const auto [itr, inserted] = m_liveEventIndices.try_emplace(e, index); + if (!inserted) { + _invalidateEventAt(itr->second, true /*keepIdentity*/); + itr->second = index; + } + + switch (e.idEvent) { + case EVENT_OBJECT_FOCUS: + _addFocusEvent(index); + break; + case EVENT_SYSTEM_FOREGROUND: + // An equivalent queued focus event is superseded by this foreground event. + _invalidateEquivEvent(e, EVENT_OBJECT_FOCUS); + _addFocusEvent(index); + break; + case EVENT_OBJECT_SHOW: _invalidateEquivEvent(e, EVENT_OBJECT_HIDE); + break; + case EVENT_OBJECT_HIDE: _invalidateEquivEvent(e, EVENT_OBJECT_SHOW); + break; + default: + const bool isMenuEvent = std::find( + MENU_EVENTIDS.cbegin(), MENU_EVENTIDS.cend(), e.idEvent + ) != MENU_EVENTIDS.cend(); + if (isMenuEvent) { + if (m_lastMenuEvent.has_value()) { + // Also resets m_lastMenuEvent. + _invalidateEventAt(m_lastMenuEvent.value()); + } + m_lastMenuEvent = index; + } + } + if (m_events.size() >= MAX_BUFFERED_EVENTS) { + _compact(); + } + return true; +} + +void WinEventLimiter::SetAlwaysAllowedObject(HWND hwnd, LONG idObject, LONG idChild) { + if (hwnd == nullptr && idObject == 0 && idChild == 0) { + m_alwaysAllowedObject.reset(); + return; + } + m_alwaysAllowedObject = AlwaysAllowedObject{ hwnd, idObject, idChild }; +} + +bool WinEventLimiter::_matchesAlwaysAllowed(const EventData& e) const { + if (!m_alwaysAllowedObject.has_value()) { + return false; + } + const auto& allowed = m_alwaysAllowedObject.value(); + // Like the Python limiter, the event ID and thread are deliberately not compared: + // any kind of event for the allowed object is exempt, whichever thread raised it. + return e.hwnd == allowed.hwnd + && e.idObject == allowed.idObject + && e.idChild == allowed.idChild; +} + +void WinEventLimiter::_compact() { + // Shrink the buffer to at most half of MAX_BUFFERED_EVENTS. Invalidated entries (mostly + // superseded duplicates) are removed first; only if the buffer is still too large are + // the oldest valid events dropped, generic ones first. Focus class events and the + // surviving menu event are never dropped (their own limits bound them). Events for the + // always allowed (focused) object are preferred survivors (#11520), but only while the + // generic events can cover the excess: deduplication keeps one entry per distinct + // event ID and thread, so the focused object alone can overflow the cap, and the cap + // must stay absolute or the buffer would grow without bound while recompacting on + // every add. + m_maxBufferSize = std::max(m_maxBufferSize, m_events.size()); + const auto keepCount = MAX_BUFFERED_EVENTS / 2; + const auto totalToDrop = m_validEventCount > keepCount ? m_validEventCount - keepCount : 0; + eventBufferLimits::EventDropBudget dropBudget = eventBufferLimits::CalculateDropBudget( + m_events.cbegin(), + m_events.cend(), + totalToDrop, + [this](const cachedEvent& cached) { + if (!cached.valid || _matchesAlwaysAllowed(cached.event)) { + return false; + } + const VecSize_t index = static_cast(&cached - m_events.data()); + return !_isExemptFromThreadLimit(index); + } + ); + std::vector keptEvents; + keptEvents.reserve(std::min(m_validEventCount, keepCount)); + constexpr auto DROPPED = std::numeric_limits::max(); + std::vector indexRemap(m_events.size(), DROPPED); + for (VecSize_t index = 0; index < m_events.size(); ++index) { + const auto& cached = m_events[index]; + if (!cached.valid) { + continue; + } + if (!_isExemptFromThreadLimit(index) + && dropBudget.ShouldDrop(!_matchesAlwaysAllowed(cached.event)) + ) { + continue; + } + indexRemap[index] = keptEvents.size(); + keptEvents.push_back(cached); + } + m_events = std::move(keptEvents); + m_liveEventIndices.clear(); + for (VecSize_t index = 0; index < m_events.size(); ++index) { + m_liveEventIndices[m_events[index].event] = index; + } + std::vector rebasedFocusIndices; + rebasedFocusIndices.reserve(m_focusEventIndices.size()); + for (const auto index : m_focusEventIndices) { + if (indexRemap[index] != DROPPED) { + rebasedFocusIndices.push_back(indexRemap[index]); + } + } + m_focusEventIndices = std::move(rebasedFocusIndices); + if (m_lastMenuEvent.has_value()) { + const auto remapped = indexRemap[m_lastMenuEvent.value()]; + if (remapped != DROPPED) { + m_lastMenuEvent = remapped; + } + else { + m_lastMenuEvent.reset(); + } + } + // The compaction loop omitted every invalid entry. + m_validEventCount = m_events.size(); +} + +void WinEventLimiter::_reset() { + m_maxBufferSize = std::max(m_maxBufferSize, m_events.size()); + m_lastMenuEvent.reset(); + m_events.clear(); + m_liveEventIndices.clear(); + m_focusEventIndices.clear(); + m_validEventCount = 0; +} + +bool WinEventLimiter::_isExemptFromThreadLimit(VecSize_t index) const { + // Focus class events and the surviving menu event are limited by their own + // mechanisms (MAX_FOCUS_ITEMS and m_lastMenuEvent) and are never counted + // towards the per-thread limit. + if (m_lastMenuEvent.has_value() && m_lastMenuEvent.value() == index) { + return true; + } + return std::find( + m_focusEventIndices.cbegin(), m_focusEventIndices.cend(), index + ) != m_focusEventIndices.cend(); +} + +void WinEventLimiter::_applyThreadLimit() { + // Walk newest to oldest, keeping only the newest MAX_EVENTS_FOR_THREAD generic + // events per source thread. + std::map perThreadCounts; + for (auto index = m_events.size(); index-- > 0; ) { + auto& cached = m_events[index]; + if (!cached.valid || _isExemptFromThreadLimit(index)) { + continue; + } + auto& count = perThreadCounts[cached.event.dwEventThread]; + ++count; + if (count > MAX_EVENTS_FOR_THREAD && !_matchesAlwaysAllowed(cached.event)) { + // #11520: events for the always allowed (focused) object are never + // dropped by the per thread limit. + _invalidateEventAt(index); + } + } +} + +EventBuffer WinEventLimiter::Flush() { + _applyThreadLimit(); + EventBuffer ret; + ret.reserve(m_validEventCount); + + for (auto& e : m_events) { + if (e.valid) { + ret.push_back(e.event); + } + } + _reset(); + return ret; +} + +VecSize_t WinEventLimiter::GetMaxBufferSize() +{ + return m_maxBufferSize; +} diff --git a/nvdaHelper/winEventLimiter/lib/winEventLimiter.h b/nvdaHelper/winEventLimiter/lib/winEventLimiter.h new file mode 100644 index 00000000000..242674fb41a --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/winEventLimiter.h @@ -0,0 +1,66 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#pragma once +#include "stdafx.h" +#include "eventBufferLimits.h" +#include "eventData.h" +#include +#include +#include + +struct cachedEvent; +using VecSize_t = std::vector::size_type; + +class WinEventLimiter +{ +public: + WinEventLimiter(); + bool AddEvent(EventData& e); + EventBuffer Flush(); + // #11520: Exempts events for the given object from the per thread limit; + // All zeros clears the exemption. + void SetAlwaysAllowedObject(HWND hwnd, LONG idObject, LONG idChild); + VecSize_t GetMaxBufferSize(); // Used for diagnostics + + ~WinEventLimiter(); + + static const int MAX_FOCUS_ITEMS; + static const int MAX_EVENTS_FOR_THREAD; + static constexpr VecSize_t MAX_BUFFERED_EVENTS = + eventBufferLimits::MAX_BUFFERED_EVENTS; + +private: + std::vector m_events; + // The index in m_events of the single valid entry for each event identity, giving constant-time + // duplicate and equivalent-event invalidation. + std::unordered_map m_liveEventIndices; + std::vector m_focusEventIndices; + std::optional m_lastMenuEvent; + std::optional m_alwaysAllowedObject; + EventBuffer::size_type m_validEventCount; + VecSize_t m_maxBufferSize; + + void _addFocusEvent(VecSize_t index); + // keepIdentity skips the m_liveEventIndices erase; the caller repoints the entry. + void _invalidateEventAt(VecSize_t index, bool keepIdentity = false); + void _invalidateMatching(const EventData & e); + void _invalidateEquivEvent(const EventData & e, const DWORD eventID); + void _applyThreadLimit(); + bool _isExemptFromThreadLimit(VecSize_t index) const; + bool _matchesAlwaysAllowed(const EventData & e) const; + void _compact(); + void _reset(); + bool _preFilter(const EventData & e); +}; diff --git a/nvdaHelper/winEventLimiter/lib/winEventLimiterLib.vcxproj b/nvdaHelper/winEventLimiter/lib/winEventLimiterLib.vcxproj new file mode 100644 index 00000000000..056a2e15698 --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/winEventLimiterLib.vcxproj @@ -0,0 +1,99 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 18.0 + {72D4ECCA-A420-4523-A7CD-88EE06C26829} + Win32Proj + winEventLimiterLib + 10.0 + + + + StaticLibrary + true + v145 + Unicode + + + StaticLibrary + false + v145 + true + Unicode + + + + + + + + + + + + + + + true + + + false + + + + Use + Level3 + Disabled + true + _WIN32_WINNT=_WIN32_WINNT_WIN10;_DEBUG;_LIB;%(PreprocessorDefinitions) + true + stdcpp20 + + + Windows + true + + + + + Use + Level3 + MaxSpeed + true + true + true + _WIN32_WINNT=_WIN32_WINNT_WIN10;NDEBUG;_LIB;%(PreprocessorDefinitions) + true + stdcpp20 + + + Windows + true + true + true + + + + + + + + + Create + Create + + + + + + diff --git a/nvdaHelper/winEventLimiter/lib/winEventLimiterLib.vcxproj.filters b/nvdaHelper/winEventLimiter/lib/winEventLimiterLib.vcxproj.filters new file mode 100644 index 00000000000..ccd3f7659ed --- /dev/null +++ b/nvdaHelper/winEventLimiter/lib/winEventLimiterLib.vcxproj.filters @@ -0,0 +1,27 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Header Files + + + + + Source Files + + + diff --git a/nvdaHelper/winEventLimiter/logger/WinEventLogger.cpp b/nvdaHelper/winEventLimiter/logger/WinEventLogger.cpp new file mode 100644 index 00000000000..2c94998a12a --- /dev/null +++ b/nvdaHelper/winEventLimiter/logger/WinEventLogger.cpp @@ -0,0 +1,227 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +// WinEventLogger.cpp : This file contains the 'main' function. Program execution begins and ends there. + +#include "pch.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +constexpr DWORD IA2_EVENT_DOCUMENT_LOAD_COMPLETE = 261; +constexpr DWORD IA2_EVENT_OBJECT_ATTRIBUTE_CHANGED = 272; +constexpr DWORD IA2_EVENT_PAGE_CHANGED = 273; +constexpr DWORD IA2_EVENT_TEXT_CARET_MOVED = 283; +#ifndef EVENT_OBJECT_LIVEREGIONCHANGED +// Not defined in Windows SDK headers before 10.0.14393 +constexpr DWORD EVENT_OBJECT_LIVEREGIONCHANGED = 0x8019; +#endif + +// Display names for winEvent IDs, and the set of IDs this tool hooks. +// NVDA uses the mapping at source/IAccessibleHandler/internalWinEventHandler.py, which it passes to +// winEventLimiter_start; this copy only serves this standalone diagnostic tool. +const std::map eventIdsToNames = { + {EVENT_SYSTEM_DESKTOPSWITCH, "desktopSwitch"}, + {EVENT_SYSTEM_FOREGROUND, "gainFocus"}, + {EVENT_SYSTEM_ALERT, "alert"}, + {EVENT_SYSTEM_MENUSTART, "menuStart"}, + {EVENT_SYSTEM_MENUEND, "menuEnd"}, + {EVENT_SYSTEM_MENUPOPUPSTART, "menuStart"}, + {EVENT_SYSTEM_MENUPOPUPEND, "menuEnd"}, + {EVENT_SYSTEM_SCROLLINGSTART, "scrollingStart"}, + // We don't need switchStart. + {EVENT_SYSTEM_SWITCHEND, "switchEnd"}, + {EVENT_OBJECT_FOCUS, "gainFocus"}, + {EVENT_OBJECT_SHOW, "show"}, + {EVENT_OBJECT_HIDE, "hide"}, + {EVENT_OBJECT_DESTROY, "destroy"}, + {EVENT_OBJECT_DESCRIPTIONCHANGE, "descriptionChange"}, + {EVENT_OBJECT_LOCATIONCHANGE, "locationChange"}, + {EVENT_OBJECT_NAMECHANGE, "nameChange"}, + {EVENT_OBJECT_SELECTION, "selection"}, + {EVENT_OBJECT_SELECTIONADD, "selectionAdd"}, + {EVENT_OBJECT_SELECTIONREMOVE, "selectionRemove"}, + {EVENT_OBJECT_SELECTIONWITHIN, "selectionWithIn"}, + {EVENT_OBJECT_STATECHANGE, "stateChange"}, + {EVENT_OBJECT_VALUECHANGE, "valueChange"}, + {EVENT_OBJECT_LIVEREGIONCHANGED, "liveRegionChange"}, + {IA2_EVENT_TEXT_CARET_MOVED, "caret"}, + {IA2_EVENT_DOCUMENT_LOAD_COMPLETE, "documentLoadComplete"}, + {IA2_EVENT_OBJECT_ATTRIBUTE_CHANGED, "IA2AttributeChange"}, + {IA2_EVENT_PAGE_CHANGED, "pageChange"}, +}; + +std::string getEventName(DWORD eventID) { + const auto itr = eventIdsToNames.find(eventID); + return itr != eventIdsToNames.end() ? itr->second : ""; +} + +} // namespace + +HWND doCreateWindow(); +void blocking_messagePump(); + +std::atomic g_keepPrinting = true; +std::atomic g_glitch = false; +void printFromBuffer(); +void printEvent(unsigned int eventIndex, bool fromDestroyBuffer = false); + + +void onNewEvent(int includesFocusEvent) { + // Callback from the winEventLimiter dll +} + +int main() { + std::cout << "Starting\n"; + std::vector eventIds; + eventIds.reserve(eventIdsToNames.size()); + for (const auto& [id, name] : eventIdsToNames) { + eventIds.push_back(id); + } + winEventLimiter_start(onNewEvent, eventIds.data(), static_cast(eventIds.size())); + auto printThread = std::thread(printFromBuffer); + doCreateWindow(); + blocking_messagePump(); + g_keepPrinting = false; // stops the print thread. + printThread.join(); + winEventLimiter_stop(); + return 0; +} + +void printFromBuffer() { + while (g_keepPrinting) { + std::cout << "-- flush --" << '\n'; + winEventLimiter_flushDestroyEvents(); + winEventLimiter_flushEvents(); + const auto numDestroys = winEventLimiter_getDestroyEventCount(); + for (auto i = 0u; i < numDestroys; ++i) { + std::cout << "Destroy: "; + printEvent(i, true); + } + if (g_glitch.exchange(false)) { + auto numEvents = winEventLimiter_getEventCount(); + std::cout << "Glitch Event: "; + printEvent(numEvents+1); + } + else { + auto numEvents = winEventLimiter_getEventCount(); + for (auto i = 0u; i < numEvents; ++i) { + printEvent(i); + } + } + + using namespace std::chrono_literals; + std::this_thread::sleep_for(100ms); // mimic cadence of updates from NVDA + } +} + +void printEvent(unsigned int eventIndex, bool fromDestroyBuffer) { + EventData e; + std::stringstream sstream; + auto constexpr MAX_EVENTS = 1u; + const auto gotCount = fromDestroyBuffer + ? winEventLimiter_getDestroyEvents(eventIndex, MAX_EVENTS, &e) + : winEventLimiter_getEvents(eventIndex, MAX_EVENTS, &e); + if (0u < gotCount && gotCount <= MAX_EVENTS) { + const auto eName = getEventName(e.idEvent); + sstream << std::hex << std::showbase << + " eventID: " << e.idEvent << + " eventName: " << eName << + " hwnd: " << e.hwnd << + " idObject: " << e.idObject << + " idChild: " << e.idChild << + " eventThread: " << e.dwEventThread << + " eventTime: " << e.dwmsEventTime + << '\n'; + } + else { + const auto eCount = fromDestroyBuffer + ? winEventLimiter_getDestroyEventCount() + : winEventLimiter_getEventCount(); + sstream << "failed to get event at index: " << eventIndex << " from outBuffer with size: " << eCount << '\n'; + } + std::cout << sstream.str(); +} + +LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { + switch (message) { + case WM_CHAR: + if (wparam == VK_ESCAPE) { + DestroyWindow(hwnd); + } + if (wparam == 'g') { + g_glitch = true; + } + break; + case WM_DESTROY: + PostQuitMessage(0); + break; + } + return DefWindowProc(hwnd, message, wparam, lparam); +} + +HWND doCreateWindow() { + WNDCLASS windowClass = { 0 }; + windowClass.hbrBackground = static_cast(GetStockObject(WHITE_BRUSH)); + windowClass.hCursor = LoadCursor(nullptr, IDC_ARROW); + windowClass.hInstance = nullptr; + windowClass.lpfnWndProc = WndProc; + windowClass.lpszClassName = L"Window in Console"; //needs to be the same name when creating the window as well + windowClass.style = CS_HREDRAW | CS_VREDRAW; + //also register the class + if (!RegisterClass(&windowClass)) { + std::cout << "Could not register class\n"; + exit(-1); + } + + HWND windowHandle = CreateWindow( + L"Window in Console", // lpClassName + nullptr, // lpWindowName + WS_OVERLAPPEDWINDOW, // dwStyle + // coordinate of window start point + 0, // x + 0, // y + // window size + 500, // width + 100, // height + nullptr, // hWndParent + nullptr, // hMenu + nullptr, // hInstance + nullptr // lpParam + ); + ShowWindow(windowHandle, SW_RESTORE); + return windowHandle; +} + +void blocking_messagePump() { + // Keep this app running until we're told to stop + MSG msg; + while (0 < GetMessage(&msg, NULL, NULL, NULL) + ) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } +} diff --git a/nvdaHelper/winEventLimiter/logger/WinEventLogger.vcxproj b/nvdaHelper/winEventLimiter/logger/WinEventLogger.vcxproj new file mode 100644 index 00000000000..eb11c37813e --- /dev/null +++ b/nvdaHelper/winEventLimiter/logger/WinEventLogger.vcxproj @@ -0,0 +1,107 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 18.0 + {8BA10293-4201-4C53-BFA2-163FC4C969E6} + Win32Proj + WinEventLogger + 10.0 + + + + Application + true + v145 + Unicode + + + Application + false + v145 + true + Unicode + + + + + + + + + + + + + + + true + + + false + + + + Use + Level3 + Disabled + true + _WIN32_WINNT=_WIN32_WINNT_WIN10;_DEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + pch.h + stdcpp20 + $(SolutionDir)dll\;$(SolutionDir)lib\;%(AdditionalIncludeDirectories) + + + Console + true + winEventLimiter.lib;winEventLimiterLib.lib;oleacc.lib;%(AdditionalDependencies) + $(OutDir) + + + + + Use + Level3 + MaxSpeed + true + true + true + _WIN32_WINNT=_WIN32_WINNT_WIN10;NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + pch.h + stdcpp20 + $(SolutionDir)dll\;$(SolutionDir)lib\;%(AdditionalIncludeDirectories) + + + Console + true + true + true + winEventLimiter.lib;winEventLimiterLib.lib;oleacc.lib;%(AdditionalDependencies) + $(OutDir) + + + + + + + + Create + Create + + + + + + + diff --git a/nvdaHelper/winEventLimiter/logger/WinEventLogger.vcxproj.filters b/nvdaHelper/winEventLimiter/logger/WinEventLogger.vcxproj.filters new file mode 100644 index 00000000000..3538220cc5f --- /dev/null +++ b/nvdaHelper/winEventLimiter/logger/WinEventLogger.vcxproj.filters @@ -0,0 +1,30 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Header Files + + + + + Source Files + + + Source Files + + + \ No newline at end of file diff --git a/nvdaHelper/winEventLimiter/logger/pch.cpp b/nvdaHelper/winEventLimiter/logger/pch.cpp new file mode 100644 index 00000000000..e0bb4152172 --- /dev/null +++ b/nvdaHelper/winEventLimiter/logger/pch.cpp @@ -0,0 +1,17 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +// Source file the precompiled header (pch.h) is compiled through. + +#include "pch.h" diff --git a/nvdaHelper/winEventLimiter/logger/pch.h b/nvdaHelper/winEventLimiter/logger/pch.h new file mode 100644 index 00000000000..b9656d62374 --- /dev/null +++ b/nvdaHelper/winEventLimiter/logger/pch.h @@ -0,0 +1,21 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#ifndef PCH_H +#define PCH_H + +// Precompiled header for the WinEventLogger diagnostic tool. Intentionally empty: +// the tool includes what it needs directly. + +#endif //PCH_H diff --git a/nvdaHelper/winEventLimiter/tests/stdafx.cpp b/nvdaHelper/winEventLimiter/tests/stdafx.cpp new file mode 100644 index 00000000000..141c4a8db4d --- /dev/null +++ b/nvdaHelper/winEventLimiter/tests/stdafx.cpp @@ -0,0 +1,22 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +// stdafx.cpp : source file that includes just the standard includes +// Test_EventHandler.pch will be the pre-compiled header +// stdafx.obj will contain the pre-compiled type information + +#include "stdafx.h" + +// TODO: reference any additional headers you need in STDAFX.H +// and not in this file diff --git a/nvdaHelper/winEventLimiter/tests/stdafx.h b/nvdaHelper/winEventLimiter/tests/stdafx.h new file mode 100644 index 00000000000..21acb16f8ad --- /dev/null +++ b/nvdaHelper/winEventLimiter/tests/stdafx.h @@ -0,0 +1,21 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +// Precompiled header for the winEventLimiter unit tests: just the CppUnitTest framework. +// The library's own precompiled header (../lib/stdafx.h, pulled in via its public +// headers) supplies the Windows headers. + +#pragma once + +#include "CppUnitTest.h" diff --git a/nvdaHelper/winEventLimiter/tests/test_winEventLimiter.cpp b/nvdaHelper/winEventLimiter/tests/test_winEventLimiter.cpp new file mode 100644 index 00000000000..ee98a2c606d --- /dev/null +++ b/nvdaHelper/winEventLimiter/tests/test_winEventLimiter.cpp @@ -0,0 +1,849 @@ +/* +This file is a part of the NVDA project. +URL: http://www.nvda-project.org/ +Copyright 2019-2026 NV Access Limited, Bill Dengler +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2.0, as published by +the Free Software Foundation. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +This license can be found at: +http://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ + +#include "stdafx.h" +#include "CppUnitTest.h" +#include "eventDoubleBuffer.h" +#include "eventLimiterThread.h" +#include "eventNotifier.h" +#include "winEventLimiter.h" +#include +#include +#include +#include +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +std::vector specialCaseEvents({ + EVENT_SYSTEM_FOREGROUND, + EVENT_OBJECT_FOCUS, + EVENT_OBJECT_SHOW, + EVENT_OBJECT_HIDE, + EVENT_SYSTEM_MENUSTART, + EVENT_SYSTEM_MENUEND, + EVENT_SYSTEM_MENUPOPUPSTART, + EVENT_SYSTEM_MENUPOPUPEND, +}); + +namespace Microsoft { + namespace VisualStudio { + namespace CppUnitTestFramework + { + template<> inline std::wstring ToString>(const std::vector& t) { + std::wstringstream ss; + ss << "[ "; + for (auto i : t) { + ss << i << L", "; + } + ss << " ]"; + return ss.str(); + } + } + } +} + +namespace { + + // Builds an event whose window, objectID and childID are all `id`, + // making each id a distinct object as far as the limiter is concerned. + EventData makeEvent(DWORD idEvent, int id, DWORD threadID = 0) { + return EventData{ + idEvent, + reinterpret_cast(static_cast(id)), + id, // idObject + id, // idChild + threadID, + 0, // dwmsEventTime + }; + } + + void addEvent(WinEventLimiter& limiter, DWORD idEvent, int id, DWORD threadID = 0) { + auto e = makeEvent(idEvent, id, threadID); + limiter.AddEvent(e); + } + + std::vector flushIds(WinEventLimiter& limiter) { + auto events = limiter.Flush(); + std::vector ids; + for (auto& e : events) { + ids.push_back(e.idObject); + } + return ids; + } + +} + +namespace WinEventLimiterTests +{ + TEST_CLASS(test_WinEventLimiter) + { + public: + + TEST_METHOD(test_limitEventsPerThread) + { + WinEventLimiter limiter; + for (int n = 2000 - 1; n >= 0; --n) { + EventData e = { + specialCaseEvents[n % specialCaseEvents.size()], + reinterpret_cast(static_cast(n)), // window + n, // objectID - in this test, used as an ID. + n, // childID + 0, // threadID - all events for same thread + }; + limiter.AddEvent(e); + } + + auto actualEvents = limiter.Flush(); + std::vector actualIds; + for (auto& e : actualEvents) { + actualIds.push_back(e.idObject); + } + // Derivation: events are added from n=1999 down to n=0, so chronological + // order is descending n, and event type cycles through specialCaseEvents + // by n % 8 (0=foreground, 1=focus, 2=show, 3=hide, 4-7=menu). All ids are + // distinct, so no duplicate or show/hide cancellation applies. + // - Focus class (n % 8 in {0, 1}): the newest MAX_FOCUS_ITEMS=4 survive: + // n in {9, 8, 1, 0}. Foreground events evicted from the focus cache are + // demoted to generic events rather than dropped (n in {16, 24, 32, ...}). + // - Menu (n % 8 in {4..7}): only the newest survives, n=4 (menuStart). + // - Generic (show/hide and demoted foregrounds): the per thread limit keeps + // the newest MAX_EVENTS_FOR_THREAD=10: n in {2,3,10,11,16,18,19,24,26,27}. + // Output is chronological (descending n). + std::vector expectedIds({ + 27, 26, 24, 19, 18, 16, 11, 10, 9, 8, 4, 3, 2, 1, 0, + }); + Assert::AreEqual(expectedIds, actualIds); + } + + TEST_METHOD(test_showDoesNotCancelItself) + { + // Regression test: _invalidateEquivEvent previously compared against the + // event itself, so every show/hide event invalidated its own entry. + WinEventLimiter limiter; + addEvent(limiter, EVENT_OBJECT_SHOW, 1); + auto out = limiter.Flush(); + Assert::AreEqual(size_t{ 1 }, out.size()); + Assert::AreEqual(static_cast(EVENT_OBJECT_SHOW), out[0].idEvent); + } + + TEST_METHOD(test_showCancelsEarlierHide) + { + WinEventLimiter limiter; + addEvent(limiter, EVENT_OBJECT_HIDE, 1); + addEvent(limiter, EVENT_OBJECT_SHOW, 1); + auto out = limiter.Flush(); + Assert::AreEqual(size_t{ 1 }, out.size()); + Assert::AreEqual(static_cast(EVENT_OBJECT_SHOW), out[0].idEvent); + } + + TEST_METHOD(test_hideCancelsEarlierShow) + { + WinEventLimiter limiter; + addEvent(limiter, EVENT_OBJECT_SHOW, 1); + addEvent(limiter, EVENT_OBJECT_HIDE, 1); + auto out = limiter.Flush(); + Assert::AreEqual(size_t{ 1 }, out.size()); + Assert::AreEqual(static_cast(EVENT_OBJECT_HIDE), out[0].idEvent); + } + + TEST_METHOD(test_showDoesNotCancelHideForOtherObject) + { + WinEventLimiter limiter; + addEvent(limiter, EVENT_OBJECT_HIDE, 1); + addEvent(limiter, EVENT_OBJECT_SHOW, 2); + auto actualIds = flushIds(limiter); + Assert::AreEqual(std::vector({ 1, 2 }), actualIds); + } + + TEST_METHOD(test_duplicateEventMovesForward) + { + // A duplicate replaces the original, moving it forward in time, + // matching the in-process Python limiter. + WinEventLimiter limiter; + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, 1); + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, 2); + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, 1); + auto actualIds = flushIds(limiter); + Assert::AreEqual(std::vector({ 2, 1 }), actualIds); + } + + TEST_METHOD(test_threadCapKeepsNewestTen) + { + WinEventLimiter limiter; + for (int id = 1; id <= 12; ++id) { + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, id, 1); + } + auto actualIds = flushIds(limiter); + Assert::AreEqual(std::vector({ 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }), actualIds); + } + + TEST_METHOD(test_threadCapIsPerThread) + { + WinEventLimiter limiter; + for (int id = 1; id <= 11; ++id) { + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, id, 1); + } + for (int id = 100; id <= 102; ++id) { + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, id, 2); + } + auto actualIds = flushIds(limiter); + // Thread 1 loses only its oldest event; thread 2 is untouched. + Assert::AreEqual( + std::vector({ 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 100, 101, 102 }), + actualIds + ); + } + + TEST_METHOD(test_maxFocusEvents) + { + // Mirrors test_maxFocusEvents in tests/unit/test_orderedWinEventLimiter.py: + // of 5 focus events, only the last MAX_FOCUS_ITEMS=4 survive + // (the oldest focus event is removed to make room). + WinEventLimiter limiter; + for (int id = 1; id <= 5; ++id) { + addEvent(limiter, EVENT_OBJECT_FOCUS, id); + } + auto actualIds = flushIds(limiter); + Assert::AreEqual(std::vector({ 2, 3, 4, 5 }), actualIds); + } + + TEST_METHOD(test_foregroundInvalidatesMatchingFocus) + { + WinEventLimiter limiter; + addEvent(limiter, EVENT_OBJECT_FOCUS, 1); + addEvent(limiter, EVENT_SYSTEM_FOREGROUND, 1); + auto out = limiter.Flush(); + Assert::AreEqual(size_t{ 1 }, out.size()); + Assert::AreEqual(static_cast(EVENT_SYSTEM_FOREGROUND), out[0].idEvent); + } + + TEST_METHOD(test_menuEventsKeepLast) + { + WinEventLimiter limiter; + addEvent(limiter, EVENT_SYSTEM_MENUSTART, 1); + addEvent(limiter, EVENT_SYSTEM_MENUPOPUPSTART, 2); + addEvent(limiter, EVENT_SYSTEM_MENUEND, 3); + auto out = limiter.Flush(); + Assert::AreEqual(size_t{ 1 }, out.size()); + Assert::AreEqual(static_cast(EVENT_SYSTEM_MENUEND), out[0].idEvent); + } + + TEST_METHOD(test_focusAndMenuNotCountedInThreadCap) + { + WinEventLimiter limiter; + for (int id = 1; id <= 10; ++id) { + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, id, 7); + } + addEvent(limiter, EVENT_OBJECT_FOCUS, 100, 7); + addEvent(limiter, EVENT_SYSTEM_MENUSTART, 200, 7); + auto actualIds = flushIds(limiter); + // All 10 generic events survive: focus and menu events are limited by their + // own mechanisms and don't consume the thread's generic allowance. + Assert::AreEqual( + std::vector({ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 200 }), + actualIds + ); + } + + TEST_METHOD(test_alwaysAllowedObjectExemptFromThreadCap) + { + WinEventLimiter limiter; + for (int id = 1; id <= 12; ++id) { + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, id, 1); + } + // Exempt the oldest event's object, which the per thread limit + // would otherwise drop. + limiter.SetAlwaysAllowedObject( + reinterpret_cast(static_cast(1)), 1, 1); + auto actualIds = flushIds(limiter); + Assert::AreEqual( + std::vector({ 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }), + actualIds + ); + } + + TEST_METHOD(test_alwaysAllowedObjectCleared) + { + WinEventLimiter limiter; + for (int id = 1; id <= 12; ++id) { + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, id, 1); + } + limiter.SetAlwaysAllowedObject( + reinterpret_cast(static_cast(1)), 1, 1); + limiter.SetAlwaysAllowedObject(nullptr, 0, 0); + auto actualIds = flushIds(limiter); + Assert::AreEqual( + std::vector({ 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }), + actualIds + ); + } + + TEST_METHOD(test_alwaysAllowedObjectStillDeduplicated) + { + // The exemption only applies to the per thread limit; + // duplicate collapsing still applies to the allowed object. + WinEventLimiter limiter; + limiter.SetAlwaysAllowedObject( + reinterpret_cast(static_cast(1)), 1, 1); + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, 1); + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, 1); + auto out = limiter.Flush(); + Assert::AreEqual(size_t{ 1 }, out.size()); + } + + TEST_METHOD(test_bufferOverflowKeepsNewestHalf) + { + WinEventLimiter limiter; + const auto max = static_cast(WinEventLimiter::MAX_BUFFERED_EVENTS); + // Unique thread per event, so the per thread limit stays inert. + for (int id = 0; id <= max; ++id) { + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, id, static_cast(id)); + } + auto actualIds = flushIds(limiter); + // Compaction fired when the buffer reached MAX_BUFFERED_EVENTS (after adding + // id max-1), keeping the newest half; one more event was added afterwards. + Assert::AreEqual(size_t{ WinEventLimiter::MAX_BUFFERED_EVENTS / 2 + 1 }, actualIds.size()); + Assert::AreEqual(max / 2, actualIds.front()); + Assert::AreEqual(max, actualIds.back()); + } + + TEST_METHOD(test_doubleBufferOverflowKeepsNewestHalf) + { + // The write buffer must stay bounded even while the limiter thread is not + // draining it (the in-process implementation was implicitly bounded by the OS + // limit on the hooking thread's message queue); the oldest half is discarded, + // like the queues downstream. + EventDoubleBuffer buffer; + const auto max = static_cast(EventDoubleBuffer::MAX_BUFFERED_EVENTS); + const auto total = max + 100; + for (int id = 0; id < total; ++id) { + buffer.Write(makeEvent(EVENT_OBJECT_NAMECHANGE, id)); + } + buffer.SwapEventBuffers(); + const auto& read = buffer.Read(); + // Writing event max triggered the drop of the oldest half; everything since + // survives, newest last. + Assert::AreEqual( + size_t{ EventDoubleBuffer::MAX_BUFFERED_EVENTS / 2 + 100 }, read.size()); + Assert::AreEqual(static_cast(max / 2), read.front().idObject); + Assert::AreEqual(static_cast(total - 1), read.back().idObject); + } + + TEST_METHOD(test_doubleBufferOverflowRetainsProtectedEvents) + { + // The overflow trim prefers generic events: focus, foreground and destroy + // events feed the limiter's focus protections and destroy diversion, which + // only see events that survive this buffer, so discarding them here would + // leave NVDA on stale focus or skip a destroy correction. + EventDoubleBuffer buffer; + buffer.Write(makeEvent(EVENT_OBJECT_FOCUS, 9001)); + buffer.Write(makeEvent(EVENT_SYSTEM_FOREGROUND, 9002)); + buffer.Write(makeEvent(EVENT_OBJECT_DESTROY, 9003)); + const auto max = static_cast(EventDoubleBuffer::MAX_BUFFERED_EVENTS); + const auto total = max + 100; + for (int id = 0; id < total - 3; ++id) { + buffer.Write(makeEvent(EVENT_OBJECT_NAMECHANGE, id)); + } + buffer.SwapEventBuffers(); + const auto& read = buffer.Read(); + // The trim dropped only the oldest generics; the three protected events were + // the oldest in the buffer, yet survive in order. + Assert::AreEqual( + size_t{ EventDoubleBuffer::MAX_BUFFERED_EVENTS / 2 + 100 }, read.size()); + Assert::AreEqual(9001L, read[0].idObject); + Assert::AreEqual(9002L, read[1].idObject); + Assert::AreEqual(9003L, read[2].idObject); + Assert::AreEqual(static_cast(max / 2), read[3].idObject); + Assert::AreEqual(static_cast(total - 4), read.back().idObject); + // The generics covered the excess, so no destroy was lost. + Assert::IsFalse(buffer.TakeLostDestroys()); + } + + TEST_METHOD(test_doubleBufferMenuFloodDoesNotDisplaceFocus) + { + // Raw menu events are coalesced downstream, so protecting an entire menu + // flood here must not make an older focus event pay the protected-event + // overflow budget. + EventDoubleBuffer buffer; + buffer.Write(makeEvent(EVENT_OBJECT_FOCUS, 9001)); + const auto max = static_cast(EventDoubleBuffer::MAX_BUFFERED_EVENTS); + const auto genericCount = max / 2 - 1; + for (int id = 0; id < genericCount; ++id) { + buffer.Write(makeEvent(EVENT_OBJECT_NAMECHANGE, id)); + } + const auto menuCount = max - genericCount - 1; + for (int id = 0; id < menuCount; ++id) { + buffer.Write(makeEvent(EVENT_SYSTEM_MENUSTART, 10000 + id)); + } + buffer.Write(makeEvent(EVENT_OBJECT_NAMECHANGE, 20000)); + buffer.SwapEventBuffers(); + const auto& read = buffer.Read(); + Assert::AreEqual( + size_t{ EventDoubleBuffer::MAX_BUFFERED_EVENTS / 2 + 1 }, read.size()); + Assert::AreEqual(9001L, read.front().idObject); + Assert::AreEqual( + static_cast(EVENT_OBJECT_FOCUS), read.front().idEvent); + Assert::AreEqual(10001L, read[1].idObject); + Assert::AreEqual(20000L, read.back().idObject); + } + + TEST_METHOD(test_doubleBufferOverflowRetainsFocusedObjectEvents) + { + // #11520: events for the always allowed (focused) object must survive the + // overflow trim, or they would be lost before WinEventLimiter's exemption + // could see them. Events in this buffer are raw, so the trim must apply the + // same objectID remap the limiter thread's preprocessing will. + EventDoubleBuffer buffer; + const HWND focusedWindow = reinterpret_cast(static_cast(9004)); + buffer.SetAlwaysAllowedObject(focusedWindow, OBJID_CLIENT, 0); + // Raw form: a window objectID of 0 remaps to OBJID_CLIENT downstream. + buffer.Write(EventData{ + EVENT_OBJECT_NAMECHANGE, focusedWindow, 0, 0, 7, 0, + }); + // Already-client form matches directly. + buffer.Write(EventData{ + EVENT_OBJECT_VALUECHANGE, focusedWindow, OBJID_CLIENT, 0, 7, 0, + }); + const auto max = static_cast(EventDoubleBuffer::MAX_BUFFERED_EVENTS); + const auto total = max + 100; + for (int id = 0; id < total; ++id) { + buffer.Write(makeEvent(EVENT_OBJECT_NAMECHANGE, id)); + } + buffer.SwapEventBuffers(); + const auto& read = buffer.Read(); + // The focused object's events were the oldest in the buffer, yet survive in + // order; only the oldest generics were dropped. + Assert::AreEqual( + size_t{ EventDoubleBuffer::MAX_BUFFERED_EVENTS / 2 + 102 }, read.size()); + Assert::IsTrue(focusedWindow == read[0].hwnd); + Assert::AreEqual(0L, read[0].idObject); + Assert::IsTrue(focusedWindow == read[1].hwnd); + Assert::AreEqual(static_cast(OBJID_CLIENT), read[1].idObject); + Assert::AreEqual(static_cast(max / 2), read[2].idObject); + Assert::AreEqual(static_cast(total - 1), read.back().idObject); + } + + TEST_METHOD(test_doubleBufferOverflowShedsDestroyFloodAndReportsLoss) + { + // Protection is a preference, not an exemption: when the buffer is full of + // protected events (e.g. an application with thousands of windows closing), + // memory boundedness wins and the oldest are dropped regardless, or every + // write above the limit would copy an ever-growing buffer. The loss is + // reported, so NVDA can treat every window as potentially destroyed instead + // of relying on the dropped destroy events. + EventDoubleBuffer buffer; + const auto max = static_cast(EventDoubleBuffer::MAX_BUFFERED_EVENTS); + for (int id = 0; id <= max; ++id) { + buffer.Write(makeEvent(EVENT_OBJECT_DESTROY, id)); + } + buffer.SwapEventBuffers(); + const auto& read = buffer.Read(); + Assert::AreEqual( + size_t{ EventDoubleBuffer::MAX_BUFFERED_EVENTS / 2 + 1 }, read.size()); + Assert::AreEqual(static_cast(max / 2), read.front().idObject); + Assert::AreEqual(static_cast(max), read.back().idObject); + Assert::IsTrue(buffer.TakeLostDestroys()); + // Reading consumed the flag. + Assert::IsFalse(buffer.TakeLostDestroys()); + } + + TEST_METHOD(test_maxBufferSizeDiagnosticTracksHighWater) + { + WinEventLimiter limiter; + const auto max = static_cast(WinEventLimiter::MAX_BUFFERED_EVENTS); + for (int id = 0; id < max; ++id) { + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, id, static_cast(id)); + } + Assert::IsTrue(limiter.GetMaxBufferSize() >= WinEventLimiter::MAX_BUFFERED_EVENTS); + } + + TEST_METHOD(test_notifierCoalescesAndUpgradesForFocus) + { + // The notifier fires at most once until the client flushes, but a focus + // event still upgrades an already delivered notification once, so NVDA can + // upgrade a pending delayed pump to an immediate one (#14928). + std::vector received; + EventNotifier notifier( + [&received](int includesFocusEvent) { received.push_back(includesFocusEvent); } + ); + notifier.NotifyClientOfNewEvents(false); + notifier.NotifyClientOfNewEvents(false); + Assert::AreEqual(std::vector({ 0 }), received); + notifier.NotifyClientOfNewEvents(true); + notifier.NotifyClientOfNewEvents(true); + Assert::AreEqual(std::vector({ 0, 1 }), received); + notifier.ResetNotify(); + notifier.NotifyClientOfNewEvents(false); + Assert::AreEqual(std::vector({ 0, 1, 0 }), received); + } + + TEST_METHOD(test_duplicateFocusDoesNotConsumeFocusBudget) + { + // A duplicated focus event replaces its old copy in place (like the Python + // limiter's keyed cache) and must not consume an extra MAX_FOCUS_ITEMS slot, + // which would wrongly evict a still valid older focus event. + WinEventLimiter limiter; + addEvent(limiter, EVENT_OBJECT_FOCUS, 1); + addEvent(limiter, EVENT_OBJECT_FOCUS, 2); + addEvent(limiter, EVENT_OBJECT_FOCUS, 2); + addEvent(limiter, EVENT_OBJECT_FOCUS, 3); + addEvent(limiter, EVENT_OBJECT_FOCUS, 4); + auto actualIds = flushIds(limiter); + Assert::AreEqual(std::vector({ 1, 2, 3, 4 }), actualIds); + } + + TEST_METHOD(test_foregroundSurvivesFocusEviction) + { + // A foreground event evicted from the focus cache is demoted to a generic + // event rather than dropped, mirroring the Python limiter's generic cache + // copy: the application switch must still be announced. + WinEventLimiter limiter; + addEvent(limiter, EVENT_SYSTEM_FOREGROUND, 1); + for (int id = 2; id <= 5; ++id) { + addEvent(limiter, EVENT_OBJECT_FOCUS, id); + } + auto actualIds = flushIds(limiter); + Assert::AreEqual(std::vector({ 1, 2, 3, 4, 5 }), actualIds); + } + + TEST_METHOD(test_demotedForegroundSubjectToThreadCap) + { + // Once demoted to a generic event, an evicted foreground competes in the per + // thread limit like any other generic event. + WinEventLimiter limiter; + addEvent(limiter, EVENT_SYSTEM_FOREGROUND, 100, 7); + for (int id = 200; id <= 203; ++id) { + // The fourth focus event evicts and demotes the foreground. + addEvent(limiter, EVENT_OBJECT_FOCUS, id, 7); + } + for (int id = 1; id <= 10; ++id) { + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, id, 7); + } + auto actualIds = flushIds(limiter); + // The 10 newer generic events push the demoted foreground over the cap. + Assert::AreEqual( + std::vector({ 200, 201, 202, 203, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }), + actualIds + ); + } + + TEST_METHOD(test_compactionPreservesExemptEvents) + { + // Compaction under buffer overflow must never drop focus class events, the + // surviving menu event, or events for the always allowed (focused) object, + // however old: the Python limiter never evicted them before a flush. + WinEventLimiter limiter; + limiter.SetAlwaysAllowedObject( + reinterpret_cast(static_cast(9003)), 9003, 9003); + addEvent(limiter, EVENT_OBJECT_FOCUS, 9001); + addEvent(limiter, EVENT_SYSTEM_MENUSTART, 9002); + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, 9003); + const auto max = static_cast(WinEventLimiter::MAX_BUFFERED_EVENTS); + // Unique thread per event, so the per thread limit stays inert. + for (int id = 0; id < max; ++id) { + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, id, static_cast(id + 1)); + } + auto actualIds = flushIds(limiter); + // The three protected events were the oldest in the buffer, yet survive. + Assert::AreEqual(9001, actualIds[0]); + Assert::AreEqual(9002, actualIds[1]); + Assert::AreEqual(9003, actualIds[2]); + } + + TEST_METHOD(test_compactionRemovesInvalidatedEntriesFirst) + { + // Filling the buffer with duplicates of one object leaves one valid entry and + // thousands of invalidated ones; compaction must reclaim the invalid entries + // without dropping any valid event. + WinEventLimiter limiter; + const auto max = static_cast(WinEventLimiter::MAX_BUFFERED_EVENTS); + for (int n = 0; n < max + 100; ++n) { + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, 1); + } + Assert::IsTrue(limiter.GetMaxBufferSize() >= WinEventLimiter::MAX_BUFFERED_EVENTS); + auto out = limiter.Flush(); + Assert::AreEqual(size_t{ 1 }, out.size()); + } + + TEST_METHOD(test_compactionBoundsAlwaysAllowedFlood) + { + // The #11520 exemption is a preference, not an exemption from the absolute + // cap: deduplication keeps one entry per distinct event ID and thread, so a + // focused object emitting from enough distinct threads could otherwise grow + // the buffer without bound while recompacting on every add. + WinEventLimiter limiter; + limiter.SetAlwaysAllowedObject( + reinterpret_cast(static_cast(9003)), 9003, 9003); + const auto max = static_cast(WinEventLimiter::MAX_BUFFERED_EVENTS); + for (int n = 0; n < max + 100; ++n) { + // Every event matches the allowed object; distinct threads defeat + // deduplication. + addEvent(limiter, EVENT_OBJECT_NAMECHANGE, 9003, static_cast(n + 1)); + } + Assert::IsTrue(limiter.GetMaxBufferSize() <= WinEventLimiter::MAX_BUFFERED_EVENTS); + auto out = limiter.Flush(); + // Compaction fired once at the cap, keeping the newest half; the rest were + // added afterwards. + Assert::AreEqual(size_t{ WinEventLimiter::MAX_BUFFERED_EVENTS / 2 + 100 }, out.size()); + Assert::AreEqual(static_cast(max / 2 + 1), out.front().dwEventThread); + Assert::AreEqual(static_cast(max + 100), out.back().dwEventThread); + } + + TEST_METHOD(test_destroyEventsDeliveredRawViaFetch) + { + // Destroy events are diverted to their own fetch queue before preprocessing: + // NVDA's processDestroyWinEvent needs the raw objectID (#2695), and the invalid + // window drop must not apply to a window that is being destroyed. + std::atomic signalled = false; + std::promise notified; + EventNotifier notifier( + [&signalled, ¬ified](int) { + if (!signalled.exchange(true)) { + notified.set_value(); + } + } + ); + EventDoubleBuffer buffer; + EventLimiterThread limiterThread(notifier, buffer); + buffer.Write(EventData{ + EVENT_OBJECT_DESTROY, + nullptr, // hwnd: already destroyed + 0, // idObject: raw, must not be remapped to OBJID_CLIENT + 0, // idChild + 7, // threadID + 0, // dwmsEventTime + }); + const auto status = notified.get_future().wait_for(std::chrono::seconds(5)); + Assert::IsTrue(std::future_status::ready == status); + auto destroys = limiterThread.TakeDestroyEvents(); + bool deferred = true; + auto flushed = limiterThread.FlushEventLimiter(deferred); + limiterThread.Stop(); + Assert::AreEqual(size_t{ 1 }, destroys.size()); + Assert::AreEqual(static_cast(EVENT_OBJECT_DESTROY), destroys[0].idEvent); + Assert::IsNull(destroys[0].hwnd); + Assert::AreEqual(0L, destroys[0].idObject); + // The destroy event must not also surface as a regular event, and a destroy + // alone never defers the flush. + Assert::AreEqual(size_t{ 0 }, flushed.size()); + Assert::IsFalse(deferred); + } + + TEST_METHOD(test_injectedEventsBypassHookPreprocessing) + { + // Synthetic events were already selected by NVDA. External mode must match + // direct insertion into the in-process limiter, including when a transient + // cross-thread handoff makes the window handle fail IsWindow. + std::atomic signalled = false; + std::promise notified; + EventNotifier notifier( + [&signalled, ¬ified](int) { + if (!signalled.exchange(true)) { + notified.set_value(); + } + } + ); + EventDoubleBuffer buffer; + EventLimiterThread limiterThread(notifier, buffer); + const HWND transientWindow = reinterpret_cast( + static_cast(42) + ); + buffer.Write(EventData{ + EVENT_OBJECT_FOCUS, + transientWindow, + OBJID_CLIENT, + 0, + 7, + 0, + true, + }); + const std::future_status status = notified.get_future().wait_for( + std::chrono::seconds(5) + ); + Assert::IsTrue(std::future_status::ready == status); + bool deferred = false; + EventBuffer flushed = limiterThread.FlushEventLimiter(deferred); + limiterThread.Stop(); + Assert::IsFalse(deferred); + Assert::AreEqual(size_t{ 1 }, flushed.size()); + Assert::IsTrue(transientWindow == flushed[0].hwnd); + } + + TEST_METHOD(test_destroyEventsBypassForegroundDefer) + { + // While a foreground change defers the regular flush (#3831), destroy events + // must still reach the client: the in-process implementation processed them + // regardless of pump gating. + std::atomic notifyCount = 0; + std::promise firstNotify; + std::promise secondNotify; + EventNotifier notifier( + [¬ifyCount, &firstNotify, &secondNotify](int) { + const auto n = notifyCount.fetch_add(1); + if (n == 0) { + firstNotify.set_value(); + } + else if (n == 1) { + secondNotify.set_value(); + } + } + ); + EventDoubleBuffer buffer; + EventLimiterThread limiterThread(notifier, buffer); + // A message-only window can never become the foreground window, so the defer + // deterministically holds. + const HWND messageOnlyWindow = CreateWindowExW( + 0, L"STATIC", nullptr, 0, + 0, 0, 0, 0, + HWND_MESSAGE, nullptr, nullptr, nullptr + ); + Assert::IsNotNull(messageOnlyWindow); + buffer.Write(EventData{ + EVENT_SYSTEM_FOREGROUND, messageOnlyWindow, 0, 0, 7, 0, + }); + auto status = firstNotify.get_future().wait_for(std::chrono::seconds(5)); + Assert::IsTrue(std::future_status::ready == status); + notifier.ResetNotify(); + buffer.Write(EventData{ + EVENT_OBJECT_DESTROY, messageOnlyWindow, 0, 0, 7, 0, + }); + status = secondNotify.get_future().wait_for(std::chrono::seconds(5)); + Assert::IsTrue(std::future_status::ready == status); + bool deferred = false; + auto flushed = limiterThread.FlushEventLimiter(deferred); + auto destroys = limiterThread.TakeDestroyEvents(); + limiterThread.Stop(); + DestroyWindow(messageOnlyWindow); + Assert::AreEqual(size_t{ 0 }, flushed.size()); // deferred + // The deferral is reported, so the client can hold its whole cycle (#3831). + Assert::IsTrue(deferred); + Assert::AreEqual(size_t{ 1 }, destroys.size()); // delivered anyway + } + + TEST_METHOD(test_destroysDivertedAfterSnapshotStillNotify) + { + // The client takes the destroy snapshot before FlushEventLimiter resets the + // notifier. A destroy diverted between the two has its own notification + // coalesced away with the one being serviced, so the flush must leave a new + // notification behind for it; otherwise it - and any focus correction it + // implies - would stay pending until some unrelated event caused a pump. + std::atomic notifyCount = 0; + std::promise firstNotify; + std::promise secondNotify; + EventNotifier notifier( + [¬ifyCount, &firstNotify, &secondNotify](int) { + const auto n = notifyCount.fetch_add(1); + if (n == 0) { + firstNotify.set_value(); + } + else if (n == 1) { + secondNotify.set_value(); + } + } + ); + EventDoubleBuffer buffer; + EventLimiterThread limiterThread(notifier, buffer); + buffer.Write(EventData{ + EVENT_OBJECT_DESTROY, nullptr, 0, 0, 7, 0, + }); + auto status = firstNotify.get_future().wait_for(std::chrono::seconds(5)); + Assert::IsTrue(std::future_status::ready == status); + // The client's cycle begins: destroys are snapshotted first, with the + // notifier still holding the notification being serviced. + auto destroys = limiterThread.TakeDestroyEvents(); + Assert::AreEqual(size_t{ 1 }, destroys.size()); + // Reset only so the test can observe the late destroy being diverted. Once + // its callback runs, the notifier is held again, matching the coalesced state. + notifier.ResetNotify(); + buffer.Write(EventData{ + EVENT_OBJECT_DESTROY, nullptr, 0, 0, 7, 0, + }); + status = secondNotify.get_future().wait_for(std::chrono::seconds(5)); + Assert::IsTrue(std::future_status::ready == status); + bool deferred = false; + auto flushed = limiterThread.FlushEventLimiter(deferred); + // The flush must replace the notification it reset so the client pumps again. + Assert::AreEqual(size_t{ 3 }, notifyCount.load()); + flushed = limiterThread.FlushEventLimiter(deferred); + Assert::IsTrue(deferred); + Assert::AreEqual(size_t{ 4 }, notifyCount.load()); + flushed = limiterThread.FlushEventLimiter(deferred); + Assert::IsFalse(deferred); + // The cap may permit regular events only after escalating the pending + // destroy to whole-cache recovery, and it must leave a notification for + // the next pump. + Assert::AreEqual(size_t{ 5 }, notifyCount.load()); + Assert::IsTrue(limiterThread.TakeLostDestroys()); + auto lateDestroys = limiterThread.TakeDestroyEvents(); + limiterThread.Stop(); + Assert::AreEqual(size_t{ 0 }, flushed.size()); + Assert::IsFalse(deferred); + Assert::AreEqual(size_t{ 1 }, lateDestroys.size()); + } + + TEST_METHOD(test_unfetchedDestroyFloodStaysBoundedAndReportsLoss) + { + // If NVDA's core stalls while windows are destroyed en masse, the destroy + // queue and the input buffer shed their oldest events rather than grow (and + // copy) without bound; the loss is reported so NVDA can treat every window + // as potentially destroyed. + EventNotifier notifier([](int) {}); + EventDoubleBuffer buffer; + EventLimiterThread limiterThread(notifier, buffer); + // More destroys than the unfetched queue may hold: once the limiter thread + // has drained the writes, every survivor sits in the capped queue, so some + // stage must have shed and flagged the loss, no matter how the draining + // interleaved with the writes. + const auto total = static_cast( + EventLimiterThread::MAX_BUFFERED_DESTROY_EVENTS + + EventDoubleBuffer::MAX_BUFFERED_EVENTS + + 100 + ); + for (int id = 0; id < total; ++id) { + buffer.Write(makeEvent(EVENT_OBJECT_DESTROY, id)); + } + bool lost = false; + for (int i = 0; i < 5000 && !lost; ++i) { + lost = limiterThread.TakeLostDestroys() || buffer.TakeLostDestroys(); + if (!lost) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } + limiterThread.Stop(); + const auto destroys = limiterThread.TakeDestroyEvents(); + Assert::IsTrue(lost); + Assert::IsTrue( + destroys.size() <= EventLimiterThread::MAX_BUFFERED_DESTROY_EVENTS); + } + + TEST_METHOD(test_menuBarFocusIgnored) + { + WinEventLimiter limiter; + // A focus event on a menu bar itself (objectID OBJID_MENU, childID 0). + EventData e{ + EVENT_OBJECT_FOCUS, + reinterpret_cast(static_cast(1)), + OBJID_MENU, + 0, // childID + 0, // threadID + 0, // dwmsEventTime + }; + const bool added = limiter.AddEvent(e); + Assert::IsFalse(added); + Assert::AreEqual(size_t{ 0 }, limiter.Flush().size()); + } + + }; +} diff --git a/nvdaHelper/winEventLimiter/tests/winEventLimiterTests.vcxproj b/nvdaHelper/winEventLimiter/tests/winEventLimiterTests.vcxproj new file mode 100644 index 00000000000..f5864586e32 --- /dev/null +++ b/nvdaHelper/winEventLimiter/tests/winEventLimiterTests.vcxproj @@ -0,0 +1,105 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 18.0 + {60A07B8C-440D-4D30-B4AE-203B450C4EF3} + Win32Proj + winEventLimiterTests + 10.0 + NativeUnitTestProject + winEventLimiterTests + + + + DynamicLibrary + true + v145 + Unicode + false + + + DynamicLibrary + false + v145 + true + Unicode + false + + + + + + + + + + + + + + + true + + + true + + + + Use + Level3 + Disabled + $(VCInstallDir)UnitTest\include;$(SolutionDir)lib\;%(AdditionalIncludeDirectories) + _WIN32_WINNT=_WIN32_WINNT_WIN10;_DEBUG;%(PreprocessorDefinitions) + true + stdcpp20 + + + Windows + $(VCInstallDir)UnitTest\lib;$(OutDir);%(AdditionalLibraryDirectories) + winEventLimiterLib.lib;%(AdditionalDependencies) + + + + + Level3 + Use + MaxSpeed + true + true + $(VCInstallDir)UnitTest\include;$(SolutionDir)lib\;%(AdditionalIncludeDirectories) + _WIN32_WINNT=_WIN32_WINNT_WIN10;NDEBUG;%(PreprocessorDefinitions) + true + stdcpp20 + + + Windows + true + true + $(VCInstallDir)UnitTest\lib;$(OutDir);%(AdditionalLibraryDirectories) + winEventLimiterLib.lib;%(AdditionalDependencies) + + + + + + + + Create + Create + + + + + + + diff --git a/nvdaHelper/winEventLimiter/tests/winEventLimiterTests.vcxproj.filters b/nvdaHelper/winEventLimiter/tests/winEventLimiterTests.vcxproj.filters new file mode 100644 index 00000000000..ba661fcb490 --- /dev/null +++ b/nvdaHelper/winEventLimiter/tests/winEventLimiterTests.vcxproj.filters @@ -0,0 +1,30 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Header Files + + + + + Source Files + + + Source Files + + + \ No newline at end of file diff --git a/nvdaHelper/winEventLimiter/winEventLimiter.sln b/nvdaHelper/winEventLimiter/winEventLimiter.sln new file mode 100644 index 00000000000..3d64293f815 --- /dev/null +++ b/nvdaHelper/winEventLimiter/winEventLimiter.sln @@ -0,0 +1,53 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.36231.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "WinEventLogger", "logger\WinEventLogger.vcxproj", "{8BA10293-4201-4C53-BFA2-163FC4C969E6}" + ProjectSection(ProjectDependencies) = postProject + {FA4E9CA9-96CF-42B4-BA60-6525C8FB94A2} = {FA4E9CA9-96CF-42B4-BA60-6525C8FB94A2} + {72D4ECCA-A420-4523-A7CD-88EE06C26829} = {72D4ECCA-A420-4523-A7CD-88EE06C26829} + EndProjectSection +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "winEventLimiterDll", "dll\winEventLimiterDll.vcxproj", "{FA4E9CA9-96CF-42B4-BA60-6525C8FB94A2}" + ProjectSection(ProjectDependencies) = postProject + {72D4ECCA-A420-4523-A7CD-88EE06C26829} = {72D4ECCA-A420-4523-A7CD-88EE06C26829} + EndProjectSection +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "winEventLimiterTests", "tests\winEventLimiterTests.vcxproj", "{60A07B8C-440D-4D30-B4AE-203B450C4EF3}" + ProjectSection(ProjectDependencies) = postProject + {72D4ECCA-A420-4523-A7CD-88EE06C26829} = {72D4ECCA-A420-4523-A7CD-88EE06C26829} + EndProjectSection +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "winEventLimiterLib", "lib\winEventLimiterLib.vcxproj", "{72D4ECCA-A420-4523-A7CD-88EE06C26829}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8BA10293-4201-4C53-BFA2-163FC4C969E6}.Debug|x64.ActiveCfg = Debug|x64 + {8BA10293-4201-4C53-BFA2-163FC4C969E6}.Debug|x64.Build.0 = Debug|x64 + {8BA10293-4201-4C53-BFA2-163FC4C969E6}.Release|x64.ActiveCfg = Release|x64 + {8BA10293-4201-4C53-BFA2-163FC4C969E6}.Release|x64.Build.0 = Release|x64 + {FA4E9CA9-96CF-42B4-BA60-6525C8FB94A2}.Debug|x64.ActiveCfg = Debug|x64 + {FA4E9CA9-96CF-42B4-BA60-6525C8FB94A2}.Debug|x64.Build.0 = Debug|x64 + {FA4E9CA9-96CF-42B4-BA60-6525C8FB94A2}.Release|x64.ActiveCfg = Release|x64 + {FA4E9CA9-96CF-42B4-BA60-6525C8FB94A2}.Release|x64.Build.0 = Release|x64 + {60A07B8C-440D-4D30-B4AE-203B450C4EF3}.Debug|x64.ActiveCfg = Debug|x64 + {60A07B8C-440D-4D30-B4AE-203B450C4EF3}.Debug|x64.Build.0 = Debug|x64 + {60A07B8C-440D-4D30-B4AE-203B450C4EF3}.Release|x64.ActiveCfg = Release|x64 + {60A07B8C-440D-4D30-B4AE-203B450C4EF3}.Release|x64.Build.0 = Release|x64 + {72D4ECCA-A420-4523-A7CD-88EE06C26829}.Debug|x64.ActiveCfg = Debug|x64 + {72D4ECCA-A420-4523-A7CD-88EE06C26829}.Debug|x64.Build.0 = Debug|x64 + {72D4ECCA-A420-4523-A7CD-88EE06C26829}.Release|x64.ActiveCfg = Release|x64 + {72D4ECCA-A420-4523-A7CD-88EE06C26829}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {58E64B05-D5B5-4B1A-B9C1-65C81E5483D8} + EndGlobalSection +EndGlobal diff --git a/projectDocs/dev/buildSystemNotes.md b/projectDocs/dev/buildSystemNotes.md index f0a4452e405..e2729ad1194 100644 --- a/projectDocs/dev/buildSystemNotes.md +++ b/projectDocs/dev/buildSystemNotes.md @@ -21,6 +21,7 @@ These are the only files expected to be executed directly by a user/developer: * `scons.bat` * `runnvda.bat` * `rununittests.bat` +* `runcxxtests.bat` * `runsystemtests.bat` * `runlint.bat` * `runlicensecheck.bat` diff --git a/projectDocs/testing/automated.md b/projectDocs/testing/automated.md index d0e6293c7e6..13875718e1d 100644 --- a/projectDocs/testing/automated.md +++ b/projectDocs/testing/automated.md @@ -80,6 +80,19 @@ rununittests -k test_cursorManager.TestMove -k test_cursorManager.TestSelection Please refer to [unittest's documentation](https://docs.python.org/3/library/unittest.html#command-line-interface) for further information on how to filter tests. +## C++ Unit Tests + +C++ unit tests cover native NVDA components (currently `nvdaHelper/winEventLimiter`). +They use the [Microsoft C++ Unit Test framework](https://learn.microsoft.com/en-gb/visualstudio/test/how-to-use-microsoft-test-framework-for-cpp) and can be run with the `runcxxtests.bat` script, which builds the test project with MSBuild and runs it with `vstest.console.exe`. +They can also be built and run from Visual Studio's Test Explorer by opening `nvdaHelper\winEventLimiter\winEventLimiter.sln`. + +Any arguments given to `runcxxtests.bat` are forwarded onto `vstest.console.exe`. +For example, to run only tests whose name contains `alwaysAllowed`: + +```cmd +runcxxtests /TestCaseFilter:"FullyQualifiedName~alwaysAllowed" +``` + ## System Tests System tests can be run with the `runsystemtests.bat --include ` script. diff --git a/runcxxtests.bat b/runcxxtests.bat new file mode 100644 index 00000000000..fbed7e318af --- /dev/null +++ b/runcxxtests.bat @@ -0,0 +1,40 @@ +@echo off +rem Builds and runs the C++ unit tests for nvdaHelper components +rem (currently nvdaHelper\winEventLimiter). +rem Requires Visual Studio with the Desktop development with C++ workload, +rem including the C++ Unit Test framework. +rem Extra arguments are passed to vstest.console.exe, e.g. +rem runcxxtests.bat /TestCaseFilter:"FullyQualifiedName~alwaysAllowed" +set hereOrig=%~dp0 +set here=%hereOrig% +if #%hereOrig:~-1%# == #\# set here=%hereOrig:~0,-1% +set testOutput=%here%\testOutput\cxx +md "%testOutput%" >nul 2>&1 + +set vswhere=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe +if not exist "%vswhere%" ( + echo ERROR: vswhere.exe not found. Install Visual Studio with the C++ workload. + exit /b 1 +) +for /f "usebackq tokens=*" %%i in (`"%vswhere%" -latest -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe`) do set msbuild=%%i +if not defined msbuild ( + echo ERROR: MSBuild not found. Install Visual Studio with the C++ workload. + exit /b 1 +) + +rem The project files pin the toolchain NVDA currently uses. +rem Set CXXTEST_PLATFORMTOOLSET to override it, +rem e.g. set CXXTEST_PLATFORMTOOLSET=v143 for Visual Studio 2022. +set toolsetArg= +if defined CXXTEST_PLATFORMTOOLSET set toolsetArg=/p:PlatformToolset=%CXXTEST_PLATFORMTOOLSET% + +"%msbuild%" "%here%\nvdaHelper\winEventLimiter\winEventLimiter.sln" /m /v:minimal /t:winEventLimiterTests /p:Configuration=Release /p:Platform=x64 %toolsetArg% +if %errorlevel% neq 0 exit /b %errorlevel% + +for /f "usebackq tokens=*" %%i in (`"%vswhere%" -latest -products * -find Common7\IDE\Extensions\TestPlatform\vstest.console.exe`) do set vstest=%%i +if not defined vstest ( + echo ERROR: vstest.console.exe not found. Install Visual Studio with the C++ workload. + exit /b 1 +) +"%vstest%" "%here%\nvdaHelper\winEventLimiter\x64\Release\winEventLimiterTests.dll" "/logger:trx;LogFileName=cxxTests.trx" /ResultsDirectory:"%testOutput%" %* +exit /b %errorlevel% diff --git a/source/IAccessibleHandler/__init__.py b/source/IAccessibleHandler/__init__.py index 79e83ec8e5d..3fd27bf49f0 100644 --- a/source/IAccessibleHandler/__init__.py +++ b/source/IAccessibleHandler/__init__.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2025 NV Access Limited, Łukasz Golonka, Leonard de Ruijter +# Copyright (C) 2006-2026 NV Access Limited, Łukasz Golonka, Leonard de Ruijter, Bill Dengler # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -908,6 +908,35 @@ def processShowWinEvent(window, objectID, childID): eventHandler.queueEvent(*NVDAEvent) +def _correctFocusForDestroyedCandidateWindow( + window: int | None = None, +) -> None: + """Move focus out of an IME candidate window that was destroyed. + + When C{window} is C{None}, the limiter lost the destroy event and its + window identity is unavailable. In that case, only correct focus when the + focused candidate window no longer exists. + """ + # Specific support for input method MSAA candidate lists. + # Their parent could be a composition string, so generic focus correction + # cannot be used. (#2695) + focus = api.getFocusObject() + from NVDAObjects.IAccessible.mscandui import BaseCandidateItem + + if not isinstance(focus, BaseCandidateItem): + return + if window is None: + if winUser.isWindow(focus.windowHandle): + return + elif window != focus.windowHandle: + return + if eventHandler.isPendingEvents("gainFocus"): + return + obj = focus.container + if obj: + eventHandler.queueEvent("gainFocus", obj) + + def processDestroyWinEvent(window, objectID, childID): """Process a destroy win event. This removes the object associated with the event parameters from L{liveNVDAObjectTable} if @@ -921,22 +950,18 @@ def processDestroyWinEvent(window, objectID, childID): del liveNVDAObjectTable[(window, objectID, childID)] except KeyError: pass - # Specific support for input method MSAA candidate lists. - # When their window is destroyed we must correct focus to its parent - which could be a composition string - # so can't use generic focus correction. (#2695) - focus = api.getFocusObject() - from NVDAObjects.IAccessible.mscandui import BaseCandidateItem + if objectID == 0 and childID == 0: + _correctFocusForDestroyedCandidateWindow(window) - if ( - objectID == 0 - and childID == 0 - and isinstance(focus, BaseCandidateItem) - and window == focus.windowHandle - and not eventHandler.isPendingEvents("gainFocus") - ): - obj = focus.container - if obj: - eventHandler.queueEvent("gainFocus", obj) + +def _recoverFromLostDestroyEvents() -> None: + # A stale entry could be matched to an unrelated object when Windows + # recycles its window handle, so every cached object is suspect. + liveNVDAObjectTable.clear() + # The lost event might be the window-level destroy for the focused IME + # candidate. Its HWND is unknown, so apply the #2695 correction only if the + # focused candidate window is no longer live. + _correctFocusForDestroyedCandidateWindow() def processMenuStartWinEvent(eventID, window, objectID, childID, validFocus): @@ -1017,23 +1042,43 @@ def initialize(): # C901 'pumpAll' is too complex def pumpAll(): # noqa: C901 - if not internalWinEventHandler._shouldGetEvents(): - return + destroysLost, destroyedWinEvents = internalWinEventHandler.fetchDestroyEvents() + if destroysLost: + try: + _recoverFromLostDestroyEvents() + except Exception: + log.error("_recoverFromLostDestroyEvents", exc_info=True) + for destroyedWinEvent in destroyedWinEvents: + try: + internalWinEventHandler.processDestroyWinEvent(*destroyedWinEvent) + except Exception: + log.error("processDestroyWinEvent", exc_info=True) focusWinEvents = [] validFocus = False fakeFocusEvent = None focus = eventHandler.lastQueuedFocusObject - alwaysAllowedObjects = [] # winEvents for the currently focused object are special, - # and should be never filtered out. + # and should never be filtered out. + alwaysAllowedObject: Optional[Tuple[int, int, int]] = None if isinstance(focus, NVDAObjects.IAccessible.IAccessible) and focus.event_objectID is not None: - alwaysAllowedObjects.append((focus.event_windowHandle, focus.event_objectID, focus.event_childID)) - - # Receive all the winEvents from the limiter for this cycle - winEvents: list[tuple[int, int, int, int]] = internalWinEventHandler.winEventLimiter.flushEvents( - alwaysAllowedObjects, - ) + alwaysAllowedObject = (focus.event_windowHandle, focus.event_objectID, focus.event_childID) + + # Receive all events from the active limiter, including explicitly added + # events. + # #3831: either implementation can hold the whole cycle while the + # foreground settles. + deferred, winEvents = internalWinEventHandler.flushEvents(alwaysAllowedObject) + # A destroy can be eliminated from the DLL's input buffer, or remain pending when the + # defer cap permits regular events. Sample loss again before processing them. + if internalWinEventHandler.takeLostDestroyEvents(): + try: + _recoverFromLostDestroyEvents() + except Exception: + log.error("_recoverFromLostDestroyEvents", exc_info=True) + focus = eventHandler.lastQueuedFocusObject + if deferred: + return for winEvent in winEvents: isEventOnCaret = winEvent[2] == winUser.OBJID_CARET diff --git a/source/IAccessibleHandler/internalWinEventHandler.py b/source/IAccessibleHandler/internalWinEventHandler.py index 4669248406e..fff79562007 100644 --- a/source/IAccessibleHandler/internalWinEventHandler.py +++ b/source/IAccessibleHandler/internalWinEventHandler.py @@ -1,17 +1,21 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2020-2025 NV Access Limited +# Copyright (C) 2020-2026 NV Access Limited, Bill Dengler # This file is covered by the GNU General Public License. # See the file COPYING for more details. """ -Provides a non-threaded (limited by GIL) Windows Event Hook and processing. +Provides Windows event hooks and processing using either the in-process limiter +or the optional external limiter DLL. """ -from ctypes import c_int +from ctypes import ArgumentError, CDLL, c_int, wintypes +import threading -from typing import Dict, Callable +from typing import Any, Callable, Dict +import config import core +from NVDAHelper import winEventLimiter as winEventLimiterLib from winBindings.user32 import WINEVENTPROC import winUser from .utils import getWinEventLogInfo, isMSAADebugLoggingEnabled @@ -25,6 +29,13 @@ # The win event limiter for all winEvents winEventLimiter = OrderedWinEventLimiter() +# The loaded winEventLimiter dll, only set while initialized in external mode. +_externalWinEventLimiterDll: CDLL | None = None + +# UIA initializes first and may explicitly add an event from a callback thread. +# Hold those calls until initialize has published the selected limiter. +_limiterSelectionComplete: threading.Event = threading.Event() + # #3831: Stuff related to deferring of events for foreground changes. # See pumpAll for details. MAX_FOREGROUND_DEFERS = 2 @@ -65,6 +76,14 @@ _processDestroyWinEvent = None +def processDestroyWinEvent(window: int, objectID: int, childID: int) -> None: + """Call the destroy handler registered by L{initialize}, if still active.""" + handler = _processDestroyWinEvent + if handler is None: + return + handler(window, objectID, childID) + + # C901: winEventCallback is too complex def winEventCallback( handle: int | None, @@ -75,6 +94,8 @@ def winEventCallback( threadID: int, timestamp: int, ) -> None: # noqa: C901 + # Keep this logic aligned with MessagePumpThread::_handleWinEvent + # and EventLimiterThread::_preprocessEvent in the external limiter. if window is None: window = 0 if isMSAADebugLoggingEnabled(): @@ -99,7 +120,7 @@ def winEventCallback( ) return if eventID == winUser.EVENT_OBJECT_DESTROY: - _processDestroyWinEvent(window, objectID, childID) + processDestroyWinEvent(window, objectID, childID) return # Change window objIDs to client objIDs for better reporting of objects if (objectID == 0) and (childID == 0): @@ -201,6 +222,85 @@ def winEventCallback( winEventHookIDs = [] +@winEventLimiterLib.NotifyCallback +def _newEventsCallback(hasFocusEvent: int): + # #14928: focus events are pumped immediately rather than after a delay. + core.requestPump(immediate=bool(hasFocusEvent)) + + +def addEvent( + eventID: int, + window: int, + objectID: int, + childID: int, + threadID: int, +) -> bool: + """Add a winEvent to whichever limiter is active. + + Events explicitly added while the external limiter is active must enter the DLL's + input buffer. This preserves their chronology relative to hooked winEvents before + limiting and focus selection. + + Returns Whether the event was accepted by the active limiter. + """ + _limiterSelectionComplete.wait() + dll = _externalWinEventLimiterDll + if dll is not None: + return ( + dll.winEventLimiter_addEvent( + eventID, + window, + objectID, + childID, + threadID, + ) + == 0 + ) + return winEventLimiter.addEvent( + eventID, + window, + objectID, + childID, + threadID, + ) + + +def _initializeExternal() -> CDLL | None: + """Load the external winEventLimiter dll and start receiving winEvents from it. + + Returns the started dll, or None if it could not be loaded or failed to start. + """ + try: + dll = winEventLimiterLib.getWinEventLimiterDll() + eventIds = sorted(winEventIDsToNVDAEventNames) + eventIdsArg = (wintypes.DWORD * len(eventIds))(*eventIds) + res = dll.winEventLimiter_start( + _newEventsCallback, + eventIdsArg, + len(eventIds), + ) + except (OSError, AttributeError, ArgumentError): + log.error("Could not load or start the winEventLimiter dll", exc_info=True) + return None + if res != 0: + log.error(f"Could not start the winEventLimiter dll, error code {res}") + return None + return dll + + +def _registerInternalHooks(): + """Hook winEvents on NVDA's main thread, processing them with the in-process limiter.""" + for eventType in winEventIDsToNVDAEventNames: + hookID = winUser.setWinEventHook(eventType, eventType, 0, cWinEventCallback, 0, 0, 0) + if hookID: + winEventHookIDs.append(hookID) + else: + log.error( + f"initialize: could not register callback for" + f" event {eventType} ({winEventIDsToNVDAEventNames[eventType]})", + ) + + def initialize( processDestroyWinEventFunc: Callable[ [ @@ -211,23 +311,195 @@ def initialize( None, ], ): - global _processDestroyWinEvent - _processDestroyWinEvent = processDestroyWinEventFunc - for eventType in winEventIDsToNVDAEventNames: - hookID = winUser.setWinEventHook(eventType, eventType, 0, cWinEventCallback, 0, 0, 0) - if hookID: - winEventHookIDs.append(hookID) - else: - log.error( - f"initialize: could not register callback for" - f" event {eventType} ({winEventIDsToNVDAEventNames[eventType]})", - ) + global _externalWinEventLimiterDll, _processDestroyWinEvent + _limiterSelectionComplete.clear() + try: + _processDestroyWinEvent = processDestroyWinEventFunc + # Read once at initialization: changing the flag requires an NVDA restart. + shouldUseExternal = bool(config.conf["IAccessible"]["externalWinEventLimiter"]) + dll: CDLL | None = _initializeExternal() if shouldUseExternal else None + if dll is None: + # Either the external limiter is disabled, + # or it failed to load or start; + # fall back to hooking winEvents on NVDA's main thread. + _registerInternalHooks() + _externalWinEventLimiterDll = dll + finally: + # UIA callbacks must never remain blocked if initialization raises. + _limiterSelectionComplete.set() def terminate(): + global _externalWinEventLimiterDll + global _processDestroyWinEvent, _deferUntilForegroundWindow, _foregroundDefers + dll = _externalWinEventLimiterDll + _externalWinEventLimiterDll = None + # UIA terminates after IAccessible, so its shutdown callbacks must not block + # waiting for a limiter that will never be initialized again. + _limiterSelectionComplete.set() + if dll is not None: + dll.winEventLimiter_stop() for handle in winEventHookIDs: winUser.unhookWinEvent(handle) winEventHookIDs.clear() + # Reset the state that initialize() and event processing established, so a stale + # destroy handler or foreground defer can never leak into a later initialize + # (or from one unit test into another). + _processDestroyWinEvent = None + _deferUntilForegroundWindow = None + _foregroundDefers = 0 + winEventLimiter.clear() + consoleWindowsToThreadIDs.clear() + + +def _fetchExternalEventData( + flush: Callable[[], int | None], + getCount: Callable[[], int], + getEvents: Callable[[int, int, Any], int], +) -> tuple[bool, list[tuple[int, int, int, int, int]]]: + """Flush and fetch one of the external limiter's event queues.""" + # Only the regular flush returns 1 for a defer; the destroy flush returns + # None. + if flush() == 1: + return True, [] + eventCount = getCount() + data = (winEventLimiterLib.EventData * eventCount)() + countFetched = getEvents(0, eventCount, data) + return False, [ + ( + int(e.idEvent), + int(e.hwnd) if e.hwnd else 0, + int(e.idObject), + int(e.idChild), + int(e.dwEventThread), + ) + for e in data[:countFetched] + ] + + +def _fetchExternalDestroyEvents() -> tuple[bool, list[tuple[int, int, int]]]: + """Fetch the destroy events collected by the external winEventLimiter dll since the last + core pump. + + Destroys are snapshotted independently of the regular flush so pumpAll can process them + before it captures this cycle's focus: processing a destroy may correct the focus + (#2695), and the in-process implementation handles destroys at hook-callback time, + which is always before pumpAll runs. They are never held back by a foreground defer, + for the same reason. + + Returns a (destroysLost, destroys) tuple. + destroysLost is True when the dll could not preserve per-window destroy handling, + either because events were discarded under a sustained flood or a pending destroy + reached the regular-event defer cap. The caller must then treat every cached object + as potentially destroyed. + destroys are raw (window, objectID, childID) tuples for destroyed objects. + """ + dll = _externalWinEventLimiterDll + if dll is None: + return False, [] + _deferred, destroyEvents = _fetchExternalEventData( + dll.winEventLimiter_flushDestroyEvents, + dll.winEventLimiter_getDestroyEventCount, + dll.winEventLimiter_getDestroyEvents, + ) + destroysLost = takeLostDestroyEvents() + if destroyEvents and isMSAADebugLoggingEnabled(): + log.debug( + f"Number of destroy events fetched from the winEventLimiter dll: {len(destroyEvents)}", + ) + return destroysLost, [ + (window, objectID, childID) for _eventID, window, objectID, childID, _threadID in destroyEvents + ] + + +def takeLostDestroyEvents() -> bool: + """Report and consume the external limiter's pending destroy-recovery flag.""" + dll = _externalWinEventLimiterDll + if dll is None: + return False + destroysLost = bool(dll.winEventLimiter_takeLostDestroys()) + if destroysLost: + log.warning( + "The winEventLimiter dll could not preserve per-window destroy " + "handling; treating every cached object as potentially destroyed", + ) + return destroysLost + + +def fetchDestroyEvents() -> tuple[bool, list[tuple[int, int, int]]]: + """Fetch pending destroys. + + Returns a (destroysLost, destroys) tuple (see L{_fetchExternalDestroyEvents}). + Both are falsy when the internal limiter is active: it processes destroys at + hook-callback time and never sheds them. + """ + return _fetchExternalDestroyEvents() + + +def _fetchExternalEvents( + alwaysAllowedObject: tuple[int, int, int] | None = None, +) -> tuple[bool, list[tuple[int, int, int, int]]]: + """Fetch the winEvents collected by the external winEventLimiter dll since the last core pump. + + Per-event decisions made inside the dll (dropping, remapping and limiting) are not + logged; only the events that survive limiting are visible here. + + alwaysAllowedObject is a (window, objectID, childID) tuple of the object whose events + must never be dropped by the dll's per-thread limit, i.e. the focused object (#11520), + or None to clear the exemption. + Returns a (deferred, winEvents) tuple. + deferred is True when the dll deferred the flush because a foreground + change is still settling (#3831), or a destroy arrived after this cycle's + destroy snapshot. Nothing was flushed, the dll has already requested another + pump, and the caller should hold its whole cycle. + winEvents are (eventID, window, objectID, childID) tuples in + chronological order. + """ + dll = _externalWinEventLimiterDll + if dll is None: + return False, [] + normalizedAlwaysAllowedObject = alwaysAllowedObject or (0, 0, 0) + dll.winEventLimiter_setAlwaysAllowedObject(*normalizedAlwaysAllowedObject) + deferred, fetchedEvents = _fetchExternalEventData( + dll.winEventLimiter_flushEvents, + dll.winEventLimiter_getEventCount, + dll.winEventLimiter_getEvents, + ) + if deferred: + if isMSAADebugLoggingEnabled(): + log.debug( + "The winEventLimiter dll deferred this flush for a pending " + "foreground change or destroy event", + ) + return True, [] + debugLogging = isMSAADebugLoggingEnabled() + if debugLogging: + log.debug( + f"Number of events fetched from the winEventLimiter dll: {len(fetchedEvents)}", + ) + winEvents = [] + for eventID, window, objectID, childID, threadID in fetchedEvents: + if debugLogging: + log.debug( + f"Emitting winEvent {getWinEventLogInfo(window, objectID, childID, eventID, threadID)}", + ) + winEvents.append((eventID, window, objectID, childID)) + return False, winEvents + + +def flushEvents( + alwaysAllowedObject: tuple[int, int, int] | None = None, +) -> tuple[bool, list[tuple[int, int, int, int]]]: + """Flush the active limiter and report whether this cycle was deferred.""" + dll = _externalWinEventLimiterDll + if dll is not None: + return _fetchExternalEvents(alwaysAllowedObject) + if not _shouldGetEvents(): + return True, [] + allowedObjects: list[tuple[int, int, int]] = [] + if alwaysAllowedObject is not None: + allowedObjects.append(alwaysAllowedObject) + return False, winEventLimiter.flushEvents(allowedObjects) def _shouldGetEvents(): @@ -270,4 +542,16 @@ def _shouldGetEvents(): # In order to handle speaking of typed characters etc. # winEventCallback adds these whenever it sees an event for ConsoleWindowClass windows, # As winEvents always contain the true thread ID. +# Only populated in internal mode; use L{getConsoleWindowThreadID} to also cover +# external mode, where the winEventLimiter dll records the mapping instead. consoleWindowsToThreadIDs: Dict[int, int] = {} + + +def getConsoleWindowThreadID(window: int) -> int: + """The thread a console (ConsoleWindowClass) window was really created in (#10113, #10554), + whichever winEvent source is active, or 0 if unknown. + """ + dll = _externalWinEventLimiterDll + if dll is not None: + return dll.winEventLimiter_getConsoleThreadID(window) + return consoleWindowsToThreadIDs.get(window, 0) diff --git a/source/IAccessibleHandler/orderedWinEventLimiter.py b/source/IAccessibleHandler/orderedWinEventLimiter.py index 2e6575d8231..ab09da0cf3a 100644 --- a/source/IAccessibleHandler/orderedWinEventLimiter.py +++ b/source/IAccessibleHandler/orderedWinEventLimiter.py @@ -80,6 +80,13 @@ def addEvent( self._genericEventCache[(eventID, window, objectID, childID, threadID)] = next(self._eventCounter) return True + def clear(self) -> None: + """Discard all events queued for a later flush.""" + self._focusEventCache.clear() + self._genericEventCache.clear() + self._eventHeap.clear() + self._lastMenuEvent = None + def flushEvents( self, alwaysAllowedObjects: Optional[List[IAccessibleObjectIdentifierType]] = None, diff --git a/source/NVDAHelper/winEventLimiter.py b/source/NVDAHelper/winEventLimiter.py new file mode 100644 index 00000000000..9ad319801f7 --- /dev/null +++ b/source/NVDAHelper/winEventLimiter.py @@ -0,0 +1,113 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2019-2026 NV Access Limited, Bill Dengler +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Wrapper for the winEventLimiter dll, +which registers for winEvents and limits them on a dedicated thread, +outside of NVDA's core (Python) event loop. + +.. seealso:: + ``nvdaHelper/winEventLimiter`` +""" + +import ctypes +from ctypes import wintypes + +import NVDAState + + +class EventData(ctypes.Structure): + """A single winEvent, as collected by the winEventLimiter dll. + + .. seealso:: + ``nvdaHelper/winEventLimiter/lib/eventData.h`` + """ + + _fields_ = ( + ("idEvent", wintypes.DWORD), + ("hwnd", wintypes.HWND), + ("idObject", wintypes.LONG), + ("idChild", wintypes.LONG), + ("dwEventThread", wintypes.DWORD), + ("dwmsEventTime", wintypes.DWORD), + ("bypassesPreprocessing", ctypes.c_bool), + ) + + +NotifyCallback = ctypes.CFUNCTYPE(None, ctypes.c_int) +"""Function called by the winEventLimiter dll when new events are available to fetch. +The argument is non-zero when the new events include a focus event, +so the client can pump immediately rather than after a delay (#14928). +""" + + +def getWinEventLimiterDll() -> ctypes.CDLL: + """Load the winEventLimiter dll and configure its function prototypes. + + :raises OSError: If the dll could not be loaded. + :return: The loaded dll. + """ + dll = ctypes.cdll.LoadLibrary(NVDAState.ReadPaths.winEventLimiterDll) + + dll.winEventLimiter_getEvents.restype = ctypes.c_uint + dll.winEventLimiter_getEvents.argtypes = ( + ctypes.c_uint, # eventIndex + ctypes.c_uint, # maxEvents + ctypes.POINTER(EventData), # data + ) + + dll.winEventLimiter_getEventCount.restype = ctypes.c_uint + dll.winEventLimiter_getEventCount.argtypes = () + + dll.winEventLimiter_flushDestroyEvents.restype = None + dll.winEventLimiter_flushDestroyEvents.argtypes = () + + dll.winEventLimiter_getDestroyEvents.restype = ctypes.c_uint + dll.winEventLimiter_getDestroyEvents.argtypes = ( + ctypes.c_uint, # eventIndex + ctypes.c_uint, # maxEvents + ctypes.POINTER(EventData), # data + ) + + dll.winEventLimiter_getDestroyEventCount.restype = ctypes.c_uint + dll.winEventLimiter_getDestroyEventCount.argtypes = () + + dll.winEventLimiter_takeLostDestroys.restype = ctypes.c_int + dll.winEventLimiter_takeLostDestroys.argtypes = () + + dll.winEventLimiter_flushEvents.restype = ctypes.c_int + dll.winEventLimiter_flushEvents.argtypes = () + + dll.winEventLimiter_start.restype = ctypes.c_int + dll.winEventLimiter_start.argtypes = ( + NotifyCallback, # notifyOfNewEventsCallback + ctypes.POINTER(wintypes.DWORD), # eventIds + ctypes.c_uint, # eventIdCount + ) + + dll.winEventLimiter_stop.restype = ctypes.c_int + dll.winEventLimiter_stop.argtypes = () + + dll.winEventLimiter_addEvent.restype = ctypes.c_int + dll.winEventLimiter_addEvent.argtypes = ( + wintypes.DWORD, # eventID + wintypes.HWND, # window + wintypes.LONG, # objectID + wintypes.LONG, # childID + wintypes.DWORD, # threadID + ) + + dll.winEventLimiter_setAlwaysAllowedObject.restype = ctypes.c_int + dll.winEventLimiter_setAlwaysAllowedObject.argtypes = ( + wintypes.HWND, # hwnd + wintypes.LONG, # idObject + wintypes.LONG, # idChild + ) + + dll.winEventLimiter_getConsoleThreadID.restype = wintypes.DWORD + dll.winEventLimiter_getConsoleThreadID.argtypes = ( + wintypes.HWND, # hwnd + ) + + return dll diff --git a/source/NVDAObjects/UIA/winConsoleUIA.py b/source/NVDAObjects/UIA/winConsoleUIA.py index 4230cc46efc..806e7019915 100644 --- a/source/NVDAObjects/UIA/winConsoleUIA.py +++ b/source/NVDAObjects/UIA/winConsoleUIA.py @@ -1,7 +1,7 @@ # A part of NonVisual Desktop Access (NVDA) # This file is covered by the GNU General Public License. # See the file COPYING for more details. -# Copyright (C) 2019-2023 Bill Dengler, Leonard de Ruijter +# Copyright (C) 2019-2026 Bill Dengler, Leonard de Ruijter import api import braille @@ -383,9 +383,9 @@ def _get_windowThreadID(self): # However, To correctly handle speaking of typed characters, # NVDA really requires the real thread the window was created in, # I.e. a thread inside conhost. - from IAccessibleHandler.internalWinEventHandler import consoleWindowsToThreadIDs + from IAccessibleHandler.internalWinEventHandler import getConsoleWindowThreadID - threadID = consoleWindowsToThreadIDs.get(self.windowHandle, 0) + threadID = getConsoleWindowThreadID(self.windowHandle) if not threadID: threadID = super().windowThreadID return threadID diff --git a/source/NVDAObjects/window/winConsole.py b/source/NVDAObjects/window/winConsole.py index ed4d78f5a4d..06b398818d0 100644 --- a/source/NVDAObjects/window/winConsole.py +++ b/source/NVDAObjects/window/winConsole.py @@ -1,7 +1,7 @@ # A part of NonVisual Desktop Access (NVDA) # This file is covered by the GNU General Public License. # See the file COPYING for more details. -# Copyright (C) 2007-2020 NV Access Limited, Bill Dengler +# Copyright (C) 2007-2026 NV Access Limited, Bill Dengler import winConsoleHandler from . import Window @@ -27,9 +27,9 @@ def _get_windowThreadID(self): # However, To correctly handle speaking of typed characters, # NVDA really requires the real thread the window was created in, # I.e. a thread inside conhost. - from IAccessibleHandler.internalWinEventHandler import consoleWindowsToThreadIDs + from IAccessibleHandler.internalWinEventHandler import getConsoleWindowThreadID - threadID = consoleWindowsToThreadIDs.get(self.windowHandle, 0) + threadID = getConsoleWindowThreadID(self.windowHandle) if not threadID: threadID = super().windowThreadID return threadID diff --git a/source/NVDAState.py b/source/NVDAState.py index a1431ff7f19..6f5f8f19800 100644 --- a/source/NVDAState.py +++ b/source/NVDAState.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022-2025 NV Access Limited +# Copyright (C) 2022-2026 NV Access Limited, Bill Dengler # This file may be used under the terms of the GNU General Public License, version 2 or later. # For more details see: https://www.gnu.org/licenses/gpl-2.0.html @@ -259,6 +259,10 @@ def nvdaHelperLocalDll(self) -> str: def nvdaHelperLocalWin10Dll(self) -> str: return os.path.join(self.coreArchLibPath, "nvdaHelperLocalWin10.dll") + @property + def winEventLimiterDll(self) -> str: + return os.path.join(self.coreArchLibPath, "winEventLimiter.dll") + @property def cppjiebaDll(self) -> str: return os.path.join(self.coreArchLibPath, "cppjieba.dll") diff --git a/source/UIAHandler/__init__.py b/source/UIAHandler/__init__.py index 15c6a8481fa..67cedf2c9de 100644 --- a/source/UIAHandler/__init__.py +++ b/source/UIAHandler/__init__.py @@ -1505,7 +1505,7 @@ def _emitMSAAFocusForWordDocIfNecessary(self, element: UIA.IUIAutomationElement) and self.getNearestWindowHandle(element) == oldFocus.windowHandle and not self.isUIAWindow(oldFocus.windowHandle) ): - IAccessibleHandler.internalWinEventHandler.winEventLimiter.addEvent( + IAccessibleHandler.internalWinEventHandler.addEvent( winUser.EVENT_OBJECT_FOCUS, oldFocus.windowHandle, winUser.OBJID_CLIENT, diff --git a/source/config/configSpec.py b/source/config/configSpec.py index b87ae2d578a..cce723fa085 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -307,6 +307,11 @@ allowInMSWord = integer(0, 3, default=0) enhancedEventProcessing = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled") +[IAccessible] + # Receive and limit winEvents on a dedicated thread in the external winEventLimiter dll, + # rather than hooking them on NVDA's main thread. Requires restart. + externalWinEventLimiter = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="disabled") + [annotations] reportDetails = boolean(default=true) reportAriaDescription = boolean(default=true) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 5a88d99d176..c915614852a 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -4466,6 +4466,22 @@ def __init__(self, parent): ) self.bindHelpEvent("UIAEnhancedEventProcessing", self.enhancedEventProcessingComboBox) + # Translators: This is the label for a group of advanced options in the + # Advanced settings panel + label = _("Microsoft Active Accessibility") + msaaSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=label) + msaaGroup = guiHelper.BoxSizerHelper(self, sizer=msaaSizer) + sHelper.addItem(msaaGroup) + + self.externalWinEventLimiterCombo: nvdaControls.FeatureFlagCombo = msaaGroup.addLabeledControl( + # Translators: This is the label for a combo box in the Advanced settings panel. + labelText=_("Process winE&vents on a dedicated thread (requires restart)"), + wxCtrlClass=nvdaControls.FeatureFlagCombo, + keyPath=["IAccessible", "externalWinEventLimiter"], + conf=config.conf, + ) + self.bindHelpEvent("ExternalWinEventLimiter", self.externalWinEventLimiterCombo) + # Translators: This is the label for a group of advanced options in the # Advanced settings panel label = _("Annotations") @@ -4838,6 +4854,7 @@ def haveConfigDefaultsBeenRestored(self): and self.consoleCombo.GetSelection() == self.consoleCombo.defaultValue and self.UIAInChromiumCombo.GetSelection() == self.UIAInChromiumCombo.defaultValue and self.enhancedEventProcessingComboBox.isValueConfigSpecDefault() + and self.externalWinEventLimiterCombo.isValueConfigSpecDefault() and self.annotationsDetailsCheckBox.IsChecked() == self.annotationsDetailsCheckBox.defaultValue and self.ariaDescCheckBox.IsChecked() == self.ariaDescCheckBox.defaultValue and self.brailleLiveRegionsCombo.isValueConfigSpecDefault() @@ -4873,6 +4890,7 @@ def restoreToDefaults(self): self.consoleCombo.SetSelection(self.consoleCombo.defaultValue) self.UIAInChromiumCombo.SetSelection(self.UIAInChromiumCombo.defaultValue) self.enhancedEventProcessingComboBox.resetToConfigSpecDefault() + self.externalWinEventLimiterCombo.resetToConfigSpecDefault() self.annotationsDetailsCheckBox.SetValue(self.annotationsDetailsCheckBox.defaultValue) self.ariaDescCheckBox.SetValue(self.ariaDescCheckBox.defaultValue) self.brailleLiveRegionsCombo.resetToConfigSpecDefault() @@ -4921,6 +4939,7 @@ def onSave(self): self.speechDictsUseModernRegexCombo.saveCurrentValueToConf() config.conf["UIA"]["allowInChromium"] = self.UIAInChromiumCombo.GetSelection() self.enhancedEventProcessingComboBox.saveCurrentValueToConf() + self.externalWinEventLimiterCombo.saveCurrentValueToConf() config.conf["terminals"]["speakPasswords"] = self.winConsoleSpeakPasswordsCheckBox.IsChecked() config.conf["terminals"]["keyboardSupportInLegacy"] = self.keyboardSupportInLegacyCheckBox.IsChecked() config.conf["terminals"]["beepForSkippedLines"] = self.beepForSkippedLinesCheckBox.IsChecked() diff --git a/tests/unit/test_externalWinEventLimiter.py b/tests/unit/test_externalWinEventLimiter.py new file mode 100644 index 00000000000..af7112031c8 --- /dev/null +++ b/tests/unit/test_externalWinEventLimiter.py @@ -0,0 +1,823 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Bill Dengler +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Unit tests for the external winEventLimiter integration in IAccessibleHandler.""" + +import unittest +from unittest import mock + +import api +import config +import eventHandler +import IAccessibleHandler +from IAccessibleHandler import internalWinEventHandler +from NVDAHelper import winEventLimiter as winEventLimiterLib +import NVDAObjects.IAccessible +import winUser + + +def _makeFakeDll( + events: list[tuple[int, int | None, int, int]] | None = None, + destroyEvents: list[tuple[int | None, int, int]] | None = None, + startRet: int = 0, + flushDeferred: bool = False, + lostDestroys: bool = False, +) -> mock.MagicMock: + """Create a stand-in for the loaded winEventLimiter dll. + + :param events: The ``(eventID, window, objectID, childID)`` tuples, oldest first, + that winEventLimiter_getEvents should produce. + :param destroyEvents: The ``(window, objectID, childID)`` tuples that + winEventLimiter_getDestroyEvents should produce. + :param startRet: The return value of winEventLimiter_start. + :param flushDeferred: Whether winEventLimiter_flushEvents reports the flush as + deferred for a pending foreground change (#3831). + :param lostDestroys: Whether winEventLimiter_takeLostDestroys reports that destroy + events were discarded under a sustained flood. + """ + events = events if events is not None else [] + destroyEvents = destroyEvents if destroyEvents is not None else [] + dll = mock.MagicMock() + dll.winEventLimiter_start.return_value = startRet + dll.winEventLimiter_flushEvents.return_value = 1 if flushDeferred else 0 + dll.winEventLimiter_flushDestroyEvents.return_value = None + dll.winEventLimiter_getEventCount.side_effect = lambda: len(events) + dll.winEventLimiter_getDestroyEventCount.side_effect = lambda: len(destroyEvents) + lostDestroysPending = lostDestroys + + def fakeTakeLostDestroys() -> int: + nonlocal lostDestroysPending + res = 1 if lostDestroysPending else 0 + lostDestroysPending = False + return res + + dll.winEventLimiter_takeLostDestroys.side_effect = fakeTakeLostDestroys + + def fakeAddEvent( + eventID: int, + window: int, + objectID: int, + childID: int, + threadID: int, + ) -> int: + events.append((eventID, window, objectID, childID)) + return 0 + + def fakeGetEvents(index: int, maxEvents: int, data) -> int: + numberFetched = min(len(events) - index, maxEvents) + for i in range(numberFetched): + eventID, window, objectID, childID = events[index + i] + data[i].idEvent = eventID + data[i].hwnd = window + data[i].idObject = objectID + data[i].idChild = childID + return numberFetched + + def fakeGetDestroyEvents(index: int, maxEvents: int, data) -> int: + numberFetched = min(len(destroyEvents) - index, maxEvents) + for i in range(numberFetched): + window, objectID, childID = destroyEvents[index + i] + data[i].idEvent = winUser.EVENT_OBJECT_DESTROY + data[i].hwnd = window + data[i].idObject = objectID + data[i].idChild = childID + return numberFetched + + dll.winEventLimiter_addEvent.side_effect = fakeAddEvent + dll.winEventLimiter_getEvents.side_effect = fakeGetEvents + dll.winEventLimiter_getDestroyEvents.side_effect = fakeGetDestroyEvents + return dll + + +class _FakeDllTestCase(unittest.TestCase): + """Base class installing a fake winEventLimiter dll for the duration of each test.""" + + def setUp(self) -> None: + super().setUp() + internalWinEventHandler._limiterSelectionComplete.set() + + def installFakeDll(self, fakeDll: mock.MagicMock) -> None: + patcher = mock.patch.object( + internalWinEventHandler, + "_externalWinEventLimiterDll", + fakeDll, + ) + patcher.start() + self.addCleanup(patcher.stop) + + +class Test_fetchExternalEvents(_FakeDllTestCase): + def test_fetchYieldsChronologicalOrder(self): + """Events are played back in chronological order, like the in-process limiter's + flushEvents (pumpAll relies on this to use the most recent possible focus event), + and the dll calls happen in the right order. + """ + events = [ + (winUser.EVENT_OBJECT_SHOW, 1, 11, 0), + (winUser.EVENT_OBJECT_HIDE, 2, 22, 0), + (winUser.EVENT_OBJECT_NAMECHANGE, 3, 33, 0), + ] + fakeDll = _makeFakeDll(events=events) + self.installFakeDll(fakeDll) + deferred, fetched = internalWinEventHandler._fetchExternalEvents() + self.assertFalse(deferred) + self.assertEqual(fetched, events) + callNames = [name for name, args, kwargs in fakeDll.mock_calls] + self.assertEqual( + callNames, + [ + "winEventLimiter_setAlwaysAllowedObject", + "winEventLimiter_flushEvents", + "winEventLimiter_getEventCount", + "winEventLimiter_getEvents", + ], + ) + + def test_fetchAllPendingEvents(self): + """All flushed events are fetched, matching the in-process limiter since #11520 + removed its 500 event truncation; the dll bounds its backlog itself. + """ + eventCount = 600 + events = [(winUser.EVENT_OBJECT_NAMECHANGE, n, n, n) for n in range(eventCount)] + fakeDll = _makeFakeDll(events=events) + self.installFakeDll(fakeDll) + _deferred, fetched = internalWinEventHandler._fetchExternalEvents() + self.assertEqual(fetched, events) + startIndexArg, maxEventsArg = fakeDll.winEventLimiter_getEvents.call_args.args[:2] + self.assertEqual(startIndexArg, 0) + self.assertEqual(maxEventsArg, eventCount) + + def test_fetchHwndNoneBecomesZero(self): + fakeDll = _makeFakeDll(events=[(winUser.EVENT_OBJECT_SHOW, None, 1, 2)]) + self.installFakeDll(fakeDll) + _deferred, fetched = internalWinEventHandler._fetchExternalEvents() + self.assertEqual(fetched, [(winUser.EVENT_OBJECT_SHOW, 0, 1, 2)]) + + def test_fetchReportsDeferredWithoutFetching(self): + """#3831: when the dll defers the flush for a pending foreground change, nothing + was flushed and nothing is fetched; the dll has already requested another pump. + """ + fakeDll = _makeFakeDll( + events=[(winUser.EVENT_OBJECT_NAMECHANGE, 3, 33, 0)], + flushDeferred=True, + ) + self.installFakeDll(fakeDll) + deferred, fetched = internalWinEventHandler._fetchExternalEvents() + self.assertTrue(deferred) + self.assertEqual(fetched, []) + fakeDll.winEventLimiter_getEventCount.assert_not_called() + fakeDll.winEventLimiter_getEvents.assert_not_called() + + def test_fetchReturnsRawDestroys(self): + """Destroys come back as raw (window, objectID, childID) tuples (hwnd None + becoming 0), snapshotted independently of the regular flush so they can be + processed before this cycle's focus is captured. + """ + fakeDll = _makeFakeDll(destroyEvents=[(42, 0, 0), (None, -4, 1)]) + self.installFakeDll(fakeDll) + destroysLost, destroys = internalWinEventHandler._fetchExternalDestroyEvents() + self.assertFalse(destroysLost) + self.assertEqual(destroys, [(42, 0, 0), (0, -4, 1)]) + callNames = [name for name, args, kwargs in fakeDll.mock_calls] + self.assertEqual( + callNames, + [ + "winEventLimiter_flushDestroyEvents", + "winEventLimiter_getDestroyEventCount", + "winEventLimiter_getDestroyEvents", + "winEventLimiter_takeLostDestroys", + ], + ) + + def test_fetchReportsLostDestroys(self): + """When the dll had to shed destroys under a sustained flood, the fetch reports + it, so pumpAll can treat every cached object as potentially destroyed; the + destroys that did survive are still delivered. + """ + fakeDll = _makeFakeDll(destroyEvents=[(42, 0, 0)], lostDestroys=True) + self.installFakeDll(fakeDll) + destroysLost, destroys = internalWinEventHandler._fetchExternalDestroyEvents() + self.assertTrue(destroysLost) + self.assertEqual(destroys, [(42, 0, 0)]) + + def test_fetchDestroysWithInternalLimiter(self): + """The internal limiter processes destroys at hook-callback time: nothing is + fetched, and nothing can have been lost. + """ + with mock.patch.object( + internalWinEventHandler, + "_externalWinEventLimiterDll", + None, + ): + destroysLost, destroys = internalWinEventHandler.fetchDestroyEvents() + self.assertFalse(destroysLost) + self.assertEqual(destroys, []) + + def test_setAlwaysAllowedObjectPassed(self): + fakeDll = _makeFakeDll() + self.installFakeDll(fakeDll) + internalWinEventHandler._fetchExternalEvents((123, -4, 1)) + fakeDll.winEventLimiter_setAlwaysAllowedObject.assert_called_once_with(123, -4, 1) + + def test_setAlwaysAllowedObjectCleared(self): + fakeDll = _makeFakeDll() + self.installFakeDll(fakeDll) + internalWinEventHandler._fetchExternalEvents(None) + fakeDll.winEventLimiter_setAlwaysAllowedObject.assert_called_once_with(0, 0, 0) + + def test_unchangedAlwaysAllowedObjectIsSentAgain(self): + """Each flush updates the DLL without relying on cross-lifecycle cached state.""" + fakeDll = _makeFakeDll() + self.installFakeDll(fakeDll) + alwaysAllowedObject = (123, winUser.OBJID_CLIENT, 1) + internalWinEventHandler._fetchExternalEvents(alwaysAllowedObject) + internalWinEventHandler._fetchExternalEvents(alwaysAllowedObject) + self.assertEqual( + fakeDll.winEventLimiter_setAlwaysAllowedObject.call_args_list, + [mock.call(*alwaysAllowedObject), mock.call(*alwaysAllowedObject)], + ) + + +class Test_addEvent(_FakeDllTestCase): + def test_internalLimiterUsedWhenExternalInactive(self): + with ( + mock.patch.object( + internalWinEventHandler, + "_externalWinEventLimiterDll", + None, + ), + mock.patch.object( + internalWinEventHandler.winEventLimiter, + "addEvent", + return_value=True, + ) as internalAddEvent, + ): + accepted = internalWinEventHandler.addEvent( + winUser.EVENT_OBJECT_FOCUS, + 42, + winUser.OBJID_CLIENT, + 0, + 7, + ) + self.assertTrue(accepted) + internalAddEvent.assert_called_once_with( + winUser.EVENT_OBJECT_FOCUS, + 42, + winUser.OBJID_CLIENT, + 0, + 7, + ) + + def test_externalLimiterUsedWhenActive(self): + events: list[tuple[int, int | None, int, int]] = [] + fakeDll = _makeFakeDll(events=events) + self.installFakeDll(fakeDll) + with ( + mock.patch.object( + internalWinEventHandler.winEventLimiter, + "addEvent", + ) as internalAddEvent, + ): + accepted = internalWinEventHandler.addEvent( + winUser.EVENT_OBJECT_FOCUS, + 42, + winUser.OBJID_CLIENT, + 0, + 7, + ) + self.assertTrue(accepted) + fakeDll.winEventLimiter_addEvent.assert_called_once_with( + winUser.EVENT_OBJECT_FOCUS, + 42, + winUser.OBJID_CLIENT, + 0, + 7, + ) + internalAddEvent.assert_not_called() + self.assertEqual( + events, + [(winUser.EVENT_OBJECT_FOCUS, 42, winUser.OBJID_CLIENT, 0)], + ) + + +class Test_newEventsCallback(unittest.TestCase): + def test_immediatePumpForFocus(self): + """#14928: focus events are pumped immediately rather than after a delay, + matching the in-process winEventCallback. + """ + import core + + with mock.patch.object(core, "requestPump") as requestPump: + internalWinEventHandler._newEventsCallback(0) + requestPump.assert_called_once_with(immediate=False) + requestPump.reset_mock() + internalWinEventHandler._newEventsCallback(1) + requestPump.assert_called_once_with(immediate=True) + + +class Test_getConsoleWindowThreadID(unittest.TestCase): + def test_externalModeQueriesDll(self): + fakeDll = mock.MagicMock() + fakeDll.winEventLimiter_getConsoleThreadID.return_value = 1234 + with mock.patch.object( + internalWinEventHandler, + "_externalWinEventLimiterDll", + fakeDll, + ): + self.assertEqual(internalWinEventHandler.getConsoleWindowThreadID(42), 1234) + fakeDll.winEventLimiter_getConsoleThreadID.assert_called_once_with(42) + + def test_internalModeUsesMap(self): + with ( + mock.patch.object( + internalWinEventHandler, + "_externalWinEventLimiterDll", + None, + ), + mock.patch.dict(internalWinEventHandler.consoleWindowsToThreadIDs, {42: 5678}), + ): + self.assertEqual(internalWinEventHandler.getConsoleWindowThreadID(42), 5678) + self.assertEqual(internalWinEventHandler.getConsoleWindowThreadID(43), 0) + + +class Test_destroyEvents(_FakeDllTestCase): + def test_destroysProcessedBeforeEventsInPump(self): + """Destroys are processed synchronously at the start of the pump, before any of + the cycle's other events and with the raw (unremapped) objectID, mirroring the + in-process implementation which handles them immediately on receipt (#2695). + """ + fakeDll = _makeFakeDll( + events=[(winUser.EVENT_OBJECT_NAMECHANGE, 3, 33, 0)], + destroyEvents=[(42, 0, 0)], + ) + self.installFakeDll(fakeDll) + processed = [] + + def fakeShouldAcceptEvent(eventName, windowHandle=None): + processed.append(("event", eventName)) + return False + + with ( + mock.patch.object( + internalWinEventHandler, + "_processDestroyWinEvent", + side_effect=lambda *args: processed.append(("destroy", args)), + ), + mock.patch.object(eventHandler, "shouldAcceptEvent", fakeShouldAcceptEvent), + ): + IAccessibleHandler.pumpAll() + self.assertEqual( + processed, + [ + ("destroy", (42, 0, 0)), + ("event", "nameChange"), + ], + ) + + def test_destroysProcessedBeforeFocusCapture(self): + """Processing a destroy may correct the focus (#2695: a destroyed IME candidate is + corrected to its container). The corrected focus must be the one this cycle captures + and exempts from limiting, matching the in-process implementation which handles + destroys at hook-callback time, always before pumpAll captures the focus. + """ + fakeDll = _makeFakeDll(destroyEvents=[(42, 0, 0)]) + self.installFakeDll(fakeDll) + correctedFocus = mock.MagicMock(spec=NVDAObjects.IAccessible.IAccessible) + correctedFocus.event_windowHandle = 77 + correctedFocus.event_objectID = winUser.OBJID_CLIENT + correctedFocus.event_childID = 0 + + def fakeProcessDestroy(*args): + eventHandler.lastQueuedFocusObject = correctedFocus + + with ( + mock.patch.object(eventHandler, "lastQueuedFocusObject", None), + mock.patch.object( + internalWinEventHandler, + "_processDestroyWinEvent", + side_effect=fakeProcessDestroy, + ), + ): + IAccessibleHandler.pumpAll() + fakeDll.winEventLimiter_setAlwaysAllowedObject.assert_called_once_with( + 77, + winUser.OBJID_CLIENT, + 0, + ) + + def test_destroyHandlerFailureDoesNotAbortPump(self): + """A raising destroy handler is isolated per destroy, as in the in-process + winEventCallback: the remaining destroys are still processed, and the cycle still + reaches the dll flush, which rearms the dll's coalesced notifier so future events + keep requesting pumps. + """ + fakeDll = _makeFakeDll(destroyEvents=[(1, 0, 0), (2, 0, 0)]) + self.installFakeDll(fakeDll) + processed = [] + + def failingProcessDestroy(window, objectID, childID): + processed.append((window, objectID, childID)) + if window == 1: + raise RuntimeError("destroy handler failure") + + with ( + mock.patch.object( + internalWinEventHandler, + "_processDestroyWinEvent", + side_effect=failingProcessDestroy, + ), + ): + IAccessibleHandler.pumpAll() + self.assertEqual(processed, [(1, 0, 0), (2, 0, 0)]) + fakeDll.winEventLimiter_flushEvents.assert_called_once() + + def test_lossAfterRegularFlushEvictsCacheBeforeEventProcessing(self): + """Loss is sampled again after the regular flush, before its events can use + objects whose destroy notifications might have been discarded. + """ + cacheKey = (42, winUser.OBJID_CLIENT, 0) + cachedObject = mock.MagicMock(spec=NVDAObjects.IAccessible.IAccessible) + IAccessibleHandler.liveNVDAObjectTable[cacheKey] = cachedObject + fakeDll = _makeFakeDll( + events=[(winUser.EVENT_OBJECT_NAMECHANGE, *cacheKey)], + ) + # No loss at the destroy snapshot; a destroy is discarded before the + # later regular event enters the DLL flush. + fakeDll.winEventLimiter_takeLostDestroys.side_effect = [0, 1] + self.installFakeDll(fakeDll) + cacheWasEvicted = [] + + def fakeShouldAcceptEvent(eventName, windowHandle=None): + cacheWasEvicted.append( + cacheKey not in IAccessibleHandler.liveNVDAObjectTable, + ) + return False + + with ( + mock.patch.object(api, "getFocusObject", return_value=None), + mock.patch.object(eventHandler, "shouldAcceptEvent", fakeShouldAcceptEvent), + ): + IAccessibleHandler.pumpAll() + self.assertEqual(cacheWasEvicted, [True]) + self.assertEqual(fakeDll.winEventLimiter_takeLostDestroys.call_count, 2) + + def test_lostCandidateWindowDestroyCorrectsFocus(self): + """A discarded destroy applies the #2695 IME candidate focus correction + when the focused candidate window no longer exists. + """ + from NVDAObjects.IAccessible.mscandui import BaseCandidateItem + + fakeDll = _makeFakeDll(lostDestroys=True) + self.installFakeDll(fakeDll) + candidate = mock.MagicMock(spec=BaseCandidateItem) + candidate.windowHandle = 42 + container = candidate.container + with ( + mock.patch.object(api, "getFocusObject", return_value=candidate), + mock.patch.object(winUser, "isWindow", return_value=False), + mock.patch.object(eventHandler, "isPendingEvents", return_value=False), + mock.patch.object(eventHandler, "lastQueuedFocusObject", None), + mock.patch.object(eventHandler, "queueEvent") as queueEvent, + ): + IAccessibleHandler.pumpAll() + queueEvent.assert_called_once_with("gainFocus", container) + + def test_unrelatedLostDestroyDoesNotCorrectCandidateFocus(self): + """A discarded destroy for an unknown object must not move focus away + from a candidate window that still exists. + """ + from NVDAObjects.IAccessible.mscandui import BaseCandidateItem + + fakeDll = _makeFakeDll(lostDestroys=True) + self.installFakeDll(fakeDll) + candidate = mock.MagicMock(spec=BaseCandidateItem) + candidate.windowHandle = 42 + with ( + mock.patch.object(api, "getFocusObject", return_value=candidate), + mock.patch.object(winUser, "isWindow", return_value=True), + mock.patch.object(eventHandler, "lastQueuedFocusObject", None), + mock.patch.object(eventHandler, "queueEvent") as queueEvent, + ): + IAccessibleHandler.pumpAll() + queueEvent.assert_not_called() + + +class Test_initializeFallback(unittest.TestCase): + """Initialization honours the externalWinEventLimiter feature flag, + and falls back to internal hooks when the external limiter cannot be used. + """ + + def setUp(self): + self.hooks = [] + self.unhooked = [] + patchers = [ + mock.patch.object( + winUser, + "setWinEventHook", + side_effect=lambda *args: (self.hooks.append(args), len(self.hooks))[1], + ), + mock.patch.object( + winUser, + "unhookWinEvent", + side_effect=self.unhooked.append, + ), + ] + for patcher in patchers: + patcher.start() + self.addCleanup(patcher.stop) + self.addCleanup(internalWinEventHandler.terminate) + + def enableExternalLimiterFlag(self): + config.conf["IAccessible"]["externalWinEventLimiter"] = "enabled" + self.addCleanup( + config.conf["IAccessible"].__setitem__, + "externalWinEventLimiter", + "default", + ) + + def test_flagDisabledByDefaultUsesInternalHooks(self): + with mock.patch.object(winEventLimiterLib, "getWinEventLimiterDll") as loader: + internalWinEventHandler.initialize(lambda *args: None) + loader.assert_not_called() + self.assertIsNone(internalWinEventHandler._externalWinEventLimiterDll) + self.assertEqual( + len(self.hooks), + len(internalWinEventHandler.winEventIDsToNVDAEventNames), + ) + + def test_fallsBackWhenDllMissing(self): + self.enableExternalLimiterFlag() + with mock.patch.object( + winEventLimiterLib, + "getWinEventLimiterDll", + side_effect=OSError("dll not found"), + ): + internalWinEventHandler.initialize(lambda *args: None) + self.assertIsNone(internalWinEventHandler._externalWinEventLimiterDll) + self.assertEqual( + len(self.hooks), + len(internalWinEventHandler.winEventIDsToNVDAEventNames), + ) + + def test_fallsBackWhenDllExportMissing(self): + """A loadable but stale dll raises AttributeError while its function prototypes + are configured; NVDA must fall back to internal hooks rather than fail to start. + """ + self.enableExternalLimiterFlag() + with mock.patch.object( + winEventLimiterLib, + "getWinEventLimiterDll", + side_effect=AttributeError("function 'winEventLimiter_getDestroyEvents' not found"), + ): + internalWinEventHandler.initialize(lambda *args: None) + self.assertIsNone(internalWinEventHandler._externalWinEventLimiterDll) + self.assertIsNone(internalWinEventHandler._externalWinEventLimiterDll) + self.assertEqual( + len(self.hooks), + len(internalWinEventHandler.winEventIDsToNVDAEventNames), + ) + + def test_fallsBackWhenStartFails(self): + self.enableExternalLimiterFlag() + fakeDll = _makeFakeDll(startRet=-2) + with mock.patch.object( + winEventLimiterLib, + "getWinEventLimiterDll", + return_value=fakeDll, + ): + internalWinEventHandler.initialize(lambda *args: None) + self.assertIsNone(internalWinEventHandler._externalWinEventLimiterDll) + self.assertIsNone(internalWinEventHandler._externalWinEventLimiterDll) + self.assertEqual( + len(self.hooks), + len(internalWinEventHandler.winEventIDsToNVDAEventNames), + ) + + def test_fallsBackWhenStartRaises(self): + self.enableExternalLimiterFlag() + fakeDll = _makeFakeDll() + fakeDll.winEventLimiter_start.side_effect = OSError("start fault") + with mock.patch.object( + winEventLimiterLib, + "getWinEventLimiterDll", + return_value=fakeDll, + ): + internalWinEventHandler.initialize(lambda *args: None) + self.assertIsNone(internalWinEventHandler._externalWinEventLimiterDll) + self.assertEqual( + len(self.hooks), + len(internalWinEventHandler.winEventIDsToNVDAEventNames), + ) + + def test_externalModeDisabledBeforeDllStops(self): + self.enableExternalLimiterFlag() + fakeDll = _makeFakeDll() + modeWhileStopping: list[bool] = [] + fakeDll.winEventLimiter_stop.side_effect = lambda: modeWhileStopping.append( + internalWinEventHandler._externalWinEventLimiterDll is not None, + ) + with mock.patch.object( + winEventLimiterLib, + "getWinEventLimiterDll", + return_value=fakeDll, + ): + internalWinEventHandler.initialize(lambda *args: None) + self.assertIs( + internalWinEventHandler._externalWinEventLimiterDll, + fakeDll, + ) + self.assertEqual(self.hooks, []) + internalWinEventHandler.terminate() + fakeDll.winEventLimiter_stop.assert_called_once() + self.assertEqual(modeWhileStopping, [False]) + self.assertIsNone(internalWinEventHandler._externalWinEventLimiterDll) + + def test_initializationFailureReleasesWaitingCallbacks(self): + with ( + mock.patch.object( + internalWinEventHandler, + "_registerInternalHooks", + side_effect=RuntimeError("hook failure"), + ), + self.assertRaises(RuntimeError), + ): + internalWinEventHandler.initialize(lambda *args: None) + self.assertTrue(internalWinEventHandler._limiterSelectionComplete.is_set()) + + def test_startReceivesEventIdsTable(self): + """The dll hooks exactly the events NVDA's table defines: the Python table is the + single source of truth for the winEvents to receive. + """ + self.enableExternalLimiterFlag() + fakeDll = _makeFakeDll() + with mock.patch.object( + winEventLimiterLib, + "getWinEventLimiterDll", + return_value=fakeDll, + ): + internalWinEventHandler.initialize(lambda *args: None) + args = fakeDll.winEventLimiter_start.call_args.args + self.assertEqual(len(args), 3) + expectedIds = sorted(internalWinEventHandler.winEventIDsToNVDAEventNames) + self.assertEqual(args[2], len(expectedIds)) + self.assertEqual(list(args[1]), expectedIds) + + def test_terminateUnhooksInternalHooks(self): + self.enableExternalLimiterFlag() + with mock.patch.object( + winEventLimiterLib, + "getWinEventLimiterDll", + side_effect=OSError("dll not found"), + ): + internalWinEventHandler.initialize(lambda *args: None) + internalWinEventHandler.terminate() + self.assertEqual(len(self.unhooked), len(self.hooks)) + self.assertEqual(internalWinEventHandler.winEventHookIDs, []) + + def test_terminateResetsDestroyHandler(self): + """terminate() must clear the destroy handler installed by initialize(), so a + stale handler can never leak into a later initialize (or another test). + """ + + def sentinel(*args): + return None + + internalWinEventHandler.initialize(sentinel) + self.assertIs(internalWinEventHandler._processDestroyWinEvent, sentinel) + internalWinEventHandler.terminate() + self.assertIsNone(internalWinEventHandler._processDestroyWinEvent) + + def test_terminateClearsConsoleWindowThreadIds(self): + internalWinEventHandler.consoleWindowsToThreadIDs[42] = 5678 + internalWinEventHandler.terminate() + self.assertEqual(internalWinEventHandler.consoleWindowsToThreadIDs, {}) + + +class Test_pumpAll(_FakeDllTestCase): + def test_pumpAllFetchesFromExternalWhenActive(self): + fakeDll = _makeFakeDll() + self.installFakeDll(fakeDll) + IAccessibleHandler.pumpAll() + fakeDll.winEventLimiter_flushEvents.assert_called_once() + + def test_pumpAllIgnoresExternalWhenInactive(self): + fakeDll = _makeFakeDll() + self.installFakeDll(fakeDll) + with ( + mock.patch.object(internalWinEventHandler, "_externalWinEventLimiterDll", None), + mock.patch.object(internalWinEventHandler, "_shouldGetEvents", return_value=False), + ): + IAccessibleHandler.pumpAll() + fakeDll.winEventLimiter_flushEvents.assert_not_called() + + def test_realFocusAfterAddedFocusWins(self): + """A later hooked focus event must remain newer than an explicitly added focus.""" + events: list[tuple[int, int | None, int, int]] = [] + fakeDll = _makeFakeDll(events=events) + self.installFakeDll(fakeDll) + with ( + mock.patch.object(eventHandler, "shouldAcceptEvent", return_value=True), + mock.patch.object( + internalWinEventHandler.winEventLimiter, + "flushEvents", + ) as flushInternal, + mock.patch.object( + IAccessibleHandler, + "processFocusWinEvent", + return_value=True, + ) as processFocus, + ): + internalWinEventHandler.addEvent( + winUser.EVENT_OBJECT_FOCUS, + 42, + winUser.OBJID_CLIENT, + 0, + 7, + ) + # This represents a real MSAA focus arriving after the added Word focus. + events.append( + (winUser.EVENT_OBJECT_FOCUS, 84, winUser.OBJID_CLIENT, 0), + ) + IAccessibleHandler.pumpAll() + processFocus.assert_called_once_with(84, winUser.OBJID_CLIENT, 0) + flushInternal.assert_not_called() + + def test_addedFocusAfterRealFocusWins(self): + """An explicitly added focus must remain newer than an earlier hooked focus.""" + events: list[tuple[int, int | None, int, int]] = [ + (winUser.EVENT_OBJECT_FOCUS, 84, winUser.OBJID_CLIENT, 0), + ] + fakeDll = _makeFakeDll(events=events) + self.installFakeDll(fakeDll) + with ( + mock.patch.object(eventHandler, "shouldAcceptEvent", return_value=True), + mock.patch.object( + IAccessibleHandler, + "processFocusWinEvent", + return_value=True, + ) as processFocus, + ): + internalWinEventHandler.addEvent( + winUser.EVENT_OBJECT_FOCUS, + 42, + winUser.OBJID_CLIENT, + 0, + 7, + ) + IAccessibleHandler.pumpAll() + processFocus.assert_called_once_with(42, winUser.OBJID_CLIENT, 0) + + def test_deferredCycleHoldsAddedEvents(self): + """#3831: while the dll defers the flush for a pending foreground change, the whole + cycle is held, including explicitly added events. Destroys are still processed, + matching the in-process implementation which handles them at hook-callback time + regardless of the defer. + """ + events: list[tuple[int, int | None, int, int]] = [] + fakeDll = _makeFakeDll( + events=events, + destroyEvents=[(42, 0, 0)], + flushDeferred=True, + ) + self.installFakeDll(fakeDll) + with ( + mock.patch.object(eventHandler, "shouldAcceptEvent", return_value=True), + mock.patch.object( + internalWinEventHandler, + "_processDestroyWinEvent", + ) as processDestroy, + mock.patch.object( + IAccessibleHandler, + "processFocusWinEvent", + return_value=True, + ) as processFocus, + ): + internalWinEventHandler.addEvent( + winUser.EVENT_OBJECT_FOCUS, + 42, + winUser.OBJID_CLIENT, + 0, + 7, + ) + IAccessibleHandler.pumpAll() + processDestroy.assert_called_once_with(42, 0, 0) + processFocus.assert_not_called() + fakeDll.winEventLimiter_getEvents.assert_not_called() + self.assertEqual( + events, + [(winUser.EVENT_OBJECT_FOCUS, 42, winUser.OBJID_CLIENT, 0)], + ) + + def test_lostDestroysEvictWholeObjectCache(self): + """When the dll had to shed destroys under a sustained flood, a destroy may have + been missed for any window, so pumpAll evicts the whole object cache rather than + relying on per window destroy processing. + """ + fakeDll = _makeFakeDll(lostDestroys=True) + self.installFakeDll(fakeDll) + cachedObject = mock.MagicMock(spec=NVDAObjects.IAccessible.IAccessible) + IAccessibleHandler.liveNVDAObjectTable[(42, 0, 0)] = cachedObject + IAccessibleHandler.pumpAll() + self.assertNotIn((42, 0, 0), IAccessibleHandler.liveNVDAObjectTable) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 7432953d116..68ad0164366 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -41,6 +41,8 @@ Please responsibly disclose security issues following NVDA's [security policy](h * It is now possible to change an existing gesture in the Input Gestures dialog. (#10983, @amirmahdifard) * A new "Say all reads by" speech setting lets you choose whether say all reads by sentence, paragraph or line; say all now reads by sentence by default where supported. (#13420, #9179, #13971, @LeonarddeR) * A new command, assigned to `NVDA+control+x`, copies the last spoken information to the clipboard. (#19385, @Cary-rowen) +* Added an experimental option to the Advanced category of NVDA's settings to receive and filter winEvents (used for Microsoft Active Accessibility) on a dedicated thread, outside of NVDA's main event loop. + This may keep NVDA more responsive when applications fire large numbers of events. (#20653, @codeofdusk) ### Changes diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index b2481b5262d..57d1e773321 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -4244,6 +4244,18 @@ The combo box has the following options: * Yes: If the browser makes UIA available, NVDA will use it. * No: Don't use UIA, even if NVDA is unable to inject in process. This may be useful for developers debugging issues with IA2 and want to ensure that NVDA does not fall back to UIA. +##### Process winEvents on a dedicated thread {#ExternalWinEventLimiter} + +When this option is enabled, NVDA receives and filters winEvents (used for Microsoft Active Accessibility) on a dedicated thread, outside of NVDA's main event loop. +This may keep NVDA more responsive when applications fire large numbers of events, such as in complex web documents or applications starting up. +This feature is experimental. +After changing this option, you will need to restart NVDA for the change to take effect. + +| . {.hideHeaderRow} |.| +|---|---| +|Options |Default (Disabled), Enabled, Disabled| +|Default |Disabled| + ##### Annotations {#Annotations} This group of options is used to enable features which add experimental support for ARIA annotations.