Extend ctx semantics suite for streaming edge cases!
Muchas grax to @guilledk for finding the first issue which kicked of this further scrutiny of the `tractor.Context` and `MsgStream` semantics test suite with a strange edge case where, - if the parent opened and immediately closed a stream while the remote child task started and continued (without terminating) to send msgs the parent's `open_context().__aexit__()` would **not block** on the child to complete! => this was seemingly due to a bug discovered inside the `.msg._ops.drain_to_final_msg()` stream handling case logic where we are NOT checking if `Context._stream` is non-`None`! As such this, - extends the `test_caller_closes_ctx_after_callee_opens_stream` (now renamed, see below) to include cases for all combinations of the child and parent sending before receiving on the stream as well as all placements of `Context.cancel()` in the parent before, around and after the stream open. - uses the new `expect_ctxc()` for expecting the taskc (`trio.Task` cancelled)` cases. - also extends the `test_callee_closes_ctx_after_stream_open` (also renamed) to include the case where the parent sends a msg before it receives. => this case has unveiled yet-another-bug where somehow the underlying `MsgStream._rx_chan: trio.ReceiveMemoryChannel` is allowing the child's `Return[None]` msg be consumed and NOT in a place where it is correctly set as `Context._result` resulting in the parent hanging forever inside `._ops.drain_to_final_msg()`.. Alongside, - start renaming using the new "remote-task-peer-side" semantics throughout the test module: "caller" -> "parent", "callee" -> "child".
							parent
							
								
									9008b2a0d4
								
							
						
					
					
						commit
						b13cd4f16b
					
				|  | @ -443,7 +443,6 @@ def test_caller_cancels( | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def close_ctx_immediately( | async def close_ctx_immediately( | ||||||
| 
 |  | ||||||
|     ctx: Context, |     ctx: Context, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|  | @ -454,10 +453,21 @@ async def close_ctx_immediately( | ||||||
|     async with ctx.open_stream(): |     async with ctx.open_stream(): | ||||||
|         pass |         pass | ||||||
| 
 | 
 | ||||||
|  |     print('child returning!') | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'parent_send_before_receive', | ||||||
|  |     [ | ||||||
|  |         False, | ||||||
|  |         True, | ||||||
|  |     ], | ||||||
|  |     ids=lambda item: f'child_send_before_receive={item}' | ||||||
|  | ) | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_callee_closes_ctx_after_stream_open( | async def test_child_exits_ctx_after_stream_open( | ||||||
|     debug_mode: bool, |     debug_mode: bool, | ||||||
|  |     parent_send_before_receive: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     callee context closes without using stream. |     callee context closes without using stream. | ||||||
|  | @ -474,6 +484,15 @@ async def test_callee_closes_ctx_after_stream_open( | ||||||
|     => {'stop': True, 'cid': <str>} |     => {'stop': True, 'cid': <str>} | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|  |     timeout: float = ( | ||||||
|  |         0.5 if ( | ||||||
|  |             not debug_mode | ||||||
|  |             # NOTE, for debugging final | ||||||
|  |             # Return-consumed-n-discarded-ishue! | ||||||
|  |             # and | ||||||
|  |             # not parent_send_before_receive | ||||||
|  |         ) else 999 | ||||||
|  |     ) | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         debug_mode=debug_mode, |         debug_mode=debug_mode, | ||||||
|     ) as an: |     ) as an: | ||||||
|  | @ -482,7 +501,7 @@ async def test_callee_closes_ctx_after_stream_open( | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         with trio.fail_after(0.5): |         with trio.fail_after(timeout): | ||||||
|             async with portal.open_context( |             async with portal.open_context( | ||||||
|                 close_ctx_immediately, |                 close_ctx_immediately, | ||||||
| 
 | 
 | ||||||
|  | @ -494,41 +513,56 @@ async def test_callee_closes_ctx_after_stream_open( | ||||||
| 
 | 
 | ||||||
|                 with trio.fail_after(0.4): |                 with trio.fail_after(0.4): | ||||||
|                     async with ctx.open_stream() as stream: |                     async with ctx.open_stream() as stream: | ||||||
|  |                         if parent_send_before_receive: | ||||||
|  |                             print('sending first msg from parent!') | ||||||
|  |                             await stream.send('yo') | ||||||
| 
 | 
 | ||||||
|                         # should fall through since ``StopAsyncIteration`` |                         # should fall through since ``StopAsyncIteration`` | ||||||
|                         # should be raised through translation of |                         # should be raised through translation of | ||||||
|                         # a ``trio.EndOfChannel`` by |                         # a ``trio.EndOfChannel`` by | ||||||
|                         # ``trio.abc.ReceiveChannel.__anext__()`` |                         # ``trio.abc.ReceiveChannel.__anext__()`` | ||||||
|                         async for _ in stream: |                         msg = 10 | ||||||
|  |                         async for msg in stream: | ||||||
|                             # trigger failure if we DO NOT |                             # trigger failure if we DO NOT | ||||||
|                             # get an EOC! |                             # get an EOC! | ||||||
|                             assert 0 |                             assert 0 | ||||||
|                         else: |                         else: | ||||||
|  |                             # never should get anythinig new from | ||||||
|  |                             # the underlying stream | ||||||
|  |                             assert msg == 10 | ||||||
| 
 | 
 | ||||||
|                             # verify stream is now closed |                             # verify stream is now closed | ||||||
|                             try: |                             try: | ||||||
|                                 with trio.fail_after(0.3): |                                 with trio.fail_after(0.3): | ||||||
|  |                                     print('parent trying to `.receive()` on EoC stream!') | ||||||
|                                     await stream.receive() |                                     await stream.receive() | ||||||
|  |                                     assert 0, 'should have raised eoc!?' | ||||||
|                             except trio.EndOfChannel: |                             except trio.EndOfChannel: | ||||||
|  |                                 print('parent got EoC as expected!') | ||||||
|                                 pass |                                 pass | ||||||
|  |                                 # raise | ||||||
| 
 | 
 | ||||||
|                 # TODO: should be just raise the closed resource err |                 # TODO: should be just raise the closed resource err | ||||||
|                 # directly here to enforce not allowing a re-open |                 # directly here to enforce not allowing a re-open | ||||||
|                 # of a stream to the context (at least until a time of |                 # of a stream to the context (at least until a time of | ||||||
|                 # if/when we decide that's a good idea?) |                 # if/when we decide that's a good idea?) | ||||||
|                 try: |                 try: | ||||||
|                     with trio.fail_after(0.5): |                     with trio.fail_after(timeout): | ||||||
|                         async with ctx.open_stream() as stream: |                         async with ctx.open_stream() as stream: | ||||||
|                             pass |                             pass | ||||||
|                 except trio.ClosedResourceError: |                 except trio.ClosedResourceError: | ||||||
|                     pass |                     pass | ||||||
| 
 | 
 | ||||||
|  |                 # if ctx._rx_chan._state.data: | ||||||
|  |                 #     await tractor.pause() | ||||||
|  | 
 | ||||||
|         await portal.cancel_actor() |         await portal.cancel_actor() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def expect_cancelled( | async def expect_cancelled( | ||||||
|     ctx: Context, |     ctx: Context, | ||||||
|  |     send_before_receive: bool = False, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     global _state |     global _state | ||||||
|  | @ -538,6 +572,10 @@ async def expect_cancelled( | ||||||
| 
 | 
 | ||||||
|     try: |     try: | ||||||
|         async with ctx.open_stream() as stream: |         async with ctx.open_stream() as stream: | ||||||
|  | 
 | ||||||
|  |             if send_before_receive: | ||||||
|  |                 await stream.send('yo') | ||||||
|  | 
 | ||||||
|             async for msg in stream: |             async for msg in stream: | ||||||
|                 await stream.send(msg)  # echo server |                 await stream.send(msg)  # echo server | ||||||
| 
 | 
 | ||||||
|  | @ -567,23 +605,46 @@ async def expect_cancelled( | ||||||
|         assert 0, "callee wasn't cancelled !?" |         assert 0, "callee wasn't cancelled !?" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'child_send_before_receive', | ||||||
|  |     [ | ||||||
|  |         False, | ||||||
|  |         True, | ||||||
|  |     ], | ||||||
|  |     ids=lambda item: f'child_send_before_receive={item}' | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'rent_wait_for_msg', | ||||||
|  |     [ | ||||||
|  |         False, | ||||||
|  |         True, | ||||||
|  |     ], | ||||||
|  |     ids=lambda item: f'rent_wait_for_msg={item}' | ||||||
|  | ) | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'use_ctx_cancel_method', |     'use_ctx_cancel_method', | ||||||
|     [False, True], |     [ | ||||||
|  |         False, | ||||||
|  |         'pre_stream', | ||||||
|  |         'post_stream_open', | ||||||
|  |         'post_stream_close', | ||||||
|  |     ], | ||||||
|  |     ids=lambda item: f'use_ctx_cancel_method={item}' | ||||||
| ) | ) | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_caller_closes_ctx_after_callee_opens_stream( | async def test_parent_exits_ctx_after_child_enters_stream( | ||||||
|     use_ctx_cancel_method: bool, |     use_ctx_cancel_method: bool|str, | ||||||
|     debug_mode: bool, |     debug_mode: bool, | ||||||
|  |     rent_wait_for_msg: bool, | ||||||
|  |     child_send_before_receive: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     caller context closes without using/opening stream |     Parent-side of IPC context closes without sending on `MsgStream`. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         debug_mode=debug_mode, |         debug_mode=debug_mode, | ||||||
|     ) as an: |     ) as an: | ||||||
| 
 |  | ||||||
|         root: Actor = current_actor() |         root: Actor = current_actor() | ||||||
|         portal = await an.start_actor( |         portal = await an.start_actor( | ||||||
|             'ctx_cancelled', |             'ctx_cancelled', | ||||||
|  | @ -592,41 +653,52 @@ async def test_caller_closes_ctx_after_callee_opens_stream( | ||||||
| 
 | 
 | ||||||
|         async with portal.open_context( |         async with portal.open_context( | ||||||
|             expect_cancelled, |             expect_cancelled, | ||||||
|  |             send_before_receive=child_send_before_receive, | ||||||
|         ) as (ctx, sent): |         ) as (ctx, sent): | ||||||
|             assert sent is None |             assert sent is None | ||||||
| 
 | 
 | ||||||
|             await portal.run(assert_state, value=True) |             await portal.run(assert_state, value=True) | ||||||
| 
 | 
 | ||||||
|             # call `ctx.cancel()` explicitly |             # call `ctx.cancel()` explicitly | ||||||
|             if use_ctx_cancel_method: |             if use_ctx_cancel_method == 'pre_stream': | ||||||
|                 await ctx.cancel() |                 await ctx.cancel() | ||||||
| 
 | 
 | ||||||
|                 # NOTE: means the local side `ctx._scope` will |                 # NOTE: means the local side `ctx._scope` will | ||||||
|                 # have been cancelled by an ctxc ack and thus |                 # have been cancelled by an ctxc ack and thus | ||||||
|                 # `._scope.cancelled_caught` should be set. |                 # `._scope.cancelled_caught` should be set. | ||||||
|                 try: |                 async with ( | ||||||
|  |                     expect_ctxc( | ||||||
|  |                         # XXX: the cause is US since we call | ||||||
|  |                         # `Context.cancel()` just above! | ||||||
|  |                         yay=True, | ||||||
|  | 
 | ||||||
|  |                         # XXX: must be propagated to __aexit__ | ||||||
|  |                         # and should be silently absorbed there | ||||||
|  |                         # since we called `.cancel()` just above ;) | ||||||
|  |                         reraise=True, | ||||||
|  |                     ) as maybe_ctxc, | ||||||
|  |                 ): | ||||||
|                     async with ctx.open_stream() as stream: |                     async with ctx.open_stream() as stream: | ||||||
|                         async for msg in stream: |  | ||||||
|                             pass |  | ||||||
| 
 | 
 | ||||||
|                 except tractor.ContextCancelled as ctxc: |                         if rent_wait_for_msg: | ||||||
|                     # XXX: the cause is US since we call |                             async for msg in stream: | ||||||
|                     # `Context.cancel()` just above! |                                 print(f'PARENT rx: {msg!r}\n') | ||||||
|                     assert ( |                                 break | ||||||
|                         ctxc.canceller |  | ||||||
|                         == |  | ||||||
|                         current_actor().uid |  | ||||||
|                         == |  | ||||||
|                         root.uid |  | ||||||
|                     ) |  | ||||||
| 
 | 
 | ||||||
|                     # XXX: must be propagated to __aexit__ |                         if use_ctx_cancel_method == 'post_stream_open': | ||||||
|                     # and should be silently absorbed there |                             await ctx.cancel() | ||||||
|                     # since we called `.cancel()` just above ;) |  | ||||||
|                     raise |  | ||||||
| 
 | 
 | ||||||
|                 else: |                     if use_ctx_cancel_method == 'post_stream_close': | ||||||
|                     assert 0, "Should have context cancelled?" |                         await ctx.cancel() | ||||||
|  | 
 | ||||||
|  |                 ctxc: tractor.ContextCancelled = maybe_ctxc.value | ||||||
|  |                 assert ( | ||||||
|  |                     ctxc.canceller | ||||||
|  |                     == | ||||||
|  |                     current_actor().uid | ||||||
|  |                     == | ||||||
|  |                     root.uid | ||||||
|  |                 ) | ||||||
| 
 | 
 | ||||||
|                 # channel should still be up |                 # channel should still be up | ||||||
|                 assert portal.channel.connected() |                 assert portal.channel.connected() | ||||||
|  | @ -637,13 +709,20 @@ async def test_caller_closes_ctx_after_callee_opens_stream( | ||||||
|                     value=False, |                     value=False, | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|  |             # XXX CHILD-BLOCKS case, we SHOULD NOT exit from the | ||||||
|  |             # `.open_context()` before the child has returned, | ||||||
|  |             # errored or been cancelled! | ||||||
|             else: |             else: | ||||||
|                 try: |                 try: | ||||||
|                     with trio.fail_after(0.2): |                     with trio.fail_after( | ||||||
|                         await ctx.result() |                         0.5  # if not debug_mode else 999 | ||||||
|  |                     ): | ||||||
|  |                         res = await ctx.wait_for_result() | ||||||
|  |                         assert res is not tractor._context.Unresolved | ||||||
|                         assert 0, "Callee should have blocked!?" |                         assert 0, "Callee should have blocked!?" | ||||||
|                 except trio.TooSlowError: |                 except trio.TooSlowError: | ||||||
|                     # NO-OP -> since already called above |                     # NO-OP -> since already triggered by | ||||||
|  |                     # `trio.fail_after()` above! | ||||||
|                     await ctx.cancel() |                     await ctx.cancel() | ||||||
| 
 | 
 | ||||||
|         # NOTE: local scope should have absorbed the cancellation since |         # NOTE: local scope should have absorbed the cancellation since | ||||||
|  | @ -683,7 +762,7 @@ async def test_caller_closes_ctx_after_callee_opens_stream( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_multitask_caller_cancels_from_nonroot_task( | async def test_multitask_parent_cancels_from_nonroot_task( | ||||||
|     debug_mode: bool, |     debug_mode: bool, | ||||||
| ): | ): | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|  | @ -735,7 +814,6 @@ async def test_multitask_caller_cancels_from_nonroot_task( | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def cancel_self( | async def cancel_self( | ||||||
| 
 |  | ||||||
|     ctx: Context, |     ctx: Context, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|  | @ -775,7 +853,7 @@ async def cancel_self( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_callee_cancels_before_started( | async def test_child_cancels_before_started( | ||||||
|     debug_mode: bool, |     debug_mode: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|  | @ -826,8 +904,7 @@ async def never_open_stream( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def keep_sending_from_callee( | async def keep_sending_from_child( | ||||||
| 
 |  | ||||||
|     ctx:  Context, |     ctx:  Context, | ||||||
|     msg_buffer_size: int|None = None, |     msg_buffer_size: int|None = None, | ||||||
| 
 | 
 | ||||||
|  | @ -850,7 +927,7 @@ async def keep_sending_from_callee( | ||||||
|     'overrun_by', |     'overrun_by', | ||||||
|     [ |     [ | ||||||
|         ('caller', 1, never_open_stream), |         ('caller', 1, never_open_stream), | ||||||
|         ('callee', 0, keep_sending_from_callee), |         ('callee', 0, keep_sending_from_child), | ||||||
|     ], |     ], | ||||||
|     ids=[ |     ids=[ | ||||||
|          ('caller_1buf_never_open_stream'), |          ('caller_1buf_never_open_stream'), | ||||||
|  | @ -931,8 +1008,7 @@ def test_one_end_stream_not_opened( | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def echo_back_sequence( | async def echo_back_sequence( | ||||||
| 
 |     ctx: Context, | ||||||
|     ctx:  Context, |  | ||||||
|     seq: list[int], |     seq: list[int], | ||||||
|     wait_for_cancel: bool, |     wait_for_cancel: bool, | ||||||
|     allow_overruns_side: str, |     allow_overruns_side: str, | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue