Compare commits
	
		
			87 Commits 
		
	
	
		
			23809b8468
			...
			05a02d97b4
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | 05a02d97b4 | |
|  | 3acf69be8b | |
|  | d6066705e3 | |
|  | 8519d4ff9e | |
|  | ed56eda684 | |
|  | 311a1e6d55 | |
|  | 49c35f06ae | |
|  | 7b7e872cbc | |
|  | 0978e23ca9 | |
|  | 2eec5a27ff | |
|  | 9427273ea1 | |
|  | 5e2b7c557e | |
|  | 8e162d7a70 | |
|  | 42e9076776 | |
|  | 2bb33da9c8 | |
|  | 06f7f2e06b | |
|  | a92d0ebf02 | |
|  | 8c8d79e475 | |
|  | 0ccf83d520 | |
|  | 1d54096379 | |
|  | fe23331365 | |
|  | 6445f1cde4 | |
|  | 112ed27cda | |
|  | 42cf9e11a4 | |
|  | 1ccb14455d | |
|  | d534f1491b | |
|  | 0f8b299b4f | |
|  | 9807318e3d | |
|  | b700d90e09 | |
|  | 6ff3b6c757 | |
|  | 8bda59c23d | |
|  | 1628fd1d7b | |
|  | 5f74ce9a95 | |
|  | 477343af53 | |
|  | c208bcbb1b | |
|  | c9e9a3949f | |
|  | 8fd7d1cec4 | |
|  | 0cb011e883 | |
|  | 74df5034c0 | |
|  | 692bd0edf6 | |
|  | c21b9cdf57 | |
|  | 0e25c16572 | |
|  | 1d4513eb5d | |
|  | 3d3a1959ed | |
|  | 9e812d7793 | |
|  | 789bb7145b | |
|  | b05c5b6c50 | |
|  | f6a4a0818f | |
|  | a045c78e4d | |
|  | c85606075d | |
|  | 7d200223fa | |
|  | 4244db2f08 | |
|  | 52901a8e7d | |
|  | eb11235ec8 | |
|  | c8d164b211 | |
|  | 00b5bb777d | |
|  | 674a33e3b1 | |
|  | a49bfddf32 | |
|  | e025959d60 | |
|  | d0414709f2 | |
|  | b958590212 | |
|  | 8884ed05f0 | |
|  | a403958c2c | |
|  | 009cadf28e | |
|  | 3cb8f9242d | |
|  | 544b5bdd9c | |
|  | 47d66e6c0b | |
|  | ddeab1355a | |
|  | cb6c10bbe9 | |
|  | bf9d7ba074 | |
|  | 4a8a555bdf | |
|  | 1762b3eb64 | |
|  | 486f4a3843 | |
|  | d5e0b08787 | |
|  | f80a47571a | |
|  | 9b2161506f | |
|  | 6b155849b7 | |
|  | 59c8c7bfe3 | |
|  | 6ac6fd56c0 | |
|  | f799e9ac51 | |
|  | 9980bb2bd0 | |
|  | 8de9ab291e | |
|  | 1a83626f26 | |
|  | 6b4d08d030 | |
|  | 7b8b9d6805 | |
|  | 5afe0a0264 | |
|  | eeb9a7d61b | 
|  | @ -0,0 +1,19 @@ | ||||||
|  | { pkgs ? import <nixpkgs> {} }: | ||||||
|  | let | ||||||
|  |   nativeBuildInputs = with pkgs; [ | ||||||
|  |     stdenv.cc.cc.lib | ||||||
|  |     uv | ||||||
|  |   ]; | ||||||
|  | 
 | ||||||
|  | in | ||||||
|  | pkgs.mkShell { | ||||||
|  |   inherit nativeBuildInputs; | ||||||
|  | 
 | ||||||
|  |   LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath nativeBuildInputs; | ||||||
|  |   TMPDIR = "/tmp"; | ||||||
|  | 
 | ||||||
|  |   shellHook = '' | ||||||
|  |     set -e | ||||||
|  |     uv venv .venv --python=3.12 | ||||||
|  |   ''; | ||||||
|  | } | ||||||
|  | @ -120,6 +120,7 @@ async def main( | ||||||
|     break_parent_ipc_after: int|bool = False, |     break_parent_ipc_after: int|bool = False, | ||||||
|     break_child_ipc_after: int|bool = False, |     break_child_ipc_after: int|bool = False, | ||||||
|     pre_close: bool = False, |     pre_close: bool = False, | ||||||
|  |     tpt_proto: str = 'tcp', | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
| 
 | 
 | ||||||
|  | @ -131,6 +132,7 @@ async def main( | ||||||
|             # a hang since it never engages due to broken IPC |             # a hang since it never engages due to broken IPC | ||||||
|             debug_mode=debug_mode, |             debug_mode=debug_mode, | ||||||
|             loglevel=loglevel, |             loglevel=loglevel, | ||||||
|  |             enable_transports=[tpt_proto], | ||||||
| 
 | 
 | ||||||
|         ) as an, |         ) as an, | ||||||
|     ): |     ): | ||||||
|  | @ -145,7 +147,8 @@ async def main( | ||||||
|             _testing.expect_ctxc( |             _testing.expect_ctxc( | ||||||
|                 yay=( |                 yay=( | ||||||
|                     break_parent_ipc_after |                     break_parent_ipc_after | ||||||
|                     or break_child_ipc_after |                     or | ||||||
|  |                     break_child_ipc_after | ||||||
|                 ), |                 ), | ||||||
|                 # TODO: we CAN'T remove this right? |                 # TODO: we CAN'T remove this right? | ||||||
|                 # since we need the ctxc to bubble up from either |                 # since we need the ctxc to bubble up from either | ||||||
|  |  | ||||||
|  | @ -9,7 +9,7 @@ async def main(service_name): | ||||||
|     async with tractor.open_nursery() as an: |     async with tractor.open_nursery() as an: | ||||||
|         await an.start_actor(service_name) |         await an.start_actor(service_name) | ||||||
| 
 | 
 | ||||||
|         async with tractor.get_registry('127.0.0.1', 1616) as portal: |         async with tractor.get_registry() as portal: | ||||||
|             print(f"Arbiter is listening on {portal.channel}") |             print(f"Arbiter is listening on {portal.channel}") | ||||||
| 
 | 
 | ||||||
|         async with tractor.wait_for_actor(service_name) as sockaddr: |         async with tractor.wait_for_actor(service_name) as sockaddr: | ||||||
|  |  | ||||||
|  | @ -45,6 +45,8 @@ dependencies = [ | ||||||
|   "pdbp>=1.6,<2", # windows only (from `pdbp`) |   "pdbp>=1.6,<2", # windows only (from `pdbp`) | ||||||
|   # typed IPC msging |   # typed IPC msging | ||||||
|   "msgspec>=0.19.0", |   "msgspec>=0.19.0", | ||||||
|  |   "cffi>=1.17.1", | ||||||
|  |   "bidict>=0.23.1", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| # ------ project ------ | # ------ project ------ | ||||||
|  | @ -62,6 +64,7 @@ dev = [ | ||||||
|   "pyperclip>=1.9.0", |   "pyperclip>=1.9.0", | ||||||
|   "prompt-toolkit>=3.0.50", |   "prompt-toolkit>=3.0.50", | ||||||
|   "xonsh>=0.19.2", |   "xonsh>=0.19.2", | ||||||
|  |   "psutil>=7.0.0", | ||||||
| ] | ] | ||||||
| # TODO, add these with sane versions; were originally in | # TODO, add these with sane versions; were originally in | ||||||
| # `requirements-docs.txt`.. | # `requirements-docs.txt`.. | ||||||
|  |  | ||||||
|  | @ -1,24 +1,27 @@ | ||||||
| """ | """ | ||||||
| ``tractor`` testing!! | Top level of the testing suites! | ||||||
|  | 
 | ||||||
| """ | """ | ||||||
|  | from __future__ import annotations | ||||||
| import sys | import sys | ||||||
| import subprocess | import subprocess | ||||||
| import os | import os | ||||||
| import random |  | ||||||
| import signal | import signal | ||||||
| import platform | import platform | ||||||
| import time | import time | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| import tractor |  | ||||||
| from tractor._testing import ( | from tractor._testing import ( | ||||||
|     examples_dir as examples_dir, |     examples_dir as examples_dir, | ||||||
|     tractor_test as tractor_test, |     tractor_test as tractor_test, | ||||||
|     expect_ctxc as expect_ctxc, |     expect_ctxc as expect_ctxc, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| # TODO: include wtv plugin(s) we build in `._testing.pytest`? | pytest_plugins: list[str] = [ | ||||||
| pytest_plugins = ['pytester'] |     'pytester', | ||||||
|  |     'tractor._testing.pytest', | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| # Sending signal.SIGINT on subprocess fails on windows. Use CTRL_* alternatives | # Sending signal.SIGINT on subprocess fails on windows. Use CTRL_* alternatives | ||||||
| if platform.system() == 'Windows': | if platform.system() == 'Windows': | ||||||
|  | @ -30,7 +33,11 @@ else: | ||||||
|     _KILL_SIGNAL = signal.SIGKILL |     _KILL_SIGNAL = signal.SIGKILL | ||||||
|     _INT_SIGNAL = signal.SIGINT |     _INT_SIGNAL = signal.SIGINT | ||||||
|     _INT_RETURN_CODE = 1 if sys.version_info < (3, 8) else -signal.SIGINT.value |     _INT_RETURN_CODE = 1 if sys.version_info < (3, 8) else -signal.SIGINT.value | ||||||
|     _PROC_SPAWN_WAIT = 0.6 if sys.version_info < (3, 7) else 0.4 |     _PROC_SPAWN_WAIT = ( | ||||||
|  |         0.6 | ||||||
|  |         if sys.version_info < (3, 7) | ||||||
|  |         else 0.4 | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| no_windows = pytest.mark.skipif( | no_windows = pytest.mark.skipif( | ||||||
|  | @ -39,7 +46,12 @@ no_windows = pytest.mark.skipif( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def pytest_addoption(parser): | def pytest_addoption( | ||||||
|  |     parser: pytest.Parser, | ||||||
|  | ): | ||||||
|  |     # ?TODO? should this be exposed from our `._testing.pytest` | ||||||
|  |     # plugin or should we make it more explicit with `--tl` for | ||||||
|  |     # tractor logging like we do in other client projects? | ||||||
|     parser.addoption( |     parser.addoption( | ||||||
|         "--ll", |         "--ll", | ||||||
|         action="store", |         action="store", | ||||||
|  | @ -47,42 +59,10 @@ def pytest_addoption(parser): | ||||||
|         default='ERROR', help="logging level to set when testing" |         default='ERROR', help="logging level to set when testing" | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     parser.addoption( |  | ||||||
|         "--spawn-backend", |  | ||||||
|         action="store", |  | ||||||
|         dest='spawn_backend', |  | ||||||
|         default='trio', |  | ||||||
|         help="Processing spawning backend to use for test run", |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     parser.addoption( |  | ||||||
|         "--tpdb", "--debug-mode", |  | ||||||
|         action="store_true", |  | ||||||
|         dest='tractor_debug_mode', |  | ||||||
|         # default=False, |  | ||||||
|         help=( |  | ||||||
|             'Enable a flag that can be used by tests to to set the ' |  | ||||||
|             '`debug_mode: bool` for engaging the internal ' |  | ||||||
|             'multi-proc debugger sys.' |  | ||||||
|         ), |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def pytest_configure(config): |  | ||||||
|     backend = config.option.spawn_backend |  | ||||||
|     tractor._spawn.try_set_start_method(backend) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @pytest.fixture(scope='session') |  | ||||||
| def debug_mode(request): |  | ||||||
|     debug_mode: bool = request.config.option.tractor_debug_mode |  | ||||||
|     # if debug_mode: |  | ||||||
|     #     breakpoint() |  | ||||||
|     return debug_mode |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| @pytest.fixture(scope='session', autouse=True) | @pytest.fixture(scope='session', autouse=True) | ||||||
| def loglevel(request): | def loglevel(request): | ||||||
|  |     import tractor | ||||||
|     orig = tractor.log._default_loglevel |     orig = tractor.log._default_loglevel | ||||||
|     level = tractor.log._default_loglevel = request.config.option.loglevel |     level = tractor.log._default_loglevel = request.config.option.loglevel | ||||||
|     tractor.log.get_console_log(level) |     tractor.log.get_console_log(level) | ||||||
|  | @ -90,106 +70,44 @@ def loglevel(request): | ||||||
|     tractor.log._default_loglevel = orig |     tractor.log._default_loglevel = orig | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.fixture(scope='session') |  | ||||||
| def spawn_backend(request) -> str: |  | ||||||
|     return request.config.option.spawn_backend |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| # @pytest.fixture(scope='function', autouse=True) |  | ||||||
| # def debug_enabled(request) -> str: |  | ||||||
| #     from tractor import _state |  | ||||||
| #     if _state._runtime_vars['_debug_mode']: |  | ||||||
| #         breakpoint() |  | ||||||
| 
 |  | ||||||
| _ci_env: bool = os.environ.get('CI', False) | _ci_env: bool = os.environ.get('CI', False) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.fixture(scope='session') | @pytest.fixture(scope='session') | ||||||
| def ci_env() -> bool: | def ci_env() -> bool: | ||||||
|     ''' |     ''' | ||||||
|     Detect CI envoirment. |     Detect CI environment. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     return _ci_env |     return _ci_env | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # TODO: also move this to `._testing` for now? | def sig_prog( | ||||||
| # -[ ] possibly generalize and re-use for multi-tree spawning |     proc: subprocess.Popen, | ||||||
| #    along with the new stuff for multi-addrs in distribute_dis |     sig: int, | ||||||
| #    branch? |     canc_timeout: float = 0.1, | ||||||
| # | ) -> int: | ||||||
| # choose randomly at import time |  | ||||||
| _reg_addr: tuple[str, int] = ( |  | ||||||
|     '127.0.0.1', |  | ||||||
|     random.randint(1000, 9999), |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @pytest.fixture(scope='session') |  | ||||||
| def reg_addr() -> tuple[str, int]: |  | ||||||
| 
 |  | ||||||
|     # globally override the runtime to the per-test-session-dynamic |  | ||||||
|     # addr so that all tests never conflict with any other actor |  | ||||||
|     # tree using the default. |  | ||||||
|     from tractor import _root |  | ||||||
|     _root._default_lo_addrs = [_reg_addr] |  | ||||||
| 
 |  | ||||||
|     return _reg_addr |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def pytest_generate_tests(metafunc): |  | ||||||
|     spawn_backend = metafunc.config.option.spawn_backend |  | ||||||
| 
 |  | ||||||
|     if not spawn_backend: |  | ||||||
|         # XXX some weird windows bug with `pytest`? |  | ||||||
|         spawn_backend = 'trio' |  | ||||||
| 
 |  | ||||||
|     # TODO: maybe just use the literal `._spawn.SpawnMethodKey`? |  | ||||||
|     assert spawn_backend in ( |  | ||||||
|         'mp_spawn', |  | ||||||
|         'mp_forkserver', |  | ||||||
|         'trio', |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     # NOTE: used to be used to dyanmically parametrize tests for when |  | ||||||
|     # you just passed --spawn-backend=`mp` on the cli, but now we expect |  | ||||||
|     # that cli input to be manually specified, BUT, maybe we'll do |  | ||||||
|     # something like this again in the future? |  | ||||||
|     if 'start_method' in metafunc.fixturenames: |  | ||||||
|         metafunc.parametrize("start_method", [spawn_backend], scope='module') |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| # TODO: a way to let test scripts (like from `examples/`) |  | ||||||
| # guarantee they won't registry addr collide! |  | ||||||
| # @pytest.fixture |  | ||||||
| # def open_test_runtime( |  | ||||||
| #     reg_addr: tuple, |  | ||||||
| # ) -> AsyncContextManager: |  | ||||||
| #     return partial( |  | ||||||
| #         tractor.open_nursery, |  | ||||||
| #         registry_addrs=[reg_addr], |  | ||||||
| #     ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def sig_prog(proc, sig): |  | ||||||
|     "Kill the actor-process with ``sig``." |     "Kill the actor-process with ``sig``." | ||||||
|     proc.send_signal(sig) |     proc.send_signal(sig) | ||||||
|     time.sleep(0.1) |     time.sleep(canc_timeout) | ||||||
|     if not proc.poll(): |     if not proc.poll(): | ||||||
|         # TODO: why sometimes does SIGINT not work on teardown? |         # TODO: why sometimes does SIGINT not work on teardown? | ||||||
|         # seems to happen only when trace logging enabled? |         # seems to happen only when trace logging enabled? | ||||||
|         proc.send_signal(_KILL_SIGNAL) |         proc.send_signal(_KILL_SIGNAL) | ||||||
|     ret = proc.wait() |     ret: int = proc.wait() | ||||||
|     assert ret |     assert ret | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # TODO: factor into @cm and move to `._testing`? | # TODO: factor into @cm and move to `._testing`? | ||||||
| @pytest.fixture | @pytest.fixture | ||||||
| def daemon( | def daemon( | ||||||
|  |     debug_mode: bool, | ||||||
|     loglevel: str, |     loglevel: str, | ||||||
|     testdir, |     testdir, | ||||||
|     reg_addr: tuple[str, int], |     reg_addr: tuple[str, int], | ||||||
| ): |     tpt_proto: str, | ||||||
|  | 
 | ||||||
|  | ) -> subprocess.Popen: | ||||||
|     ''' |     ''' | ||||||
|     Run a daemon root actor as a separate actor-process tree and |     Run a daemon root actor as a separate actor-process tree and | ||||||
|     "remote registrar" for discovery-protocol related tests. |     "remote registrar" for discovery-protocol related tests. | ||||||
|  | @ -200,28 +118,100 @@ def daemon( | ||||||
|         loglevel: str = 'info' |         loglevel: str = 'info' | ||||||
| 
 | 
 | ||||||
|     code: str = ( |     code: str = ( | ||||||
|             "import tractor; " |         "import tractor; " | ||||||
|             "tractor.run_daemon([], registry_addrs={reg_addrs}, loglevel={ll})" |         "tractor.run_daemon([], " | ||||||
|  |         "registry_addrs={reg_addrs}, " | ||||||
|  |         "debug_mode={debug_mode}, " | ||||||
|  |         "loglevel={ll})" | ||||||
|     ).format( |     ).format( | ||||||
|         reg_addrs=str([reg_addr]), |         reg_addrs=str([reg_addr]), | ||||||
|         ll="'{}'".format(loglevel) if loglevel else None, |         ll="'{}'".format(loglevel) if loglevel else None, | ||||||
|  |         debug_mode=debug_mode, | ||||||
|     ) |     ) | ||||||
|     cmd: list[str] = [ |     cmd: list[str] = [ | ||||||
|         sys.executable, |         sys.executable, | ||||||
|         '-c', code, |         '-c', code, | ||||||
|     ] |     ] | ||||||
|  |     # breakpoint() | ||||||
|     kwargs = {} |     kwargs = {} | ||||||
|     if platform.system() == 'Windows': |     if platform.system() == 'Windows': | ||||||
|         # without this, tests hang on windows forever |         # without this, tests hang on windows forever | ||||||
|         kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP |         kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP | ||||||
| 
 | 
 | ||||||
|     proc = testdir.popen( |     proc: subprocess.Popen = testdir.popen( | ||||||
|         cmd, |         cmd, | ||||||
|         stdout=subprocess.PIPE, |  | ||||||
|         stderr=subprocess.PIPE, |  | ||||||
|         **kwargs, |         **kwargs, | ||||||
|     ) |     ) | ||||||
|     assert not proc.returncode | 
 | ||||||
|  |     # UDS sockets are **really** fast to bind()/listen()/connect() | ||||||
|  |     # so it's often required that we delay a bit more starting | ||||||
|  |     # the first actor-tree.. | ||||||
|  |     if tpt_proto == 'uds': | ||||||
|  |         global _PROC_SPAWN_WAIT | ||||||
|  |         _PROC_SPAWN_WAIT = 0.6 | ||||||
|  | 
 | ||||||
|     time.sleep(_PROC_SPAWN_WAIT) |     time.sleep(_PROC_SPAWN_WAIT) | ||||||
|  | 
 | ||||||
|  |     assert not proc.returncode | ||||||
|     yield proc |     yield proc | ||||||
|     sig_prog(proc, _INT_SIGNAL) |     sig_prog(proc, _INT_SIGNAL) | ||||||
|  | 
 | ||||||
|  |     # XXX! yeah.. just be reaaal careful with this bc sometimes it | ||||||
|  |     # can lock up on the `_io.BufferedReader` and hang.. | ||||||
|  |     stderr: str = proc.stderr.read().decode() | ||||||
|  |     if stderr: | ||||||
|  |         print( | ||||||
|  |             f'Daemon actor tree produced STDERR:\n' | ||||||
|  |             f'{proc.args}\n' | ||||||
|  |             f'\n' | ||||||
|  |             f'{stderr}\n' | ||||||
|  |         ) | ||||||
|  |     if proc.returncode != -2: | ||||||
|  |         raise RuntimeError( | ||||||
|  |             'Daemon actor tree failed !?\n' | ||||||
|  |             f'{proc.args}\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # @pytest.fixture(autouse=True) | ||||||
|  | # def shared_last_failed(pytestconfig): | ||||||
|  | #     val = pytestconfig.cache.get("example/value", None) | ||||||
|  | #     breakpoint() | ||||||
|  | #     if val is None: | ||||||
|  | #         pytestconfig.cache.set("example/value", val) | ||||||
|  | #     return val | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: a way to let test scripts (like from `examples/`) | ||||||
|  | # guarantee they won't `registry_addrs` collide! | ||||||
|  | # -[ ] maybe use some kinda standard `def main()` arg-spec that | ||||||
|  | #     we can introspect from a fixture that is called from the test | ||||||
|  | #     body? | ||||||
|  | # -[ ] test and figure out typing for below prototype! Bp | ||||||
|  | # | ||||||
|  | # @pytest.fixture | ||||||
|  | # def set_script_runtime_args( | ||||||
|  | #     reg_addr: tuple, | ||||||
|  | # ) -> Callable[[...], None]: | ||||||
|  | 
 | ||||||
|  | #     def import_n_partial_in_args_n_triorun( | ||||||
|  | #         script: Path,  # under examples? | ||||||
|  | #         **runtime_args, | ||||||
|  | #     ) -> Callable[[], Any]:  # a `partial`-ed equiv of `trio.run()` | ||||||
|  | 
 | ||||||
|  | #         # NOTE, below is taken from | ||||||
|  | #         # `.test_advanced_faults.test_ipc_channel_break_during_stream` | ||||||
|  | #         mod: ModuleType = import_path( | ||||||
|  | #             examples_dir() / 'advanced_faults' | ||||||
|  | #             / 'ipc_failure_during_stream.py', | ||||||
|  | #             root=examples_dir(), | ||||||
|  | #             consider_namespace_packages=False, | ||||||
|  | #         ) | ||||||
|  | #         return partial( | ||||||
|  | #             trio.run, | ||||||
|  | #             partial( | ||||||
|  | #                 mod.main, | ||||||
|  | #                 **runtime_args, | ||||||
|  | #             ) | ||||||
|  | #         ) | ||||||
|  | #     return import_n_partial_in_args_n_triorun | ||||||
|  |  | ||||||
|  | @ -10,6 +10,9 @@ import pytest | ||||||
| from _pytest.pathlib import import_path | from _pytest.pathlib import import_path | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
|  | from tractor import ( | ||||||
|  |     TransportClosed, | ||||||
|  | ) | ||||||
| from tractor._testing import ( | from tractor._testing import ( | ||||||
|     examples_dir, |     examples_dir, | ||||||
|     break_ipc, |     break_ipc, | ||||||
|  | @ -74,6 +77,7 @@ def test_ipc_channel_break_during_stream( | ||||||
|     spawn_backend: str, |     spawn_backend: str, | ||||||
|     ipc_break: dict|None, |     ipc_break: dict|None, | ||||||
|     pre_aclose_msgstream: bool, |     pre_aclose_msgstream: bool, | ||||||
|  |     tpt_proto: str, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     Ensure we can have an IPC channel break its connection during |     Ensure we can have an IPC channel break its connection during | ||||||
|  | @ -91,7 +95,7 @@ def test_ipc_channel_break_during_stream( | ||||||
|         # non-`trio` spawners should never hit the hang condition that |         # non-`trio` spawners should never hit the hang condition that | ||||||
|         # requires the user to do ctl-c to cancel the actor tree. |         # requires the user to do ctl-c to cancel the actor tree. | ||||||
|         # expect_final_exc = trio.ClosedResourceError |         # expect_final_exc = trio.ClosedResourceError | ||||||
|         expect_final_exc = tractor.TransportClosed |         expect_final_exc = TransportClosed | ||||||
| 
 | 
 | ||||||
|     mod: ModuleType = import_path( |     mod: ModuleType = import_path( | ||||||
|         examples_dir() / 'advanced_faults' |         examples_dir() / 'advanced_faults' | ||||||
|  | @ -104,6 +108,8 @@ def test_ipc_channel_break_during_stream( | ||||||
|     # period" wherein the user eventually hits ctl-c to kill the |     # period" wherein the user eventually hits ctl-c to kill the | ||||||
|     # root-actor tree. |     # root-actor tree. | ||||||
|     expect_final_exc: BaseException = KeyboardInterrupt |     expect_final_exc: BaseException = KeyboardInterrupt | ||||||
|  |     expect_final_cause: BaseException|None = None | ||||||
|  | 
 | ||||||
|     if ( |     if ( | ||||||
|         # only expect EoC if trans is broken on the child side, |         # only expect EoC if trans is broken on the child side, | ||||||
|         ipc_break['break_child_ipc_after'] is not False |         ipc_break['break_child_ipc_after'] is not False | ||||||
|  | @ -138,6 +144,9 @@ def test_ipc_channel_break_during_stream( | ||||||
|         # a user sending ctl-c by raising a KBI. |         # a user sending ctl-c by raising a KBI. | ||||||
|         if pre_aclose_msgstream: |         if pre_aclose_msgstream: | ||||||
|             expect_final_exc = KeyboardInterrupt |             expect_final_exc = KeyboardInterrupt | ||||||
|  |             if tpt_proto == 'uds': | ||||||
|  |                 expect_final_exc = TransportClosed | ||||||
|  |                 expect_final_cause = trio.BrokenResourceError | ||||||
| 
 | 
 | ||||||
|             # XXX OLD XXX |             # XXX OLD XXX | ||||||
|             # if child calls `MsgStream.aclose()` then expect EoC. |             # if child calls `MsgStream.aclose()` then expect EoC. | ||||||
|  | @ -157,6 +166,10 @@ def test_ipc_channel_break_during_stream( | ||||||
|         if pre_aclose_msgstream: |         if pre_aclose_msgstream: | ||||||
|             expect_final_exc = KeyboardInterrupt |             expect_final_exc = KeyboardInterrupt | ||||||
| 
 | 
 | ||||||
|  |             if tpt_proto == 'uds': | ||||||
|  |                 expect_final_exc = TransportClosed | ||||||
|  |                 expect_final_cause = trio.BrokenResourceError | ||||||
|  | 
 | ||||||
|     # NOTE when the parent IPC side dies (even if the child does as well |     # NOTE when the parent IPC side dies (even if the child does as well | ||||||
|     # but the child fails BEFORE the parent) we always expect the |     # but the child fails BEFORE the parent) we always expect the | ||||||
|     # IPC layer to raise a closed-resource, NEVER do we expect |     # IPC layer to raise a closed-resource, NEVER do we expect | ||||||
|  | @ -169,8 +182,8 @@ def test_ipc_channel_break_during_stream( | ||||||
|         and |         and | ||||||
|         ipc_break['break_child_ipc_after'] is False |         ipc_break['break_child_ipc_after'] is False | ||||||
|     ): |     ): | ||||||
|         # expect_final_exc = trio.ClosedResourceError |  | ||||||
|         expect_final_exc = tractor.TransportClosed |         expect_final_exc = tractor.TransportClosed | ||||||
|  |         expect_final_cause = trio.ClosedResourceError | ||||||
| 
 | 
 | ||||||
|     # BOTH but, PARENT breaks FIRST |     # BOTH but, PARENT breaks FIRST | ||||||
|     elif ( |     elif ( | ||||||
|  | @ -181,8 +194,8 @@ def test_ipc_channel_break_during_stream( | ||||||
|             ipc_break['break_parent_ipc_after'] |             ipc_break['break_parent_ipc_after'] | ||||||
|         ) |         ) | ||||||
|     ): |     ): | ||||||
|         # expect_final_exc = trio.ClosedResourceError |  | ||||||
|         expect_final_exc = tractor.TransportClosed |         expect_final_exc = tractor.TransportClosed | ||||||
|  |         expect_final_cause = trio.ClosedResourceError | ||||||
| 
 | 
 | ||||||
|     with pytest.raises( |     with pytest.raises( | ||||||
|         expected_exception=( |         expected_exception=( | ||||||
|  | @ -198,6 +211,7 @@ def test_ipc_channel_break_during_stream( | ||||||
|                     start_method=spawn_backend, |                     start_method=spawn_backend, | ||||||
|                     loglevel=loglevel, |                     loglevel=loglevel, | ||||||
|                     pre_close=pre_aclose_msgstream, |                     pre_close=pre_aclose_msgstream, | ||||||
|  |                     tpt_proto=tpt_proto, | ||||||
|                     **ipc_break, |                     **ipc_break, | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|  | @ -220,10 +234,15 @@ def test_ipc_channel_break_during_stream( | ||||||
|                 ) |                 ) | ||||||
|             cause: Exception = tc.__cause__ |             cause: Exception = tc.__cause__ | ||||||
|             assert ( |             assert ( | ||||||
|                 type(cause) is trio.ClosedResourceError |                 # type(cause) is trio.ClosedResourceError | ||||||
|                 and |                 type(cause) is expect_final_cause | ||||||
|                 cause.args[0] == 'another task closed this fd' | 
 | ||||||
|  |                 # TODO, should we expect a certain exc-message (per | ||||||
|  |                 # tpt) as well?? | ||||||
|  |                 # and | ||||||
|  |                 # cause.args[0] == 'another task closed this fd' | ||||||
|             ) |             ) | ||||||
|  | 
 | ||||||
|             raise |             raise | ||||||
| 
 | 
 | ||||||
|     # get raw instance from pytest wrapper |     # get raw instance from pytest wrapper | ||||||
|  |  | ||||||
|  | @ -7,7 +7,9 @@ import platform | ||||||
| from functools import partial | from functools import partial | ||||||
| import itertools | import itertools | ||||||
| 
 | 
 | ||||||
|  | import psutil | ||||||
| import pytest | import pytest | ||||||
|  | import subprocess | ||||||
| import tractor | import tractor | ||||||
| from tractor._testing import tractor_test | from tractor._testing import tractor_test | ||||||
| import trio | import trio | ||||||
|  | @ -26,7 +28,7 @@ async def test_reg_then_unreg(reg_addr): | ||||||
|         portal = await n.start_actor('actor', enable_modules=[__name__]) |         portal = await n.start_actor('actor', enable_modules=[__name__]) | ||||||
|         uid = portal.channel.uid |         uid = portal.channel.uid | ||||||
| 
 | 
 | ||||||
|         async with tractor.get_registry(*reg_addr) as aportal: |         async with tractor.get_registry(reg_addr) as aportal: | ||||||
|             # this local actor should be the arbiter |             # this local actor should be the arbiter | ||||||
|             assert actor is aportal.actor |             assert actor is aportal.actor | ||||||
| 
 | 
 | ||||||
|  | @ -152,15 +154,25 @@ async def unpack_reg(actor_or_portal): | ||||||
| async def spawn_and_check_registry( | async def spawn_and_check_registry( | ||||||
|     reg_addr: tuple, |     reg_addr: tuple, | ||||||
|     use_signal: bool, |     use_signal: bool, | ||||||
|  |     debug_mode: bool = False, | ||||||
|     remote_arbiter: bool = False, |     remote_arbiter: bool = False, | ||||||
|     with_streaming: bool = False, |     with_streaming: bool = False, | ||||||
|  |     maybe_daemon: tuple[ | ||||||
|  |         subprocess.Popen, | ||||||
|  |         psutil.Process, | ||||||
|  |     ]|None = None, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
| 
 | 
 | ||||||
|  |     if maybe_daemon: | ||||||
|  |         popen, proc = maybe_daemon | ||||||
|  |         # breakpoint() | ||||||
|  | 
 | ||||||
|     async with tractor.open_root_actor( |     async with tractor.open_root_actor( | ||||||
|         registry_addrs=[reg_addr], |         registry_addrs=[reg_addr], | ||||||
|  |         debug_mode=debug_mode, | ||||||
|     ): |     ): | ||||||
|         async with tractor.get_registry(*reg_addr) as portal: |         async with tractor.get_registry(reg_addr) as portal: | ||||||
|             # runtime needs to be up to call this |             # runtime needs to be up to call this | ||||||
|             actor = tractor.current_actor() |             actor = tractor.current_actor() | ||||||
| 
 | 
 | ||||||
|  | @ -176,11 +188,11 @@ async def spawn_and_check_registry( | ||||||
|                 extra = 2  # local root actor + remote arbiter |                 extra = 2  # local root actor + remote arbiter | ||||||
| 
 | 
 | ||||||
|             # ensure current actor is registered |             # ensure current actor is registered | ||||||
|             registry = await get_reg() |             registry: dict = await get_reg() | ||||||
|             assert actor.uid in registry |             assert actor.uid in registry | ||||||
| 
 | 
 | ||||||
|             try: |             try: | ||||||
|                 async with tractor.open_nursery() as n: |                 async with tractor.open_nursery() as an: | ||||||
|                     async with trio.open_nursery( |                     async with trio.open_nursery( | ||||||
|                         strict_exception_groups=False, |                         strict_exception_groups=False, | ||||||
|                     ) as trion: |                     ) as trion: | ||||||
|  | @ -189,17 +201,17 @@ async def spawn_and_check_registry( | ||||||
|                         for i in range(3): |                         for i in range(3): | ||||||
|                             name = f'a{i}' |                             name = f'a{i}' | ||||||
|                             if with_streaming: |                             if with_streaming: | ||||||
|                                 portals[name] = await n.start_actor( |                                 portals[name] = await an.start_actor( | ||||||
|                                     name=name, enable_modules=[__name__]) |                                     name=name, enable_modules=[__name__]) | ||||||
| 
 | 
 | ||||||
|                             else:  # no streaming |                             else:  # no streaming | ||||||
|                                 portals[name] = await n.run_in_actor( |                                 portals[name] = await an.run_in_actor( | ||||||
|                                     trio.sleep_forever, name=name) |                                     trio.sleep_forever, name=name) | ||||||
| 
 | 
 | ||||||
|                         # wait on last actor to come up |                         # wait on last actor to come up | ||||||
|                         async with tractor.wait_for_actor(name): |                         async with tractor.wait_for_actor(name): | ||||||
|                             registry = await get_reg() |                             registry = await get_reg() | ||||||
|                             for uid in n._children: |                             for uid in an._children: | ||||||
|                                 assert uid in registry |                                 assert uid in registry | ||||||
| 
 | 
 | ||||||
|                         assert len(portals) + extra == len(registry) |                         assert len(portals) + extra == len(registry) | ||||||
|  | @ -232,6 +244,7 @@ async def spawn_and_check_registry( | ||||||
| @pytest.mark.parametrize('use_signal', [False, True]) | @pytest.mark.parametrize('use_signal', [False, True]) | ||||||
| @pytest.mark.parametrize('with_streaming', [False, True]) | @pytest.mark.parametrize('with_streaming', [False, True]) | ||||||
| def test_subactors_unregister_on_cancel( | def test_subactors_unregister_on_cancel( | ||||||
|  |     debug_mode: bool, | ||||||
|     start_method, |     start_method, | ||||||
|     use_signal, |     use_signal, | ||||||
|     reg_addr, |     reg_addr, | ||||||
|  | @ -248,6 +261,7 @@ def test_subactors_unregister_on_cancel( | ||||||
|                 spawn_and_check_registry, |                 spawn_and_check_registry, | ||||||
|                 reg_addr, |                 reg_addr, | ||||||
|                 use_signal, |                 use_signal, | ||||||
|  |                 debug_mode=debug_mode, | ||||||
|                 remote_arbiter=False, |                 remote_arbiter=False, | ||||||
|                 with_streaming=with_streaming, |                 with_streaming=with_streaming, | ||||||
|             ), |             ), | ||||||
|  | @ -257,7 +271,8 @@ def test_subactors_unregister_on_cancel( | ||||||
| @pytest.mark.parametrize('use_signal', [False, True]) | @pytest.mark.parametrize('use_signal', [False, True]) | ||||||
| @pytest.mark.parametrize('with_streaming', [False, True]) | @pytest.mark.parametrize('with_streaming', [False, True]) | ||||||
| def test_subactors_unregister_on_cancel_remote_daemon( | def test_subactors_unregister_on_cancel_remote_daemon( | ||||||
|     daemon, |     daemon: subprocess.Popen, | ||||||
|  |     debug_mode: bool, | ||||||
|     start_method, |     start_method, | ||||||
|     use_signal, |     use_signal, | ||||||
|     reg_addr, |     reg_addr, | ||||||
|  | @ -273,8 +288,13 @@ def test_subactors_unregister_on_cancel_remote_daemon( | ||||||
|                 spawn_and_check_registry, |                 spawn_and_check_registry, | ||||||
|                 reg_addr, |                 reg_addr, | ||||||
|                 use_signal, |                 use_signal, | ||||||
|  |                 debug_mode=debug_mode, | ||||||
|                 remote_arbiter=True, |                 remote_arbiter=True, | ||||||
|                 with_streaming=with_streaming, |                 with_streaming=with_streaming, | ||||||
|  |                 maybe_daemon=( | ||||||
|  |                     daemon, | ||||||
|  |                     psutil.Process(daemon.pid) | ||||||
|  |                 ), | ||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  | @ -300,7 +320,7 @@ async def close_chans_before_nursery( | ||||||
|     async with tractor.open_root_actor( |     async with tractor.open_root_actor( | ||||||
|         registry_addrs=[reg_addr], |         registry_addrs=[reg_addr], | ||||||
|     ): |     ): | ||||||
|         async with tractor.get_registry(*reg_addr) as aportal: |         async with tractor.get_registry(reg_addr) as aportal: | ||||||
|             try: |             try: | ||||||
|                 get_reg = partial(unpack_reg, aportal) |                 get_reg = partial(unpack_reg, aportal) | ||||||
| 
 | 
 | ||||||
|  | @ -373,7 +393,7 @@ def test_close_channel_explicit( | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize('use_signal', [False, True]) | @pytest.mark.parametrize('use_signal', [False, True]) | ||||||
| def test_close_channel_explicit_remote_arbiter( | def test_close_channel_explicit_remote_arbiter( | ||||||
|     daemon, |     daemon: subprocess.Popen, | ||||||
|     start_method, |     start_method, | ||||||
|     use_signal, |     use_signal, | ||||||
|     reg_addr, |     reg_addr, | ||||||
|  |  | ||||||
|  | @ -66,6 +66,9 @@ def run_example_in_subproc( | ||||||
|         # due to backpressure!!! |         # due to backpressure!!! | ||||||
|         proc = testdir.popen( |         proc = testdir.popen( | ||||||
|             cmdargs, |             cmdargs, | ||||||
|  |             stdin=subprocess.PIPE, | ||||||
|  |             stdout=subprocess.PIPE, | ||||||
|  |             stderr=subprocess.PIPE, | ||||||
|             **kwargs, |             **kwargs, | ||||||
|         ) |         ) | ||||||
|         assert not proc.returncode |         assert not proc.returncode | ||||||
|  | @ -119,10 +122,14 @@ def test_example( | ||||||
|         code = ex.read() |         code = ex.read() | ||||||
| 
 | 
 | ||||||
|         with run_example_in_subproc(code) as proc: |         with run_example_in_subproc(code) as proc: | ||||||
|             proc.wait() |             err = None | ||||||
|             err, _ = proc.stderr.read(), proc.stdout.read() |             try: | ||||||
|             # print(f'STDERR: {err}') |                 if not proc.poll(): | ||||||
|             # print(f'STDOUT: {out}') |                     _, err = proc.communicate(timeout=15) | ||||||
|  | 
 | ||||||
|  |             except subprocess.TimeoutExpired as e: | ||||||
|  |                 proc.kill() | ||||||
|  |                 err = e.stderr | ||||||
| 
 | 
 | ||||||
|             # if we get some gnarly output let's aggregate and raise |             # if we get some gnarly output let's aggregate and raise | ||||||
|             if err: |             if err: | ||||||
|  |  | ||||||
|  | @ -871,7 +871,7 @@ async def serve_subactors( | ||||||
|                 ) |                 ) | ||||||
|                 await ipc.send(( |                 await ipc.send(( | ||||||
|                     peer.chan.uid, |                     peer.chan.uid, | ||||||
|                     peer.chan.raddr, |                     peer.chan.raddr.unwrap(), | ||||||
|                 )) |                 )) | ||||||
| 
 | 
 | ||||||
|         print('Spawner exiting spawn serve loop!') |         print('Spawner exiting spawn serve loop!') | ||||||
|  |  | ||||||
|  | @ -38,7 +38,7 @@ async def test_self_is_registered_localportal(reg_addr): | ||||||
|     "Verify waiting on the arbiter to register itself using a local portal." |     "Verify waiting on the arbiter to register itself using a local portal." | ||||||
|     actor = tractor.current_actor() |     actor = tractor.current_actor() | ||||||
|     assert actor.is_arbiter |     assert actor.is_arbiter | ||||||
|     async with tractor.get_registry(*reg_addr) as portal: |     async with tractor.get_registry(reg_addr) as portal: | ||||||
|         assert isinstance(portal, tractor._portal.LocalPortal) |         assert isinstance(portal, tractor._portal.LocalPortal) | ||||||
| 
 | 
 | ||||||
|         with trio.fail_after(0.2): |         with trio.fail_after(0.2): | ||||||
|  |  | ||||||
|  | @ -32,7 +32,7 @@ def test_abort_on_sigint(daemon): | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_cancel_remote_arbiter(daemon, reg_addr): | async def test_cancel_remote_arbiter(daemon, reg_addr): | ||||||
|     assert not tractor.current_actor().is_arbiter |     assert not tractor.current_actor().is_arbiter | ||||||
|     async with tractor.get_registry(*reg_addr) as portal: |     async with tractor.get_registry(reg_addr) as portal: | ||||||
|         await portal.cancel_actor() |         await portal.cancel_actor() | ||||||
| 
 | 
 | ||||||
|     time.sleep(0.1) |     time.sleep(0.1) | ||||||
|  | @ -41,7 +41,7 @@ async def test_cancel_remote_arbiter(daemon, reg_addr): | ||||||
| 
 | 
 | ||||||
|     # no arbiter socket should exist |     # no arbiter socket should exist | ||||||
|     with pytest.raises(OSError): |     with pytest.raises(OSError): | ||||||
|         async with tractor.get_registry(*reg_addr) as portal: |         async with tractor.get_registry(reg_addr) as portal: | ||||||
|             pass |             pass | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -100,16 +100,29 @@ async def streamer( | ||||||
| @acm | @acm | ||||||
| async def open_stream() -> Awaitable[tractor.MsgStream]: | async def open_stream() -> Awaitable[tractor.MsgStream]: | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_nursery() as tn: |     try: | ||||||
|         portal = await tn.start_actor('streamer', enable_modules=[__name__]) |         async with tractor.open_nursery() as an: | ||||||
|         async with ( |             portal = await an.start_actor( | ||||||
|             portal.open_context(streamer) as (ctx, first), |                 'streamer', | ||||||
|             ctx.open_stream() as stream, |                 enable_modules=[__name__], | ||||||
|         ): |             ) | ||||||
|             yield stream |             async with ( | ||||||
|  |                 portal.open_context(streamer) as (ctx, first), | ||||||
|  |                 ctx.open_stream() as stream, | ||||||
|  |             ): | ||||||
|  |                 yield stream | ||||||
| 
 | 
 | ||||||
|         await portal.cancel_actor() |             print('Cancelling streamer') | ||||||
|     print('CANCELLED STREAMER') |             await portal.cancel_actor() | ||||||
|  |             print('Cancelled streamer') | ||||||
|  | 
 | ||||||
|  |     except Exception as err: | ||||||
|  |         print( | ||||||
|  |             f'`open_stream()` errored?\n' | ||||||
|  |             f'{err!r}\n' | ||||||
|  |         ) | ||||||
|  |         await tractor.pause(shield=True) | ||||||
|  |         raise err | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @acm | @acm | ||||||
|  | @ -132,19 +145,28 @@ async def maybe_open_stream(taskname: str): | ||||||
|             yield stream |             yield stream | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_open_local_sub_to_stream(): | def test_open_local_sub_to_stream( | ||||||
|  |     debug_mode: bool, | ||||||
|  | ): | ||||||
|     ''' |     ''' | ||||||
|     Verify a single inter-actor stream can can be fanned-out shared to |     Verify a single inter-actor stream can can be fanned-out shared to | ||||||
|     N local tasks using ``trionics.maybe_open_context():``. |     N local tasks using `trionics.maybe_open_context()`. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     timeout: float = 3.6 if platform.system() != "Windows" else 10 |     timeout: float = 3.6 | ||||||
|  |     if platform.system() == "Windows": | ||||||
|  |         timeout: float = 10 | ||||||
|  | 
 | ||||||
|  |     if debug_mode: | ||||||
|  |         timeout = 999 | ||||||
| 
 | 
 | ||||||
|     async def main(): |     async def main(): | ||||||
| 
 | 
 | ||||||
|         full = list(range(1000)) |         full = list(range(1000)) | ||||||
| 
 | 
 | ||||||
|         async def get_sub_and_pull(taskname: str): |         async def get_sub_and_pull(taskname: str): | ||||||
|  | 
 | ||||||
|  |             stream: tractor.MsgStream | ||||||
|             async with ( |             async with ( | ||||||
|                 maybe_open_stream(taskname) as stream, |                 maybe_open_stream(taskname) as stream, | ||||||
|             ): |             ): | ||||||
|  | @ -165,17 +187,27 @@ def test_open_local_sub_to_stream(): | ||||||
|                 assert set(seq).issubset(set(full)) |                 assert set(seq).issubset(set(full)) | ||||||
|             print(f'{taskname} finished') |             print(f'{taskname} finished') | ||||||
| 
 | 
 | ||||||
|         with trio.fail_after(timeout): |         with trio.fail_after(timeout) as cs: | ||||||
|             # TODO: turns out this isn't multi-task entrant XD |             # TODO: turns out this isn't multi-task entrant XD | ||||||
|             # We probably need an indepotent entry semantic? |             # We probably need an indepotent entry semantic? | ||||||
|             async with tractor.open_root_actor(): |             async with tractor.open_root_actor( | ||||||
|  |                 debug_mode=debug_mode, | ||||||
|  |             ): | ||||||
|                 async with ( |                 async with ( | ||||||
|                     trio.open_nursery() as nurse, |                     trio.open_nursery() as tn, | ||||||
|                 ): |                 ): | ||||||
|                     for i in range(10): |                     for i in range(10): | ||||||
|                         nurse.start_soon(get_sub_and_pull, f'task_{i}') |                         tn.start_soon( | ||||||
|  |                             get_sub_and_pull, | ||||||
|  |                             f'task_{i}', | ||||||
|  |                         ) | ||||||
|                         await trio.sleep(0.001) |                         await trio.sleep(0.001) | ||||||
| 
 | 
 | ||||||
|                 print('all consumer tasks finished') |                 print('all consumer tasks finished') | ||||||
| 
 | 
 | ||||||
|  |         if cs.cancelled_caught: | ||||||
|  |             pytest.fail( | ||||||
|  |                 'Should NOT time out in `open_root_actor()` ?' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|     trio.run(main) |     trio.run(main) | ||||||
|  |  | ||||||
|  | @ -0,0 +1,211 @@ | ||||||
|  | import time | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | import pytest | ||||||
|  | 
 | ||||||
|  | import tractor | ||||||
|  | from tractor.ipc._ringbuf import ( | ||||||
|  |     open_ringbuf, | ||||||
|  |     RBToken, | ||||||
|  |     RingBuffSender, | ||||||
|  |     RingBuffReceiver | ||||||
|  | ) | ||||||
|  | from tractor._testing.samples import ( | ||||||
|  |     generate_sample_messages, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | # in case you don't want to melt your cores, uncomment dis! | ||||||
|  | pytestmark = pytest.mark.skip | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def child_read_shm( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  |     msg_amount: int, | ||||||
|  |     token: RBToken, | ||||||
|  |     total_bytes: int, | ||||||
|  | ) -> None: | ||||||
|  |     recvd_bytes = 0 | ||||||
|  |     await ctx.started() | ||||||
|  |     start_ts = time.time() | ||||||
|  |     async with RingBuffReceiver(token) as receiver: | ||||||
|  |         while recvd_bytes < total_bytes: | ||||||
|  |             msg = await receiver.receive_some() | ||||||
|  |             recvd_bytes += len(msg) | ||||||
|  | 
 | ||||||
|  |         # make sure we dont hold any memoryviews | ||||||
|  |         # before the ctx manager aclose() | ||||||
|  |         msg = None | ||||||
|  | 
 | ||||||
|  |     end_ts = time.time() | ||||||
|  |     elapsed = end_ts - start_ts | ||||||
|  |     elapsed_ms = int(elapsed * 1000) | ||||||
|  | 
 | ||||||
|  |     print(f'\n\telapsed ms: {elapsed_ms}') | ||||||
|  |     print(f'\tmsg/sec: {int(msg_amount / elapsed):,}') | ||||||
|  |     print(f'\tbytes/sec: {int(recvd_bytes / elapsed):,}') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def child_write_shm( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  |     msg_amount: int, | ||||||
|  |     rand_min: int, | ||||||
|  |     rand_max: int, | ||||||
|  |     token: RBToken, | ||||||
|  | ) -> None: | ||||||
|  |     msgs, total_bytes = generate_sample_messages( | ||||||
|  |         msg_amount, | ||||||
|  |         rand_min=rand_min, | ||||||
|  |         rand_max=rand_max, | ||||||
|  |     ) | ||||||
|  |     await ctx.started(total_bytes) | ||||||
|  |     async with RingBuffSender(token) as sender: | ||||||
|  |         for msg in msgs: | ||||||
|  |             await sender.send_all(msg) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'msg_amount,rand_min,rand_max,buf_size', | ||||||
|  |     [ | ||||||
|  |         # simple case, fixed payloads, large buffer | ||||||
|  |         (100_000, 0, 0, 10 * 1024), | ||||||
|  | 
 | ||||||
|  |         # guaranteed wrap around on every write | ||||||
|  |         (100, 10 * 1024, 20 * 1024, 10 * 1024), | ||||||
|  | 
 | ||||||
|  |         # large payload size, but large buffer | ||||||
|  |         (10_000, 256 * 1024, 512 * 1024, 10 * 1024 * 1024) | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'fixed_payloads_large_buffer', | ||||||
|  |         'wrap_around_every_write', | ||||||
|  |         'large_payloads_large_buffer', | ||||||
|  |     ] | ||||||
|  | ) | ||||||
|  | def test_ringbuf( | ||||||
|  |     msg_amount: int, | ||||||
|  |     rand_min: int, | ||||||
|  |     rand_max: int, | ||||||
|  |     buf_size: int | ||||||
|  | ): | ||||||
|  |     async def main(): | ||||||
|  |         with open_ringbuf( | ||||||
|  |             'test_ringbuf', | ||||||
|  |             buf_size=buf_size | ||||||
|  |         ) as token: | ||||||
|  |             proc_kwargs = { | ||||||
|  |                 'pass_fds': (token.write_eventfd, token.wrap_eventfd) | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             common_kwargs = { | ||||||
|  |                 'msg_amount': msg_amount, | ||||||
|  |                 'token': token, | ||||||
|  |             } | ||||||
|  |             async with tractor.open_nursery() as an: | ||||||
|  |                 send_p = await an.start_actor( | ||||||
|  |                     'ring_sender', | ||||||
|  |                     enable_modules=[__name__], | ||||||
|  |                     proc_kwargs=proc_kwargs | ||||||
|  |                 ) | ||||||
|  |                 recv_p = await an.start_actor( | ||||||
|  |                     'ring_receiver', | ||||||
|  |                     enable_modules=[__name__], | ||||||
|  |                     proc_kwargs=proc_kwargs | ||||||
|  |                 ) | ||||||
|  |                 async with ( | ||||||
|  |                     send_p.open_context( | ||||||
|  |                         child_write_shm, | ||||||
|  |                         rand_min=rand_min, | ||||||
|  |                         rand_max=rand_max, | ||||||
|  |                         **common_kwargs | ||||||
|  |                     ) as (sctx, total_bytes), | ||||||
|  |                     recv_p.open_context( | ||||||
|  |                         child_read_shm, | ||||||
|  |                         **common_kwargs, | ||||||
|  |                         total_bytes=total_bytes, | ||||||
|  |                     ) as (sctx, _sent), | ||||||
|  |                 ): | ||||||
|  |                     await recv_p.result() | ||||||
|  | 
 | ||||||
|  |                 await send_p.cancel_actor() | ||||||
|  |                 await recv_p.cancel_actor() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def child_blocked_receiver( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  |     token: RBToken | ||||||
|  | ): | ||||||
|  |     async with RingBuffReceiver(token) as receiver: | ||||||
|  |         await ctx.started() | ||||||
|  |         await receiver.receive_some() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_ring_reader_cancel(): | ||||||
|  |     async def main(): | ||||||
|  |         with open_ringbuf('test_ring_cancel_reader') as token: | ||||||
|  |             async with ( | ||||||
|  |                 tractor.open_nursery() as an, | ||||||
|  |                 RingBuffSender(token) as _sender, | ||||||
|  |             ): | ||||||
|  |                 recv_p = await an.start_actor( | ||||||
|  |                     'ring_blocked_receiver', | ||||||
|  |                     enable_modules=[__name__], | ||||||
|  |                     proc_kwargs={ | ||||||
|  |                         'pass_fds': (token.write_eventfd, token.wrap_eventfd) | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |                 async with ( | ||||||
|  |                     recv_p.open_context( | ||||||
|  |                         child_blocked_receiver, | ||||||
|  |                         token=token | ||||||
|  |                     ) as (sctx, _sent), | ||||||
|  |                 ): | ||||||
|  |                     await trio.sleep(1) | ||||||
|  |                     await an.cancel() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(tractor._exceptions.ContextCancelled): | ||||||
|  |         trio.run(main) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def child_blocked_sender( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  |     token: RBToken | ||||||
|  | ): | ||||||
|  |     async with RingBuffSender(token) as sender: | ||||||
|  |         await ctx.started() | ||||||
|  |         await sender.send_all(b'this will wrap') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_ring_sender_cancel(): | ||||||
|  |     async def main(): | ||||||
|  |         with open_ringbuf( | ||||||
|  |             'test_ring_cancel_sender', | ||||||
|  |             buf_size=1 | ||||||
|  |         ) as token: | ||||||
|  |             async with tractor.open_nursery() as an: | ||||||
|  |                 recv_p = await an.start_actor( | ||||||
|  |                     'ring_blocked_sender', | ||||||
|  |                     enable_modules=[__name__], | ||||||
|  |                     proc_kwargs={ | ||||||
|  |                         'pass_fds': (token.write_eventfd, token.wrap_eventfd) | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |                 async with ( | ||||||
|  |                     recv_p.open_context( | ||||||
|  |                         child_blocked_sender, | ||||||
|  |                         token=token | ||||||
|  |                     ) as (sctx, _sent), | ||||||
|  |                 ): | ||||||
|  |                     await trio.sleep(1) | ||||||
|  |                     await an.cancel() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(tractor._exceptions.ContextCancelled): | ||||||
|  |         trio.run(main) | ||||||
|  | @ -0,0 +1,108 @@ | ||||||
|  | ''' | ||||||
|  | Runtime boot/init sanity. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | 
 | ||||||
|  | import pytest | ||||||
|  | import trio | ||||||
|  | 
 | ||||||
|  | import tractor | ||||||
|  | from tractor._exceptions import RuntimeFailure | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def open_new_root_in_sub( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  | ) -> None: | ||||||
|  | 
 | ||||||
|  |     async with tractor.open_root_actor(): | ||||||
|  |         pass | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'open_root_in', | ||||||
|  |     ['root', 'sub'], | ||||||
|  |     ids='open_2nd_root_in={}'.format, | ||||||
|  | ) | ||||||
|  | def test_only_one_root_actor( | ||||||
|  |     open_root_in: str, | ||||||
|  |     reg_addr: tuple, | ||||||
|  |     debug_mode: bool | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify we specially fail whenever more then one root actor | ||||||
|  |     is attempted to be opened within an already opened tree. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     async def main(): | ||||||
|  |         async with tractor.open_nursery() as an: | ||||||
|  | 
 | ||||||
|  |             if open_root_in == 'root': | ||||||
|  |                 async with tractor.open_root_actor( | ||||||
|  |                     registry_addrs=[reg_addr], | ||||||
|  |                 ): | ||||||
|  |                     pass | ||||||
|  | 
 | ||||||
|  |             ptl: tractor.Portal = await an.start_actor( | ||||||
|  |                 name='bad_rooty_boi', | ||||||
|  |                 enable_modules=[__name__], | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             async with ptl.open_context( | ||||||
|  |                 open_new_root_in_sub, | ||||||
|  |             ) as (ctx, first): | ||||||
|  |                 pass | ||||||
|  | 
 | ||||||
|  |     if open_root_in == 'root': | ||||||
|  |         with pytest.raises( | ||||||
|  |             RuntimeFailure | ||||||
|  |         ) as excinfo: | ||||||
|  |             trio.run(main) | ||||||
|  | 
 | ||||||
|  |     else: | ||||||
|  |         with pytest.raises( | ||||||
|  |             tractor.RemoteActorError, | ||||||
|  |         ) as excinfo: | ||||||
|  |             trio.run(main) | ||||||
|  | 
 | ||||||
|  |         assert excinfo.value.boxed_type is RuntimeFailure | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_implicit_root_via_first_nursery( | ||||||
|  |     reg_addr: tuple, | ||||||
|  |     debug_mode: bool | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     The first `ActorNursery` open should implicitly call | ||||||
|  |     `_root.open_root_actor()`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     async def main(): | ||||||
|  |         async with tractor.open_nursery() as an: | ||||||
|  |             assert an._implicit_runtime_started | ||||||
|  |             assert tractor.current_actor().aid.name == 'root' | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_runtime_vars_unset( | ||||||
|  |     reg_addr: tuple, | ||||||
|  |     debug_mode: bool | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Ensure any `._state._runtime_vars` are restored to default values | ||||||
|  |     after the root actor-runtime exits! | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     assert not tractor._state._runtime_vars['_debug_mode'] | ||||||
|  |     async def main(): | ||||||
|  |         assert not tractor._state._runtime_vars['_debug_mode'] | ||||||
|  |         async with tractor.open_nursery( | ||||||
|  |             debug_mode=True, | ||||||
|  |         ): | ||||||
|  |             assert tractor._state._runtime_vars['_debug_mode'] | ||||||
|  | 
 | ||||||
|  |         # after runtime closure, should be reverted! | ||||||
|  |         assert not tractor._state._runtime_vars['_debug_mode'] | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | @ -8,7 +8,7 @@ import uuid | ||||||
| import pytest | import pytest | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
| from tractor._shm import ( | from tractor.ipc._shm import ( | ||||||
|     open_shm_list, |     open_shm_list, | ||||||
|     attach_shm_list, |     attach_shm_list, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ | ||||||
| Spawning basics | Spawning basics | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
|  | from functools import partial | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, |     Any, | ||||||
| ) | ) | ||||||
|  | @ -12,74 +13,99 @@ import tractor | ||||||
| 
 | 
 | ||||||
| from tractor._testing import tractor_test | from tractor._testing import tractor_test | ||||||
| 
 | 
 | ||||||
| data_to_pass_down = {'doggy': 10, 'kitty': 4} | data_to_pass_down = { | ||||||
|  |     'doggy': 10, | ||||||
|  |     'kitty': 4, | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def spawn( | async def spawn( | ||||||
|     is_arbiter: bool, |     should_be_root: bool, | ||||||
|     data: dict, |     data: dict, | ||||||
|     reg_addr: tuple[str, int], |     reg_addr: tuple[str, int], | ||||||
|  | 
 | ||||||
|  |     debug_mode: bool = False, | ||||||
| ): | ): | ||||||
|     namespaces = [__name__] |  | ||||||
| 
 |  | ||||||
|     await trio.sleep(0.1) |     await trio.sleep(0.1) | ||||||
|  |     actor = tractor.current_actor(err_on_no_runtime=False) | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_root_actor( |     if should_be_root: | ||||||
|         arbiter_addr=reg_addr, |         assert actor is None  # no runtime yet | ||||||
|     ): |         async with ( | ||||||
|         actor = tractor.current_actor() |             tractor.open_root_actor( | ||||||
|         assert actor.is_arbiter == is_arbiter |                 arbiter_addr=reg_addr, | ||||||
|         data = data_to_pass_down |             ), | ||||||
|  |             tractor.open_nursery() as an, | ||||||
|  |         ): | ||||||
|  |             # now runtime exists | ||||||
|  |             actor: tractor.Actor = tractor.current_actor() | ||||||
|  |             assert actor.is_arbiter == should_be_root | ||||||
| 
 | 
 | ||||||
|         if actor.is_arbiter: |             # spawns subproc here | ||||||
|             async with tractor.open_nursery() as nursery: |             portal: tractor.Portal = await an.run_in_actor( | ||||||
|  |                 fn=spawn, | ||||||
| 
 | 
 | ||||||
|                 # forks here |                 # spawning args | ||||||
|                 portal = await nursery.run_in_actor( |                 name='sub-actor', | ||||||
|                     spawn, |                 enable_modules=[__name__], | ||||||
|                     is_arbiter=False, |  | ||||||
|                     name='sub-actor', |  | ||||||
|                     data=data, |  | ||||||
|                     reg_addr=reg_addr, |  | ||||||
|                     enable_modules=namespaces, |  | ||||||
|                 ) |  | ||||||
| 
 | 
 | ||||||
|                 assert len(nursery._children) == 1 |                 # passed to a subactor-recursive RPC invoke | ||||||
|                 assert portal.channel.uid in tractor.current_actor()._peers |                 # of this same `spawn()` fn. | ||||||
|                 # be sure we can still get the result |                 should_be_root=False, | ||||||
|                 result = await portal.result() |                 data=data_to_pass_down, | ||||||
|                 assert result == 10 |                 reg_addr=reg_addr, | ||||||
|                 return result |             ) | ||||||
|         else: | 
 | ||||||
|             return 10 |             assert len(an._children) == 1 | ||||||
|  |             assert ( | ||||||
|  |                 portal.channel.uid | ||||||
|  |                 in | ||||||
|  |                 tractor.current_actor().ipc_server._peers | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # get result from child subactor | ||||||
|  |             result = await portal.result() | ||||||
|  |             assert result == 10 | ||||||
|  |             return result | ||||||
|  |     else: | ||||||
|  |         assert actor.is_arbiter == should_be_root | ||||||
|  |         return 10 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_local_arbiter_subactor_global_state( | def test_run_in_actor_same_func_in_child( | ||||||
|     reg_addr, |     reg_addr: tuple, | ||||||
|  |     debug_mode: bool, | ||||||
| ): | ): | ||||||
|     result = trio.run( |     result = trio.run( | ||||||
|         spawn, |         partial( | ||||||
|         True, |             spawn, | ||||||
|         data_to_pass_down, |             should_be_root=True, | ||||||
|         reg_addr, |             data=data_to_pass_down, | ||||||
|  |             reg_addr=reg_addr, | ||||||
|  |             debug_mode=debug_mode, | ||||||
|  |         ) | ||||||
|     ) |     ) | ||||||
|     assert result == 10 |     assert result == 10 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def movie_theatre_question(): | async def movie_theatre_question(): | ||||||
|     """A question asked in a dark theatre, in a tangent |     ''' | ||||||
|  |     A question asked in a dark theatre, in a tangent | ||||||
|     (errr, I mean different) process. |     (errr, I mean different) process. | ||||||
|     """ | 
 | ||||||
|  |     ''' | ||||||
|     return 'have you ever seen a portal?' |     return 'have you ever seen a portal?' | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_movie_theatre_convo(start_method): | async def test_movie_theatre_convo(start_method): | ||||||
|     """The main ``tractor`` routine. |     ''' | ||||||
|     """ |     The main ``tractor`` routine. | ||||||
|     async with tractor.open_nursery() as n: |  | ||||||
| 
 | 
 | ||||||
|         portal = await n.start_actor( |     ''' | ||||||
|  |     async with tractor.open_nursery(debug_mode=True) as an: | ||||||
|  | 
 | ||||||
|  |         portal = await an.start_actor( | ||||||
|             'frank', |             'frank', | ||||||
|             # enable the actor to run funcs from this current module |             # enable the actor to run funcs from this current module | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|  | @ -118,8 +144,8 @@ async def test_most_beautiful_word( | ||||||
|     with trio.fail_after(1): |     with trio.fail_after(1): | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             debug_mode=debug_mode, |             debug_mode=debug_mode, | ||||||
|         ) as n: |         ) as an: | ||||||
|             portal = await n.run_in_actor( |             portal = await an.run_in_actor( | ||||||
|                 cellar_door, |                 cellar_door, | ||||||
|                 return_value=return_value, |                 return_value=return_value, | ||||||
|                 name='some_linguist', |                 name='some_linguist', | ||||||
|  |  | ||||||
|  | @ -180,7 +180,8 @@ def test_acm_embedded_nursery_propagates_enter_err( | ||||||
|         with tractor.devx.maybe_open_crash_handler( |         with tractor.devx.maybe_open_crash_handler( | ||||||
|             pdb=debug_mode, |             pdb=debug_mode, | ||||||
|         ) as bxerr: |         ) as bxerr: | ||||||
|             assert not bxerr.value |             if bxerr: | ||||||
|  |                 assert not bxerr.value | ||||||
| 
 | 
 | ||||||
|             async with ( |             async with ( | ||||||
|                 wraps_tn_that_always_cancels() as tn, |                 wraps_tn_that_always_cancels() as tn, | ||||||
|  |  | ||||||
|  | @ -64,7 +64,7 @@ from ._root import ( | ||||||
|     run_daemon as run_daemon, |     run_daemon as run_daemon, | ||||||
|     open_root_actor as open_root_actor, |     open_root_actor as open_root_actor, | ||||||
| ) | ) | ||||||
| from ._ipc import Channel as Channel | from .ipc import Channel as Channel | ||||||
| from ._portal import Portal as Portal | from ._portal import Portal as Portal | ||||||
| from ._runtime import Actor as Actor | from ._runtime import Actor as Actor | ||||||
| # from . import hilevel as hilevel | # from . import hilevel as hilevel | ||||||
|  |  | ||||||
|  | @ -0,0 +1,282 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | from __future__ import annotations | ||||||
|  | from uuid import uuid4 | ||||||
|  | from typing import ( | ||||||
|  |     Protocol, | ||||||
|  |     ClassVar, | ||||||
|  |     Type, | ||||||
|  |     TYPE_CHECKING, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from bidict import bidict | ||||||
|  | from trio import ( | ||||||
|  |     SocketListener, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from .log import get_logger | ||||||
|  | from ._state import ( | ||||||
|  |     _def_tpt_proto, | ||||||
|  | ) | ||||||
|  | from .ipc._tcp import TCPAddress | ||||||
|  | from .ipc._uds import UDSAddress | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from ._runtime import Actor | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO, maybe breakout the netns key to a struct? | ||||||
|  | # class NetNs(Struct)[str, int]: | ||||||
|  | #     ... | ||||||
|  | 
 | ||||||
|  | # TODO, can't we just use a type alias | ||||||
|  | # for this? namely just some `tuple[str, int, str, str]`? | ||||||
|  | # | ||||||
|  | # -[ ] would also just be simpler to keep this as SockAddr[tuple] | ||||||
|  | #     or something, implying it's just a simple pair of values which can | ||||||
|  | #     presumably be mapped to all transports? | ||||||
|  | # -[ ] `pydoc socket.socket.getsockname()` delivers a 4-tuple for | ||||||
|  | #     ipv6 `(hostaddr, port, flowinfo, scope_id)`.. so how should we | ||||||
|  | #     handle that? | ||||||
|  | # -[ ] as a further alternative to this wrap()/unwrap() approach we | ||||||
|  | #     could just implement `enc/dec_hook()`s for the `Address`-types | ||||||
|  | #     and just deal with our internal objs directly and always and | ||||||
|  | #     leave it to the codec layer to figure out marshalling? | ||||||
|  | #    |_ would mean only one spot to do the `.unwrap()` (which we may | ||||||
|  | #       end up needing to call from the hook()s anyway?) | ||||||
|  | # -[x] rename to `UnwrappedAddress[Descriptor]` ?? | ||||||
|  | #    seems like the right name as per, | ||||||
|  | #    https://www.geeksforgeeks.org/introduction-to-address-descriptor/ | ||||||
|  | # | ||||||
|  | UnwrappedAddress = ( | ||||||
|  |     # tcp/udp/uds | ||||||
|  |     tuple[ | ||||||
|  |         str,  # host/domain(tcp), filesys-dir(uds) | ||||||
|  |         int|str,  # port/path(uds) | ||||||
|  |     ] | ||||||
|  |     # ?TODO? should we also include another 2 fields from | ||||||
|  |     # our `Aid` msg such that we include the runtime `Actor.uid` | ||||||
|  |     # of `.name` and `.uuid`? | ||||||
|  |     # - would ensure uniqueness across entire net? | ||||||
|  |     # - allows for easier runtime-level filtering of "actors by | ||||||
|  |     #   service name" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO, maybe rename to `SocketAddress`? | ||||||
|  | class Address(Protocol): | ||||||
|  |     proto_key: ClassVar[str] | ||||||
|  |     unwrapped_type: ClassVar[UnwrappedAddress] | ||||||
|  | 
 | ||||||
|  |     # TODO, i feel like an `.is_bound()` is a better thing to | ||||||
|  |     # support? | ||||||
|  |     # Lke, what use does this have besides a noop and if it's not | ||||||
|  |     # valid why aren't we erroring on creation/use? | ||||||
|  |     @property | ||||||
|  |     def is_valid(self) -> bool: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     # TODO, maybe `.netns` is a better name? | ||||||
|  |     @property | ||||||
|  |     def namespace(self) -> tuple[str, int]|None: | ||||||
|  |         ''' | ||||||
|  |         The if-available, OS-specific "network namespace" key. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def bindspace(self) -> str: | ||||||
|  |         ''' | ||||||
|  |         Deliver the socket address' "bindable space" from | ||||||
|  |         a `socket.socket.bind()` and thus from the perspective of | ||||||
|  |         specific transport protocol domain. | ||||||
|  | 
 | ||||||
|  |         I.e. for most (layer-4) network-socket protocols this is | ||||||
|  |         normally the ipv4/6 address, for UDS this is normally | ||||||
|  |         a filesystem (sub-directory). | ||||||
|  | 
 | ||||||
|  |         For (distributed) network protocols this is normally the routing | ||||||
|  |         layer's domain/(ip-)address, though it might also include a "network namespace" | ||||||
|  |         key different then the default. | ||||||
|  | 
 | ||||||
|  |         For local-host-only transports this is either an explicit | ||||||
|  |         namespace (with types defined by the OS: netns, Cgroup, IPC, | ||||||
|  |         pid, etc. on linux) or failing that the sub-directory in the | ||||||
|  |         filesys in which socket/shm files are located *under*. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def from_addr(cls, addr: UnwrappedAddress) -> Address: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     def unwrap(self) -> UnwrappedAddress: | ||||||
|  |         ''' | ||||||
|  |         Deliver the underying minimum field set in | ||||||
|  |         a primitive python data type-structure. | ||||||
|  |         ''' | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def get_random( | ||||||
|  |         cls, | ||||||
|  |         current_actor: Actor, | ||||||
|  |         bindspace: str|None = None, | ||||||
|  |     ) -> Address: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     # TODO, this should be something like a `.get_def_registar_addr()` | ||||||
|  |     # or similar since, | ||||||
|  |     # - it should be a **host singleton** (not root/tree singleton) | ||||||
|  |     # - we **only need this value** when one isn't provided to the | ||||||
|  |     #   runtime at boot and we want to implicitly provide a host-wide | ||||||
|  |     #   registrar. | ||||||
|  |     # - each rooted-actor-tree should likely have its own | ||||||
|  |     #   micro-registry (likely the root being it), also see | ||||||
|  |     @classmethod | ||||||
|  |     def get_root(cls) -> Address: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     def __repr__(self) -> str: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     def __eq__(self, other) -> bool: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     async def open_listener( | ||||||
|  |         self, | ||||||
|  |         **kwargs, | ||||||
|  |     ) -> SocketListener: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     async def close_listener(self): | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _address_types: bidict[str, Type[Address]] = { | ||||||
|  |     'tcp': TCPAddress, | ||||||
|  |     'uds': UDSAddress | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO! really these are discovery sys default addrs ONLY useful for | ||||||
|  | # when none is provided to a root actor on first boot. | ||||||
|  | _default_lo_addrs: dict[ | ||||||
|  |     str, | ||||||
|  |     UnwrappedAddress | ||||||
|  | ] = { | ||||||
|  |     'tcp': TCPAddress.get_root().unwrap(), | ||||||
|  |     'uds': UDSAddress.get_root().unwrap(), | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_address_cls(name: str) -> Type[Address]: | ||||||
|  |     return _address_types[name] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def is_wrapped_addr(addr: any) -> bool: | ||||||
|  |     return type(addr) in _address_types.values() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def mk_uuid() -> str: | ||||||
|  |     ''' | ||||||
|  |     Encapsulate creation of a uuid4 as `str` as used | ||||||
|  |     for creating `Actor.uid: tuple[str, str]` and/or | ||||||
|  |     `.msg.types.Aid`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return str(uuid4()) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def wrap_address( | ||||||
|  |     addr: UnwrappedAddress | ||||||
|  | ) -> Address: | ||||||
|  |     ''' | ||||||
|  |     Wrap an `UnwrappedAddress` as an `Address`-type based | ||||||
|  |     on matching builtin python data-structures which we adhoc | ||||||
|  |     use for each. | ||||||
|  | 
 | ||||||
|  |     XXX NOTE, careful care must be placed to ensure | ||||||
|  |     `UnwrappedAddress` cases are **definitely unique** otherwise the | ||||||
|  |     wrong transport backend may be loaded and will break many | ||||||
|  |     low-level things in our runtime in a not-fun-to-debug way! | ||||||
|  | 
 | ||||||
|  |     XD | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     if is_wrapped_addr(addr): | ||||||
|  |         return addr | ||||||
|  | 
 | ||||||
|  |     cls: Type|None = None | ||||||
|  |     # if 'sock' in addr[0]: | ||||||
|  |     #     import pdbp; pdbp.set_trace() | ||||||
|  |     match addr: | ||||||
|  | 
 | ||||||
|  |         # classic network socket-address as tuple/list | ||||||
|  |         case ( | ||||||
|  |             (str(), int()) | ||||||
|  |             | | ||||||
|  |             [str(), int()] | ||||||
|  |         ): | ||||||
|  |             cls = TCPAddress | ||||||
|  | 
 | ||||||
|  |         case ( | ||||||
|  |             # (str()|Path(), str()|Path()), | ||||||
|  |             # ^TODO? uhh why doesn't this work!? | ||||||
|  | 
 | ||||||
|  |             (_, filename) | ||||||
|  |         ) if type(filename) is str: | ||||||
|  |             cls = UDSAddress | ||||||
|  | 
 | ||||||
|  |         # likely an unset UDS or TCP reg address as defaulted in | ||||||
|  |         # `_state._runtime_vars['_root_mailbox']` | ||||||
|  |         # | ||||||
|  |         # TODO? figure out when/if we even need this? | ||||||
|  |         case ( | ||||||
|  |             None | ||||||
|  |             | | ||||||
|  |             [None, None] | ||||||
|  |         ): | ||||||
|  |             cls: Type[Address] = get_address_cls(_def_tpt_proto) | ||||||
|  |             addr: UnwrappedAddress = cls.get_root().unwrap() | ||||||
|  | 
 | ||||||
|  |         case _: | ||||||
|  |             # import pdbp; pdbp.set_trace() | ||||||
|  |             raise TypeError( | ||||||
|  |                 f'Can not wrap unwrapped-address ??\n' | ||||||
|  |                 f'type(addr): {type(addr)!r}\n' | ||||||
|  |                 f'addr: {addr!r}\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     return cls.from_addr(addr) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def default_lo_addrs( | ||||||
|  |     transports: list[str], | ||||||
|  | ) -> list[Type[Address]]: | ||||||
|  |     ''' | ||||||
|  |     Return the default, host-singleton, registry address | ||||||
|  |     for an input transport key set. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return [ | ||||||
|  |         _default_lo_addrs[transport] | ||||||
|  |         for transport in transports | ||||||
|  |     ] | ||||||
|  | @ -31,8 +31,12 @@ def parse_uid(arg): | ||||||
|     return str(name), str(uuid)  # ensures str encoding |     return str(name), str(uuid)  # ensures str encoding | ||||||
| 
 | 
 | ||||||
| def parse_ipaddr(arg): | def parse_ipaddr(arg): | ||||||
|     host, port = literal_eval(arg) |     try: | ||||||
|     return (str(host), int(port)) |         return literal_eval(arg) | ||||||
|  | 
 | ||||||
|  |     except (ValueError, SyntaxError): | ||||||
|  |         # UDS: try to interpret as a straight up str | ||||||
|  |         return arg | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|  | @ -46,8 +50,8 @@ if __name__ == "__main__": | ||||||
|     args = parser.parse_args() |     args = parser.parse_args() | ||||||
| 
 | 
 | ||||||
|     subactor = Actor( |     subactor = Actor( | ||||||
|         args.uid[0], |         name=args.uid[0], | ||||||
|         uid=args.uid[1], |         uuid=args.uid[1], | ||||||
|         loglevel=args.loglevel, |         loglevel=args.loglevel, | ||||||
|         spawn_method="trio" |         spawn_method="trio" | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  | @ -89,7 +89,7 @@ from .msg import ( | ||||||
|     pretty_struct, |     pretty_struct, | ||||||
|     _ops as msgops, |     _ops as msgops, | ||||||
| ) | ) | ||||||
| from ._ipc import ( | from .ipc import ( | ||||||
|     Channel, |     Channel, | ||||||
| ) | ) | ||||||
| from ._streaming import ( | from ._streaming import ( | ||||||
|  | @ -105,7 +105,7 @@ from ._state import ( | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from ._portal import Portal |     from ._portal import Portal | ||||||
|     from ._runtime import Actor |     from ._runtime import Actor | ||||||
|     from ._ipc import MsgTransport |     from .ipc._transport import MsgTransport | ||||||
|     from .devx._frame_stack import ( |     from .devx._frame_stack import ( | ||||||
|         CallerInfo, |         CallerInfo, | ||||||
|     ) |     ) | ||||||
|  | @ -366,7 +366,7 @@ class Context: | ||||||
|             # f'   ---\n' |             # f'   ---\n' | ||||||
|             f' |_ipc: {self.dst_maddr}\n' |             f' |_ipc: {self.dst_maddr}\n' | ||||||
|             # f'   dst_maddr{ds}{self.dst_maddr}\n' |             # f'   dst_maddr{ds}{self.dst_maddr}\n' | ||||||
|             f"   uid{ds}'{self.chan.uid}'\n" |             f"   uid{ds}'{self.chan.aid}'\n" | ||||||
|             f"   cid{ds}'{self.cid}'\n" |             f"   cid{ds}'{self.cid}'\n" | ||||||
|             # f'   ---\n' |             # f'   ---\n' | ||||||
|             f'\n' |             f'\n' | ||||||
|  | @ -859,19 +859,10 @@ class Context: | ||||||
|     @property |     @property | ||||||
|     def dst_maddr(self) -> str: |     def dst_maddr(self) -> str: | ||||||
|         chan: Channel = self.chan |         chan: Channel = self.chan | ||||||
|         dst_addr, dst_port = chan.raddr |  | ||||||
|         trans: MsgTransport = chan.transport |         trans: MsgTransport = chan.transport | ||||||
|         # cid: str = self.cid |         # cid: str = self.cid | ||||||
|         # cid_head, cid_tail = cid[:6], cid[-6:] |         # cid_head, cid_tail = cid[:6], cid[-6:] | ||||||
|         return ( |         return trans.maddr | ||||||
|             f'/ipv4/{dst_addr}' |  | ||||||
|             f'/{trans.name_key}/{dst_port}' |  | ||||||
|             # f'/{self.chan.uid[0]}' |  | ||||||
|             # f'/{self.cid}' |  | ||||||
| 
 |  | ||||||
|             # f'/cid={cid_head}..{cid_tail}' |  | ||||||
|             # TODO: ? not use this ^ right ? |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
|     dmaddr = dst_maddr |     dmaddr = dst_maddr | ||||||
| 
 | 
 | ||||||
|  | @ -954,10 +945,10 @@ class Context: | ||||||
|         reminfo: str = ( |         reminfo: str = ( | ||||||
|             # ' =>\n' |             # ' =>\n' | ||||||
|             # f'Context.cancel() => {self.chan.uid}\n' |             # f'Context.cancel() => {self.chan.uid}\n' | ||||||
|  |             f'\n' | ||||||
|             f'c)=> {self.chan.uid}\n' |             f'c)=> {self.chan.uid}\n' | ||||||
|             # f'{self.chan.uid}\n' |             f'   |_[{self.dst_maddr}\n' | ||||||
|             f'  |_ @{self.dst_maddr}\n' |             f'     >>{self.repr_rpc}\n' | ||||||
|             f'    >> {self.repr_rpc}\n' |  | ||||||
|             # f'    >> {self._nsf}() -> {codec}[dict]:\n\n' |             # f'    >> {self._nsf}() -> {codec}[dict]:\n\n' | ||||||
|             # TODO: pull msg-type from spec re #320 |             # TODO: pull msg-type from spec re #320 | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | @ -29,7 +29,12 @@ from contextlib import asynccontextmanager as acm | ||||||
| 
 | 
 | ||||||
| from tractor.log import get_logger | from tractor.log import get_logger | ||||||
| from .trionics import gather_contexts | from .trionics import gather_contexts | ||||||
| from ._ipc import _connect_chan, Channel | from .ipc import _connect_chan, Channel | ||||||
|  | from ._addr import ( | ||||||
|  |     UnwrappedAddress, | ||||||
|  |     Address, | ||||||
|  |     wrap_address | ||||||
|  | ) | ||||||
| from ._portal import ( | from ._portal import ( | ||||||
|     Portal, |     Portal, | ||||||
|     open_portal, |     open_portal, | ||||||
|  | @ -38,10 +43,12 @@ from ._portal import ( | ||||||
| from ._state import ( | from ._state import ( | ||||||
|     current_actor, |     current_actor, | ||||||
|     _runtime_vars, |     _runtime_vars, | ||||||
|  |     _def_tpt_proto, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from ._runtime import Actor |     from ._runtime import Actor | ||||||
|  |     from .ipc._server import IPCServer | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
|  | @ -49,9 +56,7 @@ log = get_logger(__name__) | ||||||
| 
 | 
 | ||||||
| @acm | @acm | ||||||
| async def get_registry( | async def get_registry( | ||||||
|     host: str, |     addr: UnwrappedAddress|None = None, | ||||||
|     port: int, |  | ||||||
| 
 |  | ||||||
| ) -> AsyncGenerator[ | ) -> AsyncGenerator[ | ||||||
|     Portal | LocalPortal | None, |     Portal | LocalPortal | None, | ||||||
|     None, |     None, | ||||||
|  | @ -69,13 +74,15 @@ async def get_registry( | ||||||
|         # (likely a re-entrant call from the arbiter actor) |         # (likely a re-entrant call from the arbiter actor) | ||||||
|         yield LocalPortal( |         yield LocalPortal( | ||||||
|             actor, |             actor, | ||||||
|             Channel((host, port)) |             Channel(transport=None) | ||||||
|  |             # ^XXX, we DO NOT actually provide nor connect an | ||||||
|  |             # underlying transport since this is merely an API shim. | ||||||
|         ) |         ) | ||||||
|     else: |     else: | ||||||
|         # TODO: try to look pre-existing connection from |         # TODO: try to look pre-existing connection from | ||||||
|         # `Actor._peers` and use it instead? |         # `IPCServer._peers` and use it instead? | ||||||
|         async with ( |         async with ( | ||||||
|             _connect_chan(host, port) as chan, |             _connect_chan(addr) as chan, | ||||||
|             open_portal(chan) as regstr_ptl, |             open_portal(chan) as regstr_ptl, | ||||||
|         ): |         ): | ||||||
|             yield regstr_ptl |             yield regstr_ptl | ||||||
|  | @ -89,11 +96,10 @@ async def get_root( | ||||||
| 
 | 
 | ||||||
|     # TODO: rename mailbox to `_root_maddr` when we finally |     # TODO: rename mailbox to `_root_maddr` when we finally | ||||||
|     # add and impl libp2p multi-addrs? |     # add and impl libp2p multi-addrs? | ||||||
|     host, port = _runtime_vars['_root_mailbox'] |     addr = _runtime_vars['_root_mailbox'] | ||||||
|     assert host is not None |  | ||||||
| 
 | 
 | ||||||
|     async with ( |     async with ( | ||||||
|         _connect_chan(host, port) as chan, |         _connect_chan(addr) as chan, | ||||||
|         open_portal(chan, **kwargs) as portal, |         open_portal(chan, **kwargs) as portal, | ||||||
|     ): |     ): | ||||||
|         yield portal |         yield portal | ||||||
|  | @ -106,14 +112,15 @@ def get_peer_by_name( | ||||||
| ) -> list[Channel]|None:  # at least 1 | ) -> list[Channel]|None:  # at least 1 | ||||||
|     ''' |     ''' | ||||||
|     Scan for an existing connection (set) to a named actor |     Scan for an existing connection (set) to a named actor | ||||||
|     and return any channels from `Actor._peers`. |     and return any channels from `IPCServer._peers: dict`. | ||||||
| 
 | 
 | ||||||
|     This is an optimization method over querying the registrar for |     This is an optimization method over querying the registrar for | ||||||
|     the same info. |     the same info. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     actor: Actor = current_actor() |     actor: Actor = current_actor() | ||||||
|     to_scan: dict[tuple, list[Channel]] = actor._peers.copy() |     server: IPCServer = actor.ipc_server | ||||||
|  |     to_scan: dict[tuple, list[Channel]] = server._peers.copy() | ||||||
|     pchan: Channel|None = actor._parent_chan |     pchan: Channel|None = actor._parent_chan | ||||||
|     if pchan: |     if pchan: | ||||||
|         to_scan[pchan.uid].append(pchan) |         to_scan[pchan.uid].append(pchan) | ||||||
|  | @ -134,10 +141,10 @@ def get_peer_by_name( | ||||||
| @acm | @acm | ||||||
| async def query_actor( | async def query_actor( | ||||||
|     name: str, |     name: str, | ||||||
|     regaddr: tuple[str, int]|None = None, |     regaddr: UnwrappedAddress|None = None, | ||||||
| 
 | 
 | ||||||
| ) -> AsyncGenerator[ | ) -> AsyncGenerator[ | ||||||
|     tuple[str, int]|None, |     UnwrappedAddress|None, | ||||||
|     None, |     None, | ||||||
| ]: | ]: | ||||||
|     ''' |     ''' | ||||||
|  | @ -163,31 +170,31 @@ async def query_actor( | ||||||
|         return |         return | ||||||
| 
 | 
 | ||||||
|     reg_portal: Portal |     reg_portal: Portal | ||||||
|     regaddr: tuple[str, int] = regaddr or actor.reg_addrs[0] |     regaddr: Address = wrap_address(regaddr) or actor.reg_addrs[0] | ||||||
|     async with get_registry(*regaddr) as reg_portal: |     async with get_registry(regaddr) as reg_portal: | ||||||
|         # TODO: return portals to all available actors - for now |         # TODO: return portals to all available actors - for now | ||||||
|         # just the last one that registered |         # just the last one that registered | ||||||
|         sockaddr: tuple[str, int] = await reg_portal.run_from_ns( |         addr: UnwrappedAddress = await reg_portal.run_from_ns( | ||||||
|             'self', |             'self', | ||||||
|             'find_actor', |             'find_actor', | ||||||
|             name=name, |             name=name, | ||||||
|         ) |         ) | ||||||
|         yield sockaddr |         yield addr | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @acm | @acm | ||||||
| async def maybe_open_portal( | async def maybe_open_portal( | ||||||
|     addr: tuple[str, int], |     addr: UnwrappedAddress, | ||||||
|     name: str, |     name: str, | ||||||
| ): | ): | ||||||
|     async with query_actor( |     async with query_actor( | ||||||
|         name=name, |         name=name, | ||||||
|         regaddr=addr, |         regaddr=addr, | ||||||
|     ) as sockaddr: |     ) as addr: | ||||||
|         pass |         pass | ||||||
| 
 | 
 | ||||||
|     if sockaddr: |     if addr: | ||||||
|         async with _connect_chan(*sockaddr) as chan: |         async with _connect_chan(addr) as chan: | ||||||
|             async with open_portal(chan) as portal: |             async with open_portal(chan) as portal: | ||||||
|                 yield portal |                 yield portal | ||||||
|     else: |     else: | ||||||
|  | @ -197,7 +204,8 @@ async def maybe_open_portal( | ||||||
| @acm | @acm | ||||||
| async def find_actor( | async def find_actor( | ||||||
|     name: str, |     name: str, | ||||||
|     registry_addrs: list[tuple[str, int]]|None = None, |     registry_addrs: list[UnwrappedAddress]|None = None, | ||||||
|  |     enable_transports: list[str] = [_def_tpt_proto], | ||||||
| 
 | 
 | ||||||
|     only_first: bool = True, |     only_first: bool = True, | ||||||
|     raise_on_none: bool = False, |     raise_on_none: bool = False, | ||||||
|  | @ -224,15 +232,15 @@ async def find_actor( | ||||||
|         # XXX NOTE: make sure to dynamically read the value on |         # XXX NOTE: make sure to dynamically read the value on | ||||||
|         # every call since something may change it globally (eg. |         # every call since something may change it globally (eg. | ||||||
|         # like in our discovery test suite)! |         # like in our discovery test suite)! | ||||||
|         from . import _root |         from ._addr import default_lo_addrs | ||||||
|         registry_addrs = ( |         registry_addrs = ( | ||||||
|             _runtime_vars['_registry_addrs'] |             _runtime_vars['_registry_addrs'] | ||||||
|             or |             or | ||||||
|             _root._default_lo_addrs |             default_lo_addrs(enable_transports) | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     maybe_portals: list[ |     maybe_portals: list[ | ||||||
|         AsyncContextManager[tuple[str, int]] |         AsyncContextManager[UnwrappedAddress] | ||||||
|     ] = list( |     ] = list( | ||||||
|         maybe_open_portal( |         maybe_open_portal( | ||||||
|             addr=addr, |             addr=addr, | ||||||
|  | @ -274,7 +282,7 @@ async def find_actor( | ||||||
| @acm | @acm | ||||||
| async def wait_for_actor( | async def wait_for_actor( | ||||||
|     name: str, |     name: str, | ||||||
|     registry_addr: tuple[str, int] | None = None, |     registry_addr: UnwrappedAddress | None = None, | ||||||
| 
 | 
 | ||||||
| ) -> AsyncGenerator[Portal, None]: | ) -> AsyncGenerator[Portal, None]: | ||||||
|     ''' |     ''' | ||||||
|  | @ -291,7 +299,7 @@ async def wait_for_actor( | ||||||
|             yield peer_portal |             yield peer_portal | ||||||
|             return |             return | ||||||
| 
 | 
 | ||||||
|     regaddr: tuple[str, int] = ( |     regaddr: UnwrappedAddress = ( | ||||||
|         registry_addr |         registry_addr | ||||||
|         or |         or | ||||||
|         actor.reg_addrs[0] |         actor.reg_addrs[0] | ||||||
|  | @ -299,8 +307,8 @@ async def wait_for_actor( | ||||||
|     # TODO: use `.trionics.gather_contexts()` like |     # TODO: use `.trionics.gather_contexts()` like | ||||||
|     # above in `find_actor()` as well? |     # above in `find_actor()` as well? | ||||||
|     reg_portal: Portal |     reg_portal: Portal | ||||||
|     async with get_registry(*regaddr) as reg_portal: |     async with get_registry(regaddr) as reg_portal: | ||||||
|         sockaddrs = await reg_portal.run_from_ns( |         addrs = await reg_portal.run_from_ns( | ||||||
|             'self', |             'self', | ||||||
|             'wait_for_actor', |             'wait_for_actor', | ||||||
|             name=name, |             name=name, | ||||||
|  | @ -308,8 +316,8 @@ async def wait_for_actor( | ||||||
| 
 | 
 | ||||||
|         # get latest registered addr by default? |         # get latest registered addr by default? | ||||||
|         # TODO: offer multi-portal yields in multi-homed case? |         # TODO: offer multi-portal yields in multi-homed case? | ||||||
|         sockaddr: tuple[str, int] = sockaddrs[-1] |         addr: UnwrappedAddress = addrs[-1] | ||||||
| 
 | 
 | ||||||
|         async with _connect_chan(*sockaddr) as chan: |         async with _connect_chan(addr) as chan: | ||||||
|             async with open_portal(chan) as portal: |             async with open_portal(chan) as portal: | ||||||
|                 yield portal |                 yield portal | ||||||
|  |  | ||||||
|  | @ -22,7 +22,6 @@ from __future__ import annotations | ||||||
| from functools import partial | from functools import partial | ||||||
| import multiprocessing as mp | import multiprocessing as mp | ||||||
| import os | import os | ||||||
| import textwrap |  | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, |     Any, | ||||||
|     TYPE_CHECKING, |     TYPE_CHECKING, | ||||||
|  | @ -35,8 +34,12 @@ from .log import ( | ||||||
|     get_logger, |     get_logger, | ||||||
| ) | ) | ||||||
| from . import _state | from . import _state | ||||||
| from .devx import _debug | from .devx import ( | ||||||
|  |     _debug, | ||||||
|  |     pformat, | ||||||
|  | ) | ||||||
| from .to_asyncio import run_as_asyncio_guest | from .to_asyncio import run_as_asyncio_guest | ||||||
|  | from ._addr import UnwrappedAddress | ||||||
| from ._runtime import ( | from ._runtime import ( | ||||||
|     async_main, |     async_main, | ||||||
|     Actor, |     Actor, | ||||||
|  | @ -52,10 +55,10 @@ log = get_logger(__name__) | ||||||
| def _mp_main( | def _mp_main( | ||||||
| 
 | 
 | ||||||
|     actor: Actor, |     actor: Actor, | ||||||
|     accept_addrs: list[tuple[str, int]], |     accept_addrs: list[UnwrappedAddress], | ||||||
|     forkserver_info: tuple[Any, Any, Any, Any, Any], |     forkserver_info: tuple[Any, Any, Any, Any, Any], | ||||||
|     start_method: SpawnMethodKey, |     start_method: SpawnMethodKey, | ||||||
|     parent_addr: tuple[str, int] | None = None, |     parent_addr: UnwrappedAddress | None = None, | ||||||
|     infect_asyncio: bool = False, |     infect_asyncio: bool = False, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|  | @ -102,111 +105,10 @@ def _mp_main( | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # TODO: move this func to some kinda `.devx._conc_lang.py` eventually |  | ||||||
| # as we work out our multi-domain state-flow-syntax! |  | ||||||
| def nest_from_op( |  | ||||||
|     input_op: str, |  | ||||||
|     # |  | ||||||
|     # ?TODO? an idea for a syntax to the state of concurrent systems |  | ||||||
|     # as a "3-domain" (execution, scope, storage) model and using |  | ||||||
|     # a minimal ascii/utf-8 operator-set. |  | ||||||
|     # |  | ||||||
|     # try not to take any of this seriously yet XD |  | ||||||
|     # |  | ||||||
|     # > is a "play operator" indicating (CPU bound) |  | ||||||
|     #   exec/work/ops required at the "lowest level computing" |  | ||||||
|     # |  | ||||||
|     # execution primititves (tasks, threads, actors..) denote their |  | ||||||
|     # lifetime with '(' and ')' since parentheses normally are used |  | ||||||
|     # in many langs to denote function calls. |  | ||||||
|     # |  | ||||||
|     # starting = ( |  | ||||||
|     # >(  opening/starting; beginning of the thread-of-exec (toe?) |  | ||||||
|     # (>  opened/started,  (finished spawning toe) |  | ||||||
|     # |_<Task: blah blah..>  repr of toe, in py these look like <objs> |  | ||||||
|     # |  | ||||||
|     # >) closing/exiting/stopping, |  | ||||||
|     # )> closed/exited/stopped, |  | ||||||
|     # |_<Task: blah blah..> |  | ||||||
|     #   [OR <), )< ?? ] |  | ||||||
|     # |  | ||||||
|     # ending = ) |  | ||||||
|     # >c) cancelling to close/exit |  | ||||||
|     # c)> cancelled (caused close), OR? |  | ||||||
|     #  |_<Actor: ..> |  | ||||||
|     #   OR maybe "<c)" which better indicates the cancel being |  | ||||||
|     #   "delivered/returned" / returned" to LHS? |  | ||||||
|     # |  | ||||||
|     # >x)  erroring to eventuall exit |  | ||||||
|     # x)>  errored and terminated |  | ||||||
|     #  |_<Actor: ...> |  | ||||||
|     # |  | ||||||
|     # scopes: supers/nurseries, IPC-ctxs, sessions, perms, etc. |  | ||||||
|     # >{  opening |  | ||||||
|     # {>  opened |  | ||||||
|     # }>  closed |  | ||||||
|     # >}  closing |  | ||||||
|     # |  | ||||||
|     # storage: like queues, shm-buffers, files, etc.. |  | ||||||
|     # >[  opening |  | ||||||
|     # [>  opened |  | ||||||
|     #  |_<FileObj: ..> |  | ||||||
|     # |  | ||||||
|     # >]  closing |  | ||||||
|     # ]>  closed |  | ||||||
| 
 |  | ||||||
|     # IPC ops: channels, transports, msging |  | ||||||
|     # =>  req msg |  | ||||||
|     # <=  resp msg |  | ||||||
|     # <=> 2-way streaming (of msgs) |  | ||||||
|     # <-  recv 1 msg |  | ||||||
|     # ->  send 1 msg |  | ||||||
|     # |  | ||||||
|     # TODO: still not sure on R/L-HS approach..? |  | ||||||
|     # =>(  send-req to exec start (task, actor, thread..) |  | ||||||
|     # (<=  recv-req to ^ |  | ||||||
|     # |  | ||||||
|     # (<=  recv-req ^ |  | ||||||
|     # <=(  recv-resp opened remote exec primitive |  | ||||||
|     # <=)  recv-resp closed |  | ||||||
|     # |  | ||||||
|     # )<=c req to stop due to cancel |  | ||||||
|     # c=>) req to stop due to cancel |  | ||||||
|     # |  | ||||||
|     # =>{  recv-req to open |  | ||||||
|     # <={  send-status that it closed |  | ||||||
| 
 |  | ||||||
|     tree_str: str, |  | ||||||
| 
 |  | ||||||
|     # NOTE: so move back-from-the-left of the `input_op` by |  | ||||||
|     # this amount. |  | ||||||
|     back_from_op: int = 0, |  | ||||||
| ) -> str: |  | ||||||
|     ''' |  | ||||||
|     Depth-increment the input (presumably hierarchy/supervision) |  | ||||||
|     input "tree string" below the provided `input_op` execution |  | ||||||
|     operator, so injecting a `"\n|_{input_op}\n"`and indenting the |  | ||||||
|     `tree_str` to nest content aligned with the ops last char. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     return ( |  | ||||||
|         f'{input_op}\n' |  | ||||||
|         + |  | ||||||
|         textwrap.indent( |  | ||||||
|             tree_str, |  | ||||||
|             prefix=( |  | ||||||
|                 len(input_op) |  | ||||||
|                 - |  | ||||||
|                 (back_from_op + 1) |  | ||||||
|             ) * ' ', |  | ||||||
|         ) |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def _trio_main( | def _trio_main( | ||||||
|     actor: Actor, |     actor: Actor, | ||||||
|     *, |     *, | ||||||
|     parent_addr: tuple[str, int] | None = None, |     parent_addr: UnwrappedAddress|None = None, | ||||||
|     infect_asyncio: bool = False, |     infect_asyncio: bool = False, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|  | @ -235,7 +137,7 @@ def _trio_main( | ||||||
|         log.info( |         log.info( | ||||||
|             'Starting new `trio` subactor:\n' |             'Starting new `trio` subactor:\n' | ||||||
|             + |             + | ||||||
|             nest_from_op( |             pformat.nest_from_op( | ||||||
|                 input_op='>(',  # see syntax ideas above |                 input_op='>(',  # see syntax ideas above | ||||||
|                 tree_str=actor_info, |                 tree_str=actor_info, | ||||||
|                 back_from_op=2,  # since "complete" |                 back_from_op=2,  # since "complete" | ||||||
|  | @ -245,7 +147,7 @@ def _trio_main( | ||||||
|     exit_status: str = ( |     exit_status: str = ( | ||||||
|         'Subactor exited\n' |         'Subactor exited\n' | ||||||
|         + |         + | ||||||
|         nest_from_op( |         pformat.nest_from_op( | ||||||
|             input_op=')>',  # like a "closed-to-play"-icon from super perspective |             input_op=')>',  # like a "closed-to-play"-icon from super perspective | ||||||
|             tree_str=actor_info, |             tree_str=actor_info, | ||||||
|             back_from_op=1, |             back_from_op=1, | ||||||
|  | @ -263,7 +165,7 @@ def _trio_main( | ||||||
|         exit_status: str = ( |         exit_status: str = ( | ||||||
|             'Actor received KBI (aka an OS-cancel)\n' |             'Actor received KBI (aka an OS-cancel)\n' | ||||||
|             + |             + | ||||||
|             nest_from_op( |             pformat.nest_from_op( | ||||||
|                 input_op='c)>',  # closed due to cancel (see above) |                 input_op='c)>',  # closed due to cancel (see above) | ||||||
|                 tree_str=actor_info, |                 tree_str=actor_info, | ||||||
|             ) |             ) | ||||||
|  | @ -273,7 +175,7 @@ def _trio_main( | ||||||
|         exit_status: str = ( |         exit_status: str = ( | ||||||
|             'Main actor task exited due to crash?\n' |             'Main actor task exited due to crash?\n' | ||||||
|             + |             + | ||||||
|             nest_from_op( |             pformat.nest_from_op( | ||||||
|                 input_op='x)>',  # closed by error |                 input_op='x)>',  # closed by error | ||||||
|                 tree_str=actor_info, |                 tree_str=actor_info, | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|  | @ -23,7 +23,6 @@ import builtins | ||||||
| import importlib | import importlib | ||||||
| from pprint import pformat | from pprint import pformat | ||||||
| from pdb import bdb | from pdb import bdb | ||||||
| import sys |  | ||||||
| from types import ( | from types import ( | ||||||
|     TracebackType, |     TracebackType, | ||||||
| ) | ) | ||||||
|  | @ -65,15 +64,29 @@ if TYPE_CHECKING: | ||||||
|     from ._context import Context |     from ._context import Context | ||||||
|     from .log import StackLevelAdapter |     from .log import StackLevelAdapter | ||||||
|     from ._stream import MsgStream |     from ._stream import MsgStream | ||||||
|     from ._ipc import Channel |     from .ipc import Channel | ||||||
| 
 | 
 | ||||||
| log = get_logger('tractor') | log = get_logger('tractor') | ||||||
| 
 | 
 | ||||||
| _this_mod = importlib.import_module(__name__) | _this_mod = importlib.import_module(__name__) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ActorFailure(Exception): | class RuntimeFailure(RuntimeError): | ||||||
|     "General actor failure" |     ''' | ||||||
|  |     General `Actor`-runtime failure due to, | ||||||
|  | 
 | ||||||
|  |     - a bad runtime-env, | ||||||
|  |     - falied spawning (bad input to process), | ||||||
|  |     -   API usage. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ActorFailure(RuntimeFailure): | ||||||
|  |     ''' | ||||||
|  |     `Actor` failed to boot before/after spawn | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class InternalError(RuntimeError): | class InternalError(RuntimeError): | ||||||
|  | @ -126,6 +139,12 @@ class TrioTaskExited(Exception): | ||||||
|     ''' |     ''' | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | class DebugRequestError(RuntimeError): | ||||||
|  |     ''' | ||||||
|  |     Failed to request stdio lock from root actor! | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  | 
 | ||||||
| # NOTE: more or less should be close to these: | # NOTE: more or less should be close to these: | ||||||
| # 'boxed_type', | # 'boxed_type', | ||||||
| # 'src_type', | # 'src_type', | ||||||
|  | @ -191,6 +210,8 @@ def get_err_type(type_name: str) -> BaseException|None: | ||||||
|         ): |         ): | ||||||
|             return type_ref |             return type_ref | ||||||
| 
 | 
 | ||||||
|  |     return None | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| def pack_from_raise( | def pack_from_raise( | ||||||
|     local_err: ( |     local_err: ( | ||||||
|  | @ -521,7 +542,6 @@ class RemoteActorError(Exception): | ||||||
|             if val: |             if val: | ||||||
|                 _repr += f'{key}={val_str}{end_char}' |                 _repr += f'{key}={val_str}{end_char}' | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|         return _repr |         return _repr | ||||||
| 
 | 
 | ||||||
|     def reprol(self) -> str: |     def reprol(self) -> str: | ||||||
|  | @ -600,56 +620,9 @@ class RemoteActorError(Exception): | ||||||
|             the type name is already implicitly shown by python). |             the type name is already implicitly shown by python). | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         header: str = '' |  | ||||||
|         body: str = '' |  | ||||||
|         message: str = '' |  | ||||||
| 
 |  | ||||||
|         # XXX when the currently raised exception is this instance, |  | ||||||
|         # we do not ever use the "type header" style repr. |  | ||||||
|         is_being_raised: bool = False |  | ||||||
|         if ( |  | ||||||
|             (exc := sys.exception()) |  | ||||||
|             and |  | ||||||
|             exc is self |  | ||||||
|         ): |  | ||||||
|             is_being_raised: bool = True |  | ||||||
| 
 |  | ||||||
|         with_type_header: bool = ( |  | ||||||
|             with_type_header |  | ||||||
|             and |  | ||||||
|             not is_being_raised |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         # <RemoteActorError( .. )> style |  | ||||||
|         if with_type_header: |  | ||||||
|             header: str = f'<{type(self).__name__}(' |  | ||||||
| 
 |  | ||||||
|         if message := self._message: |  | ||||||
| 
 |  | ||||||
|             # split off the first line so, if needed, it isn't |  | ||||||
|             # indented the same like the "boxed content" which |  | ||||||
|             # since there is no `.tb_str` is just the `.message`. |  | ||||||
|             lines: list[str] = message.splitlines() |  | ||||||
|             first: str = lines[0] |  | ||||||
|             message: str = message.removeprefix(first) |  | ||||||
| 
 |  | ||||||
|             # with a type-style header we, |  | ||||||
|             # - have no special message "first line" extraction/handling |  | ||||||
|             # - place the message a space in from the header: |  | ||||||
|             #  `MsgTypeError( <message> ..` |  | ||||||
|             #                 ^-here |  | ||||||
|             # - indent the `.message` inside the type body. |  | ||||||
|             if with_type_header: |  | ||||||
|                 first = f' {first} )>' |  | ||||||
| 
 |  | ||||||
|             message: str = textwrap.indent( |  | ||||||
|                 message, |  | ||||||
|                 prefix=' '*2, |  | ||||||
|             ) |  | ||||||
|             message: str = first + message |  | ||||||
| 
 |  | ||||||
|         # IFF there is an embedded traceback-str we always |         # IFF there is an embedded traceback-str we always | ||||||
|         # draw the ascii-box around it. |         # draw the ascii-box around it. | ||||||
|  |         body: str = '' | ||||||
|         if tb_str := self.tb_str: |         if tb_str := self.tb_str: | ||||||
|             fields: str = self._mk_fields_str( |             fields: str = self._mk_fields_str( | ||||||
|                 _body_fields |                 _body_fields | ||||||
|  | @ -670,21 +643,15 @@ class RemoteActorError(Exception): | ||||||
|                 boxer_header=self.relay_uid, |                 boxer_header=self.relay_uid, | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|         tail = '' |         # !TODO, it'd be nice to import these top level without | ||||||
|         if ( |         # cycles! | ||||||
|             with_type_header |         from tractor.devx.pformat import ( | ||||||
|             and not message |             pformat_exc, | ||||||
|         ): |         ) | ||||||
|             tail: str = '>' |         return pformat_exc( | ||||||
| 
 |             exc=self, | ||||||
|         return ( |             with_type_header=with_type_header, | ||||||
|             header |             body=body, | ||||||
|             + |  | ||||||
|             message |  | ||||||
|             + |  | ||||||
|             f'{body}' |  | ||||||
|             + |  | ||||||
|             tail |  | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     __repr__ = pformat |     __repr__ = pformat | ||||||
|  | @ -962,7 +929,7 @@ class StreamOverrun( | ||||||
|     ''' |     ''' | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TransportClosed(trio.BrokenResourceError): | class TransportClosed(Exception): | ||||||
|     ''' |     ''' | ||||||
|     IPC transport (protocol) connection was closed or broke and |     IPC transport (protocol) connection was closed or broke and | ||||||
|     indicates that the wrapping communication `Channel` can no longer |     indicates that the wrapping communication `Channel` can no longer | ||||||
|  | @ -973,24 +940,39 @@ class TransportClosed(trio.BrokenResourceError): | ||||||
|         self, |         self, | ||||||
|         message: str, |         message: str, | ||||||
|         loglevel: str = 'transport', |         loglevel: str = 'transport', | ||||||
|         cause: BaseException|None = None, |         src_exc: Exception|None = None, | ||||||
|         raise_on_report: bool = False, |         raise_on_report: bool = False, | ||||||
| 
 | 
 | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         self.message: str = message |         self.message: str = message | ||||||
|         self._loglevel = loglevel |         self._loglevel: str = loglevel | ||||||
|         super().__init__(message) |         super().__init__(message) | ||||||
| 
 | 
 | ||||||
|         if cause is not None: |         self._src_exc = src_exc | ||||||
|             self.__cause__ = cause |         # set the cause manually if not already set by python | ||||||
|  |         if ( | ||||||
|  |             src_exc is not None | ||||||
|  |             and | ||||||
|  |             not self.__cause__ | ||||||
|  |         ): | ||||||
|  |             self.__cause__ = src_exc | ||||||
| 
 | 
 | ||||||
|         # flag to toggle whether the msg loop should raise |         # flag to toggle whether the msg loop should raise | ||||||
|         # the exc in its `TransportClosed` handler block. |         # the exc in its `TransportClosed` handler block. | ||||||
|         self._raise_on_report = raise_on_report |         self._raise_on_report = raise_on_report | ||||||
| 
 | 
 | ||||||
|  |     @property | ||||||
|  |     def src_exc(self) -> Exception: | ||||||
|  |         return ( | ||||||
|  |             self.__cause__ | ||||||
|  |             or | ||||||
|  |             self._src_exc | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|     def report_n_maybe_raise( |     def report_n_maybe_raise( | ||||||
|         self, |         self, | ||||||
|         message: str|None = None, |         message: str|None = None, | ||||||
|  |         hide_tb: bool = True, | ||||||
| 
 | 
 | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         ''' |         ''' | ||||||
|  | @ -998,9 +980,10 @@ class TransportClosed(trio.BrokenResourceError): | ||||||
|         for this error. |         for this error. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|         message: str = message or self.message |         message: str = message or self.message | ||||||
|         # when a cause is set, slap it onto the log emission. |         # when a cause is set, slap it onto the log emission. | ||||||
|         if cause := self.__cause__: |         if cause := self.src_exc: | ||||||
|             cause_tb_str: str = ''.join( |             cause_tb_str: str = ''.join( | ||||||
|                 traceback.format_tb(cause.__traceback__) |                 traceback.format_tb(cause.__traceback__) | ||||||
|             ) |             ) | ||||||
|  | @ -1009,13 +992,86 @@ class TransportClosed(trio.BrokenResourceError): | ||||||
|                 f'    {cause}\n'  # exc repr |                 f'    {cause}\n'  # exc repr | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|         getattr(log, self._loglevel)(message) |         getattr( | ||||||
|  |             log, | ||||||
|  |             self._loglevel | ||||||
|  |         )(message) | ||||||
| 
 | 
 | ||||||
|         # some errors we want to blow up from |         # some errors we want to blow up from | ||||||
|         # inside the RPC msg loop |         # inside the RPC msg loop | ||||||
|         if self._raise_on_report: |         if self._raise_on_report: | ||||||
|             raise self from cause |             raise self from cause | ||||||
| 
 | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def repr_src_exc( | ||||||
|  |         self, | ||||||
|  |         src_exc: Exception|None = None, | ||||||
|  |     ) -> str: | ||||||
|  | 
 | ||||||
|  |         if src_exc is None: | ||||||
|  |             return '<unknown>' | ||||||
|  | 
 | ||||||
|  |         src_msg: tuple[str] = src_exc.args | ||||||
|  |         src_exc_repr: str = ( | ||||||
|  |             f'{type(src_exc).__name__}[ {src_msg} ]' | ||||||
|  |         ) | ||||||
|  |         return src_exc_repr | ||||||
|  | 
 | ||||||
|  |     def pformat(self) -> str: | ||||||
|  |         from tractor.devx.pformat import ( | ||||||
|  |             pformat_exc, | ||||||
|  |         ) | ||||||
|  |         return pformat_exc( | ||||||
|  |             exc=self, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # delegate to `str`-ified pformat | ||||||
|  |     __repr__ = pformat | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def from_src_exc( | ||||||
|  |         cls, | ||||||
|  |         src_exc: ( | ||||||
|  |             Exception| | ||||||
|  |             trio.ClosedResource| | ||||||
|  |             trio.BrokenResourceError | ||||||
|  |         ), | ||||||
|  |         message: str, | ||||||
|  |         body: str = '', | ||||||
|  |         **init_kws, | ||||||
|  |     ) -> TransportClosed: | ||||||
|  |         ''' | ||||||
|  |         Convenience constructor for creation from an underlying | ||||||
|  |         `trio`-sourced async-resource/chan/stream error. | ||||||
|  | 
 | ||||||
|  |         Embeds the original `src_exc`'s repr within the | ||||||
|  |         `Exception.args` via a first-line-in-`.message`-put-in-header | ||||||
|  |         pre-processing and allows inserting additional content beyond | ||||||
|  |         the main message via a `body: str`. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         repr_src_exc: str = cls.repr_src_exc( | ||||||
|  |             src_exc, | ||||||
|  |         ) | ||||||
|  |         next_line: str = f'  src_exc: {repr_src_exc}\n' | ||||||
|  |         if body: | ||||||
|  |             body: str = textwrap.indent( | ||||||
|  |                 body, | ||||||
|  |                 prefix=' '*2, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         return TransportClosed( | ||||||
|  |             message=( | ||||||
|  |                 message | ||||||
|  |                 + | ||||||
|  |                 next_line | ||||||
|  |                 + | ||||||
|  |                 body | ||||||
|  |             ), | ||||||
|  |             src_exc=src_exc, | ||||||
|  |             **init_kws, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class NoResult(RuntimeError): | class NoResult(RuntimeError): | ||||||
|     "No final result is expected for this actor" |     "No final result is expected for this actor" | ||||||
|  |  | ||||||
							
								
								
									
										820
									
								
								tractor/_ipc.py
								
								
								
								
							
							
						
						
									
										820
									
								
								tractor/_ipc.py
								
								
								
								
							|  | @ -1,820 +0,0 @@ | ||||||
| # tractor: structured concurrent "actors". |  | ||||||
| # Copyright 2018-eternity Tyler Goodlet. |  | ||||||
| 
 |  | ||||||
| # This program is free software: you can redistribute it and/or modify |  | ||||||
| # it under the terms of the GNU Affero General Public License as published by |  | ||||||
| # the Free Software Foundation, either version 3 of the License, or |  | ||||||
| # (at your option) any later version. |  | ||||||
| 
 |  | ||||||
| # This program is distributed in the hope that it will be useful, |  | ||||||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the |  | ||||||
| # GNU Affero General Public License for more details. |  | ||||||
| 
 |  | ||||||
| # You should have received a copy of the GNU Affero General Public License |  | ||||||
| # along with this program.  If not, see <https://www.gnu.org/licenses/>. |  | ||||||
| 
 |  | ||||||
| """ |  | ||||||
| Inter-process comms abstractions |  | ||||||
| 
 |  | ||||||
| """ |  | ||||||
| from __future__ import annotations |  | ||||||
| from collections.abc import ( |  | ||||||
|     AsyncGenerator, |  | ||||||
|     AsyncIterator, |  | ||||||
| ) |  | ||||||
| from contextlib import ( |  | ||||||
|     asynccontextmanager as acm, |  | ||||||
|     contextmanager as cm, |  | ||||||
| ) |  | ||||||
| import platform |  | ||||||
| from pprint import pformat |  | ||||||
| import struct |  | ||||||
| import typing |  | ||||||
| from typing import ( |  | ||||||
|     Any, |  | ||||||
|     Callable, |  | ||||||
|     runtime_checkable, |  | ||||||
|     Protocol, |  | ||||||
|     Type, |  | ||||||
|     TypeVar, |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| import msgspec |  | ||||||
| from tricycle import BufferedReceiveStream |  | ||||||
| import trio |  | ||||||
| 
 |  | ||||||
| from tractor.log import get_logger |  | ||||||
| from tractor._exceptions import ( |  | ||||||
|     MsgTypeError, |  | ||||||
|     pack_from_raise, |  | ||||||
|     TransportClosed, |  | ||||||
|     _mk_send_mte, |  | ||||||
|     _mk_recv_mte, |  | ||||||
| ) |  | ||||||
| from tractor.msg import ( |  | ||||||
|     _ctxvar_MsgCodec, |  | ||||||
|     # _codec,  XXX see `self._codec` sanity/debug checks |  | ||||||
|     MsgCodec, |  | ||||||
|     types as msgtypes, |  | ||||||
|     pretty_struct, |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| log = get_logger(__name__) |  | ||||||
| 
 |  | ||||||
| _is_windows = platform.system() == 'Windows' |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def get_stream_addrs( |  | ||||||
|     stream: trio.SocketStream |  | ||||||
| ) -> tuple[ |  | ||||||
|     tuple[str, int],  # local |  | ||||||
|     tuple[str, int],  # remote |  | ||||||
| ]: |  | ||||||
|     ''' |  | ||||||
|     Return the `trio` streaming transport prot's socket-addrs for |  | ||||||
|     both the local and remote sides as a pair. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     # rn, should both be IP sockets |  | ||||||
|     lsockname = stream.socket.getsockname() |  | ||||||
|     rsockname = stream.socket.getpeername() |  | ||||||
|     return ( |  | ||||||
|         tuple(lsockname[:2]), |  | ||||||
|         tuple(rsockname[:2]), |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| # from tractor.msg.types import MsgType |  | ||||||
| # ?TODO? this should be our `Union[*msgtypes.__spec__]` alias now right..? |  | ||||||
| # => BLEH, except can't bc prots must inherit typevar or param-spec |  | ||||||
| #   vars.. |  | ||||||
| MsgType = TypeVar('MsgType') |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| # TODO: break up this mod into a subpkg so we can start adding new |  | ||||||
| # backends and move this type stuff into a dedicated file.. Bo |  | ||||||
| # |  | ||||||
| @runtime_checkable |  | ||||||
| class MsgTransport(Protocol[MsgType]): |  | ||||||
| # |  | ||||||
| # ^-TODO-^ consider using a generic def and indexing with our |  | ||||||
| # eventual msg definition/types? |  | ||||||
| # - https://docs.python.org/3/library/typing.html#typing.Protocol |  | ||||||
| 
 |  | ||||||
|     stream: trio.SocketStream |  | ||||||
|     drained: list[MsgType] |  | ||||||
| 
 |  | ||||||
|     def __init__(self, stream: trio.SocketStream) -> None: |  | ||||||
|         ... |  | ||||||
| 
 |  | ||||||
|     # XXX: should this instead be called `.sendall()`? |  | ||||||
|     async def send(self, msg: MsgType) -> None: |  | ||||||
|         ... |  | ||||||
| 
 |  | ||||||
|     async def recv(self) -> MsgType: |  | ||||||
|         ... |  | ||||||
| 
 |  | ||||||
|     def __aiter__(self) -> MsgType: |  | ||||||
|         ... |  | ||||||
| 
 |  | ||||||
|     def connected(self) -> bool: |  | ||||||
|         ... |  | ||||||
| 
 |  | ||||||
|     # defining this sync otherwise it causes a mypy error because it |  | ||||||
|     # can't figure out it's a generator i guess?..? |  | ||||||
|     def drain(self) -> AsyncIterator[dict]: |  | ||||||
|         ... |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def laddr(self) -> tuple[str, int]: |  | ||||||
|         ... |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def raddr(self) -> tuple[str, int]: |  | ||||||
|         ... |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| # TODO: typing oddity.. not sure why we have to inherit here, but it |  | ||||||
| # seems to be an issue with `get_msg_transport()` returning |  | ||||||
| # a `Type[Protocol]`; probably should make a `mypy` issue? |  | ||||||
| class MsgpackTCPStream(MsgTransport): |  | ||||||
|     ''' |  | ||||||
|     A ``trio.SocketStream`` delivering ``msgpack`` formatted data |  | ||||||
|     using the ``msgspec`` codec lib. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     layer_key: int = 4 |  | ||||||
|     name_key: str = 'tcp' |  | ||||||
| 
 |  | ||||||
|     # TODO: better naming for this? |  | ||||||
|     # -[ ] check how libp2p does naming for such things? |  | ||||||
|     codec_key: str = 'msgpack' |  | ||||||
| 
 |  | ||||||
|     def __init__( |  | ||||||
|         self, |  | ||||||
|         stream: trio.SocketStream, |  | ||||||
|         prefix_size: int = 4, |  | ||||||
| 
 |  | ||||||
|         # XXX optionally provided codec pair for `msgspec`: |  | ||||||
|         # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types |  | ||||||
|         # |  | ||||||
|         # TODO: define this as a `Codec` struct which can be |  | ||||||
|         # overriden dynamically by the application/runtime? |  | ||||||
|         codec: tuple[ |  | ||||||
|             Callable[[Any], Any]|None,  # coder |  | ||||||
|             Callable[[type, Any], Any]|None,  # decoder |  | ||||||
|         ]|None = None, |  | ||||||
| 
 |  | ||||||
|     ) -> None: |  | ||||||
| 
 |  | ||||||
|         self.stream = stream |  | ||||||
|         assert self.stream.socket |  | ||||||
| 
 |  | ||||||
|         # should both be IP sockets |  | ||||||
|         self._laddr, self._raddr = get_stream_addrs(stream) |  | ||||||
| 
 |  | ||||||
|         # create read loop instance |  | ||||||
|         self._aiter_pkts = self._iter_packets() |  | ||||||
|         self._send_lock = trio.StrictFIFOLock() |  | ||||||
| 
 |  | ||||||
|         # public i guess? |  | ||||||
|         self.drained: list[dict] = [] |  | ||||||
| 
 |  | ||||||
|         self.recv_stream = BufferedReceiveStream( |  | ||||||
|             transport_stream=stream |  | ||||||
|         ) |  | ||||||
|         self.prefix_size = prefix_size |  | ||||||
| 
 |  | ||||||
|         # allow for custom IPC msg interchange format |  | ||||||
|         # dynamic override Bo |  | ||||||
|         self._task = trio.lowlevel.current_task() |  | ||||||
| 
 |  | ||||||
|         # XXX for ctxvar debug only! |  | ||||||
|         # self._codec: MsgCodec = ( |  | ||||||
|         #     codec |  | ||||||
|         #     or |  | ||||||
|         #     _codec._ctxvar_MsgCodec.get() |  | ||||||
|         # ) |  | ||||||
| 
 |  | ||||||
|     async def _iter_packets(self) -> AsyncGenerator[dict, None]: |  | ||||||
|         ''' |  | ||||||
|         Yield `bytes`-blob decoded packets from the underlying TCP |  | ||||||
|         stream using the current task's `MsgCodec`. |  | ||||||
| 
 |  | ||||||
|         This is a streaming routine implemented as an async generator |  | ||||||
|         func (which was the original design, but could be changed?) |  | ||||||
|         and is allocated by a `.__call__()` inside `.__init__()` where |  | ||||||
|         it is assigned to the `._aiter_pkts` attr. |  | ||||||
| 
 |  | ||||||
|         ''' |  | ||||||
|         decodes_failed: int = 0 |  | ||||||
| 
 |  | ||||||
|         while True: |  | ||||||
|             try: |  | ||||||
|                 header: bytes = await self.recv_stream.receive_exactly(4) |  | ||||||
|             except ( |  | ||||||
|                 ValueError, |  | ||||||
|                 ConnectionResetError, |  | ||||||
| 
 |  | ||||||
|                 # not sure entirely why we need this but without it we |  | ||||||
|                 # seem to be getting racy failures here on |  | ||||||
|                 # arbiter/registry name subs.. |  | ||||||
|                 trio.BrokenResourceError, |  | ||||||
| 
 |  | ||||||
|             ) as trans_err: |  | ||||||
| 
 |  | ||||||
|                 loglevel = 'transport' |  | ||||||
|                 match trans_err: |  | ||||||
|                     # case ( |  | ||||||
|                     #     ConnectionResetError() |  | ||||||
|                     # ): |  | ||||||
|                     #     loglevel = 'transport' |  | ||||||
| 
 |  | ||||||
|                     # peer actor (graceful??) TCP EOF but `tricycle` |  | ||||||
|                     # seems to raise a 0-bytes-read? |  | ||||||
|                     case ValueError() if ( |  | ||||||
|                         'unclean EOF' in trans_err.args[0] |  | ||||||
|                     ): |  | ||||||
|                         pass |  | ||||||
| 
 |  | ||||||
|                     # peer actor (task) prolly shutdown quickly due |  | ||||||
|                     # to cancellation |  | ||||||
|                     case trio.BrokenResourceError() if ( |  | ||||||
|                         'Connection reset by peer' in trans_err.args[0] |  | ||||||
|                     ): |  | ||||||
|                         pass |  | ||||||
| 
 |  | ||||||
|                     # unless the disconnect condition falls under "a |  | ||||||
|                     # normal operation breakage" we usualy console warn |  | ||||||
|                     # about it. |  | ||||||
|                     case _: |  | ||||||
|                         loglevel: str = 'warning' |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|                 raise TransportClosed( |  | ||||||
|                     message=( |  | ||||||
|                         f'IPC transport already closed by peer\n' |  | ||||||
|                         f'x]> {type(trans_err)}\n' |  | ||||||
|                         f'  |_{self}\n' |  | ||||||
|                     ), |  | ||||||
|                     loglevel=loglevel, |  | ||||||
|                 ) from trans_err |  | ||||||
| 
 |  | ||||||
|             # XXX definitely can happen if transport is closed |  | ||||||
|             # manually by another `trio.lowlevel.Task` in the |  | ||||||
|             # same actor; we use this in some simulated fault |  | ||||||
|             # testing for ex, but generally should never happen |  | ||||||
|             # under normal operation! |  | ||||||
|             # |  | ||||||
|             # NOTE: as such we always re-raise this error from the |  | ||||||
|             #       RPC msg loop! |  | ||||||
|             except trio.ClosedResourceError as closure_err: |  | ||||||
|                 raise TransportClosed( |  | ||||||
|                     message=( |  | ||||||
|                         f'IPC transport already manually closed locally?\n' |  | ||||||
|                         f'x]> {type(closure_err)} \n' |  | ||||||
|                         f'  |_{self}\n' |  | ||||||
|                     ), |  | ||||||
|                     loglevel='error', |  | ||||||
|                     raise_on_report=( |  | ||||||
|                         closure_err.args[0] == 'another task closed this fd' |  | ||||||
|                         or |  | ||||||
|                         closure_err.args[0] in ['another task closed this fd'] |  | ||||||
|                     ), |  | ||||||
|                 ) from closure_err |  | ||||||
| 
 |  | ||||||
|             # graceful TCP EOF disconnect |  | ||||||
|             if header == b'': |  | ||||||
|                 raise TransportClosed( |  | ||||||
|                     message=( |  | ||||||
|                         f'IPC transport already gracefully closed\n' |  | ||||||
|                         f']>\n' |  | ||||||
|                         f' |_{self}\n' |  | ||||||
|                     ), |  | ||||||
|                     loglevel='transport', |  | ||||||
|                     # cause=???  # handy or no? |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|             size: int |  | ||||||
|             size, = struct.unpack("<I", header) |  | ||||||
| 
 |  | ||||||
|             log.transport(f'received header {size}')  # type: ignore |  | ||||||
|             msg_bytes: bytes = await self.recv_stream.receive_exactly(size) |  | ||||||
| 
 |  | ||||||
|             log.transport(f"received {msg_bytes}")  # type: ignore |  | ||||||
|             try: |  | ||||||
|                 # NOTE: lookup the `trio.Task.context`'s var for |  | ||||||
|                 # the current `MsgCodec`. |  | ||||||
|                 codec: MsgCodec = _ctxvar_MsgCodec.get() |  | ||||||
| 
 |  | ||||||
|                 # XXX for ctxvar debug only! |  | ||||||
|                 # if self._codec.pld_spec != codec.pld_spec: |  | ||||||
|                 #     assert ( |  | ||||||
|                 #         task := trio.lowlevel.current_task() |  | ||||||
|                 #     ) is not self._task |  | ||||||
|                 #     self._task = task |  | ||||||
|                 #     self._codec = codec |  | ||||||
|                 #     log.runtime( |  | ||||||
|                 #         f'Using new codec in {self}.recv()\n' |  | ||||||
|                 #         f'codec: {self._codec}\n\n' |  | ||||||
|                 #         f'msg_bytes: {msg_bytes}\n' |  | ||||||
|                 #     ) |  | ||||||
|                 yield codec.decode(msg_bytes) |  | ||||||
| 
 |  | ||||||
|             # XXX NOTE: since the below error derives from |  | ||||||
|             # `DecodeError` we need to catch is specially |  | ||||||
|             # and always raise such that spec violations |  | ||||||
|             # are never allowed to be caught silently! |  | ||||||
|             except msgspec.ValidationError as verr: |  | ||||||
|                 msgtyperr: MsgTypeError = _mk_recv_mte( |  | ||||||
|                     msg=msg_bytes, |  | ||||||
|                     codec=codec, |  | ||||||
|                     src_validation_error=verr, |  | ||||||
|                 ) |  | ||||||
|                 # XXX deliver up to `Channel.recv()` where |  | ||||||
|                 # a re-raise and `Error`-pack can inject the far |  | ||||||
|                 # end actor `.uid`. |  | ||||||
|                 yield msgtyperr |  | ||||||
| 
 |  | ||||||
|             except ( |  | ||||||
|                 msgspec.DecodeError, |  | ||||||
|                 UnicodeDecodeError, |  | ||||||
|             ): |  | ||||||
|                 if decodes_failed < 4: |  | ||||||
|                     # ignore decoding errors for now and assume they have to |  | ||||||
|                     # do with a channel drop - hope that receiving from the |  | ||||||
|                     # channel will raise an expected error and bubble up. |  | ||||||
|                     try: |  | ||||||
|                         msg_str: str|bytes = msg_bytes.decode() |  | ||||||
|                     except UnicodeDecodeError: |  | ||||||
|                         msg_str = msg_bytes |  | ||||||
| 
 |  | ||||||
|                     log.exception( |  | ||||||
|                         'Failed to decode msg?\n' |  | ||||||
|                         f'{codec}\n\n' |  | ||||||
|                         'Rxed bytes from wire:\n\n' |  | ||||||
|                         f'{msg_str!r}\n' |  | ||||||
|                     ) |  | ||||||
|                     decodes_failed += 1 |  | ||||||
|                 else: |  | ||||||
|                     raise |  | ||||||
| 
 |  | ||||||
|     async def send( |  | ||||||
|         self, |  | ||||||
|         msg: msgtypes.MsgType, |  | ||||||
| 
 |  | ||||||
|         strict_types: bool = True, |  | ||||||
|         hide_tb: bool = False, |  | ||||||
| 
 |  | ||||||
|     ) -> None: |  | ||||||
|         ''' |  | ||||||
|         Send a msgpack encoded py-object-blob-as-msg over TCP. |  | ||||||
| 
 |  | ||||||
|         If `strict_types == True` then a `MsgTypeError` will be raised on any |  | ||||||
|         invalid msg type |  | ||||||
| 
 |  | ||||||
|         ''' |  | ||||||
|         __tracebackhide__: bool = hide_tb |  | ||||||
| 
 |  | ||||||
|         # XXX see `trio._sync.AsyncContextManagerMixin` for details |  | ||||||
|         # on the `.acquire()`/`.release()` sequencing.. |  | ||||||
|         async with self._send_lock: |  | ||||||
| 
 |  | ||||||
|             # NOTE: lookup the `trio.Task.context`'s var for |  | ||||||
|             # the current `MsgCodec`. |  | ||||||
|             codec: MsgCodec = _ctxvar_MsgCodec.get() |  | ||||||
| 
 |  | ||||||
|             # XXX for ctxvar debug only! |  | ||||||
|             # if self._codec.pld_spec != codec.pld_spec: |  | ||||||
|             #     self._codec = codec |  | ||||||
|             #     log.runtime( |  | ||||||
|             #         f'Using new codec in {self}.send()\n' |  | ||||||
|             #         f'codec: {self._codec}\n\n' |  | ||||||
|             #         f'msg: {msg}\n' |  | ||||||
|             #     ) |  | ||||||
| 
 |  | ||||||
|             if type(msg) not in msgtypes.__msg_types__: |  | ||||||
|                 if strict_types: |  | ||||||
|                     raise _mk_send_mte( |  | ||||||
|                         msg, |  | ||||||
|                         codec=codec, |  | ||||||
|                     ) |  | ||||||
|                 else: |  | ||||||
|                     log.warning( |  | ||||||
|                         'Sending non-`Msg`-spec msg?\n\n' |  | ||||||
|                         f'{msg}\n' |  | ||||||
|                     ) |  | ||||||
| 
 |  | ||||||
|             try: |  | ||||||
|                 bytes_data: bytes = codec.encode(msg) |  | ||||||
|             except TypeError as _err: |  | ||||||
|                 typerr = _err |  | ||||||
|                 msgtyperr: MsgTypeError = _mk_send_mte( |  | ||||||
|                     msg, |  | ||||||
|                     codec=codec, |  | ||||||
|                     message=( |  | ||||||
|                         f'IPC-msg-spec violation in\n\n' |  | ||||||
|                         f'{pretty_struct.Struct.pformat(msg)}' |  | ||||||
|                     ), |  | ||||||
|                     src_type_error=typerr, |  | ||||||
|                 ) |  | ||||||
|                 raise msgtyperr from typerr |  | ||||||
| 
 |  | ||||||
|             # supposedly the fastest says, |  | ||||||
|             # https://stackoverflow.com/a/54027962 |  | ||||||
|             size: bytes = struct.pack("<I", len(bytes_data)) |  | ||||||
|             return await self.stream.send_all(size + bytes_data) |  | ||||||
| 
 |  | ||||||
|         # ?TODO? does it help ever to dynamically show this |  | ||||||
|         # frame? |  | ||||||
|         # try: |  | ||||||
|         #     <the-above_code> |  | ||||||
|         # except BaseException as _err: |  | ||||||
|         #     err = _err |  | ||||||
|         #     if not isinstance(err, MsgTypeError): |  | ||||||
|         #         __tracebackhide__: bool = False |  | ||||||
|         #     raise |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def laddr(self) -> tuple[str, int]: |  | ||||||
|         return self._laddr |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def raddr(self) -> tuple[str, int]: |  | ||||||
|         return self._raddr |  | ||||||
| 
 |  | ||||||
|     async def recv(self) -> Any: |  | ||||||
|         return await self._aiter_pkts.asend(None) |  | ||||||
| 
 |  | ||||||
|     async def drain(self) -> AsyncIterator[dict]: |  | ||||||
|         ''' |  | ||||||
|         Drain the stream's remaining messages sent from |  | ||||||
|         the far end until the connection is closed by |  | ||||||
|         the peer. |  | ||||||
| 
 |  | ||||||
|         ''' |  | ||||||
|         try: |  | ||||||
|             async for msg in self._iter_packets(): |  | ||||||
|                 self.drained.append(msg) |  | ||||||
|         except TransportClosed: |  | ||||||
|             for msg in self.drained: |  | ||||||
|                 yield msg |  | ||||||
| 
 |  | ||||||
|     def __aiter__(self): |  | ||||||
|         return self._aiter_pkts |  | ||||||
| 
 |  | ||||||
|     def connected(self) -> bool: |  | ||||||
|         return self.stream.socket.fileno() != -1 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def get_msg_transport( |  | ||||||
| 
 |  | ||||||
|     key: tuple[str, str], |  | ||||||
| 
 |  | ||||||
| ) -> Type[MsgTransport]: |  | ||||||
| 
 |  | ||||||
|     return { |  | ||||||
|         ('msgpack', 'tcp'): MsgpackTCPStream, |  | ||||||
|     }[key] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class Channel: |  | ||||||
|     ''' |  | ||||||
|     An inter-process channel for communication between (remote) actors. |  | ||||||
| 
 |  | ||||||
|     Wraps a ``MsgStream``: transport + encoding IPC connection. |  | ||||||
| 
 |  | ||||||
|     Currently we only support ``trio.SocketStream`` for transport |  | ||||||
|     (aka TCP) and the ``msgpack`` interchange format via the ``msgspec`` |  | ||||||
|     codec libary. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     def __init__( |  | ||||||
| 
 |  | ||||||
|         self, |  | ||||||
|         destaddr: tuple[str, int]|None, |  | ||||||
| 
 |  | ||||||
|         msg_transport_type_key: tuple[str, str] = ('msgpack', 'tcp'), |  | ||||||
| 
 |  | ||||||
|         # TODO: optional reconnection support? |  | ||||||
|         # auto_reconnect: bool = False, |  | ||||||
|         # on_reconnect: typing.Callable[..., typing.Awaitable] = None, |  | ||||||
| 
 |  | ||||||
|     ) -> None: |  | ||||||
| 
 |  | ||||||
|         # self._recon_seq = on_reconnect |  | ||||||
|         # self._autorecon = auto_reconnect |  | ||||||
| 
 |  | ||||||
|         self._destaddr = destaddr |  | ||||||
|         self._transport_key = msg_transport_type_key |  | ||||||
| 
 |  | ||||||
|         # Either created in ``.connect()`` or passed in by |  | ||||||
|         # user in ``.from_stream()``. |  | ||||||
|         self._stream: trio.SocketStream|None = None |  | ||||||
|         self._transport: MsgTransport|None = None |  | ||||||
| 
 |  | ||||||
|         # set after handshake - always uid of far end |  | ||||||
|         self.uid: tuple[str, str]|None = None |  | ||||||
| 
 |  | ||||||
|         self._aiter_msgs = self._iter_msgs() |  | ||||||
|         self._exc: Exception|None = None  # set if far end actor errors |  | ||||||
|         self._closed: bool = False |  | ||||||
| 
 |  | ||||||
|         # flag set by ``Portal.cancel_actor()`` indicating remote |  | ||||||
|         # (possibly peer) cancellation of the far end actor |  | ||||||
|         # runtime. |  | ||||||
|         self._cancel_called: bool = False |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def msgstream(self) -> MsgTransport: |  | ||||||
|         log.info( |  | ||||||
|             '`Channel.msgstream` is an old name, use `._transport`' |  | ||||||
|         ) |  | ||||||
|         return self._transport |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def transport(self) -> MsgTransport: |  | ||||||
|         return self._transport |  | ||||||
| 
 |  | ||||||
|     @classmethod |  | ||||||
|     def from_stream( |  | ||||||
|         cls, |  | ||||||
|         stream: trio.SocketStream, |  | ||||||
|         **kwargs, |  | ||||||
| 
 |  | ||||||
|     ) -> Channel: |  | ||||||
| 
 |  | ||||||
|         src, dst = get_stream_addrs(stream) |  | ||||||
|         chan = Channel( |  | ||||||
|             destaddr=dst, |  | ||||||
|             **kwargs, |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         # set immediately here from provided instance |  | ||||||
|         chan._stream: trio.SocketStream = stream |  | ||||||
|         chan.set_msg_transport(stream) |  | ||||||
|         return chan |  | ||||||
| 
 |  | ||||||
|     def set_msg_transport( |  | ||||||
|         self, |  | ||||||
|         stream: trio.SocketStream, |  | ||||||
|         type_key: tuple[str, str]|None = None, |  | ||||||
| 
 |  | ||||||
|         # XXX optionally provided codec pair for `msgspec`: |  | ||||||
|         # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types |  | ||||||
|         codec: MsgCodec|None = None, |  | ||||||
| 
 |  | ||||||
|     ) -> MsgTransport: |  | ||||||
|         type_key = ( |  | ||||||
|             type_key |  | ||||||
|             or |  | ||||||
|             self._transport_key |  | ||||||
|         ) |  | ||||||
|         # get transport type, then |  | ||||||
|         self._transport = get_msg_transport( |  | ||||||
|             type_key |  | ||||||
|         # instantiate an instance of the msg-transport |  | ||||||
|         )( |  | ||||||
|             stream, |  | ||||||
|             codec=codec, |  | ||||||
|         ) |  | ||||||
|         return self._transport |  | ||||||
| 
 |  | ||||||
|     @cm |  | ||||||
|     def apply_codec( |  | ||||||
|         self, |  | ||||||
|         codec: MsgCodec, |  | ||||||
| 
 |  | ||||||
|     ) -> None: |  | ||||||
|         ''' |  | ||||||
|         Temporarily override the underlying IPC msg codec for |  | ||||||
|         dynamic enforcement of messaging schema. |  | ||||||
| 
 |  | ||||||
|         ''' |  | ||||||
|         orig: MsgCodec = self._transport.codec |  | ||||||
|         try: |  | ||||||
|             self._transport.codec = codec |  | ||||||
|             yield |  | ||||||
|         finally: |  | ||||||
|             self._transport.codec = orig |  | ||||||
| 
 |  | ||||||
|     # TODO: do a .src/.dst: str for maddrs? |  | ||||||
|     def __repr__(self) -> str: |  | ||||||
|         if not self._transport: |  | ||||||
|             return '<Channel with inactive transport?>' |  | ||||||
| 
 |  | ||||||
|         return repr( |  | ||||||
|             self._transport.stream.socket._sock |  | ||||||
|         ).replace(  # type: ignore |  | ||||||
|             "socket.socket", |  | ||||||
|             "Channel", |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def laddr(self) -> tuple[str, int]|None: |  | ||||||
|         return self._transport.laddr if self._transport else None |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def raddr(self) -> tuple[str, int]|None: |  | ||||||
|         return self._transport.raddr if self._transport else None |  | ||||||
| 
 |  | ||||||
|     async def connect( |  | ||||||
|         self, |  | ||||||
|         destaddr: tuple[Any, ...] | None = None, |  | ||||||
|         **kwargs |  | ||||||
| 
 |  | ||||||
|     ) -> MsgTransport: |  | ||||||
| 
 |  | ||||||
|         if self.connected(): |  | ||||||
|             raise RuntimeError("channel is already connected?") |  | ||||||
| 
 |  | ||||||
|         destaddr = destaddr or self._destaddr |  | ||||||
|         assert isinstance(destaddr, tuple) |  | ||||||
| 
 |  | ||||||
|         stream = await trio.open_tcp_stream( |  | ||||||
|             *destaddr, |  | ||||||
|             **kwargs |  | ||||||
|         ) |  | ||||||
|         transport = self.set_msg_transport(stream) |  | ||||||
| 
 |  | ||||||
|         log.transport( |  | ||||||
|             f'Opened channel[{type(transport)}]: {self.laddr} -> {self.raddr}' |  | ||||||
|         ) |  | ||||||
|         return transport |  | ||||||
| 
 |  | ||||||
|     # TODO: something like, |  | ||||||
|     # `pdbp.hideframe_on(errors=[MsgTypeError])` |  | ||||||
|     # instead of the `try/except` hack we have rn.. |  | ||||||
|     # seems like a pretty useful thing to have in general |  | ||||||
|     # along with being able to filter certain stack frame(s / sets) |  | ||||||
|     # possibly based on the current log-level? |  | ||||||
|     async def send( |  | ||||||
|         self, |  | ||||||
|         payload: Any, |  | ||||||
| 
 |  | ||||||
|         hide_tb: bool = False, |  | ||||||
| 
 |  | ||||||
|     ) -> None: |  | ||||||
|         ''' |  | ||||||
|         Send a coded msg-blob over the transport. |  | ||||||
| 
 |  | ||||||
|         ''' |  | ||||||
|         __tracebackhide__: bool = hide_tb |  | ||||||
|         try: |  | ||||||
|             log.transport( |  | ||||||
|                 '=> send IPC msg:\n\n' |  | ||||||
|                 f'{pformat(payload)}\n' |  | ||||||
|             ) |  | ||||||
|             # assert self._transport  # but why typing? |  | ||||||
|             await self._transport.send( |  | ||||||
|                 payload, |  | ||||||
|                 hide_tb=hide_tb, |  | ||||||
|             ) |  | ||||||
|         except BaseException as _err: |  | ||||||
|             err = _err  # bind for introspection |  | ||||||
|             if not isinstance(_err, MsgTypeError): |  | ||||||
|                 # assert err |  | ||||||
|                 __tracebackhide__: bool = False |  | ||||||
|             else: |  | ||||||
|                 assert err.cid |  | ||||||
| 
 |  | ||||||
|             raise |  | ||||||
| 
 |  | ||||||
|     async def recv(self) -> Any: |  | ||||||
|         assert self._transport |  | ||||||
|         return await self._transport.recv() |  | ||||||
| 
 |  | ||||||
|         # TODO: auto-reconnect features like 0mq/nanomsg? |  | ||||||
|         # -[ ] implement it manually with nods to SC prot |  | ||||||
|         #      possibly on multiple transport backends? |  | ||||||
|         #  -> seems like that might be re-inventing scalability |  | ||||||
|         #     prots tho no? |  | ||||||
|         # try: |  | ||||||
|         #     return await self._transport.recv() |  | ||||||
|         # except trio.BrokenResourceError: |  | ||||||
|         #     if self._autorecon: |  | ||||||
|         #         await self._reconnect() |  | ||||||
|         #         return await self.recv() |  | ||||||
|         #     raise |  | ||||||
| 
 |  | ||||||
|     async def aclose(self) -> None: |  | ||||||
| 
 |  | ||||||
|         log.transport( |  | ||||||
|             f'Closing channel to {self.uid} ' |  | ||||||
|             f'{self.laddr} -> {self.raddr}' |  | ||||||
|         ) |  | ||||||
|         assert self._transport |  | ||||||
|         await self._transport.stream.aclose() |  | ||||||
|         self._closed = True |  | ||||||
| 
 |  | ||||||
|     async def __aenter__(self): |  | ||||||
|         await self.connect() |  | ||||||
|         return self |  | ||||||
| 
 |  | ||||||
|     async def __aexit__(self, *args): |  | ||||||
|         await self.aclose(*args) |  | ||||||
| 
 |  | ||||||
|     def __aiter__(self): |  | ||||||
|         return self._aiter_msgs |  | ||||||
| 
 |  | ||||||
|     # ?TODO? run any reconnection sequence? |  | ||||||
|     # -[ ] prolly should be impl-ed as deco-API? |  | ||||||
|     # |  | ||||||
|     # async def _reconnect(self) -> None: |  | ||||||
|     #     """Handle connection failures by polling until a reconnect can be |  | ||||||
|     #     established. |  | ||||||
|     #     """ |  | ||||||
|     #     down = False |  | ||||||
|     #     while True: |  | ||||||
|     #         try: |  | ||||||
|     #             with trio.move_on_after(3) as cancel_scope: |  | ||||||
|     #                 await self.connect() |  | ||||||
|     #             cancelled = cancel_scope.cancelled_caught |  | ||||||
|     #             if cancelled: |  | ||||||
|     #                 log.transport( |  | ||||||
|     #                     "Reconnect timed out after 3 seconds, retrying...") |  | ||||||
|     #                 continue |  | ||||||
|     #             else: |  | ||||||
|     #                 log.transport("Stream connection re-established!") |  | ||||||
| 
 |  | ||||||
|     #                 # on_recon = self._recon_seq |  | ||||||
|     #                 # if on_recon: |  | ||||||
|     #                 #     await on_recon(self) |  | ||||||
| 
 |  | ||||||
|     #                 break |  | ||||||
|     #         except (OSError, ConnectionRefusedError): |  | ||||||
|     #             if not down: |  | ||||||
|     #                 down = True |  | ||||||
|     #                 log.transport( |  | ||||||
|     #                     f"Connection to {self.raddr} went down, waiting" |  | ||||||
|     #                     " for re-establishment") |  | ||||||
|     #             await trio.sleep(1) |  | ||||||
| 
 |  | ||||||
|     async def _iter_msgs( |  | ||||||
|         self |  | ||||||
|     ) -> AsyncGenerator[Any, None]: |  | ||||||
|         ''' |  | ||||||
|         Yield `MsgType` IPC msgs decoded and deliverd from |  | ||||||
|         an underlying `MsgTransport` protocol. |  | ||||||
| 
 |  | ||||||
|         This is a streaming routine alo implemented as an async-gen |  | ||||||
|         func (same a `MsgTransport._iter_pkts()`) gets allocated by |  | ||||||
|         a `.__call__()` inside `.__init__()` where it is assigned to |  | ||||||
|         the `._aiter_msgs` attr. |  | ||||||
| 
 |  | ||||||
|         ''' |  | ||||||
|         assert self._transport |  | ||||||
|         while True: |  | ||||||
|             try: |  | ||||||
|                 async for msg in self._transport: |  | ||||||
|                     match msg: |  | ||||||
|                         # NOTE: if transport/interchange delivers |  | ||||||
|                         # a type error, we pack it with the far |  | ||||||
|                         # end peer `Actor.uid` and relay the |  | ||||||
|                         # `Error`-msg upward to the `._rpc` stack |  | ||||||
|                         # for normal RAE handling. |  | ||||||
|                         case MsgTypeError(): |  | ||||||
|                             yield pack_from_raise( |  | ||||||
|                                 local_err=msg, |  | ||||||
|                                 cid=msg.cid, |  | ||||||
| 
 |  | ||||||
|                                 # XXX we pack it here bc lower |  | ||||||
|                                 # layers have no notion of an |  | ||||||
|                                 # actor-id ;) |  | ||||||
|                                 src_uid=self.uid, |  | ||||||
|                             ) |  | ||||||
|                         case _: |  | ||||||
|                             yield msg |  | ||||||
| 
 |  | ||||||
|             except trio.BrokenResourceError: |  | ||||||
| 
 |  | ||||||
|                 # if not self._autorecon: |  | ||||||
|                 raise |  | ||||||
| 
 |  | ||||||
|             await self.aclose() |  | ||||||
| 
 |  | ||||||
|             # if self._autorecon:  # attempt reconnect |  | ||||||
|             #     await self._reconnect() |  | ||||||
|             #     continue |  | ||||||
| 
 |  | ||||||
|     def connected(self) -> bool: |  | ||||||
|         return self._transport.connected() if self._transport else False |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @acm |  | ||||||
| async def _connect_chan( |  | ||||||
|     host: str, |  | ||||||
|     port: int |  | ||||||
| 
 |  | ||||||
| ) -> typing.AsyncGenerator[Channel, None]: |  | ||||||
|     ''' |  | ||||||
|     Create and connect a channel with disconnect on context manager |  | ||||||
|     teardown. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     chan = Channel((host, port)) |  | ||||||
|     await chan.connect() |  | ||||||
|     yield chan |  | ||||||
|     with trio.CancelScope(shield=True): |  | ||||||
|         await chan.aclose() |  | ||||||
|  | @ -43,7 +43,7 @@ from .trionics import maybe_open_nursery | ||||||
| from ._state import ( | from ._state import ( | ||||||
|     current_actor, |     current_actor, | ||||||
| ) | ) | ||||||
| from ._ipc import Channel | from .ipc import Channel | ||||||
| from .log import get_logger | from .log import get_logger | ||||||
| from .msg import ( | from .msg import ( | ||||||
|     # Error, |     # Error, | ||||||
|  | @ -52,8 +52,8 @@ from .msg import ( | ||||||
|     Return, |     Return, | ||||||
| ) | ) | ||||||
| from ._exceptions import ( | from ._exceptions import ( | ||||||
|     # unpack_error, |  | ||||||
|     NoResult, |     NoResult, | ||||||
|  |     TransportClosed, | ||||||
| ) | ) | ||||||
| from ._context import ( | from ._context import ( | ||||||
|     Context, |     Context, | ||||||
|  | @ -107,6 +107,10 @@ class Portal: | ||||||
|         # point. |         # point. | ||||||
|         self._expect_result_ctx: Context|None = None |         self._expect_result_ctx: Context|None = None | ||||||
|         self._streams: set[MsgStream] = set() |         self._streams: set[MsgStream] = set() | ||||||
|  | 
 | ||||||
|  |         # TODO, this should be PRIVATE (and never used publicly)! since it's just | ||||||
|  |         # a cached ref to the local runtime instead of calling | ||||||
|  |         # `current_actor()` everywhere.. XD | ||||||
|         self.actor: Actor = current_actor() |         self.actor: Actor = current_actor() | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|  | @ -171,7 +175,7 @@ class Portal: | ||||||
|         # not expecting a "main" result |         # not expecting a "main" result | ||||||
|         if self._expect_result_ctx is None: |         if self._expect_result_ctx is None: | ||||||
|             log.warning( |             log.warning( | ||||||
|                 f"Portal for {self.channel.uid} not expecting a final" |                 f"Portal for {self.channel.aid} not expecting a final" | ||||||
|                 " result?\nresult() should only be called if subactor" |                 " result?\nresult() should only be called if subactor" | ||||||
|                 " was spawned with `ActorNursery.run_in_actor()`") |                 " was spawned with `ActorNursery.run_in_actor()`") | ||||||
|             return NoResult |             return NoResult | ||||||
|  | @ -218,7 +222,7 @@ class Portal: | ||||||
|         # IPC calls |         # IPC calls | ||||||
|         if self._streams: |         if self._streams: | ||||||
|             log.cancel( |             log.cancel( | ||||||
|                 f"Cancelling all streams with {self.channel.uid}") |                 f"Cancelling all streams with {self.channel.aid}") | ||||||
|             for stream in self._streams.copy(): |             for stream in self._streams.copy(): | ||||||
|                 try: |                 try: | ||||||
|                     await stream.aclose() |                     await stream.aclose() | ||||||
|  | @ -263,7 +267,7 @@ class Portal: | ||||||
|             return False |             return False | ||||||
| 
 | 
 | ||||||
|         reminfo: str = ( |         reminfo: str = ( | ||||||
|             f'c)=> {self.channel.uid}\n' |             f'c)=> {self.channel.aid}\n' | ||||||
|             f'  |_{chan}\n' |             f'  |_{chan}\n' | ||||||
|         ) |         ) | ||||||
|         log.cancel( |         log.cancel( | ||||||
|  | @ -301,14 +305,34 @@ class Portal: | ||||||
|             return False |             return False | ||||||
| 
 | 
 | ||||||
|         except ( |         except ( | ||||||
|  |             # XXX, should never really get raised unless we aren't | ||||||
|  |             # wrapping them in the below type by mistake? | ||||||
|  |             # | ||||||
|  |             # Leaving the catch here for now until we're very sure | ||||||
|  |             # all the cases (for various tpt protos) have indeed been | ||||||
|  |             # re-wrapped ;p | ||||||
|             trio.ClosedResourceError, |             trio.ClosedResourceError, | ||||||
|             trio.BrokenResourceError, |             trio.BrokenResourceError, | ||||||
|         ): | 
 | ||||||
|             log.debug( |             TransportClosed, | ||||||
|                 'IPC chan for actor already closed or broken?\n\n' |         ) as tpt_err: | ||||||
|                 f'{self.channel.uid}\n' |             report: str = ( | ||||||
|  |                 f'IPC chan for actor already closed or broken?\n\n' | ||||||
|  |                 f'{self.channel.aid}\n' | ||||||
|                 f' |_{self.channel}\n' |                 f' |_{self.channel}\n' | ||||||
|             ) |             ) | ||||||
|  |             match tpt_err: | ||||||
|  |                 case TransportClosed(): | ||||||
|  |                     log.debug(report) | ||||||
|  |                 case _: | ||||||
|  |                     report += ( | ||||||
|  |                         f'\n' | ||||||
|  |                         f'Unhandled low-level transport-closed/error during\n' | ||||||
|  |                         f'Portal.cancel_actor()` request?\n' | ||||||
|  |                         f'<{type(tpt_err).__name__}( {tpt_err} )>\n' | ||||||
|  |                     ) | ||||||
|  |                     log.warning(report) | ||||||
|  | 
 | ||||||
|             return False |             return False | ||||||
| 
 | 
 | ||||||
|     # TODO: do we still need this for low level `Actor`-runtime |     # TODO: do we still need this for low level `Actor`-runtime | ||||||
|  | @ -504,8 +528,12 @@ class LocalPortal: | ||||||
|         return it's result. |         return it's result. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         obj = self.actor if ns == 'self' else importlib.import_module(ns) |         obj = ( | ||||||
|         func = getattr(obj, func_name) |             self.actor | ||||||
|  |             if ns == 'self' | ||||||
|  |             else importlib.import_module(ns) | ||||||
|  |         ) | ||||||
|  |         func: Callable = getattr(obj, func_name) | ||||||
|         return await func(**kwargs) |         return await func(**kwargs) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -543,15 +571,17 @@ async def open_portal( | ||||||
|             await channel.connect() |             await channel.connect() | ||||||
|             was_connected = True |             was_connected = True | ||||||
| 
 | 
 | ||||||
|         if channel.uid is None: |         if channel.aid is None: | ||||||
|             await actor._do_handshake(channel) |             await channel._do_handshake( | ||||||
|  |                 aid=actor.aid, | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|         msg_loop_cs: trio.CancelScope|None = None |         msg_loop_cs: trio.CancelScope|None = None | ||||||
|         if start_msg_loop: |         if start_msg_loop: | ||||||
|             from ._runtime import process_messages |             from . import _rpc | ||||||
|             msg_loop_cs = await tn.start( |             msg_loop_cs = await tn.start( | ||||||
|                 partial( |                 partial( | ||||||
|                     process_messages, |                     _rpc.process_messages, | ||||||
|                     actor, |                     actor, | ||||||
|                     channel, |                     channel, | ||||||
|                     # if the local task is cancelled we want to keep |                     # if the local task is cancelled we want to keep | ||||||
|  |  | ||||||
							
								
								
									
										772
									
								
								tractor/_root.py
								
								
								
								
							
							
						
						
									
										772
									
								
								tractor/_root.py
								
								
								
								
							|  | @ -18,7 +18,9 @@ | ||||||
| Root actor runtime ignition(s). | Root actor runtime ignition(s). | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| from contextlib import asynccontextmanager as acm | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  | ) | ||||||
| from functools import partial | from functools import partial | ||||||
| import importlib | import importlib | ||||||
| import inspect | import inspect | ||||||
|  | @ -26,7 +28,10 @@ import logging | ||||||
| import os | import os | ||||||
| import signal | import signal | ||||||
| import sys | import sys | ||||||
| from typing import Callable | from typing import ( | ||||||
|  |     Any, | ||||||
|  |     Callable, | ||||||
|  | ) | ||||||
| import warnings | import warnings | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -43,33 +48,111 @@ from .devx import _debug | ||||||
| from . import _spawn | from . import _spawn | ||||||
| from . import _state | from . import _state | ||||||
| from . import log | from . import log | ||||||
| from ._ipc import _connect_chan | from .ipc import ( | ||||||
| from ._exceptions import is_multi_cancelled |     _connect_chan, | ||||||
| 
 | ) | ||||||
| 
 | from ._addr import ( | ||||||
| # set at startup and after forks |     Address, | ||||||
| _default_host: str = '127.0.0.1' |     UnwrappedAddress, | ||||||
| _default_port: int = 1616 |     default_lo_addrs, | ||||||
| 
 |     mk_uuid, | ||||||
| # default registry always on localhost |     wrap_address, | ||||||
| _default_lo_addrs: list[tuple[str, int]] = [( | ) | ||||||
|     _default_host, | from ._exceptions import ( | ||||||
|     _default_port, |     RuntimeFailure, | ||||||
| )] |     is_multi_cancelled, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| logger = log.get_logger('tractor') | logger = log.get_logger('tractor') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # TODO: stick this in a `@acm` defined in `devx._debug`? | ||||||
|  | # -[ ] also maybe consider making this a `wrapt`-deco to | ||||||
|  | #     save an indent level? | ||||||
|  | # | ||||||
|  | @acm | ||||||
|  | async def maybe_block_bp( | ||||||
|  |     debug_mode: bool, | ||||||
|  |     maybe_enable_greenback: bool, | ||||||
|  | ) -> bool: | ||||||
|  |     # Override the global debugger hook to make it play nice with | ||||||
|  |     # ``trio``, see much discussion in: | ||||||
|  |     # https://github.com/python-trio/trio/issues/1155#issuecomment-742964018 | ||||||
|  |     builtin_bp_handler: Callable = sys.breakpointhook | ||||||
|  |     orig_bp_path: str|None = os.environ.get( | ||||||
|  |         'PYTHONBREAKPOINT', | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  |     bp_blocked: bool | ||||||
|  |     if ( | ||||||
|  |         debug_mode | ||||||
|  |         and maybe_enable_greenback | ||||||
|  |         and ( | ||||||
|  |             maybe_mod := await _debug.maybe_init_greenback( | ||||||
|  |                 raise_not_found=False, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |     ): | ||||||
|  |         logger.info( | ||||||
|  |             f'Found `greenback` installed @ {maybe_mod}\n' | ||||||
|  |             'Enabling `tractor.pause_from_sync()` support!\n' | ||||||
|  |         ) | ||||||
|  |         os.environ['PYTHONBREAKPOINT'] = ( | ||||||
|  |             'tractor.devx._debug._sync_pause_from_builtin' | ||||||
|  |         ) | ||||||
|  |         _state._runtime_vars['use_greenback'] = True | ||||||
|  |         bp_blocked = False | ||||||
|  | 
 | ||||||
|  |     else: | ||||||
|  |         # TODO: disable `breakpoint()` by default (without | ||||||
|  |         # `greenback`) since it will break any multi-actor | ||||||
|  |         # usage by a clobbered TTY's stdstreams! | ||||||
|  |         def block_bps(*args, **kwargs): | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 'Trying to use `breakpoint()` eh?\n\n' | ||||||
|  |                 'Welp, `tractor` blocks `breakpoint()` built-in calls by default!\n' | ||||||
|  |                 'If you need to use it please install `greenback` and set ' | ||||||
|  |                 '`debug_mode=True` when opening the runtime ' | ||||||
|  |                 '(either via `.open_nursery()` or `open_root_actor()`)\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         sys.breakpointhook = block_bps | ||||||
|  |         # lol ok, | ||||||
|  |         # https://docs.python.org/3/library/sys.html#sys.breakpointhook | ||||||
|  |         os.environ['PYTHONBREAKPOINT'] = "0" | ||||||
|  |         bp_blocked = True | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|  |         yield bp_blocked | ||||||
|  |     finally: | ||||||
|  |         # restore any prior built-in `breakpoint()` hook state | ||||||
|  |         if builtin_bp_handler is not None: | ||||||
|  |             sys.breakpointhook = builtin_bp_handler | ||||||
|  | 
 | ||||||
|  |         if orig_bp_path is not None: | ||||||
|  |             os.environ['PYTHONBREAKPOINT'] = orig_bp_path | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             # clear env back to having no entry | ||||||
|  |             os.environ.pop('PYTHONBREAKPOINT', None) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @acm | @acm | ||||||
| async def open_root_actor( | async def open_root_actor( | ||||||
| 
 |  | ||||||
|     *, |     *, | ||||||
|     # defaults are above |     # defaults are above | ||||||
|     registry_addrs: list[tuple[str, int]]|None = None, |     registry_addrs: list[UnwrappedAddress]|None = None, | ||||||
| 
 | 
 | ||||||
|     # defaults are above |     # defaults are above | ||||||
|     arbiter_addr: tuple[str, int]|None = None, |     arbiter_addr: tuple[UnwrappedAddress]|None = None, | ||||||
|  | 
 | ||||||
|  |     enable_transports: list[ | ||||||
|  |         # TODO, this should eventually be the pairs as | ||||||
|  |         # defined by (codec, proto) as on `MsgTransport. | ||||||
|  |         _state.TransportProtocolKey, | ||||||
|  |     ]|None = None, | ||||||
| 
 | 
 | ||||||
|     name: str|None = 'root', |     name: str|None = 'root', | ||||||
| 
 | 
 | ||||||
|  | @ -111,350 +194,341 @@ async def open_root_actor( | ||||||
|     Runtime init entry point for ``tractor``. |     Runtime init entry point for ``tractor``. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     _debug.hide_runtime_frames() |     # XXX NEVER allow nested actor-trees! | ||||||
|     __tracebackhide__: bool = hide_tb |     if already_actor := _state.current_actor(err_on_no_runtime=False): | ||||||
| 
 |         rtvs: dict[str, Any] = _state._runtime_vars | ||||||
|     # TODO: stick this in a `@cm` defined in `devx._debug`? |         root_mailbox: list[str, int] = rtvs['_root_mailbox'] | ||||||
|     # |         registry_addrs: list[list[str, int]] = rtvs['_registry_addrs'] | ||||||
|     # Override the global debugger hook to make it play nice with |         raise RuntimeFailure( | ||||||
|     # ``trio``, see much discussion in: |             f'A current actor already exists !?\n' | ||||||
|     # https://github.com/python-trio/trio/issues/1155#issuecomment-742964018 |             f'({already_actor}\n' | ||||||
|     builtin_bp_handler: Callable = sys.breakpointhook |             f'\n' | ||||||
|     orig_bp_path: str|None = os.environ.get( |             f'You can NOT open a second root actor from within ' | ||||||
|         'PYTHONBREAKPOINT', |             f'an existing tree and the current root of this ' | ||||||
|         None, |             f'already exists !!\n' | ||||||
|     ) |             f'\n' | ||||||
|     if ( |             f'_root_mailbox: {root_mailbox!r}\n' | ||||||
|         debug_mode |             f'_registry_addrs: {registry_addrs!r}\n' | ||||||
|         and maybe_enable_greenback |  | ||||||
|         and ( |  | ||||||
|             maybe_mod := await _debug.maybe_init_greenback( |  | ||||||
|                 raise_not_found=False, |  | ||||||
|             ) |  | ||||||
|         ) |         ) | ||||||
|  | 
 | ||||||
|  |     async with maybe_block_bp( | ||||||
|  |         debug_mode=debug_mode, | ||||||
|  |         maybe_enable_greenback=maybe_enable_greenback, | ||||||
|     ): |     ): | ||||||
|         logger.info( |         if enable_transports is None: | ||||||
|             f'Found `greenback` installed @ {maybe_mod}\n' |             enable_transports: list[str] = _state.current_ipc_protos() | ||||||
|             'Enabling `tractor.pause_from_sync()` support!\n' |  | ||||||
|         ) |  | ||||||
|         os.environ['PYTHONBREAKPOINT'] = ( |  | ||||||
|             'tractor.devx._debug._sync_pause_from_builtin' |  | ||||||
|         ) |  | ||||||
|         _state._runtime_vars['use_greenback'] = True |  | ||||||
| 
 | 
 | ||||||
|     else: |             # TODO! support multi-tpts per actor! Bo | ||||||
|         # TODO: disable `breakpoint()` by default (without |             assert ( | ||||||
|         # `greenback`) since it will break any multi-actor |                 len(enable_transports) == 1 | ||||||
|         # usage by a clobbered TTY's stdstreams! |             ), 'No multi-tpt support yet!' | ||||||
|         def block_bps(*args, **kwargs): | 
 | ||||||
|             raise RuntimeError( |         _debug.hide_runtime_frames() | ||||||
|                 'Trying to use `breakpoint()` eh?\n\n' |         __tracebackhide__: bool = hide_tb | ||||||
|                 'Welp, `tractor` blocks `breakpoint()` built-in calls by default!\n' | 
 | ||||||
|                 'If you need to use it please install `greenback` and set ' |         # attempt to retreive ``trio``'s sigint handler and stash it | ||||||
|                 '`debug_mode=True` when opening the runtime ' |         # on our debugger lock state. | ||||||
|                 '(either via `.open_nursery()` or `open_root_actor()`)\n' |         _debug.DebugStatus._trio_handler = signal.getsignal(signal.SIGINT) | ||||||
|  | 
 | ||||||
|  |         # mark top most level process as root actor | ||||||
|  |         _state._runtime_vars['_is_root'] = True | ||||||
|  | 
 | ||||||
|  |         # caps based rpc list | ||||||
|  |         enable_modules = ( | ||||||
|  |             enable_modules | ||||||
|  |             or | ||||||
|  |             [] | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         if rpc_module_paths: | ||||||
|  |             warnings.warn( | ||||||
|  |                 "`rpc_module_paths` is now deprecated, use " | ||||||
|  |                 " `enable_modules` instead.", | ||||||
|  |                 DeprecationWarning, | ||||||
|  |                 stacklevel=2, | ||||||
|  |             ) | ||||||
|  |             enable_modules.extend(rpc_module_paths) | ||||||
|  | 
 | ||||||
|  |         if start_method is not None: | ||||||
|  |             _spawn.try_set_start_method(start_method) | ||||||
|  | 
 | ||||||
|  |         # TODO! remove this ASAP! | ||||||
|  |         if arbiter_addr is not None: | ||||||
|  |             warnings.warn( | ||||||
|  |                 '`arbiter_addr` is now deprecated\n' | ||||||
|  |                 'Use `registry_addrs: list[tuple]` instead..', | ||||||
|  |                 DeprecationWarning, | ||||||
|  |                 stacklevel=2, | ||||||
|  |             ) | ||||||
|  |             registry_addrs = [arbiter_addr] | ||||||
|  | 
 | ||||||
|  |         if not registry_addrs: | ||||||
|  |             registry_addrs: list[UnwrappedAddress] = default_lo_addrs( | ||||||
|  |                 enable_transports | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|         sys.breakpointhook = block_bps |         assert registry_addrs | ||||||
|         # lol ok, |  | ||||||
|         # https://docs.python.org/3/library/sys.html#sys.breakpointhook |  | ||||||
|         os.environ['PYTHONBREAKPOINT'] = "0" |  | ||||||
| 
 | 
 | ||||||
|     # attempt to retreive ``trio``'s sigint handler and stash it |         loglevel = ( | ||||||
|     # on our debugger lock state. |             loglevel | ||||||
|     _debug.DebugStatus._trio_handler = signal.getsignal(signal.SIGINT) |             or log._default_loglevel | ||||||
|  |         ).upper() | ||||||
| 
 | 
 | ||||||
|     # mark top most level process as root actor |  | ||||||
|     _state._runtime_vars['_is_root'] = True |  | ||||||
| 
 |  | ||||||
|     # caps based rpc list |  | ||||||
|     enable_modules = ( |  | ||||||
|         enable_modules |  | ||||||
|         or |  | ||||||
|         [] |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     if rpc_module_paths: |  | ||||||
|         warnings.warn( |  | ||||||
|             "`rpc_module_paths` is now deprecated, use " |  | ||||||
|             " `enable_modules` instead.", |  | ||||||
|             DeprecationWarning, |  | ||||||
|             stacklevel=2, |  | ||||||
|         ) |  | ||||||
|         enable_modules.extend(rpc_module_paths) |  | ||||||
| 
 |  | ||||||
|     if start_method is not None: |  | ||||||
|         _spawn.try_set_start_method(start_method) |  | ||||||
| 
 |  | ||||||
|     if arbiter_addr is not None: |  | ||||||
|         warnings.warn( |  | ||||||
|             '`arbiter_addr` is now deprecated\n' |  | ||||||
|             'Use `registry_addrs: list[tuple]` instead..', |  | ||||||
|             DeprecationWarning, |  | ||||||
|             stacklevel=2, |  | ||||||
|         ) |  | ||||||
|         registry_addrs = [arbiter_addr] |  | ||||||
| 
 |  | ||||||
|     registry_addrs: list[tuple[str, int]] = ( |  | ||||||
|         registry_addrs |  | ||||||
|         or |  | ||||||
|         _default_lo_addrs |  | ||||||
|     ) |  | ||||||
|     assert registry_addrs |  | ||||||
| 
 |  | ||||||
|     loglevel = ( |  | ||||||
|         loglevel |  | ||||||
|         or log._default_loglevel |  | ||||||
|     ).upper() |  | ||||||
| 
 |  | ||||||
|     if ( |  | ||||||
|         debug_mode |  | ||||||
|         and _spawn._spawn_method == 'trio' |  | ||||||
|     ): |  | ||||||
|         _state._runtime_vars['_debug_mode'] = True |  | ||||||
| 
 |  | ||||||
|         # expose internal debug module to every actor allowing for |  | ||||||
|         # use of ``await tractor.pause()`` |  | ||||||
|         enable_modules.append('tractor.devx._debug') |  | ||||||
| 
 |  | ||||||
|         # if debug mode get's enabled *at least* use that level of |  | ||||||
|         # logging for some informative console prompts. |  | ||||||
|         if ( |  | ||||||
|             logging.getLevelName( |  | ||||||
|                 # lul, need the upper case for the -> int map? |  | ||||||
|                 # sweet "dynamic function behaviour" stdlib... |  | ||||||
|                 loglevel, |  | ||||||
|             ) > logging.getLevelName('PDB') |  | ||||||
|         ): |  | ||||||
|             loglevel = 'PDB' |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     elif debug_mode: |  | ||||||
|         raise RuntimeError( |  | ||||||
|             "Debug mode is only supported for the `trio` backend!" |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     assert loglevel |  | ||||||
|     _log = log.get_console_log(loglevel) |  | ||||||
|     assert _log |  | ||||||
| 
 |  | ||||||
|     # TODO: factor this into `.devx._stackscope`!! |  | ||||||
|     if ( |  | ||||||
|         debug_mode |  | ||||||
|         and |  | ||||||
|         enable_stack_on_sig |  | ||||||
|     ): |  | ||||||
|         from .devx._stackscope import enable_stack_on_sig |  | ||||||
|         enable_stack_on_sig() |  | ||||||
| 
 |  | ||||||
|     # closed into below ping task-func |  | ||||||
|     ponged_addrs: list[tuple[str, int]] = [] |  | ||||||
| 
 |  | ||||||
|     async def ping_tpt_socket( |  | ||||||
|         addr: tuple[str, int], |  | ||||||
|         timeout: float = 1, |  | ||||||
|     ) -> None: |  | ||||||
|         ''' |  | ||||||
|         Attempt temporary connection to see if a registry is |  | ||||||
|         listening at the requested address by a tranport layer |  | ||||||
|         ping. |  | ||||||
| 
 |  | ||||||
|         If a connection can't be made quickly we assume none no |  | ||||||
|         server is listening at that addr. |  | ||||||
| 
 |  | ||||||
|         ''' |  | ||||||
|         try: |  | ||||||
|             # TODO: this connect-and-bail forces us to have to |  | ||||||
|             # carefully rewrap TCP 104-connection-reset errors as |  | ||||||
|             # EOF so as to avoid propagating cancel-causing errors |  | ||||||
|             # to the channel-msg loop machinery. Likely it would |  | ||||||
|             # be better to eventually have a "discovery" protocol |  | ||||||
|             # with basic handshake instead? |  | ||||||
|             with trio.move_on_after(timeout): |  | ||||||
|                 async with _connect_chan(*addr): |  | ||||||
|                     ponged_addrs.append(addr) |  | ||||||
| 
 |  | ||||||
|         except OSError: |  | ||||||
|             # TODO: make this a "discovery" log level? |  | ||||||
|             logger.info( |  | ||||||
|                 f'No actor registry found @ {addr}\n' |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|     async with trio.open_nursery() as tn: |  | ||||||
|         for addr in registry_addrs: |  | ||||||
|             tn.start_soon( |  | ||||||
|                 ping_tpt_socket, |  | ||||||
|                 tuple(addr),  # TODO: just drop this requirement? |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|     trans_bind_addrs: list[tuple[str, int]] = [] |  | ||||||
| 
 |  | ||||||
|     # Create a new local root-actor instance which IS NOT THE |  | ||||||
|     # REGISTRAR |  | ||||||
|     if ponged_addrs: |  | ||||||
|         if ensure_registry: |  | ||||||
|             raise RuntimeError( |  | ||||||
|                  f'Failed to open `{name}`@{ponged_addrs}: ' |  | ||||||
|                 'registry socket(s) already bound' |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|         # we were able to connect to an arbiter |  | ||||||
|         logger.info( |  | ||||||
|             f'Registry(s) seem(s) to exist @ {ponged_addrs}' |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         actor = Actor( |  | ||||||
|             name=name or 'anonymous', |  | ||||||
|             registry_addrs=ponged_addrs, |  | ||||||
|             loglevel=loglevel, |  | ||||||
|             enable_modules=enable_modules, |  | ||||||
|         ) |  | ||||||
|         # DO NOT use the registry_addrs as the transport server |  | ||||||
|         # addrs for this new non-registar, root-actor. |  | ||||||
|         for host, port in ponged_addrs: |  | ||||||
|             # NOTE: zero triggers dynamic OS port allocation |  | ||||||
|             trans_bind_addrs.append((host, 0)) |  | ||||||
| 
 |  | ||||||
|     # Start this local actor as the "registrar", aka a regular |  | ||||||
|     # actor who manages the local registry of "mailboxes" of |  | ||||||
|     # other process-tree-local sub-actors. |  | ||||||
|     else: |  | ||||||
| 
 |  | ||||||
|         # NOTE that if the current actor IS THE REGISTAR, the |  | ||||||
|         # following init steps are taken: |  | ||||||
|         # - the tranport layer server is bound to each (host, port) |  | ||||||
|         #   pair defined in provided registry_addrs, or the default. |  | ||||||
|         trans_bind_addrs = registry_addrs |  | ||||||
| 
 |  | ||||||
|         # - it is normally desirable for any registrar to stay up |  | ||||||
|         #   indefinitely until either all registered (child/sub) |  | ||||||
|         #   actors are terminated (via SC supervision) or, |  | ||||||
|         #   a re-election process has taken place.  |  | ||||||
|         # NOTE: all of ^ which is not implemented yet - see: |  | ||||||
|         # https://github.com/goodboy/tractor/issues/216 |  | ||||||
|         # https://github.com/goodboy/tractor/pull/348 |  | ||||||
|         # https://github.com/goodboy/tractor/issues/296 |  | ||||||
| 
 |  | ||||||
|         actor = Arbiter( |  | ||||||
|             name or 'registrar', |  | ||||||
|             registry_addrs=registry_addrs, |  | ||||||
|             loglevel=loglevel, |  | ||||||
|             enable_modules=enable_modules, |  | ||||||
|         ) |  | ||||||
|         # XXX, in case the root actor runtime was actually run from |  | ||||||
|         # `tractor.to_asyncio.run_as_asyncio_guest()` and NOt |  | ||||||
|         # `.trio.run()`. |  | ||||||
|         actor._infected_aio = _state._runtime_vars['_is_infected_aio'] |  | ||||||
| 
 |  | ||||||
|     # Start up main task set via core actor-runtime nurseries. |  | ||||||
|     try: |  | ||||||
|         # assign process-local actor |  | ||||||
|         _state._current_actor = actor |  | ||||||
| 
 |  | ||||||
|         # start local channel-server and fake the portal API |  | ||||||
|         # NOTE: this won't block since we provide the nursery |  | ||||||
|         ml_addrs_str: str = '\n'.join( |  | ||||||
|             f'@{addr}' for addr in trans_bind_addrs |  | ||||||
|         ) |  | ||||||
|         logger.info( |  | ||||||
|             f'Starting local {actor.uid} on the following transport addrs:\n' |  | ||||||
|             f'{ml_addrs_str}' |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         # start the actor runtime in a new task |  | ||||||
|         async with trio.open_nursery( |  | ||||||
|             strict_exception_groups=False, |  | ||||||
|             # ^XXX^ TODO? instead unpack any RAE as per "loose" style? |  | ||||||
|         ) as nursery: |  | ||||||
| 
 |  | ||||||
|             # ``_runtime.async_main()`` creates an internal nursery |  | ||||||
|             # and blocks here until any underlying actor(-process) |  | ||||||
|             # tree has terminated thereby conducting so called |  | ||||||
|             # "end-to-end" structured concurrency throughout an |  | ||||||
|             # entire hierarchical python sub-process set; all |  | ||||||
|             # "actor runtime" primitives are SC-compat and thus all |  | ||||||
|             # transitively spawned actors/processes must be as |  | ||||||
|             # well. |  | ||||||
|             await nursery.start( |  | ||||||
|                 partial( |  | ||||||
|                     async_main, |  | ||||||
|                     actor, |  | ||||||
|                     accept_addrs=trans_bind_addrs, |  | ||||||
|                     parent_addr=None |  | ||||||
|                 ) |  | ||||||
|             ) |  | ||||||
|             try: |  | ||||||
|                 yield actor |  | ||||||
|             except ( |  | ||||||
|                 Exception, |  | ||||||
|                 BaseExceptionGroup, |  | ||||||
|             ) as err: |  | ||||||
| 
 |  | ||||||
|                 # TODO, in beginning to handle the subsubactor with |  | ||||||
|                 # crashed grandparent cases.. |  | ||||||
|                 # |  | ||||||
|                 # was_locked: bool = await _debug.maybe_wait_for_debugger( |  | ||||||
|                 #     child_in_debug=True, |  | ||||||
|                 # ) |  | ||||||
|                 # XXX NOTE XXX see equiv note inside |  | ||||||
|                 # `._runtime.Actor._stream_handler()` where in the |  | ||||||
|                 # non-root or root-that-opened-this-mahually case we |  | ||||||
|                 # wait for the local actor-nursery to exit before |  | ||||||
|                 # exiting the transport channel handler. |  | ||||||
|                 entered: bool = await _debug._maybe_enter_pm( |  | ||||||
|                     err, |  | ||||||
|                     api_frame=inspect.currentframe(), |  | ||||||
|                     debug_filter=debug_filter, |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|                 if ( |  | ||||||
|                     not entered |  | ||||||
|                     and |  | ||||||
|                     not is_multi_cancelled( |  | ||||||
|                         err, |  | ||||||
|                     ) |  | ||||||
|                 ): |  | ||||||
|                     logger.exception('Root actor crashed\n') |  | ||||||
| 
 |  | ||||||
|                 # ALWAYS re-raise any error bubbled up from the |  | ||||||
|                 # runtime! |  | ||||||
|                 raise |  | ||||||
| 
 |  | ||||||
|             finally: |  | ||||||
|                 # NOTE: not sure if we'll ever need this but it's |  | ||||||
|                 # possibly better for even more determinism? |  | ||||||
|                 # logger.cancel( |  | ||||||
|                 #     f'Waiting on {len(nurseries)} nurseries in root..') |  | ||||||
|                 # nurseries = actor._actoruid2nursery.values() |  | ||||||
|                 # async with trio.open_nursery() as tempn: |  | ||||||
|                 #     for an in nurseries: |  | ||||||
|                 #         tempn.start_soon(an.exited.wait) |  | ||||||
| 
 |  | ||||||
|                 logger.info( |  | ||||||
|                     'Closing down root actor' |  | ||||||
|                 ) |  | ||||||
|                 await actor.cancel(None)  # self cancel |  | ||||||
|     finally: |  | ||||||
|         _state._current_actor = None |  | ||||||
|         _state._last_actor_terminated = actor |  | ||||||
| 
 |  | ||||||
|         # restore built-in `breakpoint()` hook state |  | ||||||
|         if ( |         if ( | ||||||
|             debug_mode |             debug_mode | ||||||
|             and |             and | ||||||
|             maybe_enable_greenback |             _spawn._spawn_method == 'trio' | ||||||
|         ): |         ): | ||||||
|             if builtin_bp_handler is not None: |             _state._runtime_vars['_debug_mode'] = True | ||||||
|                 sys.breakpointhook = builtin_bp_handler |  | ||||||
| 
 | 
 | ||||||
|             if orig_bp_path is not None: |             # expose internal debug module to every actor allowing for | ||||||
|                 os.environ['PYTHONBREAKPOINT'] = orig_bp_path |             # use of ``await tractor.pause()`` | ||||||
|  |             enable_modules.append('tractor.devx._debug') | ||||||
| 
 | 
 | ||||||
|             else: |             # if debug mode get's enabled *at least* use that level of | ||||||
|                 # clear env back to having no entry |             # logging for some informative console prompts. | ||||||
|                 os.environ.pop('PYTHONBREAKPOINT', None) |             if ( | ||||||
|  |                 logging.getLevelName( | ||||||
|  |                     # lul, need the upper case for the -> int map? | ||||||
|  |                     # sweet "dynamic function behaviour" stdlib... | ||||||
|  |                     loglevel, | ||||||
|  |                 ) > logging.getLevelName('PDB') | ||||||
|  |             ): | ||||||
|  |                 loglevel = 'PDB' | ||||||
| 
 | 
 | ||||||
|         logger.runtime("Root actor terminated") | 
 | ||||||
|  |         elif debug_mode: | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 "Debug mode is only supported for the `trio` backend!" | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         assert loglevel | ||||||
|  |         _log = log.get_console_log(loglevel) | ||||||
|  |         assert _log | ||||||
|  | 
 | ||||||
|  |         # TODO: factor this into `.devx._stackscope`!! | ||||||
|  |         if ( | ||||||
|  |             debug_mode | ||||||
|  |             and | ||||||
|  |             enable_stack_on_sig | ||||||
|  |         ): | ||||||
|  |             from .devx._stackscope import enable_stack_on_sig | ||||||
|  |             enable_stack_on_sig() | ||||||
|  | 
 | ||||||
|  |         # closed into below ping task-func | ||||||
|  |         ponged_addrs: list[UnwrappedAddress] = [] | ||||||
|  | 
 | ||||||
|  |         async def ping_tpt_socket( | ||||||
|  |             addr: UnwrappedAddress, | ||||||
|  |             timeout: float = 1, | ||||||
|  |         ) -> None: | ||||||
|  |             ''' | ||||||
|  |             Attempt temporary connection to see if a registry is | ||||||
|  |             listening at the requested address by a tranport layer | ||||||
|  |             ping. | ||||||
|  | 
 | ||||||
|  |             If a connection can't be made quickly we assume none no | ||||||
|  |             server is listening at that addr. | ||||||
|  | 
 | ||||||
|  |             ''' | ||||||
|  |             try: | ||||||
|  |                 # TODO: this connect-and-bail forces us to have to | ||||||
|  |                 # carefully rewrap TCP 104-connection-reset errors as | ||||||
|  |                 # EOF so as to avoid propagating cancel-causing errors | ||||||
|  |                 # to the channel-msg loop machinery. Likely it would | ||||||
|  |                 # be better to eventually have a "discovery" protocol | ||||||
|  |                 # with basic handshake instead? | ||||||
|  |                 with trio.move_on_after(timeout): | ||||||
|  |                     async with _connect_chan(addr): | ||||||
|  |                         ponged_addrs.append(addr) | ||||||
|  | 
 | ||||||
|  |             except OSError: | ||||||
|  |                 # TODO: make this a "discovery" log level? | ||||||
|  |                 logger.info( | ||||||
|  |                     f'No actor registry found @ {addr}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         async with trio.open_nursery() as tn: | ||||||
|  |             for addr in registry_addrs: | ||||||
|  |                 tn.start_soon( | ||||||
|  |                     ping_tpt_socket, | ||||||
|  |                     addr, | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         trans_bind_addrs: list[UnwrappedAddress] = [] | ||||||
|  | 
 | ||||||
|  |         # Create a new local root-actor instance which IS NOT THE | ||||||
|  |         # REGISTRAR | ||||||
|  |         if ponged_addrs: | ||||||
|  |             if ensure_registry: | ||||||
|  |                 raise RuntimeError( | ||||||
|  |                      f'Failed to open `{name}`@{ponged_addrs}: ' | ||||||
|  |                     'registry socket(s) already bound' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             # we were able to connect to an arbiter | ||||||
|  |             logger.info( | ||||||
|  |                 f'Registry(s) seem(s) to exist @ {ponged_addrs}' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             actor = Actor( | ||||||
|  |                 name=name or 'anonymous', | ||||||
|  |                 uuid=mk_uuid(), | ||||||
|  |                 registry_addrs=ponged_addrs, | ||||||
|  |                 loglevel=loglevel, | ||||||
|  |                 enable_modules=enable_modules, | ||||||
|  |             ) | ||||||
|  |             # DO NOT use the registry_addrs as the transport server | ||||||
|  |             # addrs for this new non-registar, root-actor. | ||||||
|  |             for addr in ponged_addrs: | ||||||
|  |                 waddr: Address = wrap_address(addr) | ||||||
|  |                 trans_bind_addrs.append( | ||||||
|  |                     waddr.get_random(bindspace=waddr.bindspace) | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         # Start this local actor as the "registrar", aka a regular | ||||||
|  |         # actor who manages the local registry of "mailboxes" of | ||||||
|  |         # other process-tree-local sub-actors. | ||||||
|  |         else: | ||||||
|  | 
 | ||||||
|  |             # NOTE that if the current actor IS THE REGISTAR, the | ||||||
|  |             # following init steps are taken: | ||||||
|  |             # - the tranport layer server is bound to each addr | ||||||
|  |             #   pair defined in provided registry_addrs, or the default. | ||||||
|  |             trans_bind_addrs = registry_addrs | ||||||
|  | 
 | ||||||
|  |             # - it is normally desirable for any registrar to stay up | ||||||
|  |             #   indefinitely until either all registered (child/sub) | ||||||
|  |             #   actors are terminated (via SC supervision) or, | ||||||
|  |             #   a re-election process has taken place. | ||||||
|  |             # NOTE: all of ^ which is not implemented yet - see: | ||||||
|  |             # https://github.com/goodboy/tractor/issues/216 | ||||||
|  |             # https://github.com/goodboy/tractor/pull/348 | ||||||
|  |             # https://github.com/goodboy/tractor/issues/296 | ||||||
|  | 
 | ||||||
|  |             actor = Arbiter( | ||||||
|  |                 name=name or 'registrar', | ||||||
|  |                 uuid=mk_uuid(), | ||||||
|  |                 registry_addrs=registry_addrs, | ||||||
|  |                 loglevel=loglevel, | ||||||
|  |                 enable_modules=enable_modules, | ||||||
|  |             ) | ||||||
|  |             # XXX, in case the root actor runtime was actually run from | ||||||
|  |             # `tractor.to_asyncio.run_as_asyncio_guest()` and NOt | ||||||
|  |             # `.trio.run()`. | ||||||
|  |             actor._infected_aio = _state._runtime_vars['_is_infected_aio'] | ||||||
|  | 
 | ||||||
|  |         # Start up main task set via core actor-runtime nurseries. | ||||||
|  |         try: | ||||||
|  |             # assign process-local actor | ||||||
|  |             _state._current_actor = actor | ||||||
|  | 
 | ||||||
|  |             # start local channel-server and fake the portal API | ||||||
|  |             # NOTE: this won't block since we provide the nursery | ||||||
|  |             ml_addrs_str: str = '\n'.join( | ||||||
|  |                 f'@{addr}' for addr in trans_bind_addrs | ||||||
|  |             ) | ||||||
|  |             logger.info( | ||||||
|  |                 f'Starting local {actor.uid} on the following transport addrs:\n' | ||||||
|  |                 f'{ml_addrs_str}' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # start the actor runtime in a new task | ||||||
|  |             async with trio.open_nursery( | ||||||
|  |                 strict_exception_groups=False, | ||||||
|  |                 # ^XXX^ TODO? instead unpack any RAE as per "loose" style? | ||||||
|  |             ) as nursery: | ||||||
|  | 
 | ||||||
|  |                 # ``_runtime.async_main()`` creates an internal nursery | ||||||
|  |                 # and blocks here until any underlying actor(-process) | ||||||
|  |                 # tree has terminated thereby conducting so called | ||||||
|  |                 # "end-to-end" structured concurrency throughout an | ||||||
|  |                 # entire hierarchical python sub-process set; all | ||||||
|  |                 # "actor runtime" primitives are SC-compat and thus all | ||||||
|  |                 # transitively spawned actors/processes must be as | ||||||
|  |                 # well. | ||||||
|  |                 await nursery.start( | ||||||
|  |                     partial( | ||||||
|  |                         async_main, | ||||||
|  |                         actor, | ||||||
|  |                         accept_addrs=trans_bind_addrs, | ||||||
|  |                         parent_addr=None | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |                 try: | ||||||
|  |                     yield actor | ||||||
|  |                 except ( | ||||||
|  |                     Exception, | ||||||
|  |                     BaseExceptionGroup, | ||||||
|  |                 ) as err: | ||||||
|  | 
 | ||||||
|  |                     # TODO, in beginning to handle the subsubactor with | ||||||
|  |                     # crashed grandparent cases.. | ||||||
|  |                     # | ||||||
|  |                     # was_locked: bool = await _debug.maybe_wait_for_debugger( | ||||||
|  |                     #     child_in_debug=True, | ||||||
|  |                     # ) | ||||||
|  |                     # XXX NOTE XXX see equiv note inside | ||||||
|  |                     # `._runtime.Actor._stream_handler()` where in the | ||||||
|  |                     # non-root or root-that-opened-this-mahually case we | ||||||
|  |                     # wait for the local actor-nursery to exit before | ||||||
|  |                     # exiting the transport channel handler. | ||||||
|  |                     entered: bool = await _debug._maybe_enter_pm( | ||||||
|  |                         err, | ||||||
|  |                         api_frame=inspect.currentframe(), | ||||||
|  |                         debug_filter=debug_filter, | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                     if ( | ||||||
|  |                         not entered | ||||||
|  |                         and | ||||||
|  |                         not is_multi_cancelled( | ||||||
|  |                             err, | ||||||
|  |                         ) | ||||||
|  |                     ): | ||||||
|  |                         logger.exception( | ||||||
|  |                             'Root actor crashed\n' | ||||||
|  |                             f'>x)\n' | ||||||
|  |                             f' |_{actor}\n' | ||||||
|  |                         ) | ||||||
|  | 
 | ||||||
|  |                     # ALWAYS re-raise any error bubbled up from the | ||||||
|  |                     # runtime! | ||||||
|  |                     raise | ||||||
|  | 
 | ||||||
|  |                 finally: | ||||||
|  |                     # NOTE: not sure if we'll ever need this but it's | ||||||
|  |                     # possibly better for even more determinism? | ||||||
|  |                     # logger.cancel( | ||||||
|  |                     #     f'Waiting on {len(nurseries)} nurseries in root..') | ||||||
|  |                     # nurseries = actor._actoruid2nursery.values() | ||||||
|  |                     # async with trio.open_nursery() as tempn: | ||||||
|  |                     #     for an in nurseries: | ||||||
|  |                     #         tempn.start_soon(an.exited.wait) | ||||||
|  | 
 | ||||||
|  |                     logger.info( | ||||||
|  |                         f'Closing down root actor\n' | ||||||
|  |                         f'>)\n' | ||||||
|  |                         f'|_{actor}\n' | ||||||
|  |                     ) | ||||||
|  |                     await actor.cancel(None)  # self cancel | ||||||
|  |         finally: | ||||||
|  |             # revert all process-global runtime state | ||||||
|  |             if ( | ||||||
|  |                 debug_mode | ||||||
|  |                 and | ||||||
|  |                 _spawn._spawn_method == 'trio' | ||||||
|  |             ): | ||||||
|  |                 _state._runtime_vars['_debug_mode'] = False | ||||||
|  | 
 | ||||||
|  |             _state._current_actor = None | ||||||
|  |             _state._last_actor_terminated = actor | ||||||
|  | 
 | ||||||
|  |             logger.runtime( | ||||||
|  |                 f'Root actor terminated\n' | ||||||
|  |                 f')>\n' | ||||||
|  |                 f' |_{actor}\n' | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def run_daemon( | def run_daemon( | ||||||
|  | @ -462,7 +536,7 @@ def run_daemon( | ||||||
| 
 | 
 | ||||||
|     # runtime kwargs |     # runtime kwargs | ||||||
|     name: str | None = 'root', |     name: str | None = 'root', | ||||||
|     registry_addrs: list[tuple[str, int]] = _default_lo_addrs, |     registry_addrs: list[UnwrappedAddress]|None = None, | ||||||
| 
 | 
 | ||||||
|     start_method: str | None = None, |     start_method: str | None = None, | ||||||
|     debug_mode: bool = False, |     debug_mode: bool = False, | ||||||
|  |  | ||||||
|  | @ -42,7 +42,7 @@ from trio import ( | ||||||
|     TaskStatus, |     TaskStatus, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| from ._ipc import Channel | from .ipc import Channel | ||||||
| from ._context import ( | from ._context import ( | ||||||
|     Context, |     Context, | ||||||
| ) | ) | ||||||
|  | @ -1156,7 +1156,7 @@ async def process_messages( | ||||||
|                                 trio.Event(), |                                 trio.Event(), | ||||||
|                             ) |                             ) | ||||||
| 
 | 
 | ||||||
|                     # runtime-scoped remote (internal) error |                     # XXX RUNTIME-SCOPED! remote (likely internal) error | ||||||
|                     # (^- bc no `Error.cid` -^) |                     # (^- bc no `Error.cid` -^) | ||||||
|                     # |                     # | ||||||
|                     # NOTE: this is the non-rpc error case, that |                     # NOTE: this is the non-rpc error case, that | ||||||
|  | @ -1219,8 +1219,10 @@ async def process_messages( | ||||||
|         # -[ ] figure out how this will break with other transports? |         # -[ ] figure out how this will break with other transports? | ||||||
|         tc.report_n_maybe_raise( |         tc.report_n_maybe_raise( | ||||||
|             message=( |             message=( | ||||||
|                 f'peer IPC channel closed abruptly?\n\n' |                 f'peer IPC channel closed abruptly?\n' | ||||||
|                 f'<=x {chan}\n' |                 f'\n' | ||||||
|  |                 f'<=x[\n' | ||||||
|  |                 f'  {chan}\n' | ||||||
|                 f'  |_{chan.raddr}\n\n' |                 f'  |_{chan.raddr}\n\n' | ||||||
|             ) |             ) | ||||||
|             + |             + | ||||||
|  |  | ||||||
							
								
								
									
										1005
									
								
								tractor/_runtime.py
								
								
								
								
							
							
						
						
									
										1005
									
								
								tractor/_runtime.py
								
								
								
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -46,19 +46,23 @@ from tractor._state import ( | ||||||
|     _runtime_vars, |     _runtime_vars, | ||||||
| ) | ) | ||||||
| from tractor.log import get_logger | from tractor.log import get_logger | ||||||
|  | from tractor._addr import UnwrappedAddress | ||||||
| from tractor._portal import Portal | from tractor._portal import Portal | ||||||
| from tractor._runtime import Actor | from tractor._runtime import Actor | ||||||
| from tractor._entry import _mp_main | from tractor._entry import _mp_main | ||||||
| from tractor._exceptions import ActorFailure | from tractor._exceptions import ActorFailure | ||||||
| from tractor.msg.types import ( | from tractor.msg.types import ( | ||||||
|  |     Aid, | ||||||
|     SpawnSpec, |     SpawnSpec, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|  |     from ipc import IPCServer | ||||||
|     from ._supervise import ActorNursery |     from ._supervise import ActorNursery | ||||||
|     ProcessType = TypeVar('ProcessType', mp.Process, trio.Process) |     ProcessType = TypeVar('ProcessType', mp.Process, trio.Process) | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
| log = get_logger('tractor') | log = get_logger('tractor') | ||||||
| 
 | 
 | ||||||
| # placeholder for an mp start context if so using that backend | # placeholder for an mp start context if so using that backend | ||||||
|  | @ -163,7 +167,7 @@ async def exhaust_portal( | ||||||
|         # TODO: merge with above? |         # TODO: merge with above? | ||||||
|         log.warning( |         log.warning( | ||||||
|             'Cancelled portal result waiter task:\n' |             'Cancelled portal result waiter task:\n' | ||||||
|             f'uid: {portal.channel.uid}\n' |             f'uid: {portal.channel.aid}\n' | ||||||
|             f'error: {err}\n' |             f'error: {err}\n' | ||||||
|         ) |         ) | ||||||
|         return err |         return err | ||||||
|  | @ -171,7 +175,7 @@ async def exhaust_portal( | ||||||
|     else: |     else: | ||||||
|         log.debug( |         log.debug( | ||||||
|             f'Returning final result from portal:\n' |             f'Returning final result from portal:\n' | ||||||
|             f'uid: {portal.channel.uid}\n' |             f'uid: {portal.channel.aid}\n' | ||||||
|             f'result: {final}\n' |             f'result: {final}\n' | ||||||
|         ) |         ) | ||||||
|         return final |         return final | ||||||
|  | @ -324,12 +328,12 @@ async def soft_kill( | ||||||
|     see `.hard_kill()`). |     see `.hard_kill()`). | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     uid: tuple[str, str] = portal.channel.uid |     peer_aid: Aid = portal.channel.aid | ||||||
|     try: |     try: | ||||||
|         log.cancel( |         log.cancel( | ||||||
|             f'Soft killing sub-actor via portal request\n' |             f'Soft killing sub-actor via portal request\n' | ||||||
|             f'\n' |             f'\n' | ||||||
|             f'(c=> {portal.chan.uid}\n' |             f'(c=> {peer_aid}\n' | ||||||
|             f'  |_{proc}\n' |             f'  |_{proc}\n' | ||||||
|         ) |         ) | ||||||
|         # wait on sub-proc to signal termination |         # wait on sub-proc to signal termination | ||||||
|  | @ -378,7 +382,7 @@ async def soft_kill( | ||||||
|             if proc.poll() is None:  # type: ignore |             if proc.poll() is None:  # type: ignore | ||||||
|                 log.warning( |                 log.warning( | ||||||
|                     'Subactor still alive after cancel request?\n\n' |                     'Subactor still alive after cancel request?\n\n' | ||||||
|                     f'uid: {uid}\n' |                     f'uid: {peer_aid}\n' | ||||||
|                     f'|_{proc}\n' |                     f'|_{proc}\n' | ||||||
|                 ) |                 ) | ||||||
|                 n.cancel_scope.cancel() |                 n.cancel_scope.cancel() | ||||||
|  | @ -392,14 +396,15 @@ async def new_proc( | ||||||
|     errors: dict[tuple[str, str], Exception], |     errors: dict[tuple[str, str], Exception], | ||||||
| 
 | 
 | ||||||
|     # passed through to actor main |     # passed through to actor main | ||||||
|     bind_addrs: list[tuple[str, int]], |     bind_addrs: list[UnwrappedAddress], | ||||||
|     parent_addr: tuple[str, int], |     parent_addr: UnwrappedAddress, | ||||||
|     _runtime_vars: dict[str, Any],  # serialized and sent to _child |     _runtime_vars: dict[str, Any],  # serialized and sent to _child | ||||||
| 
 | 
 | ||||||
|     *, |     *, | ||||||
| 
 | 
 | ||||||
|     infect_asyncio: bool = False, |     infect_asyncio: bool = False, | ||||||
|     task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED |     task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, | ||||||
|  |     proc_kwargs: dict[str, any] = {} | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
| 
 | 
 | ||||||
|  | @ -419,6 +424,7 @@ async def new_proc( | ||||||
|         _runtime_vars,  # run time vars |         _runtime_vars,  # run time vars | ||||||
|         infect_asyncio=infect_asyncio, |         infect_asyncio=infect_asyncio, | ||||||
|         task_status=task_status, |         task_status=task_status, | ||||||
|  |         proc_kwargs=proc_kwargs | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -429,12 +435,13 @@ async def trio_proc( | ||||||
|     errors: dict[tuple[str, str], Exception], |     errors: dict[tuple[str, str], Exception], | ||||||
| 
 | 
 | ||||||
|     # passed through to actor main |     # passed through to actor main | ||||||
|     bind_addrs: list[tuple[str, int]], |     bind_addrs: list[UnwrappedAddress], | ||||||
|     parent_addr: tuple[str, int], |     parent_addr: UnwrappedAddress, | ||||||
|     _runtime_vars: dict[str, Any],  # serialized and sent to _child |     _runtime_vars: dict[str, Any],  # serialized and sent to _child | ||||||
|     *, |     *, | ||||||
|     infect_asyncio: bool = False, |     infect_asyncio: bool = False, | ||||||
|     task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED |     task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, | ||||||
|  |     proc_kwargs: dict[str, any] = {} | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|  | @ -456,6 +463,9 @@ async def trio_proc( | ||||||
|         # the OS; it otherwise can be passed via the parent channel if |         # the OS; it otherwise can be passed via the parent channel if | ||||||
|         # we prefer in the future (for privacy). |         # we prefer in the future (for privacy). | ||||||
|         "--uid", |         "--uid", | ||||||
|  |         # TODO, how to pass this over "wire" encodings like | ||||||
|  |         # cmdline args? | ||||||
|  |         # -[ ] maybe we can add an `Aid.min_tuple()` ? | ||||||
|         str(subactor.uid), |         str(subactor.uid), | ||||||
|         # Address the child must connect to on startup |         # Address the child must connect to on startup | ||||||
|         "--parent_addr", |         "--parent_addr", | ||||||
|  | @ -473,9 +483,10 @@ async def trio_proc( | ||||||
| 
 | 
 | ||||||
|     cancelled_during_spawn: bool = False |     cancelled_during_spawn: bool = False | ||||||
|     proc: trio.Process|None = None |     proc: trio.Process|None = None | ||||||
|  |     ipc_server: IPCServer = actor_nursery._actor.ipc_server | ||||||
|     try: |     try: | ||||||
|         try: |         try: | ||||||
|             proc: trio.Process = await trio.lowlevel.open_process(spawn_cmd) |             proc: trio.Process = await trio.lowlevel.open_process(spawn_cmd, **proc_kwargs) | ||||||
|             log.runtime( |             log.runtime( | ||||||
|                 'Started new child\n' |                 'Started new child\n' | ||||||
|                 f'|_{proc}\n' |                 f'|_{proc}\n' | ||||||
|  | @ -484,7 +495,7 @@ async def trio_proc( | ||||||
|             # wait for actor to spawn and connect back to us |             # wait for actor to spawn and connect back to us | ||||||
|             # channel should have handshake completed by the |             # channel should have handshake completed by the | ||||||
|             # local actor by the time we get a ref to it |             # local actor by the time we get a ref to it | ||||||
|             event, chan = await actor_nursery._actor.wait_for_peer( |             event, chan = await ipc_server.wait_for_peer( | ||||||
|                 subactor.uid |                 subactor.uid | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|  | @ -517,15 +528,15 @@ async def trio_proc( | ||||||
| 
 | 
 | ||||||
|         # send a "spawning specification" which configures the |         # send a "spawning specification" which configures the | ||||||
|         # initial runtime state of the child. |         # initial runtime state of the child. | ||||||
|         await chan.send( |         sspec = SpawnSpec( | ||||||
|             SpawnSpec( |             _parent_main_data=subactor._parent_main_data, | ||||||
|                 _parent_main_data=subactor._parent_main_data, |             enable_modules=subactor.enable_modules, | ||||||
|                 enable_modules=subactor.enable_modules, |             reg_addrs=subactor.reg_addrs, | ||||||
|                 reg_addrs=subactor.reg_addrs, |             bind_addrs=bind_addrs, | ||||||
|                 bind_addrs=bind_addrs, |             _runtime_vars=_runtime_vars, | ||||||
|                 _runtime_vars=_runtime_vars, |  | ||||||
|             ) |  | ||||||
|         ) |         ) | ||||||
|  |         log.runtime(f'Sending spawn spec: {str(sspec)}') | ||||||
|  |         await chan.send(sspec) | ||||||
| 
 | 
 | ||||||
|         # track subactor in current nursery |         # track subactor in current nursery | ||||||
|         curr_actor: Actor = current_actor() |         curr_actor: Actor = current_actor() | ||||||
|  | @ -635,12 +646,13 @@ async def mp_proc( | ||||||
|     subactor: Actor, |     subactor: Actor, | ||||||
|     errors: dict[tuple[str, str], Exception], |     errors: dict[tuple[str, str], Exception], | ||||||
|     # passed through to actor main |     # passed through to actor main | ||||||
|     bind_addrs: list[tuple[str, int]], |     bind_addrs: list[UnwrappedAddress], | ||||||
|     parent_addr: tuple[str, int], |     parent_addr: UnwrappedAddress, | ||||||
|     _runtime_vars: dict[str, Any],  # serialized and sent to _child |     _runtime_vars: dict[str, Any],  # serialized and sent to _child | ||||||
|     *, |     *, | ||||||
|     infect_asyncio: bool = False, |     infect_asyncio: bool = False, | ||||||
|     task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED |     task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, | ||||||
|  |     proc_kwargs: dict[str, any] = {} | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
| 
 | 
 | ||||||
|  | @ -715,12 +727,14 @@ async def mp_proc( | ||||||
| 
 | 
 | ||||||
|     log.runtime(f"Started {proc}") |     log.runtime(f"Started {proc}") | ||||||
| 
 | 
 | ||||||
|  |     ipc_server: IPCServer = actor_nursery._actor.ipc_server | ||||||
|     try: |     try: | ||||||
|         # wait for actor to spawn and connect back to us |         # wait for actor to spawn and connect back to us | ||||||
|         # channel should have handshake completed by the |         # channel should have handshake completed by the | ||||||
|         # local actor by the time we get a ref to it |         # local actor by the time we get a ref to it | ||||||
|         event, chan = await actor_nursery._actor.wait_for_peer( |         event, chan = await ipc_server.wait_for_peer( | ||||||
|             subactor.uid) |             subactor.uid, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         # XXX: monkey patch poll API to match the ``subprocess`` API.. |         # XXX: monkey patch poll API to match the ``subprocess`` API.. | ||||||
|         # not sure why they don't expose this but kk. |         # not sure why they don't expose this but kk. | ||||||
|  |  | ||||||
|  | @ -14,16 +14,19 @@ | ||||||
| # You should have received a copy of the GNU Affero General Public License | # You should have received a copy of the GNU Affero General Public License | ||||||
| # along with this program.  If not, see <https://www.gnu.org/licenses/>. | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| """ | ''' | ||||||
| Per process state | Per actor-process runtime state mgmt APIs. | ||||||
| 
 | 
 | ||||||
| """ | ''' | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
| from contextvars import ( | from contextvars import ( | ||||||
|     ContextVar, |     ContextVar, | ||||||
| ) | ) | ||||||
|  | import os | ||||||
|  | from pathlib import Path | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, |     Any, | ||||||
|  |     Literal, | ||||||
|     TYPE_CHECKING, |     TYPE_CHECKING, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -99,7 +102,7 @@ def current_actor( | ||||||
|     return _current_actor |     return _current_actor | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def is_main_process() -> bool: | def is_root_process() -> bool: | ||||||
|     ''' |     ''' | ||||||
|     Bool determining if this actor is running in the top-most process. |     Bool determining if this actor is running in the top-most process. | ||||||
| 
 | 
 | ||||||
|  | @ -108,8 +111,10 @@ def is_main_process() -> bool: | ||||||
|     return mp.current_process().name == 'MainProcess' |     return mp.current_process().name == 'MainProcess' | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # TODO, more verby name? | is_main_process = is_root_process | ||||||
| def debug_mode() -> bool: | 
 | ||||||
|  | 
 | ||||||
|  | def is_debug_mode() -> bool: | ||||||
|     ''' |     ''' | ||||||
|     Bool determining if "debug mode" is on which enables |     Bool determining if "debug mode" is on which enables | ||||||
|     remote subactor pdb entry on crashes. |     remote subactor pdb entry on crashes. | ||||||
|  | @ -118,6 +123,9 @@ def debug_mode() -> bool: | ||||||
|     return bool(_runtime_vars['_debug_mode']) |     return bool(_runtime_vars['_debug_mode']) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | debug_mode = is_debug_mode | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| def is_root_process() -> bool: | def is_root_process() -> bool: | ||||||
|     return _runtime_vars['_is_root'] |     return _runtime_vars['_is_root'] | ||||||
| 
 | 
 | ||||||
|  | @ -143,3 +151,42 @@ def current_ipc_ctx( | ||||||
|             f'|_{current_task()}\n' |             f'|_{current_task()}\n' | ||||||
|         ) |         ) | ||||||
|     return ctx |     return ctx | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # std ODE (mutable) app state location | ||||||
|  | _rtdir: Path = Path(os.environ['XDG_RUNTIME_DIR']) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_rt_dir( | ||||||
|  |     subdir: str = 'tractor' | ||||||
|  | ) -> Path: | ||||||
|  |     ''' | ||||||
|  |     Return the user "runtime dir" where most userspace apps stick | ||||||
|  |     their IPC and cache related system util-files; we take hold | ||||||
|  |     of a `'XDG_RUNTIME_DIR'/tractor/` subdir by default. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     rtdir: Path = _rtdir / subdir | ||||||
|  |     if not rtdir.is_dir(): | ||||||
|  |         rtdir.mkdir() | ||||||
|  |     return rtdir | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # default IPC transport protocol settings | ||||||
|  | TransportProtocolKey = Literal[ | ||||||
|  |     'tcp', | ||||||
|  |     'uds', | ||||||
|  | ] | ||||||
|  | _def_tpt_proto: TransportProtocolKey = 'tcp' | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def current_ipc_protos() -> list[str]: | ||||||
|  |     ''' | ||||||
|  |     Return the list of IPC transport protocol keys currently | ||||||
|  |     in use by this actor. | ||||||
|  | 
 | ||||||
|  |     The keys are as declared by `MsgTransport` and `Address` | ||||||
|  |     concrete-backend sub-types defined throughout `tractor.ipc`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return [_def_tpt_proto] | ||||||
|  |  | ||||||
|  | @ -56,7 +56,7 @@ from tractor.msg import ( | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from ._runtime import Actor |     from ._runtime import Actor | ||||||
|     from ._context import Context |     from ._context import Context | ||||||
|     from ._ipc import Channel |     from .ipc import Channel | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
|  | @ -437,22 +437,23 @@ class MsgStream(trio.abc.Channel): | ||||||
|             message: str = ( |             message: str = ( | ||||||
|                 f'Stream self-closed by {this_side!r}-side before EoC from {peer_side!r}\n' |                 f'Stream self-closed by {this_side!r}-side before EoC from {peer_side!r}\n' | ||||||
|                 # } bc a stream is a "scope"/msging-phase inside an IPC |                 # } bc a stream is a "scope"/msging-phase inside an IPC | ||||||
|                 f'x}}>\n' |                 f'c}}>\n' | ||||||
|                 f'  |_{self}\n' |                 f'  |_{self}\n' | ||||||
|             ) |             ) | ||||||
|             log.cancel(message) |  | ||||||
|             self._eoc = trio.EndOfChannel(message) |  | ||||||
| 
 |  | ||||||
|             if ( |             if ( | ||||||
|                 (rx_chan := self._rx_chan) |                 (rx_chan := self._rx_chan) | ||||||
|                 and |                 and | ||||||
|                 (stats := rx_chan.statistics()).tasks_waiting_receive |                 (stats := rx_chan.statistics()).tasks_waiting_receive | ||||||
|             ): |             ): | ||||||
|                 log.cancel( |                 message += ( | ||||||
|                     f'Msg-stream is closing but there is still reader tasks,\n' |                     f'AND there is still reader tasks,\n' | ||||||
|  |                     f'\n' | ||||||
|                     f'{stats}\n' |                     f'{stats}\n' | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|  |             log.cancel(message) | ||||||
|  |             self._eoc = trio.EndOfChannel(message) | ||||||
|  | 
 | ||||||
|         # ?XXX WAIT, why do we not close the local mem chan `._rx_chan` XXX? |         # ?XXX WAIT, why do we not close the local mem chan `._rx_chan` XXX? | ||||||
|         # => NO, DEFINITELY NOT! <= |         # => NO, DEFINITELY NOT! <= | ||||||
|         # if we're a bi-dir `MsgStream` BECAUSE this same |         # if we're a bi-dir `MsgStream` BECAUSE this same | ||||||
|  | @ -595,8 +596,17 @@ class MsgStream(trio.abc.Channel): | ||||||
|             trio.ClosedResourceError, |             trio.ClosedResourceError, | ||||||
|             trio.BrokenResourceError, |             trio.BrokenResourceError, | ||||||
|             BrokenPipeError, |             BrokenPipeError, | ||||||
|         ) as trans_err: |         ) as _trans_err: | ||||||
|             if hide_tb: |             trans_err = _trans_err | ||||||
|  |             if ( | ||||||
|  |                 hide_tb | ||||||
|  |                 and | ||||||
|  |                 self._ctx.chan._exc is trans_err | ||||||
|  |                 # ^XXX, IOW, only if the channel is marked errored | ||||||
|  |                 # for the same reason as whatever its underlying | ||||||
|  |                 # transport raised, do we keep the full low-level tb | ||||||
|  |                 # suppressed from the user. | ||||||
|  |             ): | ||||||
|                 raise type(trans_err)( |                 raise type(trans_err)( | ||||||
|                     *trans_err.args |                     *trans_err.args | ||||||
|                 ) from trans_err |                 ) from trans_err | ||||||
|  | @ -802,13 +812,12 @@ async def open_stream_from_ctx( | ||||||
|                 # sanity, can remove? |                 # sanity, can remove? | ||||||
|                 assert eoc is stream._eoc |                 assert eoc is stream._eoc | ||||||
| 
 | 
 | ||||||
|                 log.warning( |                 log.runtime( | ||||||
|                     'Stream was terminated by EoC\n\n' |                     'Stream was terminated by EoC\n\n' | ||||||
|                     # NOTE: won't show the error <Type> but |                     # NOTE: won't show the error <Type> but | ||||||
|                     # does show txt followed by IPC msg. |                     # does show txt followed by IPC msg. | ||||||
|                     f'{str(eoc)}\n' |                     f'{str(eoc)}\n' | ||||||
|                 ) |                 ) | ||||||
| 
 |  | ||||||
|         finally: |         finally: | ||||||
|             if ctx._portal: |             if ctx._portal: | ||||||
|                 try: |                 try: | ||||||
|  |  | ||||||
|  | @ -22,13 +22,20 @@ from contextlib import asynccontextmanager as acm | ||||||
| from functools import partial | from functools import partial | ||||||
| import inspect | import inspect | ||||||
| from pprint import pformat | from pprint import pformat | ||||||
| from typing import TYPE_CHECKING | from typing import ( | ||||||
|  |     TYPE_CHECKING, | ||||||
|  | ) | ||||||
| import typing | import typing | ||||||
| import warnings | import warnings | ||||||
| 
 | 
 | ||||||
| import trio | import trio | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
| from .devx._debug import maybe_wait_for_debugger | from .devx._debug import maybe_wait_for_debugger | ||||||
|  | from ._addr import ( | ||||||
|  |     UnwrappedAddress, | ||||||
|  |     mk_uuid, | ||||||
|  | ) | ||||||
| from ._state import current_actor, is_main_process | from ._state import current_actor, is_main_process | ||||||
| from .log import get_logger, get_loglevel | from .log import get_logger, get_loglevel | ||||||
| from ._runtime import Actor | from ._runtime import Actor | ||||||
|  | @ -37,18 +44,21 @@ from ._exceptions import ( | ||||||
|     is_multi_cancelled, |     is_multi_cancelled, | ||||||
|     ContextCancelled, |     ContextCancelled, | ||||||
| ) | ) | ||||||
| from ._root import open_root_actor | from ._root import ( | ||||||
|  |     open_root_actor, | ||||||
|  | ) | ||||||
| from . import _state | from . import _state | ||||||
| from . import _spawn | from . import _spawn | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     import multiprocessing as mp |     import multiprocessing as mp | ||||||
|  |     # from .ipc._server import IPCServer | ||||||
|  |     from .ipc import IPCServer | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
| 
 | 
 | ||||||
| _default_bind_addr: tuple[str, int] = ('127.0.0.1', 0) |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| class ActorNursery: | class ActorNursery: | ||||||
|     ''' |     ''' | ||||||
|  | @ -130,8 +140,9 @@ class ActorNursery: | ||||||
| 
 | 
 | ||||||
|         *, |         *, | ||||||
| 
 | 
 | ||||||
|         bind_addrs: list[tuple[str, int]] = [_default_bind_addr], |         bind_addrs: list[UnwrappedAddress]|None = None, | ||||||
|         rpc_module_paths: list[str]|None = None, |         rpc_module_paths: list[str]|None = None, | ||||||
|  |         enable_transports: list[str] = [_state._def_tpt_proto], | ||||||
|         enable_modules: list[str]|None = None, |         enable_modules: list[str]|None = None, | ||||||
|         loglevel: str|None = None,  # set log level per subactor |         loglevel: str|None = None,  # set log level per subactor | ||||||
|         debug_mode: bool|None = None, |         debug_mode: bool|None = None, | ||||||
|  | @ -141,6 +152,7 @@ class ActorNursery: | ||||||
|         # a `._ria_nursery` since the dependent APIs have been |         # a `._ria_nursery` since the dependent APIs have been | ||||||
|         # removed! |         # removed! | ||||||
|         nursery: trio.Nursery|None = None, |         nursery: trio.Nursery|None = None, | ||||||
|  |         proc_kwargs: dict[str, any] = {} | ||||||
| 
 | 
 | ||||||
|     ) -> Portal: |     ) -> Portal: | ||||||
|         ''' |         ''' | ||||||
|  | @ -177,7 +189,9 @@ class ActorNursery: | ||||||
|             enable_modules.extend(rpc_module_paths) |             enable_modules.extend(rpc_module_paths) | ||||||
| 
 | 
 | ||||||
|         subactor = Actor( |         subactor = Actor( | ||||||
|             name, |             name=name, | ||||||
|  |             uuid=mk_uuid(), | ||||||
|  | 
 | ||||||
|             # modules allowed to invoked funcs from |             # modules allowed to invoked funcs from | ||||||
|             enable_modules=enable_modules, |             enable_modules=enable_modules, | ||||||
|             loglevel=loglevel, |             loglevel=loglevel, | ||||||
|  | @ -185,7 +199,7 @@ class ActorNursery: | ||||||
|             # verbatim relay this actor's registrar addresses |             # verbatim relay this actor's registrar addresses | ||||||
|             registry_addrs=current_actor().reg_addrs, |             registry_addrs=current_actor().reg_addrs, | ||||||
|         ) |         ) | ||||||
|         parent_addr = self._actor.accept_addr |         parent_addr: UnwrappedAddress = self._actor.accept_addr | ||||||
|         assert parent_addr |         assert parent_addr | ||||||
| 
 | 
 | ||||||
|         # start a task to spawn a process |         # start a task to spawn a process | ||||||
|  | @ -204,6 +218,7 @@ class ActorNursery: | ||||||
|                 parent_addr, |                 parent_addr, | ||||||
|                 _rtv,  # run time vars |                 _rtv,  # run time vars | ||||||
|                 infect_asyncio=infect_asyncio, |                 infect_asyncio=infect_asyncio, | ||||||
|  |                 proc_kwargs=proc_kwargs | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  | @ -222,11 +237,12 @@ class ActorNursery: | ||||||
|         *, |         *, | ||||||
| 
 | 
 | ||||||
|         name: str | None = None, |         name: str | None = None, | ||||||
|         bind_addrs: tuple[str, int] = [_default_bind_addr], |         bind_addrs: UnwrappedAddress|None = None, | ||||||
|         rpc_module_paths: list[str] | None = None, |         rpc_module_paths: list[str] | None = None, | ||||||
|         enable_modules: list[str] | None = None, |         enable_modules: list[str] | None = None, | ||||||
|         loglevel: str | None = None,  # set log level per subactor |         loglevel: str | None = None,  # set log level per subactor | ||||||
|         infect_asyncio: bool = False, |         infect_asyncio: bool = False, | ||||||
|  |         proc_kwargs: dict[str, any] = {}, | ||||||
| 
 | 
 | ||||||
|         **kwargs,  # explicit args to ``fn`` |         **kwargs,  # explicit args to ``fn`` | ||||||
| 
 | 
 | ||||||
|  | @ -257,6 +273,7 @@ class ActorNursery: | ||||||
|             # use the run_in_actor nursery |             # use the run_in_actor nursery | ||||||
|             nursery=self._ria_nursery, |             nursery=self._ria_nursery, | ||||||
|             infect_asyncio=infect_asyncio, |             infect_asyncio=infect_asyncio, | ||||||
|  |             proc_kwargs=proc_kwargs | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         # XXX: don't allow stream funcs |         # XXX: don't allow stream funcs | ||||||
|  | @ -301,8 +318,13 @@ class ActorNursery: | ||||||
|         children: dict = self._children |         children: dict = self._children | ||||||
|         child_count: int = len(children) |         child_count: int = len(children) | ||||||
|         msg: str = f'Cancelling actor nursery with {child_count} children\n' |         msg: str = f'Cancelling actor nursery with {child_count} children\n' | ||||||
|  | 
 | ||||||
|  |         server: IPCServer = self._actor.ipc_server | ||||||
|  | 
 | ||||||
|         with trio.move_on_after(3) as cs: |         with trio.move_on_after(3) as cs: | ||||||
|             async with trio.open_nursery() as tn: |             async with trio.open_nursery( | ||||||
|  |                 strict_exception_groups=False, | ||||||
|  |             ) as tn: | ||||||
| 
 | 
 | ||||||
|                 subactor: Actor |                 subactor: Actor | ||||||
|                 proc: trio.Process |                 proc: trio.Process | ||||||
|  | @ -321,7 +343,7 @@ class ActorNursery: | ||||||
| 
 | 
 | ||||||
|                     else: |                     else: | ||||||
|                         if portal is None:  # actor hasn't fully spawned yet |                         if portal is None:  # actor hasn't fully spawned yet | ||||||
|                             event = self._actor._peer_connected[subactor.uid] |                             event: trio.Event = server._peer_connected[subactor.uid] | ||||||
|                             log.warning( |                             log.warning( | ||||||
|                                 f"{subactor.uid} never 't finished spawning?" |                                 f"{subactor.uid} never 't finished spawning?" | ||||||
|                             ) |                             ) | ||||||
|  | @ -337,7 +359,7 @@ class ActorNursery: | ||||||
|                             if portal is None: |                             if portal is None: | ||||||
|                                 # cancelled while waiting on the event |                                 # cancelled while waiting on the event | ||||||
|                                 # to arrive |                                 # to arrive | ||||||
|                                 chan = self._actor._peers[subactor.uid][-1] |                                 chan = server._peers[subactor.uid][-1] | ||||||
|                                 if chan: |                                 if chan: | ||||||
|                                     portal = Portal(chan) |                                     portal = Portal(chan) | ||||||
|                                 else:  # there's no other choice left |                                 else:  # there's no other choice left | ||||||
|  |  | ||||||
|  | @ -37,6 +37,9 @@ from .fault_simulation import ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # TODO, use dulwhich for this instead? | ||||||
|  | # -> we're going to likely need it (or something similar) | ||||||
|  | #   for supporting hot-coad reload feats eventually anyway! | ||||||
| def repodir() -> pathlib.Path: | def repodir() -> pathlib.Path: | ||||||
|     ''' |     ''' | ||||||
|     Return the abspath to the repo directory. |     Return the abspath to the repo directory. | ||||||
|  |  | ||||||
|  | @ -0,0 +1,70 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Random IPC addr generation for isolating | ||||||
|  | the discovery space between test sessions. | ||||||
|  | 
 | ||||||
|  | Might be eventually useful to expose as a util set from | ||||||
|  | our `tractor.discovery` subsys? | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import random | ||||||
|  | from typing import ( | ||||||
|  |     Type, | ||||||
|  | ) | ||||||
|  | from tractor import ( | ||||||
|  |     _addr, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_rando_addr( | ||||||
|  |     tpt_proto: str, | ||||||
|  |     *, | ||||||
|  | 
 | ||||||
|  |     # choose random port at import time | ||||||
|  |     _rando_port: str = random.randint(1000, 9999) | ||||||
|  | 
 | ||||||
|  | ) -> tuple[str, str|int]: | ||||||
|  |     ''' | ||||||
|  |     Used to globally override the runtime to the | ||||||
|  |     per-test-session-dynamic addr so that all tests never conflict | ||||||
|  |     with any other actor tree using the default. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     addr_type: Type[_addr.Addres] = _addr._address_types[tpt_proto] | ||||||
|  |     def_reg_addr: tuple[str, int] = _addr._default_lo_addrs[tpt_proto] | ||||||
|  | 
 | ||||||
|  |     # this is the "unwrapped" form expected to be passed to | ||||||
|  |     # `.open_root_actor()` by test body. | ||||||
|  |     testrun_reg_addr: tuple[str, int|str] | ||||||
|  |     match tpt_proto: | ||||||
|  |         case 'tcp': | ||||||
|  |             testrun_reg_addr = ( | ||||||
|  |                 addr_type.def_bindspace, | ||||||
|  |                 _rando_port, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         # NOTE, file-name uniqueness (no-collisions) will be based on | ||||||
|  |         # the runtime-directory and root (pytest-proc's) pid. | ||||||
|  |         case 'uds': | ||||||
|  |             testrun_reg_addr = addr_type.get_random().unwrap() | ||||||
|  | 
 | ||||||
|  |     # XXX, as sanity it should never the same as the default for the | ||||||
|  |     # host-singleton registry actor. | ||||||
|  |     assert def_reg_addr != testrun_reg_addr | ||||||
|  | 
 | ||||||
|  |     return testrun_reg_addr | ||||||
|  | @ -26,29 +26,46 @@ from functools import ( | ||||||
| import inspect | import inspect | ||||||
| import platform | import platform | ||||||
| 
 | 
 | ||||||
|  | import pytest | ||||||
| import tractor | import tractor | ||||||
| import trio | import trio | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def tractor_test(fn): | def tractor_test(fn): | ||||||
|     ''' |     ''' | ||||||
|     Decorator for async test funcs to present them as "native" |     Decorator for async test fns to decorator-wrap them as "native" | ||||||
|     looking sync funcs runnable by `pytest` using `trio.run()`. |     looking sync funcs runnable by `pytest` and auto invoked with | ||||||
|  |     `trio.run()` (much like the `pytest-trio` plugin's approach). | ||||||
| 
 | 
 | ||||||
|     Use: |     Further the test fn body will be invoked AFTER booting the actor | ||||||
|  |     runtime, i.e. from inside a `tractor.open_root_actor()` block AND | ||||||
|  |     with various runtime and tooling parameters implicitly passed as | ||||||
|  |     requested by by the test session's config; see immediately below. | ||||||
| 
 | 
 | ||||||
|     @tractor_test |     Basic deco use: | ||||||
|     async def test_whatever(): |     --------------- | ||||||
|         await ... |  | ||||||
| 
 | 
 | ||||||
|     If fixtures: |       @tractor_test | ||||||
|  |       async def test_whatever(): | ||||||
|  |           await ... | ||||||
| 
 | 
 | ||||||
|         - ``reg_addr`` (a socket addr tuple where arbiter is listening) |  | ||||||
|         - ``loglevel`` (logging level passed to tractor internals) |  | ||||||
|         - ``start_method`` (subprocess spawning backend) |  | ||||||
| 
 | 
 | ||||||
|     are defined in the `pytest` fixture space they will be automatically |     Runtime config via special fixtures: | ||||||
|     injected to tests declaring these funcargs. |     ------------------------------------ | ||||||
|  |     If any of the following fixture are requested by the wrapped test | ||||||
|  |     fn (via normal func-args declaration), | ||||||
|  | 
 | ||||||
|  |     - `reg_addr` (a socket addr tuple where arbiter is listening) | ||||||
|  |     - `loglevel` (logging level passed to tractor internals) | ||||||
|  |     - `start_method` (subprocess spawning backend) | ||||||
|  | 
 | ||||||
|  |     (TODO support) | ||||||
|  |     - `tpt_proto` (IPC transport protocol key) | ||||||
|  | 
 | ||||||
|  |     they will be automatically injected to each test as normally | ||||||
|  |     expected as well as passed to the initial | ||||||
|  |     `tractor.open_root_actor()` funcargs. | ||||||
|  | 
 | ||||||
|     ''' |     ''' | ||||||
|     @wraps(fn) |     @wraps(fn) | ||||||
|     def wrapper( |     def wrapper( | ||||||
|  | @ -111,3 +128,164 @@ def tractor_test(fn): | ||||||
|         return trio.run(main) |         return trio.run(main) | ||||||
| 
 | 
 | ||||||
|     return wrapper |     return wrapper | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def pytest_addoption( | ||||||
|  |     parser: pytest.Parser, | ||||||
|  | ): | ||||||
|  |     # parser.addoption( | ||||||
|  |     #     "--ll", | ||||||
|  |     #     action="store", | ||||||
|  |     #     dest='loglevel', | ||||||
|  |     #     default='ERROR', help="logging level to set when testing" | ||||||
|  |     # ) | ||||||
|  | 
 | ||||||
|  |     parser.addoption( | ||||||
|  |         "--spawn-backend", | ||||||
|  |         action="store", | ||||||
|  |         dest='spawn_backend', | ||||||
|  |         default='trio', | ||||||
|  |         help="Processing spawning backend to use for test run", | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     parser.addoption( | ||||||
|  |         "--tpdb", | ||||||
|  |         "--debug-mode", | ||||||
|  |         action="store_true", | ||||||
|  |         dest='tractor_debug_mode', | ||||||
|  |         # default=False, | ||||||
|  |         help=( | ||||||
|  |             'Enable a flag that can be used by tests to to set the ' | ||||||
|  |             '`debug_mode: bool` for engaging the internal ' | ||||||
|  |             'multi-proc debugger sys.' | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # provide which IPC transport protocols opting-in test suites | ||||||
|  |     # should accumulatively run against. | ||||||
|  |     parser.addoption( | ||||||
|  |         "--tpt-proto", | ||||||
|  |         nargs='+',  # accumulate-multiple-args | ||||||
|  |         action="store", | ||||||
|  |         dest='tpt_protos', | ||||||
|  |         default=['tcp'], | ||||||
|  |         help="Transport protocol to use under the `tractor.ipc.Channel`", | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def pytest_configure(config): | ||||||
|  |     backend = config.option.spawn_backend | ||||||
|  |     tractor._spawn.try_set_start_method(backend) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.fixture(scope='session') | ||||||
|  | def debug_mode(request) -> bool: | ||||||
|  |     ''' | ||||||
|  |     Flag state for whether `--tpdb` (for `tractor`-py-debugger) | ||||||
|  |     was passed to the test run. | ||||||
|  | 
 | ||||||
|  |     Normally tests should pass this directly to `.open_root_actor()` | ||||||
|  |     to allow the user to opt into suite-wide crash handling. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     debug_mode: bool = request.config.option.tractor_debug_mode | ||||||
|  |     return debug_mode | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.fixture(scope='session') | ||||||
|  | def spawn_backend(request) -> str: | ||||||
|  |     return request.config.option.spawn_backend | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.fixture(scope='session') | ||||||
|  | def tpt_protos(request) -> list[str]: | ||||||
|  | 
 | ||||||
|  |     # allow quoting on CLI | ||||||
|  |     proto_keys: list[str] = [ | ||||||
|  |         proto_key.replace('"', '').replace("'", "") | ||||||
|  |         for proto_key in request.config.option.tpt_protos | ||||||
|  |     ] | ||||||
|  | 
 | ||||||
|  |     # ?TODO, eventually support multiple protos per test-sesh? | ||||||
|  |     if len(proto_keys) > 1: | ||||||
|  |         pytest.fail( | ||||||
|  |             'We only support one `--tpt-proto <key>` atm!\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # XXX ensure we support the protocol by name via lookup! | ||||||
|  |     for proto_key in proto_keys: | ||||||
|  |         addr_type = tractor._addr._address_types[proto_key] | ||||||
|  |         assert addr_type.proto_key == proto_key | ||||||
|  | 
 | ||||||
|  |     yield proto_keys | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.fixture( | ||||||
|  |     scope='session', | ||||||
|  |     autouse=True, | ||||||
|  | ) | ||||||
|  | def tpt_proto( | ||||||
|  |     tpt_protos: list[str], | ||||||
|  | ) -> str: | ||||||
|  |     proto_key: str = tpt_protos[0] | ||||||
|  | 
 | ||||||
|  |     from tractor import _state | ||||||
|  |     if _state._def_tpt_proto != proto_key: | ||||||
|  |         _state._def_tpt_proto = proto_key | ||||||
|  | 
 | ||||||
|  |     yield proto_key | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.fixture(scope='session') | ||||||
|  | def reg_addr( | ||||||
|  |     tpt_proto: str, | ||||||
|  | ) -> tuple[str, int|str]: | ||||||
|  |     ''' | ||||||
|  |     Deliver a test-sesh unique registry address such | ||||||
|  |     that each run's (tests which use this fixture) will | ||||||
|  |     have no conflicts/cross-talk when running simultaneously | ||||||
|  |     nor will interfere with other live `tractor` apps active | ||||||
|  |     on the same network-host (namespace). | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     from tractor._testing.addr import get_rando_addr | ||||||
|  |     return get_rando_addr( | ||||||
|  |         tpt_proto=tpt_proto, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def pytest_generate_tests( | ||||||
|  |     metafunc: pytest.Metafunc, | ||||||
|  | ): | ||||||
|  |     spawn_backend: str = metafunc.config.option.spawn_backend | ||||||
|  | 
 | ||||||
|  |     if not spawn_backend: | ||||||
|  |         # XXX some weird windows bug with `pytest`? | ||||||
|  |         spawn_backend = 'trio' | ||||||
|  | 
 | ||||||
|  |     # TODO: maybe just use the literal `._spawn.SpawnMethodKey`? | ||||||
|  |     assert spawn_backend in ( | ||||||
|  |         'mp_spawn', | ||||||
|  |         'mp_forkserver', | ||||||
|  |         'trio', | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # NOTE: used-to-be-used-to dyanmically parametrize tests for when | ||||||
|  |     # you just passed --spawn-backend=`mp` on the cli, but now we expect | ||||||
|  |     # that cli input to be manually specified, BUT, maybe we'll do | ||||||
|  |     # something like this again in the future? | ||||||
|  |     if 'start_method' in metafunc.fixturenames: | ||||||
|  |         metafunc.parametrize( | ||||||
|  |             "start_method", | ||||||
|  |             [spawn_backend], | ||||||
|  |             scope='module', | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # TODO, parametrize any `tpt_proto: str` declaring tests! | ||||||
|  |     # proto_tpts: list[str] = metafunc.config.option.proto_tpts | ||||||
|  |     # if 'tpt_proto' in metafunc.fixturenames: | ||||||
|  |     #     metafunc.parametrize( | ||||||
|  |     #         'tpt_proto', | ||||||
|  |     #         proto_tpts,  # TODO, double check this list usage! | ||||||
|  |     #         scope='module', | ||||||
|  |     #     ) | ||||||
|  |  | ||||||
|  | @ -0,0 +1,35 @@ | ||||||
|  | import os | ||||||
|  | import random | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def generate_sample_messages( | ||||||
|  |     amount: int, | ||||||
|  |     rand_min: int = 0, | ||||||
|  |     rand_max: int = 0, | ||||||
|  |     silent: bool = False | ||||||
|  | ) -> tuple[list[bytes], int]: | ||||||
|  | 
 | ||||||
|  |     msgs = [] | ||||||
|  |     size = 0 | ||||||
|  | 
 | ||||||
|  |     if not silent: | ||||||
|  |         print(f'\ngenerating {amount} messages...') | ||||||
|  | 
 | ||||||
|  |     for i in range(amount): | ||||||
|  |         msg = f'[{i:08}]'.encode('utf-8') | ||||||
|  | 
 | ||||||
|  |         if rand_max > 0: | ||||||
|  |             msg += os.urandom( | ||||||
|  |                 random.randint(rand_min, rand_max)) | ||||||
|  | 
 | ||||||
|  |         size += len(msg) | ||||||
|  | 
 | ||||||
|  |         msgs.append(msg) | ||||||
|  | 
 | ||||||
|  |         if not silent and i and i % 10_000 == 0: | ||||||
|  |             print(f'{i} generated') | ||||||
|  | 
 | ||||||
|  |     if not silent: | ||||||
|  |         print(f'done, {size:,} bytes in total') | ||||||
|  | 
 | ||||||
|  |     return msgs, size | ||||||
|  | @ -73,6 +73,7 @@ from tractor.log import get_logger | ||||||
| from tractor._context import Context | from tractor._context import Context | ||||||
| from tractor import _state | from tractor import _state | ||||||
| from tractor._exceptions import ( | from tractor._exceptions import ( | ||||||
|  |     DebugRequestError, | ||||||
|     InternalError, |     InternalError, | ||||||
|     NoRuntime, |     NoRuntime, | ||||||
|     is_multi_cancelled, |     is_multi_cancelled, | ||||||
|  | @ -91,7 +92,11 @@ from tractor._state import ( | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from trio.lowlevel import Task |     from trio.lowlevel import Task | ||||||
|     from threading import Thread |     from threading import Thread | ||||||
|     from tractor._ipc import Channel |     from tractor.ipc import ( | ||||||
|  |         Channel, | ||||||
|  |         IPCServer, | ||||||
|  |         # _server,  # TODO? export at top level? | ||||||
|  |     ) | ||||||
|     from tractor._runtime import ( |     from tractor._runtime import ( | ||||||
|         Actor, |         Actor, | ||||||
|     ) |     ) | ||||||
|  | @ -1433,6 +1438,7 @@ def any_connected_locker_child() -> bool: | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     actor: Actor = current_actor() |     actor: Actor = current_actor() | ||||||
|  |     server: IPCServer = actor.ipc_server | ||||||
| 
 | 
 | ||||||
|     if not is_root_process(): |     if not is_root_process(): | ||||||
|         raise InternalError('This is a root-actor only API!') |         raise InternalError('This is a root-actor only API!') | ||||||
|  | @ -1442,7 +1448,7 @@ def any_connected_locker_child() -> bool: | ||||||
|         and |         and | ||||||
|         (uid_in_debug := ctx.chan.uid) |         (uid_in_debug := ctx.chan.uid) | ||||||
|     ): |     ): | ||||||
|         chans: list[tractor.Channel] = actor._peers.get( |         chans: list[tractor.Channel] = server._peers.get( | ||||||
|             tuple(uid_in_debug) |             tuple(uid_in_debug) | ||||||
|         ) |         ) | ||||||
|         if chans: |         if chans: | ||||||
|  | @ -1740,13 +1746,6 @@ def sigint_shield( | ||||||
| _pause_msg: str = 'Opening a pdb REPL in paused actor' | _pause_msg: str = 'Opening a pdb REPL in paused actor' | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class DebugRequestError(RuntimeError): |  | ||||||
|     ''' |  | ||||||
|     Failed to request stdio lock from root actor! |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| _repl_fail_msg: str|None = ( | _repl_fail_msg: str|None = ( | ||||||
|     'Failed to REPl via `_pause()` ' |     'Failed to REPl via `_pause()` ' | ||||||
| ) | ) | ||||||
|  | @ -3009,6 +3008,7 @@ async def _maybe_enter_pm( | ||||||
|         [BaseException|BaseExceptionGroup], |         [BaseException|BaseExceptionGroup], | ||||||
|         bool, |         bool, | ||||||
|     ] = lambda err: not is_multi_cancelled(err), |     ] = lambda err: not is_multi_cancelled(err), | ||||||
|  |     **_pause_kws, | ||||||
| 
 | 
 | ||||||
| ): | ): | ||||||
|     if ( |     if ( | ||||||
|  | @ -3035,6 +3035,7 @@ async def _maybe_enter_pm( | ||||||
|         await post_mortem( |         await post_mortem( | ||||||
|             api_frame=api_frame, |             api_frame=api_frame, | ||||||
|             tb=tb, |             tb=tb, | ||||||
|  |             **_pause_kws, | ||||||
|         ) |         ) | ||||||
|         return True |         return True | ||||||
| 
 | 
 | ||||||
|  | @ -3279,7 +3280,7 @@ def open_crash_handler( | ||||||
| 
 | 
 | ||||||
| @cm | @cm | ||||||
| def maybe_open_crash_handler( | def maybe_open_crash_handler( | ||||||
|     pdb: bool = False, |     pdb: bool|None = None, | ||||||
|     tb_hide: bool = True, |     tb_hide: bool = True, | ||||||
| 
 | 
 | ||||||
|     **kwargs, |     **kwargs, | ||||||
|  | @ -3290,7 +3291,11 @@ def maybe_open_crash_handler( | ||||||
| 
 | 
 | ||||||
|     Normally this is used with CLI endpoints such that if the --pdb |     Normally this is used with CLI endpoints such that if the --pdb | ||||||
|     flag is passed the pdb REPL is engaed on any crashes B) |     flag is passed the pdb REPL is engaed on any crashes B) | ||||||
|  | 
 | ||||||
|     ''' |     ''' | ||||||
|  |     if pdb is None: | ||||||
|  |         pdb: bool = _state.is_debug_mode() | ||||||
|  | 
 | ||||||
|     __tracebackhide__: bool = tb_hide |     __tracebackhide__: bool = tb_hide | ||||||
| 
 | 
 | ||||||
|     rtctx = nullcontext( |     rtctx = nullcontext( | ||||||
|  |  | ||||||
|  | @ -19,6 +19,7 @@ Pretty formatters for use throughout the code base. | ||||||
| Mostly handy for logging and exception message content. | Mostly handy for logging and exception message content. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
|  | import sys | ||||||
| import textwrap | import textwrap | ||||||
| import traceback | import traceback | ||||||
| 
 | 
 | ||||||
|  | @ -115,6 +116,85 @@ def pformat_boxed_tb( | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def pformat_exc( | ||||||
|  |     exc: Exception, | ||||||
|  |     header: str = '', | ||||||
|  |     message: str = '', | ||||||
|  |     body: str = '', | ||||||
|  |     with_type_header: bool = True, | ||||||
|  | ) -> str: | ||||||
|  | 
 | ||||||
|  |     # XXX when the currently raised exception is this instance, | ||||||
|  |     # we do not ever use the "type header" style repr. | ||||||
|  |     is_being_raised: bool = False | ||||||
|  |     if ( | ||||||
|  |         (curr_exc := sys.exception()) | ||||||
|  |         and | ||||||
|  |         curr_exc is exc | ||||||
|  |     ): | ||||||
|  |         is_being_raised: bool = True | ||||||
|  | 
 | ||||||
|  |     with_type_header: bool = ( | ||||||
|  |         with_type_header | ||||||
|  |         and | ||||||
|  |         not is_being_raised | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # <RemoteActorError( .. )> style | ||||||
|  |     if ( | ||||||
|  |         with_type_header | ||||||
|  |         and | ||||||
|  |         not header | ||||||
|  |     ): | ||||||
|  |         header: str = f'<{type(exc).__name__}(' | ||||||
|  | 
 | ||||||
|  |     message: str = ( | ||||||
|  |         message | ||||||
|  |         or | ||||||
|  |         exc.message | ||||||
|  |     ) | ||||||
|  |     if message: | ||||||
|  |         # split off the first line so, if needed, it isn't | ||||||
|  |         # indented the same like the "boxed content" which | ||||||
|  |         # since there is no `.tb_str` is just the `.message`. | ||||||
|  |         lines: list[str] = message.splitlines() | ||||||
|  |         first: str = lines[0] | ||||||
|  |         message: str = message.removeprefix(first) | ||||||
|  | 
 | ||||||
|  |         # with a type-style header we, | ||||||
|  |         # - have no special message "first line" extraction/handling | ||||||
|  |         # - place the message a space in from the header: | ||||||
|  |         #  `MsgTypeError( <message> ..` | ||||||
|  |         #                 ^-here | ||||||
|  |         # - indent the `.message` inside the type body. | ||||||
|  |         if with_type_header: | ||||||
|  |             first = f' {first} )>' | ||||||
|  | 
 | ||||||
|  |         message: str = textwrap.indent( | ||||||
|  |             message, | ||||||
|  |             prefix=' '*2, | ||||||
|  |         ) | ||||||
|  |         message: str = first + message | ||||||
|  | 
 | ||||||
|  |     tail: str = '' | ||||||
|  |     if ( | ||||||
|  |         with_type_header | ||||||
|  |         and | ||||||
|  |         not message | ||||||
|  |     ): | ||||||
|  |         tail: str = '>' | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         header | ||||||
|  |         + | ||||||
|  |         message | ||||||
|  |         + | ||||||
|  |         f'{body}' | ||||||
|  |         + | ||||||
|  |         tail | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| def pformat_caller_frame( | def pformat_caller_frame( | ||||||
|     stack_limit: int = 1, |     stack_limit: int = 1, | ||||||
|     box_tb: bool = True, |     box_tb: bool = True, | ||||||
|  | @ -167,3 +247,104 @@ def pformat_cs( | ||||||
|         + |         + | ||||||
|         fields |         fields | ||||||
|     ) |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: move this func to some kinda `.devx.pformat.py` eventually | ||||||
|  | # as we work out our multi-domain state-flow-syntax! | ||||||
|  | def nest_from_op( | ||||||
|  |     input_op: str, | ||||||
|  |     # | ||||||
|  |     # ?TODO? an idea for a syntax to the state of concurrent systems | ||||||
|  |     # as a "3-domain" (execution, scope, storage) model and using | ||||||
|  |     # a minimal ascii/utf-8 operator-set. | ||||||
|  |     # | ||||||
|  |     # try not to take any of this seriously yet XD | ||||||
|  |     # | ||||||
|  |     # > is a "play operator" indicating (CPU bound) | ||||||
|  |     #   exec/work/ops required at the "lowest level computing" | ||||||
|  |     # | ||||||
|  |     # execution primititves (tasks, threads, actors..) denote their | ||||||
|  |     # lifetime with '(' and ')' since parentheses normally are used | ||||||
|  |     # in many langs to denote function calls. | ||||||
|  |     # | ||||||
|  |     # starting = ( | ||||||
|  |     # >(  opening/starting; beginning of the thread-of-exec (toe?) | ||||||
|  |     # (>  opened/started,  (finished spawning toe) | ||||||
|  |     # |_<Task: blah blah..>  repr of toe, in py these look like <objs> | ||||||
|  |     # | ||||||
|  |     # >) closing/exiting/stopping, | ||||||
|  |     # )> closed/exited/stopped, | ||||||
|  |     # |_<Task: blah blah..> | ||||||
|  |     #   [OR <), )< ?? ] | ||||||
|  |     # | ||||||
|  |     # ending = ) | ||||||
|  |     # >c) cancelling to close/exit | ||||||
|  |     # c)> cancelled (caused close), OR? | ||||||
|  |     #  |_<Actor: ..> | ||||||
|  |     #   OR maybe "<c)" which better indicates the cancel being | ||||||
|  |     #   "delivered/returned" / returned" to LHS? | ||||||
|  |     # | ||||||
|  |     # >x)  erroring to eventuall exit | ||||||
|  |     # x)>  errored and terminated | ||||||
|  |     #  |_<Actor: ...> | ||||||
|  |     # | ||||||
|  |     # scopes: supers/nurseries, IPC-ctxs, sessions, perms, etc. | ||||||
|  |     # >{  opening | ||||||
|  |     # {>  opened | ||||||
|  |     # }>  closed | ||||||
|  |     # >}  closing | ||||||
|  |     # | ||||||
|  |     # storage: like queues, shm-buffers, files, etc.. | ||||||
|  |     # >[  opening | ||||||
|  |     # [>  opened | ||||||
|  |     #  |_<FileObj: ..> | ||||||
|  |     # | ||||||
|  |     # >]  closing | ||||||
|  |     # ]>  closed | ||||||
|  | 
 | ||||||
|  |     # IPC ops: channels, transports, msging | ||||||
|  |     # =>  req msg | ||||||
|  |     # <=  resp msg | ||||||
|  |     # <=> 2-way streaming (of msgs) | ||||||
|  |     # <-  recv 1 msg | ||||||
|  |     # ->  send 1 msg | ||||||
|  |     # | ||||||
|  |     # TODO: still not sure on R/L-HS approach..? | ||||||
|  |     # =>(  send-req to exec start (task, actor, thread..) | ||||||
|  |     # (<=  recv-req to ^ | ||||||
|  |     # | ||||||
|  |     # (<=  recv-req ^ | ||||||
|  |     # <=(  recv-resp opened remote exec primitive | ||||||
|  |     # <=)  recv-resp closed | ||||||
|  |     # | ||||||
|  |     # )<=c req to stop due to cancel | ||||||
|  |     # c=>) req to stop due to cancel | ||||||
|  |     # | ||||||
|  |     # =>{  recv-req to open | ||||||
|  |     # <={  send-status that it closed | ||||||
|  | 
 | ||||||
|  |     tree_str: str, | ||||||
|  | 
 | ||||||
|  |     # NOTE: so move back-from-the-left of the `input_op` by | ||||||
|  |     # this amount. | ||||||
|  |     back_from_op: int = 0, | ||||||
|  | ) -> str: | ||||||
|  |     ''' | ||||||
|  |     Depth-increment the input (presumably hierarchy/supervision) | ||||||
|  |     input "tree string" below the provided `input_op` execution | ||||||
|  |     operator, so injecting a `"\n|_{input_op}\n"`and indenting the | ||||||
|  |     `tree_str` to nest content aligned with the ops last char. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return ( | ||||||
|  |         f'{input_op}\n' | ||||||
|  |         + | ||||||
|  |         textwrap.indent( | ||||||
|  |             tree_str, | ||||||
|  |             prefix=( | ||||||
|  |                 len(input_op) | ||||||
|  |                 - | ||||||
|  |                 (back_from_op + 1) | ||||||
|  |             ) * ' ', | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  | @ -45,6 +45,8 @@ __all__ = ['pub'] | ||||||
| log = get_logger('messaging') | log = get_logger('messaging') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # TODO! this needs to reworked to use the modern | ||||||
|  | # `Context`/`MsgStream` APIs!! | ||||||
| async def fan_out_to_ctxs( | async def fan_out_to_ctxs( | ||||||
|     pub_async_gen_func: typing.Callable,  # it's an async gen ... gd mypy |     pub_async_gen_func: typing.Callable,  # it's an async gen ... gd mypy | ||||||
|     topics2ctxs: dict[str, list], |     topics2ctxs: dict[str, list], | ||||||
|  |  | ||||||
|  | @ -0,0 +1,26 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2024-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | High level design patterns, APIs and runtime extensions built on top | ||||||
|  | of the `tractor` runtime core. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from ._service import ( | ||||||
|  |     open_service_mngr as open_service_mngr, | ||||||
|  |     get_service_mngr as get_service_mngr, | ||||||
|  |     ServiceMngr as ServiceMngr, | ||||||
|  | ) | ||||||
|  | @ -0,0 +1,592 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2024-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Daemon subactor as service(s) management and supervision primitives | ||||||
|  | and API. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  |     # contextmanager as cm, | ||||||
|  | ) | ||||||
|  | from collections import defaultdict | ||||||
|  | from dataclasses import ( | ||||||
|  |     dataclass, | ||||||
|  |     field, | ||||||
|  | ) | ||||||
|  | import functools | ||||||
|  | import inspect | ||||||
|  | from typing import ( | ||||||
|  |     Callable, | ||||||
|  |     Any, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | import tractor | ||||||
|  | import trio | ||||||
|  | from trio import TaskStatus | ||||||
|  | from tractor import ( | ||||||
|  |     log, | ||||||
|  |     ActorNursery, | ||||||
|  |     current_actor, | ||||||
|  |     ContextCancelled, | ||||||
|  |     Context, | ||||||
|  |     Portal, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | log = log.get_logger('tractor') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: implement a `@singleton` deco-API for wrapping the below | ||||||
|  | # factory's impl for general actor-singleton use? | ||||||
|  | # | ||||||
|  | # -[ ] go through the options peeps on SO did? | ||||||
|  | #  * https://stackoverflow.com/questions/6760685/what-is-the-best-way-of-implementing-singleton-in-python | ||||||
|  | #  * including @mikenerone's answer | ||||||
|  | #   |_https://stackoverflow.com/questions/6760685/what-is-the-best-way-of-implementing-singleton-in-python/39186313#39186313 | ||||||
|  | # | ||||||
|  | # -[ ] put it in `tractor.lowlevel._globals` ? | ||||||
|  | #  * fits with our oustanding actor-local/global feat req? | ||||||
|  | #   |_ https://github.com/goodboy/tractor/issues/55 | ||||||
|  | #  * how can it relate to the `Actor.lifetime_stack` that was | ||||||
|  | #    silently patched in? | ||||||
|  | #   |_ we could implicitly call both of these in the same | ||||||
|  | #     spot in the runtime using the lifetime stack? | ||||||
|  | #    - `open_singleton_cm().__exit__()` | ||||||
|  | #    -`del_singleton()` | ||||||
|  | #   |_ gives SC fixtue semantics to sync code oriented around | ||||||
|  | #     sub-process lifetime? | ||||||
|  | #  * what about with `trio.RunVar`? | ||||||
|  | #   |_https://trio.readthedocs.io/en/stable/reference-lowlevel.html#trio.lowlevel.RunVar | ||||||
|  | #    - which we'll need for no-GIL cpython (right?) presuming | ||||||
|  | #      multiple `trio.run()` calls in process? | ||||||
|  | # | ||||||
|  | # | ||||||
|  | # @singleton | ||||||
|  | # async def open_service_mngr( | ||||||
|  | #     **init_kwargs, | ||||||
|  | # ) -> ServiceMngr: | ||||||
|  | #     ''' | ||||||
|  | #     Note this function body is invoke IFF no existing singleton instance already | ||||||
|  | #     exists in this proc's memory. | ||||||
|  | 
 | ||||||
|  | #     ''' | ||||||
|  | #     # setup | ||||||
|  | #     yield ServiceMngr(**init_kwargs) | ||||||
|  | #     # teardown | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # a deletion API for explicit instance de-allocation? | ||||||
|  | # @open_service_mngr.deleter | ||||||
|  | # def del_service_mngr() -> None: | ||||||
|  | #     mngr = open_service_mngr._singleton[0] | ||||||
|  | #     open_service_mngr._singleton[0] = None | ||||||
|  | #     del mngr | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: implement a singleton deco-API for wrapping the below | ||||||
|  | # factory's impl for general actor-singleton use? | ||||||
|  | # | ||||||
|  | # @singleton | ||||||
|  | # async def open_service_mngr( | ||||||
|  | #     **init_kwargs, | ||||||
|  | # ) -> ServiceMngr: | ||||||
|  | #     ''' | ||||||
|  | #     Note this function body is invoke IFF no existing singleton instance already | ||||||
|  | #     exists in this proc's memory. | ||||||
|  | 
 | ||||||
|  | #     ''' | ||||||
|  | #     # setup | ||||||
|  | #     yield ServiceMngr(**init_kwargs) | ||||||
|  | #     # teardown | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: singleton factory API instead of a class API | ||||||
|  | @acm | ||||||
|  | async def open_service_mngr( | ||||||
|  |     *, | ||||||
|  |     debug_mode: bool = False, | ||||||
|  | 
 | ||||||
|  |     # NOTE; since default values for keyword-args are effectively | ||||||
|  |     # module-vars/globals as per the note from, | ||||||
|  |     # https://docs.python.org/3/tutorial/controlflow.html#default-argument-values | ||||||
|  |     # | ||||||
|  |     # > "The default value is evaluated only once. This makes | ||||||
|  |     #   a difference when the default is a mutable object such as | ||||||
|  |     #   a list, dictionary, or instances of most classes" | ||||||
|  |     # | ||||||
|  |     _singleton: list[ServiceMngr|None] = [None], | ||||||
|  |     **init_kwargs, | ||||||
|  | 
 | ||||||
|  | ) -> ServiceMngr: | ||||||
|  |     ''' | ||||||
|  |     Open an actor-global "service-manager" for supervising a tree | ||||||
|  |     of subactors and/or actor-global tasks. | ||||||
|  | 
 | ||||||
|  |     The delivered `ServiceMngr` is singleton instance for each | ||||||
|  |     actor-process, that is, allocated on first open and never | ||||||
|  |     de-allocated unless explicitly deleted by al call to | ||||||
|  |     `del_service_mngr()`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # TODO: factor this an allocation into | ||||||
|  |     # a `._mngr.open_service_mngr()` and put in the | ||||||
|  |     # once-n-only-once setup/`.__aenter__()` part! | ||||||
|  |     # -[ ] how to make this only happen on the `mngr == None` case? | ||||||
|  |     #  |_ use `.trionics.maybe_open_context()` (for generic | ||||||
|  |     #     async-with-style-only-once of the factory impl, though | ||||||
|  |     #     what do we do for the allocation case? | ||||||
|  |     #    / `.maybe_open_nursery()` (since for this specific case | ||||||
|  |     #    it's simpler?) to activate | ||||||
|  |     async with ( | ||||||
|  |         tractor.open_nursery() as an, | ||||||
|  |         trio.open_nursery() as tn, | ||||||
|  |     ): | ||||||
|  |         # impl specific obvi.. | ||||||
|  |         init_kwargs.update({ | ||||||
|  |             'an': an, | ||||||
|  |             'tn': tn, | ||||||
|  |         }) | ||||||
|  | 
 | ||||||
|  |         mngr: ServiceMngr|None | ||||||
|  |         if (mngr := _singleton[0]) is None: | ||||||
|  | 
 | ||||||
|  |             log.info('Allocating a new service mngr!') | ||||||
|  |             mngr = _singleton[0] = ServiceMngr(**init_kwargs) | ||||||
|  | 
 | ||||||
|  |             # TODO: put into `.__aenter__()` section of | ||||||
|  |             # eventual `@singleton_acm` API wrapper. | ||||||
|  |             # | ||||||
|  |             # assign globally for future daemon/task creation | ||||||
|  |             mngr.an = an | ||||||
|  |             mngr.tn = tn | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             assert (mngr.an and mngr.tn) | ||||||
|  |             log.info( | ||||||
|  |                 'Using extant service mngr!\n\n' | ||||||
|  |                 f'{mngr!r}\n'  # it has a nice `.__repr__()` of services state | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         try: | ||||||
|  |             # NOTE: this is a singleton factory impl specific detail | ||||||
|  |             # which should be supported in the condensed | ||||||
|  |             # `@singleton_acm` API? | ||||||
|  |             mngr.debug_mode = debug_mode | ||||||
|  | 
 | ||||||
|  |             yield mngr | ||||||
|  |         finally: | ||||||
|  |             # TODO: is this more clever/efficient? | ||||||
|  |             # if 'samplerd' in mngr.service_ctxs: | ||||||
|  |             #     await mngr.cancel_service('samplerd') | ||||||
|  |             tn.cancel_scope.cancel() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_service_mngr() -> ServiceMngr: | ||||||
|  |     ''' | ||||||
|  |     Try to get the singleton service-mngr for this actor presuming it | ||||||
|  |     has already been allocated using, | ||||||
|  | 
 | ||||||
|  |     .. code:: python | ||||||
|  | 
 | ||||||
|  |         async with open_<@singleton_acm(func)>() as mngr` | ||||||
|  |             ... this block kept open ... | ||||||
|  | 
 | ||||||
|  |     If not yet allocated raise a `ServiceError`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # https://stackoverflow.com/a/12627202 | ||||||
|  |     # https://docs.python.org/3/library/inspect.html#inspect.Signature | ||||||
|  |     maybe_mngr: ServiceMngr|None = inspect.signature( | ||||||
|  |         open_service_mngr | ||||||
|  |     ).parameters['_singleton'].default[0] | ||||||
|  | 
 | ||||||
|  |     if maybe_mngr is None: | ||||||
|  |         raise RuntimeError( | ||||||
|  |             'Someone must allocate a `ServiceMngr` using\n\n' | ||||||
|  |             '`async with open_service_mngr()` beforehand!!\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     return maybe_mngr | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def _open_and_supervise_service_ctx( | ||||||
|  |     serman: ServiceMngr, | ||||||
|  |     name: str, | ||||||
|  |     ctx_fn: Callable,  # TODO, type for `@tractor.context` requirement | ||||||
|  |     portal: Portal, | ||||||
|  | 
 | ||||||
|  |     allow_overruns: bool = False, | ||||||
|  |     task_status: TaskStatus[ | ||||||
|  |         tuple[ | ||||||
|  |             trio.CancelScope, | ||||||
|  |             Context, | ||||||
|  |             trio.Event, | ||||||
|  |             Any, | ||||||
|  |         ] | ||||||
|  |     ] = trio.TASK_STATUS_IGNORED, | ||||||
|  |     **ctx_kwargs, | ||||||
|  | 
 | ||||||
|  | ) -> Any: | ||||||
|  |     ''' | ||||||
|  |     Open a remote IPC-context defined by `ctx_fn` in the | ||||||
|  |     (service) actor accessed via `portal` and supervise the | ||||||
|  |     (local) parent task to termination at which point the remote | ||||||
|  |     actor runtime is cancelled alongside it. | ||||||
|  | 
 | ||||||
|  |     The main application is for allocating long-running | ||||||
|  |     "sub-services" in a main daemon and explicitly controlling | ||||||
|  |     their lifetimes from an actor-global singleton. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # TODO: use the ctx._scope directly here instead? | ||||||
|  |     # -[ ] actually what semantics do we expect for this | ||||||
|  |     #   usage!? | ||||||
|  |     with trio.CancelScope() as cs: | ||||||
|  |         try: | ||||||
|  |             async with portal.open_context( | ||||||
|  |                 ctx_fn, | ||||||
|  |                 allow_overruns=allow_overruns, | ||||||
|  |                 **ctx_kwargs, | ||||||
|  | 
 | ||||||
|  |             ) as (ctx, started): | ||||||
|  | 
 | ||||||
|  |                 # unblock once the remote context has started | ||||||
|  |                 complete = trio.Event() | ||||||
|  |                 task_status.started(( | ||||||
|  |                     cs, | ||||||
|  |                     ctx, | ||||||
|  |                     complete, | ||||||
|  |                     started, | ||||||
|  |                 )) | ||||||
|  |                 log.info( | ||||||
|  |                     f'`pikerd` service {name} started with value {started}' | ||||||
|  |                 ) | ||||||
|  |                 # wait on any context's return value | ||||||
|  |                 # and any final portal result from the | ||||||
|  |                 # sub-actor. | ||||||
|  |                 ctx_res: Any = await ctx.wait_for_result() | ||||||
|  | 
 | ||||||
|  |                 # NOTE: blocks indefinitely until cancelled | ||||||
|  |                 # either by error from the target context | ||||||
|  |                 # function or by being cancelled here by the | ||||||
|  |                 # surrounding cancel scope. | ||||||
|  |                 return ( | ||||||
|  |                     await portal.wait_for_result(), | ||||||
|  |                     ctx_res, | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         except ContextCancelled as ctxe: | ||||||
|  |             canceller: tuple[str, str] = ctxe.canceller | ||||||
|  |             our_uid: tuple[str, str] = current_actor().uid | ||||||
|  |             if ( | ||||||
|  |                 canceller != portal.chan.uid | ||||||
|  |                 and | ||||||
|  |                 canceller != our_uid | ||||||
|  |             ): | ||||||
|  |                 log.cancel( | ||||||
|  |                     f'Actor-service `{name}` was remotely cancelled by a peer?\n' | ||||||
|  | 
 | ||||||
|  |                     # TODO: this would be a good spot to use | ||||||
|  |                     # a respawn feature Bo | ||||||
|  |                     f'-> Keeping `pikerd` service manager alive despite this inter-peer cancel\n\n' | ||||||
|  | 
 | ||||||
|  |                     f'cancellee: {portal.chan.uid}\n' | ||||||
|  |                     f'canceller: {canceller}\n' | ||||||
|  |                 ) | ||||||
|  |             else: | ||||||
|  |                 raise | ||||||
|  | 
 | ||||||
|  |         finally: | ||||||
|  |             # NOTE: the ctx MUST be cancelled first if we | ||||||
|  |             # don't want the above `ctx.wait_for_result()` to | ||||||
|  |             # raise a self-ctxc. WHY, well since from the ctx's | ||||||
|  |             # perspective the cancel request will have | ||||||
|  |             # arrived out-out-of-band at the `Actor.cancel()` | ||||||
|  |             # level, thus `Context.cancel_called == False`, | ||||||
|  |             # meaning `ctx._is_self_cancelled() == False`. | ||||||
|  |             # with trio.CancelScope(shield=True): | ||||||
|  |             # await ctx.cancel() | ||||||
|  |             await portal.cancel_actor()  # terminate (remote) sub-actor | ||||||
|  |             complete.set()  # signal caller this task is done | ||||||
|  |             serman.service_ctxs.pop(name)  # remove mngr entry | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: we need remote wrapping and a general soln: | ||||||
|  | # - factor this into a ``tractor.highlevel`` extension # pack for the | ||||||
|  | #   library. | ||||||
|  | # - wrap a "remote api" wherein you can get a method proxy | ||||||
|  | #   to the pikerd actor for starting services remotely! | ||||||
|  | # - prolly rename this to ActorServicesNursery since it spawns | ||||||
|  | #   new actors and supervises them to completion? | ||||||
|  | @dataclass | ||||||
|  | class ServiceMngr: | ||||||
|  |     ''' | ||||||
|  |     A multi-subactor-as-service manager. | ||||||
|  | 
 | ||||||
|  |     Spawn, supervise and monitor service/daemon subactors in a SC | ||||||
|  |     process tree. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     an: ActorNursery | ||||||
|  |     tn: trio.Nursery | ||||||
|  |     debug_mode: bool = False # tractor sub-actor debug mode flag | ||||||
|  | 
 | ||||||
|  |     service_tasks: dict[ | ||||||
|  |         str, | ||||||
|  |         tuple[ | ||||||
|  |             trio.CancelScope, | ||||||
|  |             trio.Event, | ||||||
|  |         ] | ||||||
|  |     ] = field(default_factory=dict) | ||||||
|  | 
 | ||||||
|  |     service_ctxs: dict[ | ||||||
|  |         str, | ||||||
|  |         tuple[ | ||||||
|  |             trio.CancelScope, | ||||||
|  |             Context, | ||||||
|  |             Portal, | ||||||
|  |             trio.Event, | ||||||
|  |         ] | ||||||
|  |     ] = field(default_factory=dict) | ||||||
|  | 
 | ||||||
|  |     # internal per-service task mutexs | ||||||
|  |     _locks = defaultdict(trio.Lock) | ||||||
|  | 
 | ||||||
|  |     # TODO, unify this interface with our `TaskManager` PR! | ||||||
|  |     # | ||||||
|  |     # | ||||||
|  |     async def start_service_task( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         # TODO: typevar for the return type of the target and then | ||||||
|  |         # use it below for `ctx_res`? | ||||||
|  |         fn: Callable, | ||||||
|  | 
 | ||||||
|  |         allow_overruns: bool = False, | ||||||
|  |         **ctx_kwargs, | ||||||
|  | 
 | ||||||
|  |     ) -> tuple[ | ||||||
|  |         trio.CancelScope, | ||||||
|  |         Any, | ||||||
|  |         trio.Event, | ||||||
|  |     ]: | ||||||
|  |         async def _task_manager_start( | ||||||
|  |             task_status: TaskStatus[ | ||||||
|  |                 tuple[ | ||||||
|  |                     trio.CancelScope, | ||||||
|  |                     trio.Event, | ||||||
|  |                 ] | ||||||
|  |             ] = trio.TASK_STATUS_IGNORED, | ||||||
|  |         ) -> Any: | ||||||
|  | 
 | ||||||
|  |             task_cs = trio.CancelScope() | ||||||
|  |             task_complete = trio.Event() | ||||||
|  | 
 | ||||||
|  |             with task_cs as cs: | ||||||
|  |                 task_status.started(( | ||||||
|  |                     cs, | ||||||
|  |                     task_complete, | ||||||
|  |                 )) | ||||||
|  |                 try: | ||||||
|  |                     await fn() | ||||||
|  |                 except trio.Cancelled as taskc: | ||||||
|  |                     log.cancel( | ||||||
|  |                         f'Service task for `{name}` was cancelled!\n' | ||||||
|  |                         # TODO: this would be a good spot to use | ||||||
|  |                         # a respawn feature Bo | ||||||
|  |                     ) | ||||||
|  |                     raise taskc | ||||||
|  |                 finally: | ||||||
|  |                     task_complete.set() | ||||||
|  |         ( | ||||||
|  |             cs, | ||||||
|  |             complete, | ||||||
|  |         ) = await self.tn.start(_task_manager_start) | ||||||
|  | 
 | ||||||
|  |         # store the cancel scope and portal for later cancellation or | ||||||
|  |         # retstart if needed. | ||||||
|  |         self.service_tasks[name] = ( | ||||||
|  |             cs, | ||||||
|  |             complete, | ||||||
|  |         ) | ||||||
|  |         return ( | ||||||
|  |             cs, | ||||||
|  |             complete, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     async def cancel_service_task( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  | 
 | ||||||
|  |     ) -> Any: | ||||||
|  |         log.info(f'Cancelling `pikerd` service {name}') | ||||||
|  |         cs, complete = self.service_tasks[name] | ||||||
|  | 
 | ||||||
|  |         cs.cancel() | ||||||
|  |         await complete.wait() | ||||||
|  |         # TODO, if we use the `TaskMngr` from #346 | ||||||
|  |         # we can also get the return value from the task! | ||||||
|  | 
 | ||||||
|  |         if name in self.service_tasks: | ||||||
|  |             # TODO: custom err? | ||||||
|  |             # raise ServiceError( | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 f'Service task {name!r} not terminated!?\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     async def start_service_ctx( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         portal: Portal, | ||||||
|  |         # TODO: typevar for the return type of the target and then | ||||||
|  |         # use it below for `ctx_res`? | ||||||
|  |         ctx_fn: Callable, | ||||||
|  |         **ctx_kwargs, | ||||||
|  | 
 | ||||||
|  |     ) -> tuple[ | ||||||
|  |         trio.CancelScope, | ||||||
|  |         Context, | ||||||
|  |         Any, | ||||||
|  |     ]: | ||||||
|  |         ''' | ||||||
|  |         Start a remote IPC-context defined by `ctx_fn` in a background | ||||||
|  |         task and immediately return supervision primitives to manage it: | ||||||
|  | 
 | ||||||
|  |         - a `cs: CancelScope` for the newly allocated bg task | ||||||
|  |         - the `ipc_ctx: Context` to manage the remotely scheduled | ||||||
|  |           `trio.Task`. | ||||||
|  |         - the `started: Any` value returned by the remote endpoint | ||||||
|  |           task's `Context.started(<value>)` call. | ||||||
|  | 
 | ||||||
|  |         The bg task supervises the ctx such that when it terminates the supporting | ||||||
|  |         actor runtime is also cancelled, see `_open_and_supervise_service_ctx()` | ||||||
|  |         for details. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         cs, ipc_ctx, complete, started = await self.tn.start( | ||||||
|  |             functools.partial( | ||||||
|  |                 _open_and_supervise_service_ctx, | ||||||
|  |                 serman=self, | ||||||
|  |                 name=name, | ||||||
|  |                 ctx_fn=ctx_fn, | ||||||
|  |                 portal=portal, | ||||||
|  |                 **ctx_kwargs, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # store the cancel scope and portal for later cancellation or | ||||||
|  |         # retstart if needed. | ||||||
|  |         self.service_ctxs[name] = (cs, ipc_ctx, portal, complete) | ||||||
|  |         return ( | ||||||
|  |             cs, | ||||||
|  |             ipc_ctx, | ||||||
|  |             started, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     async def start_service( | ||||||
|  |         self, | ||||||
|  |         daemon_name: str, | ||||||
|  |         ctx_ep: Callable,  # kwargs must `partial`-ed in! | ||||||
|  |         # ^TODO, type for `@tractor.context` deco-ed funcs! | ||||||
|  | 
 | ||||||
|  |         debug_mode: bool = False, | ||||||
|  |         **start_actor_kwargs, | ||||||
|  | 
 | ||||||
|  |     ) -> Context: | ||||||
|  |         ''' | ||||||
|  |         Start new subactor and schedule a supervising "service task" | ||||||
|  |         in it which explicitly defines the sub's lifetime. | ||||||
|  | 
 | ||||||
|  |         "Service daemon subactors" are cancelled (and thus | ||||||
|  |         terminated) using the paired `.cancel_service()`. | ||||||
|  | 
 | ||||||
|  |         Effectively this API can be used to manage "service daemons" | ||||||
|  |         spawned under a single parent actor with supervision | ||||||
|  |         semantics equivalent to a one-cancels-one style actor-nursery | ||||||
|  |         or "(subactor) task manager" where each subprocess's (and | ||||||
|  |         thus its embedded actor runtime) lifetime is synced to that | ||||||
|  |         of the remotely spawned task defined by `ctx_ep`. | ||||||
|  | 
 | ||||||
|  |         The funcionality can be likened to a "daemonized" version of | ||||||
|  |         `.hilevel.worker.run_in_actor()` but with supervision | ||||||
|  |         controls offered by `tractor.Context` where the main/root | ||||||
|  |         remotely scheduled `trio.Task` invoking `ctx_ep` determines | ||||||
|  |         the underlying subactor's lifetime. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         entry: tuple|None = self.service_ctxs.get(daemon_name) | ||||||
|  |         if entry: | ||||||
|  |             (cs, sub_ctx, portal, complete) = entry | ||||||
|  |             return sub_ctx | ||||||
|  | 
 | ||||||
|  |         if daemon_name not in self.service_ctxs: | ||||||
|  |             portal: Portal = await self.an.start_actor( | ||||||
|  |                 daemon_name, | ||||||
|  |                 debug_mode=(  # maybe set globally during allocate | ||||||
|  |                     debug_mode | ||||||
|  |                     or | ||||||
|  |                     self.debug_mode | ||||||
|  |                 ), | ||||||
|  |                 **start_actor_kwargs, | ||||||
|  |             ) | ||||||
|  |             ctx_kwargs: dict[str, Any] = {} | ||||||
|  |             if isinstance(ctx_ep, functools.partial): | ||||||
|  |                 ctx_kwargs: dict[str, Any] = ctx_ep.keywords | ||||||
|  |                 ctx_ep: Callable = ctx_ep.func | ||||||
|  | 
 | ||||||
|  |             ( | ||||||
|  |                 cs, | ||||||
|  |                 sub_ctx, | ||||||
|  |                 started, | ||||||
|  |             ) = await self.start_service_ctx( | ||||||
|  |                 name=daemon_name, | ||||||
|  |                 portal=portal, | ||||||
|  |                 ctx_fn=ctx_ep, | ||||||
|  |                 **ctx_kwargs, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             return sub_ctx | ||||||
|  | 
 | ||||||
|  |     async def cancel_service( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  | 
 | ||||||
|  |     ) -> Any: | ||||||
|  |         ''' | ||||||
|  |         Cancel the service task and actor for the given ``name``. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         log.info(f'Cancelling `pikerd` service {name}') | ||||||
|  |         cs, sub_ctx, portal, complete = self.service_ctxs[name] | ||||||
|  | 
 | ||||||
|  |         # cs.cancel() | ||||||
|  |         await sub_ctx.cancel() | ||||||
|  |         await complete.wait() | ||||||
|  | 
 | ||||||
|  |         if name in self.service_ctxs: | ||||||
|  |             # TODO: custom err? | ||||||
|  |             # raise ServiceError( | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 f'Service actor for {name} not terminated and/or unknown?' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         # assert name not in self.service_ctxs, \ | ||||||
|  |         #     f'Serice task for {name} not terminated?' | ||||||
|  | @ -0,0 +1,24 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | A modular IPC layer supporting the power of cross-process SC! | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from ._chan import ( | ||||||
|  |     _connect_chan as _connect_chan, | ||||||
|  |     Channel as Channel | ||||||
|  | ) | ||||||
|  | @ -0,0 +1,457 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | """ | ||||||
|  | Inter-process comms abstractions | ||||||
|  | 
 | ||||||
|  | """ | ||||||
|  | from __future__ import annotations | ||||||
|  | from collections.abc import AsyncGenerator | ||||||
|  | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  |     contextmanager as cm, | ||||||
|  | ) | ||||||
|  | import platform | ||||||
|  | from pprint import pformat | ||||||
|  | import typing | ||||||
|  | from typing import ( | ||||||
|  |     Any, | ||||||
|  |     TYPE_CHECKING, | ||||||
|  | ) | ||||||
|  | import warnings | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | 
 | ||||||
|  | from ._types import ( | ||||||
|  |     transport_from_addr, | ||||||
|  |     transport_from_stream, | ||||||
|  | ) | ||||||
|  | from tractor._addr import ( | ||||||
|  |     is_wrapped_addr, | ||||||
|  |     wrap_address, | ||||||
|  |     Address, | ||||||
|  |     UnwrappedAddress, | ||||||
|  | ) | ||||||
|  | from tractor.log import get_logger | ||||||
|  | from tractor._exceptions import ( | ||||||
|  |     MsgTypeError, | ||||||
|  |     pack_from_raise, | ||||||
|  |     TransportClosed, | ||||||
|  | ) | ||||||
|  | from tractor.msg import ( | ||||||
|  |     Aid, | ||||||
|  |     MsgCodec, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from ._transport import MsgTransport | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | _is_windows = platform.system() == 'Windows' | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Channel: | ||||||
|  |     ''' | ||||||
|  |     An inter-process channel for communication between (remote) actors. | ||||||
|  | 
 | ||||||
|  |     Wraps a ``MsgStream``: transport + encoding IPC connection. | ||||||
|  | 
 | ||||||
|  |     Currently we only support ``trio.SocketStream`` for transport | ||||||
|  |     (aka TCP) and the ``msgpack`` interchange format via the ``msgspec`` | ||||||
|  |     codec libary. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     def __init__( | ||||||
|  | 
 | ||||||
|  |         self, | ||||||
|  |         transport: MsgTransport|None = None, | ||||||
|  |         # TODO: optional reconnection support? | ||||||
|  |         # auto_reconnect: bool = False, | ||||||
|  |         # on_reconnect: typing.Callable[..., typing.Awaitable] = None, | ||||||
|  | 
 | ||||||
|  |     ) -> None: | ||||||
|  | 
 | ||||||
|  |         # self._recon_seq = on_reconnect | ||||||
|  |         # self._autorecon = auto_reconnect | ||||||
|  | 
 | ||||||
|  |         # Either created in ``.connect()`` or passed in by | ||||||
|  |         # user in ``.from_stream()``. | ||||||
|  |         self._transport: MsgTransport|None = transport | ||||||
|  | 
 | ||||||
|  |         # set after handshake - always info from peer end | ||||||
|  |         self.aid: Aid|None = None | ||||||
|  | 
 | ||||||
|  |         self._aiter_msgs = self._iter_msgs() | ||||||
|  |         self._exc: Exception|None = None | ||||||
|  |         # ^XXX! ONLY set if a remote actor sends an `Error`-msg | ||||||
|  |         self._closed: bool = False | ||||||
|  | 
 | ||||||
|  |         # flag set by ``Portal.cancel_actor()`` indicating remote | ||||||
|  |         # (possibly peer) cancellation of the far end actor | ||||||
|  |         # runtime. | ||||||
|  |         self._cancel_called: bool = False | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def uid(self) -> tuple[str, str]: | ||||||
|  |         ''' | ||||||
|  |         Peer actor's unique id. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         msg: str = ( | ||||||
|  |             f'`{type(self).__name__}.uid` is now deprecated.\n' | ||||||
|  |             'Use the new `.aid: tractor.msg.Aid` (struct) instead ' | ||||||
|  |             'which also provides additional named (optional) fields ' | ||||||
|  |             'beyond just the `.name` and `.uuid`.' | ||||||
|  |         ) | ||||||
|  |         warnings.warn( | ||||||
|  |             msg, | ||||||
|  |             DeprecationWarning, | ||||||
|  |             stacklevel=2, | ||||||
|  |         ) | ||||||
|  |         peer_aid: Aid = self.aid | ||||||
|  |         return ( | ||||||
|  |             peer_aid.name, | ||||||
|  |             peer_aid.uuid, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def stream(self) -> trio.abc.Stream | None: | ||||||
|  |         return self._transport.stream if self._transport else None | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def msgstream(self) -> MsgTransport: | ||||||
|  |         log.info( | ||||||
|  |             '`Channel.msgstream` is an old name, use `._transport`' | ||||||
|  |         ) | ||||||
|  |         return self._transport | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def transport(self) -> MsgTransport: | ||||||
|  |         return self._transport | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def from_stream( | ||||||
|  |         cls, | ||||||
|  |         stream: trio.abc.Stream, | ||||||
|  |     ) -> Channel: | ||||||
|  |         transport_cls = transport_from_stream(stream) | ||||||
|  |         return Channel( | ||||||
|  |             transport=transport_cls(stream) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     async def from_addr( | ||||||
|  |         cls, | ||||||
|  |         addr: UnwrappedAddress, | ||||||
|  |         **kwargs | ||||||
|  |     ) -> Channel: | ||||||
|  | 
 | ||||||
|  |         if not is_wrapped_addr(addr): | ||||||
|  |             addr: Address = wrap_address(addr) | ||||||
|  | 
 | ||||||
|  |         transport_cls = transport_from_addr(addr) | ||||||
|  |         transport = await transport_cls.connect_to( | ||||||
|  |             addr, | ||||||
|  |             **kwargs, | ||||||
|  |         ) | ||||||
|  |         assert transport.raddr == addr | ||||||
|  |         chan = Channel(transport=transport) | ||||||
|  |         log.runtime( | ||||||
|  |             f'Connected channel IPC transport\n' | ||||||
|  |             f'[>\n' | ||||||
|  |             f' |_{chan}\n' | ||||||
|  |         ) | ||||||
|  |         return chan | ||||||
|  | 
 | ||||||
|  |     @cm | ||||||
|  |     def apply_codec( | ||||||
|  |         self, | ||||||
|  |         codec: MsgCodec, | ||||||
|  |     ) -> None: | ||||||
|  |         ''' | ||||||
|  |         Temporarily override the underlying IPC msg codec for | ||||||
|  |         dynamic enforcement of messaging schema. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         orig: MsgCodec = self._transport.codec | ||||||
|  |         try: | ||||||
|  |             self._transport.codec = codec | ||||||
|  |             yield | ||||||
|  |         finally: | ||||||
|  |             self._transport.codec = orig | ||||||
|  | 
 | ||||||
|  |     # TODO: do a .src/.dst: str for maddrs? | ||||||
|  |     def pformat(self) -> str: | ||||||
|  |         if not self._transport: | ||||||
|  |             return '<Channel with inactive transport?>' | ||||||
|  | 
 | ||||||
|  |         tpt: MsgTransport = self._transport | ||||||
|  |         tpt_name: str = type(tpt).__name__ | ||||||
|  |         tpt_status: str = ( | ||||||
|  |             'connected' if self.connected() | ||||||
|  |             else 'closed' | ||||||
|  |         ) | ||||||
|  |         return ( | ||||||
|  |             f'<Channel(\n' | ||||||
|  |             f' |_status: {tpt_status!r}\n' | ||||||
|  |             f'   _closed={self._closed}\n' | ||||||
|  |             f'   _cancel_called={self._cancel_called}\n' | ||||||
|  |             f'\n' | ||||||
|  |             f' |_peer: {self.aid}\n' | ||||||
|  |             f'\n' | ||||||
|  |             f' |_msgstream: {tpt_name}\n' | ||||||
|  |             f'   proto={tpt.laddr.proto_key!r}\n' | ||||||
|  |             f'   layer={tpt.layer_key!r}\n' | ||||||
|  |             f'   laddr={tpt.laddr}\n' | ||||||
|  |             f'   raddr={tpt.raddr}\n' | ||||||
|  |             f'   codec={tpt.codec_key!r}\n' | ||||||
|  |             f'   stream={tpt.stream}\n' | ||||||
|  |             f'   maddr={tpt.maddr!r}\n' | ||||||
|  |             f'   drained={tpt.drained}\n' | ||||||
|  |             f'   _send_lock={tpt._send_lock.statistics()}\n' | ||||||
|  |             f')>\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # NOTE: making this return a value that can be passed to | ||||||
|  |     # `eval()` is entirely **optional** FYI! | ||||||
|  |     # https://docs.python.org/3/library/functions.html#repr | ||||||
|  |     # https://docs.python.org/3/reference/datamodel.html#object.__repr__ | ||||||
|  |     # | ||||||
|  |     # Currently we target **readability** from a (console) | ||||||
|  |     # logging perspective over `eval()`-ability since we do NOT | ||||||
|  |     # target serializing non-struct instances! | ||||||
|  |     # def __repr__(self) -> str: | ||||||
|  |     __str__ = pformat | ||||||
|  |     __repr__ = pformat | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def laddr(self) -> Address|None: | ||||||
|  |         return self._transport.laddr if self._transport else None | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def raddr(self) -> Address|None: | ||||||
|  |         return self._transport.raddr if self._transport else None | ||||||
|  | 
 | ||||||
|  |     # TODO: something like, | ||||||
|  |     # `pdbp.hideframe_on(errors=[MsgTypeError])` | ||||||
|  |     # instead of the `try/except` hack we have rn.. | ||||||
|  |     # seems like a pretty useful thing to have in general | ||||||
|  |     # along with being able to filter certain stack frame(s / sets) | ||||||
|  |     # possibly based on the current log-level? | ||||||
|  |     async def send( | ||||||
|  |         self, | ||||||
|  |         payload: Any, | ||||||
|  | 
 | ||||||
|  |         hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  |     ) -> None: | ||||||
|  |         ''' | ||||||
|  |         Send a coded msg-blob over the transport. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|  |         try: | ||||||
|  |             log.transport( | ||||||
|  |                 '=> send IPC msg:\n\n' | ||||||
|  |                 f'{pformat(payload)}\n' | ||||||
|  |             ) | ||||||
|  |             # assert self._transport  # but why typing? | ||||||
|  |             await self._transport.send( | ||||||
|  |                 payload, | ||||||
|  |                 hide_tb=hide_tb, | ||||||
|  |             ) | ||||||
|  |         except ( | ||||||
|  |             BaseException, | ||||||
|  |             MsgTypeError, | ||||||
|  |             TransportClosed, | ||||||
|  |         )as _err: | ||||||
|  |             err = _err  # bind for introspection | ||||||
|  |             match err: | ||||||
|  |                 case MsgTypeError(): | ||||||
|  |                     try: | ||||||
|  |                         assert err.cid | ||||||
|  |                     except KeyError: | ||||||
|  |                         raise err | ||||||
|  |                 case TransportClosed(): | ||||||
|  |                     log.transport( | ||||||
|  |                         f'Transport stream closed due to\n' | ||||||
|  |                         f'{err.repr_src_exc()}\n' | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                 case _: | ||||||
|  |                     # never suppress non-tpt sources | ||||||
|  |                     __tracebackhide__: bool = False | ||||||
|  |             raise | ||||||
|  | 
 | ||||||
|  |     async def recv(self) -> Any: | ||||||
|  |         assert self._transport | ||||||
|  |         return await self._transport.recv() | ||||||
|  | 
 | ||||||
|  |         # TODO: auto-reconnect features like 0mq/nanomsg? | ||||||
|  |         # -[ ] implement it manually with nods to SC prot | ||||||
|  |         #      possibly on multiple transport backends? | ||||||
|  |         #  -> seems like that might be re-inventing scalability | ||||||
|  |         #     prots tho no? | ||||||
|  |         # try: | ||||||
|  |         #     return await self._transport.recv() | ||||||
|  |         # except trio.BrokenResourceError: | ||||||
|  |         #     if self._autorecon: | ||||||
|  |         #         await self._reconnect() | ||||||
|  |         #         return await self.recv() | ||||||
|  |         #     raise | ||||||
|  | 
 | ||||||
|  |     async def aclose(self) -> None: | ||||||
|  | 
 | ||||||
|  |         log.transport( | ||||||
|  |             f'Closing channel to {self.aid} ' | ||||||
|  |             f'{self.laddr} -> {self.raddr}' | ||||||
|  |         ) | ||||||
|  |         assert self._transport | ||||||
|  |         await self._transport.stream.aclose() | ||||||
|  |         self._closed = True | ||||||
|  | 
 | ||||||
|  |     async def __aenter__(self): | ||||||
|  |         await self.connect() | ||||||
|  |         return self | ||||||
|  | 
 | ||||||
|  |     async def __aexit__(self, *args): | ||||||
|  |         await self.aclose(*args) | ||||||
|  | 
 | ||||||
|  |     def __aiter__(self): | ||||||
|  |         return self._aiter_msgs | ||||||
|  | 
 | ||||||
|  |     # ?TODO? run any reconnection sequence? | ||||||
|  |     # -[ ] prolly should be impl-ed as deco-API? | ||||||
|  |     # | ||||||
|  |     # async def _reconnect(self) -> None: | ||||||
|  |     #     """Handle connection failures by polling until a reconnect can be | ||||||
|  |     #     established. | ||||||
|  |     #     """ | ||||||
|  |     #     down = False | ||||||
|  |     #     while True: | ||||||
|  |     #         try: | ||||||
|  |     #             with trio.move_on_after(3) as cancel_scope: | ||||||
|  |     #                 await self.connect() | ||||||
|  |     #             cancelled = cancel_scope.cancelled_caught | ||||||
|  |     #             if cancelled: | ||||||
|  |     #                 log.transport( | ||||||
|  |     #                     "Reconnect timed out after 3 seconds, retrying...") | ||||||
|  |     #                 continue | ||||||
|  |     #             else: | ||||||
|  |     #                 log.transport("Stream connection re-established!") | ||||||
|  | 
 | ||||||
|  |     #                 # on_recon = self._recon_seq | ||||||
|  |     #                 # if on_recon: | ||||||
|  |     #                 #     await on_recon(self) | ||||||
|  | 
 | ||||||
|  |     #                 break | ||||||
|  |     #         except (OSError, ConnectionRefusedError): | ||||||
|  |     #             if not down: | ||||||
|  |     #                 down = True | ||||||
|  |     #                 log.transport( | ||||||
|  |     #                     f"Connection to {self.raddr} went down, waiting" | ||||||
|  |     #                     " for re-establishment") | ||||||
|  |     #             await trio.sleep(1) | ||||||
|  | 
 | ||||||
|  |     async def _iter_msgs( | ||||||
|  |         self | ||||||
|  |     ) -> AsyncGenerator[Any, None]: | ||||||
|  |         ''' | ||||||
|  |         Yield `MsgType` IPC msgs decoded and deliverd from | ||||||
|  |         an underlying `MsgTransport` protocol. | ||||||
|  | 
 | ||||||
|  |         This is a streaming routine alo implemented as an async-gen | ||||||
|  |         func (same a `MsgTransport._iter_pkts()`) gets allocated by | ||||||
|  |         a `.__call__()` inside `.__init__()` where it is assigned to | ||||||
|  |         the `._aiter_msgs` attr. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         assert self._transport | ||||||
|  |         while True: | ||||||
|  |             try: | ||||||
|  |                 async for msg in self._transport: | ||||||
|  |                     match msg: | ||||||
|  |                         # NOTE: if transport/interchange delivers | ||||||
|  |                         # a type error, we pack it with the far | ||||||
|  |                         # end peer `Actor.uid` and relay the | ||||||
|  |                         # `Error`-msg upward to the `._rpc` stack | ||||||
|  |                         # for normal RAE handling. | ||||||
|  |                         case MsgTypeError(): | ||||||
|  |                             yield pack_from_raise( | ||||||
|  |                                 local_err=msg, | ||||||
|  |                                 cid=msg.cid, | ||||||
|  | 
 | ||||||
|  |                                 # XXX we pack it here bc lower | ||||||
|  |                                 # layers have no notion of an | ||||||
|  |                                 # actor-id ;) | ||||||
|  |                                 src_uid=self.uid, | ||||||
|  |                             ) | ||||||
|  |                         case _: | ||||||
|  |                             yield msg | ||||||
|  | 
 | ||||||
|  |             except trio.BrokenResourceError: | ||||||
|  | 
 | ||||||
|  |                 # if not self._autorecon: | ||||||
|  |                 raise | ||||||
|  | 
 | ||||||
|  |             await self.aclose() | ||||||
|  | 
 | ||||||
|  |             # if self._autorecon:  # attempt reconnect | ||||||
|  |             #     await self._reconnect() | ||||||
|  |             #     continue | ||||||
|  | 
 | ||||||
|  |     def connected(self) -> bool: | ||||||
|  |         return self._transport.connected() if self._transport else False | ||||||
|  | 
 | ||||||
|  |     async def _do_handshake( | ||||||
|  |         self, | ||||||
|  |         aid: Aid, | ||||||
|  | 
 | ||||||
|  |     ) -> Aid: | ||||||
|  |         ''' | ||||||
|  |         Exchange `(name, UUIDs)` identifiers as the first | ||||||
|  |         communication step with any (peer) remote `Actor`. | ||||||
|  | 
 | ||||||
|  |         These are essentially the "mailbox addresses" found in | ||||||
|  |         "actor model" parlance. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         await self.send(aid) | ||||||
|  |         peer_aid: Aid = await self.recv() | ||||||
|  |         log.runtime( | ||||||
|  |             f'Received hanshake with peer actor,\n' | ||||||
|  |             f'{peer_aid}\n' | ||||||
|  |         ) | ||||||
|  |         # NOTE, we always are referencing the remote peer! | ||||||
|  |         self.aid = peer_aid | ||||||
|  |         return peer_aid | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @acm | ||||||
|  | async def _connect_chan( | ||||||
|  |     addr: UnwrappedAddress | ||||||
|  | ) -> typing.AsyncGenerator[Channel, None]: | ||||||
|  |     ''' | ||||||
|  |     Create and connect a channel with disconnect on context manager | ||||||
|  |     teardown. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     chan = await Channel.from_addr(addr) | ||||||
|  |     yield chan | ||||||
|  |     with trio.CancelScope(shield=True): | ||||||
|  |         await chan.aclose() | ||||||
|  | @ -0,0 +1,163 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | ''' | ||||||
|  | File-descriptor-sharing on `linux` by "wilhelm_of_bohemia". | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | import os | ||||||
|  | import array | ||||||
|  | import socket | ||||||
|  | import tempfile | ||||||
|  | from pathlib import Path | ||||||
|  | from contextlib import ExitStack | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | from tractor.ipc import RBToken | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | actor_name = 'ringd' | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _rings: dict[str, dict] = {} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def _attach_to_ring( | ||||||
|  |     ring_name: str | ||||||
|  | ) -> tuple[int, int, int]: | ||||||
|  |     actor = tractor.current_actor() | ||||||
|  | 
 | ||||||
|  |     fd_amount = 3 | ||||||
|  |     sock_path = ( | ||||||
|  |         Path(tempfile.gettempdir()) | ||||||
|  |         / | ||||||
|  |         f'{os.getpid()}-pass-ring-fds-{ring_name}-to-{actor.name}.sock' | ||||||
|  |     ) | ||||||
|  |     sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | ||||||
|  |     sock.bind(sock_path) | ||||||
|  |     sock.listen(1) | ||||||
|  | 
 | ||||||
|  |     async with ( | ||||||
|  |         tractor.find_actor(actor_name) as ringd, | ||||||
|  |         ringd.open_context( | ||||||
|  |             _pass_fds, | ||||||
|  |             name=ring_name, | ||||||
|  |             sock_path=sock_path | ||||||
|  |         ) as (ctx, _sent) | ||||||
|  |     ): | ||||||
|  |         # prepare array to receive FD | ||||||
|  |         fds = array.array("i", [0] * fd_amount) | ||||||
|  | 
 | ||||||
|  |         conn, _ = sock.accept() | ||||||
|  | 
 | ||||||
|  |         # receive FD | ||||||
|  |         msg, ancdata, flags, addr = conn.recvmsg( | ||||||
|  |             1024, | ||||||
|  |             socket.CMSG_LEN(fds.itemsize * fd_amount) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         for ( | ||||||
|  |             cmsg_level, | ||||||
|  |             cmsg_type, | ||||||
|  |             cmsg_data, | ||||||
|  |         ) in ancdata: | ||||||
|  |             if ( | ||||||
|  |                 cmsg_level == socket.SOL_SOCKET | ||||||
|  |                 and | ||||||
|  |                 cmsg_type == socket.SCM_RIGHTS | ||||||
|  |             ): | ||||||
|  |                 fds.frombytes(cmsg_data[:fds.itemsize * fd_amount]) | ||||||
|  |                 break | ||||||
|  |             else: | ||||||
|  |                 raise RuntimeError("Receiver: No FDs received") | ||||||
|  | 
 | ||||||
|  |         conn.close() | ||||||
|  |         sock.close() | ||||||
|  |         sock_path.unlink() | ||||||
|  | 
 | ||||||
|  |         return RBToken.from_msg( | ||||||
|  |             await ctx.wait_for_result() | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def _pass_fds( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  |     name: str, | ||||||
|  |     sock_path: str | ||||||
|  | ) -> RBToken: | ||||||
|  |     global _rings | ||||||
|  |     token = _rings[name] | ||||||
|  |     client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | ||||||
|  |     client.connect(sock_path) | ||||||
|  |     await ctx.started() | ||||||
|  |     fds = array.array('i', token.fds) | ||||||
|  |     client.sendmsg([b'FDs'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fds)]) | ||||||
|  |     client.close() | ||||||
|  |     return token | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def _open_ringbuf( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  |     name: str, | ||||||
|  |     buf_size: int | ||||||
|  | ) -> RBToken: | ||||||
|  |     global _rings | ||||||
|  |     is_owner = False | ||||||
|  |     if name not in _rings: | ||||||
|  |         stack = ExitStack() | ||||||
|  |         token = stack.enter_context( | ||||||
|  |             tractor.open_ringbuf( | ||||||
|  |                 name, | ||||||
|  |                 buf_size=buf_size | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         _rings[name] = { | ||||||
|  |             'token': token, | ||||||
|  |             'stack': stack, | ||||||
|  |         } | ||||||
|  |         is_owner = True | ||||||
|  | 
 | ||||||
|  |     ring = _rings[name] | ||||||
|  |     await ctx.started() | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|  |         await trio.sleep_forever() | ||||||
|  | 
 | ||||||
|  |     except tractor.ContextCancelled: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     finally: | ||||||
|  |         if is_owner: | ||||||
|  |             ring['stack'].close() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def open_ringbuf( | ||||||
|  |     name: str, | ||||||
|  |     buf_size: int | ||||||
|  | ) -> RBToken: | ||||||
|  |     async with ( | ||||||
|  |         tractor.find_actor(actor_name) as ringd, | ||||||
|  |         ringd.open_context( | ||||||
|  |             _open_ringbuf, | ||||||
|  |             name=name, | ||||||
|  |             buf_size=buf_size | ||||||
|  |         ) as (rd_ctx, _) | ||||||
|  |     ): | ||||||
|  |         yield await _attach_to_ring(name) | ||||||
|  |         await rd_ctx.cancel() | ||||||
|  | @ -0,0 +1,153 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | ''' | ||||||
|  | Linux specifics, for now we are only exposing EventFD | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import os | ||||||
|  | import errno | ||||||
|  | 
 | ||||||
|  | import cffi | ||||||
|  | import trio | ||||||
|  | 
 | ||||||
|  | ffi = cffi.FFI() | ||||||
|  | 
 | ||||||
|  | # Declare the C functions and types we plan to use. | ||||||
|  | #    - eventfd: for creating the event file descriptor | ||||||
|  | #    - write:   for writing to the file descriptor | ||||||
|  | #    - read:    for reading from the file descriptor | ||||||
|  | #    - close:   for closing the file descriptor | ||||||
|  | ffi.cdef( | ||||||
|  |     ''' | ||||||
|  |     int eventfd(unsigned int initval, int flags); | ||||||
|  | 
 | ||||||
|  |     ssize_t write(int fd, const void *buf, size_t count); | ||||||
|  |     ssize_t read(int fd, void *buf, size_t count); | ||||||
|  | 
 | ||||||
|  |     int close(int fd); | ||||||
|  |     ''' | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # Open the default dynamic library (essentially 'libc' in most cases) | ||||||
|  | C = ffi.dlopen(None) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # Constants from <sys/eventfd.h>, if needed. | ||||||
|  | EFD_SEMAPHORE = 1 | ||||||
|  | EFD_CLOEXEC = 0o2000000 | ||||||
|  | EFD_NONBLOCK = 0o4000 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def open_eventfd(initval: int = 0, flags: int = 0) -> int: | ||||||
|  |     ''' | ||||||
|  |     Open an eventfd with the given initial value and flags. | ||||||
|  |     Returns the file descriptor on success, otherwise raises OSError. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     fd = C.eventfd(initval, flags) | ||||||
|  |     if fd < 0: | ||||||
|  |         raise OSError(errno.errorcode[ffi.errno], 'eventfd failed') | ||||||
|  |     return fd | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def write_eventfd(fd: int, value: int) -> int: | ||||||
|  |     ''' | ||||||
|  |     Write a 64-bit integer (uint64_t) to the eventfd's counter. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # Create a uint64_t* in C, store `value` | ||||||
|  |     data_ptr = ffi.new('uint64_t *', value) | ||||||
|  | 
 | ||||||
|  |     # Call write(fd, data_ptr, 8) | ||||||
|  |     # We expect to write exactly 8 bytes (sizeof(uint64_t)) | ||||||
|  |     ret = C.write(fd, data_ptr, 8) | ||||||
|  |     if ret < 0: | ||||||
|  |         raise OSError(errno.errorcode[ffi.errno], 'write to eventfd failed') | ||||||
|  |     return ret | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def read_eventfd(fd: int) -> int: | ||||||
|  |     ''' | ||||||
|  |     Read a 64-bit integer (uint64_t) from the eventfd, returning the value. | ||||||
|  |     Reading resets the counter to 0 (unless using EFD_SEMAPHORE). | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # Allocate an 8-byte buffer in C for reading | ||||||
|  |     buf = ffi.new('char[]', 8) | ||||||
|  | 
 | ||||||
|  |     ret = C.read(fd, buf, 8) | ||||||
|  |     if ret < 0: | ||||||
|  |         raise OSError(errno.errorcode[ffi.errno], 'read from eventfd failed') | ||||||
|  |     # Convert the 8 bytes we read into a Python integer | ||||||
|  |     data_bytes = ffi.unpack(buf, 8)  # returns a Python bytes object of length 8 | ||||||
|  |     value = int.from_bytes(data_bytes, byteorder='little', signed=False) | ||||||
|  |     return value | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def close_eventfd(fd: int) -> int: | ||||||
|  |     ''' | ||||||
|  |     Close the eventfd. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     ret = C.close(fd) | ||||||
|  |     if ret < 0: | ||||||
|  |         raise OSError(errno.errorcode[ffi.errno], 'close failed') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class EventFD: | ||||||
|  |     ''' | ||||||
|  |     Use a previously opened eventfd(2), meant to be used in | ||||||
|  |     sub-actors after root actor opens the eventfds then passes | ||||||
|  |     them through pass_fds | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  | 
 | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         fd: int, | ||||||
|  |         omode: str | ||||||
|  |     ): | ||||||
|  |         self._fd: int = fd | ||||||
|  |         self._omode: str = omode | ||||||
|  |         self._fobj = None | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def fd(self) -> int | None: | ||||||
|  |         return self._fd | ||||||
|  | 
 | ||||||
|  |     def write(self, value: int) -> int: | ||||||
|  |         return write_eventfd(self._fd, value) | ||||||
|  | 
 | ||||||
|  |     async def read(self) -> int: | ||||||
|  |         return await trio.to_thread.run_sync( | ||||||
|  |             read_eventfd, self._fd, | ||||||
|  |             abandon_on_cancel=True | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     def open(self): | ||||||
|  |         self._fobj = os.fdopen(self._fd, self._omode) | ||||||
|  | 
 | ||||||
|  |     def close(self): | ||||||
|  |         if self._fobj: | ||||||
|  |             self._fobj.close() | ||||||
|  | 
 | ||||||
|  |     def __enter__(self): | ||||||
|  |         self.open() | ||||||
|  |         return self | ||||||
|  | 
 | ||||||
|  |     def __exit__(self, exc_type, exc_value, traceback): | ||||||
|  |         self.close() | ||||||
|  | @ -0,0 +1,45 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | ''' | ||||||
|  | Utils to tame mp non-SC madeness | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | def disable_mantracker(): | ||||||
|  |     ''' | ||||||
|  |     Disable all ``multiprocessing``` "resource tracking" machinery since | ||||||
|  |     it's an absolute multi-threaded mess of non-SC madness. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     from multiprocessing import resource_tracker as mantracker | ||||||
|  | 
 | ||||||
|  |     # Tell the "resource tracker" thing to fuck off. | ||||||
|  |     class ManTracker(mantracker.ResourceTracker): | ||||||
|  |         def register(self, name, rtype): | ||||||
|  |             pass | ||||||
|  | 
 | ||||||
|  |         def unregister(self, name, rtype): | ||||||
|  |             pass | ||||||
|  | 
 | ||||||
|  |         def ensure_running(self): | ||||||
|  |             pass | ||||||
|  | 
 | ||||||
|  |     # "know your land and know your prey" | ||||||
|  |     # https://www.dailymotion.com/video/x6ozzco | ||||||
|  |     mantracker._resource_tracker = ManTracker() | ||||||
|  |     mantracker.register = mantracker._resource_tracker.register | ||||||
|  |     mantracker.ensure_running = mantracker._resource_tracker.ensure_running | ||||||
|  |     mantracker.unregister = mantracker._resource_tracker.unregister | ||||||
|  |     mantracker.getfd = mantracker._resource_tracker.getfd | ||||||
|  | @ -0,0 +1,253 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | ''' | ||||||
|  | IPC Reliable RingBuffer implementation | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from contextlib import contextmanager as cm | ||||||
|  | from multiprocessing.shared_memory import SharedMemory | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | from msgspec import ( | ||||||
|  |     Struct, | ||||||
|  |     to_builtins | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from ._linux import ( | ||||||
|  |     EFD_NONBLOCK, | ||||||
|  |     open_eventfd, | ||||||
|  |     EventFD | ||||||
|  | ) | ||||||
|  | from ._mp_bs import disable_mantracker | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | disable_mantracker() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class RBToken(Struct, frozen=True): | ||||||
|  |     ''' | ||||||
|  |     RingBuffer token contains necesary info to open the two | ||||||
|  |     eventfds and the shared memory | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     shm_name: str | ||||||
|  |     write_eventfd: int | ||||||
|  |     wrap_eventfd: int | ||||||
|  |     buf_size: int | ||||||
|  | 
 | ||||||
|  |     def as_msg(self): | ||||||
|  |         return to_builtins(self) | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def from_msg(cls, msg: dict) -> RBToken: | ||||||
|  |         if isinstance(msg, RBToken): | ||||||
|  |             return msg | ||||||
|  | 
 | ||||||
|  |         return RBToken(**msg) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @cm | ||||||
|  | def open_ringbuf( | ||||||
|  |     shm_name: str, | ||||||
|  |     buf_size: int = 10 * 1024, | ||||||
|  |     write_efd_flags: int = 0, | ||||||
|  |     wrap_efd_flags: int = 0 | ||||||
|  | ) -> RBToken: | ||||||
|  |     shm = SharedMemory( | ||||||
|  |         name=shm_name, | ||||||
|  |         size=buf_size, | ||||||
|  |         create=True | ||||||
|  |     ) | ||||||
|  |     try: | ||||||
|  |         token = RBToken( | ||||||
|  |             shm_name=shm_name, | ||||||
|  |             write_eventfd=open_eventfd(flags=write_efd_flags), | ||||||
|  |             wrap_eventfd=open_eventfd(flags=wrap_efd_flags), | ||||||
|  |             buf_size=buf_size | ||||||
|  |         ) | ||||||
|  |         yield token | ||||||
|  | 
 | ||||||
|  |     finally: | ||||||
|  |         shm.unlink() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class RingBuffSender(trio.abc.SendStream): | ||||||
|  |     ''' | ||||||
|  |     IPC Reliable Ring Buffer sender side implementation | ||||||
|  | 
 | ||||||
|  |     `eventfd(2)` is used for wrap around sync, and also to signal | ||||||
|  |     writes to the reader. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         token: RBToken, | ||||||
|  |         start_ptr: int = 0, | ||||||
|  |     ): | ||||||
|  |         token = RBToken.from_msg(token) | ||||||
|  |         self._shm = SharedMemory( | ||||||
|  |             name=token.shm_name, | ||||||
|  |             size=token.buf_size, | ||||||
|  |             create=False | ||||||
|  |         ) | ||||||
|  |         self._write_event = EventFD(token.write_eventfd, 'w') | ||||||
|  |         self._wrap_event = EventFD(token.wrap_eventfd, 'r') | ||||||
|  |         self._ptr = start_ptr | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def key(self) -> str: | ||||||
|  |         return self._shm.name | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def size(self) -> int: | ||||||
|  |         return self._shm.size | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def ptr(self) -> int: | ||||||
|  |         return self._ptr | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def write_fd(self) -> int: | ||||||
|  |         return self._write_event.fd | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def wrap_fd(self) -> int: | ||||||
|  |         return self._wrap_event.fd | ||||||
|  | 
 | ||||||
|  |     async def send_all(self, data: bytes | bytearray | memoryview): | ||||||
|  |         # while data is larger than the remaining buf | ||||||
|  |         target_ptr = self.ptr + len(data) | ||||||
|  |         while target_ptr > self.size: | ||||||
|  |             # write all bytes that fit | ||||||
|  |             remaining = self.size - self.ptr | ||||||
|  |             self._shm.buf[self.ptr:] = data[:remaining] | ||||||
|  |             # signal write and wait for reader wrap around | ||||||
|  |             self._write_event.write(remaining) | ||||||
|  |             await self._wrap_event.read() | ||||||
|  | 
 | ||||||
|  |             # wrap around and trim already written bytes | ||||||
|  |             self._ptr = 0 | ||||||
|  |             data = data[remaining:] | ||||||
|  |             target_ptr = self._ptr + len(data) | ||||||
|  | 
 | ||||||
|  |         # remaining data fits on buffer | ||||||
|  |         self._shm.buf[self.ptr:target_ptr] = data | ||||||
|  |         self._write_event.write(len(data)) | ||||||
|  |         self._ptr = target_ptr | ||||||
|  | 
 | ||||||
|  |     async def wait_send_all_might_not_block(self): | ||||||
|  |         raise NotImplementedError | ||||||
|  | 
 | ||||||
|  |     async def aclose(self): | ||||||
|  |         self._write_event.close() | ||||||
|  |         self._wrap_event.close() | ||||||
|  |         self._shm.close() | ||||||
|  | 
 | ||||||
|  |     async def __aenter__(self): | ||||||
|  |         self._write_event.open() | ||||||
|  |         self._wrap_event.open() | ||||||
|  |         return self | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class RingBuffReceiver(trio.abc.ReceiveStream): | ||||||
|  |     ''' | ||||||
|  |     IPC Reliable Ring Buffer receiver side implementation | ||||||
|  | 
 | ||||||
|  |     `eventfd(2)` is used for wrap around sync, and also to signal | ||||||
|  |     writes to the reader. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         token: RBToken, | ||||||
|  |         start_ptr: int = 0, | ||||||
|  |         flags: int = 0 | ||||||
|  |     ): | ||||||
|  |         token = RBToken.from_msg(token) | ||||||
|  |         self._shm = SharedMemory( | ||||||
|  |             name=token.shm_name, | ||||||
|  |             size=token.buf_size, | ||||||
|  |             create=False | ||||||
|  |         ) | ||||||
|  |         self._write_event = EventFD(token.write_eventfd, 'w') | ||||||
|  |         self._wrap_event = EventFD(token.wrap_eventfd, 'r') | ||||||
|  |         self._ptr = start_ptr | ||||||
|  |         self._flags = flags | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def key(self) -> str: | ||||||
|  |         return self._shm.name | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def size(self) -> int: | ||||||
|  |         return self._shm.size | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def ptr(self) -> int: | ||||||
|  |         return self._ptr | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def write_fd(self) -> int: | ||||||
|  |         return self._write_event.fd | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def wrap_fd(self) -> int: | ||||||
|  |         return self._wrap_event.fd | ||||||
|  | 
 | ||||||
|  |     async def receive_some( | ||||||
|  |         self, | ||||||
|  |         max_bytes: int | None = None, | ||||||
|  |         nb_timeout: float = 0.1 | ||||||
|  |     ) -> memoryview: | ||||||
|  |         # if non blocking eventfd enabled, do polling | ||||||
|  |         # until next write, this allows signal handling | ||||||
|  |         if self._flags | EFD_NONBLOCK: | ||||||
|  |             delta = None | ||||||
|  |             while delta is None: | ||||||
|  |                 try: | ||||||
|  |                     delta = await self._write_event.read() | ||||||
|  | 
 | ||||||
|  |                 except OSError as e: | ||||||
|  |                     if e.errno == 'EAGAIN': | ||||||
|  |                         continue | ||||||
|  | 
 | ||||||
|  |                     raise e | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             delta = await self._write_event.read() | ||||||
|  | 
 | ||||||
|  |         # fetch next segment and advance ptr | ||||||
|  |         next_ptr = self._ptr + delta | ||||||
|  |         segment = self._shm.buf[self._ptr:next_ptr] | ||||||
|  |         self._ptr = next_ptr | ||||||
|  | 
 | ||||||
|  |         if self.ptr == self.size: | ||||||
|  |             # reached the end, signal wrap around | ||||||
|  |             self._ptr = 0 | ||||||
|  |             self._wrap_event.write(1) | ||||||
|  | 
 | ||||||
|  |         return segment | ||||||
|  | 
 | ||||||
|  |     async def aclose(self): | ||||||
|  |         self._write_event.close() | ||||||
|  |         self._wrap_event.close() | ||||||
|  |         self._shm.close() | ||||||
|  | 
 | ||||||
|  |     async def __aenter__(self): | ||||||
|  |         self._write_event.open() | ||||||
|  |         self._wrap_event.open() | ||||||
|  |         return self | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -32,10 +32,14 @@ from multiprocessing.shared_memory import ( | ||||||
|     ShareableList, |     ShareableList, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| from msgspec import Struct | from msgspec import ( | ||||||
|  |     Struct, | ||||||
|  |     to_builtins | ||||||
|  | ) | ||||||
| import tractor | import tractor | ||||||
| 
 | 
 | ||||||
| from .log import get_logger | from tractor.ipc._mp_bs import disable_mantracker | ||||||
|  | from tractor.log import get_logger | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| _USE_POSIX = getattr(shm, '_USE_POSIX', False) | _USE_POSIX = getattr(shm, '_USE_POSIX', False) | ||||||
|  | @ -46,7 +50,10 @@ if _USE_POSIX: | ||||||
| try: | try: | ||||||
|     import numpy as np |     import numpy as np | ||||||
|     from numpy.lib import recfunctions as rfn |     from numpy.lib import recfunctions as rfn | ||||||
|     import nptyping |     # TODO ruff complains with, | ||||||
|  |     # warning| F401: `nptyping` imported but unused; consider using | ||||||
|  |     # `importlib.util.find_spec` to test for availability | ||||||
|  |     import nptyping  # noqa | ||||||
| except ImportError: | except ImportError: | ||||||
|     pass |     pass | ||||||
| 
 | 
 | ||||||
|  | @ -54,34 +61,6 @@ except ImportError: | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def disable_mantracker(): |  | ||||||
|     ''' |  | ||||||
|     Disable all ``multiprocessing``` "resource tracking" machinery since |  | ||||||
|     it's an absolute multi-threaded mess of non-SC madness. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     from multiprocessing import resource_tracker as mantracker |  | ||||||
| 
 |  | ||||||
|     # Tell the "resource tracker" thing to fuck off. |  | ||||||
|     class ManTracker(mantracker.ResourceTracker): |  | ||||||
|         def register(self, name, rtype): |  | ||||||
|             pass |  | ||||||
| 
 |  | ||||||
|         def unregister(self, name, rtype): |  | ||||||
|             pass |  | ||||||
| 
 |  | ||||||
|         def ensure_running(self): |  | ||||||
|             pass |  | ||||||
| 
 |  | ||||||
|     # "know your land and know your prey" |  | ||||||
|     # https://www.dailymotion.com/video/x6ozzco |  | ||||||
|     mantracker._resource_tracker = ManTracker() |  | ||||||
|     mantracker.register = mantracker._resource_tracker.register |  | ||||||
|     mantracker.ensure_running = mantracker._resource_tracker.ensure_running |  | ||||||
|     mantracker.unregister = mantracker._resource_tracker.unregister |  | ||||||
|     mantracker.getfd = mantracker._resource_tracker.getfd |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| disable_mantracker() | disable_mantracker() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -142,7 +121,7 @@ class NDToken(Struct, frozen=True): | ||||||
|         ).descr |         ).descr | ||||||
| 
 | 
 | ||||||
|     def as_msg(self): |     def as_msg(self): | ||||||
|         return self.to_dict() |         return to_builtins(self) | ||||||
| 
 | 
 | ||||||
|     @classmethod |     @classmethod | ||||||
|     def from_msg(cls, msg: dict) -> NDToken: |     def from_msg(cls, msg: dict) -> NDToken: | ||||||
|  | @ -0,0 +1,212 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | ''' | ||||||
|  | TCP implementation of tractor.ipc._transport.MsgTransport protocol  | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from typing import ( | ||||||
|  |     ClassVar, | ||||||
|  | ) | ||||||
|  | # from contextlib import ( | ||||||
|  | #     asynccontextmanager as acm, | ||||||
|  | # ) | ||||||
|  | 
 | ||||||
|  | import msgspec | ||||||
|  | import trio | ||||||
|  | from trio import ( | ||||||
|  |     SocketListener, | ||||||
|  |     open_tcp_listeners, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from tractor.msg import MsgCodec | ||||||
|  | from tractor.log import get_logger | ||||||
|  | from tractor.ipc._transport import ( | ||||||
|  |     MsgTransport, | ||||||
|  |     MsgpackTransport, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TCPAddress( | ||||||
|  |     msgspec.Struct, | ||||||
|  |     frozen=True, | ||||||
|  | ): | ||||||
|  |     _host: str | ||||||
|  |     _port: int | ||||||
|  | 
 | ||||||
|  |     proto_key: ClassVar[str] = 'tcp' | ||||||
|  |     unwrapped_type: ClassVar[type] = tuple[str, int] | ||||||
|  |     def_bindspace: ClassVar[str] = '127.0.0.1' | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def is_valid(self) -> bool: | ||||||
|  |         return self._port != 0 | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def bindspace(self) -> str: | ||||||
|  |         return self._host | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def domain(self) -> str: | ||||||
|  |         return self._host | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def from_addr( | ||||||
|  |         cls, | ||||||
|  |         addr: tuple[str, int] | ||||||
|  |     ) -> TCPAddress: | ||||||
|  |         match addr: | ||||||
|  |             case (str(), int()): | ||||||
|  |                 return TCPAddress(addr[0], addr[1]) | ||||||
|  |             case _: | ||||||
|  |                 raise ValueError( | ||||||
|  |                     f'Invalid unwrapped address for {cls}\n' | ||||||
|  |                     f'{addr}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |     def unwrap(self) -> tuple[str, int]: | ||||||
|  |         return ( | ||||||
|  |             self._host, | ||||||
|  |             self._port, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def get_random( | ||||||
|  |         cls, | ||||||
|  |         bindspace: str = def_bindspace, | ||||||
|  |     ) -> TCPAddress: | ||||||
|  |         return TCPAddress(bindspace, 0) | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def get_root(cls) -> TCPAddress: | ||||||
|  |         return TCPAddress( | ||||||
|  |             '127.0.0.1', | ||||||
|  |             1616, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     def __repr__(self) -> str: | ||||||
|  |         return ( | ||||||
|  |             f'{type(self).__name__}[{self.unwrap()}]' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def get_transport( | ||||||
|  |         cls, | ||||||
|  |         codec: str = 'msgpack', | ||||||
|  |     ) -> MsgTransport: | ||||||
|  |         match codec: | ||||||
|  |             case 'msgspack': | ||||||
|  |                 return MsgpackTCPStream | ||||||
|  |             case _: | ||||||
|  |                 raise ValueError( | ||||||
|  |                     f'No IPC transport with {codec!r} supported !' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def start_listener( | ||||||
|  |     addr: TCPAddress, | ||||||
|  |     **kwargs, | ||||||
|  | ) -> SocketListener: | ||||||
|  |     ''' | ||||||
|  |     Start a TCP socket listener on the given `TCPAddress`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # ?TODO, maybe we should just change the lower-level call this is | ||||||
|  |     # using internall per-listener? | ||||||
|  |     listeners: list[SocketListener] = await open_tcp_listeners( | ||||||
|  |         host=addr._host, | ||||||
|  |         port=addr._port, | ||||||
|  |         **kwargs | ||||||
|  |     ) | ||||||
|  |     # NOTE, for now we don't expect non-singleton-resolving | ||||||
|  |     # domain-addresses/multi-homed-hosts. | ||||||
|  |     # (though it is supported by `open_tcp_listeners()`) | ||||||
|  |     assert len(listeners) == 1 | ||||||
|  |     listener = listeners[0] | ||||||
|  |     host, port = listener.socket.getsockname()[:2] | ||||||
|  |     return listener | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: typing oddity.. not sure why we have to inherit here, but it | ||||||
|  | # seems to be an issue with `get_msg_transport()` returning | ||||||
|  | # a `Type[Protocol]`; probably should make a `mypy` issue? | ||||||
|  | class MsgpackTCPStream(MsgpackTransport): | ||||||
|  |     ''' | ||||||
|  |     A ``trio.SocketStream`` delivering ``msgpack`` formatted data | ||||||
|  |     using the ``msgspec`` codec lib. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     address_type = TCPAddress | ||||||
|  |     layer_key: int = 4 | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def maddr(self) -> str: | ||||||
|  |         host, port = self.raddr.unwrap() | ||||||
|  |         return ( | ||||||
|  |             # TODO, use `ipaddress` from stdlib to handle | ||||||
|  |             # first detecting which of `ipv4/6` before | ||||||
|  |             # choosing the routing prefix part. | ||||||
|  |             f'/ipv4/{host}' | ||||||
|  | 
 | ||||||
|  |             f'/{self.address_type.proto_key}/{port}' | ||||||
|  |             # f'/{self.chan.uid[0]}' | ||||||
|  |             # f'/{self.cid}' | ||||||
|  | 
 | ||||||
|  |             # f'/cid={cid_head}..{cid_tail}' | ||||||
|  |             # TODO: ? not use this ^ right ? | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     def connected(self) -> bool: | ||||||
|  |         return self.stream.socket.fileno() != -1 | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     async def connect_to( | ||||||
|  |         cls, | ||||||
|  |         destaddr: TCPAddress, | ||||||
|  |         prefix_size: int = 4, | ||||||
|  |         codec: MsgCodec|None = None, | ||||||
|  |         **kwargs | ||||||
|  |     ) -> MsgpackTCPStream: | ||||||
|  |         stream = await trio.open_tcp_stream( | ||||||
|  |             *destaddr.unwrap(), | ||||||
|  |             **kwargs | ||||||
|  |         ) | ||||||
|  |         return MsgpackTCPStream( | ||||||
|  |             stream, | ||||||
|  |             prefix_size=prefix_size, | ||||||
|  |             codec=codec | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def get_stream_addrs( | ||||||
|  |         cls, | ||||||
|  |         stream: trio.SocketStream | ||||||
|  |     ) -> tuple[ | ||||||
|  |         TCPAddress, | ||||||
|  |         TCPAddress, | ||||||
|  |     ]: | ||||||
|  |         # TODO, what types are these? | ||||||
|  |         lsockname = stream.socket.getsockname() | ||||||
|  |         l_sockaddr: tuple[str, int] = tuple(lsockname[:2]) | ||||||
|  |         rsockname = stream.socket.getpeername() | ||||||
|  |         r_sockaddr: tuple[str, int] = tuple(rsockname[:2]) | ||||||
|  |         return ( | ||||||
|  |             TCPAddress.from_addr(l_sockaddr), | ||||||
|  |             TCPAddress.from_addr(r_sockaddr), | ||||||
|  |         ) | ||||||
|  | @ -0,0 +1,514 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | ''' | ||||||
|  | typing.Protocol based generic msg API, implement this class to add | ||||||
|  | backends for tractor.ipc.Channel | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from typing import ( | ||||||
|  |     runtime_checkable, | ||||||
|  |     Type, | ||||||
|  |     Protocol, | ||||||
|  |     # TypeVar, | ||||||
|  |     ClassVar, | ||||||
|  |     TYPE_CHECKING, | ||||||
|  | ) | ||||||
|  | from collections.abc import ( | ||||||
|  |     AsyncGenerator, | ||||||
|  |     AsyncIterator, | ||||||
|  | ) | ||||||
|  | import struct | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | import msgspec | ||||||
|  | from tricycle import BufferedReceiveStream | ||||||
|  | 
 | ||||||
|  | from tractor.log import get_logger | ||||||
|  | from tractor._exceptions import ( | ||||||
|  |     MsgTypeError, | ||||||
|  |     TransportClosed, | ||||||
|  |     _mk_send_mte, | ||||||
|  |     _mk_recv_mte, | ||||||
|  | ) | ||||||
|  | from tractor.msg import ( | ||||||
|  |     _ctxvar_MsgCodec, | ||||||
|  |     # _codec,  XXX see `self._codec` sanity/debug checks | ||||||
|  |     MsgCodec, | ||||||
|  |     MsgType, | ||||||
|  |     types as msgtypes, | ||||||
|  |     pretty_struct, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from tractor._addr import Address | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # (codec, transport) | ||||||
|  | MsgTransportKey = tuple[str, str] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # from tractor.msg.types import MsgType | ||||||
|  | # ?TODO? this should be our `Union[*msgtypes.__spec__]` alias now right..? | ||||||
|  | # => BLEH, except can't bc prots must inherit typevar or param-spec | ||||||
|  | #   vars.. | ||||||
|  | # MsgType = TypeVar('MsgType') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @runtime_checkable | ||||||
|  | class MsgTransport(Protocol): | ||||||
|  | # | ||||||
|  | # class MsgTransport(Protocol[MsgType]): | ||||||
|  | # ^-TODO-^ consider using a generic def and indexing with our | ||||||
|  | # eventual msg definition/types? | ||||||
|  | # - https://docs.python.org/3/library/typing.html#typing.Protocol | ||||||
|  | 
 | ||||||
|  |     stream: trio.SocketStream | ||||||
|  |     drained: list[MsgType] | ||||||
|  | 
 | ||||||
|  |     address_type: ClassVar[Type[Address]] | ||||||
|  |     codec_key: ClassVar[str] | ||||||
|  | 
 | ||||||
|  |     # XXX: should this instead be called `.sendall()`? | ||||||
|  |     async def send(self, msg: MsgType) -> None: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     async def recv(self) -> MsgType: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     def __aiter__(self) -> MsgType: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     def connected(self) -> bool: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     # defining this sync otherwise it causes a mypy error because it | ||||||
|  |     # can't figure out it's a generator i guess?..? | ||||||
|  |     def drain(self) -> AsyncIterator[dict]: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def key(cls) -> MsgTransportKey: | ||||||
|  |         return ( | ||||||
|  |             cls.codec_key, | ||||||
|  |             cls.address_type.proto_key, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def laddr(self) -> Address: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def raddr(self) -> Address: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def maddr(self) -> str: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     async def connect_to( | ||||||
|  |         cls, | ||||||
|  |         addr: Address, | ||||||
|  |         **kwargs | ||||||
|  |     ) -> MsgTransport: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def get_stream_addrs( | ||||||
|  |         cls, | ||||||
|  |         stream: trio.abc.Stream | ||||||
|  |     ) -> tuple[ | ||||||
|  |         Address,  # local | ||||||
|  |         Address   # remote | ||||||
|  |     ]: | ||||||
|  |         ''' | ||||||
|  |         Return the transport protocol's address pair for the local | ||||||
|  |         and remote-peer side. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     # TODO, such that all `.raddr`s for each `SocketStream` are | ||||||
|  |     # delivered? | ||||||
|  |     # -[ ] move `.open_listener()` here and internally track the | ||||||
|  |     #     listener set, per address? | ||||||
|  |     # def get_peers( | ||||||
|  |     #     self, | ||||||
|  |     # ) -> list[Address]: | ||||||
|  |     #     ... | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class MsgpackTransport(MsgTransport): | ||||||
|  | 
 | ||||||
|  |     # TODO: better naming for this? | ||||||
|  |     # -[ ] check how libp2p does naming for such things? | ||||||
|  |     codec_key: str = 'msgpack' | ||||||
|  | 
 | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         stream: trio.abc.Stream, | ||||||
|  |         prefix_size: int = 4, | ||||||
|  | 
 | ||||||
|  |         # XXX optionally provided codec pair for `msgspec`: | ||||||
|  |         # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types | ||||||
|  |         # | ||||||
|  |         # TODO: define this as a `Codec` struct which can be | ||||||
|  |         # overriden dynamically by the application/runtime? | ||||||
|  |         codec: MsgCodec = None, | ||||||
|  | 
 | ||||||
|  |     ) -> None: | ||||||
|  |         self.stream = stream | ||||||
|  |         ( | ||||||
|  |             self._laddr, | ||||||
|  |             self._raddr, | ||||||
|  |         ) = self.get_stream_addrs(stream) | ||||||
|  | 
 | ||||||
|  |         # create read loop instance | ||||||
|  |         self._aiter_pkts = self._iter_packets() | ||||||
|  |         self._send_lock = trio.StrictFIFOLock() | ||||||
|  | 
 | ||||||
|  |         # public i guess? | ||||||
|  |         self.drained: list[dict] = [] | ||||||
|  | 
 | ||||||
|  |         self.recv_stream = BufferedReceiveStream( | ||||||
|  |             transport_stream=stream | ||||||
|  |         ) | ||||||
|  |         self.prefix_size = prefix_size | ||||||
|  | 
 | ||||||
|  |         # allow for custom IPC msg interchange format | ||||||
|  |         # dynamic override Bo | ||||||
|  |         self._task = trio.lowlevel.current_task() | ||||||
|  | 
 | ||||||
|  |         # XXX for ctxvar debug only! | ||||||
|  |         # self._codec: MsgCodec = ( | ||||||
|  |         #     codec | ||||||
|  |         #     or | ||||||
|  |         #     _codec._ctxvar_MsgCodec.get() | ||||||
|  |         # ) | ||||||
|  | 
 | ||||||
|  |     async def _iter_packets(self) -> AsyncGenerator[dict, None]: | ||||||
|  |         ''' | ||||||
|  |         Yield `bytes`-blob decoded packets from the underlying TCP | ||||||
|  |         stream using the current task's `MsgCodec`. | ||||||
|  | 
 | ||||||
|  |         This is a streaming routine implemented as an async generator | ||||||
|  |         func (which was the original design, but could be changed?) | ||||||
|  |         and is allocated by a `.__call__()` inside `.__init__()` where | ||||||
|  |         it is assigned to the `._aiter_pkts` attr. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         decodes_failed: int = 0 | ||||||
|  | 
 | ||||||
|  |         tpt_name: str = f'{type(self).__name__!r}' | ||||||
|  |         while True: | ||||||
|  |             try: | ||||||
|  |                 header: bytes = await self.recv_stream.receive_exactly(4) | ||||||
|  |             except ( | ||||||
|  |                 ValueError, | ||||||
|  |                 ConnectionResetError, | ||||||
|  | 
 | ||||||
|  |                 # not sure entirely why we need this but without it we | ||||||
|  |                 # seem to be getting racy failures here on | ||||||
|  |                 # arbiter/registry name subs.. | ||||||
|  |                 trio.BrokenResourceError, | ||||||
|  | 
 | ||||||
|  |             ) as trans_err: | ||||||
|  | 
 | ||||||
|  |                 loglevel = 'transport' | ||||||
|  |                 match trans_err: | ||||||
|  |                     # case ( | ||||||
|  |                     #     ConnectionResetError() | ||||||
|  |                     # ): | ||||||
|  |                     #     loglevel = 'transport' | ||||||
|  | 
 | ||||||
|  |                     # peer actor (graceful??) TCP EOF but `tricycle` | ||||||
|  |                     # seems to raise a 0-bytes-read? | ||||||
|  |                     case ValueError() if ( | ||||||
|  |                         'unclean EOF' in trans_err.args[0] | ||||||
|  |                     ): | ||||||
|  |                         pass | ||||||
|  | 
 | ||||||
|  |                     # peer actor (task) prolly shutdown quickly due | ||||||
|  |                     # to cancellation | ||||||
|  |                     case trio.BrokenResourceError() if ( | ||||||
|  |                         'Connection reset by peer' in trans_err.args[0] | ||||||
|  |                     ): | ||||||
|  |                         pass | ||||||
|  | 
 | ||||||
|  |                     # unless the disconnect condition falls under "a | ||||||
|  |                     # normal operation breakage" we usualy console warn | ||||||
|  |                     # about it. | ||||||
|  |                     case _: | ||||||
|  |                         loglevel: str = 'warning' | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |                 raise TransportClosed( | ||||||
|  |                     message=( | ||||||
|  |                         f'{tpt_name} already closed by peer\n' | ||||||
|  |                     ), | ||||||
|  |                     src_exc=trans_err, | ||||||
|  |                     loglevel=loglevel, | ||||||
|  |                 ) from trans_err | ||||||
|  | 
 | ||||||
|  |             # XXX definitely can happen if transport is closed | ||||||
|  |             # manually by another `trio.lowlevel.Task` in the | ||||||
|  |             # same actor; we use this in some simulated fault | ||||||
|  |             # testing for ex, but generally should never happen | ||||||
|  |             # under normal operation! | ||||||
|  |             # | ||||||
|  |             # NOTE: as such we always re-raise this error from the | ||||||
|  |             #       RPC msg loop! | ||||||
|  |             except trio.ClosedResourceError as cre: | ||||||
|  |                 closure_err = cre | ||||||
|  | 
 | ||||||
|  |                 raise TransportClosed( | ||||||
|  |                     message=( | ||||||
|  |                         f'{tpt_name} was already closed locally ?\n' | ||||||
|  |                     ), | ||||||
|  |                     src_exc=closure_err, | ||||||
|  |                     loglevel='error', | ||||||
|  |                     raise_on_report=( | ||||||
|  |                         'another task closed this fd' in closure_err.args | ||||||
|  |                     ), | ||||||
|  |                 ) from closure_err | ||||||
|  | 
 | ||||||
|  |             # graceful TCP EOF disconnect | ||||||
|  |             if header == b'': | ||||||
|  |                 raise TransportClosed( | ||||||
|  |                     message=( | ||||||
|  |                         f'{tpt_name} already gracefully closed\n' | ||||||
|  |                     ), | ||||||
|  |                     loglevel='transport', | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             size: int | ||||||
|  |             size, = struct.unpack("<I", header) | ||||||
|  | 
 | ||||||
|  |             log.transport(f'received header {size}')  # type: ignore | ||||||
|  |             msg_bytes: bytes = await self.recv_stream.receive_exactly(size) | ||||||
|  | 
 | ||||||
|  |             log.transport(f"received {msg_bytes}")  # type: ignore | ||||||
|  |             try: | ||||||
|  |                 # NOTE: lookup the `trio.Task.context`'s var for | ||||||
|  |                 # the current `MsgCodec`. | ||||||
|  |                 codec: MsgCodec = _ctxvar_MsgCodec.get() | ||||||
|  | 
 | ||||||
|  |                 # XXX for ctxvar debug only! | ||||||
|  |                 # if self._codec.pld_spec != codec.pld_spec: | ||||||
|  |                 #     assert ( | ||||||
|  |                 #         task := trio.lowlevel.current_task() | ||||||
|  |                 #     ) is not self._task | ||||||
|  |                 #     self._task = task | ||||||
|  |                 #     self._codec = codec | ||||||
|  |                 #     log.runtime( | ||||||
|  |                 #         f'Using new codec in {self}.recv()\n' | ||||||
|  |                 #         f'codec: {self._codec}\n\n' | ||||||
|  |                 #         f'msg_bytes: {msg_bytes}\n' | ||||||
|  |                 #     ) | ||||||
|  |                 yield codec.decode(msg_bytes) | ||||||
|  | 
 | ||||||
|  |             # XXX NOTE: since the below error derives from | ||||||
|  |             # `DecodeError` we need to catch is specially | ||||||
|  |             # and always raise such that spec violations | ||||||
|  |             # are never allowed to be caught silently! | ||||||
|  |             except msgspec.ValidationError as verr: | ||||||
|  |                 msgtyperr: MsgTypeError = _mk_recv_mte( | ||||||
|  |                     msg=msg_bytes, | ||||||
|  |                     codec=codec, | ||||||
|  |                     src_validation_error=verr, | ||||||
|  |                 ) | ||||||
|  |                 # XXX deliver up to `Channel.recv()` where | ||||||
|  |                 # a re-raise and `Error`-pack can inject the far | ||||||
|  |                 # end actor `.uid`. | ||||||
|  |                 yield msgtyperr | ||||||
|  | 
 | ||||||
|  |             except ( | ||||||
|  |                 msgspec.DecodeError, | ||||||
|  |                 UnicodeDecodeError, | ||||||
|  |             ): | ||||||
|  |                 if decodes_failed < 4: | ||||||
|  |                     # ignore decoding errors for now and assume they have to | ||||||
|  |                     # do with a channel drop - hope that receiving from the | ||||||
|  |                     # channel will raise an expected error and bubble up. | ||||||
|  |                     try: | ||||||
|  |                         msg_str: str|bytes = msg_bytes.decode() | ||||||
|  |                     except UnicodeDecodeError: | ||||||
|  |                         msg_str = msg_bytes | ||||||
|  | 
 | ||||||
|  |                     log.exception( | ||||||
|  |                         'Failed to decode msg?\n' | ||||||
|  |                         f'{codec}\n\n' | ||||||
|  |                         'Rxed bytes from wire:\n\n' | ||||||
|  |                         f'{msg_str!r}\n' | ||||||
|  |                     ) | ||||||
|  |                     decodes_failed += 1 | ||||||
|  |                 else: | ||||||
|  |                     raise | ||||||
|  | 
 | ||||||
|  |     async def send( | ||||||
|  |         self, | ||||||
|  |         msg: msgtypes.MsgType, | ||||||
|  | 
 | ||||||
|  |         strict_types: bool = True, | ||||||
|  |         hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  |     ) -> None: | ||||||
|  |         ''' | ||||||
|  |         Send a msgpack encoded py-object-blob-as-msg over TCP. | ||||||
|  | 
 | ||||||
|  |         If `strict_types == True` then a `MsgTypeError` will be raised on any | ||||||
|  |         invalid msg type | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|  | 
 | ||||||
|  |         # XXX see `trio._sync.AsyncContextManagerMixin` for details | ||||||
|  |         # on the `.acquire()`/`.release()` sequencing.. | ||||||
|  |         async with self._send_lock: | ||||||
|  | 
 | ||||||
|  |             # NOTE: lookup the `trio.Task.context`'s var for | ||||||
|  |             # the current `MsgCodec`. | ||||||
|  |             codec: MsgCodec = _ctxvar_MsgCodec.get() | ||||||
|  | 
 | ||||||
|  |             # XXX for ctxvar debug only! | ||||||
|  |             # if self._codec.pld_spec != codec.pld_spec: | ||||||
|  |             #     self._codec = codec | ||||||
|  |             #     log.runtime( | ||||||
|  |             #         f'Using new codec in {self}.send()\n' | ||||||
|  |             #         f'codec: {self._codec}\n\n' | ||||||
|  |             #         f'msg: {msg}\n' | ||||||
|  |             #     ) | ||||||
|  | 
 | ||||||
|  |             if type(msg) not in msgtypes.__msg_types__: | ||||||
|  |                 if strict_types: | ||||||
|  |                     raise _mk_send_mte( | ||||||
|  |                         msg, | ||||||
|  |                         codec=codec, | ||||||
|  |                     ) | ||||||
|  |                 else: | ||||||
|  |                     log.warning( | ||||||
|  |                         'Sending non-`Msg`-spec msg?\n\n' | ||||||
|  |                         f'{msg}\n' | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |             try: | ||||||
|  |                 bytes_data: bytes = codec.encode(msg) | ||||||
|  |             except TypeError as _err: | ||||||
|  |                 typerr = _err | ||||||
|  |                 msgtyperr: MsgTypeError = _mk_send_mte( | ||||||
|  |                     msg, | ||||||
|  |                     codec=codec, | ||||||
|  |                     message=( | ||||||
|  |                         f'IPC-msg-spec violation in\n\n' | ||||||
|  |                         f'{pretty_struct.Struct.pformat(msg)}' | ||||||
|  |                     ), | ||||||
|  |                     src_type_error=typerr, | ||||||
|  |                 ) | ||||||
|  |                 raise msgtyperr from typerr | ||||||
|  | 
 | ||||||
|  |             # supposedly the fastest says, | ||||||
|  |             # https://stackoverflow.com/a/54027962 | ||||||
|  |             size: bytes = struct.pack("<I", len(bytes_data)) | ||||||
|  |             try: | ||||||
|  |                 return await self.stream.send_all(size + bytes_data) | ||||||
|  |             except ( | ||||||
|  |                 trio.BrokenResourceError, | ||||||
|  |             ) as bre: | ||||||
|  |                 trans_err = bre | ||||||
|  |                 tpt_name: str = f'{type(self).__name__!r}' | ||||||
|  |                 match trans_err: | ||||||
|  |                     case trio.BrokenResourceError() if ( | ||||||
|  |                         '[Errno 32] Broken pipe' in trans_err.args[0] | ||||||
|  |                         # ^XXX, specifc to UDS transport and its, | ||||||
|  |                         # well, "speediness".. XD | ||||||
|  |                         # |_ likely todo with races related to how fast | ||||||
|  |                         #    the socket is setup/torn-down on linux | ||||||
|  |                         #    as it pertains to rando pings from the | ||||||
|  |                         #    `.discovery` subsys and protos. | ||||||
|  |                     ): | ||||||
|  |                         raise TransportClosed.from_src_exc( | ||||||
|  |                             message=( | ||||||
|  |                                 f'{tpt_name} already closed by peer\n' | ||||||
|  |                             ), | ||||||
|  |                             body=f'{self}\n', | ||||||
|  |                             src_exc=trans_err, | ||||||
|  |                             raise_on_report=True, | ||||||
|  |                             loglevel='transport', | ||||||
|  |                         ) from bre | ||||||
|  | 
 | ||||||
|  |                     # unless the disconnect condition falls under "a | ||||||
|  |                     # normal operation breakage" we usualy console warn | ||||||
|  |                     # about it. | ||||||
|  |                     case _: | ||||||
|  |                         log.exception( | ||||||
|  |                             '{tpt_name} layer failed pre-send ??\n' | ||||||
|  |                         ) | ||||||
|  |                         raise trans_err | ||||||
|  | 
 | ||||||
|  |         # ?TODO? does it help ever to dynamically show this | ||||||
|  |         # frame? | ||||||
|  |         # try: | ||||||
|  |         #     <the-above_code> | ||||||
|  |         # except BaseException as _err: | ||||||
|  |         #     err = _err | ||||||
|  |         #     if not isinstance(err, MsgTypeError): | ||||||
|  |         #         __tracebackhide__: bool = False | ||||||
|  |         #     raise | ||||||
|  | 
 | ||||||
|  |     async def recv(self) -> msgtypes.MsgType: | ||||||
|  |         return await self._aiter_pkts.asend(None) | ||||||
|  | 
 | ||||||
|  |     async def drain(self) -> AsyncIterator[dict]: | ||||||
|  |         ''' | ||||||
|  |         Drain the stream's remaining messages sent from | ||||||
|  |         the far end until the connection is closed by | ||||||
|  |         the peer. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         try: | ||||||
|  |             async for msg in self._iter_packets(): | ||||||
|  |                 self.drained.append(msg) | ||||||
|  |         except TransportClosed: | ||||||
|  |             for msg in self.drained: | ||||||
|  |                 yield msg | ||||||
|  | 
 | ||||||
|  |     def __aiter__(self): | ||||||
|  |         return self._aiter_pkts | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def laddr(self) -> Address: | ||||||
|  |         return self._laddr | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def raddr(self) -> Address: | ||||||
|  |         return self._raddr | ||||||
|  | 
 | ||||||
|  |     def pformat(self) -> str: | ||||||
|  |         return ( | ||||||
|  |             f'<{type(self).__name__}(\n' | ||||||
|  |             f' |_peers: 2\n' | ||||||
|  |             f'   laddr: {self._laddr}\n' | ||||||
|  |             f'   raddr: {self._raddr}\n' | ||||||
|  |             # f'\n' | ||||||
|  |             f' |_task: {self._task}\n' | ||||||
|  |             f')>\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     __repr__ = __str__ = pformat | ||||||
|  | @ -0,0 +1,123 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | IPC subsys type-lookup helpers? | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from typing import ( | ||||||
|  |     Type, | ||||||
|  |     # TYPE_CHECKING, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | import socket | ||||||
|  | 
 | ||||||
|  | from tractor.ipc._transport import ( | ||||||
|  |     MsgTransportKey, | ||||||
|  |     MsgTransport | ||||||
|  | ) | ||||||
|  | from tractor.ipc._tcp import ( | ||||||
|  |     TCPAddress, | ||||||
|  |     MsgpackTCPStream, | ||||||
|  | ) | ||||||
|  | from tractor.ipc._uds import ( | ||||||
|  |     UDSAddress, | ||||||
|  |     MsgpackUDSStream, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | # if TYPE_CHECKING: | ||||||
|  | #     from tractor._addr import Address | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | Address = TCPAddress|UDSAddress | ||||||
|  | 
 | ||||||
|  | # manually updated list of all supported msg transport types | ||||||
|  | _msg_transports = [ | ||||||
|  |     MsgpackTCPStream, | ||||||
|  |     MsgpackUDSStream | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # convert a MsgTransportKey to the corresponding transport type | ||||||
|  | _key_to_transport: dict[ | ||||||
|  |     MsgTransportKey, | ||||||
|  |     Type[MsgTransport], | ||||||
|  | ] = { | ||||||
|  |     ('msgpack', 'tcp'): MsgpackTCPStream, | ||||||
|  |     ('msgpack', 'uds'): MsgpackUDSStream, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | # convert an Address wrapper to its corresponding transport type | ||||||
|  | _addr_to_transport: dict[ | ||||||
|  |     Type[TCPAddress|UDSAddress], | ||||||
|  |     Type[MsgTransport] | ||||||
|  | ] = { | ||||||
|  |     TCPAddress: MsgpackTCPStream, | ||||||
|  |     UDSAddress: MsgpackUDSStream, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def transport_from_addr( | ||||||
|  |     addr: Address, | ||||||
|  |     codec_key: str = 'msgpack', | ||||||
|  | ) -> Type[MsgTransport]: | ||||||
|  |     ''' | ||||||
|  |     Given a destination address and a desired codec, find the | ||||||
|  |     corresponding `MsgTransport` type. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     try: | ||||||
|  |         return _addr_to_transport[type(addr)] | ||||||
|  | 
 | ||||||
|  |     except KeyError: | ||||||
|  |         raise NotImplementedError( | ||||||
|  |             f'No known transport for address {repr(addr)}' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def transport_from_stream( | ||||||
|  |     stream: trio.abc.Stream, | ||||||
|  |     codec_key: str = 'msgpack' | ||||||
|  | ) -> Type[MsgTransport]: | ||||||
|  |     ''' | ||||||
|  |     Given an arbitrary `trio.abc.Stream` and a desired codec, | ||||||
|  |     find the corresponding `MsgTransport` type. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     transport = None | ||||||
|  |     if isinstance(stream, trio.SocketStream): | ||||||
|  |         sock: socket.socket = stream.socket | ||||||
|  |         match sock.family: | ||||||
|  |             case socket.AF_INET | socket.AF_INET6: | ||||||
|  |                 transport = 'tcp' | ||||||
|  | 
 | ||||||
|  |             case socket.AF_UNIX: | ||||||
|  |                 transport = 'uds' | ||||||
|  | 
 | ||||||
|  |             case _: | ||||||
|  |                 raise NotImplementedError( | ||||||
|  |                     f'Unsupported socket family: {sock.family}' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |     if not transport: | ||||||
|  |         raise NotImplementedError( | ||||||
|  |             f'Could not figure out transport type for stream type {type(stream)}' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     key = (codec_key, transport) | ||||||
|  | 
 | ||||||
|  |     return _key_to_transport[key] | ||||||
|  | @ -0,0 +1,422 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | ''' | ||||||
|  | Unix Domain Socket implementation of tractor.ipc._transport.MsgTransport protocol  | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from pathlib import Path | ||||||
|  | import os | ||||||
|  | from socket import ( | ||||||
|  |     AF_UNIX, | ||||||
|  |     SOCK_STREAM, | ||||||
|  |     SO_PASSCRED, | ||||||
|  |     SO_PEERCRED, | ||||||
|  |     SOL_SOCKET, | ||||||
|  | ) | ||||||
|  | import struct | ||||||
|  | from typing import ( | ||||||
|  |     TYPE_CHECKING, | ||||||
|  |     ClassVar, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | import msgspec | ||||||
|  | import trio | ||||||
|  | from trio import ( | ||||||
|  |     socket, | ||||||
|  |     SocketListener, | ||||||
|  | ) | ||||||
|  | from trio._highlevel_open_unix_stream import ( | ||||||
|  |     close_on_error, | ||||||
|  |     has_unix, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from tractor.msg import MsgCodec | ||||||
|  | from tractor.log import get_logger | ||||||
|  | from tractor.ipc._transport import ( | ||||||
|  |     MsgpackTransport, | ||||||
|  | ) | ||||||
|  | from .._state import ( | ||||||
|  |     get_rt_dir, | ||||||
|  |     current_actor, | ||||||
|  |     is_root_process, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from ._runtime import Actor | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def unwrap_sockpath( | ||||||
|  |     sockpath: Path, | ||||||
|  | ) -> tuple[Path, Path]: | ||||||
|  |     return ( | ||||||
|  |         sockpath.parent, | ||||||
|  |         sockpath.name, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class UDSAddress( | ||||||
|  |     msgspec.Struct, | ||||||
|  |     frozen=True, | ||||||
|  | ): | ||||||
|  |     filedir: str|Path|None | ||||||
|  |     filename: str|Path | ||||||
|  |     maybe_pid: int|None = None | ||||||
|  | 
 | ||||||
|  |     # TODO, maybe we should use better field and value | ||||||
|  |     # -[x] really this is a `.protocol_key` not a "name" of anything. | ||||||
|  |     # -[ ] consider a 'unix' proto-key instead? | ||||||
|  |     # -[ ] need to check what other mult-transport frameworks do | ||||||
|  |     #     like zmq, nng, uri-spec et al! | ||||||
|  |     proto_key: ClassVar[str] = 'uds' | ||||||
|  |     unwrapped_type: ClassVar[type] = tuple[str, int] | ||||||
|  |     def_bindspace: ClassVar[Path] = get_rt_dir() | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def bindspace(self) -> Path: | ||||||
|  |         ''' | ||||||
|  |         We replicate the "ip-set-of-hosts" part of a UDS socket as | ||||||
|  |         just the sub-directory in which we allocate socket files. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         return ( | ||||||
|  |             self.filedir | ||||||
|  |             or | ||||||
|  |             self.def_bindspace | ||||||
|  |             # or | ||||||
|  |             # get_rt_dir() | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def sockpath(self) -> Path: | ||||||
|  |         return self.bindspace / self.filename | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def is_valid(self) -> bool: | ||||||
|  |         ''' | ||||||
|  |         We block socket files not allocated under the runtime subdir. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         return self.bindspace in self.sockpath.parents | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def from_addr( | ||||||
|  |         cls, | ||||||
|  |         addr: ( | ||||||
|  |             tuple[Path|str, Path|str]|Path|str | ||||||
|  |         ), | ||||||
|  |     ) -> UDSAddress: | ||||||
|  |         match addr: | ||||||
|  |             case tuple()|list(): | ||||||
|  |                 filedir = Path(addr[0]) | ||||||
|  |                 filename = Path(addr[1]) | ||||||
|  |                 return UDSAddress( | ||||||
|  |                     filedir=filedir, | ||||||
|  |                     filename=filename, | ||||||
|  |                     # maybe_pid=pid, | ||||||
|  |                 ) | ||||||
|  |             # NOTE, in case we ever decide to just `.unwrap()` | ||||||
|  |             # to a `Path|str`? | ||||||
|  |             case str()|Path(): | ||||||
|  |                 sockpath: Path = Path(addr) | ||||||
|  |                 return UDSAddress(*unwrap_sockpath(sockpath)) | ||||||
|  |             case _: | ||||||
|  |                 # import pdbp; pdbp.set_trace() | ||||||
|  |                 raise TypeError( | ||||||
|  |                     f'Bad unwrapped-address for {cls} !\n' | ||||||
|  |                     f'{addr!r}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |     def unwrap(self) -> tuple[str, int]: | ||||||
|  |         # XXX NOTE, since this gets passed DIRECTLY to | ||||||
|  |         # `.ipc._uds.open_unix_socket_w_passcred()` | ||||||
|  |         return ( | ||||||
|  |             str(self.filedir), | ||||||
|  |             str(self.filename), | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def get_random( | ||||||
|  |         cls, | ||||||
|  |         bindspace: Path|None = None,  # default netns | ||||||
|  |     ) -> UDSAddress: | ||||||
|  | 
 | ||||||
|  |         filedir: Path = bindspace or cls.def_bindspace | ||||||
|  |         pid: int = os.getpid() | ||||||
|  |         actor: Actor|None = current_actor( | ||||||
|  |             err_on_no_runtime=False, | ||||||
|  |         ) | ||||||
|  |         if actor: | ||||||
|  |             sockname: str = '::'.join(actor.uid) + f'@{pid}' | ||||||
|  |         else: | ||||||
|  |             prefix: str = '<unknown-actor>' | ||||||
|  |             if is_root_process(): | ||||||
|  |                 prefix: str = 'root' | ||||||
|  |             sockname: str = f'{prefix}@{pid}' | ||||||
|  | 
 | ||||||
|  |         sockpath: Path = Path(f'{sockname}.sock') | ||||||
|  |         return UDSAddress( | ||||||
|  |             filedir=filedir, | ||||||
|  |             filename=sockpath, | ||||||
|  |             maybe_pid=pid, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def get_root(cls) -> UDSAddress: | ||||||
|  |         def_uds_filename: Path = 'registry@1616.sock' | ||||||
|  |         return UDSAddress( | ||||||
|  |             filedir=cls.def_bindspace, | ||||||
|  |             filename=def_uds_filename, | ||||||
|  |             # maybe_pid=1616, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # ?TODO, maybe we should just our .msg.pretty_struct.Struct` for | ||||||
|  |     # this instead? | ||||||
|  |     # -[ ] is it too "multi-line"y tho? | ||||||
|  |     #      the compact tuple/.unwrapped() form is simple enough? | ||||||
|  |     # | ||||||
|  |     def __repr__(self) -> str: | ||||||
|  |         if not (pid := self.maybe_pid): | ||||||
|  |             pid: str = '<unknown-peer-pid>' | ||||||
|  | 
 | ||||||
|  |         body: str = ( | ||||||
|  |             f'({self.filedir}, {self.filename}, {pid})' | ||||||
|  |         ) | ||||||
|  |         return ( | ||||||
|  |             f'{type(self).__name__}' | ||||||
|  |             f'[' | ||||||
|  |             f'{body}' | ||||||
|  |             f']' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def start_listener( | ||||||
|  |     addr: UDSAddress, | ||||||
|  |     **kwargs, | ||||||
|  | ) -> SocketListener: | ||||||
|  |     # sock = addr._sock = socket.socket( | ||||||
|  |     sock = socket.socket( | ||||||
|  |         socket.AF_UNIX, | ||||||
|  |         socket.SOCK_STREAM | ||||||
|  |     ) | ||||||
|  |     log.info( | ||||||
|  |         f'Attempting to bind UDS socket\n' | ||||||
|  |         f'>[\n' | ||||||
|  |         f'|_{addr}\n' | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     bindpath: Path = addr.sockpath | ||||||
|  |     try: | ||||||
|  |         await sock.bind(str(bindpath)) | ||||||
|  |     except ( | ||||||
|  |         FileNotFoundError, | ||||||
|  |     ) as fdne: | ||||||
|  |         raise ConnectionError( | ||||||
|  |             f'Bad UDS socket-filepath-as-address ??\n' | ||||||
|  |             f'{addr}\n' | ||||||
|  |             f' |_sockpath: {addr.sockpath}\n' | ||||||
|  |         ) from fdne | ||||||
|  | 
 | ||||||
|  |     sock.listen(1) | ||||||
|  |     log.info( | ||||||
|  |         f'Listening on UDS socket\n' | ||||||
|  |         f'[>\n' | ||||||
|  |         f' |_{addr}\n' | ||||||
|  |     ) | ||||||
|  |     return SocketListener(sock) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def close_listener( | ||||||
|  |     addr: UDSAddress, | ||||||
|  |     lstnr: SocketListener, | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Close and remove the listening unix socket's path. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     lstnr.socket.close() | ||||||
|  |     os.unlink(addr.sockpath) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def open_unix_socket_w_passcred( | ||||||
|  |     filename: str|bytes|os.PathLike[str]|os.PathLike[bytes], | ||||||
|  | ) -> trio.SocketStream: | ||||||
|  |     ''' | ||||||
|  |     Literally the exact same as `trio.open_unix_socket()` except we set the additiona | ||||||
|  |     `socket.SO_PASSCRED` option to ensure the server side (the process calling `accept()`) | ||||||
|  |     can extract the connecting peer's credentials, namely OS specific process | ||||||
|  |     related IDs. | ||||||
|  | 
 | ||||||
|  |     See this SO for "why" the extra opts, | ||||||
|  |     - https://stackoverflow.com/a/7982749 | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     if not has_unix: | ||||||
|  |         raise RuntimeError("Unix sockets are not supported on this platform") | ||||||
|  | 
 | ||||||
|  |     # much more simplified logic vs tcp sockets - one socket type and only one | ||||||
|  |     # possible location to connect to | ||||||
|  |     sock = trio.socket.socket(AF_UNIX, SOCK_STREAM) | ||||||
|  |     sock.setsockopt(SOL_SOCKET, SO_PASSCRED, 1) | ||||||
|  |     with close_on_error(sock): | ||||||
|  |         await sock.connect(os.fspath(filename)) | ||||||
|  | 
 | ||||||
|  |     return trio.SocketStream(sock) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_peer_info(sock: trio.socket.socket) -> tuple[ | ||||||
|  |     int,  # pid | ||||||
|  |     int,  # uid | ||||||
|  |     int,  # guid | ||||||
|  | ]: | ||||||
|  |     ''' | ||||||
|  |     Deliver the connecting peer's "credentials"-info as defined in | ||||||
|  |     a very Linux specific way.. | ||||||
|  | 
 | ||||||
|  |     For more deats see, | ||||||
|  |     - `man accept`, | ||||||
|  |     - `man unix`, | ||||||
|  | 
 | ||||||
|  |     this great online guide to all things sockets, | ||||||
|  |     - https://beej.us/guide/bgnet/html/split-wide/man-pages.html#setsockoptman | ||||||
|  | 
 | ||||||
|  |     AND this **wonderful SO answer** | ||||||
|  |     - https://stackoverflow.com/a/7982749 | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     creds: bytes = sock.getsockopt( | ||||||
|  |         SOL_SOCKET, | ||||||
|  |         SO_PEERCRED, | ||||||
|  |         struct.calcsize('3i') | ||||||
|  |     ) | ||||||
|  |     # i.e a tuple of the fields, | ||||||
|  |     # pid: int, "process" | ||||||
|  |     # uid: int, "user" | ||||||
|  |     # gid: int, "group" | ||||||
|  |     return struct.unpack('3i', creds) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class MsgpackUDSStream(MsgpackTransport): | ||||||
|  |     ''' | ||||||
|  |     A `trio.SocketStream` around a Unix-Domain-Socket transport | ||||||
|  |     delivering `msgpack` encoded msgs using the `msgspec` codec lib. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     address_type = UDSAddress | ||||||
|  |     layer_key: int = 4 | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def maddr(self) -> str: | ||||||
|  |         if not self.raddr: | ||||||
|  |             return '<unknown-peer>' | ||||||
|  | 
 | ||||||
|  |         filepath: Path = Path(self.raddr.unwrap()[0]) | ||||||
|  |         return ( | ||||||
|  |             f'/{self.address_type.proto_key}/{filepath}' | ||||||
|  |             # f'/{self.chan.uid[0]}' | ||||||
|  |             # f'/{self.cid}' | ||||||
|  | 
 | ||||||
|  |             # f'/cid={cid_head}..{cid_tail}' | ||||||
|  |             # TODO: ? not use this ^ right ? | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     def connected(self) -> bool: | ||||||
|  |         return self.stream.socket.fileno() != -1 | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     async def connect_to( | ||||||
|  |         cls, | ||||||
|  |         addr: UDSAddress, | ||||||
|  |         prefix_size: int = 4, | ||||||
|  |         codec: MsgCodec|None = None, | ||||||
|  |         **kwargs | ||||||
|  |     ) -> MsgpackUDSStream: | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |         sockpath: Path = addr.sockpath | ||||||
|  |         # | ||||||
|  |         # ^XXX NOTE, we don't provide any out-of-band `.pid` info | ||||||
|  |         # (like, over the socket as extra msgs) since the (augmented) | ||||||
|  |         # `.setsockopt()` call tells the OS provide it; the client | ||||||
|  |         # pid can then be read on server/listen() side via | ||||||
|  |         # `get_peer_info()` above. | ||||||
|  |         try: | ||||||
|  |             stream = await open_unix_socket_w_passcred( | ||||||
|  |                 str(sockpath), | ||||||
|  |                 **kwargs | ||||||
|  |             ) | ||||||
|  |         except ( | ||||||
|  |             FileNotFoundError, | ||||||
|  |         ) as fdne: | ||||||
|  |             raise ConnectionError( | ||||||
|  |                 f'Bad UDS socket-filepath-as-address ??\n' | ||||||
|  |                 f'{addr}\n' | ||||||
|  |                 f' |_sockpath: {sockpath}\n' | ||||||
|  |             ) from fdne | ||||||
|  | 
 | ||||||
|  |         stream = MsgpackUDSStream( | ||||||
|  |             stream, | ||||||
|  |             prefix_size=prefix_size, | ||||||
|  |             codec=codec | ||||||
|  |         ) | ||||||
|  |         stream._raddr = addr | ||||||
|  |         return stream | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def get_stream_addrs( | ||||||
|  |         cls, | ||||||
|  |         stream: trio.SocketStream | ||||||
|  |     ) -> tuple[ | ||||||
|  |         Path, | ||||||
|  |         int, | ||||||
|  |     ]: | ||||||
|  |         sock: trio.socket.socket = stream.socket | ||||||
|  | 
 | ||||||
|  |         # NOTE XXX, it's unclear why one or the other ends up being | ||||||
|  |         # `bytes` versus the socket-file-path, i presume it's | ||||||
|  |         # something to do with who is the server (called `.listen()`)? | ||||||
|  |         # maybe could be better implemented using another info-query | ||||||
|  |         # on the socket like, | ||||||
|  |         # https://beej.us/guide/bgnet/html/split-wide/system-calls-or-bust.html#gethostnamewho-am-i | ||||||
|  |         sockname: str|bytes = sock.getsockname() | ||||||
|  |         # https://beej.us/guide/bgnet/html/split-wide/system-calls-or-bust.html#getpeernamewho-are-you | ||||||
|  |         peername: str|bytes = sock.getpeername() | ||||||
|  |         match (peername, sockname): | ||||||
|  |             case (str(), bytes()): | ||||||
|  |                 sock_path: Path = Path(peername) | ||||||
|  |             case (bytes(), str()): | ||||||
|  |                 sock_path: Path = Path(sockname) | ||||||
|  |         ( | ||||||
|  |             peer_pid, | ||||||
|  |             _, | ||||||
|  |             _, | ||||||
|  |         ) = get_peer_info(sock) | ||||||
|  | 
 | ||||||
|  |         filedir, filename = unwrap_sockpath(sock_path) | ||||||
|  |         laddr = UDSAddress( | ||||||
|  |             filedir=filedir, | ||||||
|  |             filename=filename, | ||||||
|  |             maybe_pid=os.getpid(), | ||||||
|  |         ) | ||||||
|  |         raddr = UDSAddress( | ||||||
|  |             filedir=filedir, | ||||||
|  |             filename=filename, | ||||||
|  |             maybe_pid=peer_pid | ||||||
|  |         ) | ||||||
|  |         return (laddr, raddr) | ||||||
|  | @ -92,7 +92,7 @@ class StackLevelAdapter(LoggerAdapter): | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         ''' |         ''' | ||||||
|         IPC transport level msg IO; generally anything below |         IPC transport level msg IO; generally anything below | ||||||
|         `._ipc.Channel` and friends. |         `.ipc.Channel` and friends. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         return self.log(5, msg) |         return self.log(5, msg) | ||||||
|  | @ -270,7 +270,9 @@ def get_logger( | ||||||
|     subsys_spec: str|None = None, |     subsys_spec: str|None = None, | ||||||
| 
 | 
 | ||||||
| ) -> StackLevelAdapter: | ) -> StackLevelAdapter: | ||||||
|     '''Return the package log or a sub-logger for ``name`` if provided. |     ''' | ||||||
|  |     Return the `tractor`-library root logger or a sub-logger for | ||||||
|  |     `name` if provided. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     log: Logger |     log: Logger | ||||||
|  | @ -282,10 +284,10 @@ def get_logger( | ||||||
|         name != _proj_name |         name != _proj_name | ||||||
|     ): |     ): | ||||||
| 
 | 
 | ||||||
|         # NOTE: for handling for modules that use ``get_logger(__name__)`` |         # NOTE: for handling for modules that use `get_logger(__name__)` | ||||||
|         # we make the following stylistic choice: |         # we make the following stylistic choice: | ||||||
|         # - always avoid duplicate project-package token |         # - always avoid duplicate project-package token | ||||||
|         #   in msg output: i.e. tractor.tractor _ipc.py in header |         #   in msg output: i.e. tractor.tractor.ipc._chan.py in header | ||||||
|         #   looks ridiculous XD |         #   looks ridiculous XD | ||||||
|         # - never show the leaf module name in the {name} part |         # - never show the leaf module name in the {name} part | ||||||
|         #   since in python the {filename} is always this same |         #   since in python the {filename} is always this same | ||||||
|  | @ -331,7 +333,7 @@ def get_logger( | ||||||
| 
 | 
 | ||||||
| def get_console_log( | def get_console_log( | ||||||
|     level: str|None = None, |     level: str|None = None, | ||||||
|     logger: Logger|None = None, |     logger: Logger|StackLevelAdapter|None = None, | ||||||
|     **kwargs, |     **kwargs, | ||||||
| 
 | 
 | ||||||
| ) -> LoggerAdapter: | ) -> LoggerAdapter: | ||||||
|  | @ -344,12 +346,23 @@ def get_console_log( | ||||||
|     Yeah yeah, i know we can use `logging.config.dictConfig()`. You do it. |     Yeah yeah, i know we can use `logging.config.dictConfig()`. You do it. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     log = get_logger( |     # get/create a stack-aware-adapter | ||||||
|         logger=logger, |     if ( | ||||||
|         **kwargs |         logger | ||||||
|     )  # set a root logger |         and | ||||||
|     logger: Logger = log.logger |         isinstance(logger, StackLevelAdapter) | ||||||
|  |     ): | ||||||
|  |         # XXX, for ex. when passed in by a caller wrapping some | ||||||
|  |         # other lib's logger instance with our level-adapter. | ||||||
|  |         log = logger | ||||||
| 
 | 
 | ||||||
|  |     else: | ||||||
|  |         log: StackLevelAdapter = get_logger( | ||||||
|  |             logger=logger, | ||||||
|  |             **kwargs | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     logger: Logger|StackLevelAdapter = log.logger | ||||||
|     if not level: |     if not level: | ||||||
|         return log |         return log | ||||||
| 
 | 
 | ||||||
|  | @ -367,10 +380,7 @@ def get_console_log( | ||||||
|             None, |             None, | ||||||
|         ) |         ) | ||||||
|     ): |     ): | ||||||
|         fmt = LOG_FORMAT |         fmt: str = LOG_FORMAT  # always apply our format? | ||||||
|         # if logger: |  | ||||||
|         #     fmt = None |  | ||||||
| 
 |  | ||||||
|         handler = StreamHandler() |         handler = StreamHandler() | ||||||
|         formatter = colorlog.ColoredFormatter( |         formatter = colorlog.ColoredFormatter( | ||||||
|             fmt=fmt, |             fmt=fmt, | ||||||
|  |  | ||||||
|  | @ -31,6 +31,7 @@ from typing import ( | ||||||
|     Type, |     Type, | ||||||
|     TypeVar, |     TypeVar, | ||||||
|     TypeAlias, |     TypeAlias, | ||||||
|  |     # TYPE_CHECKING, | ||||||
|     Union, |     Union, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -47,6 +48,7 @@ from tractor.msg import ( | ||||||
|     pretty_struct, |     pretty_struct, | ||||||
| ) | ) | ||||||
| from tractor.log import get_logger | from tractor.log import get_logger | ||||||
|  | # from tractor._addr import UnwrappedAddress | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| log = get_logger('tractor.msgspec') | log = get_logger('tractor.msgspec') | ||||||
|  | @ -141,9 +143,16 @@ class Aid( | ||||||
|     ''' |     ''' | ||||||
|     name: str |     name: str | ||||||
|     uuid: str |     uuid: str | ||||||
|     # TODO: use built-in support for UUIDs? |     pid: int|None = None | ||||||
|     # -[ ] `uuid.UUID` which has multi-protocol support | 
 | ||||||
|     #  https://jcristharif.com/msgspec/supported-types.html#uuid |     # TODO? can/should we extend this field set? | ||||||
|  |     # -[ ] use built-in support for UUIDs? `uuid.UUID` which has | ||||||
|  |     #     multi-protocol support | ||||||
|  |     #     https://jcristharif.com/msgspec/supported-types.html#uuid | ||||||
|  |     # | ||||||
|  |     # -[ ] as per the `.ipc._uds` / `._addr` comments, maybe we | ||||||
|  |     #     should also include at least `.pid` (equiv to port for tcp) | ||||||
|  |     #     and/or host-part always? | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class SpawnSpec( | class SpawnSpec( | ||||||
|  | @ -167,8 +176,8 @@ class SpawnSpec( | ||||||
| 
 | 
 | ||||||
|     # TODO: not just sockaddr pairs? |     # TODO: not just sockaddr pairs? | ||||||
|     # -[ ] abstract into a `TransportAddr` type? |     # -[ ] abstract into a `TransportAddr` type? | ||||||
|     reg_addrs: list[tuple[str, int]] |     reg_addrs: list[tuple[str, str|int]] | ||||||
|     bind_addrs: list[tuple[str, int]] |     bind_addrs: list[tuple[str, str|int]]|None | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # TODO: caps based RPC support in the payload? | # TODO: caps based RPC support in the payload? | ||||||
|  |  | ||||||
|  | @ -21,7 +21,6 @@ Sugary patterns for trio + tractor designs. | ||||||
| from ._mngrs import ( | from ._mngrs import ( | ||||||
|     gather_contexts as gather_contexts, |     gather_contexts as gather_contexts, | ||||||
|     maybe_open_context as maybe_open_context, |     maybe_open_context as maybe_open_context, | ||||||
|     maybe_open_nursery as maybe_open_nursery, |  | ||||||
| ) | ) | ||||||
| from ._broadcast import ( | from ._broadcast import ( | ||||||
|     AsyncReceiver as AsyncReceiver, |     AsyncReceiver as AsyncReceiver, | ||||||
|  | @ -31,4 +30,8 @@ from ._broadcast import ( | ||||||
| ) | ) | ||||||
| from ._beg import ( | from ._beg import ( | ||||||
|     collapse_eg as collapse_eg, |     collapse_eg as collapse_eg, | ||||||
|  |     maybe_collapse_eg as maybe_collapse_eg, | ||||||
|  | ) | ||||||
|  | from ._tn import ( | ||||||
|  |     maybe_open_nursery as maybe_open_nursery, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | @ -22,7 +22,7 @@ https://docs.rs/tokio/1.11.0/tokio/sync/broadcast/index.html | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
| from abc import abstractmethod | from abc import abstractmethod | ||||||
| from collections import deque | from collections import deque | ||||||
| from contextlib import asynccontextmanager | from contextlib import asynccontextmanager as acm | ||||||
| from functools import partial | from functools import partial | ||||||
| from operator import ne | from operator import ne | ||||||
| from typing import ( | from typing import ( | ||||||
|  | @ -398,7 +398,7 @@ class BroadcastReceiver(ReceiveChannel): | ||||||
| 
 | 
 | ||||||
|             return await self._receive_from_underlying(key, state) |             return await self._receive_from_underlying(key, state) | ||||||
| 
 | 
 | ||||||
|     @asynccontextmanager |     @acm | ||||||
|     async def subscribe( |     async def subscribe( | ||||||
|         self, |         self, | ||||||
|         raise_on_lag: bool = True, |         raise_on_lag: bool = True, | ||||||
|  |  | ||||||
|  | @ -23,7 +23,6 @@ from contextlib import ( | ||||||
|     asynccontextmanager as acm, |     asynccontextmanager as acm, | ||||||
| ) | ) | ||||||
| import inspect | import inspect | ||||||
| from types import ModuleType |  | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, |     Any, | ||||||
|     AsyncContextManager, |     AsyncContextManager, | ||||||
|  | @ -34,16 +33,12 @@ from typing import ( | ||||||
|     Optional, |     Optional, | ||||||
|     Sequence, |     Sequence, | ||||||
|     TypeVar, |     TypeVar, | ||||||
|     TYPE_CHECKING, |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| import trio | import trio | ||||||
| from tractor._state import current_actor | from tractor._state import current_actor | ||||||
| from tractor.log import get_logger | from tractor.log import get_logger | ||||||
| 
 | 
 | ||||||
| if TYPE_CHECKING: |  | ||||||
|     from tractor import ActorNursery |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
| 
 | 
 | ||||||
|  | @ -51,29 +46,6 @@ log = get_logger(__name__) | ||||||
| T = TypeVar("T") | T = TypeVar("T") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @acm |  | ||||||
| async def maybe_open_nursery( |  | ||||||
|     nursery: trio.Nursery|ActorNursery|None = None, |  | ||||||
|     shield: bool = False, |  | ||||||
|     lib: ModuleType = trio, |  | ||||||
| 
 |  | ||||||
|     **kwargs,  # proxy thru |  | ||||||
| 
 |  | ||||||
| ) -> AsyncGenerator[trio.Nursery, Any]: |  | ||||||
|     ''' |  | ||||||
|     Create a new nursery if None provided. |  | ||||||
| 
 |  | ||||||
|     Blocks on exit as expected if no input nursery is provided. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     if nursery is not None: |  | ||||||
|         yield nursery |  | ||||||
|     else: |  | ||||||
|         async with lib.open_nursery(**kwargs) as nursery: |  | ||||||
|             nursery.cancel_scope.shield = shield |  | ||||||
|             yield nursery |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| async def _enter_and_wait( | async def _enter_and_wait( | ||||||
|     mngr: AsyncContextManager[T], |     mngr: AsyncContextManager[T], | ||||||
|     unwrapped: dict[int, T], |     unwrapped: dict[int, T], | ||||||
|  |  | ||||||
|  | @ -0,0 +1,341 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Erlang-style (ish) "one-cancels-one" nursery, what we just call | ||||||
|  | a "task manager". | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  |     # contextmanager as cm, | ||||||
|  | ) | ||||||
|  | from functools import partial | ||||||
|  | from typing import ( | ||||||
|  |     Generator, | ||||||
|  |     Any, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from outcome import ( | ||||||
|  |     Outcome, | ||||||
|  |     acapture, | ||||||
|  | ) | ||||||
|  | from msgspec import Struct | ||||||
|  | import trio | ||||||
|  | from trio import ( | ||||||
|  |     TaskStatus, | ||||||
|  |     CancelScope, | ||||||
|  |     Nursery, | ||||||
|  | ) | ||||||
|  | from trio.lowlevel import ( | ||||||
|  |     Task, | ||||||
|  | ) | ||||||
|  | from tractor.log import get_logger | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TaskOutcome(Struct): | ||||||
|  |     ''' | ||||||
|  |     The outcome of a scheduled ``trio`` task which includes an interface | ||||||
|  |     for synchronizing to the completion of the task's runtime and access | ||||||
|  |     to the eventual boxed result/value or raised exception. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     lowlevel_task: Task | ||||||
|  |     _exited = trio.Event()  # as per `trio.Runner.task_exited()` | ||||||
|  |     _outcome: Outcome | None = None  # as per `outcome.Outcome` | ||||||
|  |     _result: Any | None = None  # the eventual maybe-returned-value | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def result(self) -> Any: | ||||||
|  |         ''' | ||||||
|  |         Either Any or None depending on whether the Outcome has compeleted. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         if self._outcome is None: | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 f'Task {self.lowlevel_task.name} is not complete.\n' | ||||||
|  |                 'First wait on `await TaskOutcome.wait_for_result()`!' | ||||||
|  |             ) | ||||||
|  |         return self._result | ||||||
|  | 
 | ||||||
|  |     def _set_outcome( | ||||||
|  |         self, | ||||||
|  |         outcome: Outcome, | ||||||
|  |     ): | ||||||
|  |         ''' | ||||||
|  |         Set the ``Outcome`` for this task. | ||||||
|  | 
 | ||||||
|  |         This method should only ever be called by the task's supervising | ||||||
|  |         nursery implemenation. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         self._outcome = outcome | ||||||
|  |         self._result = outcome.unwrap() | ||||||
|  |         self._exited.set() | ||||||
|  | 
 | ||||||
|  |     async def wait_for_result(self) -> Any: | ||||||
|  |         ''' | ||||||
|  |         Unwind the underlying task's ``Outcome`` by async waiting for | ||||||
|  |         the task to first complete and then unwrap it's result-value. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         if self._exited.is_set(): | ||||||
|  |             return self._result | ||||||
|  | 
 | ||||||
|  |         await self._exited.wait() | ||||||
|  | 
 | ||||||
|  |         out = self._outcome | ||||||
|  |         if out is None: | ||||||
|  |             raise ValueError(f'{out} is not an outcome!?') | ||||||
|  | 
 | ||||||
|  |         return self.result | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TaskManagerNursery(Struct): | ||||||
|  |     _tn: Nursery | ||||||
|  |     _scopes: dict[ | ||||||
|  |         Task, | ||||||
|  |         tuple[CancelScope, Outcome] | ||||||
|  |     ] = {} | ||||||
|  | 
 | ||||||
|  |     task_manager: Generator[Any, Outcome, None] | None = None | ||||||
|  | 
 | ||||||
|  |     async def start_soon( | ||||||
|  |         self, | ||||||
|  |         async_fn, | ||||||
|  |         *args, | ||||||
|  | 
 | ||||||
|  |         name=None, | ||||||
|  |         task_manager: Generator[Any, Outcome, None] | None = None | ||||||
|  | 
 | ||||||
|  |     ) -> tuple[CancelScope, Task]: | ||||||
|  | 
 | ||||||
|  |         # NOTE: internals of a nursery don't let you know what | ||||||
|  |         # the most recently spawned task is by order.. so we'd | ||||||
|  |         # have to either change that or do set ops. | ||||||
|  |         # pre_start_tasks: set[Task] = n._children.copy() | ||||||
|  |         # new_tasks = n._children - pre_start_Tasks | ||||||
|  |         # assert len(new_tasks) == 1 | ||||||
|  |         # task = new_tasks.pop() | ||||||
|  | 
 | ||||||
|  |         tn: Nursery = self._tn | ||||||
|  | 
 | ||||||
|  |         sm = self.task_manager | ||||||
|  |         # we do default behavior of a scope-per-nursery | ||||||
|  |         # if the user did not provide a task manager. | ||||||
|  |         if sm is None: | ||||||
|  |             return tn.start_soon(async_fn, *args, name=None) | ||||||
|  | 
 | ||||||
|  |         # new_task: Task|None = None | ||||||
|  |         to_return: tuple[Any] | None = None | ||||||
|  | 
 | ||||||
|  |         # NOTE: what do we enforce as a signature for the | ||||||
|  |         # `@task_scope_manager` here? | ||||||
|  |         mngr = sm(nursery=tn) | ||||||
|  | 
 | ||||||
|  |         async def _start_wrapped_in_scope( | ||||||
|  |             task_status: TaskStatus[ | ||||||
|  |                 tuple[CancelScope, Task] | ||||||
|  |             ] = trio.TASK_STATUS_IGNORED, | ||||||
|  | 
 | ||||||
|  |         ) -> None: | ||||||
|  | 
 | ||||||
|  |             # TODO: this was working before?! and, do we need something | ||||||
|  |             # like it to implement `.start()`? | ||||||
|  |             # nonlocal to_return | ||||||
|  | 
 | ||||||
|  |             # execute up to the first yield | ||||||
|  |             try: | ||||||
|  |                 to_return: tuple[Any] = next(mngr) | ||||||
|  |             except StopIteration: | ||||||
|  |                 raise RuntimeError("task manager didn't yield") from None | ||||||
|  | 
 | ||||||
|  |             # TODO: how do we support `.start()` style? | ||||||
|  |             # - relay through whatever the | ||||||
|  |             #   started task passes back via `.started()` ? | ||||||
|  |             #   seems like that won't work with also returning | ||||||
|  |             #   a "task handle"? | ||||||
|  |             # - we were previously binding-out this `to_return` to | ||||||
|  |             #   the parent's lexical scope, why isn't that working | ||||||
|  |             #   now? | ||||||
|  |             task_status.started(to_return) | ||||||
|  | 
 | ||||||
|  |             # invoke underlying func now that cs is entered. | ||||||
|  |             outcome = await acapture(async_fn, *args) | ||||||
|  | 
 | ||||||
|  |             # execute from the 1st yield to return and expect | ||||||
|  |             # generator-mngr `@task_scope_manager` thinger to | ||||||
|  |             # terminate! | ||||||
|  |             try: | ||||||
|  |                 mngr.send(outcome) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |                 # I would presume it's better to have a handle to | ||||||
|  |                 # the `Outcome` entirely? This method sends *into* | ||||||
|  |                 # the mngr this `Outcome.value`; seems like kinda | ||||||
|  |                 # weird semantics for our purposes? | ||||||
|  |                 # outcome.send(mngr) | ||||||
|  | 
 | ||||||
|  |             except StopIteration: | ||||||
|  |                 return | ||||||
|  |             else: | ||||||
|  |                 raise RuntimeError(f"{mngr} didn't stop!") | ||||||
|  | 
 | ||||||
|  |         to_return = await tn.start(_start_wrapped_in_scope) | ||||||
|  |         assert to_return is not None | ||||||
|  | 
 | ||||||
|  |         # TODO: use the fancy type-check-time type signature stuff from | ||||||
|  |         # mypy i guess..to like, relay the type of whatever the | ||||||
|  |         # generator yielded through? betcha that'll be un-grokable XD | ||||||
|  |         return to_return | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: define a decorator to runtime type check that this a generator | ||||||
|  | # with a single yield that also delivers a value (of some std type) from | ||||||
|  | # the yield expression? | ||||||
|  | # @trio.task_manager | ||||||
|  | def add_task_handle_and_crash_handling( | ||||||
|  |     nursery: Nursery, | ||||||
|  | 
 | ||||||
|  |     debug_mode: bool = False, | ||||||
|  | 
 | ||||||
|  | ) -> Generator[ | ||||||
|  |     Any, | ||||||
|  |     Outcome, | ||||||
|  |     None, | ||||||
|  | ]: | ||||||
|  |     ''' | ||||||
|  |     A customizable, user defined "task scope manager". | ||||||
|  | 
 | ||||||
|  |     With this specially crafted single-yield generator function you can | ||||||
|  |     add more granular controls around every task spawned by `trio` B) | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # if you need it you can ask trio for the task obj | ||||||
|  |     task: Task = trio.lowlevel.current_task() | ||||||
|  |     log.info(f'Spawning task: {task.name}') | ||||||
|  | 
 | ||||||
|  |     # User defined "task handle" for more granular supervision | ||||||
|  |     # of each spawned task as needed for their particular usage. | ||||||
|  |     task_outcome = TaskOutcome(task) | ||||||
|  | 
 | ||||||
|  |     # NOTE: if wanted the user could wrap the output task handle however | ||||||
|  |     # they want! | ||||||
|  |     # class TaskHandle(Struct): | ||||||
|  |     #     task: Task | ||||||
|  |     #     cs: CancelScope | ||||||
|  |     #     outcome: TaskOutcome | ||||||
|  | 
 | ||||||
|  |     # this yields back when the task is terminated, cancelled or returns. | ||||||
|  |     try: | ||||||
|  |         with CancelScope() as cs: | ||||||
|  | 
 | ||||||
|  |             # the yielded value(s) here are what are returned to the | ||||||
|  |             # nursery's `.start_soon()` caller B) | ||||||
|  |             lowlevel_outcome: Outcome = yield (task_outcome, cs) | ||||||
|  |             task_outcome._set_outcome(lowlevel_outcome) | ||||||
|  | 
 | ||||||
|  |     # Adds "crash handling" from `pdbp` by entering | ||||||
|  |     # a REPL on std errors. | ||||||
|  |     except Exception as err: | ||||||
|  |         if debug_mode: | ||||||
|  |             log.exception( | ||||||
|  |                 f'{task.name} crashed, entering debugger!' | ||||||
|  |             ) | ||||||
|  |             import pdbp | ||||||
|  |             pdbp.xpm() | ||||||
|  | 
 | ||||||
|  |         raise err | ||||||
|  | 
 | ||||||
|  |     finally: | ||||||
|  |         log.info( | ||||||
|  |             f'Task exitted\n' | ||||||
|  |             f')>\n' | ||||||
|  |             f' |_{task}\n' | ||||||
|  |             # ^^TODO? use sclang formatter? | ||||||
|  |             # -[ ] .devx.pformat.nest_from_op()` yo! | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @acm | ||||||
|  | async def open_taskman( | ||||||
|  |     task_manager: Generator[Any, Outcome, None] | None = None, | ||||||
|  | 
 | ||||||
|  |     **lowlevel_nursery_kwargs, | ||||||
|  | ): | ||||||
|  |     async with trio.open_nursery(**lowlevel_nursery_kwargs) as nurse: | ||||||
|  |         yield TaskManagerNursery( | ||||||
|  |             nurse, | ||||||
|  |             task_manager=task_manager, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def sleep_then_return_val(val: str): | ||||||
|  |     await trio.sleep(0.2) | ||||||
|  |     return val | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def ensure_cancelled(): | ||||||
|  |     try: | ||||||
|  |         await trio.sleep_forever() | ||||||
|  | 
 | ||||||
|  |     except trio.Cancelled: | ||||||
|  |         task = trio.lowlevel.current_task() | ||||||
|  |         log.cancel(f'heyyo ONLY {task.name} was cancelled as expected B)') | ||||||
|  |         assert 0 | ||||||
|  | 
 | ||||||
|  |     except BaseException: | ||||||
|  |         raise RuntimeError("woa woa woa this ain't right!") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == '__main__': | ||||||
|  | 
 | ||||||
|  |     from tractor.log import get_console_log | ||||||
|  |     get_console_log(level='info') | ||||||
|  | 
 | ||||||
|  |     async def main(): | ||||||
|  |         async with open_taskman( | ||||||
|  |             task_manager=partial( | ||||||
|  |                 add_task_handle_and_crash_handling, | ||||||
|  |                 debug_mode=True, | ||||||
|  |             ), | ||||||
|  |         ) as tm: | ||||||
|  |             for _ in range(3): | ||||||
|  |                 outcome, _ = await tm.start_soon(trio.sleep_forever) | ||||||
|  | 
 | ||||||
|  |             # extra task we want to engage in debugger post mortem. | ||||||
|  |             err_outcome, cs = await tm.start_soon(ensure_cancelled) | ||||||
|  | 
 | ||||||
|  |             val: str = 'yoyoyo' | ||||||
|  |             val_outcome, _ = await tm.start_soon( | ||||||
|  |                 sleep_then_return_val, | ||||||
|  |                 val, | ||||||
|  |             ) | ||||||
|  |             res = await val_outcome.wait_for_result() | ||||||
|  |             assert res == val | ||||||
|  |             log.info(f'{res} -> GOT EXPECTED TASK VALUE') | ||||||
|  | 
 | ||||||
|  |             await trio.sleep(0.6) | ||||||
|  |             log.cancel( | ||||||
|  |                 f'Cancelling and waiting on {err_outcome.lowlevel_task} ' | ||||||
|  |                 'to CRASH..' | ||||||
|  |             ) | ||||||
|  |             cs.cancel() | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | @ -0,0 +1,94 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | `trio.Nursery` wrappers which we short-hand refer to as | ||||||
|  | `tn`: "task nursery". | ||||||
|  | 
 | ||||||
|  | (whereas we refer to `tractor.ActorNursery` as the short-hand `an`) | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  | ) | ||||||
|  | from types import ModuleType | ||||||
|  | from typing import ( | ||||||
|  |     Any, | ||||||
|  |     AsyncGenerator, | ||||||
|  |     TYPE_CHECKING, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | from tractor.log import get_logger | ||||||
|  | 
 | ||||||
|  | # from ._beg import ( | ||||||
|  | #     collapse_eg, | ||||||
|  | # ) | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from tractor import ActorNursery | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # ??TODO? is this even a good idea?? | ||||||
|  | # it's an extra LoC to stack `collapse_eg()` vs. | ||||||
|  | # a new/foreign/bad-std-named very thing wrapper..? | ||||||
|  | # -[ ] is there a better/simpler name? | ||||||
|  | # @acm | ||||||
|  | # async def open_loose_tn() -> trio.Nursery: | ||||||
|  | #     ''' | ||||||
|  | #     Implements the equivalent of the old style loose eg raising | ||||||
|  | #     task-nursery from `trio<=0.25.0` , | ||||||
|  | 
 | ||||||
|  | #     .. code-block:: python | ||||||
|  | 
 | ||||||
|  | #         async with trio.open_nursery( | ||||||
|  | #             strict_exception_groups=False, | ||||||
|  | #         ) as tn: | ||||||
|  | #             ... | ||||||
|  | 
 | ||||||
|  | #     ''' | ||||||
|  | #     async with ( | ||||||
|  | #         collapse_eg(), | ||||||
|  | #         trio.open_nursery() as tn, | ||||||
|  | #     ): | ||||||
|  | #         yield tn | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @acm | ||||||
|  | async def maybe_open_nursery( | ||||||
|  |     nursery: trio.Nursery|ActorNursery|None = None, | ||||||
|  |     shield: bool = False, | ||||||
|  |     lib: ModuleType = trio, | ||||||
|  |     loose: bool = False, | ||||||
|  | 
 | ||||||
|  |     **kwargs,  # proxy thru | ||||||
|  | 
 | ||||||
|  | ) -> AsyncGenerator[trio.Nursery, Any]: | ||||||
|  |     ''' | ||||||
|  |     Create a new nursery if None provided. | ||||||
|  | 
 | ||||||
|  |     Blocks on exit as expected if no input nursery is provided. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     if nursery is not None: | ||||||
|  |         yield nursery | ||||||
|  |     else: | ||||||
|  |         async with lib.open_nursery(**kwargs) as tn: | ||||||
|  |             tn.cancel_scope.shield = shield | ||||||
|  |             yield tn | ||||||
							
								
								
									
										58
									
								
								uv.lock
								
								
								
								
							
							
						
						
									
										58
									
								
								uv.lock
								
								
								
								
							|  | @ -11,6 +11,15 @@ wheels = [ | ||||||
|     { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, |     { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "bidict" | ||||||
|  | version = "0.23.1" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "cffi" | name = "cffi" | ||||||
| version = "1.17.1" | version = "1.17.1" | ||||||
|  | @ -20,10 +29,38 @@ dependencies = [ | ||||||
| ] | ] | ||||||
| sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } | sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } | ||||||
| wheels = [ | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, |     { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, |     { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, |     { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, |     { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, |     { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, |     { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, | ||||||
| ] | ] | ||||||
|  | @ -220,6 +257,21 @@ wheels = [ | ||||||
|     { url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 }, |     { url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 }, | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "psutil" | ||||||
|  | version = "7.0.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "ptyprocess" | name = "ptyprocess" | ||||||
| version = "0.7.0" | version = "0.7.0" | ||||||
|  | @ -321,6 +373,8 @@ name = "tractor" | ||||||
| version = "0.1.0a6.dev0" | version = "0.1.0a6.dev0" | ||||||
| source = { editable = "." } | source = { editable = "." } | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  |     { name = "bidict" }, | ||||||
|  |     { name = "cffi" }, | ||||||
|     { name = "colorlog" }, |     { name = "colorlog" }, | ||||||
|     { name = "msgspec" }, |     { name = "msgspec" }, | ||||||
|     { name = "pdbp" }, |     { name = "pdbp" }, | ||||||
|  | @ -334,6 +388,7 @@ dev = [ | ||||||
|     { name = "greenback" }, |     { name = "greenback" }, | ||||||
|     { name = "pexpect" }, |     { name = "pexpect" }, | ||||||
|     { name = "prompt-toolkit" }, |     { name = "prompt-toolkit" }, | ||||||
|  |     { name = "psutil" }, | ||||||
|     { name = "pyperclip" }, |     { name = "pyperclip" }, | ||||||
|     { name = "pytest" }, |     { name = "pytest" }, | ||||||
|     { name = "stackscope" }, |     { name = "stackscope" }, | ||||||
|  | @ -342,6 +397,8 @@ dev = [ | ||||||
| 
 | 
 | ||||||
| [package.metadata] | [package.metadata] | ||||||
| requires-dist = [ | requires-dist = [ | ||||||
|  |     { name = "bidict", specifier = ">=0.23.1" }, | ||||||
|  |     { name = "cffi", specifier = ">=1.17.1" }, | ||||||
|     { name = "colorlog", specifier = ">=6.8.2,<7" }, |     { name = "colorlog", specifier = ">=6.8.2,<7" }, | ||||||
|     { name = "msgspec", specifier = ">=0.19.0" }, |     { name = "msgspec", specifier = ">=0.19.0" }, | ||||||
|     { name = "pdbp", specifier = ">=1.6,<2" }, |     { name = "pdbp", specifier = ">=1.6,<2" }, | ||||||
|  | @ -355,6 +412,7 @@ dev = [ | ||||||
|     { name = "greenback", specifier = ">=1.2.1,<2" }, |     { name = "greenback", specifier = ">=1.2.1,<2" }, | ||||||
|     { name = "pexpect", specifier = ">=4.9.0,<5" }, |     { name = "pexpect", specifier = ">=4.9.0,<5" }, | ||||||
|     { name = "prompt-toolkit", specifier = ">=3.0.50" }, |     { name = "prompt-toolkit", specifier = ">=3.0.50" }, | ||||||
|  |     { name = "psutil", specifier = ">=7.0.0" }, | ||||||
|     { name = "pyperclip", specifier = ">=1.9.0" }, |     { name = "pyperclip", specifier = ">=1.9.0" }, | ||||||
|     { name = "pytest", specifier = ">=8.3.5" }, |     { name = "pytest", specifier = ">=8.3.5" }, | ||||||
|     { name = "stackscope", specifier = ">=0.2.2,<0.3" }, |     { name = "stackscope", specifier = ">=0.2.2,<0.3" }, | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue