feat: Implement timeout handling and background processing for update feature
This commit is contained in:
parent
c97301728c
commit
f4eb511a1c
7 changed files with 849 additions and 94 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue