diff --git a/roadscore/prototype/mac_replay_ownership.py b/roadscore/prototype/mac_replay_ownership.py new file mode 100644 index 0000000000..ee4a0c7adf --- /dev/null +++ b/roadscore/prototype/mac_replay_ownership.py @@ -0,0 +1,45 @@ +"""Exclusive Mac showcase ownership while the compiled camera port is global.""" +from contextlib import contextmanager +import errno +import fcntl +import os +from pathlib import Path +import socket + + +class ReplayBusy(RuntimeError): + pass + + +def check_camera_port(): + """Check the existing IPv4 ZMQ publisher bind without connecting or sending.""" + try: + with socket.socket(socket.AF_INET,socket.SOCK_STREAM) as probe: + # Match a normal listening socket's restart behavior after TIME_WAIT. + probe.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) + probe.bind(('0.0.0.0',9000)) + except OSError as error: + if error.errno==errno.EADDRINUSE: + raise ReplayBusy('Mac replay camera port 9000 is already in use. Close the existing Mac replay before starting another.') from None + raise ReplayBusy('Cannot verify Mac replay camera port 9000: '+str(error)) from error + + +@contextmanager +def mac_replay_lease(root, *, port_check=None): + """Hold this descriptor until parent cleanup ends; children never inherit it.""" + path=Path(root)/'generated/mac_showcase.lock' + path.parent.mkdir(parents=True,exist_ok=True) + with path.open('a+') as lease: + os.set_inheritable(lease.fileno(),False) + try:fcntl.flock(lease,fcntl.LOCK_EX|fcntl.LOCK_NB) + except BlockingIOError: + raise ReplayBusy('A Mac RoadScore replay is already running. Close its existing demo before starting another.') from None + try: + (check_camera_port if port_check is None else port_check)() + yield lease + finally:fcntl.flock(lease,fcntl.LOCK_UN) + + +def preflight(root): + """Advisory check before paired launch; the Mac parent later holds its lease.""" + with mac_replay_lease(root):pass diff --git a/roadscore/prototype/mac_showcase.py b/roadscore/prototype/mac_showcase.py index b4f46afc23..78a9c6052e 100644 --- a/roadscore/prototype/mac_showcase.py +++ b/roadscore/prototype/mac_showcase.py @@ -411,6 +411,14 @@ def main(): return audio_worker(a) if a.no_control_server:raise SystemExit('--no-control-server is an internal prepared audio-worker option') if sys.platform!='darwin' or Path('/TICI').exists():raise SystemExit('Prepared Mac showcase runs only on the Mac') + if a.check:return parent_main(a) + from mac_replay_ownership import ReplayBusy, mac_replay_lease + try: + with mac_replay_lease(a.project_root.resolve()/'roadscore'):return parent_main(a) + except ReplayBusy as error:raise SystemExit(str(error)) from None + + +def parent_main(a): launch_started=time.monotonic() project=a.project_root.resolve();rt=a.runtime or project/'.host_runtime/darwin/worktree' py=rt.parent/'venv/bin/python' diff --git a/roadscore/prototype/test_mac_replay_ownership.py b/roadscore/prototype/test_mac_replay_ownership.py new file mode 100644 index 0000000000..09290c4e10 --- /dev/null +++ b/roadscore/prototype/test_mac_replay_ownership.py @@ -0,0 +1,69 @@ +import errno +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +import mac_showcase +from mac_replay_ownership import ReplayBusy, check_camera_port, mac_replay_lease, preflight + + +class MacReplayOwnershipTests(unittest.TestCase): + def setUp(self): + temporary=tempfile.TemporaryDirectory();self.addCleanup(temporary.cleanup) + self.root=Path(temporary.name) + + def test_two_real_descriptors_are_exclusive_and_release_on_exception(self): + check=MagicMock() + with self.assertRaisesRegex(ValueError,'fixture failure'): + with mac_replay_lease(self.root,port_check=check) as held: + self.assertFalse(os.get_inheritable(held.fileno())) + with self.assertRaises(ReplayBusy): + with mac_replay_lease(self.root,port_check=check):self.fail('Second lease acquired') + self.assertEqual(check.call_count,1) + raise ValueError('fixture failure') + with mac_replay_lease(self.root,port_check=check):pass + self.assertEqual(check.call_count,2) + + def test_port_failure_releases_lease_and_preflight_does_not_hold_it(self): + with patch('mac_replay_ownership.check_camera_port',side_effect=ReplayBusy('busy')): + with self.assertRaises(ReplayBusy):preflight(self.root) + with patch('mac_replay_ownership.check_camera_port') as check: + preflight(self.root) + with mac_replay_lease(self.root):pass + self.assertEqual(check.call_count,2) + + def test_port_probe_reports_busy_without_connecting_or_sending(self): + probe=MagicMock();probe.__enter__.return_value=probe + with patch('mac_replay_ownership.socket.socket',return_value=probe): + check_camera_port() + probe.bind.assert_called_once_with(('0.0.0.0',9000)) + probe.connect.assert_not_called();probe.send.assert_not_called() + probe.bind.side_effect=OSError(errno.EADDRINUSE,'in use') + with self.assertRaisesRegex(ReplayBusy,'port 9000'):check_camera_port() + + def test_parent_holds_lease_before_any_preparation(self): + args=mac_showcase.parser().parse_args(['--project-root',str(self.root)]) + def prepared(value): + with self.assertRaises(ReplayBusy): + with mac_replay_lease(self.root/'roadscore',port_check=lambda:None):pass + return 17 + with (patch('mac_showcase.parser') as parser,patch('mac_showcase.parent_main',side_effect=prepared) as parent, + patch('mac_showcase.sys.platform','darwin'),patch('mac_replay_ownership.check_camera_port')): + parser.return_value.parse_args.return_value=args + self.assertEqual(mac_showcase.main(),17) + parent.assert_called_once_with(args) + with mac_replay_lease(self.root/'roadscore',port_check=lambda:None):pass + + def test_worker_and_check_never_acquire_parent_lease(self): + for flags,target in ((['--audio-worker'],'audio_worker'),(['--check'],'parent_main')): + args=mac_showcase.parser().parse_args(flags) + with (patch('mac_showcase.parser') as parser,patch('mac_showcase.'+target,return_value=23), + patch('mac_showcase.sys.platform','darwin'),patch('mac_replay_ownership.mac_replay_lease') as lease): + parser.return_value.parse_args.return_value=args + self.assertEqual(mac_showcase.main(),23) + lease.assert_not_called() + + +if __name__=='__main__':unittest.main()