Repository: nvaccess/nvda

Issue:
NVDA crashes when collecting text in Chunks from its edit controls

### Steps to reproduce:
1. Open log viewer.
2. Move navigator object into it with NVDA+numpad minus.
3. Open Python console with NVDA+Ctrl+z
4. In the Python console enter:
```
logViewerTI = nav.makeTextInfo(textInfos.POSITION_ALL)
textInLines = tuple(logViewerTI.getTextInChunks(textInfos.UNIT_LINE))
```

### Actual behavior:
NVDA crashes with the following entry in the log:

DEBUGWARNING - watchdog._watcher (14:18:44.970) - watchdog (5408):
Trying to recover from freeze. Listing stacks for Python threads:
Python stack for thread 3340 (watchdog.CancellableCallThread.execute(<CFunctionType object at 0x03F6B738>)):
  File "threading.pyc", line 890, in _bootstrap
  File "threading.pyc", line 926, in _bootstrap_inner
  File "watchdog.pyc", line 332, in run
  File "threading.pyc", line 552, in wait
  File "threading.pyc", line 296, in wait

Python stack for thread 5408 (watchdog):
  File "threading.pyc", line 890, in _bootstrap
  File "threading.pyc", line 926, in _bootstrap_inner
  File "threading.pyc", line 870, in run
  File "watchdog.pyc", line 127, in _watcher
  File "watchdog.pyc", line 63, in getFormattedStacksForAllThreads

Python stack for thread 5336 (winInputHook):
  File "threading.pyc", line 890, in _bootstrap
  File "threading.pyc", line 926, in _bootstrap_inner
  File "threading.pyc", line 870, in run
  File "winInputHook.pyc", line 79, in hookThreadFunc

Python stack for thread 6024 (_UIAHandler.UIAHandler.MTAThread):
  File "threading.pyc", line 890, in _bootstrap
  File "threading.pyc", line 926, in _bootstrap_inner
  File "threading.pyc", line 870, in run
  File "_UIAHandler.pyc", line 310, in MTAThreadFunc
  File "queue.pyc", line 170, in get
  File "threading.pyc", line 296, in wait

Python stack for thread 5832 (braille._BgThread):
  File "threading.pyc", line 890, in _bootstrap
  File "threading.pyc", line 926, in _bootstrap_inner
  File "threading.pyc", line 870, in run
  File "braille.pyc", line 2187, in func

Python stack for thread 6044 (synthDrivers._espeak.BgThread):
  File "threading.pyc", line 890, in _bootstrap
  File "threading.pyc", line 926, in _bootstrap_inner
  File "synthDrivers\_espeak.pyc", line 190, in run
  File "queue.pyc", line 170, in get
  File "threading.pyc", line 296, in wait

Python stack for thread 5904 (MainThread):
  File "nvda.pyw", line 215, in <module>
  File "core.pyc", line 550, in main
  File "wx\core.pyc", line 2134, in MainLoop
  File "pythonConsole.pyc", line 386, in onInputChar
  File "pythonConsole.pyc", line 274, in execute
  File "pythonConsole.pyc", line 135, in push
  File "code.pyc", line 258, in push
  File "code.pyc", line 74, in runsource
  File "code.pyc", line 90, in runcode
  File "<console>", line 1, in <module>
  File "textInfos\__init__.pyc", line 529, in getTextInChunks
  File "baseObject.pyc", line 26, in __get__
  File "NVDAObjects\window\edit.pyc", line 748, in _get_text
  File "NVDAObjects\window\edit.pyc", line 642, in _getTextAtRange
  File "comtypes\__init__.pyc", line 279, in __getattr__
  File "comtypesMonkeyPatches.pyc", line 26, in __call__
  File "comtypes\__init__.pyc", line 1010, in __del__




### Expected behavior:
Tuple containing all lines from the  log viewer should be returned. NVDA should not crash.

### System configuration
#### NVDA installed/portable/running from source:
Both installed and portable
#### NVDA version:
NVDA version alpha-20966,0baf0ceb

#### Windows version:
Windows 7 X64
#### Name and version of other software in use when reproducing the issue:
None
#### Other information about your system:

### Other questions
#### Does the issue still occur after restarting your computer?
Yes
#### Have you tried any other versions of NVDA? If so, please report their behaviors.
Tested back to 2018.3 and the crash can be reproduced with every version.
#### If addons are disabled, is your problem still occuring?
Yes
#### Did you try to run the COM registry fixing tool in NVDA menu / tools?
N/A



Discussion:
cc @michaelDCurran It seems NVDA crashes when making a COM call and you've recently fixed various crashes of that sort.
This crash can be explained by the generator being infinite. It looks like it yields the bottom line over and over again.
I think we should investigate this at least before 2020.4
> This crash can be explained by the generator being infinite. It looks like it yields the bottom line over and over again.

Yes because:
In `NVDAObjects\window\edit.py`, `ITextDocumentTextInfo.collapse(end=True)` does not collapse at the end of the range but one character before.
It seems that [`ITextDocument.SetRange`](https://docs.microsoft.com/en-us/windows/win32/api/tom/nf-tom-itextrange-setrange) cannot set the range with both ends at the end of the edit field. in this case.



@CyrilleB79 Thanks for the investigation. I believe there are two issues at play here:

- The generator being infinite makes it impossible to retrieve text of the edit control in chunks. This is obviously quite bad, but in all other rich edit controls i.e. WordPad Qread, attempting to collect text results in CallCancelled being raised
- When collecting text for NVDA's controls however NVDA simply crashes - this suggests to me that it is unable to cope with the CallCancelled being raised

Can you describe a real world situation where a user may encounter this? Or is this relevant to an addon? 
I've discovered this only because my [Percentage Checker add-on](https://github.com/lukaszgo1/percentageChecker/) has a function which allows to jump to a given line number in a text field and one of users reported to me that when trying to use it in NVDA's edit fields NVDA crashes. I consider impact to be pretty low, though fixing it at some point would certainly be nice.
Ok, thanks @lukaszgo1, we won't hold up 2020.4. But as you said, it would be good to fix this.
I can reproduce this myself, I can confirm that I had to manually kill NVDA in order to un-freeze my system, I had no response from NVDA at all. I had to use task manager to kill NVDA.

Resolving patch:
diff --git a/source/textInfos/__init__.py b/source/textInfos/__init__.py
--- a/source/textInfos/__init__.py
+++ b/source/textInfos/__init__.py
@@ -12,13 +12,21 @@
 from abc import abstractmethod
 import weakref
 import re
-from typing import Any, Union, List, Optional, Dict
+from typing import (
+	Any,
+	Union,
+	List,
+	Optional,
+	Dict,
+	Tuple,
+)
 
 import baseObject
 import config
 import controlTypes
 from controlTypes import OutputReason
 import locationHelper
+from logHandler import log
 
 
 SpeechSequence = List[Union[Any, str]]
@@ -307,6 +315,24 @@ def __init__(self,obj,position):
 		#: The position with which this instance was constructed.
 		self.basePosition=position
 
+	#: Typing information for auto-property: start
+	start: "TextInfoEndpoint"
+
+	def _get_start(self) -> "TextInfoEndpoint":
+		return TextInfoEndpoint(self, True)
+
+	def _set_start(self, otherEndpoint: "TextInfoEndpoint"):
+		self.start.moveTo(otherEndpoint)
+
+	#: Typing information for auto-property: end
+	end: "TextInfoEndpoint"
+
+	def _get_end(self) -> "TextInfoEndpoint":
+		return TextInfoEndpoint(self, False)
+
+	def _set_end(self, otherEndpoint: "TextInfoEndpoint"):
+		self.end.moveTo(otherEndpoint)
+
 	def _get_obj(self):
 		"""The object containing the range of text being represented."""
 		return self._obj()
@@ -521,15 +547,18 @@ def getTextInChunks(self, unit):
 		"""
 		unitInfo=self.copy()
 		unitInfo.collapse()
-		while unitInfo.compareEndPoints(self,"startToEnd")<0:
+		while unitInfo.start < self.end:
 			unitInfo.expand(unit)
 			chunkInfo=unitInfo.copy()
-			if chunkInfo.compareEndPoints(self,"startToStart")<0:
-				chunkInfo.setEndPoint(self,"startToStart")
-			if chunkInfo.compareEndPoints(self,"endToEnd")>0:
-				chunkInfo.setEndPoint(self,"endToEnd")
+			if chunkInfo.start < self.start:
+				chunkInfo.start = self.start
+			if chunkInfo.end > self.end:
+				chunkInfo.end = self.end
 			yield chunkInfo.text
 			unitInfo.collapse(end=True)
+			if unitInfo.start < chunkInfo.end:
+				log.debugWarning("Could not move TextInfo completely to end, breaking")
+				break
 
 	def getControlFieldSpeech(
 			self,
@@ -624,3 +653,76 @@ def turnPage(self, previous=False):
 		@raise RuntimeError: If there are no further pages.
 		"""
 		raise NotImplementedError
+
+
+class TextInfoEndpoint:
+	"""
+	Represents one end of a TextInfo instance.
+	This object can be compared with another end from the same or a different TextInfo instance,
+	Using the standard math comparison operators:
+	< <= == != >= >
+	"""
+
+	_whichMap: Dict[Tuple[bool, bool], str] = {
+		(True, True): "startToStart",
+		(True, False): "startToEnd",
+		(False, True): "endToStart",
+		(False, False): "endToEnd",
+	}
+
+	def _cmp(self, other: "TextInfoEndpoint") -> int:
+		"""
+		A standard cmp function returning:
+		-1 for less than, 0 for equal and 1 for greater than.
+		"""
+		if (
+			not isinstance(other, TextInfoEndpoint)
+			or not isinstance(other.textInfo, type(self.textInfo))
+		):
+			raise ValueError(f"Cannot compare endpoint with different type: {other}")
+		return self.textInfo.compareEndPoints(other.textInfo, self._whichMap[self.isStart, other.isStart])
+
+	def __init__(
+			self,
+			textInfo: TextInfo,
+			isStart: bool
+	):
+		"""
+		@param textInfo: the TextInfo instance you wish to represent an endpoint of.
+		@param isStart: true to represent the start, false for the end.
+		"""
+		self.textInfo = textInfo
+		self.isStart = isStart
+
+	def __lt__(self, other) -> bool:
+		return self._cmp(other) < 0
+
+	def __le__(self, other) -> bool:
+		return self._cmp(other) <= 0
+
+	def __eq__(self, other) -> bool:
+		return self._cmp(other) == 0
+
+	def __ne__(self, other) -> bool:
+		return self._cmp(other) != 0
+
+	def __ge__(self, other) -> bool:
+		return self._cmp(other) >= 0
+
+	def __gt__(self, other) -> bool:
+		return self._cmp(other) > 0
+
+	def moveTo(self, other: "TextInfoEndpoint") -> None:
+		"""
+		Moves the end of the TextInfo this endpoint represents to the position of the given endpoint.
+		"""
+		if (
+			not isinstance(other, TextInfoEndpoint)
+			or not isinstance(other.textInfo, type(self.textInfo))
+		):
+			raise ValueError(f"Cannot move endpoint to different type: {other}")
+		self.textInfo.setEndPoint(other.textInfo, self._whichMap[(self.isStart, other.isStart)])
+
+	def __repr__(self):
+		endpointLabel = "start" if self.isStart else "end"
+		return f"{endpointLabel} endpoint of {self.textInfo}"


Test patch:
diff --git a/tests/unit/test_textInfos.py b/tests/unit/test_textInfos.py
--- a/tests/unit/test_textInfos.py
+++ b/tests/unit/test_textInfos.py
@@ -131,3 +131,45 @@ def test_mixedSurrogatePairsNonSurrogatesAndSingleSurrogatesBackward(self):
 		ti.move(textInfos.UNIT_CHARACTER, -1)
 		ti.expand(textInfos.UNIT_CHARACTER) # Range at a
 		self.assertEqual(ti.offsets, (0, 1)) # One offset
+
+
+class TestEndpoints(unittest.TestCase):
+
+	def test_TextInfoEndpoint_largerAndSmaller(self):
+		obj = BasicTextProvider(text="abcdef")
+		ti = obj.makeTextInfo(Offsets(0, 2))
+		smaller = ti.start
+		larger = ti.end
+		self.assertTrue(smaller < larger)
+		self.assertFalse(larger < smaller)
+		self.assertTrue(smaller <= larger)
+		self.assertFalse(larger <= smaller)
+		self.assertFalse(smaller >= larger)
+		self.assertTrue(larger >= smaller)
+		self.assertFalse(smaller > larger)
+		self.assertTrue(larger > smaller)
+		self.assertTrue(smaller != larger)
+		self.assertTrue(larger != smaller)
+
+	def test_TextInfoEndpoint_equal(self):
+		obj = BasicTextProvider(text="abcdef")
+		ti = obj.makeTextInfo(Offsets(1, 1))
+		self.assertTrue(ti.start == ti.end)
+		self.assertFalse(ti.start != ti.end)
+		self.assertFalse(ti.start < ti.end)
+		self.assertTrue(ti.start <= ti.end)
+		self.assertTrue(ti.start >= ti.end)
+		self.assertFalse(ti.start > ti.end)
+
+	def test_setEndpoint(self):
+		obj = BasicTextProvider(text="abcdef")
+		ti1 = obj.makeTextInfo(Offsets(0, 2))
+		ti2 = obj.makeTextInfo(Offsets(3, 5))
+		ti1.end = ti2.end
+		self.assertEqual((ti1._startOffset, ti1._endOffset), (0, 5))
+		ti1.start = ti2.start
+		self.assertEqual((ti1._startOffset, ti1._endOffset), (3, 5))
+		ti1.end = ti2.start
+		self.assertEqual((ti1._startOffset, ti1._endOffset), (3, 3))
+		ti1.start = ti2.end
+		self.assertEqual((ti1._startOffset, ti1._endOffset), (5, 5))