From f09f0316b840587a2459fd44b4fa2bc2318e78d5 Mon Sep 17 00:00:00 2001 From: Rani367 Date: Fri, 13 Mar 2026 23:33:36 +0200 Subject: [PATCH] Handle network errors gracefully instead of showing raw tracebacks When internet connectivity is lost during downloads, the installer now catches network exceptions and shows user-friendly error messages with recovery guidance instead of crashing with a Python traceback. - Add NetworkError exception class to urlcache.py - Add retry logic to get_size() which previously had none (5 retries with backoff) - Raise NetworkError from get_block() after retries exhausted instead of re-raising raw exceptions - Wrap pre-partition download calls with interactive retry prompts (safe to retry since no disk state has changed) - Wrap post-partition do_install() calls with clean exit and repair guidance (tells user to re-run installer to trigger repair flow) - Add NetworkError handler to action_rebuild_vendorfw - Add NetworkError safety net in global exception handler Closes #409 Signed-off-by: Rani367 --- src/main.py | 123 +++++++++++++++++++++++++++++++++++++++++++----- src/urlcache.py | 47 ++++++++++++------ 2 files changed, 143 insertions(+), 27 deletions(-) diff --git a/src/main.py b/src/main.py index d6760a4..dc04236 100644 --- a/src/main.py +++ b/src/main.py @@ -4,6 +4,7 @@ from dataclasses import dataclass import system, osenum, stub, diskutil, osinstall, asahi_firmware, m1n1, bugs +from urlcache import NetworkError from util import * PART_ALIGN = psize("1MiB") @@ -263,11 +264,25 @@ def action_install_into_container(self, avail_parts): logging.info(f"Chosen IPSW version: {ipsw.version}") self.ins = stub.StubInstaller(self.sysinfo, self.dutil, self.osinfo) - self.ins.load_ipsw(ipsw) self.osins = osinstall.OSInstaller(self.dutil, self.data, template) - self.osins.load_package() + while True: + try: + self.ins.load_ipsw(ipsw) + self.osins.load_package() + break + except NetworkError as e: + logging.error(f"Network error: {e}") + print() + p_error(f"Download failed: {e}") + p_message("Please check your internet connection.") + if not self.yesno("Retry"): + return True + print() - self.do_install() + try: + self.do_install() + except NetworkError as e: + self._handle_post_partition_network_error(e) def action_wipe(self): p_warning("This will wipe all data on the currently selected disk.") @@ -280,27 +295,63 @@ def action_wipe(self): template = self.choose_os() self.osins = osinstall.OSInstaller(self.dutil, self.data, template) - self.osins.load_package() + while True: + try: + self.osins.load_package() + break + except NetworkError as e: + logging.error(f"Network error: {e}") + print() + p_error(f"Download failed: {e}") + p_message("Please check your internet connection.") + if not self.yesno("Retry"): + return True + print() min_size = STUB_SIZE + (self.osins.min_size if self.expert else self.osins.min_recommended_size) print() p_message(f"Minimum required space for this OS: {ssize(min_size)}") start, end = self.dutil.get_disk_usable_range(self.cur_disk) - os_size = self.get_os_size_and_info(end - start, min_size, template) + while True: + try: + os_size = self.get_os_size_and_info(end - start, min_size, template) + break + except NetworkError as e: + logging.error(f"Network error: {e}") + print() + p_error(f"Download failed: {e}") + p_message("Please check your internet connection.") + if not self.yesno("Retry"): + return True + print() p_progress(f"Partitioning the whole disk ({self.cur_disk})") self.part = self.dutil.partitionDisk(self.cur_disk, "apfs", self.osins.name, STUB_SIZE) p_progress(f"Creating new stub macOS named {self.osins.name}") logging.info(f"Creating stub macOS: {self.osins.name}") - self.do_install(os_size) + try: + self.do_install(os_size) + except NetworkError as e: + self._handle_post_partition_network_error(e) def action_install_into_free(self, avail_free): template = self.choose_os() self.osins = osinstall.OSInstaller(self.dutil, self.data, template) - self.osins.load_package() + while True: + try: + self.osins.load_package() + break + except NetworkError as e: + logging.error(f"Network error: {e}") + print() + p_error(f"Download failed: {e}") + p_message("Please check your internet connection.") + if not self.yesno("Retry"): + return True + print() min_size = STUB_SIZE + (self.osins.min_size if self.expert else self.osins.min_recommended_size) print() @@ -327,13 +378,27 @@ def action_install_into_free(self, avail_free): print() p_message(f"Available free space: {ssize(free_part.size)}") - os_size = self.get_os_size_and_info(free_part.size, min_size, template) + while True: + try: + os_size = self.get_os_size_and_info(free_part.size, min_size, template) + break + except NetworkError as e: + logging.error(f"Network error: {e}") + print() + p_error(f"Download failed: {e}") + p_message("Please check your internet connection.") + if not self.yesno("Retry"): + return True + print() p_progress(f"Creating new stub macOS named {self.osins.name}") logging.info(f"Creating stub macOS: {self.osins.name}") self.part = self.dutil.addPartition(free_part.name, "apfs", self.osins.name, STUB_SIZE) - self.do_install(os_size) + try: + self.do_install(os_size) + except NetworkError as e: + self._handle_post_partition_network_error(e) def get_os_size_and_info(self, free_size, min_size, template): os_size = None @@ -488,9 +553,19 @@ def action_rebuild_vendorfw(self, oses): p_message("Unable to rebuild firmware") return False - self.ins.load_ipsw(ipsw) - self.ins.load_identity() - self.ins.collect_firmware(fw_pkg) + try: + self.ins.load_ipsw(ipsw) + self.ins.load_identity() + self.ins.collect_firmware(fw_pkg) + except NetworkError as e: + logging.error(f"Network error during firmware rebuild: {e}") + print() + p_error(f"Firmware rebuild failed: {e}") + p_message("Please check your internet connection and try again.") + print() + p_message("Press enter to return to the main menu.") + self.input() + return True fw_pkg.close() p_plain(f" Copying firmware into {target.name} partition...") @@ -505,6 +580,23 @@ def action_rebuild_vendorfw(self, oses): return True + def _handle_post_partition_network_error(self, e): + logging.error(f"Network error during installation: {e}") + print() + p_error("Installation failed due to a network error.") + p_error(f" {e}") + print() + p_message("Please check your internet connection, then re-run the installer.") + p_message("The installer will detect the incomplete installation and offer") + p_message("to repair it.") + print() + p_warning("If you need to file a bug report, please attach the log file:") + p_warning(f" {os.getcwd()}/installer.log") + print() + p_message("Press enter to exit.") + self.input() + sys.exit(1) + def do_install(self, total_size=None): p_progress(f"Installing stub macOS into {self.part.name} ({self.part.label})") @@ -1151,6 +1243,13 @@ def main_loop(self): print() logging.info("KeyboardInterrupt") p_error("Interrupted") + except NetworkError as e: + logging.exception("Network error") + p_error(f"Installation failed due to a network error: {e}") + print() + p_message("Please check your internet connection and try again.") + p_warning("If you need to file a bug report, please attach the log file:") + p_warning(f" {os.getcwd()}/installer.log") except subprocess.CalledProcessError as e: cmd = shlex.join(e.cmd) p_error(f"Failed to run process: {cmd}") diff --git a/src/urlcache.py b/src/urlcache.py index 9fb872a..365cd68 100644 --- a/src/urlcache.py +++ b/src/urlcache.py @@ -3,9 +3,12 @@ from dataclasses import dataclass from urllib import parse -from http.client import HTTPSConnection, HTTPConnection +from http.client import HTTPSConnection, HTTPConnection, HTTPException from util import * +class NetworkError(Exception): + pass + @dataclass class CacheBlock: idx: int @@ -78,19 +81,31 @@ def seekable(self): return True def get_size(self): - for i in range(10): - con = self.get_con() - con.request("HEAD", self.url.path, headers={"Connection":" keep-alive"}) - res = con.getresponse() - res.read() - loc = res.getheader("Location", None) - if loc is not None: - self.url = parse.urlparse(loc) - self.con = None - continue - return int(res.getheader("Content-length")) - - raise Exception("Maximum number of redirects reached") + retries = 5 + sleep = 1 + for retry in range(retries + 1): + try: + for i in range(10): + con = self.get_con() + con.request("HEAD", self.url.path, headers={"Connection":" keep-alive"}) + res = con.getresponse() + res.read() + loc = res.getheader("Location", None) + if loc is not None: + self.url = parse.urlparse(loc) + self.con = None + continue + return int(res.getheader("Content-length")) + raise Exception("Maximum number of redirects reached") + except (OSError, HTTPException) as e: + if retry == retries: + raise NetworkError( + f"Failed to connect to {self.url.netloc} after multiple retries" + ) from e + p_warning(f"Connection error ({e}), retrying... ({retry + 1}/{retries})") + time.sleep(sleep) + self.close_connection() + sleep += 1 def get_partial(self, off, size, bypass_cache=False): path = self.url.path @@ -147,7 +162,9 @@ def get_block(self, blk, readahead=1): except Exception as e: if retry == retries: p_error(f"Exceeded maximum retries downloading data.") - raise + raise NetworkError( + f"Download failed: lost connection to {self.url.netloc}" + ) from e p_warning(f"Error downloading data ({e}), retrying... ({retry + 1}/{retries})") time.sleep(sleep) self.close_connection()