From f7b2ff0f88fb5bac2a8500ae77752097366e2e66 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 16 Feb 2024 11:48:19 +1100 Subject: [PATCH 1/9] daemonize add-on store threads --- source/_addonStore/dataManager.py | 23 ++++++++++++++++--- source/core.py | 7 ++++-- source/gui/_addonStoreGui/viewModels/store.py | 7 +++++- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/source/_addonStore/dataManager.py b/source/_addonStore/dataManager.py index ece757ab903..b4093e141b2 100644 --- a/source/_addonStore/dataManager.py +++ b/source/_addonStore/dataManager.py @@ -56,6 +56,7 @@ addonDataManager: Optional["_DataManager"] = None +FETCH_TIMEOUT_S = 120 # seconds def initialize(): @@ -67,6 +68,16 @@ def initialize(): addonDataManager = _DataManager() +def terminate(): + global addonDataManager + if config.isAppX: + log.info("Add-ons not supported when running as a Windows Store application") + return + addonDataManager.terminate() + log.debug("terminating addonStore data manager") + addonDataManager = None + + class _DataManager: _cacheLatestFilename: str = "_cachedLatestAddons.json" _cacheCompatibleFilename: str = "_cachedCompatibleAddons.json" @@ -89,15 +100,21 @@ def __init__(self): self._compatibleAddonCache = self._getCachedAddonData(self._cacheCompatibleFile) self._installedAddonsCache = _InstalledAddonsCache() # Fetch available add-ons cache early - threading.Thread( + self._getAddonsThread = threading.Thread( target=self.getLatestCompatibleAddons, name="initialiseAvailableAddons", - ).start() + daemon=True, + ) + self._getAddonsThread.start() + + def terminate(self): + if self._getAddonsThread.is_alive(): + self._getAddonsThread.join(timeout=FETCH_TIMEOUT_S + 1) def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]: url = _getAddonStoreURL(self._preferredChannel, self._lang, apiVersion) try: - response = requests.get(url) + response = requests.get(url, timeout=FETCH_TIMEOUT_S) except requests.exceptions.RequestException as e: log.debugWarning(f"Unable to fetch addon data: {e}") return None diff --git a/source/core.py b/source/core.py index 4b5de48d410..2ec46f6a285 100644 --- a/source/core.py +++ b/source/core.py @@ -240,6 +240,10 @@ def resetConfiguration(factoryDefaults=False): hwIo.terminate() log.debug("terminating addonHandler") addonHandler.terminate() + # Addons + from _addonStore import dataManager + log.debug("terminating addon dataManager") + dataManager.terminate() log.debug("Reloading config") config.conf.reset(factoryDefaults=factoryDefaults) logHandler.setLogLevelFromConfig() @@ -250,8 +254,6 @@ def resetConfiguration(factoryDefaults=False): lang = config.conf["general"]["language"] log.debug("setting language to %s"%lang) languageHandler.setLanguage(lang) - # Addons - from _addonStore import dataManager dataManager.initialize() addonHandler.initialize() # Hardware background i/o @@ -856,6 +858,7 @@ def _doPostNvdaStartupAction(): _terminate(bdDetect) _terminate(hwIo) _terminate(addonHandler) + _terminate(dataManager, name="addon dataManager") _terminate(garbageHandler) # DMP is only started if needed. # Terminate manually (and let it write to the log if necessary) diff --git a/source/gui/_addonStoreGui/viewModels/store.py b/source/gui/_addonStoreGui/viewModels/store.py index dc1ca85ef24..3210dff4a6b 100644 --- a/source/gui/_addonStoreGui/viewModels/store.py +++ b/source/gui/_addonStoreGui/viewModels/store.py @@ -383,7 +383,12 @@ def refresh(self): _StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE, }: - threading.Thread(target=self._getAvailableAddonsInBG, name="getAddonData").start() + self._refreshAddonsThread = threading.Thread( + target=self._getAvailableAddonsInBG, + name="getAddonData", + daemon=True, + ) + self._refreshAddonsThread.start() elif self._filteredStatusKey in { _StatusFilterKey.INSTALLED, From 41e95c7413a40256f84e48e7cd7e2e9691fc0afa Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 16 Feb 2024 11:55:27 +1100 Subject: [PATCH 2/9] Add more timeouts --- source/_addonStore/dataManager.py | 2 +- source/_addonStore/network.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/source/_addonStore/dataManager.py b/source/_addonStore/dataManager.py index b4093e141b2..dbc2a9fd873 100644 --- a/source/_addonStore/dataManager.py +++ b/source/_addonStore/dataManager.py @@ -129,7 +129,7 @@ def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]: def _getCacheHash(self) -> Optional[str]: url = _getCacheHashURL() try: - response = requests.get(url) + response = requests.get(url, timeout=FETCH_TIMEOUT_S) except requests.exceptions.RequestException as e: log.debugWarning(f"Unable to get cache hash: {e}") return None diff --git a/source/_addonStore/network.py b/source/_addonStore/network.py index ba766a01f3f..40fb99ace2e 100644 --- a/source/_addonStore/network.py +++ b/source/_addonStore/network.py @@ -178,7 +178,9 @@ def _downloadAddonToPath( if not NVDAState.shouldWriteToDisk(): return False - with requests.get(addonData.model.URL, stream=True) as r: + # Some add-ons are quite large, so we need to allow for a long download time. + MAX_ADDON_DOWNLOAD_TIME = 60 * 60 * 6 # 6 hours + with requests.get(addonData.model.URL, stream=True, timeout=MAX_ADDON_DOWNLOAD_TIME) as r: with open(downloadFilePath, 'wb') as fd: # Most add-ons are small. This value was chosen quite arbitrarily, but with the intention to allow # interrupting the download. This is particularly important on a slow connection, to provide From 8de7ebfcb206a78dccb01081f1225bd3968260c6 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 16 Feb 2024 12:06:49 +1100 Subject: [PATCH 3/9] add more timeouts --- source/updateCheck.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/source/updateCheck.py b/source/updateCheck.py index 2a39a3e7575..c7c6e475bbc 100644 --- a/source/updateCheck.py +++ b/source/updateCheck.py @@ -107,6 +107,9 @@ def getQualifiedDriverClassNameForStats(cls): return "%s (core)"%name +UPDATE_FETCH_TIMEOUT_S = 30 # seconds + + def checkForUpdate(auto: bool = False) -> Optional[Dict]: """Check for an updated version of NVDA. This will block, so it generally shouldn't be called from the main thread. @@ -151,14 +154,14 @@ def checkForUpdate(auto: bool = False) -> Optional[Dict]: params.update(extraParams) url = "%s?%s" % (CHECK_URL, urllib.parse.urlencode(params)) try: - res = urllib.request.urlopen(url) + res = urllib.request.urlopen(url, timeout=UPDATE_FETCH_TIMEOUT_S) except IOError as e: if isinstance(e.reason, ssl.SSLCertVerificationError) and e.reason.reason == "CERTIFICATE_VERIFY_FAILED": # #4803: Windows fetches trusted root certificates on demand. # Python doesn't trigger this fetch (PythonIssue:20916), so try it ourselves _updateWindowsRootCertificates() # and then retry the update check. - res = urllib.request.urlopen(url) + res = urllib.request.urlopen(url, timeout=UPDATE_FETCH_TIMEOUT_S) else: raise if res.code != 200: @@ -656,7 +659,12 @@ def _download(self, url): # #2352: Some security scanners such as Eset NOD32 HTTP Scanner # cause huge read delays while downloading. # Therefore, set a higher timeout. - remote = urllib.request.urlopen(url, timeout=120) + # The NVDA exe is about 35 MB. + # The average download speed in the world is 0.5 MB/s + # in some developing countries with the slowest internet. + # This yields an expected download time of 10min on slower networks. + UPDATE_DOWNLOAD_TIMEOUT = 60 * 30 # 30 min + remote = urllib.request.urlopen(url, timeout=UPDATE_DOWNLOAD_TIMEOUT) if remote.code != 200: raise RuntimeError("Download failed with code %d" % remote.code) size = int(remote.headers["content-length"]) @@ -856,7 +864,11 @@ def _updateWindowsRootCertificates(): sslCont = ssl._create_unverified_context() # We must specify versionType so the server doesn't return a 404 error and # thus cause an exception. - u = urllib.request.urlopen(CHECK_URL + "?versionType=stable", context=sslCont) + u = urllib.request.urlopen( + CHECK_URL + "?versionType=stable", + context=sslCont, + timeout=UPDATE_FETCH_TIMEOUT_S, + ) cert = u.fp.raw._sock.getpeercert(True) u.close() # Convert to a form usable by Windows. From 1a8e8b928a988a0d23db1ed6024dc1ae1343bbe2 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 16 Feb 2024 12:13:20 +1100 Subject: [PATCH 4/9] add comment --- source/_addonStore/network.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/_addonStore/network.py b/source/_addonStore/network.py index 40fb99ace2e..47beda820f4 100644 --- a/source/_addonStore/network.py +++ b/source/_addonStore/network.py @@ -179,6 +179,7 @@ def _downloadAddonToPath( return False # Some add-ons are quite large, so we need to allow for a long download time. + # 1GB at 0.5 MB/s takes 4.5hr to download. MAX_ADDON_DOWNLOAD_TIME = 60 * 60 * 6 # 6 hours with requests.get(addonData.model.URL, stream=True, timeout=MAX_ADDON_DOWNLOAD_TIME) as r: with open(downloadFilePath, 'wb') as fd: From c5379fddda1c0c092b1040741acf68df3bceea66 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 16 Feb 2024 15:24:45 +1100 Subject: [PATCH 5/9] daemonize all threads --- source/NVDAObjects/behaviors.py | 4 ++-- source/UIAHandler/__init__.py | 4 ++-- source/_addonStore/dataManager.py | 2 +- source/nvwave.py | 3 ++- source/remotePythonConsole.py | 3 ++- source/updateCheck.py | 8 ++++---- source/virtualBuffers/__init__.py | 5 +++-- source/visionEnhancementProviders/NVDAHighlighter.py | 4 ++-- source/watchdog.py | 4 +++- source/winInputHook.py | 3 ++- .../libraries/SystemTestSpy/speechSpyGlobalPlugin.py | 6 +++++- .../libraries/SystemTestSpy/speechSpySynthDriver.py | 1 + 12 files changed, 29 insertions(+), 18 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index dd86a16bb8b..fccc39a9369 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -350,9 +350,9 @@ def startMonitoring(self): return thread = self._monitorThread = threading.Thread( name=f"{self.__class__.__qualname__}._monitorThread", - target=self._monitor + target=self._monitor, + daemon=True, ) - thread.daemon = True self._keepMonitoring = True self._event.clear() thread.start() diff --git a/source/UIAHandler/__init__.py b/source/UIAHandler/__init__.py index 921c6511b19..77823b0a45f 100644 --- a/source/UIAHandler/__init__.py +++ b/source/UIAHandler/__init__.py @@ -427,9 +427,9 @@ def __init__(self): self.MTAThreadInitException=None self.MTAThread = threading.Thread( name=f"{self.__class__.__module__}.{self.__class__.__qualname__}.MTAThread", - target=self.MTAThreadFunc + target=self.MTAThreadFunc, + daemon=True, ) - self.MTAThread.daemon=True self.MTAThread.start() self.MTAThreadInitEvent.wait(2) if self.MTAThreadInitException: diff --git a/source/_addonStore/dataManager.py b/source/_addonStore/dataManager.py index dbc2a9fd873..7392ac8ff2f 100644 --- a/source/_addonStore/dataManager.py +++ b/source/_addonStore/dataManager.py @@ -109,7 +109,7 @@ def __init__(self): def terminate(self): if self._getAddonsThread.is_alive(): - self._getAddonsThread.join(timeout=FETCH_TIMEOUT_S + 1) + self._getAddonsThread.join(timeout=1) def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]: url = _getAddonStoreURL(self._preferredChannel, self._lang, apiVersion) diff --git a/source/nvwave.py b/source/nvwave.py index 6174d2f3b04..1e34699f66e 100644 --- a/source/nvwave.py +++ b/source/nvwave.py @@ -721,7 +721,8 @@ def play(): fileWavePlayerThread.join() fileWavePlayerThread = threading.Thread( name=f"{__name__}.playWaveFile({os.path.basename(fileName)})", - target=play + target=play, + daemon=True, ) fileWavePlayerThread.start() else: diff --git a/source/remotePythonConsole.py b/source/remotePythonConsole.py index 018f51eb66c..128705a9f39 100644 --- a/source/remotePythonConsole.py +++ b/source/remotePythonConsole.py @@ -76,7 +76,8 @@ def initialize(): server.daemon_threads = True thread = threading.Thread( name=__name__, # remotePythonConsole - target=server.serve_forever + target=server.serve_forever, + daemon=True, ) thread.daemon = True thread.start() diff --git a/source/updateCheck.py b/source/updateCheck.py index c7c6e475bbc..469c8478adf 100644 --- a/source/updateCheck.py +++ b/source/updateCheck.py @@ -256,9 +256,9 @@ def check(self): """ t = threading.Thread( name=f"{self.__class__.__module__}.{self.check.__qualname__}", - target=self._bg + target=self._bg, + daemon = True, ) - t.daemon = True self._started() t.start() @@ -617,9 +617,9 @@ def start(self): self._progressDialog.Raise() t = threading.Thread( name=f"{self.__class__.__module__}.{self.start.__qualname__}", - target=self._bg + target=self._bg, + daemon=True, ) - t.daemon = True t.start() def _guiExec(self, func, *args): diff --git a/source/virtualBuffers/__init__.py b/source/virtualBuffers/__init__.py index 8f5386a5160..9d3c4d38b08 100644 --- a/source/virtualBuffers/__init__.py +++ b/source/virtualBuffers/__init__.py @@ -451,8 +451,9 @@ def loadBuffer(self): self._loadProgressCallLater = wx.CallLater(1000, self._loadProgress) threading.Thread( name=f"{self.__class__.__module__}.{self.loadBuffer.__qualname__}", - target=self._loadBuffer).start( - ) + target=self._loadBuffer, + daemon=True, + ).start() def _loadBuffer(self): try: diff --git a/source/visionEnhancementProviders/NVDAHighlighter.py b/source/visionEnhancementProviders/NVDAHighlighter.py index 9d10b892520..5a67bafcdc8 100644 --- a/source/visionEnhancementProviders/NVDAHighlighter.py +++ b/source/visionEnhancementProviders/NVDAHighlighter.py @@ -418,10 +418,10 @@ def __init__(self): winGDI.gdiPlusInitialize() self._highlighterThread = threading.Thread( name=f"{self.__class__.__module__}.{self.__class__.__qualname__}", - target=self._run + target=self._run, + daemon=True, ) self._highlighterRunningEvent = threading.Event() - self._highlighterThread.daemon = True self._highlighterThread.start() # Make sure the highlighter thread doesn't exit early. waitResult = self._highlighterRunningEvent.wait(0.2) diff --git a/source/watchdog.py b/source/watchdog.py index d220bc3f0c4..3244f239a73 100644 --- a/source/watchdog.py +++ b/source/watchdog.py @@ -292,7 +292,9 @@ def initialize(): NVDAHelper._setDllFuncPointer(NVDAHelper.localLib, "_notifySendMessageCancelled", _notifySendMessageCancelled) _watcherThread = threading.Thread( name=__name__, - target=_watcher + target=_watcher, + # TODO: should we change this? does this need to be kept alive to handle crashes? + daemon=True, ) alive() _watcherThread.start() diff --git a/source/winInputHook.py b/source/winInputHook.py index 26703ae9674..a22fcd92d05 100755 --- a/source/winInputHook.py +++ b/source/winInputHook.py @@ -91,7 +91,8 @@ def initialize(): if hookThreadRefCount==1: hookThread = threading.Thread( name=__name__, # winInputHook - target=hookThreadFunc + target=hookThreadFunc, + daemon=True, ) hookThread.start() diff --git a/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py b/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py index 725e3ed6810..f6177b1f716 100644 --- a/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py +++ b/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py @@ -534,7 +534,11 @@ def _start(self): serve=False # we want to start this serving on another thread so as not to block. ) log.debug("Server address: {}".format(server.server_address)) - server_thread = threading.Thread(target=server.serve, name="RF Test Spy Thread") + server_thread = threading.Thread( + target=server.serve, + name="RF Test Spy Thread", + daemon=True, + ) server_thread.start() def terminate(self): diff --git a/tests/system/libraries/SystemTestSpy/speechSpySynthDriver.py b/tests/system/libraries/SystemTestSpy/speechSpySynthDriver.py index 632491dc1dc..2afa0e3c51b 100644 --- a/tests/system/libraries/SystemTestSpy/speechSpySynthDriver.py +++ b/tests/system/libraries/SystemTestSpy/speechSpySynthDriver.py @@ -39,6 +39,7 @@ def __init__(self): self._doSpeechThread = threading.Thread( target=self._processSpeech, name="speech spy synth driver", + daemon=True, ) self._doSpeechThread.start() From 2d25b765b2b5faa794fbdc2112ce2a043fe0bb64 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 16 Feb 2024 16:05:52 +1100 Subject: [PATCH 6/9] fix lint --- source/updateCheck.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/updateCheck.py b/source/updateCheck.py index 469c8478adf..08ed1d8f19d 100644 --- a/source/updateCheck.py +++ b/source/updateCheck.py @@ -257,7 +257,7 @@ def check(self): t = threading.Thread( name=f"{self.__class__.__module__}.{self.check.__qualname__}", target=self._bg, - daemon = True, + daemon=True, ) self._started() t.start() From c486220b55caa867fb6a5d453d919fa14b81faf6 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 19 Feb 2024 11:34:30 +1100 Subject: [PATCH 7/9] Address review comments --- source/_addonStore/dataManager.py | 12 ++++++++---- source/remotePythonConsole.py | 1 - source/updateCheck.py | 3 +++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/source/_addonStore/dataManager.py b/source/_addonStore/dataManager.py index 7392ac8ff2f..455d809955d 100644 --- a/source/_addonStore/dataManager.py +++ b/source/_addonStore/dataManager.py @@ -100,20 +100,23 @@ def __init__(self): self._compatibleAddonCache = self._getCachedAddonData(self._cacheCompatibleFile) self._installedAddonsCache = _InstalledAddonsCache() # Fetch available add-ons cache early - self._getAddonsThread = threading.Thread( + self._initialiseAvailableAddonsThread = threading.Thread( target=self.getLatestCompatibleAddons, name="initialiseAvailableAddons", daemon=True, ) - self._getAddonsThread.start() + self._initialiseAvailableAddonsThread.start() def terminate(self): - if self._getAddonsThread.is_alive(): - self._getAddonsThread.join(timeout=1) + if self._initialiseAvailableAddonsThread.is_alive(): + self._initialiseAvailableAddonsThread.join(timeout=1) + if self._initialiseAvailableAddonsThread.is_alive(): + log.debugWarning("initialiseAvailableAddons thread did not terminate immediately") def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]: url = _getAddonStoreURL(self._preferredChannel, self._lang, apiVersion) try: + log.debug(f"Fetching add-on data from {url}") response = requests.get(url, timeout=FETCH_TIMEOUT_S) except requests.exceptions.RequestException as e: log.debugWarning(f"Unable to fetch addon data: {e}") @@ -129,6 +132,7 @@ def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]: def _getCacheHash(self) -> Optional[str]: url = _getCacheHashURL() try: + log.debug(f"Fetching add-on data from {url}") response = requests.get(url, timeout=FETCH_TIMEOUT_S) except requests.exceptions.RequestException as e: log.debugWarning(f"Unable to get cache hash: {e}") diff --git a/source/remotePythonConsole.py b/source/remotePythonConsole.py index 128705a9f39..eb6be491f21 100644 --- a/source/remotePythonConsole.py +++ b/source/remotePythonConsole.py @@ -79,7 +79,6 @@ def initialize(): target=server.serve_forever, daemon=True, ) - thread.daemon = True thread.start() def terminate(): diff --git a/source/updateCheck.py b/source/updateCheck.py index 08ed1d8f19d..4443ddd263b 100644 --- a/source/updateCheck.py +++ b/source/updateCheck.py @@ -154,6 +154,7 @@ def checkForUpdate(auto: bool = False) -> Optional[Dict]: params.update(extraParams) url = "%s?%s" % (CHECK_URL, urllib.parse.urlencode(params)) try: + log.debug(f"Fetching update data from {url}") res = urllib.request.urlopen(url, timeout=UPDATE_FETCH_TIMEOUT_S) except IOError as e: if isinstance(e.reason, ssl.SSLCertVerificationError) and e.reason.reason == "CERTIFICATE_VERIFY_FAILED": @@ -161,6 +162,7 @@ def checkForUpdate(auto: bool = False) -> Optional[Dict]: # Python doesn't trigger this fetch (PythonIssue:20916), so try it ourselves _updateWindowsRootCertificates() # and then retry the update check. + log.debug(f"Fetching update data from {url}") res = urllib.request.urlopen(url, timeout=UPDATE_FETCH_TIMEOUT_S) else: raise @@ -859,6 +861,7 @@ class CERT_CHAIN_PARA(ctypes.Structure): ) def _updateWindowsRootCertificates(): + log.debug("Updating Windows root certificates") crypt = ctypes.windll.crypt32 # Get the server certificate. sslCont = ssl._create_unverified_context() From 3e8331fa64f0968f5fc0c8ea28326fb6d2c55f80 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 19 Feb 2024 13:22:38 +1100 Subject: [PATCH 8/9] remove todo --- source/watchdog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/watchdog.py b/source/watchdog.py index 3244f239a73..cebe5156320 100644 --- a/source/watchdog.py +++ b/source/watchdog.py @@ -293,7 +293,6 @@ def initialize(): _watcherThread = threading.Thread( name=__name__, target=_watcher, - # TODO: should we change this? does this need to be kept alive to handle crashes? daemon=True, ) alive() From f3c5da42c3552e9dff870ea083849648f4df73cf Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 19 Feb 2024 13:28:12 +1100 Subject: [PATCH 9/9] update changes and build version --- source/buildVersion.py | 2 +- user_docs/en/changes.t2t | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/source/buildVersion.py b/source/buildVersion.py index ed4f8e36219..ec823123174 100644 --- a/source/buildVersion.py +++ b/source/buildVersion.py @@ -67,7 +67,7 @@ def formatVersionForGUI(year, major, minor): name = "NVDA" version_year = 2023 version_major = 3 -version_minor = 3 +version_minor = 4 version_build = 0 # Should not be set manually. Set in 'sconscript' provided by 'appVeyor.yml' version=_formatDevVersionString() publisher="unknown" diff --git a/user_docs/en/changes.t2t b/user_docs/en/changes.t2t index e1cd81c612a..bf0b90bff1e 100644 --- a/user_docs/en/changes.t2t +++ b/user_docs/en/changes.t2t @@ -4,6 +4,14 @@ What's New in NVDA %!includeconf: ../changes.t2tconf %!includeconf: ./locale.t2tconf += 2023.3.4 = +This is a patch release to fix an installer issue. + +== Bug Fixes == +- Fixed bug which caused the NVDA process to fail to exit correctly. +When running the installer, this resulted in the installation entering an unrecoverable state. (#16122, #16123) +- + = 2023.3.3 = This is a patch release to fix a security issue. Please responsibly disclose security issues following NVDA's [security policy https://github.com/nvaccess/nvda/blob/master/security.md].