231 lines
8.9 KiB
Python
231 lines
8.9 KiB
Python
"""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()
|