diff --git a/src/orchard_cli/apple/boot.py b/src/orchard_cli/apple/boot.py index d40392e..f288588 100644 --- a/src/orchard_cli/apple/boot.py +++ b/src/orchard_cli/apple/boot.py @@ -1,4 +1,6 @@ """Shared checkm8/Pongo boot sequence; adapters supply device identity constraints.""" +import os +import signal import subprocess import shutil import time @@ -45,6 +47,23 @@ def wait_mode(device, mode, *, chip, board, product, seconds=30): time.sleep(0.25) +def run_process_group(argv, *, timeout, **kwargs): + """subprocess.run(check=True) that also stops the command's children. + + palera1n posix_spawns checkra1n and only waits for it. Killing palera1n + alone leaves checkra1n running, and it exploits the next DFU device it + sees, so give up on the whole process group instead. + """ + with subprocess.Popen(argv, start_new_session=True, **kwargs) as process: + try: + returncode = process.wait(timeout=timeout) + except BaseException: + os.killpg(process.pid, signal.SIGKILL) + raise + if returncode: + raise subprocess.CalledProcessError(returncode, argv) + + def boot(device, assets, manifest, progress, *, profile, chip, board, product): if device['mode'] == 'linux': progress.phase('Using running recovery; no reboot or upload (timing starts at resume)') @@ -61,8 +80,8 @@ def boot(device, assets, manifest, progress, *, profile, chip, board, product): with tempfile.NamedTemporaryFile(prefix='palera1n-', suffix='.log', dir=logs, delete=False) as log: try: - subprocess.run([executable, '-p', '-k', str(assets['pongo'])], - stdout=log, stderr=subprocess.STDOUT, check=True, timeout=120) + run_process_group([executable, '-p', '-k', str(assets['pongo'])], + stdout=log, stderr=subprocess.STDOUT, timeout=120) except (OSError, subprocess.SubprocessError) as error: raise RuntimeError('Pongo bootstrap failed or outcome unknown; inspect USB before any retry. ' f'Log: {log.name}') from error diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 90f59a1..91aae7d 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -8,6 +8,7 @@ import subprocess import sys import tempfile +import time import unittest from unittest.mock import Mock, patch import urllib.request @@ -46,7 +47,7 @@ def test_adapter_boot_sequence_timeouts_and_no_retry_are_preserved(self): patch.object(boot.shutil, 'which', return_value='/tools/palera1n'), \ patch.object(adapter_module.usb, 'selected') as selected, \ patch.object(boot, 'wait_mode') as wait, \ - patch.object(boot.subprocess, 'run') as bootstrap, \ + patch.object(boot, 'run_process_group') as bootstrap, \ patch.object(boot.pongo, 'upload') as upload: adapter.boot(device, assets, record, Mock()) self.assertEqual(bootstrap.call_count, 1) @@ -62,6 +63,33 @@ def test_adapter_boot_sequence_timeouts_and_no_retry_are_preserved(self): self.assertEqual(bootstrap.call_count, 2) # one success, one failed call; no retry. upload.assert_not_called() + def test_bootstrap_timeout_stops_spawned_children(self): + # palera1n posix_spawns checkra1n; a surviving checkra1n would exploit + # the next DFU device behind a later command's back. + from orchard_cli.apple import boot + pidfile = self.root / 'child.pid' + script = f'sleep 300 & echo $! > {pidfile}; wait' + with self.assertRaises(subprocess.TimeoutExpired): + boot.run_process_group(['sh', '-c', script], timeout=1) + child = int(pidfile.read_text()) + for _ in range(50): + try: + state = Path(f'/proc/{child}/stat').read_text().rsplit(')', 1)[1].split()[0] + except (FileNotFoundError, ProcessLookupError): # gone mid-read + break + if state == 'Z': + break + time.sleep(0.05) + else: + os.kill(child, 9) + self.fail('bootstrap child outlived the timeout') + + def test_bootstrap_failure_is_reported(self): + from orchard_cli.apple import boot + with self.assertRaises(subprocess.CalledProcessError): + boot.run_process_group(['sh', '-c', 'exit 3'], timeout=5) + boot.run_process_group(['true'], timeout=5) + def test_manifest_becomes_existing_workflow_inputs(self): record, assets = releases.resolve(FIXTURE / 'index.toml', 'j71b') self.assertEqual(record['release'], 'example-1')