|
| 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 не требует пользовательского текста. |
0 commit comments