from datetime import datetime from pathlib import Path import tempfile import unittest from types import SimpleNamespace from unittest import mock from openpilot.common.params import Params from openpilot.starpilot.galaxy.software_operations import ( SoftwareOperations, SoftwareOperationError, UpdaterProcess, PROCESS_TITLE, ) from openpilot.starpilot.software.update_control import UpdaterControlError class FakeProcess: def __init__(self): self.running = True self.sent = [] self.fast_requests = [] def available(self): return self.running def send(self, sig, **identity): if not self.running: raise SoftwareOperationError("Updater stopped", 503) self.sent.append(sig) if sig in ("update", "fast"): self.fast_requests.append(identity) def close(self): pass class SoftwareOperationsTest(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) root = Path(self.temp.name) self.params = Params(str(root / "params")) self.params.put("Version", "test", block=True) self.params.put("GitBranch", "main", block=True) self.params.put("GitCommit", "a" * 40, block=True) self.params.put("UpdaterState", "idle", block=True) self.params.put("UpdaterTargetBranch", "main", block=True) self.params.put("UpdaterAvailableBranches", "main,release", block=True) self.params.put_bool("IsOffroad", True, block=True) self.params.put_bool("UpdateAvailable", False, block=True) self.params.put_bool("DisableUpdates", False, block=True) self.params.put("UpdateFailedCount", 0, block=True) self.process = FakeProcess() self.parked = True self.authorized = True self.finalized = root / "finalized" self.finalized.mkdir() self.installed = root / "installed" self.installed.mkdir() self.git = {self.installed: ("main", "a" * 40), self.finalized: ("release", "b" * 40)} self.owner = SoftwareOperations(self.params, parked=lambda: self.parked, process=self.process, finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path)) self.addCleanup(self.owner.close) def act(self, action, branch=None): body = {"action": action} if branch is not None: body["branch"] = branch return self.owner.action(action, body, authorized=lambda: self.authorized) def denied(self, action, status, branch=None): with self.assertRaises(SoftwareOperationError) as caught: self.act(action, branch) self.assertEqual(caught.exception.status, status) def test_snapshot_refresh_precedes_parked_read_and_relocks_without_signaling(self): order = [] def refresh(): order.append('refresh') self.parked = not self.parked return True def parked(): order.append('parked') return self.parked self.parked = False self.owner = SoftwareOperations(self.params, parked=parked, process=self.process, refresh=refresh, finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path)) self.addCleanup(self.owner.close) for expected in (True, False): order.clear() view = self.owner.snapshot(include_history=False) self.assertEqual(order, ['refresh', 'parked']) self.assertIs(view['parked'], expected) for key in ('canCheck', 'canDownload', 'canSelect'): self.assertIs(view[key], expected) self.assertIsNone(view['request']) self.assertEqual(self.process.sent, []) def test_failed_refresh_never_uses_cached_parked_authority(self): self.parked = True for outcome in (False, None, 1, OSError('collector failed')): with self.subTest(outcome=outcome): refresh = mock.Mock(side_effect=outcome) if isinstance(outcome, Exception) else mock.Mock(return_value=outcome) parked = mock.Mock(return_value=True) owner = SoftwareOperations(self.params, parked=parked, process=self.process, refresh=refresh, finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path)) self.addCleanup(owner.close) view = owner.snapshot(include_history=False) refresh.assert_called_once_with() parked.assert_not_called() self.assertIs(view['parked'], False) for key in ('canCheck', 'canDownload', 'canSelect', 'canInstall', 'canFastUpdate', 'canConfigure'): self.assertIs(view[key], False) self.assertIsNone(view['request']) self.assertEqual(self.process.sent, []) def test_idle_is_not_reported_as_update_progress(self): view = self.owner.snapshot(include_history=False) self.assertIsNone(view["progress"]["stage"]) self.assertEqual(view["progress"]["detail"], "") def test_normal_finalization_completes_only_after_idle_success(self): for failed in (False, True): with self.subTest(failed=failed): self.owner.request = None self.params.put("UpdateFailedCount", 0, block=True) self.params.put("UpdaterState", "idle", block=True) self.params.remove("LastUpdateTime") self.act("download", "main") self.params.put("UpdaterState", "finalizing update...", block=True) self.params.put("UpdaterFastState", {"version": 1, "stage": "complete", "detail": "Old fast update"}, block=True) view = self.owner.snapshot() self.assertEqual(view["request"]["state"], "pending") self.assertEqual(view["progress"], {"stage": "finalizing update...", "detail": "", "percent": None}) self.params.put("UpdaterState", "idle", block=True) if failed: self.params.put("UpdateFailedCount", 1, block=True) self.params.put("LastUpdateException", "Finalization copy failed", block=True) else: self.params.put("LastUpdateTime", datetime(2026, 10, 6), block=True) self.params.put("UpdaterLastFetchTime", datetime(2026, 10, 6), block=True) result = self.owner.snapshot()["request"] self.assertEqual(result["state"], "failed" if failed else "complete") if failed: self.assertEqual(result["error"], "Finalization copy failed") def test_unavailable_finalizing_preserves_request_until_status_recovers(self): self.act("download", "main") self.params.put("UpdaterState", "finalizing update...", block=True) self.process.running = False view = self.owner.snapshot() self.assertEqual(view["progress"]["stage"], "unavailable") self.assertIn("device updater service", view["progress"]["detail"]) self.assertNotIn("last reported state", view["progress"]["detail"]) self.assertEqual(view["request"]["state"], "pending") self.assertEqual(view["reason"], "Updater status is unavailable") self.assertFalse(view["canCheck"]) self.assertEqual(self.params.get("UpdaterState"), "finalizing update...") self.process.running = True self.params.put("UpdaterState", "idle", block=True) self.params.put("LastUpdateTime", datetime(2026, 10, 6), block=True) self.params.put("UpdaterLastFetchTime", datetime(2026, 10, 6), block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "complete") def test_running_finalization_timeout_does_not_fabricate_completion(self): self.owner.clock = lambda: 0.0 self.act("download", "main") self.params.put("UpdaterState", "finalizing update...", block=True) self.owner.clock = lambda: 901.0 view = self.owner.snapshot() self.assertEqual(view["progress"], {"stage": "timed_out", "detail": "Request timed out; the updater may still be running", "percent": None}) self.assertTrue(view["updaterAvailable"]) self.assertEqual(view["request"]["state"], "failed") self.assertFalse(view["canCheck"]) self.assertEqual(self.params.get("UpdaterState"), "finalizing update...") def test_recent_version_admission_pins_installed_snapshot_and_listed_target(self): selected = 'c' * 40 recent = {'branch': 'main', 'head': 'b' * 40, 'entries': [{'hash': selected, 'date': '2026-09-29T12:00:00+00:00', 'subject': 'Previous build'}]} payload = {'action': 'version', 'branch': 'main', 'expectedCommit': 'a' * 40, 'selectedCommit': selected} with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value=recent), \ mock.patch.object(self.process, 'send') as send: for field, value in (('selectedCommit', 'f' * 40), ('expectedCommit', 'b' * 40), ('branch', 'release')): with self.subTest(field=field), self.assertRaises(SoftwareOperationError): self.owner.action('version', dict(payload, **{field: value}), authorized=lambda: True) self.parked = False with self.assertRaises(SoftwareOperationError): self.owner.action('version', payload, authorized=lambda: True) self.parked = True self.owner.action('version', payload, authorized=lambda: True) send.assert_called_once_with('version', branch='main', commit='a' * 40, selected_commit=selected) self.assertIsNotNone(self.owner.request) assert self.owner.request is not None self.assertEqual(self.owner.request['selectedCommit'], selected) def test_version_installed_snapshot_cannot_change_at_final_dispatch(self): selected = 'c' * 40 recent = {'branch': 'main', 'head': selected, 'entries': [{'hash': selected, 'date': '2026-09-29T12:00:00+00:00', 'subject': 'Build'}]} calls = 0 def authorized(): nonlocal calls calls += 1 if calls == 3: self.params.put('GitCommit', 'd' * 40, block=True) self.git[self.installed] = ('main', 'd' * 40) return True with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value=recent), \ mock.patch.object(self.process, 'send') as send, self.assertRaises(SoftwareOperationError): self.owner.action('version', {'action': 'version', 'branch': 'main', 'expectedCommit': 'a' * 40, 'selectedCommit': selected}, authorized=authorized) send.assert_not_called() def test_listing_request_completes_without_install_timestamp_or_reboot(self): self.params.put('LastUpdateTime', datetime(2026, 9, 1), block=True) self.params.put('UpdaterLastFetchTime', datetime(2026, 9, 1), block=True) with mock.patch.object(self.process, 'send') as send: self.act('versions', 'main') send.assert_called_once_with('versions', branch='main', commit='a' * 40) self.params.put('UpdaterLastFetchTime', datetime(2026, 9, 2), block=True) state = self.owner.snapshot()['request'] self.assertEqual(state['state'], 'complete') self.assertNotIn('outcome', state) self.assertEqual(self.params.get('LastUpdateTime'), datetime(2026, 9, 1)) self.assertFalse(self.params.get_bool('DoReboot')) def test_select_then_check_then_download_signals_exact_updater(self): self.assertTrue(self.owner.snapshot()["canSelect"]) self.assertEqual(self.owner.snapshot()["selectedTarget"], "main") selected = self.act("select", "release") self.assertEqual(selected["selectedTarget"], "release") self.assertEqual(self.params.get("UpdaterTargetBranch"), "release") self.assertEqual(self.process.sent, []) checked = self.act("check") self.assertEqual(checked["request"]["state"], "pending") self.assertEqual(self.process.sent, ["check"]) self.denied("download", 409, "release") self.params.put("UpdaterState", "checking...", block=True) self.owner.snapshot() self.params.put("UpdaterState", "idle", block=True) self.params.put("LastUpdateTime", datetime(2026, 9, 27, 12, 0), block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "complete") downloaded = self.act("download", "release") self.assertEqual(downloaded["request"]["target"], "release") self.assertEqual(self.process.sent, ["check", "download"]) self.denied("download", 409, "release") def test_fresh_manager_without_target_allows_check_and_select(self): self.params.remove("UpdaterTargetBranch") initial = self.owner.snapshot() self.assertEqual(initial["selectedTarget"], "main") self.assertTrue(initial["canCheck"]) self.assertTrue(initial["canSelect"]) check = self.act("check") self.assertIsNone(check["request"]["target"]) self.assertEqual(check["request"]["state"], "pending") self.params.put("UpdaterTargetBranch", "main", block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "pending") self.params.put("LastUpdateTime", datetime(2026, 9, 27, 12, 4), block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "complete") self.act("select", "release") self.assertEqual(self.params.get("UpdaterTargetBranch"), "release") def test_bad_or_stale_branch_and_missing_updater_never_mutate(self): self.denied("select", 400, "--help") self.denied("select", 409, "unknown") self.assertEqual(self.params.get("UpdaterTargetBranch"), "main") self.process.running = False self.denied("check", 503) self.assertEqual(self.process.sent, []) def test_parked_authority_and_session_rechecked_before_effect(self): for offroad, parked, authorized in ((False, True, True), (True, False, True), (True, True, False)): with self.subTest(offroad=offroad, parked=parked, authorized=authorized): self.params.put_bool("IsOffroad", offroad, block=True) self.parked = parked self.authorized = authorized self.denied("check", 403 if not authorized else 409) self.assertEqual(self.process.sent, []) self.params.put_bool("IsOffroad", True, block=True) self.parked = self.authorized = True checks = iter((True, False)) with self.assertRaises(SoftwareOperationError): self.owner.action("check", {"action": "check"}, authorized=lambda: next(checks)) self.assertEqual(self.process.sent, []) def test_install_requires_exact_finalized_target_and_reboot_once(self): self.act("select", "release") self.params.put_bool("UpdateAvailable", True, block=True) self.params.put("UpdaterNewDescription", "test / release / bbbbbbb / Sep 27", block=True) with mock.patch("openpilot.starpilot.galaxy.software_operations.validate_revision", return_value={}): self.assertFalse(self.owner.snapshot()["canInstall"]) (self.finalized / ".overlay_consistent").touch() self.assertTrue(self.owner.snapshot()["canInstall"]) self.denied("install", 409, "main") self.git[self.finalized] = ("release", "c" * 40) self.assertFalse(self.owner.snapshot()["canInstall"]) self.denied("install", 409, "release") self.git[self.finalized] = ("release", "b" * 40) result = self.act("install", "release") self.assertEqual(result["request"]["state"], "pending") self.assertEqual(result["request"]["action"], "install") self.assertTrue(self.params.get_bool("DoReboot")) self.denied("install", 409, "release") self.assertEqual(self.process.sent, []) def test_target_change_or_failure_is_reported(self): self.act("select", "release") self.act("download", "release") self.params.put("UpdaterTargetBranch", "main", block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "failed") self.act("select", "release") self.act("check") self.params.put("UpdateFailedCount", 1, block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "failed") def test_counter_reset_does_not_claim_a_completed_check(self): self.params.put("UpdateFailedCount", 2, block=True) self.act("check") self.params.put("UpdateFailedCount", 0, block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "pending") self.params.put("LastUpdateTime", datetime(2026, 9, 27, 12, 1), block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "complete") def test_download_without_fetch_cannot_claim_unpublished_target(self): self.act("select", "release") self.act("download", "release") self.params.put_bool("UpdaterFetchAvailable", False, block=True) self.params.put("LastUpdateTime", datetime(2026, 9, 27, 12, 2), block=True) request = self.owner.snapshot()["request"] self.assertEqual(request["state"], "failed") self.assertIn("No new download", request["error"]) def test_download_does_not_call_missing_remote_branch_up_to_date(self): self.act("download", "main") self.params.put_bool("UpdaterFetchAvailable", False, block=True) self.params.put("LastUpdateTime", datetime(2026, 9, 27, 12, 3), block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "failed") def test_catalog_keeps_other_branches_when_one_entry_is_invalid(self): self.params.put("UpdaterAvailableBranches", "main,feature+lab,bad branch,feature@lab", block=True) branches = self.owner.snapshot()["availableBranches"] self.assertEqual(branches, ["main", "feature+lab", "feature@lab"]) self.act("select", "feature+lab") self.assertEqual(self.params.get("UpdaterTargetBranch"), "feature+lab") def test_published_catalog_with_more_than_64_branches_remains_usable(self): branches = [f"release/{number:03d}" for number in range(76)] self.params.put("UpdaterAvailableBranches", ",".join(branches), block=True) snapshot = self.owner.snapshot() self.assertEqual(snapshot["availableBranches"], branches) self.assertTrue(snapshot["canSelect"]) self.act("select", branches[-1]) self.assertEqual(self.params.get("UpdaterTargetBranch"), branches[-1]) def test_slash_branch_description_can_match_finalized_commit(self): self.params.put("UpdaterAvailableBranches", "main,release/2026", block=True) self.act("select", "release/2026") self.git[self.finalized] = ("release/2026", "b" * 40) self.params.put_bool("UpdateAvailable", True, block=True) self.params.put("UpdaterNewDescription", "test / release/2026 / bbbbbbb / Sep 27", block=True) (self.finalized / ".overlay_consistent").touch() with mock.patch("openpilot.starpilot.galaxy.software_operations.validate_revision", return_value={}): self.assertTrue(self.owner.snapshot()["canInstall"]) def save_downloads(self, enabled, expected): return self.owner.action('preferences', {'action': 'preferences', 'automaticDownloads': enabled, 'expectedAutomaticDownloads': expected}, authorized=lambda: self.authorized) def test_download_preference_is_independent_of_manual_updater_commands(self): self.assertIs(self.owner.snapshot()['automaticDownloads'], True) result = self.save_downloads(False, True) self.assertIs(result['automaticDownloads'], False) self.assertIsNone(result['request']) self.assertEqual(self.process.sent, []) self.assertTrue(result['canDownload']) self.act('download', 'main') self.assertEqual(self.process.sent, ['download']) def test_preference_can_be_saved_without_running_updater_but_not_after_authority_changes(self): self.process.running = False self.assertTrue(self.owner.snapshot()['canConfigure']) self.assertFalse(self.save_downloads(False, True)['automaticDownloads']) for case in ('stale', 'onroad', 'unauthorized', 'reboot'): self.parked = case != 'onroad' self.authorized = case != 'unauthorized' self.params.put_bool('DoReboot', case == 'reboot', block=True) with self.assertRaises(SoftwareOperationError): self.save_downloads(True, True if case == 'stale' else False) self.assertFalse(self.params.get_bool('UpdaterAutomaticDownloads')) self.assertEqual(self.process.sent, []) def test_invalid_preference_can_be_repaired_without_implicit_enabling(self): Path(self.params.get_param_path('UpdaterAutomaticDownloads')).write_bytes(b'bad') self.assertIsNone(self.owner.snapshot()['automaticDownloads']) self.assertFalse(self.save_downloads(False, None)['automaticDownloads']) with self.assertRaises(SoftwareOperationError): self.save_downloads(1, False) def test_repository_link_uses_only_normalized_github_origins(self): for remote in ('https://github.com/owner/repo.git', 'git@github.com:owner/repo.git', 'ssh://git@github.com/owner/repo'): self.params.put('GitRemote', remote, block=True) self.assertEqual(self.owner.snapshot(include_history=False)['repository'], 'owner/repo') self.params.put('GitRemote', 'https://foreign.test/owner/repo', block=True) self.assertIsNone(self.owner.snapshot(include_history=False)['repository']) def test_lightweight_status_preserves_controls_without_reading_history(self): with mock.patch.object(self.owner, '_history_snapshot') as history: light = self.owner.snapshot(include_history=False) self.assertNotIn('history', light) history.assert_not_called() full = self.owner.snapshot() history.assert_called_once() self.assertEqual(light, {key: value for key, value in full.items() if key != 'history'}) def test_history_caches_local_reads_and_refreshes_when_download_identity_changes(self): reader = mock.Mock(side_effect=lambda path, sha: [{'hash': sha, 'date': '2026-09-29T12:00:00+00:00', 'subject': path.name}]) self.owner.history_reader = reader self.params.put('UpdaterCurrentReleaseNotes', b'

Current

', block=True) self.params.put('UpdaterNewReleaseNotes', b'

Next

', block=True) with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value={'branch': 'main', 'head': 'c' * 40, 'entries': []}) as recent: first = self.owner.snapshot()['history'] self.assertEqual(first['installed'][0]['hash'], 'a' * 40) self.assertEqual(first['downloaded'][0]['hash'], 'b' * 40) self.assertEqual(first['currentReleaseNotes'], 'Current') self.assertEqual(first['downloadedReleaseNotes'], 'Next') self.assertEqual(reader.call_count, 2) self.assertEqual(self.owner.snapshot()['history'], first) self.assertEqual(reader.call_count, 2) self.assertEqual(recent.call_count, 1) self.params.put('UpdaterNewDescription', 'new description', block=True) self.owner.snapshot() self.assertEqual(reader.call_count, 4) self.assertEqual(recent.call_count, 2) self.params.put('UpdaterLastFetchTime', datetime(2026, 10, 4, 12), block=True) self.owner.snapshot() self.assertEqual(recent.call_count, 3) self.assertEqual(reader.call_count, 6) def test_unified_update_pins_source_and_tracks_completion(self): result = self.act("update", "release") self.assertEqual(result["request"]["action"], "update") self.assertEqual(result["request"]["target"], "release") self.assertEqual(self.process.sent, ["update"]) self.assertEqual(self.process.fast_requests, [{"branch": "release", "commit": "a" * 40}]) self.params.put("LastUpdateTime", datetime(2026, 10, 3, 12), block=True) self.params.put("UpdaterLastFetchTime", datetime(2026, 10, 3, 12), block=True) self.params.put_bool("DoReboot", True, block=True) result = self.owner.snapshot()["request"] self.assertEqual(result["state"], "complete") self.assertEqual(result["outcome"], "restarting") def test_fast_current_branch_without_branch_list_or_selected_target(self): self.params.remove("UpdaterAvailableBranches") self.params.put("UpdaterTargetBranch", "release", block=True) self.assertTrue(self.owner.snapshot()["canFastUpdate"]) result = self.act("fast", "main") self.assertEqual(result["request"]["target"], "main") self.assertEqual(self.process.sent, ["fast"]) self.assertEqual(self.process.fast_requests, [{"branch": "main", "commit": "a" * 40}]) self.assertEqual(self.params.get("UpdaterTargetBranch"), "release") self.assertFalse(self.params.get_bool("DoReboot")) self.params.put("LastUpdateTime", datetime(2026, 10, 3, 12), block=True) self.params.put("UpdaterLastFetchTime", datetime(2026, 10, 3, 12), block=True) result = self.owner.snapshot()["request"] self.assertEqual(result["state"], "complete") self.assertEqual(result["outcome"], "up_to_date") def test_fast_uses_registered_offroad_and_live_parked_authority(self): Path(self.params.get_param_path("IsOnroad")).write_bytes(b"1") self.assertTrue(self.owner.snapshot()["canFastUpdate"]) self.parked = False self.assertFalse(self.owner.snapshot()["canFastUpdate"]) self.denied("fast", 409, "main") self.parked = True self.act("fast", "main") self.assertEqual(self.process.sent, ["fast"]) def test_fast_rejects_stale_installed_identity(self): self.git[self.installed] = ("main", "b" * 40) self.denied("fast", 409, "main") self.git[self.installed] = ("release", "a" * 40) self.denied("fast", 409, "main") self.assertEqual(self.process.sent, []) self.assertFalse(self.params.get_bool("DoReboot")) def test_fast_keeps_existing_admission_and_observes_failure(self): for key in ("DisableUpdates", "DoReboot"): self.params.put_bool(key, True, block=True) self.denied("fast", 409, "main") self.params.put_bool(key, False, block=True) self.authorized = False self.denied("fast", 403, "main") self.authorized = True self.act("fast", "main") self.denied("fast", 409, "main") self.params.put("UpdateFailedCount", 1, block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "failed") self.assertEqual(self.process.sent, ["fast"]) class UpdaterIdentityTest(unittest.TestCase): def test_fresh_manager_pid_and_same_proc_start_before_signal(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) proc = root / "123" proc.mkdir() (proc / "stat").write_text("123 (updater) " + " ".join(["S", *(["0"] * 18), "3456"])) (proc / "cmdline").write_bytes(PROCESS_TITLE + b"\0") state = SimpleNamespace(name="updated", running=True, shouldBeRunning=True, pid=123) class ManagerMessages: seen = {"managerState": True} alive = {"managerState": True} valid = {"managerState": True} recv_time = {"managerState": 1.25} logMonoTime = {"managerState": 1_250_000_000} def update(self, timeout): pass def __getitem__(self, key): return SimpleNamespace(processes=[state]) sm = ManagerMessages() clock = [1_000_000_000] class FakeControl: def __init__(self): self.available_calls = [] self.sent = [] def available(self, pid, start): self.available_calls.append((pid, start)) return True def send(self, pid, start, action): self.sent.append((pid, start, action)) control = FakeControl() process = UpdaterProcess(sm, control=control, proc_root=root, mono_ns=lambda: clock[0]) clock[0] = 1_500_000_000 with mock.patch("os.kill") as kill: self.assertTrue(process.available()) process.send("check") self.assertEqual(control.available_calls, [(123, 3456)]) self.assertEqual(control.sent, [(123, 3456, "check")]) with mock.patch.object(control, "send", side_effect=UpdaterControlError("declined")): with self.assertRaises(SoftwareOperationError) as denied: process.send("download") self.assertEqual(denied.exception.status, 503) with mock.patch.object(process, "_manager_pid", side_effect=[123, 124]): with self.assertRaises(SoftwareOperationError): process.send("download") self.assertEqual(control.sent, [(123, 3456, "check")]) kill.assert_not_called() sm.recv_time["managerState"] = 0.9 self.assertFalse(process.available()) sm.recv_time["managerState"] = 1.25 (proc / "cmdline").write_bytes(b"other\0") self.assertFalse(process.available()) with self.assertRaises(SoftwareOperationError): process.send("download") if __name__ == "__main__": unittest.main()