async def spawn_opts_chain(): """Spawn an options chain UI in a new subactor. """ from .option_chain import _async_main try: async with tractor.open_nursery() as tn: portal = await tn.run_in_actor( 'optschain', _async_main, symbol=table.last_clicked_row._last_record['symbol'], brokername=brokermod.name, loglevel=tractor.log.get_loglevel(), ) except tractor.RemoteActorError: # don't allow option chain errors to crash this monitor # this is, like, the most basic of resliency policies log.exception(f"{portal.actor.name} crashed:")
async def maybe_spawn_brokerd( brokername: str, sleep: float = 0.5, loglevel: Optional[str] = None, expose_mods: List = [], **tractor_kwargs, ) -> tractor._portal.Portal: """If no ``brokerd.{brokername}`` daemon-actor can be found, spawn one in a local subactor and return a portal to it. """ if loglevel: get_console_log(loglevel) # disable debugger in brokerd? # tractor._state._runtime_vars['_debug_mode'] = False tractor_kwargs['loglevel'] = loglevel brokermod = get_brokermod(brokername) dname = f'brokerd.{brokername}' async with tractor.find_actor(dname) as portal: # WTF: why doesn't this work? if portal is not None: yield portal else: # no daemon has been spawned yet log.info(f"Spawning {brokername} broker daemon") tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {}) async with tractor.open_nursery() as nursery: try: # spawn new daemon portal = await nursery.start_actor( dname, enable_modules=_data_mods + [brokermod.__name__], loglevel=loglevel, **tractor_kwargs) async with tractor.wait_for_actor(dname) as portal: yield portal finally: # client code may block indefinitely so cancel when # teardown is invoked await nursery.cancel()
async def spawn_and_error(breadth, depth) -> None: name = tractor.current_actor().name async with tractor.open_nursery() as nursery: for i in range(breadth): if depth > 0: args = (spawn_and_error, ) kwargs = { 'name': f'spawner_{i}_depth_{depth}', 'breadth': breadth, 'depth': depth - 1, } else: args = (assert_err, ) kwargs = { 'name': f'{name}_errorer_{i}', } await nursery.run_in_actor(*args, **kwargs)
async def test_cancel_infinite_streamer(start_method): # stream for at most 1 seconds with trio.move_on_after(1) as cancel_scope: async with tractor.open_nursery() as n: portal = await n.start_actor( 'donny', enable_modules=[__name__], ) # this async for loop streams values from the above # async generator running in a separate process async with portal.open_stream_from(stream_forever) as stream: async for letter in stream: print(letter) # we support trio's cancellation system assert cancel_scope.cancelled_caught assert n.cancelled
async def test_movie_theatre_convo(start_method): """The main ``tractor`` routine. """ async with tractor.open_nursery() as n: portal = await n.start_actor( 'frank', # enable the actor to run funcs from this current module rpc_module_paths=[__name__], ) print(await portal.run(__name__, 'movie_theatre_question')) # call the subactor a 2nd time print(await portal.run(__name__, 'movie_theatre_question')) # the async with will block here indefinitely waiting # for our actor "frank" to complete, we cancel 'frank' # to avoid blocking indefinitely await portal.cancel_actor()
async def test_nested_multierrors(loglevel, start_method): """Test that failed actor sets are wrapped in `trio.MultiError`s. This test goes only 2 nurseries deep but we should eventually have tests for arbitrary n-depth actor trees. """ if start_method == 'trio_run_in_process': depth = 3 subactor_breadth = 2 else: # XXX: multiprocessing can't seem to handle any more then 2 depth # process trees for whatever reason. # Any more process levels then this and we see bugs that cause # hangs and broken pipes all over the place... if start_method == 'forkserver': pytest.skip("Forksever sux hard at nested spawning...") depth = 2 subactor_breadth = 2 with trio.fail_after(120): try: async with tractor.open_nursery() as nursery: for i in range(subactor_breadth): await nursery.run_in_actor( f'spawner_{i}', spawn_and_error, breadth=subactor_breadth, depth=depth, ) except trio.MultiError as err: assert len(err.exceptions) == subactor_breadth for subexc in err.exceptions: assert isinstance(subexc, tractor.RemoteActorError) if depth > 1 and subactor_breadth > 1: # XXX not sure what's up with this.. if platform.system() == 'Windows': assert (subexc.type is trio.MultiError) or ( subexc.type is tractor.RemoteActorError) else: assert subexc.type is trio.MultiError else: assert (subexc.type is tractor.RemoteActorError) or ( subexc.type is trio.Cancelled)
async def main(): """The main ``tractor`` routine. """ async with tractor.open_nursery() as n: portal = await n.start_actor( 'frank', # enable the actor to run funcs from this current module rpc_module_paths=[__name__], ) print(await portal.run(__name__, 'movie_theatre_question')) # call the subactor a 2nd time print(await portal.run(__name__, 'movie_theatre_question')) # the async with will block here indefinitely waiting # for our actor "frank" to complete, but since it's an # "outlive_main" actor it will never end until cancelled await portal.cancel_actor()
async def worker_pool(workers=4): """Though it's a trivial special case for ``tractor``, the well known "worker pool" seems to be the defacto "but, I want this process pattern!" for most parallelism pilgrims. Yes, the workers stay alive (and ready for work) until you close the context. """ async with tractor.open_nursery() as tn: portals = [] snd_chan, recv_chan = trio.open_memory_channel(len(PRIMES)) for i in range(workers): # this starts a new sub-actor (process + trio runtime) and # stores it's "portal" for later use to "submit jobs" (ugh). portals.append(await tn.start_actor( f'worker_{i}', enable_modules=[__name__], )) async def _map(worker_func: Callable[[int], bool], sequence: List[int]) -> List[bool]: # define an async (local) task to collect results from workers async def send_result(func, value, portal): await snd_chan.send((value, await portal.run(func, n=value))) async with trio.open_nursery() as n: for value, portal in zip(sequence, itertools.cycle(portals)): n.start_soon(send_result, worker_func, value, portal) # deliver results as they arrive for _ in range(len(sequence)): yield await recv_chan.receive() # deliver the parallel "worker mapper" to user code yield _map # tear down all "workers" on pool close await tn.cancel()
async def test_trynamic_trio(func, start_method): """Main tractor entry point, the "master" process (for now acts as the "director"). """ async with tractor.open_nursery() as n: print("Alright... Action!") donny = await n.run_in_actor( 'donny', func, other_actor='gretchen', ) gretchen = await n.run_in_actor( 'gretchen', func, other_actor='donny', ) print(await gretchen.result()) print(await donny.result()) print("CUTTTT CUUTT CUT!!?! Donny!! You're supposed to say...")
async def main(): async with tractor.open_nursery() as n: p = await n.start_actor( 'aio_server', enable_modules=[__name__], infect_asyncio=True, ) async with p.open_context( trio_to_aio_echo_server, ) as (ctx, first): assert first == 'start' async with ctx.open_stream() as stream: for i in range(100): await stream.send(i) out = await stream.receive() assert i == out if raise_error_mid_stream and i == 50: raise raise_error_mid_stream # send terminate msg await stream.send(None) out = await stream.receive() assert out is None if out is None: # ensure the stream is stopped # with trio.fail_after(0.1): try: await stream.receive() except trio.EndOfChannel: pass else: pytest.fail( "stream wasn't stopped after sentinel?!") # TODO: the case where this blocks and # is cancelled by kbi or out of task cancellation await p.cancel_actor()
async def main(): """Main tractor entry point, the "master" process (for now acts as the "director"). """ async with tractor.open_nursery() as n: print("Alright... Action!") donny = await n.run_in_actor( say_hello, name='donny', # arguments are always named other_actor='gretchen', ) gretchen = await n.run_in_actor( say_hello, name='gretchen', other_actor='donny', ) print(await gretchen.result()) print(await donny.result()) print("CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...")
async def main(): with trio.fail_after(timeout): async with tractor.open_nursery() as n: # name of this actor will be same as target func portal = await n.start_actor( '2_way', enable_modules=[__name__] ) async with portal.open_context(echo_ctx_stream) as (ctx, _): async with ctx.open_stream() as stream: async with portal.open_stream_from( async_gen_stream, sequence=list(range(1)), ) as gen_stream: msg = await gen_stream.receive() await stream.send(msg) resp = await stream.receive() assert resp == msg raise KeyboardInterrupt
async def main(): """The main ``tractor`` routine. The process tree should look as approximately as follows: -python examples/debugging/multi_subactors.py |-python -m tractor._child --uid ('name_error', 'a7caf490 ...) |-python -m tractor._child --uid ('bp_forever', '1f787a7e ...) `-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...) `-python -m tractor._child --uid ('name_error', '3391222c ...) """ async with tractor.open_nursery( debug_mode=True, ) as n: # Spawn both actors, don't bother with collecting results # (would result in a different debugger outcome due to parent's # cancellation). await n.run_in_actor(breakpoint_forever) await n.run_in_actor(name_error) await n.run_in_actor(spawn_error)
async def spawn( is_arbiter: bool, data: Dict, arb_addr: Tuple[str, int], ): namespaces = [__name__] await trio.sleep(0.1) async with tractor.open_root_actor( arbiter_addr=arb_addr, ): actor = tractor.current_actor() assert actor.is_arbiter == is_arbiter data = data_to_pass_down if actor.is_arbiter: async with tractor.open_nursery( ) as nursery: # forks here portal = await nursery.run_in_actor( spawn, is_arbiter=False, name='sub-actor', data=data, arb_addr=arb_addr, enable_modules=namespaces, ) assert len(nursery._children) == 1 assert portal.channel.uid in tractor.current_actor()._peers # be sure we can still get the result result = await portal.result() assert result == 10 return result else: return 10
async def main(): # flat to make sure we get at least one pong got_pong: bool = False timeout: int = 2 if is_win(): # smh timeout = 4 with trio.move_on_after(timeout): async with tractor.open_nursery() as n: # name of this actor will be same as target func portal = await n.start_actor( 'dual_tasks', enable_modules=[__name__] ) async with portal.open_context( one_task_streams_and_one_handles_reqresp, ) as (ctx, first): assert first is None async with ctx.open_stream() as stream: await stream.send('ping') async for msg in stream: print(f'client received: {msg}') assert msg in {'pong', 'yo'} if msg == 'pong': got_pong = True await stream.send('ping') print('client sent ping') assert got_pong
async def stream_from_single_subactor(): """Verify we can spawn a daemon actor and retrieve streamed data. """ async with tractor.find_actor('brokerd') as portals: if not portals: # only one per host address, spawns an actor if None async with tractor.open_nursery() as nursery: # no brokerd actor found portal = await nursery.start_actor( 'streamerd', rpc_module_paths=[__name__], statespace={'global_dict': {}}, ) seq = range(10) agen = await portal.run( __name__, 'stream_seq', # the func above sequence=list(seq), # has to be msgpack serializable ) # it'd sure be nice to have an asyncitertools here... iseq = iter(seq) ival = next(iseq) async for val in agen: assert val == ival try: ival = next(iseq) except StopIteration: # should cancel far end task which will be # caught and no error is raised await agen.aclose() await trio.sleep(0.3) try: await agen.__anext__() except StopAsyncIteration: # stop all spawned subactors await portal.cancel_actor()
async def test_some_cancels_all(num_actors_and_errs, start_method): """Verify a subset of failed subactors causes all others in the nursery to be cancelled just like the strategy in trio. This is the first and only supervisory strategy at the moment. """ num, first_err, err_type = num_actors_and_errs try: async with tractor.open_nursery() as n: real_actors = [] for i in range(3): real_actors.append(await n.start_actor( f'actor_{i}', rpc_module_paths=[__name__], )) for i in range(num): # start actor(s) that will fail immediately await n.run_in_actor(f'extra_{i}', assert_err) # should error here with a ``RemoteActorError`` or ``MultiError`` except first_err as err: if isinstance(err, tractor.MultiError): assert len(err.exceptions) == num for exc in err.exceptions: if isinstance(exc, tractor.RemoteActorError): assert exc.type == err_type else: assert isinstance(exc, trio.Cancelled) elif isinstance(err, tractor.RemoteActorError): assert err.type == err_type assert n.cancelled is True assert not n._children else: pytest.fail("Should have gotten a remote assertion error?")
async def main() -> None: async with tractor.open_nursery() as n: portal = await n.start_actor( 'rpc_server', enable_modules=[__name__], ) # XXX: syntax requires py3.9 async with ( portal.open_context( simple_rpc, # taken from pytest parameterization data=10, ) as (ctx, sent), ctx.open_stream() as stream, ): assert sent == 11 count = 0 # receive msgs using async for style await stream.send('ping') async for msg in stream: assert msg == 'pong' await stream.send('ping') count += 1 if count >= 9: break # explicitly teardown the daemon-actor await portal.cancel_actor()
async def a_quadruple_example(): # a nursery which spawns "actors" async with tractor.open_nursery() as nursery: seed = int(1e3) pre_start = time.time() portal = await nursery.run_in_actor( 'aggregator', aggregate, seed=seed, ) start = time.time() # the portal call returns exactly what you'd expect # as if the remote "aggregate" function was called locally result_stream = [] async for value in await portal.result(): result_stream.append(value) print(f"STREAM TIME = {time.time() - start}") print(f"STREAM + SPAWN TIME = {time.time() - pre_start}") assert result_stream == list(range(seed)) return result_stream
async def test_most_beautiful_word( start_method, return_value ): ''' The main ``tractor`` routine. ''' with trio.fail_after(1): async with tractor.open_nursery() as n: portal = await n.run_in_actor( cellar_door, return_value=return_value, name='some_linguist', ) print(await portal.result()) # The ``async with`` will unblock here since the 'some_linguist' # actor has completed its main task ``cellar_door``. # this should pull the cached final result already captured during # the nursery block exit. print(await portal.result())
async def main(): """The main ``tractor`` routine. The process tree should look as approximately as follows: python examples/debugging/multi_subactors.py ├─ python -m tractor._child --uid ('name_error', 'a7caf490 ...) `-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...) `-python -m tractor._child --uid ('name_error', '3391222c ...) Order of failure: - nested name_error sub-sub-actor - root actor should then fail on assert - program termination """ async with tractor.open_nursery( debug_mode=True, ) as n: # spawn both actors portal = await n.run_in_actor( name_error, name='name_error', ) portal1 = await n.run_in_actor( spawn_error, name='spawn_error', ) # trigger a root actor error assert 0 # attempt to collect results (which raises error in parent) # still has some issues where the parent seems to get stuck await portal.result() await portal1.result()
async def main(): async with tractor.open_nursery(debug_mode=True, ) as n: portal = await n.run_in_actor(breakpoint_forever, ) await portal.result()
async def test_some_cancels_all(num_actors_and_errs, start_method): """Verify a subset of failed subactors causes all others in the nursery to be cancelled just like the strategy in trio. This is the first and only supervisory strategy at the moment. """ num_actors, first_err, err_type, ria_func, da_func = num_actors_and_errs try: async with tractor.open_nursery() as n: # spawn the same number of deamon actors which should be cancelled dactor_portals = [] for i in range(num_actors): dactor_portals.append(await n.start_actor( f'deamon_{i}', rpc_module_paths=[__name__], )) func, kwargs = ria_func riactor_portals = [] for i in range(num_actors): # start actor(s) that will fail immediately riactor_portals.append(await n.run_in_actor(f'actor_{i}', func, **kwargs)) if da_func: func, kwargs, expect_error = da_func for portal in dactor_portals: # if this function fails then we should error here # and the nursery should teardown all other actors try: await portal.run(__name__, func.__name__, **kwargs) except tractor.RemoteActorError as err: assert err.type == err_type # we only expect this first error to propogate # (all other daemons are cancelled before they # can be scheduled) num_actors = 1 # reraise so nursery teardown is triggered raise else: if expect_error: pytest.fail( "Deamon call should fail at checkpoint?") # should error here with a ``RemoteActorError`` or ``MultiError`` except first_err as err: if isinstance(err, tractor.MultiError): assert len(err.exceptions) == num_actors for exc in err.exceptions: if isinstance(exc, tractor.RemoteActorError): assert exc.type == err_type else: assert isinstance(exc, trio.Cancelled) elif isinstance(err, tractor.RemoteActorError): assert err.type == err_type assert n.cancelled is True assert not n._children else: pytest.fail("Should have gotten a remote assertion error?")
async def main(): async with tractor.open_nursery() as nursery: for i in range(num_subactors): await nursery.run_in_actor(f'errorer{i}', assert_err, delay=delay)
async def test_caller_closes_ctx_after_callee_opens_stream( use_ctx_cancel_method: bool, ): 'caller context closes without using stream' async with tractor.open_nursery() as n: portal = await n.start_actor( 'ctx_cancelled', enable_modules=[__name__], ) async with portal.open_context( expect_cancelled, ) as (ctx, sent): await portal.run(assert_state, value=True) assert sent is None # call cancel explicitly if use_ctx_cancel_method: await ctx.cancel() try: async with ctx.open_stream() as stream: async for msg in stream: pass except tractor.ContextCancelled: raise # XXX: must be propagated to __aexit__ else: assert 0, "Should have context cancelled?" # channel should still be up assert portal.channel.connected() # ctx is closed here await portal.run(assert_state, value=False) else: try: with trio.fail_after(0.2): await ctx.result() assert 0, "Callee should have blocked!?" except trio.TooSlowError: await ctx.cancel() try: async with ctx.open_stream() as stream: async for msg in stream: pass except tractor.ContextCancelled: pass else: assert 0, "Should have received closed resource error?" # ctx is closed here await portal.run(assert_state, value=False) # channel should not have been destroyed yet, only the # inter-actor-task context assert portal.channel.connected() # teardown the actor await portal.cancel_actor()
async def spawn_router_stream_alerts( order_mode, symbol: Symbol, # lines: 'LinesEditor', task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, ) -> None: """Spawn an EMS daemon and begin sending orders and receiving alerts. """ actor = tractor.current_actor() subactor_name = 'emsd' # TODO: add ``maybe_spawn_emsd()`` for this async with tractor.open_nursery() as n: portal = await n.start_actor( subactor_name, enable_modules=[__name__], ) stream = await portal.run(stream_and_route, ui_name=actor.name) async with tractor.wait_for_actor(subactor_name): # let parent task continue task_status.started(_to_ems) # begin the trigger-alert stream # this is where we receive **back** messages # about executions **from** the EMS actor async for msg in stream: # delete the line from view oid = msg['oid'] resp = msg['msg'] if resp in ('active', ): print(f"order accepted: {msg}") # show line label once order is live order_mode.lines.commit_line(oid) continue elif resp in ('cancelled', ): # delete level from view order_mode.lines.remove_line(uuid=oid) print(f'deleting line with oid: {oid}') elif resp in ('executed', ): order_mode.lines.remove_line(uuid=oid) print(f'deleting line with oid: {oid}') order_mode.arrows.add( oid, msg['index'], msg['price'], pointing='up' if msg['name'] == 'up' else 'down') # DESKTOP NOTIFICATIONS # # TODO: this in another task? # not sure if this will ever be a bottleneck, # we probably could do graphics stuff first tho? # XXX: linux only for now result = await trio.run_process([ 'notify-send', '-u', 'normal', '-t', '10000', 'piker', f'alert: {msg}', ], ) log.runtime(result)
async def chart_from_fsp( linked_charts, fsp_func_name, sym, src_shm, brokermod, loglevel, ) -> None: """Start financial signal processing in subactor. Pass target entrypoint and historical data. """ name = f'fsp.{fsp_func_name}' # TODO: load function here and introspect # return stream type(s) # TODO: should `index` be a required internal field? fsp_dtype = np.dtype([('index', int), (fsp_func_name, float)]) async with tractor.open_nursery() as n: key = f'{sym}.' + name shm, opened = maybe_open_shm_array( key, # TODO: create entry for each time frame dtype=fsp_dtype, readonly=True, ) # XXX: fsp may have been opened by a duplicate chart. Error for # now until we figure out how to wrap fsps as "feeds". assert opened, f"A chart for {key} likely already exists?" # start fsp sub-actor portal = await n.run_in_actor( # name as title of sub-chart name, # subactor entrypoint fsp.cascade, brokername=brokermod.name, src_shm_token=src_shm.token, dst_shm_token=shm.token, symbol=sym, fsp_func_name=fsp_func_name, # tractor config loglevel=loglevel, ) stream = await portal.result() # receive last index for processed historical # data-array as first msg _ = await stream.receive() chart = linked_charts.add_plot( name=fsp_func_name, array=shm.array, # curve by default ohlc=False, # settings passed down to ``ChartPlotWidget`` static_yrange=(0, 100), ) # display contents labels asap chart.update_contents_labels(len(shm.array) - 1) array = shm.array value = array[fsp_func_name][-1] last_val_sticky = chart._ysticks[chart.name] last_val_sticky.update_from_data(-1, value) chart.update_curve_from_array(fsp_func_name, array) chart.default_view() # TODO: figure out if we can roll our own `FillToThreshold` to # get brush filled polygons for OS/OB conditions. # ``pg.FillBetweenItems`` seems to be one technique using # generic fills between curve types while ``PlotCurveItem`` has # logic inside ``.paint()`` for ``self.opts['fillLevel']`` which # might be the best solution? # graphics = chart.update_from_array(chart.name, array[fsp_func_name]) # graphics.curve.setBrush(50, 50, 200, 100) # graphics.curve.setFillLevel(50) # add moveable over-[sold/bought] lines level_line(chart, 30) level_line(chart, 70, orient_v='top') chart._shm = shm chart._set_yrange() # update chart graphics async for value in stream: # p = pg.debug.Profiler(disabled=False, delayed=False) array = shm.array value = array[-1][fsp_func_name] last_val_sticky.update_from_data(-1, value) chart.update_curve_from_array(fsp_func_name, array)
async def spawn(): async with tractor.open_nursery() as tn: await tn.run_in_actor( spin_for, name='sleeper', )
async def main(): ss = tractor.current_actor().statespace async with tractor.open_nursery() as n: name = 'arbiter' if pub_actor == 'streamer': # start the publisher as a daemon master_portal = await n.start_actor( 'streamer', rpc_module_paths=[__name__], ) even_portal = await n.run_in_actor('evens', subs, which=['even'], pub_actor_name=name) odd_portal = await n.run_in_actor('odds', subs, which=['odd'], pub_actor_name=name) async with tractor.wait_for_actor('evens'): # block until 2nd actor is initialized pass if pub_actor == 'arbiter': # wait for publisher task to be spawned in a local RPC task while not ss.get('get_topics'): await trio.sleep(0.1) get_topics = ss.get('get_topics') assert 'even' in get_topics() async with tractor.wait_for_actor('odds'): # block until 2nd actor is initialized pass if pub_actor == 'arbiter': start = time.time() while 'odd' not in get_topics(): await trio.sleep(0.1) if time.time() - start > 1: pytest.fail("odds subscription never arrived?") # TODO: how to make this work when the arbiter gets # a portal to itself? Currently this causes a hang # when the channel server is torn down due to a lingering # loopback channel # with trio.move_on_after(1): # await subs(['even', 'odd']) # XXX: this would cause infinite # blocking due to actor never terminating loop # await even_portal.result() await trio.sleep(0.5) await even_portal.cancel_actor() await trio.sleep(0.5) if pub_actor == 'arbiter': assert 'even' not in get_topics() await odd_portal.cancel_actor() await trio.sleep(1) if pub_actor == 'arbiter': while get_topics(): await trio.sleep(0.1) if time.time() - start > 1: pytest.fail("odds subscription never dropped?") else: await master_portal.cancel_actor()
async def test_respawn_consumer_task( arb_addr, spawn_backend, loglevel, ): """Verify that ``._portal.ReceiveStream.shield()`` sucessfully protects the underlying IPC channel from being closed when cancelling and respawning a consumer task. This also serves to verify that all values from the stream can be received despite the respawns. """ stream = None async with tractor.open_nursery() as n: portal = await n.start_actor(name='streamer', enable_modules=[__name__]) async with portal.open_stream_from( stream_data, seed=11, ) as stream: expect = set(range(11)) received = [] # this is the re-spawn task routine async def consume(task_status=trio.TASK_STATUS_IGNORED): print('starting consume task..') nonlocal stream with trio.CancelScope() as cs: task_status.started(cs) # shield stream's underlying channel from cancellation # with stream.shield(): async for v in stream: print(f'from stream: {v}') expect.remove(v) received.append(v) print('exited consume') async with trio.open_nursery() as ln: cs = await ln.start(consume) while True: await trio.sleep(0.1) if received[-1] % 2 == 0: print('cancelling consume task..') cs.cancel() # respawn cs = await ln.start(consume) if not expect: print("all values streamed, BREAKING") break cs.cancel() # TODO: this is justification for a # ``ActorNursery.stream_from_actor()`` helper? await portal.cancel_actor()