"""Real Windows kernel-object checks; every case has an isolated random name. Children only import desktop_instance, create test mutexes/events and exchange temporary fixture files. They never open a desktop window or application data. """ from __future__ import annotations import ctypes from ctypes import wintypes import json import os from pathlib import Path import subprocess import sys import tempfile import threading import time import unittest from unittest import mock import uuid from desktop_instance import DesktopInstanceGuard _CHILD = r''' import json, pathlib, sys, time sys.path.insert(0, sys.argv[1]) from desktop_instance import DesktopInstanceGuard folder, namespace, number, action = pathlib.Path(sys.argv[2]), sys.argv[3], sys.argv[4], sys.argv[5] guard = DesktopInstanceGuard(namespace=namespace) (folder / ('ready-' + number)).write_text('ready') deadline = time.monotonic() + 30 while not (folder / 'go').exists(): if time.monotonic() > deadline: raise RuntimeError('fixture start timeout') time.sleep(.005) primary = guard.acquire() if action == 'signal': guard.request_activation() record = {'primary': primary, 'pid': __import__('os').getpid()} temporary = folder / ('role-' + number + '.tmp') temporary.write_text(json.dumps(record)) temporary.replace(folder / ('role-' + number)) if action != 'signal': while not (folder / 'stop').exists(): if time.monotonic() > deadline: raise RuntimeError('fixture stop timeout') time.sleep(.005) guard.close() ''' @unittest.skipUnless(os.name == 'nt', 'Windows desktop kernel objects') class DesktopInstanceTests(unittest.TestCase): def setUp(self): self.namespace = 'desktop-instance-test-' + uuid.uuid4().hex self.guards = [] self.children = [] self.temporary = tempfile.TemporaryDirectory(prefix='desktop-instance-test-') self.folder = Path(self.temporary.name) def tearDown(self): (self.folder / 'stop').touch() for child in self.children: try: child.communicate(timeout=5) except subprocess.TimeoutExpired: child.kill() child.communicate(timeout=5) for guard in reversed(self.guards): guard.close() self.temporary.cleanup() def guard(self, namespace=None): guard = DesktopInstanceGuard(namespace=namespace or self.namespace) self.guards.append(guard) return guard def wait_for(self, condition, description): deadline = time.monotonic() + 12 while not condition(): for child in self.children: if child.poll() not in (None, 0): stdout, stderr = child.communicate(timeout=1) self.fail(f'child failed: {stdout}\n{stderr}') if time.monotonic() > deadline: self.fail('timeout: ' + description) time.sleep(.01) def child(self, number, action='hold', cwd=None): # Resolve from the test file, so frozen-regression import hooks can use # the actual module in this process without changing child fixture paths. project = str(Path(__file__).resolve().parent) child = subprocess.Popen( [sys.executable, '-B', '-c', _CHILD, project, str(self.folder), self.namespace, str(number), action], cwd=str(cwd or self.folder), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8', creationflags=subprocess.CREATE_NO_WINDOW, env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'}, ) self.children.append(child) return child def start_children(self, count): self.wait_for(lambda: all((self.folder / f'ready-{i}').exists() for i in range(count)), 'child readiness') (self.folder / 'go').touch() self.wait_for(lambda: all((self.folder / f'role-{i}').exists() for i in range(count)), 'mutex contenders') return [json.loads((self.folder / f'role-{i}').read_text()) for i in range(count)] def test_same_thread_second_guard_cannot_recursively_become_primary(self): primary, second = self.guard(), self.guard() self.assertTrue(primary.acquire()) self.assertTrue(primary.acquire()) self.assertFalse(second.acquire()) def test_different_test_namespaces_do_not_interfere(self): self.assertTrue(self.guard().acquire()) self.assertTrue(self.guard(self.namespace + '-other').acquire()) def test_activation_survives_sender_exit_and_waits_for_ui(self): primary = self.guard() self.assertTrue(primary.acquire()) child = self.child(0, action='signal') self.assertFalse(self.start_children(1)[0]['primary']) stdout, stderr = child.communicate(timeout=5) self.assertEqual(child.returncode, 0, stdout + stderr) self.assertTrue(primary.consume_activation()) self.assertFalse(primary.consume_activation()) def test_activation_before_primary_acquires_is_not_lost(self): launcher = self.guard() self.assertTrue(launcher.request_activation()) primary = self.guard() self.assertTrue(primary.acquire()) self.assertTrue(primary.consume_activation()) def test_multiple_clicks_coalesce_and_secondary_cannot_steal_event(self): primary, second = self.guard(), self.guard() self.assertTrue(primary.acquire()) self.assertFalse(second.acquire()) for _ in range(5): second.request_activation() self.assertFalse(second.consume_activation()) self.assertTrue(primary.consume_activation()) self.assertFalse(primary.consume_activation()) def test_closing_secondary_keeps_owner_exclusive(self): primary, second, third = self.guard(), self.guard(), self.guard() self.assertTrue(primary.acquire()) self.assertFalse(second.acquire()) second.close() self.assertFalse(third.acquire()) def test_clean_exit_allows_existing_contender_to_take_over(self): primary, second = self.guard(), self.guard() self.assertTrue(primary.acquire()) self.assertFalse(second.acquire()) primary.close() self.assertTrue(second.acquire()) def test_six_concurrent_processes_elect_exactly_one_primary(self): for i in range(6): self.child(i) roles = self.start_children(6) self.assertEqual(sum(role['primary'] for role in roles), 1, roles) def test_other_working_directory_uses_same_identity(self): primary = self.guard() self.assertTrue(primary.acquire()) other_directory = self.folder / 'another-installed-version' other_directory.mkdir() self.child(0, cwd=other_directory) self.assertFalse(self.start_children(1)[0]['primary']) def test_killed_owner_releases_mutex_even_with_secondary_handle_open(self): child = self.child(0) self.assertTrue(self.start_children(1)[0]['primary']) successor = self.guard() self.assertFalse(successor.acquire()) child.kill() child.communicate(timeout=5) self.assertTrue(successor.acquire()) self.assertFalse(self.guard().acquire()) def test_close_on_wrong_thread_preserves_live_primary(self): primary, second = self.guard(), self.guard() self.assertTrue(primary.acquire()) errors = [] def wrong_thread(): try: primary.close() except RuntimeError as error: errors.append(str(error)) worker = threading.Thread(target=wrong_thread) worker.start() worker.join(3) self.assertFalse(worker.is_alive()) self.assertEqual(len(errors), 1) self.assertFalse(second.acquire()) primary.close() self.assertTrue(second.acquire()) def test_windows_handle_signatures_are_pointer_sized(self): guard = self.guard() for name in ('CreateMutexW', 'CreateEventW', 'GetCurrentProcess'): self.assertIs(getattr(guard._api.kernel, name).restype, wintypes.HANDLE) self.assertEqual(ctypes.sizeof(wintypes.HANDLE), ctypes.sizeof(ctypes.c_void_p)) def test_mutex_creation_failure_is_not_treated_as_primary(self): guard = self.guard() def denied(*args): ctypes.set_last_error(5) return None with mock.patch.object(guard._api.kernel, 'CreateMutexW', side_effect=denied): with self.assertRaises(OSError): guard.acquire() # No owner was registered and the temporary event handle was released. self.assertTrue(self.guard().acquire()) def test_closed_guard_is_idempotent_but_cannot_reopen(self): guard = self.guard() self.assertTrue(guard.acquire()) guard.close() guard.close() self.assertFalse(guard.consume_activation()) with self.assertRaises(RuntimeError): guard.acquire() if __name__ == '__main__': unittest.main()