Skip to content

Stop a full message queue from making NVDA permanently unresponsive - #21005

Closed
ChristosBouronikos wants to merge 1 commit into
nvaccess:masterfrom
ChristosBouronikos:fixMessageQueueExhaustion
Closed

ChristosBouronikos wants to merge 1 commit into
nvaccess:masterfrom
ChristosBouronikos:fixMessageQueueExhaustion

Conversation

@ChristosBouronikos

Copy link
Copy Markdown

Link to issue number:

Fixes #20846 (partially, see below).

Important

This PR fixes the step that turns a temporary freeze into a permanent one. It does not fix the freeze itself.

When Chromium 153 is slow to answer NVDA's accessibility calls, NVDA's main thread still freezes, and that is not changed here. What this PR changes is what happens after: currently, a long enough freeze leaves NVDA permanently silent and ignoring the keyboard, even after the browser recovers, until NVDA is restarted (and sometimes it can't be restarted either). With this PR, NVDA should resume speaking and responding once the main thread is free again.

If the main thread is blocked forever (for example a call into the browser that never returns), NVDA will still stay frozen. This PR does not address that.

Note

Only unit tested so far, on macOS with fake objects. I have not yet tested this on Windows or reproduced the original freeze with the fix applied. Testing with WhatsApp Web / YouTube in Edge or Brave 153 (IAccessible2 mode, the default) would be very welcome.

Summary of the issue:

In the logs on the issue, the failure has two stages.

Stage 1, the freeze (not fixed here). With Chromium 153 over IAccessible2, busy pages such as WhatsApp Web make NVDA's main thread wait a long time on calls into the browser. The watchdog logs Starting freeze recovery. Browse mode scripts also fail with WinError 1775, because they run against a virtual buffer whose RPC handle is still null while the buffer is loading.

Stage 2, the queue fills and NVDA stops working (fixed here). Every wx.CallAfter goes through the wrapper in monkeyPatches/wxMonkeyPatches.py, which posts a WM_NULL to the main frame after queuing the call. That message is a wake-up hint for native modal loops such as menus and message boxes. While the main thread is frozen, nothing removes these messages, so they pile up. Once the thread's posted message limit (10,000 by default) is reached, PostMessage fails with WinError 1816 (ERROR_NOT_ENOUGH_QUOTA) and winUser.PostMessage raises.

At that point the call has already been queued by wx and will still run. The wake-up was only a hint. But the exception escapes into whatever called wx.CallAfter, directly or through core.requestPump and queueHandler.queueFunction. That is almost every part of NVDA that hands work to the main thread, which is why the second reporter saw the error from so many places:

  • keyboardHandler.internal_keyDownEvent: the gesture is queued, but the error is logged with a full traceback from inside the low level keyboard hook, and the key is not trapped.
  • nvwave.WasapiWavePlayer._scheduleIdleCheck: core.callLater raises before _isIdleCheckPending is set, so feed() fails on every audio chunk and speech stops.
  • IAccessibleHandler.internalWinEventHandler, UIA event handlers, virtualBuffers.changeNotify and scripts.
  • core.CorePump.Notify / processRequest (the unhandled exception in the first log).

So NVDA stops speaking and stops responding to the keyboard. A new NVDA instance also can't post WM_QUIT to the old one, which explains Couldn't terminate existing NVDA process ... [WinError 1816].

Description of user facing changes:

  • After a long freeze, such as when a Chromium-based browser is slow to respond on busy pages, NVDA should start speaking and responding to the keyboard again once the freeze ends, instead of needing a restart.
  • The freeze itself is unchanged.

Description of developer facing changes:

  • wx.CallAfter (as patched by NVDA) no longer raises if the wake-up message can't be posted. The failure is logged as a warning, once per run of consecutive failures.
  • The patched wx.CallAfter posts at most one wake-up WM_NULL until wx starts running queued calls, instead of one per call.
  • The patched wx.CallAfter now accepts a keyword argument named func for the called function, like wxPython's own CallAfter.

Description of development approach:

I checked wake-up handling in the wxWidgets 3.3.4 source (the version wxPython 4.3.2 is built on):

  • wx.CallAfter → wxEvtHandler::QueueEvent → wxWakeUpIdle(), which sets an auto-reset wake event. wx itself posts no Windows message.
  • wx installs a WH_GETMESSAGE hook (wxIdleWakeUpModule). Whenever the main thread removes any message from its queue while the wake event is set, wx runs ProcessPendingEvents(), which runs every pending call, including ones queued while it runs.

So a single WM_NULL waiting in the queue is enough to get all pending calls run, even inside a native modal loop. A second one for the same wake-up does nothing, even with the current code, because the first one already used up the wake event.

Changes in monkeyPatches/wxMonkeyPatches.py:

  1. Only one wake-up pending at a time. The queued callable is wrapped so that a "wake-up pending" flag is cleared when wx starts running it. A new WM_NULL is only posted if the flag is clear. The flag is set before posting, so a race can only cause an extra (harmless) post, never a missed one. While the main thread is frozen, NVDA now adds one message to its queue instead of one per call.
  2. Failing to post is not fatal. OSError from PostMessage is caught. If the queue is full, it already holds messages that will wake the main thread. The flag stays set after a failure, so NVDA doesn't keep retrying against a full queue until wx runs queued calls again. The warning is logged once per run of failures, to avoid flooding the log while the queue is full. logHandler is imported lazily, like in comtypesMonkeyPatches.py, so that importing monkeyPatches early in nvda.pyw still doesn't import logHandler before globalVars.appDir is set.

Not done in this PR:

  • The Chromium 153 slowness. It looks like a Chromium regression and may need a Chromium bug report.
  • The WinError 1775 script errors on a virtual buffer that hasn't finished loading. These are logged script errors, not the cause of the freeze, and can be handled separately.
  • core.CorePump is unchanged.

Testing strategy:

  • New unit tests in tests/unit/test_wxMonkeyPatches.py, using fake wx, winUser and main frame objects:
    • a failed post doesn't raise and the call still runs;
    • several calls post one WM_NULL until a queued call runs, and the next call posts again;
    • the warning is logged once per run of failures;
    • a keyword argument named func is passed through.
  • All four tests fail against the current master code and pass with this change. I ran them locally on macOS with a stubbed logHandler; they still need to run on Windows CI.
  • ruff check and ruff format --check pass (ruff 0.16.3).
  • Manual testing still needed on Windows:
    • WhatsApp Web / YouTube in Edge or Brave 153 with IAccessible2: after a freeze, NVDA should recover instead of staying silent;
    • wx.CallAfter timing in native modal loops (NVDA menu, message boxes, context menus) should be unchanged.

Known issues with pull request:

  • The main thread can still freeze. While frozen, NVDA is silent, as before.
  • If the main thread is blocked permanently, NVDA stays frozen.
  • Messages posted to NVDA by other processes can still fill the queue. NVDA no longer breaks when that happens, but things that rely on posting to NVDA, such as a new instance asking the old one to quit, can still fail while the queue is full.
  • Not yet tested on Windows.

Code Review Checklist:

  • Documentation:
    • Change log entry
    • User Documentation
    • Developer / Technical Documentation
    • Context sensitive help for GUI changes
  • Testing:
    • Unit tests
    • System (end to end) tests
    • Manual testing
  • UX of all users considered:
    • Speech
    • Braille
    • Low Vision
    • Different web browsers
    • Localization in other languages / culture than English
  • API is compatible with existing add-ons.
  • Security precautions taken.

Fixes nvaccess#20846.

The wx.CallAfter monkey patch posts a WM_NULL to the main frame after
every call. While the main thread is frozen, these pile up, and once the
thread's posted message quota is reached, PostMessage raises
ERROR_NOT_ENOUGH_QUOTA. The callback has already been queued by wx at
that point, but the exception escapes into every caller: the keyboard
hook, audio feeding, win event and UIA handlers, virtual buffer change
notifications and the core pump. NVDA then stops speaking and
responding to the keyboard.

In wxWidgets 3.3, wx runs all pending callbacks as soon as the main
thread retrieves any message after a wake-up request, so only one
WM_NULL needs to be waiting at a time. Only post a new one after wx has
started running queued callbacks, and treat a failure to post as
non-fatal, logging it once per failure episode.
@ChristosBouronikos
ChristosBouronikos marked this pull request as ready for review October 16, 2026 08:29
@ChristosBouronikos
ChristosBouronikos requested a review from a team as a code owner October 16, 2026 08:29
@ChristosBouronikos
ChristosBouronikos requested review from seanbudd and a balanced review from Copilot October 16, 2026 08:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@seanbudd seanbudd closed this Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants