Skip to content

Commit 5344d4b

Browse files
authored
LiveText: announce large bursts via a pumped generator (#20177)
Fixes #6291. Partially addresses #15786, #15850, #11002, and #14189. All of these describe the same general class of "NVDA stops responding during console flood" problem but also include symptoms (UIA event flooding, NVDA crashes destroying the console window, etc.) that are out of scope for this PR and were partly addressed in #14888 and #14067 anyway. Related to #2977 and #875. Most of these are already closed, but the underlying root cause (which is the main thread blocking during line announcement) was never actually addressed. Summary of the issue: When a live-text region (such as a terminal) produces a large burst of new text in a short window, NVDA's main thread blocks announcing every line sequentially, becoming unresponsive to keyboard input until the burst finishes. In some cases this lasts tens of seconds. In other (more drastic) cases, it can last so long that the user has to sign out, restart, or force-kill NVDA. Description of user facing changes: NVDA no longer freezes when large bursts of text are reported in a live region. Description of developer facing changes: LiveText._reportNewLines is no longer guaranteed to report lines synchronously. Internally, it now registers a generator with queueHandler which yields between batches of lines so the main thread can service input during long announcements. A new class attribute LiveText.MAX_LINES caps the number of lines announced per burst. We drop everything before that on the floor. When a new burst arrives while a previous one is still being announced, the previous generator is cancelled and the new burst supersedes it. Description of development approach: Live-text regions announce new text via LiveText._reportNewLines, which was invoked synchronously from the main thread's event queue. For workloads producing substantial amounts of new lines in a short window, every line traverses the full speech pipeline sequentially without yielding, which can leave the main thread unresponsive to keyboard input for tens of seconds or longer. This PR addresses the responsiveness problem at the announcement layer rather than at the diff or event-source layer, which were previously addressed by #14888 and #14067. Specifically: (1) Bursts larger than MAX_LINES are truncated to the most recent N lines. Skipping the oldest lines was chosen over skipping the newest because most flooding scenarios carry their actionable content at the end. (2) Reporting now happens via a generator registered with queueHandler.registerGeneratorObject, yielding every 5 lines. This is the same primitive previously used by speech.speakSpelling and the original sayAll. Between yields, the main thread services the event queue, which keeps NVDA responsive. (3) A handler is registered with pre_speechCanceled so that pressing control (or any other speech-cancel trigger) also cancels the in-flight generator. Perceptibly this may not cancel immediately and up to 5 lines may still be announced past the cancel, but internally the generator won't proceed past that iteration.
1 parent ac44c36 commit 5344d4b

2 files changed

Lines changed: 35 additions & 3 deletions

File tree

‎source/NVDAObjects/behaviors.py‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@
3030
import nvwave
3131
import globalVars
3232
from typing import List, Union
33+
from collections.abc import Generator
3334
import diffHandler
3435
from config.configFlags import (
3536
TypingEcho,
3637
ReportSpellingErrors,
3738
)
39+
from speech.extensions import pre_speechCanceled
3840

3941

4042
class ProgressBar(NVDAObject):
@@ -381,12 +383,18 @@ class LiveText(NVDAObject):
381383
# If the text is live, this is definitely content.
382384
presentationType = NVDAObject.presType_content
383385

386+
MAX_LINES: int = 100
387+
"""The maximum number of lines that will be reported when a large number of lines are queued.
388+
Subclasses may override this to allow custom line reporting batches.
389+
"""
384390
announceNewLineText = False
385391

386392
def initOverlayClass(self):
387393
self._event = threading.Event()
388394
self._monitorThread = None
389395
self._keepMonitoring = False
396+
self._reportNewLinesGenID: int | None = None
397+
pre_speechCanceled.register(self._onSpeechCanceled)
390398

391399
def startMonitoring(self):
392400
"""Start monitoring for new text.
@@ -415,6 +423,9 @@ def stopMonitoring(self):
415423
self._keepMonitoring = False
416424
self._event.set()
417425
self._monitorThread = None
426+
if self._reportNewLinesGenID is not None:
427+
queueHandler.cancelGeneratorObject(self._reportNewLinesGenID)
428+
self._reportNewLinesGenID = None
418429

419430
def event_textChange(self):
420431
"""Fired when the text changes.
@@ -451,14 +462,34 @@ def _getText(self) -> str:
451462
ti = self.makeTextInfo(textInfos.POSITION_ALL)
452463
return self.diffAlgo._getText(ti)
453464

454-
def _reportNewLines(self, lines):
465+
def _reportNewLines(self, lines: list[str]) -> None:
455466
"""
456467
Reports new lines of text using _reportNewText for each new line.
457468
Subclasses may override this method to provide custom filtering of new text,
458469
where logic depends on multiple lines.
459470
"""
460-
for line in lines:
461-
self._reportNewText(line)
471+
droppedCount = len(lines) - self.MAX_LINES
472+
if droppedCount > 0:
473+
lines = lines[-self.MAX_LINES :]
474+
if self._reportNewLinesGenID is not None:
475+
queueHandler.cancelGeneratorObject(self._reportNewLinesGenID)
476+
self._reportNewLinesGenID = None
477+
self._reportNewLinesGenID = queueHandler.registerGeneratorObject(self._reportNewLinesGenerator(lines))
478+
479+
def _reportNewLinesGenerator(self, lines: list[str]) -> Generator[None, None, None]:
480+
YIELD_EVERY = 5 # Sweet spot between yielding on every line and a batch
481+
try:
482+
for i, line in enumerate(lines, 1):
483+
self._reportNewText(line)
484+
if i % YIELD_EVERY == 0:
485+
yield
486+
finally:
487+
self._reportNewLinesGenID = None
488+
489+
def _onSpeechCanceled(self) -> None:
490+
if self._reportNewLinesGenID is not None:
491+
queueHandler.cancelGeneratorObject(self._reportNewLinesGenID)
492+
self._reportNewLinesGenID = None
462493

463494
def _reportNewText(self, line):
464495
"""Report a line of new text."""

‎user_docs/en/changes.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
### Bug Fixes
1616

1717
* When moving to an ARIA grid cell in focus mode in web browsers, NVDA no longer reports both the row and column headers even if only the row or only the column changed. (#17750, @jcsteh)
18+
* In live text regions, such as terminals, NVDA no longer freezes when substantial amounts of text are dumped to the screen. (#20177)
1819
* When an application stops responding, NVDA no longer freezes or floods its log with errors; it stays responsive and drops UIA and MSAA events from the unresponsive application until it recovers. (#16749, @heath-toby)
1920
* Reduced lag on UI Automation text change events, improving the responsiveness of controls such as combo boxes and of File Explorer, by using the cached element class name instead of a live cross-process fetch. (#16749, @heath-toby)
2021
* In Mozilla Firefox, reporting annotation details now works correctly in focus mode on controls which are not editable text. (#20208, @jcsteh)

0 commit comments

Comments
 (0)