feat: Implement timeout handling and background processing for update feature

This commit is contained in:
claudi 2026-01-30 12:09:03 +01:00
parent c97301728c
commit f4eb511a1c
7 changed files with 849 additions and 94 deletions

View file

@ -593,7 +593,7 @@ class MainWindow(QMainWindow):
logger.error(f"Failed to initialize update check: {e}")
def _run_async_check(self, manager) -> None:
"""Run update check in background thread.
"""Run update check in background thread with safety timeout.
Args:
manager: UpdateManager instance
@ -606,17 +606,11 @@ class MainWindow(QMainWindow):
# Connect signals
worker.update_available.connect(self._on_update_available)
worker.update_status.connect(self._on_update_status)
worker.check_failed.connect(self._on_check_failed)
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
# Close checking dialog when finished
def close_checking_dialog():
if hasattr(self, 'checking_dialog') and self.checking_dialog:
self.checking_dialog.close()
worker.finished.connect(close_checking_dialog)
# Keep reference to thread to prevent garbage collection
self._background_threads.append(thread)
@ -631,6 +625,26 @@ class MainWindow(QMainWindow):
worker.moveToThread(thread)
thread.started.connect(worker.run)
thread.start()
# Set a safety timeout - if check doesn't finish in 30 seconds, force close dialog
def force_close_timeout():
logger.warning("Update check taking too long (30s timeout)")
if hasattr(self, 'checking_dialog') and self.checking_dialog:
self.checking_dialog.close()
self.set_update_status("Check timed out - no server response", emoji="⏱️")
# Show error dialog
from PySide6.QtWidgets import QMessageBox
QMessageBox.warning(
self,
"Update Check Timeout",
"The server did not respond within 30 seconds.\n\n"
"This may be due to a network issue or server unavailability.\n\n"
"Please check your connection and try again."
)
QTimer.singleShot(30000, force_close_timeout) # 30 seconds
except Exception as e:
logger.error(f"Failed to start update check thread: {e}")
@ -645,13 +659,36 @@ class MainWindow(QMainWindow):
# If this is a manual check and we get the "Ready" status, it means no updates
if self._is_manual_check and status == "Ready":
# Show "No Updates Available" dialog
from webdrop_bridge.ui.update_manager_ui import NoUpdateDialog
# Close checking dialog first, then show result
if hasattr(self, 'checking_dialog') and self.checking_dialog:
self.checking_dialog.close()
from webdrop_bridge.ui.update_manager_ui import NoUpdateDialog
dialog = NoUpdateDialog(parent=self)
self._is_manual_check = False
dialog.exec()
def _on_check_failed(self, error_message: str) -> None:
"""Handle update check failure.
Args:
error_message: Error description
"""
logger.error(f"Update check failed: {error_message}")
self.set_update_status(f"Check failed: {error_message}", emoji="")
self._is_manual_check = False
# Close checking dialog first, then show error
if hasattr(self, 'checking_dialog') and self.checking_dialog:
self.checking_dialog.close()
from PySide6.QtWidgets import QMessageBox
QMessageBox.warning(
self,
"Update Check Failed",
f"Could not check for updates:\n\n{error_message}\n\nPlease try again later."
)
def _on_update_available(self, release) -> None:
"""Handle update available notification.
@ -710,7 +747,7 @@ class MainWindow(QMainWindow):
self.set_update_status(f"Skipped v{version}", emoji="")
def _start_update_download(self, release) -> None:
"""Start downloading the update.
"""Start downloading the update in background thread.
Args:
release: Release object to download
@ -718,69 +755,100 @@ class MainWindow(QMainWindow):
logger.info(f"Starting download for v{release.version}")
self.set_update_status(f"Downloading v{release.version}", emoji="⬇️")
# For now, just start installer directly (simplified)
# In production, would show download progress dialog
self._perform_update(release)
# Run download in background thread to avoid blocking UI
self._perform_update_async(release)
def _perform_update(self, release) -> None:
"""Download and install the update.
def _perform_update_async(self, release) -> None:
"""Download and install update asynchronously in background thread.
Args:
release: Release object to download and install
"""
from webdrop_bridge.core.updater import UpdateManager
from webdrop_bridge.ui.update_manager_ui import InstallDialog
try:
logger.info(f"Downloading and installing v{release.version}")
# Create update manager
manager = UpdateManager(
current_version=self.config.app_version,
config_dir=Path.home() / ".webdrop-bridge"
)
# Download synchronously for simplicity
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Create and start background thread
thread = QThread()
worker = UpdateDownloadWorker(manager, release, self.config.app_version)
installer_path = loop.run_until_complete(
manager.download_update(release)
)
# Connect signals
worker.download_complete.connect(self._on_download_complete)
worker.download_failed.connect(self._on_download_failed)
worker.update_status.connect(self._on_update_status)
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
if not installer_path:
self.set_update_status("Download failed", emoji="")
logger.error("Download failed - no installer found")
return
# Keep reference to thread to prevent garbage collection
self._background_threads.append(thread)
logger.info(f"Downloaded to {installer_path}")
# Clean up finished threads from list
def cleanup_thread():
if thread in self._background_threads:
self._background_threads.remove(thread)
# Verify checksum
checksum_ok = loop.run_until_complete(
manager.verify_checksum(installer_path, release)
)
thread.finished.connect(cleanup_thread)
loop.close()
# Start thread
worker.moveToThread(thread)
thread.started.connect(worker.run)
thread.start()
if not checksum_ok:
self.set_update_status("Checksum verification failed", emoji="")
logger.error("Checksum verification failed")
return
# Set a safety timeout - if download doesn't finish in 10 minutes (600 seconds),
# force stop to prevent infinite hang
def force_timeout():
logger.error("Download taking too long (10 minute timeout)")
self.set_update_status("Download timed out - no server response", emoji="⏱️")
worker.download_failed.emit("Download took too long with no response")
thread.quit()
thread.wait()
logger.info("Checksum verification passed")
self.set_update_status(f"Ready to install v{release.version}", emoji="")
# Show install confirmation dialog
install_dialog = InstallDialog(parent=self)
install_dialog.install_now.connect(
lambda: self._do_install(installer_path)
)
install_dialog.exec()
QTimer.singleShot(600000, force_timeout) # 10 minutes
except Exception as e:
logger.error(f"Update failed: {e}")
logger.error(f"Failed to start update download: {e}")
self.set_update_status(f"Update failed: {str(e)[:30]}", emoji="")
def _on_download_complete(self, installer_path: Path) -> None:
"""Handle successful download and verification.
Args:
installer_path: Path to downloaded and verified installer
"""
from webdrop_bridge.ui.update_manager_ui import InstallDialog
logger.info(f"Download complete: {installer_path}")
self.set_update_status("Ready to install", emoji="")
# Show install confirmation dialog
install_dialog = InstallDialog(parent=self)
install_dialog.install_now.connect(
lambda: self._do_install(installer_path)
)
install_dialog.exec()
def _on_download_failed(self, error: str) -> None:
"""Handle download failure.
Args:
error: Error message
"""
logger.error(f"Download failed: {error}")
self.set_update_status(error, emoji="")
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(
self,
"Download Failed",
f"Could not download the update:\n\n{error}\n\nPlease try again later."
)
def _do_install(self, installer_path: Path) -> None:
"""Execute the installer.
@ -810,6 +878,7 @@ class UpdateCheckWorker(QObject):
# Define signals at class level
update_available = Signal(object) # Emits Release object
update_status = Signal(str, str) # Emits (status_text, emoji)
check_failed = Signal(str) # Emits error message
finished = Signal()
def __init__(self, manager, current_version: str):
@ -825,39 +894,139 @@ class UpdateCheckWorker(QObject):
def run(self) -> None:
"""Run the update check."""
loop = None
try:
# Notify checking status
self.update_status.emit("Checking for updates", "🔄")
try:
# Run async check with timeout
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
release = loop.run_until_complete(self.manager.check_for_updates())
loop.close()
except RuntimeError as e:
# Handle event loop already running or other asyncio issues
logger.warning(f"Asyncio error during update check: {e}")
# Try using existing loop
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
release = loop.run_until_complete(self.manager.check_for_updates())
except Exception as retry_error:
logger.error(f"Failed to check updates on retry: {retry_error}")
release = None
# Create a fresh event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Emit result
if release:
self.update_available.emit(release)
else:
# No update available - show ready status
self.update_status.emit("Ready", "")
try:
# Check for updates with short timeout (network call has its own timeout)
logger.debug("Starting update check with 10-second timeout")
release = loop.run_until_complete(
asyncio.wait_for(
self.manager.check_for_updates(),
timeout=10
)
)
# Emit result
if release:
self.update_available.emit(release)
else:
# No update available - show ready status
self.update_status.emit("Ready", "")
except asyncio.TimeoutError:
logger.warning("Update check timed out - server not responding")
self.check_failed.emit("Server not responding - check again later")
except Exception as e:
logger.error(f"Update check failed: {e}")
self.update_status.emit("Update check failed", "⚠️")
self.check_failed.emit(f"Check failed: {str(e)[:50]}")
finally:
# Properly close the event loop
if loop is not None:
try:
if not loop.is_closed():
loop.close()
logger.debug("Event loop closed")
except Exception as e:
logger.warning(f"Error closing event loop: {e}")
self.finished.emit()
class UpdateDownloadWorker(QObject):
"""Worker for downloading and verifying update asynchronously."""
# Define signals at class level
download_complete = Signal(Path) # Emits installer_path
download_failed = Signal(str) # Emits error message
update_status = Signal(str, str) # Emits (status_text, emoji)
finished = Signal()
def __init__(self, manager, release, current_version: str):
"""Initialize worker.
Args:
manager: UpdateManager instance
release: Release object to download
current_version: Current app version
"""
super().__init__()
self.manager = manager
self.release = release
self.current_version = current_version
def run(self) -> None:
"""Run the download and verification."""
loop = None
try:
# Download the update
self.update_status.emit(f"Downloading v{self.release.version}", "⬇️")
# Create a fresh event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# Download with 5 minute timeout (300 seconds)
logger.info("Starting download with 5-minute timeout")
installer_path = loop.run_until_complete(
asyncio.wait_for(
self.manager.download_update(self.release),
timeout=300
)
)
if not installer_path:
self.update_status.emit("Download failed", "")
self.download_failed.emit("No installer found in release")
logger.error("Download failed - no installer found")
return
logger.info(f"Downloaded to {installer_path}")
self.update_status.emit("Verifying download", "🔍")
# Verify checksum with 30 second timeout
logger.info("Starting checksum verification")
checksum_ok = loop.run_until_complete(
asyncio.wait_for(
self.manager.verify_checksum(installer_path, self.release),
timeout=30
)
)
if not checksum_ok:
self.update_status.emit("Verification failed", "")
self.download_failed.emit("Checksum verification failed")
logger.error("Checksum verification failed")
return
logger.info("Checksum verification passed")
self.download_complete.emit(installer_path)
except asyncio.TimeoutError as e:
logger.error(f"Download/verification timed out: {e}")
self.update_status.emit("Operation timed out", "⏱️")
self.download_failed.emit("Download or verification timed out (no response from server)")
except Exception as e:
logger.error(f"Error during download: {e}")
self.download_failed.emit(f"Download error: {str(e)[:50]}")
except Exception as e:
logger.error(f"Download worker failed: {e}")
self.download_failed.emit(f"Download error: {str(e)[:50]}")
finally:
# Properly close the event loop
if loop is not None:
try:
if not loop.is_closed():
loop.close()
logger.debug("Event loop closed")
except Exception as e:
logger.warning(f"Error closing event loop: {e}")
self.finished.emit()