Skip to content

Commit 91c8e68

Browse files
authored
Merge pull request #29 from mutating/develop
0.0.23
2 parents 19f5f17 + bc9ff74 commit 91c8e68

4 files changed

Lines changed: 179 additions & 14 deletions

File tree

docs/plans/5.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# План по Issue #28: не выполнять bodies под `Slot.lock`
2+
3+
## Summary
4+
5+
- Источник: [GitHub Issue #28](https://github.com/mutating/pristan/issues/28).
6+
- Перед имплементацией сохранить этот план в [docs/plans/5.md](/Users/pomponchik/Desktop/Projects/symplug/docs/plans/5.md).
7+
- По TDD: сначала добавить/изменить тесты, запустить целевой pytest и убедиться, что проверки нового поведения падают на текущей реализации, затем менять код.
8+
- Проблема подтверждена в текущей реализации: [Slot.__call__](/Users/pomponchik/Desktop/Projects/symplug/pristan/components/slot.py:109) вызывает `self.backed_caller(...)` внутри `with self.lock`.
9+
- Исправление: держать `Slot.lock` только на `_load_entrypoints()` и создании snapshot-backed caller, а plugin/default body выполнять после release lock.
10+
- Entry point loading deadlocks не трогать: `_load_entrypoints()` остается под `Slot.lock`.
11+
12+
## Public APIs / Interfaces / Types
13+
14+
- Публичные сигнатуры API, типы возврата, тексты исключений и контракт lazy loading не менять.
15+
- `self.backed_caller` не удалять: он остается частью текущей внутренней модели и используется `__bool__`.
16+
- Новое поведение: один вызов слота видит один стабильный список плагинов; плагин, зарегистрированный во время dispatch, виден только со следующего вызова.
17+
18+
## Изменения реализации
19+
20+
- В `Slot.__call__` заменить dispatch через live `self.backed_caller` внутри lock на локальный snapshot-backed `CallerWithPlugins`:
21+
```python
22+
with self.lock:
23+
self._load_entrypoints()
24+
backed_caller = CallerWithPlugins(self.caller, list(self.plugins.plugins))
25+
26+
return backed_caller(*args, **kwargs)
27+
```
28+
- Локальный `backed_caller` создавать внутри `with self.lock`, чтобы snapshot списка плагинов фиксировался под блокировкой.
29+
- Вызов `backed_caller(...)` выполнять после release lock, чтобы plugin/default body не исполнялись в registry critical section.
30+
- Snapshot должен быть новым списком ссылок на текущие `Plugin`-объекты из `self.plugins.plugins`; сами `Plugin`-объекты не копировать и не пересоздавать.
31+
32+
## План тестирования
33+
34+
### Общие требования
35+
36+
- Новые тесты добавить в [tests/units/components/test_slot.py](/Users/pomponchik/Desktop/Projects/symplug/tests/units/components/test_slot.py).
37+
- Каждый новый или измененный тест должен иметь docstring в стиле существующих тестов проекта: начинаться с одной фразы с общим смыслом теста; при сложной семантике или фиксации поведения, явно не описанного в README, можно добавить один или несколько абзацев с уточнением, какое поведение фиксируется и как именно тест это делает.
38+
- Использовать уже подключенный `LockTraceWrapper`; не импортировать `RLock`.
39+
- В lock-boundary тестах подменять `_load_entrypoints()` на функцию без собственной блокировки, которая делает `slot.lock.notify('load')`.
40+
- Для snapshot использовать traced list, чей `__iter__` делает `slot.lock.notify('snapshot')`.
41+
- Не подменять `slot.backed_caller` и не monkeypatch-ить `slot_module.CallerWithPlugins`: тесты должны идти через реальный `Slot.__call__` и проверять observable contract, а не конкретный конструктор.
42+
- Проверять не только `was_event_locked(...)`, но и точный порядок trace: `acquire -> load -> snapshot -> release -> body`.
43+
- Не добавлять thread-based deadlock test в unit suite: он зависит от scheduling; deterministic trace/snapshot tests фиксируют контракт точнее.
44+
45+
### Новые и изменяемые тесты
46+
47+
1. Заменить `test_call_is_protected_by_slot_lock`
48+
- Новое имя: `test_call_snapshots_registered_plugins_under_slot_lock_but_runs_plugins_after_release`.
49+
- Фиксирует: для слота с зарегистрированными плагинами `_load_entrypoints()` и создание snapshot списка плагинов происходят под `Slot.lock`, а plugin body выполняется после release.
50+
- Сценарий: создать `Slot` с list-return body, зарегистрировать plugin, обернуть `slot.lock`, заменить `slot.plugins.plugins` на traced list, `_load_entrypoints()` на `load`.
51+
- Plugin body делает `slot.lock.notify('plugin-body')` и возвращает `'plugin'`.
52+
- Ожидание: `slot() == ['plugin']`, `load` и `snapshot` под lock, `plugin-body` не под lock, trace строго `acquire/load/snapshot/release/plugin-body`.
53+
54+
2. Добавить `test_call_snapshots_empty_plugins_under_lock_but_runs_fallback_after_release`
55+
- Фиксирует: для пустого слота без плагинов fallback body считается пользовательским кодом и тоже не выполняется под `Slot.lock`.
56+
- Сценарий: default body делает `slot.lock.notify('fallback-body')` и возвращает `['fallback']`.
57+
- `slot.plugins.plugins` заменить на пустой traced list, `_load_entrypoints()` на `load`.
58+
- Ожидание: `slot() == ['fallback']`, `load` и `snapshot` под lock, `fallback-body` не под lock, trace строго `acquire/load/snapshot/release/fallback-body`.
59+
60+
3. Добавить `test_plugins_registered_during_dispatch_are_called_on_later_calls_only`
61+
- Фиксирует: `Slot.__call__` dispatch-ит по стабильному snapshot, а не по live list.
62+
- Сценарий: первый plugin `registrar` при первом вызове регистрирует plugin `late`, затем возвращает `'registrar'`.
63+
- Ожидание: первый `slot()` возвращает `['registrar']`; второй `slot()` возвращает `['registrar', 'late']`.
64+
- В тесте не использовать sleeps, threads или timeouts.
65+
66+
4. Сохранить `test_bool_is_protected_by_slot_lock` без изменения поведения
67+
- `__bool__` не является частью issue и продолжает проверять `bool(self.backed_caller)` под lock.
68+
- При необходимости обновить только соседние assertions/import ordering после удаления старого `test_call_is_protected_by_slot_lock`.
69+
70+
5. Существующие `.one`, `__iter__`, `__getitem__`, `__delitem__`, `__contains__`, `__len__`, `keys`, `_pop_plugins`, `_load_entrypoints`, `_add_plugin` тесты не расширять
71+
- Они относятся к предыдущему плану потокобезопасности и уже фиксируют registry operations under lock.
72+
- Issue #28 меняет только границу `Slot.__call__`.
73+
74+
## Проверка полноты
75+
76+
- `Slot.__call__` больше не вызывает plugin/default body под `Slot.lock`.
77+
- Отдельно покрыты оба пути dispatch: слот с зарегистрированными плагинами и пустой слот с fallback body.
78+
- Lazy loading остается под `Slot.lock`.
79+
- Snapshot-backed `CallerWithPlugins` создается под `Slot.lock`.
80+
- Dispatch идет через локальный caller со snapshot, поэтому не видит плагины, добавленные во время текущего вызова.
81+
- Entry point loading остается out of scope.
82+
83+
## Проверка
84+
85+
- Запустить из активированного виртуального окружения:
86+
- `pytest tests/units/components/test_slot.py`
87+
- `coverage run --source=pristan --omit="*tests*" -m pytest --cache-clear --assert=plain && coverage report -m --fail-under=100`
88+
- `coverage run --branch --source=pristan --omit="*tests*" -m pytest --cache-clear --assert=plain && coverage report -m --fail-under=100`
89+
- `ruff check pristan`
90+
- `ruff check tests`
91+
- `mypy --strict pristan`
92+
- `mypy tests --exclude tests/typing`
93+
94+
## Предположения
95+
96+
- Snapshot копирует только контейнер списка: он содержит ссылки на те же `Plugin`-объекты, поэтому состояние `run_once` и прочие object-level semantics сохраняются.
97+
- Локальный `backed_caller` должен создаваться внутри lock, но вызываться только после release.
98+
- README и публичную документацию не менять, если новый контракт полностью покрыт тестами и issue не требует пользовательского текста.

pristan/components/slot.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@ def __init__(self, slot_function: SlotFunction[SlotParameters, SlotResult[Plugin
109109
def __call__(self, *args: SlotParameters.args, **kwargs: SlotParameters.kwargs) -> SlotResult[PluginResult]:
110110
with self.lock:
111111
self._load_entrypoints()
112-
return self.backed_caller(*args, **kwargs)
112+
backed_caller = CallerWithPlugins(self.caller, list(self.plugins.plugins))
113+
114+
return backed_caller(*args, **kwargs)
113115

114116
def __bool__(self) -> bool:
115117
with self.lock:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pristan"
7-
version = "0.0.22"
7+
version = "0.0.23"
88
authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }]
99
description = "Function-based plugin system with respect to typing"
1010
readme = "README.md"

tests/units/components/test_slot.py

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,34 +27,99 @@ def test_set_max_less_than_zero():
2727
Slot(lambda x: x, signature='.', slot_name='slot_name', max=-1, type_check=False, entrypoint_group='pristan', unique=False)
2828

2929

30-
def test_call_is_protected_by_slot_lock():
31-
"""Slot calls keep lazy loading and dispatch under the slot lock."""
32-
def empty_body():
33-
pass
30+
def test_call_snapshots_registered_plugins_under_slot_lock_but_runs_plugins_after_release():
31+
"""Slot loads entry points and snapshots registered plugins under its lock, then runs plugin bodies after release."""
32+
def empty_body() -> List[str]:
33+
return []
3434

3535
slot = Slot(empty_body, signature=None, slot_name=None, max=None, type_check=True, entrypoint_group='pristan', unique=False)
36+
37+
@slot.plugin
38+
def plugin():
39+
slot.lock.notify('plugin-body')
40+
return 'plugin'
41+
3642
slot.lock = LockTraceWrapper(slot.lock)
3743

38-
class BackedCaller:
39-
def __call__(self):
40-
slot.lock.notify('call')
41-
return 'result'
44+
class PluginsList(list):
45+
def __iter__(self):
46+
slot.lock.notify('snapshot')
47+
yield from super().__iter__()
4248

4349
slot._load_entrypoints = lambda: slot.lock.notify('load') # type: ignore[method-assign]
44-
slot.backed_caller = BackedCaller() # type: ignore[assignment]
50+
slot.plugins.plugins = PluginsList(slot.plugins.plugins)
51+
52+
assert slot() == ['plugin']
53+
54+
assert slot.lock.was_event_locked('load')
55+
assert slot.lock.was_event_locked('snapshot')
56+
assert not slot.lock.was_event_locked('plugin-body', raise_exception=False)
57+
assert [(event.type.value, event.identifier) for event in slot.lock.trace] == [
58+
('acquire', None),
59+
('action', 'load'),
60+
('action', 'snapshot'),
61+
('release', None),
62+
('action', 'plugin-body'),
63+
]
64+
4565

46-
assert slot() == 'result'
66+
def test_call_snapshots_empty_plugins_under_lock_but_runs_fallback_after_release():
67+
"""Slot loads entry points and snapshots an empty plugin list under its lock, then runs the fallback body after release."""
68+
def fallback_body() -> List[str]:
69+
slot.lock.notify('fallback-body')
70+
return ['fallback']
71+
72+
slot = Slot(fallback_body, signature=None, slot_name=None, max=None, type_check=True, entrypoint_group='pristan', unique=False)
73+
slot.lock = LockTraceWrapper(slot.lock)
74+
75+
class PluginsList(list):
76+
def __iter__(self):
77+
slot.lock.notify('snapshot')
78+
yield from super().__iter__()
79+
80+
slot._load_entrypoints = lambda: slot.lock.notify('load') # type: ignore[method-assign]
81+
slot.plugins.plugins = PluginsList()
82+
83+
assert slot() == ['fallback']
4784

4885
assert slot.lock.was_event_locked('load')
49-
assert slot.lock.was_event_locked('call')
86+
assert slot.lock.was_event_locked('snapshot')
87+
assert not slot.lock.was_event_locked('fallback-body', raise_exception=False)
5088
assert [(event.type.value, event.identifier) for event in slot.lock.trace] == [
5189
('acquire', None),
5290
('action', 'load'),
53-
('action', 'call'),
91+
('action', 'snapshot'),
5492
('release', None),
93+
('action', 'fallback-body'),
5594
]
5695

5796

97+
def test_plugins_registered_during_dispatch_are_called_on_later_calls_only():
98+
"""Slot uses a stable plugin snapshot, so plugins registered during a call run only on later calls."""
99+
def empty_body() -> List[str]:
100+
return []
101+
102+
slot = Slot(empty_body, signature=None, slot_name=None, max=None, type_check=True, entrypoint_group='pristan', unique=False)
103+
slot._load_entrypoints = lambda: None # type: ignore[method-assign]
104+
late_was_registered = False
105+
106+
@slot.plugin
107+
def registrar():
108+
nonlocal late_was_registered
109+
110+
if not late_was_registered:
111+
late_was_registered = True
112+
113+
@slot.plugin
114+
def late():
115+
return 'late'
116+
117+
return 'registrar'
118+
119+
assert slot() == ['registrar']
120+
assert slot() == ['registrar', 'late']
121+
122+
58123
def test_bool_is_protected_by_slot_lock():
59124
"""Slot truth-value checks keep lazy loading and backed-caller inspection under the slot lock."""
60125
def empty_body():

0 commit comments

Comments
 (0)