From b1a4290b32ef20c00ea29da8dc42ca749216b72f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 16 Jul 2026 13:19:36 +0300 Subject: [PATCH 1/3] Fix creating empty nodes in LocksGraph by using get with default in dfs traversal --- locklib/locks/smart_lock/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locklib/locks/smart_lock/graph.py b/locklib/locks/smart_lock/graph.py index 15293d9..994b51f 100644 --- a/locklib/locks/smart_lock/graph.py +++ b/locklib/locks/smart_lock/graph.py @@ -30,7 +30,7 @@ def delete_link(self, _from: int, to: int) -> None: del self.links[_from] def get_links_from(self, _from: int) -> Set[int]: - return self.links[_from] + return self.links.get(_from, set()) def dfs(self, path: List[int], current_node: int, target: int) -> Optional[List[int]]: path.append(current_node) From a0e1545d2805051f8c5b3280a5b219de3f25c27f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 16 Jul 2026 13:20:10 +0300 Subject: [PATCH 2/3] Add tests for LocksGraph cleanup and cycle detection behavior --- tests/units/locks/smart_lock/test_abstract.py | 46 +++++++++++++++++++ tests/units/locks/smart_lock/test_graph.py | 32 +++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/tests/units/locks/smart_lock/test_abstract.py b/tests/units/locks/smart_lock/test_abstract.py index 93e7a48..3a438f7 100644 --- a/tests/units/locks/smart_lock/test_abstract.py +++ b/tests/units/locks/smart_lock/test_abstract.py @@ -61,6 +61,52 @@ def increment_counter() -> None: assert counter == number_of_threads * number_of_increments_per_thread +@pytest.mark.timeout(5) +def test_abstract_smart_lock_subclass_cleans_wait_for_graph_after_contention(smartlock_class: Type[AbstractSmartLock]) -> None: + """ + Every public AbstractSmartLock subclass cleans up its wait-for graph after contention. + + While the test thread holds the lock, a queued waiter creates the expected waiter-to-owner edge. After the owner releases and the waiter finishes, the lock queue and dedicated wait-for graph are empty. + """ + graph = LocksGraph() + lock = smartlock_class(local_graph=graph) + unexpected_errors: List[Exception] = [] + + def waiter() -> None: + try: + with lock: + pass + except Exception as error: # noqa: BLE001 + unexpected_errors.append(error) + + waiter_thread = Thread(target=waiter, daemon=True) + + try: + with lock: + waiter_thread.start() + deadline = monotonic() + 1 + + while True: + with lock.lock, graph.lock: + if len(lock.deque) == 2: + waiter_thread_id, owner_thread_id = lock.deque + assert graph.links == {waiter_thread_id: {owner_thread_id}} + break + if monotonic() >= deadline: + raise AssertionError('Waiter did not enter the lock queue.') + sleep(0.001) + finally: + if waiter_thread.ident is not None: + waiter_thread.join(1) + + assert not waiter_thread.is_alive() + if unexpected_errors: + raise AssertionError(f'Unexpected worker exceptions: {unexpected_errors!r}') from unexpected_errors[0] + with lock.lock: + assert not lock.deque + assert graph.links == {} + + @pytest.mark.timeout(10) def test_abstract_smart_lock_subclass_detects_simple_deadlock(smartlock_class: Type[AbstractSmartLock]) -> None: # noqa: PLR0915 """Every public AbstractSmartLock subclass breaks a two-thread deadlock with one DeadLockError and graph cleanup.""" diff --git a/tests/units/locks/smart_lock/test_graph.py b/tests/units/locks/smart_lock/test_graph.py index cbe126e..4dc7103 100644 --- a/tests/units/locks/smart_lock/test_graph.py +++ b/tests/units/locks/smart_lock/test_graph.py @@ -6,9 +6,9 @@ def test_multiple_set_and_get(): """ - LocksGraph preserves multiple outgoing links from one source. + LocksGraph returns all outgoing links for a source and empty sets for nodes without outgoing links. - After several add_link calls from the same source, get_links_from returns the full adjacency set. Missing nodes and nodes that only appear as destinations return an empty set. + After adding 1 -> {2, 3, 4}, get_links_from returns all three targets for 1 and empty sets for destination-only node 2 and absent node 5. Looking up 5 leaves the adjacency map unchanged. """ graph = LocksGraph() @@ -19,8 +19,34 @@ def test_multiple_set_and_get(): assert graph.get_links_from(1) == {2, 3, 4} assert graph.get_links_from(2) == set() - assert graph.get_links_from(5) == set() + assert graph.links == {1: {2, 3, 4}} + + +def test_get_links_from_destination_only_node_does_not_create_entry(): + """ + Looking up outgoing links for a node that only appears as an edge destination does not create an adjacency entry. + + After adding 1 -> 2, get_links_from returns an empty set for 2 while preserving 1 -> 2 as the graph's only stored adjacency entry. + """ + graph = LocksGraph() + + graph.add_link(1, 2) + + assert graph.get_links_from(2) == set() + assert graph.links == {1: {2}} + + +def test_search_cycles_does_not_create_empty_nodes(): + """ + Searching from a missing source does not create an adjacency entry. + + On an empty graph, search_cycles(1, 2) returns None and leaves the adjacency map empty. + """ + graph = LocksGraph() + + assert graph.search_cycles(1, 2) is None + assert graph.links == {} def test_reverse_deleting_of_nodes(): From 9b47b7e062db50bbd5e04c38c0c085d9279055a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 16 Jul 2026 13:20:41 +0300 Subject: [PATCH 3/3] Bump version to 0.0.25 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 62fb578..8148c1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta' [project] name = 'locklib' -version = '0.0.24' +version = '0.0.25' authors = [ { name='Evgeniy Blinov', email='zheni-b@yandex.ru' }, ]