From af3d0067dcb8617f7810508e43ff04c5cb9edf80 Mon Sep 17 00:00:00 2001 From: Michael Herrmann Date: Tue, 2 Jun 2026 19:50:59 +0200 Subject: [PATCH] Gracefully handle Omaha 4 update install timeout on macOS (#36914) --- patches/chrome-updater-mac-.install.sh.patch | 21 +++++++++++-- rewrite/chrome/updater/mac/.install.sh.toml | 25 +++++++++++++++- updater/install_sh_mac_test.py | 31 +++++++++++++++++--- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/patches/chrome-updater-mac-.install.sh.patch b/patches/chrome-updater-mac-.install.sh.patch index cb556898dbe..8cdf45177a0 100644 --- a/patches/chrome-updater-mac-.install.sh.patch +++ b/patches/chrome-updater-mac-.install.sh.patch @@ -1,5 +1,5 @@ diff --git a/chrome/updater/mac/.install.sh b/chrome/updater/mac/.install.sh -index 3b1874d09fca0cf88e361927dfea7c4273790030..c98a50dab0f753aff3a604b43d1659b9eb78abd0 100755 +index 3b1874d09fca0cf88e361927dfea7c4273790030..67479bdad6fbd5e95231e0ecc0dae081801f606e 100755 --- a/chrome/updater/mac/.install.sh +++ b/chrome/updater/mac/.install.sh @@ -322,7 +322,7 @@ main() { @@ -20,11 +20,28 @@ index 3b1874d09fca0cf88e361927dfea7c4273790030..c98a50dab0f753aff3a604b43d1659b9 note "update_app = ${update_app}" # Make sure that it's an absolute path. -@@ -463,7 +463,52 @@ main() { +@@ -455,15 +455,68 @@ main() { + # directory unusable even for an instant. + if [[ -n "${update_versioned_dir}" ]]; then + note "rsyncing versioned directory" +- if ! rsync ${RSYNC_FLAGS} --delete-before "${update_versioned_dir}/" \ ++ if ! perl -e 'alarm shift; exec @ARGV' "${RSYNC_TIMEOUT:-600}" rsync ${RSYNC_FLAGS} --delete-before "${update_versioned_dir}/" \ + "${new_versioned_dir}"; then ++ local rsync_status=${PIPESTATUS[0]} ++ # The assignment we just made set PIPESTATUS[0] to 0. Restore it. The ++ # leading `!` keeps `set -e` from terminating the script. ++ ! (exit "${rsync_status}") + err "rsync of versioned directory failed, status ${PIPESTATUS[0]}" + + # If the rsync of a new-layout versioned directory failed, remove it. # The incomplete version would break code signature validation. note "cleaning up new_versioned_dir" rm -rf "${new_versioned_dir}" - exit 12 ++ if [[ ${rsync_status} -eq 142 ]]; then ++ # rsync timed out. ++ exit 80 ++ fi + # Try various remedies; Report in exit code what would have worked. + local mkdir_stderr + if ! mkdir_stderr="$(mkdir -p "${new_versioned_dir}" 2>&1 1>/dev/null)" diff --git a/rewrite/chrome/updater/mac/.install.sh.toml b/rewrite/chrome/updater/mac/.install.sh.toml index 60d6e1f7e07..0e6458d6e08 100644 --- a/rewrite/chrome/updater/mac/.install.sh.toml +++ b/rewrite/chrome/updater/mac/.install.sh.toml @@ -27,6 +27,25 @@ description = 'Omit perms, link and dir times when non-root' pattern = 'RSYNC_FLAGS="--ignore-times --links --perms --recursive --times"' replace = 'RSYNC_FLAGS="--ignore-times --links $(if [[ ${EUID} -eq 0 ]]; then echo "--perms --recursive --times"; else echo "--no-perms --executability --chmod=u=rwX,go=rX --recursive"; fi)"' +# install_from_archive.mm executes .install.sh with a timeout of 15 minutes. The +# versioned-dir rsync normally takes only a few seconds. But there was one +# reported case where it took 16 minutes. (It seems likely that there was some +# interference; either the computer went to sleep or the OS was blocking on a +# prompt.) This case bricked the browser because install_from_archive.mm then +# interrupted the later rsyncing of the app directory mid-execution. +# The following substitution runs rsync with its own, lower timeout. This gives +# the script a chance to put the browser back into a usable state when the +# parent timeout would have likely been exceeded. +[[substitution]] +description = 'Cap versioned-dir rsync at 10 minutes, capture status' +re_pattern = '''if ! rsync (.*? "\$\{update_versioned_dir\}/" .*?); then''' +re_flags = ['DOTALL'] +replace = '''if ! perl -e 'alarm shift; exec @ARGV' "${RSYNC_TIMEOUT:-600}" rsync \1; then + local rsync_status=${PIPESTATUS[0]} + # The assignment we just made set PIPESTATUS[0] to 0. Restore it. The + # leading `!` keeps `set -e` from terminating the script. + ! (exit "${rsync_status}")''' + [[substitution]] description = '''Try various fixes for exit code 12 failures. @@ -40,7 +59,11 @@ We start our custom codes at 70 in order to not clash with upstream's, even in case they add new ones beyond the current maximum 16. ''' pattern = 'exit 12' -replace = '''# Try various remedies; Report in exit code what would have worked. +replace = '''if [[ ${rsync_status} -eq 142 ]]; then + # rsync timed out. + exit 80 + fi + # Try various remedies; Report in exit code what would have worked. local mkdir_stderr if ! mkdir_stderr="$(mkdir -p "${new_versioned_dir}" 2>&1 1>/dev/null)" then diff --git a/updater/install_sh_mac_test.py b/updater/install_sh_mac_test.py index 49c00d51f0f..40cc9ecb4d8 100755 --- a/updater/install_sh_mac_test.py +++ b/updater/install_sh_mac_test.py @@ -14,6 +14,7 @@ from stat import S_IXUSR from subprocess import run, DEVNULL, Popen, PIPE, STDOUT from tempfile import TemporaryDirectory from threading import Thread +from time import sleep import plistlib import re @@ -200,6 +201,19 @@ class InstallShPatchTest(unittest.TestCase): }, expected_exit_code=expected_exit_code) + def test_rsync_timeout(self): + + def rsync(_): + sleep(2) + return 0, "" + + app_dir = join(self.temp_dir.name, f"{PRODUCT_NAME}.app") + self._make_app(app_dir, CURRENT_VERSION) + self._run_install_sh(app_dir, + commands={'rsync': rsync}, + expected_exit_code=80, + env={"RSYNC_TIMEOUT": "1"}) + def _prepare_dmg_dir(self): dmg_dir = join(self.temp_dir.name, "dmg") mkdir(dmg_dir) @@ -265,15 +279,17 @@ class InstallShPatchTest(unittest.TestCase): installed_app_dir, is_root=False, commands=None, - expected_exit_code=0): + expected_exit_code=0, + env=None): commands = commands or {} + env = env.copy() if env is not None else {} for name in commands: wrapper_path = join(self.bin_dir, name) with open(wrapper_path, "w") as f: f.write(COMMAND_WRAPPER) chmod(wrapper_path, stat(wrapper_path).st_mode | S_IXUSR) prompt_r, prompt_w = pipe() - env = {"PROMPT_FD": str(prompt_w)} + env["PROMPT_FD"] = str(prompt_w) if is_root: env["EUID"] = "0" proc = Popen([ @@ -304,14 +320,21 @@ class InstallShPatchTest(unittest.TestCase): for line in prompts: name, *args = shlex.split(line) exit_code, stderr = commands[name](args) - proc.stdin.write(f"{exit_code}\n{stderr}\n") - proc.stdin.flush() + try: + proc.stdin.write(f"{exit_code}\n{stderr}\n") + proc.stdin.flush() + except BrokenPipeError: + break proc.wait(timeout=30) finally: # Reap the subprocess if wait() timed out or the loop raised. if proc.poll() is None: proc.kill() proc.wait() + try: + proc.stdin.close() + except BrokenPipeError: + pass drain_thread.join(timeout=5) output = "".join(output_lines) self.assertEqual(