From 9c6b90ef041881cf32a67ef62c49e8f331cb875a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 6 Sep 2025 14:03:02 -0400 Subject: [PATCH] Add a ignore-masking-case script + suite Demonstrating the guilty `trio.Lock.acquire()` impl which puts a checkpoint inside its `trio.WouldBlock` handler and which will always appear to mask the "sync path" case on (graceful) cancellation. This first script draft demos the issue from within a `tractor.context` ep bc that's where it was orig discovered, however i'm going to factor out the `tractor` code and instead just use a `.trionics.maybe_raise_from_masking_exc()` to demo its low-level ignore-case feature. Further, this script exposed a previously unhandled remote graceful cancellation case which hangs: - parent actor spawns child and opens a >1 ctxs with it, - the parent then OoB (out-of-band) cancels the child actor (with `Portal.cancel_actor()`), - since the open ctxs raise a ctxc with a `.canceller == parent.uid` the `Context._is_self_cancelled()` will eval `True`, - the `Context._scope` will NOT be cancelled in `._maybe_cancel_and_set_remote_error()` resulting in any bg-task which is waiting on a `Portal.open_context()` to not be cancelled/unblocked. So my plan is to factor this ^^ scenario into a standalone unit test as well as another test which consumes from al low-level `trio`-only version of **this** script-scenario to sanity check the interaction of the unmasker-with-ignore-cases usage implicitly around a ctx ep. --- examples/trio/lockacquire_not_unmasked.py | 97 +++++++++++++++++++++++ tests/test_trioisms.py | 35 ++++++++ 2 files changed, 132 insertions(+) create mode 100644 examples/trio/lockacquire_not_unmasked.py diff --git a/examples/trio/lockacquire_not_unmasked.py b/examples/trio/lockacquire_not_unmasked.py new file mode 100644 index 00000000..3e507133 --- /dev/null +++ b/examples/trio/lockacquire_not_unmasked.py @@ -0,0 +1,97 @@ +from functools import partial + +import tractor +import trio + + +log = tractor.log.get_logger( + name=__name__ +) + + +async def acquire_singleton_lock( + _lock = trio.Lock(), +) -> None: + log.info('TRYING TO LOCK ACQUIRE') + await _lock.acquire() + log.info('ACQUIRED') + + +@tractor.context +async def acquire_actor_global_lock( + ctx: tractor.Context, + + ignore_special_cases: bool, +): + if not ignore_special_cases: + from tractor.trionics import _taskc + _taskc._mask_cases.clear() + + await acquire_singleton_lock() + await ctx.started('locked') + + # block til cancelled + await trio.sleep_forever() + + +async def main( + ignore_special_cases: bool, + loglevel: str = 'info', + debug_mode: bool = True, + + _fail_after: float = 2, +): + tractor.log.get_console_log(level=loglevel) + + with trio.fail_after(_fail_after): + async with ( + tractor.trionics.collapse_eg(), + tractor.open_nursery( + debug_mode=debug_mode, + loglevel=loglevel, + ) as an, + trio.open_nursery() as tn, + ): + ptl = await an.start_actor( + 'locker', + enable_modules=[__name__], + ) + + async def _open_ctx( + task_status=trio.TASK_STATUS_IGNORED, + ): + async with ptl.open_context( + acquire_actor_global_lock, + ignore_special_cases=ignore_special_cases, + ) as pair: + task_status.started(pair) + await trio.sleep_forever() + + first_ctx, first = await tn.start(_open_ctx,) + assert first == 'locked' + + with trio.move_on_after(0.5):# as cs: + await _open_ctx() + + # await tractor.pause() + print('cancelling first IPC ctx!') + await first_ctx.cancel() + + await ptl.cancel_actor() + # await tractor.pause() + + +# XXX, manual test as script +if __name__ == '__main__': + tractor.log.get_console_log(level='info') + for case in [False, True]: + log.info( + f'\n' + f'------ RUNNING SCRIPT TRIAL ------\n' + f'child_errors_midstream: {case!r}\n' + ) + trio.run(partial( + main, + ignore_special_cases=case, + loglevel='info', + )) diff --git a/tests/test_trioisms.py b/tests/test_trioisms.py index 18e0c80a..f68bc2e8 100644 --- a/tests/test_trioisms.py +++ b/tests/test_trioisms.py @@ -265,3 +265,38 @@ def test_unmask_aclose_as_checkpoint_on_aexit( raise_unmasked=raise_unmasked, child_errors_mid_stream=child_errors_mid_stream, )) + + + +@pytest.mark.parametrize( + 'ignore_special_cases', [ + True, + pytest.param( + False, + marks=pytest.mark.xfail( + reason="see examples/trio/lockacquire_not_umasked.py" + ) + ), + ] +) +def test_cancelled_lockacquire_in_ipctx_not_unmaskeed( + ignore_special_cases: bool, + loglevel: str, + debug_mode: bool, +): + mod: ModuleType = pathlib.import_path( + examples_dir() + / 'trio' + / 'lockacquire_not_unmasked.py', + root=examples_dir(), + consider_namespace_packages=False, + ) + async def _main(): + with trio.fail_after(2): + await mod.main( + ignore_special_cases=ignore_special_cases, + loglevel=loglevel, + debug_mode=debug_mode, + ) + + trio.run(_main)