Skip to content

Commit 57452ad

Browse files
graingertA5rocksoremanjpre-commit-ci[bot]
authored
gh-3108: Avoid materializing f_locals for KI protection (#3110)
* gh-3108: avoid materializing f_locals by using weakrefs to code objects instead * enable ki protection on async_generator objects * avoid adding an extra coroutine wrapper to Task coros * fix returning the wrong object in (enable|disable)_ki_protection * remove KIProtectionSignature from _check_type_completeness.json * fix refcycles * add newsfragment * fix mypy * now that the type annotation for enable_ki_protection is fixed, we need to fix the use of Any * pre-commit * add test for ki protection leaking accross local functions * add fix for ki protection leaking accross local functions * do slots properly * python 3.8 support * test reading currently_ki_protected doesn't freeze locals * cover some tricky bits of ki.py * cover a potentially impossible scenario * eek out some last coverage of the eeking out coverage tests * even more partial coverage * Update src/trio/_core/_ki.py * cleaner _IdRef.__eq__ * if the current_task().coro.cr_frame is in the stack ki_protection_enabled is current_task()._ki_protected * Update newsfragments/3108.bugfix.rst * avoid copying code objects for ki protected function * Update src/trio/_core/_ki.py * Update src/trio/_core/_ki.py Co-authored-by: EXPLOSION <[email protected]> * remove workaround for 3.8 * Add docs and update news Co-Authored-By: oremanj <[email protected]> * wrap ki protection locals demos in async def so they work * add newsfragment for 2670 * Apply suggestions from code review * use peo614 * add tests for passing on inspect flags * 'return; yield' isn't considered covered * Update newsfragments/3108.bugfix.rst * [pre-commit.ci] auto fixes from pre-commit.com hooks --------- Co-authored-by: EXPLOSION <[email protected]> Co-authored-by: oremanj <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 13d4bad commit 57452ad

15 files changed

+480
-149
lines changed

docs/source/reference-lowlevel.rst

+40
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,46 @@ These transitions are accomplished using two function decorators:
377377
poorly-timed :exc:`KeyboardInterrupt` could leave the lock in an
378378
inconsistent state and cause a deadlock.
379379

380+
Since KeyboardInterrupt protection is tracked per code object, any attempt to
381+
conditionally protect the same block of code in different ways is unlikely to behave
382+
how you expect. If you try to conditionally protect a closure, it will be
383+
unconditionally protected instead::
384+
385+
def example(protect: bool) -> bool:
386+
def inner() -> bool:
387+
return trio.lowlevel.currently_ki_protected()
388+
if protect:
389+
inner = trio.lowlevel.enable_ki_protection(inner)
390+
return inner()
391+
392+
async def amain():
393+
assert example(False) == False
394+
assert example(True) == True # once protected ...
395+
assert example(False) == True # ... always protected
396+
397+
trio.run(amain)
398+
399+
If you really need conditional protection, you can achieve it by giving each
400+
KI-protected instance of the closure its own code object::
401+
402+
def example(protect: bool) -> bool:
403+
def inner() -> bool:
404+
return trio.lowlevel.currently_ki_protected()
405+
if protect:
406+
inner.__code__ = inner.__code__.replace()
407+
inner = trio.lowlevel.enable_ki_protection(inner)
408+
return inner()
409+
410+
async def amain():
411+
assert example(False) == False
412+
assert example(True) == True
413+
assert example(False) == False
414+
415+
trio.run(amain)
416+
417+
(This isn't done by default because it carries some memory overhead and reduces
418+
the potential for specializing optimizations in recent versions of CPython.)
419+
380420
.. autofunction:: currently_ki_protected
381421

382422

newsfragments/2670.bugfix.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
:func:`inspect.iscoroutinefunction` and the like now give correct answers when
2+
called on KI-protected functions.

newsfragments/3108.bugfix.rst

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
Rework KeyboardInterrupt protection to track code objects, rather than frames,
2+
as protected or not. The new implementation no longer needs to access
3+
``frame.f_locals`` dictionaries, so it won't artificially extend the lifetime of
4+
local variables. Since KeyboardInterrupt protection is now imposed statically
5+
(when a protected function is defined) rather than each time the function runs,
6+
its previously-noticeable performance overhead should now be near zero.
7+
The lack of a call-time wrapper has some other benefits as well:
8+
9+
* :func:`inspect.iscoroutinefunction` and the like now give correct answers when
10+
called on KI-protected functions.
11+
12+
* Calling a synchronous KI-protected function no longer pushes an additional stack
13+
frame, so tracebacks are clearer.
14+
15+
* A synchronous KI-protected function invoked from C code (such as a weakref
16+
finalizer) is now guaranteed to start executing; previously there would be a brief
17+
window in which KeyboardInterrupt could be raised before the protection was
18+
established.
19+
20+
One minor drawback of the new approach is that multiple instances of the same
21+
closure share a single KeyboardInterrupt protection state (because they share a
22+
single code object). That means that if you apply
23+
`@enable_ki_protection <trio.lowlevel.enable_ki_protection>` to some of them
24+
and not others, you won't get the protection semantics you asked for. See the
25+
documentation of `@enable_ki_protection <trio.lowlevel.enable_ki_protection>`
26+
for more details and a workaround.

src/trio/_core/_generated_instrumentation.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@
33
# *************************************************************
44
from __future__ import annotations
55

6-
import sys
76
from typing import TYPE_CHECKING
87

9-
from ._ki import LOCALS_KEY_KI_PROTECTION_ENABLED
8+
from ._ki import enable_ki_protection
109
from ._run import GLOBAL_RUN_CONTEXT
1110

1211
if TYPE_CHECKING:
@@ -15,6 +14,7 @@
1514
__all__ = ["add_instrument", "remove_instrument"]
1615

1716

17+
@enable_ki_protection
1818
def add_instrument(instrument: Instrument) -> None:
1919
"""Start instrumenting the current run loop with the given instrument.
2020
@@ -24,13 +24,13 @@ def add_instrument(instrument: Instrument) -> None:
2424
If ``instrument`` is already active, does nothing.
2525
2626
"""
27-
sys._getframe().f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = True
2827
try:
2928
return GLOBAL_RUN_CONTEXT.runner.instruments.add_instrument(instrument)
3029
except AttributeError:
3130
raise RuntimeError("must be called from async context") from None
3231

3332

33+
@enable_ki_protection
3434
def remove_instrument(instrument: Instrument) -> None:
3535
"""Stop instrumenting the current run loop with the given instrument.
3636
@@ -44,7 +44,6 @@ def remove_instrument(instrument: Instrument) -> None:
4444
deactivated.
4545
4646
"""
47-
sys._getframe().f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = True
4847
try:
4948
return GLOBAL_RUN_CONTEXT.runner.instruments.remove_instrument(instrument)
5049
except AttributeError:

src/trio/_core/_generated_io_epoll.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import sys
77
from typing import TYPE_CHECKING
88

9-
from ._ki import LOCALS_KEY_KI_PROTECTION_ENABLED
9+
from ._ki import enable_ki_protection
1010
from ._run import GLOBAL_RUN_CONTEXT
1111

1212
if TYPE_CHECKING:
@@ -18,6 +18,7 @@
1818
__all__ = ["notify_closing", "wait_readable", "wait_writable"]
1919

2020

21+
@enable_ki_protection
2122
async def wait_readable(fd: int | _HasFileNo) -> None:
2223
"""Block until the kernel reports that the given object is readable.
2324
@@ -40,13 +41,13 @@ async def wait_readable(fd: int | _HasFileNo) -> None:
4041
if another task calls :func:`notify_closing` while this
4142
function is still working.
4243
"""
43-
sys._getframe().f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = True
4444
try:
4545
return await GLOBAL_RUN_CONTEXT.runner.io_manager.wait_readable(fd)
4646
except AttributeError:
4747
raise RuntimeError("must be called from async context") from None
4848

4949

50+
@enable_ki_protection
5051
async def wait_writable(fd: int | _HasFileNo) -> None:
5152
"""Block until the kernel reports that the given object is writable.
5253
@@ -59,13 +60,13 @@ async def wait_writable(fd: int | _HasFileNo) -> None:
5960
if another task calls :func:`notify_closing` while this
6061
function is still working.
6162
"""
62-
sys._getframe().f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = True
6363
try:
6464
return await GLOBAL_RUN_CONTEXT.runner.io_manager.wait_writable(fd)
6565
except AttributeError:
6666
raise RuntimeError("must be called from async context") from None
6767

6868

69+
@enable_ki_protection
6970
def notify_closing(fd: int | _HasFileNo) -> None:
7071
"""Notify waiters of the given object that it will be closed.
7172
@@ -91,7 +92,6 @@ def notify_closing(fd: int | _HasFileNo) -> None:
9192
step, so other tasks won't be able to tell what order they happened
9293
in anyway.
9394
"""
94-
sys._getframe().f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = True
9595
try:
9696
return GLOBAL_RUN_CONTEXT.runner.io_manager.notify_closing(fd)
9797
except AttributeError:

src/trio/_core/_generated_io_kqueue.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import sys
77
from typing import TYPE_CHECKING
88

9-
from ._ki import LOCALS_KEY_KI_PROTECTION_ENABLED
9+
from ._ki import enable_ki_protection
1010
from ._run import GLOBAL_RUN_CONTEXT
1111

1212
if TYPE_CHECKING:
@@ -31,18 +31,19 @@
3131
]
3232

3333

34+
@enable_ki_protection
3435
def current_kqueue() -> select.kqueue:
3536
"""TODO: these are implemented, but are currently more of a sketch than
3637
anything real. See `#26
3738
<https://github.com/python-trio/trio/issues/26>`__.
3839
"""
39-
sys._getframe().f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = True
4040
try:
4141
return GLOBAL_RUN_CONTEXT.runner.io_manager.current_kqueue()
4242
except AttributeError:
4343
raise RuntimeError("must be called from async context") from None
4444

4545

46+
@enable_ki_protection
4647
def monitor_kevent(
4748
ident: int,
4849
filter: int,
@@ -51,13 +52,13 @@ def monitor_kevent(
5152
anything real. See `#26
5253
<https://github.com/python-trio/trio/issues/26>`__.
5354
"""
54-
sys._getframe().f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = True
5555
try:
5656
return GLOBAL_RUN_CONTEXT.runner.io_manager.monitor_kevent(ident, filter)
5757
except AttributeError:
5858
raise RuntimeError("must be called from async context") from None
5959

6060

61+
@enable_ki_protection
6162
async def wait_kevent(
6263
ident: int,
6364
filter: int,
@@ -67,7 +68,6 @@ async def wait_kevent(
6768
anything real. See `#26
6869
<https://github.com/python-trio/trio/issues/26>`__.
6970
"""
70-
sys._getframe().f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = True
7171
try:
7272
return await GLOBAL_RUN_CONTEXT.runner.io_manager.wait_kevent(
7373
ident,
@@ -78,6 +78,7 @@ async def wait_kevent(
7878
raise RuntimeError("must be called from async context") from None
7979

8080

81+
@enable_ki_protection
8182
async def wait_readable(fd: int | _HasFileNo) -> None:
8283
"""Block until the kernel reports that the given object is readable.
8384
@@ -100,13 +101,13 @@ async def wait_readable(fd: int | _HasFileNo) -> None:
100101
if another task calls :func:`notify_closing` while this
101102
function is still working.
102103
"""
103-
sys._getframe().f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = True
104104
try:
105105
return await GLOBAL_RUN_CONTEXT.runner.io_manager.wait_readable(fd)
106106
except AttributeError:
107107
raise RuntimeError("must be called from async context") from None
108108

109109

110+
@enable_ki_protection
110111
async def wait_writable(fd: int | _HasFileNo) -> None:
111112
"""Block until the kernel reports that the given object is writable.
112113
@@ -119,13 +120,13 @@ async def wait_writable(fd: int | _HasFileNo) -> None:
119120
if another task calls :func:`notify_closing` while this
120121
function is still working.
121122
"""
122-
sys._getframe().f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = True
123123
try:
124124
return await GLOBAL_RUN_CONTEXT.runner.io_manager.wait_writable(fd)
125125
except AttributeError:
126126
raise RuntimeError("must be called from async context") from None
127127

128128

129+
@enable_ki_protection
129130
def notify_closing(fd: int | _HasFileNo) -> None:
130131
"""Notify waiters of the given object that it will be closed.
131132
@@ -151,7 +152,6 @@ def notify_closing(fd: int | _HasFileNo) -> None:
151152
step, so other tasks won't be able to tell what order they happened
152153
in anyway.
153154
"""
154-
sys._getframe().f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = True
155155
try:
156156
return GLOBAL_RUN_CONTEXT.runner.io_manager.notify_closing(fd)
157157
except AttributeError:

0 commit comments

Comments
 (0)