Compare commits
	
		
			272 Commits 
		
	
	
		
			d1f1cd3474
			...
			fd314deecb
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | fd314deecb | |
|  | dd011c0b2f | |
|  | 087aaa1c36 | |
|  | 66b7410eab | |
|  | c9a55c2d46 | |
|  | 548855b4f5 | |
|  | 5322861d6d | |
|  | 46a2fa7074 | |
|  | bfe5b2dde6 | |
|  | a9f06df3fb | |
|  | ee32bc433c | |
|  | 561954594e | |
|  | 28a6354e81 | |
|  | d1599449e7 | |
|  | 2d27c94dec | |
|  | 6e4c76245b | |
|  | a6f599901c | |
|  | 0fafd25f0d | |
|  | b74e93ee55 | |
|  | 961504b657 | |
|  | bd148300c5 | |
|  | 4a7491bda4 | |
|  | 62415518fc | |
|  | 5c7d930a9a | |
|  | c46986504d | |
|  | e05a4d3cac | |
|  | a9aa5ec04e | |
|  | 5021514a6a | |
|  | 79f502034f | |
|  | 331921f612 | |
|  | df0d00abf4 | |
|  | a72d1e6c48 | |
|  | 5931c59aef | |
|  | ba08052ddf | |
|  | 00112edd58 | |
|  | 1d706bddda | |
|  | 3c30c559d5 | |
|  | 599020c2c5 | |
|  | 50f6543ee7 | |
|  | c0854fd221 | |
|  | e875b62869 | |
|  | 3ab7498893 | |
|  | dd041b0a01 | |
|  | 4e252526b5 | |
|  | 4ba3590450 | |
|  | f1ff79a4e6 | |
|  | 70664b98de | |
|  | 1c425cbd22 | |
|  | edc2211444 | |
|  | b05abea51e | |
|  | 88c1c083bd | |
|  | b096867d40 | |
|  | a3c9822602 | |
|  | e3a542f2b5 | |
|  | 0ffcea1033 | |
|  | a7bdf0486c | |
|  | d2ac9ecf95 | |
|  | dcb1062bb8 | |
|  | 05d865c0f1 | |
|  | 8218f0f51f | |
|  | 8f19f5d3a8 | |
|  | 64c27a914b | |
|  | d9c8d543b3 | |
|  | 048b154f00 | |
|  | 88828e9f99 | |
|  | 25ff195c17 | |
|  | f60cc646ff | |
|  | a2b754b5f5 | |
|  | 5e13588aed | |
|  | 0a56f40bab | |
|  | f776c47cb4 | |
|  | 7f584d4f54 | |
|  | d650dda0fa | |
|  | f6598e8400 | |
|  | 59822ff093 | |
|  | ca427aec7e | |
|  | f53aa992af | |
|  | 69e0afccf0 | |
|  | e275c49b23 | |
|  | 48fbf38c1d | |
|  | defd6e28d2 | |
|  | 414b0e2bae | |
|  | d34fb54f7c | |
|  | 5d87f63377 | |
|  | 0ca3d50602 | |
|  | 8880a80e3e | |
|  | 7be713ee1e | |
|  | 4bd8211abb | |
|  | a23a98886c | |
|  | 31544c862c | |
|  | 7d320c4e1e | |
|  | 38944ad1d2 | |
|  | 9260909fe1 | |
|  | c00b3c86ea | |
|  | 808a336508 | |
|  | 679d999185 | |
|  | a8428d7de3 | |
|  | e9f2fecd66 | |
|  | 547cf5a210 | |
|  | b5e3fa7370 | |
|  | cd16748598 | |
|  | 1af35f8170 | |
|  | 4569d11052 | |
|  | 6ba76ab700 | |
|  | 734dda35e9 | |
|  | b7e04525cc | |
|  | 35977dcebb | |
|  | e1f26f9611 | |
|  | 63c5b7696a | |
|  | 5f94f52226 | |
|  | 6bf571a124 | |
|  | f5056cdd02 | |
|  | 9ff448faa3 | |
|  | 760b9890c4 | |
|  | d000642462 | |
|  | dd69948744 | |
|  | 5b69975f81 | |
|  | 6b474743f9 | |
|  | 5ac229244a | |
|  | bbd2ea3e4f | |
|  | 6b903f7746 | |
|  | 2280bad135 | |
|  | 8d506796ec | |
|  | 02d03ce700 | |
|  | 9786e2c404 | |
|  | 116137d066 | |
|  | 7f87b4e717 | |
|  | bb17d39c4e | |
|  | fba6edfe9a | |
|  | e4758550f7 | |
|  | a7efbfdbc2 | |
|  | 1c6660c497 | |
|  | 202befa360 | |
|  | c24708b273 | |
|  | 3aee702733 | |
|  | a573c3c9a8 | |
|  | 6a352fee87 | |
|  | 6cb361352c | |
|  | 7807ffaabe | |
|  | 65b795612c | |
|  | a42c1761a8 | |
|  | 359d732633 | |
|  | b09e35f3dc | |
|  | 6618b004f4 | |
|  | fc57a4d639 | |
|  | 2248ffb74f | |
|  | 1eb0d785a8 | |
|  | 98d0ca88e5 | |
|  | 37f843a128 | |
|  | 29cd2ddbac | |
|  | 295b06511b | |
|  | 1e6b5b3f0a | |
|  | 36ddb85197 | |
|  | d6b0ddecd7 | |
|  | 9e5475391c | |
|  | ef7ed7ac6f | |
|  | d8094f4420 | |
|  | d7b12735a8 | |
|  | 47107e44ed | |
|  | ba384ca83d | |
|  | ad9833a73a | |
|  | 161884fbf1 | |
|  | c2e7dc7407 | |
|  | 309360daa2 | |
|  | cbfb0d0144 | |
|  | c0eef3bac3 | |
|  | 27e6ad18ee | |
|  | 28e32b8f85 | |
|  | 05df634d62 | |
|  | 6d2f4d108d | |
|  | ae2687b381 | |
|  | a331f6dab3 | |
|  | 9c0de24899 | |
|  | 1f3cef5ed6 | |
|  | 8538a9c591 | |
|  | 7533e93b0f | |
|  | f67b0639b8 | |
|  | 26fedec6a1 | |
|  | 0711576678 | |
|  | 0477a62ac3 | |
|  | 01d6f111f6 | |
|  | 56ef4cba23 | |
|  | 52b5efd78d | |
|  | a7d4bcdfb9 | |
|  | 79d0c17f6b | |
|  | 98c4614a36 | |
|  | 61df10b333 | |
|  | 094447787e | |
|  | ba45c03e14 | |
|  | 00d8a2a099 | |
|  | bedde076d9 | |
|  | be1d8bf6fa | |
|  | d9aee98db2 | |
|  | 708ce4a051 | |
|  | d6d0112d95 | |
|  | 0fcbedd2be | |
|  | 412c66d000 | |
|  | 3cc835c215 | |
|  | f15bbb30cc | |
|  | ad211f8c2c | |
|  | acac605c37 | |
|  | 078e507774 | |
|  | 81bf810fbb | |
|  | 7d1512e03a | |
|  | 1c85338ff8 | |
|  | 7a3c9d0458 | |
|  | 31196b9cb4 | |
|  | 44c9da1c91 | |
|  | b4ce618e33 | |
|  | a504d92536 | |
|  | 8c0d9614bc | |
|  | a6fefcc2a8 | |
|  | abdaf7bf1f | |
|  | 7b3324b240 | |
|  | bbae2c91fd | |
|  | 2540d1f9e0 | |
|  | 63fac5a809 | |
|  | 568fb18d01 | |
|  | f67e19a852 | |
|  | 0be9f5f907 | |
|  | 5e2d456029 | |
|  | c7d5b021db | |
|  | 6f1f198fb1 | |
|  | 26fef82d33 | |
|  | 84d25b5727 | |
|  | 1ed0c861b5 | |
|  | 2dd3a682c8 | |
|  | 881813e61e | |
|  | 566a11c00d | |
|  | af69272d16 | |
|  | 8e3f581d3f | |
|  | eceb292415 | |
|  | 9921ea3cae | |
|  | 414a8c5b75 | |
|  | eeb0516017 | |
|  | d6eeddef4e | |
|  | d478dbfcfe | |
|  | ef6094a650 | |
|  | 4e8404bb09 | |
|  | bbb3484ae9 | |
|  | 5cee222353 | |
|  | 8ebb1f09de | |
|  | 2683a7f33a | |
|  | 255209f881 | |
|  | 9a0d529b18 | |
|  | 1c441b0986 | |
|  | afbdb50a30 | |
|  | e46033cbe7 | |
|  | c932bb5911 | |
|  | 33482d8f41 | |
|  | 7ae194baed | |
|  | ef7ca49e9b | |
|  | fde681fa19 | |
|  | efcf81bcad | |
|  | 3988ea69f5 | |
|  | 8bd4490cad | |
|  | 622f840dfd | |
|  | 8ba315e60c | |
|  | 80f20b35b1 | |
|  | 9ec37dd13f | |
|  | 9be76b1dda | |
|  | 31f88b59f4 | |
|  | 155d581fa2 | |
|  | a810f6c8f6 | |
|  | 83b9dc3c62 | |
|  | f152a20025 | |
|  | 1ea8254ae3 | |
|  | 8ed890f892 | |
|  | d4e6f2b8dc | |
|  | 64fe767647 | |
|  | aca015f1c2 | |
|  | 818cd8535f | 
|  | @ -8,46 +8,70 @@ on: | ||||||
|   workflow_dispatch: |   workflow_dispatch: | ||||||
| 
 | 
 | ||||||
| jobs: | jobs: | ||||||
| 
 |   # ------ sdist ------ | ||||||
|   mypy: |  | ||||||
|     name: 'MyPy' |  | ||||||
|     runs-on: ubuntu-latest |  | ||||||
| 
 |  | ||||||
|     steps: |  | ||||||
|       - name: Checkout |  | ||||||
|         uses: actions/checkout@v2 |  | ||||||
| 
 |  | ||||||
|       - name: Setup python |  | ||||||
|         uses: actions/setup-python@v2 |  | ||||||
|         with: |  | ||||||
|           python-version: '3.11' |  | ||||||
| 
 |  | ||||||
|       - name: Install dependencies |  | ||||||
|         run: pip install -U . --upgrade-strategy eager -r requirements-test.txt |  | ||||||
| 
 |  | ||||||
|       - name: Run MyPy check |  | ||||||
|         run: mypy tractor/ --ignore-missing-imports --show-traceback |  | ||||||
| 
 |  | ||||||
|   # test that we can generate a software distribution and install it |   # test that we can generate a software distribution and install it | ||||||
|   # thus avoid missing file issues after packaging. |   # thus avoid missing file issues after packaging. | ||||||
|  |   # | ||||||
|  |   # -[x] produce sdist with uv | ||||||
|  |   # ------ - ------ | ||||||
|   sdist-linux: |   sdist-linux: | ||||||
|     name: 'sdist' |     name: 'sdist' | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
| 
 | 
 | ||||||
|     steps: |     steps: | ||||||
|       - name: Checkout |       - name: Checkout | ||||||
|         uses: actions/checkout@v2 |         uses: actions/checkout@v4 | ||||||
| 
 | 
 | ||||||
|       - name: Setup python |       - name: Install latest uv | ||||||
|         uses: actions/setup-python@v2 |         uses: astral-sh/setup-uv@v6 | ||||||
|         with: |  | ||||||
|           python-version: '3.11' |  | ||||||
| 
 | 
 | ||||||
|       - name: Build sdist |       - name: Build sdist as tar.gz | ||||||
|         run: python setup.py sdist --formats=zip |         run: uv build --sdist --python=3.13 | ||||||
| 
 | 
 | ||||||
|       - name: Install sdist from .zips |       - name: Install sdist from .tar.gz | ||||||
|         run: python -m pip install dist/*.zip |         run: python -m pip install dist/*.tar.gz | ||||||
|  | 
 | ||||||
|  |   # ------ type-check ------ | ||||||
|  |   # mypy: | ||||||
|  |   #   name: 'MyPy' | ||||||
|  |   #   runs-on: ubuntu-latest | ||||||
|  | 
 | ||||||
|  |   #   steps: | ||||||
|  |   #     - name: Checkout | ||||||
|  |   #       uses: actions/checkout@v4 | ||||||
|  | 
 | ||||||
|  |   #     - name: Install latest uv | ||||||
|  |   #       uses: astral-sh/setup-uv@v6 | ||||||
|  | 
 | ||||||
|  |   #     # faster due to server caching? | ||||||
|  |   #     # https://docs.astral.sh/uv/guides/integration/github/#setting-up-python | ||||||
|  |   #     - name: "Set up Python" | ||||||
|  |   #       uses: actions/setup-python@v6 | ||||||
|  |   #       with: | ||||||
|  |   #         python-version-file: "pyproject.toml" | ||||||
|  | 
 | ||||||
|  |   #     # w uv | ||||||
|  |   #     # - name: Set up Python | ||||||
|  |   #     #   run: uv python install | ||||||
|  | 
 | ||||||
|  |   #     - name: Setup uv venv | ||||||
|  |   #       run: uv venv .venv --python=3.13 | ||||||
|  | 
 | ||||||
|  |   #     - name: Install | ||||||
|  |   #       run: uv sync --dev | ||||||
|  | 
 | ||||||
|  |   #     # TODO, ty cmd over repo | ||||||
|  |   #     # - name: type check with ty | ||||||
|  |   #     #   run: ty ./tractor/ | ||||||
|  | 
 | ||||||
|  |   #     # - uses: actions/cache@v3 | ||||||
|  |   #     #     name: Cache uv virtenv as default .venv | ||||||
|  |   #     #     with: | ||||||
|  |   #     #       path: ./.venv | ||||||
|  |   #     #       key: venv-${{ hashFiles('uv.lock') }} | ||||||
|  | 
 | ||||||
|  |   #     - name: Run MyPy check | ||||||
|  |   #       run: mypy tractor/ --ignore-missing-imports --show-traceback | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|   testing-linux: |   testing-linux: | ||||||
|  | @ -59,32 +83,45 @@ jobs: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         os: [ubuntu-latest] |         os: [ubuntu-latest] | ||||||
|         python: ['3.11'] |         python-version: ['3.13'] | ||||||
|         spawn_backend: [ |         spawn_backend: [ | ||||||
|           'trio', |           'trio', | ||||||
|           'mp_spawn', |           # 'mp_spawn', | ||||||
|           'mp_forkserver', |           # 'mp_forkserver', | ||||||
|         ] |         ] | ||||||
| 
 | 
 | ||||||
|     steps: |     steps: | ||||||
| 
 | 
 | ||||||
|       - name: Checkout |       - uses: actions/checkout@v4 | ||||||
|         uses: actions/checkout@v2 |  | ||||||
| 
 | 
 | ||||||
|       - name: Setup python |       - name: 'Install uv + py-${{ matrix.python-version }}' | ||||||
|         uses: actions/setup-python@v2 |         uses: astral-sh/setup-uv@v6 | ||||||
|         with: |         with: | ||||||
|           python-version: '${{ matrix.python }}' |           python-version: ${{ matrix.python-version }} | ||||||
| 
 | 
 | ||||||
|       - name: Install dependencies |       # GH way.. faster? | ||||||
|         run: pip install -U . -r requirements-test.txt -r requirements-docs.txt --upgrade-strategy eager |       # - name: setup-python@v6 | ||||||
|  |       #   uses: actions/setup-python@v6 | ||||||
|  |       #   with: | ||||||
|  |       #     python-version: '${{ matrix.python-version }}' | ||||||
| 
 | 
 | ||||||
|       - name: List dependencies |       # consider caching for speedups? | ||||||
|         run: pip list |       # https://docs.astral.sh/uv/guides/integration/github/#caching | ||||||
|  | 
 | ||||||
|  |       - name: Install the project w uv | ||||||
|  |         run: uv sync --all-extras --dev | ||||||
|  | 
 | ||||||
|  |       # - name: Install dependencies | ||||||
|  |       #   run: pip install -U . -r requirements-test.txt -r requirements-docs.txt --upgrade-strategy eager | ||||||
|  | 
 | ||||||
|  |       - name: List deps tree | ||||||
|  |         run: uv tree | ||||||
| 
 | 
 | ||||||
|       - name: Run tests |       - name: Run tests | ||||||
|         run: pytest tests/ --spawn-backend=${{ matrix.spawn_backend }} -rsx |         run: uv run pytest tests/ --spawn-backend=${{ matrix.spawn_backend }} -rsx | ||||||
| 
 | 
 | ||||||
|  |   # XXX legacy NOTE XXX | ||||||
|  |   # | ||||||
|   # We skip 3.10 on windows for now due to not having any collabs to |   # We skip 3.10 on windows for now due to not having any collabs to | ||||||
|   # debug the CI failures. Anyone wanting to hack and solve them is very |   # debug the CI failures. Anyone wanting to hack and solve them is very | ||||||
|   # welcome, but our primary user base is not using that OS. |   # welcome, but our primary user base is not using that OS. | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |   ''; | ||||||
|  | } | ||||||
|  | @ -1,8 +1,5 @@ | ||||||
| |logo| ``tractor``: distributed structurred concurrency | |logo| ``tractor``: distributed structurred concurrency | ||||||
| 
 | 
 | ||||||
| |gh_actions| |  | ||||||
| |docs| |  | ||||||
| 
 |  | ||||||
| ``tractor`` is a `structured concurrency`_ (SC), multi-processing_ runtime built on trio_. | ``tractor`` is a `structured concurrency`_ (SC), multi-processing_ runtime built on trio_. | ||||||
| 
 | 
 | ||||||
| Fundamentally, ``tractor`` provides parallelism via | Fundamentally, ``tractor`` provides parallelism via | ||||||
|  | @ -66,6 +63,13 @@ Features | ||||||
|   - (WIP) a ``TaskMngr``: one-cancels-one style nursery supervisor. |   - (WIP) a ``TaskMngr``: one-cancels-one style nursery supervisor. | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | Status of `main` / infra | ||||||
|  | ------------------------ | ||||||
|  | 
 | ||||||
|  | - |gh_actions| | ||||||
|  | - |docs| | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| Install | Install | ||||||
| ------- | ------- | ||||||
| ``tractor`` is still in a *alpha-near-beta-stage* for many | ``tractor`` is still in a *alpha-near-beta-stage* for many | ||||||
|  | @ -689,9 +693,11 @@ channel`_! | ||||||
| .. _msgspec: https://jcristharif.com/msgspec/ | .. _msgspec: https://jcristharif.com/msgspec/ | ||||||
| .. _guest: https://trio.readthedocs.io/en/stable/reference-lowlevel.html?highlight=guest%20mode#using-guest-mode-to-run-trio-on-top-of-other-event-loops | .. _guest: https://trio.readthedocs.io/en/stable/reference-lowlevel.html?highlight=guest%20mode#using-guest-mode-to-run-trio-on-top-of-other-event-loops | ||||||
| 
 | 
 | ||||||
| 
 | .. | ||||||
| .. |gh_actions| image:: https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fgoodboy%2Ftractor%2Fbadge&style=popout-square |    NOTE, on generating badge links from the UI | ||||||
|     :target: https://actions-badge.atrox.dev/goodboy/tractor/goto |    https://docs.github.com/en/actions/how-tos/monitoring-and-troubleshooting-workflows/monitoring-workflows/adding-a-workflow-status-badge?ref=gitguardian-blog-automated-secrets-detection#using-the-ui | ||||||
|  | .. |gh_actions| image:: https://github.com/goodboy/tractor/actions/workflows/ci.yml/badge.svg?branch=main | ||||||
|  |     :target: https://github.com/goodboy/tractor/actions/workflows/ci.yml | ||||||
| 
 | 
 | ||||||
| .. |docs| image:: https://readthedocs.org/projects/tractor/badge/?version=latest | .. |docs| image:: https://readthedocs.org/projects/tractor/badge/?version=latest | ||||||
|     :target: https://tractor.readthedocs.io/en/latest/?badge=latest |     :target: https://tractor.readthedocs.io/en/latest/?badge=latest | ||||||
|  |  | ||||||
|  | @ -16,6 +16,7 @@ from tractor import ( | ||||||
|     ContextCancelled, |     ContextCancelled, | ||||||
|     MsgStream, |     MsgStream, | ||||||
|     _testing, |     _testing, | ||||||
|  |     trionics, | ||||||
| ) | ) | ||||||
| import trio | import trio | ||||||
| import pytest | import pytest | ||||||
|  | @ -62,9 +63,8 @@ async def recv_and_spawn_net_killers( | ||||||
|     await ctx.started() |     await ctx.started() | ||||||
|     async with ( |     async with ( | ||||||
|         ctx.open_stream() as stream, |         ctx.open_stream() as stream, | ||||||
|         trio.open_nursery( |         trionics.collapse_eg(), | ||||||
|             strict_exception_groups=False, |         trio.open_nursery() as tn, | ||||||
|         ) as tn, |  | ||||||
|     ): |     ): | ||||||
|         async for i in stream: |         async for i in stream: | ||||||
|             print(f'child echoing {i}') |             print(f'child echoing {i}') | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -29,7 +29,7 @@ async def bp_then_error( | ||||||
|     to_trio.send_nowait('start') |     to_trio.send_nowait('start') | ||||||
| 
 | 
 | ||||||
|     # NOTE: what happens here inside the hook needs some refinement.. |     # NOTE: what happens here inside the hook needs some refinement.. | ||||||
|     # => seems like it's still `._debug._set_trace()` but |     # => seems like it's still `.debug._set_trace()` but | ||||||
|     #    we set `Lock.local_task_in_debug = 'sync'`, we probably want |     #    we set `Lock.local_task_in_debug = 'sync'`, we probably want | ||||||
|     #    some further, at least, meta-data about the task/actor in debug |     #    some further, at least, meta-data about the task/actor in debug | ||||||
|     #    in terms of making it clear it's `asyncio` mucking about. |     #    in terms of making it clear it's `asyncio` mucking about. | ||||||
|  |  | ||||||
|  | @ -4,6 +4,11 @@ import sys | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
| 
 | 
 | ||||||
|  | # ensure mod-path is correct! | ||||||
|  | from tractor.devx.debug import ( | ||||||
|  |     _sync_pause_from_builtin as _sync_pause_from_builtin, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| async def main() -> None: | async def main() -> None: | ||||||
| 
 | 
 | ||||||
|  | @ -13,19 +18,23 @@ async def main() -> None: | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         debug_mode=True, |         debug_mode=True, | ||||||
|     ) as an: |         loglevel='devx', | ||||||
|         assert an |         maybe_enable_greenback=True, | ||||||
|  |         # ^XXX REQUIRED to enable `breakpoint()` support (from sync | ||||||
|  |         # fns) and thus required here to avoid an assertion err | ||||||
|  |         # on the next line | ||||||
|  |     ): | ||||||
|         assert ( |         assert ( | ||||||
|             (pybp_var := os.environ['PYTHONBREAKPOINT']) |             (pybp_var := os.environ['PYTHONBREAKPOINT']) | ||||||
|             == |             == | ||||||
|             'tractor.devx._debug._sync_pause_from_builtin' |             'tractor.devx.debug._sync_pause_from_builtin' | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         # TODO: an assert that verifies the hook has indeed been, hooked |         # TODO: an assert that verifies the hook has indeed been, hooked | ||||||
|         # XD |         # XD | ||||||
|         assert ( |         assert ( | ||||||
|             (pybp_hook := sys.breakpointhook) |             (pybp_hook := sys.breakpointhook) | ||||||
|             is not tractor.devx._debug._set_trace |             is not tractor.devx.debug._set_trace | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         print( |         print( | ||||||
|  |  | ||||||
|  | @ -24,10 +24,9 @@ async def spawn_until(depth=0): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def main(): | async def main(): | ||||||
|     """The main ``tractor`` routine. |     ''' | ||||||
| 
 |     The process tree should look as approximately as follows when the | ||||||
|     The process tree should look as approximately as follows when the debugger |     debugger first engages: | ||||||
|     first engages: |  | ||||||
| 
 | 
 | ||||||
|     python examples/debugging/multi_nested_subactors_bp_forever.py |     python examples/debugging/multi_nested_subactors_bp_forever.py | ||||||
|     ├─ python -m tractor._child --uid ('spawner1', '7eab8462 ...) |     ├─ python -m tractor._child --uid ('spawner1', '7eab8462 ...) | ||||||
|  | @ -37,10 +36,11 @@ async def main(): | ||||||
|     └─ python -m tractor._child --uid ('spawner0', '1d42012b ...) |     └─ python -m tractor._child --uid ('spawner0', '1d42012b ...) | ||||||
|        └─ python -m tractor._child --uid ('name_error', '6c2733b8 ...) |        └─ python -m tractor._child --uid ('name_error', '6c2733b8 ...) | ||||||
| 
 | 
 | ||||||
|     """ |     ''' | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         debug_mode=True, |         debug_mode=True, | ||||||
|         loglevel='warning' |         loglevel='devx', | ||||||
|  |         enable_transports=['uds'], | ||||||
|     ) as n: |     ) as n: | ||||||
| 
 | 
 | ||||||
|         # spawn both actors |         # spawn both actors | ||||||
|  |  | ||||||
|  | @ -0,0 +1,35 @@ | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def main(): | ||||||
|  |     async with tractor.open_root_actor( | ||||||
|  |         debug_mode=True, | ||||||
|  |         loglevel='cancel', | ||||||
|  |     ) as _root: | ||||||
|  | 
 | ||||||
|  |         # manually trigger self-cancellation and wait | ||||||
|  |         # for it to fully trigger. | ||||||
|  |         _root.cancel_soon() | ||||||
|  |         await _root._cancel_complete.wait() | ||||||
|  |         print('root cancelled') | ||||||
|  | 
 | ||||||
|  |         # now ensure we can still use the REPL | ||||||
|  |         try: | ||||||
|  |             await tractor.pause() | ||||||
|  |         except trio.Cancelled as _taskc: | ||||||
|  |             assert (root_cs := _root._root_tn.cancel_scope).cancel_called | ||||||
|  |             # NOTE^^ above logic but inside `open_root_actor()` and | ||||||
|  |             # passed to the `shield=` expression is effectively what | ||||||
|  |             # we're testing here! | ||||||
|  |             await tractor.pause(shield=root_cs.cancel_called) | ||||||
|  | 
 | ||||||
|  |         # XXX, if shield logic *is wrong* inside `open_root_actor()`'s | ||||||
|  |         # crash-handler block this should never be interacted, | ||||||
|  |         # instead `trio.Cancelled` would be bubbled up: the original | ||||||
|  |         # BUG. | ||||||
|  |         assert 0 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == '__main__': | ||||||
|  |     trio.run(main) | ||||||
|  | @ -37,6 +37,7 @@ async def main( | ||||||
|             enable_stack_on_sig=True, |             enable_stack_on_sig=True, | ||||||
|             # maybe_enable_greenback=False, |             # maybe_enable_greenback=False, | ||||||
|             loglevel='devx', |             loglevel='devx', | ||||||
|  |             enable_transports=['uds'], | ||||||
|         ) as an, |         ) as an, | ||||||
|     ): |     ): | ||||||
|         ptl: tractor.Portal  = await an.start_actor( |         ptl: tractor.Portal  = await an.start_actor( | ||||||
|  |  | ||||||
|  | @ -33,8 +33,11 @@ async def just_bp( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def main(): | async def main(): | ||||||
|  | 
 | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         debug_mode=True, |         debug_mode=True, | ||||||
|  |         enable_transports=['uds'], | ||||||
|  |         loglevel='devx', | ||||||
|     ) as n: |     ) as n: | ||||||
|         p = await n.start_actor( |         p = await n.start_actor( | ||||||
|             'bp_boi', |             'bp_boi', | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ import tractor | ||||||
| 
 | 
 | ||||||
| # TODO: only import these when not running from test harness? | # TODO: only import these when not running from test harness? | ||||||
| # can we detect `pexpect` usage maybe? | # can we detect `pexpect` usage maybe? | ||||||
| # from tractor.devx._debug import ( | # from tractor.devx.debug import ( | ||||||
| #     get_lock, | #     get_lock, | ||||||
| #     get_debug_req, | #     get_debug_req, | ||||||
| # ) | # ) | ||||||
|  |  | ||||||
|  | @ -23,9 +23,8 @@ async def main(): | ||||||
|             modules=[__name__] |             modules=[__name__] | ||||||
|         ) as portal_map, |         ) as portal_map, | ||||||
| 
 | 
 | ||||||
|         trio.open_nursery( |         tractor.trionics.collapse_eg(), | ||||||
|             strict_exception_groups=False, |         trio.open_nursery() as tn, | ||||||
|         ) as tn, |  | ||||||
|     ): |     ): | ||||||
| 
 | 
 | ||||||
|         for (name, portal) in portal_map.items(): |         for (name, portal) in portal_map.items(): | ||||||
|  |  | ||||||
|  | @ -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 ------ | ||||||
|  | @ -59,9 +61,13 @@ dev = [ | ||||||
|   # `tractor.devx` tooling |   # `tractor.devx` tooling | ||||||
|   "greenback>=1.2.1,<2", |   "greenback>=1.2.1,<2", | ||||||
|   "stackscope>=0.2.2,<0.3", |   "stackscope>=0.2.2,<0.3", | ||||||
|  |   # ^ requires this? | ||||||
|  |   "typing-extensions>=4.14.1", | ||||||
|  | 
 | ||||||
|   "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: pytest.Pytester, | ||||||
|     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 | ||||||
|  |  | ||||||
|  | @ -2,9 +2,11 @@ | ||||||
| `tractor.devx.*` tooling sub-pkg test space. | `tractor.devx.*` tooling sub-pkg test space. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
|  | from __future__ import annotations | ||||||
| import time | import time | ||||||
| from typing import ( | from typing import ( | ||||||
|     Callable, |     Callable, | ||||||
|  |     TYPE_CHECKING, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
|  | @ -16,7 +18,7 @@ from pexpect.spawnbase import SpawnBase | ||||||
| from tractor._testing import ( | from tractor._testing import ( | ||||||
|     mk_cmd, |     mk_cmd, | ||||||
| ) | ) | ||||||
| from tractor.devx._debug import ( | from tractor.devx.debug import ( | ||||||
|     _pause_msg as _pause_msg, |     _pause_msg as _pause_msg, | ||||||
|     _crash_msg as _crash_msg, |     _crash_msg as _crash_msg, | ||||||
|     _repl_fail_msg as _repl_fail_msg, |     _repl_fail_msg as _repl_fail_msg, | ||||||
|  | @ -26,14 +28,22 @@ from ..conftest import ( | ||||||
|     _ci_env, |     _ci_env, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from pexpect import pty_spawn | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # a fn that sub-instantiates a `pexpect.spawn()` | ||||||
|  | # and returns it. | ||||||
|  | type PexpectSpawner = Callable[[str], pty_spawn.spawn] | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.fixture | @pytest.fixture | ||||||
| def spawn( | def spawn( | ||||||
|     start_method, |     start_method: str, | ||||||
|     testdir: pytest.Pytester, |     testdir: pytest.Pytester, | ||||||
|     reg_addr: tuple[str, int], |     reg_addr: tuple[str, int], | ||||||
| 
 | 
 | ||||||
| ) -> Callable[[str], None]: | ) -> PexpectSpawner: | ||||||
|     ''' |     ''' | ||||||
|     Use the `pexpect` module shipped via `testdir.spawn()` to |     Use the `pexpect` module shipped via `testdir.spawn()` to | ||||||
|     run an `./examples/..` script by name. |     run an `./examples/..` script by name. | ||||||
|  | @ -59,7 +69,7 @@ def spawn( | ||||||
|     def _spawn( |     def _spawn( | ||||||
|         cmd: str, |         cmd: str, | ||||||
|         **mkcmd_kwargs, |         **mkcmd_kwargs, | ||||||
|     ): |     ) -> pty_spawn.spawn: | ||||||
|         unset_colors() |         unset_colors() | ||||||
|         return testdir.spawn( |         return testdir.spawn( | ||||||
|             cmd=mk_cmd( |             cmd=mk_cmd( | ||||||
|  | @ -73,7 +83,7 @@ def spawn( | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     # such that test-dep can pass input script name. |     # such that test-dep can pass input script name. | ||||||
|     return _spawn |     return _spawn  # the `PexpectSpawner`, type alias. | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.fixture( | @pytest.fixture( | ||||||
|  | @ -111,7 +121,7 @@ def ctlc( | ||||||
|         # XXX: disable pygments highlighting for auto-tests |         # XXX: disable pygments highlighting for auto-tests | ||||||
|         # since some envs (like actions CI) will struggle |         # since some envs (like actions CI) will struggle | ||||||
|         # the the added color-char encoding.. |         # the the added color-char encoding.. | ||||||
|         from tractor.devx._debug import TractorConfig |         from tractor.devx.debug import TractorConfig | ||||||
|         TractorConfig.use_pygements = False |         TractorConfig.use_pygements = False | ||||||
| 
 | 
 | ||||||
|     yield use_ctlc |     yield use_ctlc | ||||||
|  |  | ||||||
|  | @ -1,19 +1,23 @@ | ||||||
| """ | """ | ||||||
| That "native" debug mode better work! | That "native" debug mode better work! | ||||||
| 
 | 
 | ||||||
| All these tests can be understood (somewhat) by running the equivalent | All these tests can be understood (somewhat) by running the | ||||||
| `examples/debugging/` scripts manually. | equivalent `examples/debugging/` scripts manually. | ||||||
| 
 | 
 | ||||||
| TODO: | TODO: | ||||||
|     - none of these tests have been run successfully on windows yet but |   - none of these tests have been run successfully on windows yet but | ||||||
|       there's been manual testing that verified it works. |     there's been manual testing that verified it works. | ||||||
|     - wonder if any of it'll work on OS X? |   - wonder if any of it'll work on OS X? | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
|  | from __future__ import annotations | ||||||
| from functools import partial | from functools import partial | ||||||
| import itertools | import itertools | ||||||
| import platform | import platform | ||||||
| import time | import time | ||||||
|  | from typing import ( | ||||||
|  |     TYPE_CHECKING, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| from pexpect.exceptions import ( | from pexpect.exceptions import ( | ||||||
|  | @ -34,6 +38,9 @@ from .conftest import ( | ||||||
|     assert_before, |     assert_before, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from ..conftest import PexpectSpawner | ||||||
|  | 
 | ||||||
| # TODO: The next great debugger audit could be done by you! | # TODO: The next great debugger audit could be done by you! | ||||||
| # - recurrent entry to breakpoint() from single actor *after* and an | # - recurrent entry to breakpoint() from single actor *after* and an | ||||||
| #   error in another task? | #   error in another task? | ||||||
|  | @ -310,7 +317,6 @@ def test_subactor_breakpoint( | ||||||
| 
 | 
 | ||||||
|     assert in_prompt_msg( |     assert in_prompt_msg( | ||||||
|         child, [ |         child, [ | ||||||
|         'MessagingError:', |  | ||||||
|         'RemoteActorError:', |         'RemoteActorError:', | ||||||
|          "('breakpoint_forever'", |          "('breakpoint_forever'", | ||||||
|          'bdb.BdbQuit', |          'bdb.BdbQuit', | ||||||
|  | @ -528,7 +534,7 @@ def test_multi_daemon_subactors( | ||||||
|     # now the root actor won't clobber the bp_forever child |     # now the root actor won't clobber the bp_forever child | ||||||
|     # during it's first access to the debug lock, but will instead |     # during it's first access to the debug lock, but will instead | ||||||
|     # wait for the lock to release, by the edge triggered |     # wait for the lock to release, by the edge triggered | ||||||
|     # ``devx._debug.Lock.no_remote_has_tty`` event before sending cancel messages |     # ``devx.debug.Lock.no_remote_has_tty`` event before sending cancel messages | ||||||
|     # (via portals) to its underlings B) |     # (via portals) to its underlings B) | ||||||
| 
 | 
 | ||||||
|     # at some point here there should have been some warning msg from |     # at some point here there should have been some warning msg from | ||||||
|  | @ -919,6 +925,7 @@ def test_post_mortem_api( | ||||||
|             "<Task 'name_error'", |             "<Task 'name_error'", | ||||||
|             "NameError", |             "NameError", | ||||||
|             "('child'", |             "('child'", | ||||||
|  |             'getattr(doggypants)',  # exc-LoC | ||||||
|         ] |         ] | ||||||
|     ) |     ) | ||||||
|     if ctlc: |     if ctlc: | ||||||
|  | @ -935,8 +942,8 @@ def test_post_mortem_api( | ||||||
|             "<Task '__main__.main'", |             "<Task '__main__.main'", | ||||||
|             "('root'", |             "('root'", | ||||||
|             "NameError", |             "NameError", | ||||||
|             "tractor.post_mortem()", |  | ||||||
|             "src_uid=('child'", |             "src_uid=('child'", | ||||||
|  |             "tractor.post_mortem()",  # in `main()`-LoC | ||||||
|         ] |         ] | ||||||
|     ) |     ) | ||||||
|     if ctlc: |     if ctlc: | ||||||
|  | @ -954,6 +961,10 @@ def test_post_mortem_api( | ||||||
|             "('root'", |             "('root'", | ||||||
|             "NameError", |             "NameError", | ||||||
|             "src_uid=('child'", |             "src_uid=('child'", | ||||||
|  | 
 | ||||||
|  |             # raising line in `main()` but from crash-handling | ||||||
|  |             # in `tractor.open_nursery()`. | ||||||
|  |             'async with p.open_context(name_error) as (ctx, first):', | ||||||
|         ] |         ] | ||||||
|     ) |     ) | ||||||
|     if ctlc: |     if ctlc: | ||||||
|  | @ -1063,6 +1074,136 @@ def test_shield_pause( | ||||||
|     child.expect(EOF) |     child.expect(EOF) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'quit_early', [False, True] | ||||||
|  | ) | ||||||
|  | def test_ctxep_pauses_n_maybe_ipc_breaks( | ||||||
|  |     spawn: PexpectSpawner, | ||||||
|  |     quit_early: bool, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Audit generator embedded `.pause()`es from within a `@context` | ||||||
|  |     endpoint with a chan close at the end, requiring that ctl-c is | ||||||
|  |     mashed and zombie reaper kills sub with no hangs. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     child = spawn('subactor_bp_in_ctx') | ||||||
|  |     child.expect(PROMPT) | ||||||
|  | 
 | ||||||
|  |     # 3 iters for the `gen()` pause-points | ||||||
|  |     for i in range(3): | ||||||
|  |         assert_before( | ||||||
|  |             child, | ||||||
|  |             [ | ||||||
|  |                 _pause_msg, | ||||||
|  |                 "('bp_boi'",  # actor name | ||||||
|  |                 "<Task 'just_bp'",  # task name | ||||||
|  |             ] | ||||||
|  |         ) | ||||||
|  |         if ( | ||||||
|  |             i == 1 | ||||||
|  |             and | ||||||
|  |             quit_early | ||||||
|  |         ): | ||||||
|  |             child.sendline('q') | ||||||
|  |             child.expect(PROMPT) | ||||||
|  |             assert_before( | ||||||
|  |                 child, | ||||||
|  |                 ["tractor._exceptions.RemoteActorError: remote task raised a 'BdbQuit'", | ||||||
|  |                  "bdb.BdbQuit", | ||||||
|  |                  "('bp_boi'", | ||||||
|  |                 ] | ||||||
|  |             ) | ||||||
|  |             child.sendline('c') | ||||||
|  |             child.expect(EOF) | ||||||
|  |             assert_before( | ||||||
|  |                 child, | ||||||
|  |                 ["tractor._exceptions.RemoteActorError: remote task raised a 'BdbQuit'", | ||||||
|  |                  "bdb.BdbQuit", | ||||||
|  |                  "('bp_boi'", | ||||||
|  |                 ] | ||||||
|  |             ) | ||||||
|  |             break  # end-of-test | ||||||
|  | 
 | ||||||
|  |         child.sendline('c') | ||||||
|  |         try: | ||||||
|  |             child.expect(PROMPT) | ||||||
|  |         except TIMEOUT: | ||||||
|  |             # no prompt since we hang due to IPC chan purposely | ||||||
|  |             # closed so verify we see error reporting as well as | ||||||
|  |             # a failed crash-REPL request msg and can CTL-c our way | ||||||
|  |             # out. | ||||||
|  |             assert_before( | ||||||
|  |                 child, | ||||||
|  |                 ['peer IPC channel closed abruptly?', | ||||||
|  |                  'another task closed this fd', | ||||||
|  |                  'Debug lock request was CANCELLED?', | ||||||
|  |                  "TransportClosed: 'MsgpackUDSStream' was already closed locally ?",] | ||||||
|  | 
 | ||||||
|  |                 # XXX races on whether these show/hit? | ||||||
|  |                  # 'Failed to REPl via `_pause()` You called `tractor.pause()` from an already cancelled scope!', | ||||||
|  |                  # 'AssertionError', | ||||||
|  |             ) | ||||||
|  |             # OSc(ancel) the hanging tree | ||||||
|  |             do_ctlc( | ||||||
|  |                 child=child, | ||||||
|  |                 expect_prompt=False, | ||||||
|  |             ) | ||||||
|  |             child.expect(EOF) | ||||||
|  |             assert_before( | ||||||
|  |                 child, | ||||||
|  |                 ['KeyboardInterrupt'], | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_crash_handling_within_cancelled_root_actor( | ||||||
|  |     spawn: PexpectSpawner, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Ensure that when only a root-actor is started via `open_root_actor()` | ||||||
|  |     we can crash-handle in debug-mode despite self-cancellation. | ||||||
|  | 
 | ||||||
|  |     More-or-less ensures we conditionally shield the pause in | ||||||
|  |     `._root.open_root_actor()`'s `await debug._maybe_enter_pm()` | ||||||
|  |     call. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     child = spawn('root_self_cancelled_w_error') | ||||||
|  |     child.expect(PROMPT) | ||||||
|  | 
 | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             "Actor.cancel_soon()` was called!", | ||||||
|  |             "root cancelled", | ||||||
|  |             _pause_msg, | ||||||
|  |             "('root'",  # actor name | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     child.sendline('c') | ||||||
|  |     child.expect(PROMPT) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "('root'",  # actor name | ||||||
|  |             "AssertionError", | ||||||
|  |             "assert 0", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     child.sendline('c') | ||||||
|  |     child.expect(EOF) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             "AssertionError", | ||||||
|  |             "assert 0", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| # TODO: better error for "non-ideal" usage from the root actor. | # TODO: better error for "non-ideal" usage from the root actor. | ||||||
| # -[ ] if called from an async scope emit a message that suggests | # -[ ] if called from an async scope emit a message that suggests | ||||||
| #    using `await tractor.pause()` instead since it's less overhead | #    using `await tractor.pause()` instead since it's less overhead | ||||||
|  |  | ||||||
|  | @ -13,9 +13,16 @@ TODO: | ||||||
|   when debugging a problem inside the stack vs. in their app. |   when debugging a problem inside the stack vs. in their app. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from contextlib import ( | ||||||
|  |     contextmanager as cm, | ||||||
|  | ) | ||||||
| import os | import os | ||||||
| import signal | import signal | ||||||
| import time | import time | ||||||
|  | from typing import ( | ||||||
|  |     TYPE_CHECKING, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| from .conftest import ( | from .conftest import ( | ||||||
|     expect, |     expect, | ||||||
|  | @ -24,14 +31,19 @@ from .conftest import ( | ||||||
|     PROMPT, |     PROMPT, | ||||||
|     _pause_msg, |     _pause_msg, | ||||||
| ) | ) | ||||||
|  | 
 | ||||||
|  | import pytest | ||||||
| from pexpect.exceptions import ( | from pexpect.exceptions import ( | ||||||
|     # TIMEOUT, |     # TIMEOUT, | ||||||
|     EOF, |     EOF, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from ..conftest import PexpectSpawner | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| def test_shield_pause( | def test_shield_pause( | ||||||
|     spawn, |     spawn: PexpectSpawner, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     Verify the `tractor.pause()/.post_mortem()` API works inside an |     Verify the `tractor.pause()/.post_mortem()` API works inside an | ||||||
|  | @ -109,9 +121,11 @@ def test_shield_pause( | ||||||
|         child.pid, |         child.pid, | ||||||
|         signal.SIGINT, |         signal.SIGINT, | ||||||
|     ) |     ) | ||||||
|  |     from tractor._supervise import _shutdown_msg | ||||||
|     expect( |     expect( | ||||||
|         child, |         child, | ||||||
|         'Shutting down actor runtime', |         # 'Shutting down actor runtime', | ||||||
|  |         _shutdown_msg, | ||||||
|         timeout=6, |         timeout=6, | ||||||
|     ) |     ) | ||||||
|     assert_before( |     assert_before( | ||||||
|  | @ -126,7 +140,7 @@ def test_shield_pause( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_breakpoint_hook_restored( | def test_breakpoint_hook_restored( | ||||||
|     spawn, |     spawn: PexpectSpawner, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     Ensures our actor runtime sets a custom `breakpoint()` hook |     Ensures our actor runtime sets a custom `breakpoint()` hook | ||||||
|  | @ -140,16 +154,22 @@ def test_breakpoint_hook_restored( | ||||||
|     child = spawn('restore_builtin_breakpoint') |     child = spawn('restore_builtin_breakpoint') | ||||||
| 
 | 
 | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
|     assert_before( |     try: | ||||||
|         child, |         assert_before( | ||||||
|         [ |             child, | ||||||
|             _pause_msg, |             [ | ||||||
|             "<Task '__main__.main'", |                 _pause_msg, | ||||||
|             "('root'", |                 "<Task '__main__.main'", | ||||||
|             "first bp, tractor hook set", |                 "('root'", | ||||||
|         ] |                 "first bp, tractor hook set", | ||||||
|     ) |             ] | ||||||
|     child.sendline('c') |         ) | ||||||
|  |     # XXX if the above raises `AssertionError`, without sending | ||||||
|  |     # the final 'continue' cmd to the REPL-active sub-process, | ||||||
|  |     # we'll hang waiting for that pexpect instance to terminate.. | ||||||
|  |     finally: | ||||||
|  |         child.sendline('c') | ||||||
|  | 
 | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
|     assert_before( |     assert_before( | ||||||
|         child, |         child, | ||||||
|  | @ -170,3 +190,117 @@ def test_breakpoint_hook_restored( | ||||||
|     ) |     ) | ||||||
|     child.sendline('c') |     child.sendline('c') | ||||||
|     child.expect(EOF) |     child.expect(EOF) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _to_raise = Exception('Triggering a crash') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'to_raise', | ||||||
|  |     [ | ||||||
|  |         None, | ||||||
|  |         _to_raise, | ||||||
|  |         RuntimeError('Never crash handle this!'), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'raise_on_exit', | ||||||
|  |     [ | ||||||
|  |         True, | ||||||
|  |         [type(_to_raise)], | ||||||
|  |         False, | ||||||
|  |     ] | ||||||
|  | ) | ||||||
|  | def test_crash_handler_cms( | ||||||
|  |     debug_mode: bool, | ||||||
|  |     to_raise: Exception, | ||||||
|  |     raise_on_exit: bool|list[Exception], | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify the `.devx.open_crash_handler()` API(s) by also | ||||||
|  |     (conveniently enough) tesing its `repl_fixture: ContextManager` | ||||||
|  |     param support which for this suite allows use to avoid use of | ||||||
|  |     a `pexpect`-style-test since we use the fixture to avoid actually | ||||||
|  |     entering `PdbpREPL.iteract()` :smirk: | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     import tractor | ||||||
|  |     # import trio | ||||||
|  | 
 | ||||||
|  |     # state flags | ||||||
|  |     repl_acquired: bool = False | ||||||
|  |     repl_released: bool = False | ||||||
|  | 
 | ||||||
|  |     @cm | ||||||
|  |     def block_repl_ux( | ||||||
|  |         repl: tractor.devx.debug.PdbREPL, | ||||||
|  |         maybe_bxerr: ( | ||||||
|  |             tractor.devx._debug.BoxedMaybeException | ||||||
|  |             |None | ||||||
|  |         ) = None, | ||||||
|  |         enter_repl: bool = True, | ||||||
|  | 
 | ||||||
|  |     ) -> bool: | ||||||
|  |         ''' | ||||||
|  |         Set pre/post-REPL state vars and bypass actual conole | ||||||
|  |         interaction. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         nonlocal repl_acquired, repl_released | ||||||
|  | 
 | ||||||
|  |         # task: trio.Task = trio.lowlevel.current_task() | ||||||
|  |         # print(f'pre-REPL active_task={task.name}') | ||||||
|  | 
 | ||||||
|  |         print('pre-REPL') | ||||||
|  |         repl_acquired = True | ||||||
|  |         yield False  # never actually .interact() | ||||||
|  |         print('post-REPL') | ||||||
|  |         repl_released = True | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|  |         # TODO, with runtime's `debug_mode` setting | ||||||
|  |         # -[ ] need to open runtime tho obvi.. | ||||||
|  |         # | ||||||
|  |         # with tractor.devx.maybe_open_crash_handler( | ||||||
|  |         #     pdb=True, | ||||||
|  | 
 | ||||||
|  |         with tractor.devx.open_crash_handler( | ||||||
|  |             raise_on_exit=raise_on_exit, | ||||||
|  |             repl_fixture=block_repl_ux | ||||||
|  |         ) as bxerr: | ||||||
|  |             if to_raise is not None: | ||||||
|  |                 raise to_raise | ||||||
|  | 
 | ||||||
|  |     except Exception as _exc: | ||||||
|  |         exc = _exc | ||||||
|  |         if ( | ||||||
|  |             raise_on_exit is True | ||||||
|  |             or | ||||||
|  |             type(to_raise) in raise_on_exit | ||||||
|  |         ): | ||||||
|  |             assert ( | ||||||
|  |                 exc | ||||||
|  |                 is | ||||||
|  |                 to_raise | ||||||
|  |                 is | ||||||
|  |                 bxerr.value | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             raise | ||||||
|  |     else: | ||||||
|  |         assert ( | ||||||
|  |             to_raise is None | ||||||
|  |             or | ||||||
|  |             not raise_on_exit | ||||||
|  |             or | ||||||
|  |             type(to_raise) not in raise_on_exit | ||||||
|  |         ) | ||||||
|  |         assert bxerr.value is to_raise | ||||||
|  | 
 | ||||||
|  |     assert bxerr.raise_on_exit == raise_on_exit | ||||||
|  | 
 | ||||||
|  |     if to_raise is not None: | ||||||
|  |         assert repl_acquired | ||||||
|  |         assert repl_released | ||||||
|  |  | ||||||
|  | @ -0,0 +1,4 @@ | ||||||
|  | ''' | ||||||
|  | `tractor.ipc` subsystem(s)/unit testing suites. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | @ -0,0 +1,114 @@ | ||||||
|  | ''' | ||||||
|  | Unit-ish tests for specific IPC transport protocol backends. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from pathlib import Path | ||||||
|  | 
 | ||||||
|  | import pytest | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | from tractor import ( | ||||||
|  |     Actor, | ||||||
|  |     _state, | ||||||
|  |     _addr, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.fixture | ||||||
|  | def bindspace_dir_str() -> str: | ||||||
|  | 
 | ||||||
|  |     rt_dir: Path = tractor._state.get_rt_dir() | ||||||
|  |     bs_dir: Path = rt_dir / 'doggy' | ||||||
|  |     bs_dir_str: str = str(bs_dir) | ||||||
|  |     assert not bs_dir.is_dir() | ||||||
|  | 
 | ||||||
|  |     yield bs_dir_str | ||||||
|  | 
 | ||||||
|  |     # delete it on suite teardown. | ||||||
|  |     # ?TODO? should we support this internally | ||||||
|  |     # or is leaking it ok? | ||||||
|  |     if bs_dir.is_dir(): | ||||||
|  |         bs_dir.rmdir() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_uds_bindspace_created_implicitly( | ||||||
|  |     debug_mode: bool, | ||||||
|  |     bindspace_dir_str: str, | ||||||
|  | ): | ||||||
|  |     registry_addr: tuple = ( | ||||||
|  |         f'{bindspace_dir_str}', | ||||||
|  |         'registry@doggy.sock', | ||||||
|  |     ) | ||||||
|  |     bs_dir_str: str = registry_addr[0] | ||||||
|  | 
 | ||||||
|  |     # XXX, ensure bindspace-dir DNE beforehand! | ||||||
|  |     assert not Path(bs_dir_str).is_dir() | ||||||
|  | 
 | ||||||
|  |     async def main(): | ||||||
|  |         async with tractor.open_nursery( | ||||||
|  |             enable_transports=['uds'], | ||||||
|  |             registry_addrs=[registry_addr], | ||||||
|  |             debug_mode=debug_mode, | ||||||
|  |         ) as _an: | ||||||
|  | 
 | ||||||
|  |             # XXX MUST be created implicitly by | ||||||
|  |             # `.ipc._uds.start_listener()`! | ||||||
|  |             assert Path(bs_dir_str).is_dir() | ||||||
|  | 
 | ||||||
|  |             root: Actor = tractor.current_actor() | ||||||
|  |             assert root.is_registrar | ||||||
|  | 
 | ||||||
|  |             assert registry_addr in root.reg_addrs | ||||||
|  |             assert ( | ||||||
|  |                 registry_addr | ||||||
|  |                 in | ||||||
|  |                 _state._runtime_vars['_registry_addrs'] | ||||||
|  |             ) | ||||||
|  |             assert ( | ||||||
|  |                 _addr.wrap_address(registry_addr) | ||||||
|  |                 in | ||||||
|  |                 root.registry_addrs | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_uds_double_listen_raises_connerr( | ||||||
|  |     debug_mode: bool, | ||||||
|  |     bindspace_dir_str: str, | ||||||
|  | ): | ||||||
|  |     registry_addr: tuple = ( | ||||||
|  |         f'{bindspace_dir_str}', | ||||||
|  |         'registry@doggy.sock', | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     async def main(): | ||||||
|  |         async with tractor.open_nursery( | ||||||
|  |             enable_transports=['uds'], | ||||||
|  |             registry_addrs=[registry_addr], | ||||||
|  |             debug_mode=debug_mode, | ||||||
|  |         ) as _an: | ||||||
|  | 
 | ||||||
|  |             # runtime up | ||||||
|  |             root: Actor = tractor.current_actor() | ||||||
|  | 
 | ||||||
|  |             from tractor.ipc._uds import ( | ||||||
|  |                 start_listener, | ||||||
|  |                 UDSAddress, | ||||||
|  |             ) | ||||||
|  |             ya_bound_addr: UDSAddress = root.registry_addrs[0] | ||||||
|  |             try: | ||||||
|  |                 await start_listener( | ||||||
|  |                     addr=ya_bound_addr, | ||||||
|  |                 ) | ||||||
|  |             except ConnectionError as connerr: | ||||||
|  |                 assert type(src_exc := connerr.__context__) is OSError | ||||||
|  |                 assert 'Address already in use' in src_exc.args | ||||||
|  |                 # complete, exit test. | ||||||
|  | 
 | ||||||
|  |             else: | ||||||
|  |                 pytest.fail('It dint raise a connerr !?') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | @ -0,0 +1,95 @@ | ||||||
|  | ''' | ||||||
|  | Verify the `enable_transports` param drives various | ||||||
|  | per-root/sub-actor IPC endpoint/server settings. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | 
 | ||||||
|  | import pytest | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | from tractor import ( | ||||||
|  |     Actor, | ||||||
|  |     Portal, | ||||||
|  |     ipc, | ||||||
|  |     msg, | ||||||
|  |     _state, | ||||||
|  |     _addr, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def chk_tpts( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  |     tpt_proto_key: str, | ||||||
|  | ): | ||||||
|  |     rtvars = _state._runtime_vars | ||||||
|  |     assert ( | ||||||
|  |         tpt_proto_key | ||||||
|  |         in | ||||||
|  |         rtvars['_enable_tpts'] | ||||||
|  |     ) | ||||||
|  |     actor: Actor = tractor.current_actor() | ||||||
|  |     spec: msg.types.SpawnSpec = actor._spawn_spec | ||||||
|  |     assert spec._runtime_vars == rtvars | ||||||
|  | 
 | ||||||
|  |     # ensure individual IPC ep-addr types | ||||||
|  |     serv: ipc._server.Server = actor.ipc_server | ||||||
|  |     addr: ipc._types.Address | ||||||
|  |     for addr in serv.addrs: | ||||||
|  |         assert addr.proto_key == tpt_proto_key | ||||||
|  | 
 | ||||||
|  |     # Actor delegate-props enforcement | ||||||
|  |     assert ( | ||||||
|  |         actor.accept_addrs | ||||||
|  |         == | ||||||
|  |         serv.accept_addrs | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     await ctx.started(serv.accept_addrs) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO, parametrize over mis-matched-proto-typed `registry_addrs` | ||||||
|  | # since i seems to work in `piker` but not exactly sure if both tcp | ||||||
|  | # & uds are being deployed then? | ||||||
|  | # | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'tpt_proto_key', | ||||||
|  |     ['tcp', 'uds'], | ||||||
|  |     ids=lambda item: f'ipc_tpt={item!r}' | ||||||
|  | ) | ||||||
|  | def test_root_passes_tpt_to_sub( | ||||||
|  |     tpt_proto_key: str, | ||||||
|  |     reg_addr: tuple, | ||||||
|  |     debug_mode: bool, | ||||||
|  | ): | ||||||
|  |     async def main(): | ||||||
|  |         async with tractor.open_nursery( | ||||||
|  |             enable_transports=[tpt_proto_key], | ||||||
|  |             registry_addrs=[reg_addr], | ||||||
|  |             debug_mode=debug_mode, | ||||||
|  |         ) as an: | ||||||
|  | 
 | ||||||
|  |             assert ( | ||||||
|  |                 tpt_proto_key | ||||||
|  |                 in | ||||||
|  |                 _state._runtime_vars['_enable_tpts'] | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             ptl: Portal = await an.start_actor( | ||||||
|  |                 name='sub', | ||||||
|  |                 enable_modules=[__name__], | ||||||
|  |             ) | ||||||
|  |             async with ptl.open_context( | ||||||
|  |                 chk_tpts, | ||||||
|  |                 tpt_proto_key=tpt_proto_key, | ||||||
|  |             ) as (ctx, accept_addrs): | ||||||
|  | 
 | ||||||
|  |                 uw_addr: tuple | ||||||
|  |                 for uw_addr in accept_addrs: | ||||||
|  |                     addr = _addr.wrap_address(uw_addr) | ||||||
|  |                     assert addr.is_valid | ||||||
|  | 
 | ||||||
|  |             # shudown sub-actor(s) | ||||||
|  |             await an.cancel() | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | @ -0,0 +1,72 @@ | ||||||
|  | ''' | ||||||
|  | High-level `.ipc._server` unit tests. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | 
 | ||||||
|  | import pytest | ||||||
|  | import trio | ||||||
|  | from tractor import ( | ||||||
|  |     devx, | ||||||
|  |     ipc, | ||||||
|  |     log, | ||||||
|  | ) | ||||||
|  | from tractor._testing.addr import ( | ||||||
|  |     get_rando_addr, | ||||||
|  | ) | ||||||
|  | # TODO, use/check-roundtripping with some of these wrapper types? | ||||||
|  | # | ||||||
|  | # from .._addr import Address | ||||||
|  | # from ._chan import Channel | ||||||
|  | # from ._transport import MsgTransport | ||||||
|  | # from ._uds import UDSAddress | ||||||
|  | # from ._tcp import TCPAddress | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     '_tpt_proto', | ||||||
|  |     ['uds', 'tcp'] | ||||||
|  | ) | ||||||
|  | def test_basic_ipc_server( | ||||||
|  |     _tpt_proto: str, | ||||||
|  |     debug_mode: bool, | ||||||
|  |     loglevel: str, | ||||||
|  | ): | ||||||
|  | 
 | ||||||
|  |     # so we see the socket-listener reporting on console | ||||||
|  |     log.get_console_log("INFO") | ||||||
|  | 
 | ||||||
|  |     rando_addr: tuple = get_rando_addr( | ||||||
|  |         tpt_proto=_tpt_proto, | ||||||
|  |     ) | ||||||
|  |     async def main(): | ||||||
|  |         async with ipc._server.open_ipc_server() as server: | ||||||
|  | 
 | ||||||
|  |             assert ( | ||||||
|  |                 server._parent_tn | ||||||
|  |                 and | ||||||
|  |                 server._parent_tn is server._stream_handler_tn | ||||||
|  |             ) | ||||||
|  |             assert server._no_more_peers.is_set() | ||||||
|  | 
 | ||||||
|  |             eps: list[ipc._server.Endpoint] = await server.listen_on( | ||||||
|  |                 accept_addrs=[rando_addr], | ||||||
|  |                 stream_handler_nursery=None, | ||||||
|  |             ) | ||||||
|  |             assert ( | ||||||
|  |                 len(eps) == 1 | ||||||
|  |                 and | ||||||
|  |                 (ep := eps[0])._listener | ||||||
|  |                 and | ||||||
|  |                 not ep.peer_tpts | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             server._parent_tn.cancel_scope.cancel() | ||||||
|  | 
 | ||||||
|  |         # !TODO! actually make a bg-task connection from a client | ||||||
|  |         # using `ipc._chan._connect_chan()` | ||||||
|  | 
 | ||||||
|  |     with devx.maybe_open_crash_handler( | ||||||
|  |         pdb=debug_mode, | ||||||
|  |     ): | ||||||
|  |         trio.run(main) | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -313,9 +313,8 @@ async def inf_streamer( | ||||||
|         # `trio.EndOfChannel` doesn't propagate directly to the above |         # `trio.EndOfChannel` doesn't propagate directly to the above | ||||||
|         # .open_stream() parent, resulting in it also raising instead |         # .open_stream() parent, resulting in it also raising instead | ||||||
|         # of gracefully absorbing as normal.. so how to handle? |         # of gracefully absorbing as normal.. so how to handle? | ||||||
|         trio.open_nursery( |         tractor.trionics.collapse_eg(), | ||||||
|             strict_exception_groups=False, |         trio.open_nursery() as tn, | ||||||
|         ) as tn, |  | ||||||
|     ): |     ): | ||||||
|         async def close_stream_on_sentinel(): |         async def close_stream_on_sentinel(): | ||||||
|             async for msg in stream: |             async for msg in stream: | ||||||
|  |  | ||||||
|  | @ -236,7 +236,10 @@ async def stream_forever(): | ||||||
| async def test_cancel_infinite_streamer(start_method): | async def test_cancel_infinite_streamer(start_method): | ||||||
| 
 | 
 | ||||||
|     # stream for at most 1 seconds |     # stream for at most 1 seconds | ||||||
|     with trio.move_on_after(1) as cancel_scope: |     with ( | ||||||
|  |         trio.fail_after(4), | ||||||
|  |         trio.move_on_after(1) as cancel_scope | ||||||
|  |     ): | ||||||
|         async with tractor.open_nursery() as n: |         async with tractor.open_nursery() as n: | ||||||
|             portal = await n.start_actor( |             portal = await n.start_actor( | ||||||
|                 'donny', |                 'donny', | ||||||
|  | @ -284,20 +287,32 @@ async def test_cancel_infinite_streamer(start_method): | ||||||
|     ], |     ], | ||||||
| ) | ) | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_some_cancels_all(num_actors_and_errs, start_method, loglevel): | async def test_some_cancels_all( | ||||||
|     """Verify a subset of failed subactors causes all others in |     num_actors_and_errs: tuple, | ||||||
|  |     start_method: str, | ||||||
|  |     loglevel: str, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify a subset of failed subactors causes all others in | ||||||
|     the nursery to be cancelled just like the strategy in trio. |     the nursery to be cancelled just like the strategy in trio. | ||||||
| 
 | 
 | ||||||
|     This is the first and only supervisory strategy at the moment. |     This is the first and only supervisory strategy at the moment. | ||||||
|     """ | 
 | ||||||
|     num_actors, first_err, err_type, ria_func, da_func = num_actors_and_errs |     ''' | ||||||
|  |     ( | ||||||
|  |         num_actors, | ||||||
|  |         first_err, | ||||||
|  |         err_type, | ||||||
|  |         ria_func, | ||||||
|  |         da_func, | ||||||
|  |     ) = num_actors_and_errs | ||||||
|     try: |     try: | ||||||
|         async with tractor.open_nursery() as n: |         async with tractor.open_nursery() as an: | ||||||
| 
 | 
 | ||||||
|             # spawn the same number of deamon actors which should be cancelled |             # spawn the same number of deamon actors which should be cancelled | ||||||
|             dactor_portals = [] |             dactor_portals = [] | ||||||
|             for i in range(num_actors): |             for i in range(num_actors): | ||||||
|                 dactor_portals.append(await n.start_actor( |                 dactor_portals.append(await an.start_actor( | ||||||
|                     f'deamon_{i}', |                     f'deamon_{i}', | ||||||
|                     enable_modules=[__name__], |                     enable_modules=[__name__], | ||||||
|                 )) |                 )) | ||||||
|  | @ -307,7 +322,7 @@ async def test_some_cancels_all(num_actors_and_errs, start_method, loglevel): | ||||||
|             for i in range(num_actors): |             for i in range(num_actors): | ||||||
|                 # start actor(s) that will fail immediately |                 # start actor(s) that will fail immediately | ||||||
|                 riactor_portals.append( |                 riactor_portals.append( | ||||||
|                     await n.run_in_actor( |                     await an.run_in_actor( | ||||||
|                         func, |                         func, | ||||||
|                         name=f'actor_{i}', |                         name=f'actor_{i}', | ||||||
|                         **kwargs |                         **kwargs | ||||||
|  | @ -337,7 +352,8 @@ async def test_some_cancels_all(num_actors_and_errs, start_method, loglevel): | ||||||
| 
 | 
 | ||||||
|         # should error here with a ``RemoteActorError`` or ``MultiError`` |         # should error here with a ``RemoteActorError`` or ``MultiError`` | ||||||
| 
 | 
 | ||||||
|     except first_err as err: |     except first_err as _err: | ||||||
|  |         err = _err | ||||||
|         if isinstance(err, BaseExceptionGroup): |         if isinstance(err, BaseExceptionGroup): | ||||||
|             assert len(err.exceptions) == num_actors |             assert len(err.exceptions) == num_actors | ||||||
|             for exc in err.exceptions: |             for exc in err.exceptions: | ||||||
|  | @ -348,8 +364,8 @@ async def test_some_cancels_all(num_actors_and_errs, start_method, loglevel): | ||||||
|         elif isinstance(err, tractor.RemoteActorError): |         elif isinstance(err, tractor.RemoteActorError): | ||||||
|             assert err.boxed_type == err_type |             assert err.boxed_type == err_type | ||||||
| 
 | 
 | ||||||
|         assert n.cancelled is True |         assert an.cancelled is True | ||||||
|         assert not n._children |         assert not an._children | ||||||
|     else: |     else: | ||||||
|         pytest.fail("Should have gotten a remote assertion error?") |         pytest.fail("Should have gotten a remote assertion error?") | ||||||
| 
 | 
 | ||||||
|  | @ -519,10 +535,15 @@ def test_cancel_via_SIGINT_other_task( | ||||||
|     async def main(): |     async def main(): | ||||||
|         # should never timeout since SIGINT should cancel the current program |         # should never timeout since SIGINT should cancel the current program | ||||||
|         with trio.fail_after(timeout): |         with trio.fail_after(timeout): | ||||||
|             async with trio.open_nursery( |             async with ( | ||||||
|                 strict_exception_groups=False, | 
 | ||||||
|             ) as n: |                 # XXX ?TODO? why no work!? | ||||||
|                 await n.start(spawn_and_sleep_forever) |                 # tractor.trionics.collapse_eg(), | ||||||
|  |                 trio.open_nursery( | ||||||
|  |                     strict_exception_groups=False, | ||||||
|  |                 ) as tn, | ||||||
|  |             ): | ||||||
|  |                 await tn.start(spawn_and_sleep_forever) | ||||||
|                 if 'mp' in spawn_backend: |                 if 'mp' in spawn_backend: | ||||||
|                     time.sleep(0.1) |                     time.sleep(0.1) | ||||||
|                 os.kill(pid, signal.SIGINT) |                 os.kill(pid, signal.SIGINT) | ||||||
|  | @ -533,38 +554,123 @@ def test_cancel_via_SIGINT_other_task( | ||||||
| 
 | 
 | ||||||
| async def spin_for(period=3): | async def spin_for(period=3): | ||||||
|     "Sync sleep." |     "Sync sleep." | ||||||
|  |     print(f'sync sleeping in sub-sub for {period}\n') | ||||||
|     time.sleep(period) |     time.sleep(period) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def spawn(): | async def spawn_sub_with_sync_blocking_task(): | ||||||
|     async with tractor.open_nursery() as tn: |     async with tractor.open_nursery() as an: | ||||||
|         await tn.run_in_actor( |         print('starting sync blocking subactor..\n') | ||||||
|  |         await an.run_in_actor( | ||||||
|             spin_for, |             spin_for, | ||||||
|             name='sleeper', |             name='sleeper', | ||||||
|         ) |         ) | ||||||
|  |         print('exiting first subactor layer..\n') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'man_cancel_outer', | ||||||
|  |     [ | ||||||
|  |         False,  # passes if delay != 2 | ||||||
|  | 
 | ||||||
|  |         # always causes an unexpected eg-w-embedded-assert-err? | ||||||
|  |         pytest.param(True, | ||||||
|  |              marks=pytest.mark.xfail( | ||||||
|  |                  reason=( | ||||||
|  |                     'always causes an unexpected eg-w-embedded-assert-err?' | ||||||
|  |                 ) | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
| @no_windows | @no_windows | ||||||
| def test_cancel_while_childs_child_in_sync_sleep( | def test_cancel_while_childs_child_in_sync_sleep( | ||||||
|     loglevel, |     loglevel: str, | ||||||
|     start_method, |     start_method: str, | ||||||
|     spawn_backend, |     spawn_backend: str, | ||||||
|  |     debug_mode: bool, | ||||||
|  |     reg_addr: tuple, | ||||||
|  |     man_cancel_outer: bool, | ||||||
| ): | ): | ||||||
|     """Verify that a child cancelled while executing sync code is torn |     ''' | ||||||
|  |     Verify that a child cancelled while executing sync code is torn | ||||||
|     down even when that cancellation is triggered by the parent |     down even when that cancellation is triggered by the parent | ||||||
|     2 nurseries "up". |     2 nurseries "up". | ||||||
|     """ | 
 | ||||||
|  |     Though the grandchild should stay blocking its actor runtime, its | ||||||
|  |     parent should issue a "zombie reaper" to hard kill it after | ||||||
|  |     sufficient timeout. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|     if start_method == 'forkserver': |     if start_method == 'forkserver': | ||||||
|         pytest.skip("Forksever sux hard at resuming from sync sleep...") |         pytest.skip("Forksever sux hard at resuming from sync sleep...") | ||||||
| 
 | 
 | ||||||
|     async def main(): |     async def main(): | ||||||
|         with trio.fail_after(2): |         # | ||||||
|             async with tractor.open_nursery() as tn: |         # XXX BIG TODO NOTE XXX | ||||||
|                 await tn.run_in_actor( |         # | ||||||
|                     spawn, |         # it seems there's a strange race that can happen | ||||||
|                     name='spawn', |         # where where the fail-after will trigger outer scope | ||||||
|  |         # .cancel() which then causes the inner scope to raise, | ||||||
|  |         # | ||||||
|  |         # BaseExceptionGroup('Exceptions from Trio nursery', [ | ||||||
|  |         #   BaseExceptionGroup('Exceptions from Trio nursery', | ||||||
|  |         #   [ | ||||||
|  |         #       Cancelled(), | ||||||
|  |         #       Cancelled(), | ||||||
|  |         #   ] | ||||||
|  |         #   ), | ||||||
|  |         #   AssertionError('assert 0') | ||||||
|  |         # ]) | ||||||
|  |         # | ||||||
|  |         # WHY THIS DOESN'T MAKE SENSE: | ||||||
|  |         # --------------------------- | ||||||
|  |         # - it should raise too-slow-error when too slow.. | ||||||
|  |         #  * verified that using simple-cs and manually cancelling | ||||||
|  |         #    you get same outcome -> indicates that the fail-after | ||||||
|  |         #    can have its TooSlowError overriden! | ||||||
|  |         #  |_ to check this it's easy, simplly decrease the timeout | ||||||
|  |         #     as per the var below. | ||||||
|  |         # | ||||||
|  |         # - when using the manual simple-cs the outcome is different | ||||||
|  |         #   DESPITE the `assert 0` which means regardless of the | ||||||
|  |         #   inner scope effectively failing in the same way, the | ||||||
|  |         #   bubbling up **is NOT the same**. | ||||||
|  |         # | ||||||
|  |         # delays trigger diff outcomes.. | ||||||
|  |         # --------------------------- | ||||||
|  |         # as seen by uncommenting various lines below there is from | ||||||
|  |         # my POV an unexpected outcome due to the delay=2 case. | ||||||
|  |         # | ||||||
|  |         # delay = 1  # no AssertionError in eg, TooSlowError raised. | ||||||
|  |         # delay = 2  # is AssertionError in eg AND no TooSlowError !? | ||||||
|  |         delay = 4  # is AssertionError in eg AND no _cs cancellation. | ||||||
|  | 
 | ||||||
|  |         with trio.fail_after(delay) as _cs: | ||||||
|  |         # with trio.CancelScope() as cs: | ||||||
|  |         # ^XXX^ can be used instead to see same outcome. | ||||||
|  | 
 | ||||||
|  |             async with ( | ||||||
|  |                 # tractor.trionics.collapse_eg(),  # doesn't help | ||||||
|  |                 tractor.open_nursery( | ||||||
|  |                     hide_tb=False, | ||||||
|  |                     debug_mode=debug_mode, | ||||||
|  |                     registry_addrs=[reg_addr], | ||||||
|  |                 ) as an, | ||||||
|  |             ): | ||||||
|  |                 await an.run_in_actor( | ||||||
|  |                     spawn_sub_with_sync_blocking_task, | ||||||
|  |                     name='sync_blocking_sub', | ||||||
|                 ) |                 ) | ||||||
|                 await trio.sleep(1) |                 await trio.sleep(1) | ||||||
|  | 
 | ||||||
|  |                 if man_cancel_outer: | ||||||
|  |                     print('Cancelling manually in root') | ||||||
|  |                     _cs.cancel() | ||||||
|  | 
 | ||||||
|  |                 # trigger exc-srced taskc down | ||||||
|  |                 # the actor tree. | ||||||
|  |                 print('RAISING IN ROOT') | ||||||
|                 assert 0 |                 assert 0 | ||||||
| 
 | 
 | ||||||
|     with pytest.raises(AssertionError): |     with pytest.raises(AssertionError): | ||||||
|  |  | ||||||
|  | @ -1,917 +0,0 @@ | ||||||
| ''' |  | ||||||
| Low-level functional audits for our |  | ||||||
| "capability based messaging"-spec feats. |  | ||||||
| 
 |  | ||||||
| B~) |  | ||||||
| 
 |  | ||||||
| ''' |  | ||||||
| import typing |  | ||||||
| from typing import ( |  | ||||||
|     Any, |  | ||||||
|     Type, |  | ||||||
|     Union, |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| from msgspec import ( |  | ||||||
|     structs, |  | ||||||
|     msgpack, |  | ||||||
|     Struct, |  | ||||||
|     ValidationError, |  | ||||||
| ) |  | ||||||
| import pytest |  | ||||||
| 
 |  | ||||||
| import tractor |  | ||||||
| from tractor import ( |  | ||||||
|     _state, |  | ||||||
|     MsgTypeError, |  | ||||||
|     Context, |  | ||||||
| ) |  | ||||||
| from tractor.msg import ( |  | ||||||
|     _codec, |  | ||||||
|     _ctxvar_MsgCodec, |  | ||||||
| 
 |  | ||||||
|     NamespacePath, |  | ||||||
|     MsgCodec, |  | ||||||
|     mk_codec, |  | ||||||
|     apply_codec, |  | ||||||
|     current_codec, |  | ||||||
| ) |  | ||||||
| from tractor.msg.types import ( |  | ||||||
|     _payload_msgs, |  | ||||||
|     log, |  | ||||||
|     PayloadMsg, |  | ||||||
|     Started, |  | ||||||
|     mk_msg_spec, |  | ||||||
| ) |  | ||||||
| import trio |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def mk_custom_codec( |  | ||||||
|     pld_spec: Union[Type]|Any, |  | ||||||
|     add_hooks: bool, |  | ||||||
| 
 |  | ||||||
| ) -> MsgCodec: |  | ||||||
|     ''' |  | ||||||
|     Create custom `msgpack` enc/dec-hooks and set a `Decoder` |  | ||||||
|     which only loads `pld_spec` (like `NamespacePath`) types. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     uid: tuple[str, str] = tractor.current_actor().uid |  | ||||||
| 
 |  | ||||||
|     # XXX NOTE XXX: despite defining `NamespacePath` as a type |  | ||||||
|     # field on our `PayloadMsg.pld`, we still need a enc/dec_hook() pair |  | ||||||
|     # to cast to/from that type on the wire. See the docs: |  | ||||||
|     # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types |  | ||||||
| 
 |  | ||||||
|     def enc_nsp(obj: Any) -> Any: |  | ||||||
|         print(f'{uid} ENC HOOK') |  | ||||||
|         match obj: |  | ||||||
|             case NamespacePath(): |  | ||||||
|                 print( |  | ||||||
|                     f'{uid}: `NamespacePath`-Only ENCODE?\n' |  | ||||||
|                     f'obj-> `{obj}`: {type(obj)}\n' |  | ||||||
|                 ) |  | ||||||
|                 # if type(obj) != NamespacePath: |  | ||||||
|                 #     breakpoint() |  | ||||||
|                 return str(obj) |  | ||||||
| 
 |  | ||||||
|         print( |  | ||||||
|             f'{uid}\n' |  | ||||||
|             'CUSTOM ENCODE\n' |  | ||||||
|             f'obj-arg-> `{obj}`: {type(obj)}\n' |  | ||||||
|         ) |  | ||||||
|         logmsg: str = ( |  | ||||||
|             f'{uid}\n' |  | ||||||
|             'FAILED ENCODE\n' |  | ||||||
|             f'obj-> `{obj}: {type(obj)}`\n' |  | ||||||
|         ) |  | ||||||
|         raise NotImplementedError(logmsg) |  | ||||||
| 
 |  | ||||||
|     def dec_nsp( |  | ||||||
|         obj_type: Type, |  | ||||||
|         obj: Any, |  | ||||||
| 
 |  | ||||||
|     ) -> Any: |  | ||||||
|         print( |  | ||||||
|             f'{uid}\n' |  | ||||||
|             'CUSTOM DECODE\n' |  | ||||||
|             f'type-arg-> {obj_type}\n' |  | ||||||
|             f'obj-arg-> `{obj}`: {type(obj)}\n' |  | ||||||
|         ) |  | ||||||
|         nsp = None |  | ||||||
| 
 |  | ||||||
|         if ( |  | ||||||
|             obj_type is NamespacePath |  | ||||||
|             and isinstance(obj, str) |  | ||||||
|             and ':' in obj |  | ||||||
|         ): |  | ||||||
|             nsp = NamespacePath(obj) |  | ||||||
|             # TODO: we could built a generic handler using |  | ||||||
|             # JUST matching the obj_type part? |  | ||||||
|             # nsp = obj_type(obj) |  | ||||||
| 
 |  | ||||||
|         if nsp: |  | ||||||
|             print(f'Returning NSP instance: {nsp}') |  | ||||||
|             return nsp |  | ||||||
| 
 |  | ||||||
|         logmsg: str = ( |  | ||||||
|             f'{uid}\n' |  | ||||||
|             'FAILED DECODE\n' |  | ||||||
|             f'type-> {obj_type}\n' |  | ||||||
|             f'obj-arg-> `{obj}`: {type(obj)}\n\n' |  | ||||||
|             f'current codec:\n' |  | ||||||
|             f'{current_codec()}\n' |  | ||||||
|         ) |  | ||||||
|         # TODO: figure out the ignore subsys for this! |  | ||||||
|         # -[ ] option whether to defense-relay backc the msg |  | ||||||
|         #   inside an `Invalid`/`Ignore` |  | ||||||
|         # -[ ] how to make this handling pluggable such that a |  | ||||||
|         #   `Channel`/`MsgTransport` can intercept and process |  | ||||||
|         #   back msgs either via exception handling or some other |  | ||||||
|         #   signal? |  | ||||||
|         log.warning(logmsg) |  | ||||||
|         # NOTE: this delivers the invalid |  | ||||||
|         # value up to `msgspec`'s decoding |  | ||||||
|         # machinery for error raising. |  | ||||||
|         return obj |  | ||||||
|         # raise NotImplementedError(logmsg) |  | ||||||
| 
 |  | ||||||
|     nsp_codec: MsgCodec = mk_codec( |  | ||||||
|         ipc_pld_spec=pld_spec, |  | ||||||
| 
 |  | ||||||
|         # NOTE XXX: the encode hook MUST be used no matter what since |  | ||||||
|         # our `NamespacePath` is not any of a `Any` native type nor |  | ||||||
|         # a `msgspec.Struct` subtype - so `msgspec` has no way to know |  | ||||||
|         # how to encode it unless we provide the custom hook. |  | ||||||
|         # |  | ||||||
|         # AGAIN that is, regardless of whether we spec an |  | ||||||
|         # `Any`-decoded-pld the enc has no knowledge (by default) |  | ||||||
|         # how to enc `NamespacePath` (nsp), so we add a custom |  | ||||||
|         # hook to do that ALWAYS. |  | ||||||
|         enc_hook=enc_nsp if add_hooks else None, |  | ||||||
| 
 |  | ||||||
|         # XXX NOTE: pretty sure this is mutex with the `type=` to |  | ||||||
|         # `Decoder`? so it won't work in tandem with the |  | ||||||
|         # `ipc_pld_spec` passed above? |  | ||||||
|         dec_hook=dec_nsp if add_hooks else None, |  | ||||||
|     ) |  | ||||||
|     return nsp_codec |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def chk_codec_applied( |  | ||||||
|     expect_codec: MsgCodec, |  | ||||||
|     enter_value: MsgCodec|None = None, |  | ||||||
| 
 |  | ||||||
| ) -> MsgCodec: |  | ||||||
|     ''' |  | ||||||
|     buncha sanity checks ensuring that the IPC channel's |  | ||||||
|     context-vars are set to the expected codec and that are |  | ||||||
|     ctx-var wrapper APIs match the same. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     # TODO: play with tricyle again, bc this is supposed to work |  | ||||||
|     # the way we want? |  | ||||||
|     # |  | ||||||
|     # TreeVar |  | ||||||
|     # task: trio.Task = trio.lowlevel.current_task() |  | ||||||
|     # curr_codec = _ctxvar_MsgCodec.get_in(task) |  | ||||||
| 
 |  | ||||||
|     # ContextVar |  | ||||||
|     # task_ctx: Context = task.context |  | ||||||
|     # assert _ctxvar_MsgCodec in task_ctx |  | ||||||
|     # curr_codec: MsgCodec = task.context[_ctxvar_MsgCodec] |  | ||||||
| 
 |  | ||||||
|     # NOTE: currently we use this! |  | ||||||
|     # RunVar |  | ||||||
|     curr_codec: MsgCodec = current_codec() |  | ||||||
|     last_read_codec = _ctxvar_MsgCodec.get() |  | ||||||
|     # assert curr_codec is last_read_codec |  | ||||||
| 
 |  | ||||||
|     assert ( |  | ||||||
|         (same_codec := expect_codec) is |  | ||||||
|         # returned from `mk_codec()` |  | ||||||
| 
 |  | ||||||
|         # yielded value from `apply_codec()` |  | ||||||
| 
 |  | ||||||
|         # read from current task's `contextvars.Context` |  | ||||||
|         curr_codec is |  | ||||||
|         last_read_codec |  | ||||||
| 
 |  | ||||||
|         # the default `msgspec` settings |  | ||||||
|         is not _codec._def_msgspec_codec |  | ||||||
|         is not _codec._def_tractor_codec |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     if enter_value: |  | ||||||
|         enter_value is same_codec |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def iter_maybe_sends( |  | ||||||
|     send_items: dict[Union[Type], Any] | list[tuple], |  | ||||||
|     ipc_pld_spec: Union[Type] | Any, |  | ||||||
|     add_codec_hooks: bool, |  | ||||||
| 
 |  | ||||||
|     codec: MsgCodec|None = None, |  | ||||||
| 
 |  | ||||||
| ) -> tuple[Any, bool]: |  | ||||||
| 
 |  | ||||||
|     if isinstance(send_items, dict): |  | ||||||
|         send_items = send_items.items() |  | ||||||
| 
 |  | ||||||
|     for ( |  | ||||||
|         send_type_spec, |  | ||||||
|         send_value, |  | ||||||
|     ) in send_items: |  | ||||||
| 
 |  | ||||||
|         expect_roundtrip: bool = False |  | ||||||
| 
 |  | ||||||
|         # values-to-typespec santiy |  | ||||||
|         send_type = type(send_value) |  | ||||||
|         assert send_type == send_type_spec or ( |  | ||||||
|             (subtypes := getattr(send_type_spec, '__args__', None)) |  | ||||||
|             and send_type in subtypes |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         spec_subtypes: set[Union[Type]] = ( |  | ||||||
|              getattr( |  | ||||||
|                  ipc_pld_spec, |  | ||||||
|                  '__args__', |  | ||||||
|                  {ipc_pld_spec,}, |  | ||||||
|              ) |  | ||||||
|         ) |  | ||||||
|         send_in_spec: bool = ( |  | ||||||
|             send_type == ipc_pld_spec |  | ||||||
|             or ( |  | ||||||
|                 ipc_pld_spec != Any |  | ||||||
|                 and  # presume `Union` of types |  | ||||||
|                 send_type in spec_subtypes |  | ||||||
|             ) |  | ||||||
|             or ( |  | ||||||
|                 ipc_pld_spec == Any |  | ||||||
|                 and |  | ||||||
|                 send_type != NamespacePath |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         expect_roundtrip = ( |  | ||||||
|             send_in_spec |  | ||||||
|             # any spec should support all other |  | ||||||
|             # builtin py values that we send |  | ||||||
|             # except our custom nsp type which |  | ||||||
|             # we should be able to send as long |  | ||||||
|             # as we provide the custom codec hooks. |  | ||||||
|             or ( |  | ||||||
|                 ipc_pld_spec == Any |  | ||||||
|                 and |  | ||||||
|                 send_type == NamespacePath |  | ||||||
|                 and |  | ||||||
|                 add_codec_hooks |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         if codec is not None: |  | ||||||
|             # XXX FIRST XXX ensure roundtripping works |  | ||||||
|             # before touching any IPC primitives/APIs. |  | ||||||
|             wire_bytes: bytes = codec.encode( |  | ||||||
|                 Started( |  | ||||||
|                     cid='blahblah', |  | ||||||
|                     pld=send_value, |  | ||||||
|                 ) |  | ||||||
|             ) |  | ||||||
|             # NOTE: demonstrates the decoder loading |  | ||||||
|             # to via our native SCIPP msg-spec |  | ||||||
|             # (structurred-conc-inter-proc-protocol) |  | ||||||
|             # implemented as per, |  | ||||||
|             try: |  | ||||||
|                 msg: Started = codec.decode(wire_bytes) |  | ||||||
|                 if not expect_roundtrip: |  | ||||||
|                     pytest.fail( |  | ||||||
|                         f'NOT-EXPECTED able to roundtrip value given spec:\n' |  | ||||||
|                         f'ipc_pld_spec -> {ipc_pld_spec}\n' |  | ||||||
|                         f'value -> {send_value}: {send_type}\n' |  | ||||||
|                     ) |  | ||||||
| 
 |  | ||||||
|                 pld = msg.pld |  | ||||||
|                 assert pld == send_value |  | ||||||
| 
 |  | ||||||
|             except ValidationError: |  | ||||||
|                 if expect_roundtrip: |  | ||||||
|                     pytest.fail( |  | ||||||
|                         f'EXPECTED to roundtrip value given spec:\n' |  | ||||||
|                         f'ipc_pld_spec -> {ipc_pld_spec}\n' |  | ||||||
|                         f'value -> {send_value}: {send_type}\n' |  | ||||||
|                     ) |  | ||||||
| 
 |  | ||||||
|         yield ( |  | ||||||
|             str(send_type), |  | ||||||
|             send_value, |  | ||||||
|             expect_roundtrip, |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def dec_type_union( |  | ||||||
|     type_names: list[str], |  | ||||||
| ) -> Type: |  | ||||||
|     ''' |  | ||||||
|     Look up types by name, compile into a list and then create and |  | ||||||
|     return a `typing.Union` from the full set. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     import importlib |  | ||||||
|     types: list[Type] = [] |  | ||||||
|     for type_name in type_names: |  | ||||||
|         for mod in [ |  | ||||||
|             typing, |  | ||||||
|             importlib.import_module(__name__), |  | ||||||
|         ]: |  | ||||||
|             if type_ref := getattr( |  | ||||||
|                 mod, |  | ||||||
|                 type_name, |  | ||||||
|                 False, |  | ||||||
|             ): |  | ||||||
|                 types.append(type_ref) |  | ||||||
| 
 |  | ||||||
|     # special case handling only.. |  | ||||||
|     # ipc_pld_spec: Union[Type] = eval( |  | ||||||
|     #     pld_spec_str, |  | ||||||
|     #     {},  # globals |  | ||||||
|     #     {'typing': typing},  # locals |  | ||||||
|     # ) |  | ||||||
| 
 |  | ||||||
|     return Union[*types] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def enc_type_union( |  | ||||||
|     union_or_type: Union[Type]|Type, |  | ||||||
| ) -> list[str]: |  | ||||||
|     ''' |  | ||||||
|     Encode a type-union or single type to a list of type-name-strings |  | ||||||
|     ready for IPC interchange. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     type_strs: list[str] = [] |  | ||||||
|     for typ in getattr( |  | ||||||
|         union_or_type, |  | ||||||
|         '__args__', |  | ||||||
|         {union_or_type,}, |  | ||||||
|     ): |  | ||||||
|         type_strs.append(typ.__qualname__) |  | ||||||
| 
 |  | ||||||
|     return type_strs |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @tractor.context |  | ||||||
| async def send_back_values( |  | ||||||
|     ctx: Context, |  | ||||||
|     expect_debug: bool, |  | ||||||
|     pld_spec_type_strs: list[str], |  | ||||||
|     add_hooks: bool, |  | ||||||
|     started_msg_bytes: bytes, |  | ||||||
|     expect_ipc_send: dict[str, tuple[Any, bool]], |  | ||||||
| 
 |  | ||||||
| ) -> None: |  | ||||||
|     ''' |  | ||||||
|     Setup up a custom codec to load instances of `NamespacePath` |  | ||||||
|     and ensure we can round trip a func ref with our parent. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     uid: tuple = tractor.current_actor().uid |  | ||||||
| 
 |  | ||||||
|     # debug mode sanity check (prolly superfluous but, meh) |  | ||||||
|     assert expect_debug == _state.debug_mode() |  | ||||||
| 
 |  | ||||||
|     # init state in sub-actor should be default |  | ||||||
|     chk_codec_applied( |  | ||||||
|         expect_codec=_codec._def_tractor_codec, |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     # load pld spec from input str |  | ||||||
|     ipc_pld_spec = dec_type_union( |  | ||||||
|         pld_spec_type_strs, |  | ||||||
|     ) |  | ||||||
|     pld_spec_str = str(ipc_pld_spec) |  | ||||||
| 
 |  | ||||||
|     # same as on parent side config. |  | ||||||
|     nsp_codec: MsgCodec = mk_custom_codec( |  | ||||||
|         pld_spec=ipc_pld_spec, |  | ||||||
|         add_hooks=add_hooks, |  | ||||||
|     ) |  | ||||||
|     with ( |  | ||||||
|         apply_codec(nsp_codec) as codec, |  | ||||||
|     ): |  | ||||||
|         chk_codec_applied( |  | ||||||
|             expect_codec=nsp_codec, |  | ||||||
|             enter_value=codec, |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         print( |  | ||||||
|             f'{uid}: attempting `Started`-bytes DECODE..\n' |  | ||||||
|         ) |  | ||||||
|         try: |  | ||||||
|             msg: Started = nsp_codec.decode(started_msg_bytes) |  | ||||||
|             expected_pld_spec_str: str = msg.pld |  | ||||||
|             assert pld_spec_str == expected_pld_spec_str |  | ||||||
| 
 |  | ||||||
|         # TODO: maybe we should add our own wrapper error so as to |  | ||||||
|         # be interchange-lib agnostic? |  | ||||||
|         # -[ ] the error type is wtv is raised from the hook so we |  | ||||||
|         #   could also require a type-class of errors for |  | ||||||
|         #   indicating whether the hook-failure can be handled by |  | ||||||
|         #   a nasty-dialog-unprot sub-sys? |  | ||||||
|         except ValidationError: |  | ||||||
| 
 |  | ||||||
|             # NOTE: only in the `Any` spec case do we expect this to |  | ||||||
|             # work since otherwise no spec covers a plain-ol' |  | ||||||
|             # `.pld: str` |  | ||||||
|             if pld_spec_str == 'Any': |  | ||||||
|                 raise |  | ||||||
|             else: |  | ||||||
|                 print( |  | ||||||
|                     f'{uid}: (correctly) unable to DECODE `Started`-bytes\n' |  | ||||||
|                     f'{started_msg_bytes}\n' |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|         iter_send_val_items = iter(expect_ipc_send.values()) |  | ||||||
|         sent: list[Any] = [] |  | ||||||
|         for send_value, expect_send in iter_send_val_items: |  | ||||||
|             try: |  | ||||||
|                 print( |  | ||||||
|                     f'{uid}: attempting to `.started({send_value})`\n' |  | ||||||
|                     f'=> expect_send: {expect_send}\n' |  | ||||||
|                     f'SINCE, ipc_pld_spec: {ipc_pld_spec}\n' |  | ||||||
|                     f'AND, codec: {codec}\n' |  | ||||||
|                 ) |  | ||||||
|                 await ctx.started(send_value) |  | ||||||
|                 sent.append(send_value) |  | ||||||
|                 if not expect_send: |  | ||||||
| 
 |  | ||||||
|                     # XXX NOTE XXX THIS WON'T WORK WITHOUT SPECIAL |  | ||||||
|                     # `str` handling! or special debug mode IPC |  | ||||||
|                     # msgs! |  | ||||||
|                     await tractor.pause() |  | ||||||
| 
 |  | ||||||
|                     raise RuntimeError( |  | ||||||
|                         f'NOT-EXPECTED able to roundtrip value given spec:\n' |  | ||||||
|                         f'ipc_pld_spec -> {ipc_pld_spec}\n' |  | ||||||
|                         f'value -> {send_value}: {type(send_value)}\n' |  | ||||||
|                     ) |  | ||||||
| 
 |  | ||||||
|                 break  # move on to streaming block.. |  | ||||||
| 
 |  | ||||||
|             except tractor.MsgTypeError: |  | ||||||
|                 await tractor.pause() |  | ||||||
| 
 |  | ||||||
|                 if expect_send: |  | ||||||
|                     raise RuntimeError( |  | ||||||
|                         f'EXPECTED to `.started()` value given spec:\n' |  | ||||||
|                         f'ipc_pld_spec -> {ipc_pld_spec}\n' |  | ||||||
|                         f'value -> {send_value}: {type(send_value)}\n' |  | ||||||
|                     ) |  | ||||||
| 
 |  | ||||||
|         async with ctx.open_stream() as ipc: |  | ||||||
|             print( |  | ||||||
|                 f'{uid}: Entering streaming block to send remaining values..' |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|             for send_value, expect_send in iter_send_val_items: |  | ||||||
|                 send_type: Type = type(send_value) |  | ||||||
|                 print( |  | ||||||
|                     '------ - ------\n' |  | ||||||
|                     f'{uid}: SENDING NEXT VALUE\n' |  | ||||||
|                     f'ipc_pld_spec: {ipc_pld_spec}\n' |  | ||||||
|                     f'expect_send: {expect_send}\n' |  | ||||||
|                     f'val: {send_value}\n' |  | ||||||
|                     '------ - ------\n' |  | ||||||
|                 ) |  | ||||||
|                 try: |  | ||||||
|                     await ipc.send(send_value) |  | ||||||
|                     print(f'***\n{uid}-CHILD sent {send_value!r}\n***\n') |  | ||||||
|                     sent.append(send_value) |  | ||||||
| 
 |  | ||||||
|                     # NOTE: should only raise above on |  | ||||||
|                     # `.started()` or a `Return` |  | ||||||
|                     # if not expect_send: |  | ||||||
|                     #     raise RuntimeError( |  | ||||||
|                     #         f'NOT-EXPECTED able to roundtrip value given spec:\n' |  | ||||||
|                     #         f'ipc_pld_spec -> {ipc_pld_spec}\n' |  | ||||||
|                     #         f'value -> {send_value}: {send_type}\n' |  | ||||||
|                     #     ) |  | ||||||
| 
 |  | ||||||
|                 except ValidationError: |  | ||||||
|                     print(f'{uid} FAILED TO SEND {send_value}!') |  | ||||||
| 
 |  | ||||||
|                     # await tractor.pause() |  | ||||||
|                     if expect_send: |  | ||||||
|                         raise RuntimeError( |  | ||||||
|                             f'EXPECTED to roundtrip value given spec:\n' |  | ||||||
|                             f'ipc_pld_spec -> {ipc_pld_spec}\n' |  | ||||||
|                             f'value -> {send_value}: {send_type}\n' |  | ||||||
|                         ) |  | ||||||
|                     # continue |  | ||||||
| 
 |  | ||||||
|             else: |  | ||||||
|                 print( |  | ||||||
|                     f'{uid}: finished sending all values\n' |  | ||||||
|                     'Should be exiting stream block!\n' |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|         print(f'{uid}: exited streaming block!') |  | ||||||
| 
 |  | ||||||
|         # TODO: this won't be true bc in streaming phase we DO NOT |  | ||||||
|         # msgspec check outbound msgs! |  | ||||||
|         # -[ ] once we implement the receiver side `InvalidMsg` |  | ||||||
|         #   then we can expect it here? |  | ||||||
|         # assert ( |  | ||||||
|         #     len(sent) |  | ||||||
|         #     == |  | ||||||
|         #     len([val |  | ||||||
|         #          for val, expect in |  | ||||||
|         #          expect_ipc_send.values() |  | ||||||
|         #          if expect is True]) |  | ||||||
|         # ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def ex_func(*args): |  | ||||||
|     print(f'ex_func({args})') |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @pytest.mark.parametrize( |  | ||||||
|     'ipc_pld_spec', |  | ||||||
|     [ |  | ||||||
|         Any, |  | ||||||
|         NamespacePath, |  | ||||||
|         NamespacePath|None,  # the "maybe" spec Bo |  | ||||||
|     ], |  | ||||||
|     ids=[ |  | ||||||
|         'any_type', |  | ||||||
|         'nsp_type', |  | ||||||
|         'maybe_nsp_type', |  | ||||||
|     ] |  | ||||||
| ) |  | ||||||
| @pytest.mark.parametrize( |  | ||||||
|     'add_codec_hooks', |  | ||||||
|     [ |  | ||||||
|         True, |  | ||||||
|         False, |  | ||||||
|     ], |  | ||||||
|     ids=['use_codec_hooks', 'no_codec_hooks'], |  | ||||||
| ) |  | ||||||
| def test_codec_hooks_mod( |  | ||||||
|     debug_mode: bool, |  | ||||||
|     ipc_pld_spec: Union[Type]|Any, |  | ||||||
|     # send_value: None|str|NamespacePath, |  | ||||||
|     add_codec_hooks: bool, |  | ||||||
| ): |  | ||||||
|     ''' |  | ||||||
|     Audit the `.msg.MsgCodec` override apis details given our impl |  | ||||||
|     uses `contextvars` to accomplish per `trio` task codec |  | ||||||
|     application around an inter-proc-task-comms context. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     async def main(): |  | ||||||
|         nsp = NamespacePath.from_ref(ex_func) |  | ||||||
|         send_items: dict[Union, Any] = { |  | ||||||
|             Union[None]: None, |  | ||||||
|             Union[NamespacePath]: nsp, |  | ||||||
|             Union[str]: str(nsp), |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         # init default state for actor |  | ||||||
|         chk_codec_applied( |  | ||||||
|             expect_codec=_codec._def_tractor_codec, |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         async with tractor.open_nursery( |  | ||||||
|             debug_mode=debug_mode, |  | ||||||
|         ) as an: |  | ||||||
|             p: tractor.Portal = await an.start_actor( |  | ||||||
|                 'sub', |  | ||||||
|                 enable_modules=[__name__], |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|             # TODO: 2 cases: |  | ||||||
|             # - codec not modified -> decode nsp as `str` |  | ||||||
|             # - codec modified with hooks -> decode nsp as |  | ||||||
|             #   `NamespacePath` |  | ||||||
|             nsp_codec: MsgCodec = mk_custom_codec( |  | ||||||
|                 pld_spec=ipc_pld_spec, |  | ||||||
|                 add_hooks=add_codec_hooks, |  | ||||||
|             ) |  | ||||||
|             with apply_codec(nsp_codec) as codec: |  | ||||||
|                 chk_codec_applied( |  | ||||||
|                     expect_codec=nsp_codec, |  | ||||||
|                     enter_value=codec, |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|                 expect_ipc_send: dict[str, tuple[Any, bool]] = {} |  | ||||||
| 
 |  | ||||||
|                 report: str = ( |  | ||||||
|                     'Parent report on send values with\n' |  | ||||||
|                     f'ipc_pld_spec: {ipc_pld_spec}\n' |  | ||||||
|                     '       ------ - ------\n' |  | ||||||
|                 ) |  | ||||||
|                 for val_type_str, val, expect_send in iter_maybe_sends( |  | ||||||
|                     send_items, |  | ||||||
|                     ipc_pld_spec, |  | ||||||
|                     add_codec_hooks=add_codec_hooks, |  | ||||||
|                 ): |  | ||||||
|                     report += ( |  | ||||||
|                         f'send_value: {val}: {type(val)} ' |  | ||||||
|                         f'=> expect_send: {expect_send}\n' |  | ||||||
|                     ) |  | ||||||
|                     expect_ipc_send[val_type_str] = (val, expect_send) |  | ||||||
| 
 |  | ||||||
|                 print( |  | ||||||
|                     report + |  | ||||||
|                     '       ------ - ------\n' |  | ||||||
|                 ) |  | ||||||
|                 assert len(expect_ipc_send) == len(send_items) |  | ||||||
|                 # now try over real IPC with a the subactor |  | ||||||
|                 # expect_ipc_rountrip: bool = True |  | ||||||
|                 expected_started = Started( |  | ||||||
|                     cid='cid', |  | ||||||
|                     pld=str(ipc_pld_spec), |  | ||||||
|                 ) |  | ||||||
|                 # build list of values we expect to receive from |  | ||||||
|                 # the subactor. |  | ||||||
|                 expect_to_send: list[Any] = [ |  | ||||||
|                     val |  | ||||||
|                     for val, expect_send in expect_ipc_send.values() |  | ||||||
|                     if expect_send |  | ||||||
|                 ] |  | ||||||
| 
 |  | ||||||
|                 pld_spec_type_strs: list[str] = enc_type_union(ipc_pld_spec) |  | ||||||
| 
 |  | ||||||
|                 # XXX should raise an mte (`MsgTypeError`) |  | ||||||
|                 # when `add_codec_hooks == False` bc the input |  | ||||||
|                 # `expect_ipc_send` kwarg has a nsp which can't be |  | ||||||
|                 # serialized! |  | ||||||
|                 # |  | ||||||
|                 # TODO:can we ensure this happens from the |  | ||||||
|                 # `Return`-side (aka the sub) as well? |  | ||||||
|                 if not add_codec_hooks: |  | ||||||
|                     try: |  | ||||||
|                         async with p.open_context( |  | ||||||
|                             send_back_values, |  | ||||||
|                             expect_debug=debug_mode, |  | ||||||
|                             pld_spec_type_strs=pld_spec_type_strs, |  | ||||||
|                             add_hooks=add_codec_hooks, |  | ||||||
|                             started_msg_bytes=nsp_codec.encode(expected_started), |  | ||||||
| 
 |  | ||||||
|                             # XXX NOTE bc we send a `NamespacePath` in this kwarg |  | ||||||
|                             expect_ipc_send=expect_ipc_send, |  | ||||||
| 
 |  | ||||||
|                         ) as (ctx, first): |  | ||||||
|                             pytest.fail('ctx should fail to open without custom enc_hook!?') |  | ||||||
| 
 |  | ||||||
|                     # this test passes bc we can go no further! |  | ||||||
|                     except MsgTypeError: |  | ||||||
|                         # teardown nursery |  | ||||||
|                         await p.cancel_actor() |  | ||||||
|                         return |  | ||||||
| 
 |  | ||||||
|                 # TODO: send the original nsp here and |  | ||||||
|                 # test with `limit_msg_spec()` above? |  | ||||||
|                 # await tractor.pause() |  | ||||||
|                 print('PARENT opening IPC ctx!\n') |  | ||||||
|                 async with ( |  | ||||||
| 
 |  | ||||||
|                     # XXX should raise an mte (`MsgTypeError`) |  | ||||||
|                     # when `add_codec_hooks == False`.. |  | ||||||
|                     p.open_context( |  | ||||||
|                         send_back_values, |  | ||||||
|                         expect_debug=debug_mode, |  | ||||||
|                         pld_spec_type_strs=pld_spec_type_strs, |  | ||||||
|                         add_hooks=add_codec_hooks, |  | ||||||
|                         started_msg_bytes=nsp_codec.encode(expected_started), |  | ||||||
|                         expect_ipc_send=expect_ipc_send, |  | ||||||
|                     ) as (ctx, first), |  | ||||||
| 
 |  | ||||||
|                     ctx.open_stream() as ipc, |  | ||||||
|                 ): |  | ||||||
|                     # ensure codec is still applied across |  | ||||||
|                     # `tractor.Context` + its embedded nursery. |  | ||||||
|                     chk_codec_applied( |  | ||||||
|                         expect_codec=nsp_codec, |  | ||||||
|                         enter_value=codec, |  | ||||||
|                     ) |  | ||||||
|                     print( |  | ||||||
|                         'root: ENTERING CONTEXT BLOCK\n' |  | ||||||
|                         f'type(first): {type(first)}\n' |  | ||||||
|                         f'first: {first}\n' |  | ||||||
|                     ) |  | ||||||
|                     expect_to_send.remove(first) |  | ||||||
| 
 |  | ||||||
|                     # TODO: explicit values we expect depending on |  | ||||||
|                     # codec config! |  | ||||||
|                     # assert first == first_val |  | ||||||
|                     # assert first == f'{__name__}:ex_func' |  | ||||||
| 
 |  | ||||||
|                     async for next_sent in ipc: |  | ||||||
|                         print( |  | ||||||
|                             'Parent: child sent next value\n' |  | ||||||
|                             f'{next_sent}: {type(next_sent)}\n' |  | ||||||
|                         ) |  | ||||||
|                         if expect_to_send: |  | ||||||
|                             expect_to_send.remove(next_sent) |  | ||||||
|                         else: |  | ||||||
|                             print('PARENT should terminate stream loop + block!') |  | ||||||
| 
 |  | ||||||
|                     # all sent values should have arrived! |  | ||||||
|                     assert not expect_to_send |  | ||||||
| 
 |  | ||||||
|             await p.cancel_actor() |  | ||||||
| 
 |  | ||||||
|     trio.run(main) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def chk_pld_type( |  | ||||||
|     payload_spec: Type[Struct]|Any, |  | ||||||
|     pld: Any, |  | ||||||
| 
 |  | ||||||
|     expect_roundtrip: bool|None = None, |  | ||||||
| 
 |  | ||||||
| ) -> bool: |  | ||||||
| 
 |  | ||||||
|     pld_val_type: Type = type(pld) |  | ||||||
| 
 |  | ||||||
|     # TODO: verify that the overridden subtypes |  | ||||||
|     # DO NOT have modified type-annots from original! |  | ||||||
|     # 'Start',  .pld: FuncSpec |  | ||||||
|     # 'StartAck',  .pld: IpcCtxSpec |  | ||||||
|     # 'Stop',  .pld: UNSEt |  | ||||||
|     # 'Error',  .pld: ErrorData |  | ||||||
| 
 |  | ||||||
|     codec: MsgCodec = mk_codec( |  | ||||||
|         # NOTE: this ONLY accepts `PayloadMsg.pld` fields of a specified |  | ||||||
|         # type union. |  | ||||||
|         ipc_pld_spec=payload_spec, |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     # make a one-off dec to compare with our `MsgCodec` instance |  | ||||||
|     # which does the below `mk_msg_spec()` call internally |  | ||||||
|     ipc_msg_spec: Union[Type[Struct]] |  | ||||||
|     msg_types: list[PayloadMsg[payload_spec]] |  | ||||||
|     ( |  | ||||||
|         ipc_msg_spec, |  | ||||||
|         msg_types, |  | ||||||
|     ) = mk_msg_spec( |  | ||||||
|         payload_type_union=payload_spec, |  | ||||||
|     ) |  | ||||||
|     _enc = msgpack.Encoder() |  | ||||||
|     _dec = msgpack.Decoder( |  | ||||||
|         type=ipc_msg_spec or Any,  # like `PayloadMsg[Any]` |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     assert ( |  | ||||||
|         payload_spec |  | ||||||
|         == |  | ||||||
|         codec.pld_spec |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     # assert codec.dec == dec |  | ||||||
|     # |  | ||||||
|     # ^-XXX-^ not sure why these aren't "equal" but when cast |  | ||||||
|     # to `str` they seem to match ?? .. kk |  | ||||||
| 
 |  | ||||||
|     assert ( |  | ||||||
|         str(ipc_msg_spec) |  | ||||||
|         == |  | ||||||
|         str(codec.msg_spec) |  | ||||||
|         == |  | ||||||
|         str(_dec.type) |  | ||||||
|         == |  | ||||||
|         str(codec.dec.type) |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     # verify the boxed-type for all variable payload-type msgs. |  | ||||||
|     if not msg_types: |  | ||||||
|         breakpoint() |  | ||||||
| 
 |  | ||||||
|     roundtrip: bool|None = None |  | ||||||
|     pld_spec_msg_names: list[str] = [ |  | ||||||
|         td.__name__ for td in _payload_msgs |  | ||||||
|     ] |  | ||||||
|     for typedef in msg_types: |  | ||||||
| 
 |  | ||||||
|         skip_runtime_msg: bool = typedef.__name__ not in pld_spec_msg_names |  | ||||||
|         if skip_runtime_msg: |  | ||||||
|             continue |  | ||||||
| 
 |  | ||||||
|         pld_field = structs.fields(typedef)[1] |  | ||||||
|         assert pld_field.type is payload_spec # TODO-^ does this need to work to get all subtypes to adhere? |  | ||||||
| 
 |  | ||||||
|         kwargs: dict[str, Any] = { |  | ||||||
|             'cid': '666', |  | ||||||
|             'pld': pld, |  | ||||||
|         } |  | ||||||
|         enc_msg: PayloadMsg = typedef(**kwargs) |  | ||||||
| 
 |  | ||||||
|         _wire_bytes: bytes = _enc.encode(enc_msg) |  | ||||||
|         wire_bytes: bytes = codec.enc.encode(enc_msg) |  | ||||||
|         assert _wire_bytes == wire_bytes |  | ||||||
| 
 |  | ||||||
|         ve: ValidationError|None = None |  | ||||||
|         try: |  | ||||||
|             dec_msg = codec.dec.decode(wire_bytes) |  | ||||||
|             _dec_msg = _dec.decode(wire_bytes) |  | ||||||
| 
 |  | ||||||
|             # decoded msg and thus payload should be exactly same! |  | ||||||
|             assert (roundtrip := ( |  | ||||||
|                 _dec_msg |  | ||||||
|                 == |  | ||||||
|                 dec_msg |  | ||||||
|                 == |  | ||||||
|                 enc_msg |  | ||||||
|             )) |  | ||||||
| 
 |  | ||||||
|             if ( |  | ||||||
|                 expect_roundtrip is not None |  | ||||||
|                 and expect_roundtrip != roundtrip |  | ||||||
|             ): |  | ||||||
|                 breakpoint() |  | ||||||
| 
 |  | ||||||
|             assert ( |  | ||||||
|                 pld |  | ||||||
|                 == |  | ||||||
|                 dec_msg.pld |  | ||||||
|                 == |  | ||||||
|                 enc_msg.pld |  | ||||||
|             ) |  | ||||||
|             # assert (roundtrip := (_dec_msg == enc_msg)) |  | ||||||
| 
 |  | ||||||
|         except ValidationError as _ve: |  | ||||||
|             ve = _ve |  | ||||||
|             roundtrip: bool = False |  | ||||||
|             if pld_val_type is payload_spec: |  | ||||||
|                 raise ValueError( |  | ||||||
|                    'Got `ValidationError` despite type-var match!?\n' |  | ||||||
|                     f'pld_val_type: {pld_val_type}\n' |  | ||||||
|                     f'payload_type: {payload_spec}\n' |  | ||||||
|                 ) from ve |  | ||||||
| 
 |  | ||||||
|             else: |  | ||||||
|                 # ow we good cuz the pld spec mismatched. |  | ||||||
|                 print( |  | ||||||
|                     'Got expected `ValidationError` since,\n' |  | ||||||
|                     f'{pld_val_type} is not {payload_spec}\n' |  | ||||||
|                 ) |  | ||||||
|         else: |  | ||||||
|             if ( |  | ||||||
|                 payload_spec is not Any |  | ||||||
|                 and |  | ||||||
|                 pld_val_type is not payload_spec |  | ||||||
|             ): |  | ||||||
|                 raise ValueError( |  | ||||||
|                    'DID NOT `ValidationError` despite expected type match!?\n' |  | ||||||
|                     f'pld_val_type: {pld_val_type}\n' |  | ||||||
|                     f'payload_type: {payload_spec}\n' |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|     # full code decode should always be attempted! |  | ||||||
|     if roundtrip is None: |  | ||||||
|         breakpoint() |  | ||||||
| 
 |  | ||||||
|     return roundtrip |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_limit_msgspec( |  | ||||||
|     debug_mode: bool, |  | ||||||
| ): |  | ||||||
|     async def main(): |  | ||||||
|         async with tractor.open_root_actor( |  | ||||||
|             debug_mode=debug_mode, |  | ||||||
|         ): |  | ||||||
|             # ensure we can round-trip a boxing `PayloadMsg` |  | ||||||
|             assert chk_pld_type( |  | ||||||
|                 payload_spec=Any, |  | ||||||
|                 pld=None, |  | ||||||
|                 expect_roundtrip=True, |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|             # verify that a mis-typed payload value won't decode |  | ||||||
|             assert not chk_pld_type( |  | ||||||
|                 payload_spec=int, |  | ||||||
|                 pld='doggy', |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|             # parametrize the boxed `.pld` type as a custom-struct |  | ||||||
|             # and ensure that parametrization propagates |  | ||||||
|             # to all payload-msg-spec-able subtypes! |  | ||||||
|             class CustomPayload(Struct): |  | ||||||
|                 name: str |  | ||||||
|                 value: Any |  | ||||||
| 
 |  | ||||||
|             assert not chk_pld_type( |  | ||||||
|                 payload_spec=CustomPayload, |  | ||||||
|                 pld='doggy', |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|             assert chk_pld_type( |  | ||||||
|                 payload_spec=CustomPayload, |  | ||||||
|                 pld=CustomPayload(name='doggy', value='urmom') |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|             # yah, we can `.pause_from_sync()` now! |  | ||||||
|             # breakpoint() |  | ||||||
| 
 |  | ||||||
|     trio.run(main) |  | ||||||
|  | @ -117,9 +117,10 @@ async def open_actor_local_nursery( | ||||||
|     ctx: tractor.Context, |     ctx: tractor.Context, | ||||||
| ): | ): | ||||||
|     global _nursery |     global _nursery | ||||||
|     async with trio.open_nursery( |     async with ( | ||||||
|         strict_exception_groups=False, |         tractor.trionics.collapse_eg(), | ||||||
|     ) as tn: |         trio.open_nursery() as tn | ||||||
|  |     ): | ||||||
|         _nursery = tn |         _nursery = tn | ||||||
|         await ctx.started() |         await ctx.started() | ||||||
|         await trio.sleep(10) |         await trio.sleep(10) | ||||||
|  |  | ||||||
|  | @ -13,26 +13,24 @@ MESSAGE = 'tractoring at full speed' | ||||||
| def test_empty_mngrs_input_raises() -> None: | def test_empty_mngrs_input_raises() -> None: | ||||||
| 
 | 
 | ||||||
|     async def main(): |     async def main(): | ||||||
|         with trio.fail_after(1): |         with trio.fail_after(3): | ||||||
|             async with ( |             async with ( | ||||||
|                 open_actor_cluster( |                 open_actor_cluster( | ||||||
|                     modules=[__name__], |                     modules=[__name__], | ||||||
| 
 | 
 | ||||||
|                     # NOTE: ensure we can passthrough runtime opts |                     # NOTE: ensure we can passthrough runtime opts | ||||||
|                     loglevel='info', |                     loglevel='cancel', | ||||||
|                     # debug_mode=True, |                     debug_mode=False, | ||||||
| 
 | 
 | ||||||
|                 ) as portals, |                 ) as portals, | ||||||
| 
 | 
 | ||||||
|                 gather_contexts( |                 gather_contexts(mngrs=()), | ||||||
|                     # NOTE: it's the use of inline-generator syntax |  | ||||||
|                     # here that causes the empty input. |  | ||||||
|                     mngrs=( |  | ||||||
|                         p.open_context(worker) for p in portals.values() |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ): |             ): | ||||||
|                 assert 0 |                 # should fail before this? | ||||||
|  |                 assert portals | ||||||
|  | 
 | ||||||
|  |                 # test should fail if we mk it here! | ||||||
|  |                 assert 0, 'Should have raised val-err !?' | ||||||
| 
 | 
 | ||||||
|     with pytest.raises(ValueError): |     with pytest.raises(ValueError): | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
|  |  | ||||||
|  | @ -38,9 +38,9 @@ from tractor._testing import ( | ||||||
| # - standard setup/teardown: | # - standard setup/teardown: | ||||||
| #   ``Portal.open_context()`` starts a new | #   ``Portal.open_context()`` starts a new | ||||||
| #   remote task context in another actor. The target actor's task must | #   remote task context in another actor. The target actor's task must | ||||||
| #   call ``Context.started()`` to unblock this entry on the caller side. | #   call ``Context.started()`` to unblock this entry on the parent side. | ||||||
| #   the callee task executes until complete and returns a final value | #   the child task executes until complete and returns a final value | ||||||
| #   which is delivered to the caller side and retreived via | #   which is delivered to the parent side and retreived via | ||||||
| #   ``Context.result()``. | #   ``Context.result()``. | ||||||
| 
 | 
 | ||||||
| # - cancel termination: | # - cancel termination: | ||||||
|  | @ -170,9 +170,9 @@ async def assert_state(value: bool): | ||||||
|     [False, ValueError, KeyboardInterrupt], |     [False, ValueError, KeyboardInterrupt], | ||||||
| ) | ) | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'callee_blocks_forever', |     'child_blocks_forever', | ||||||
|     [False, True], |     [False, True], | ||||||
|     ids=lambda item: f'callee_blocks_forever={item}' |     ids=lambda item: f'child_blocks_forever={item}' | ||||||
| ) | ) | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'pointlessly_open_stream', |     'pointlessly_open_stream', | ||||||
|  | @ -181,7 +181,7 @@ async def assert_state(value: bool): | ||||||
| ) | ) | ||||||
| def test_simple_context( | def test_simple_context( | ||||||
|     error_parent, |     error_parent, | ||||||
|     callee_blocks_forever, |     child_blocks_forever, | ||||||
|     pointlessly_open_stream, |     pointlessly_open_stream, | ||||||
|     debug_mode: bool, |     debug_mode: bool, | ||||||
| ): | ): | ||||||
|  | @ -204,13 +204,13 @@ def test_simple_context( | ||||||
|                         portal.open_context( |                         portal.open_context( | ||||||
|                             simple_setup_teardown, |                             simple_setup_teardown, | ||||||
|                             data=10, |                             data=10, | ||||||
|                             block_forever=callee_blocks_forever, |                             block_forever=child_blocks_forever, | ||||||
|                         ) as (ctx, sent), |                         ) as (ctx, sent), | ||||||
|                     ): |                     ): | ||||||
|                         assert current_ipc_ctx() is ctx |                         assert current_ipc_ctx() is ctx | ||||||
|                         assert sent == 11 |                         assert sent == 11 | ||||||
| 
 | 
 | ||||||
|                         if callee_blocks_forever: |                         if child_blocks_forever: | ||||||
|                             await portal.run(assert_state, value=True) |                             await portal.run(assert_state, value=True) | ||||||
|                         else: |                         else: | ||||||
|                             assert await ctx.result() == 'yo' |                             assert await ctx.result() == 'yo' | ||||||
|  | @ -220,7 +220,7 @@ def test_simple_context( | ||||||
|                                 if error_parent: |                                 if error_parent: | ||||||
|                                     raise error_parent |                                     raise error_parent | ||||||
| 
 | 
 | ||||||
|                                 if callee_blocks_forever: |                                 if child_blocks_forever: | ||||||
|                                     await ctx.cancel() |                                     await ctx.cancel() | ||||||
|                                 else: |                                 else: | ||||||
|                                     # in this case the stream will send a |                                     # in this case the stream will send a | ||||||
|  | @ -252,16 +252,16 @@ def test_simple_context( | ||||||
|             pass |             pass | ||||||
|         except BaseExceptionGroup as beg: |         except BaseExceptionGroup as beg: | ||||||
|             # XXX: on windows it seems we may have to expect the group error |             # XXX: on windows it seems we may have to expect the group error | ||||||
|             from tractor._exceptions import is_multi_cancelled |             from tractor.trionics import is_multi_cancelled | ||||||
|             assert is_multi_cancelled(beg) |             assert is_multi_cancelled(beg) | ||||||
|     else: |     else: | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'callee_returns_early', |     'child_returns_early', | ||||||
|     [True, False], |     [True, False], | ||||||
|     ids=lambda item: f'callee_returns_early={item}' |     ids=lambda item: f'child_returns_early={item}' | ||||||
| ) | ) | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'cancel_method', |     'cancel_method', | ||||||
|  | @ -273,14 +273,14 @@ def test_simple_context( | ||||||
|     [True, False], |     [True, False], | ||||||
|     ids=lambda item: f'chk_ctx_result_before_exit={item}' |     ids=lambda item: f'chk_ctx_result_before_exit={item}' | ||||||
| ) | ) | ||||||
| def test_caller_cancels( | def test_parent_cancels( | ||||||
|     cancel_method: str, |     cancel_method: str, | ||||||
|     chk_ctx_result_before_exit: bool, |     chk_ctx_result_before_exit: bool, | ||||||
|     callee_returns_early: bool, |     child_returns_early: bool, | ||||||
|     debug_mode: bool, |     debug_mode: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     Verify that when the opening side of a context (aka the caller) |     Verify that when the opening side of a context (aka the parent) | ||||||
|     cancels that context, the ctx does not raise a cancelled when |     cancels that context, the ctx does not raise a cancelled when | ||||||
|     either calling `.result()` or on context exit. |     either calling `.result()` or on context exit. | ||||||
| 
 | 
 | ||||||
|  | @ -294,7 +294,7 @@ def test_caller_cancels( | ||||||
| 
 | 
 | ||||||
|         if ( |         if ( | ||||||
|             cancel_method == 'portal' |             cancel_method == 'portal' | ||||||
|             and not callee_returns_early |             and not child_returns_early | ||||||
|         ): |         ): | ||||||
|             try: |             try: | ||||||
|                 res = await ctx.result() |                 res = await ctx.result() | ||||||
|  | @ -318,7 +318,7 @@ def test_caller_cancels( | ||||||
|                 pytest.fail(f'should not have raised ctxc\n{ctxc}') |                 pytest.fail(f'should not have raised ctxc\n{ctxc}') | ||||||
| 
 | 
 | ||||||
|         # we actually get a result |         # we actually get a result | ||||||
|         if callee_returns_early: |         if child_returns_early: | ||||||
|             assert res == 'yo' |             assert res == 'yo' | ||||||
|             assert ctx.outcome is res |             assert ctx.outcome is res | ||||||
|             assert ctx.maybe_error is None |             assert ctx.maybe_error is None | ||||||
|  | @ -362,14 +362,14 @@ def test_caller_cancels( | ||||||
|             ) |             ) | ||||||
|             timeout: float = ( |             timeout: float = ( | ||||||
|                 0.5 |                 0.5 | ||||||
|                 if not callee_returns_early |                 if not child_returns_early | ||||||
|                 else 2 |                 else 2 | ||||||
|             ) |             ) | ||||||
|             with trio.fail_after(timeout): |             with trio.fail_after(timeout): | ||||||
|                 async with ( |                 async with ( | ||||||
|                     expect_ctxc( |                     expect_ctxc( | ||||||
|                         yay=( |                         yay=( | ||||||
|                             not callee_returns_early |                             not child_returns_early | ||||||
|                             and cancel_method == 'portal' |                             and cancel_method == 'portal' | ||||||
|                         ) |                         ) | ||||||
|                     ), |                     ), | ||||||
|  | @ -377,13 +377,13 @@ def test_caller_cancels( | ||||||
|                     portal.open_context( |                     portal.open_context( | ||||||
|                         simple_setup_teardown, |                         simple_setup_teardown, | ||||||
|                         data=10, |                         data=10, | ||||||
|                         block_forever=not callee_returns_early, |                         block_forever=not child_returns_early, | ||||||
|                     ) as (ctx, sent), |                     ) as (ctx, sent), | ||||||
|                 ): |                 ): | ||||||
| 
 | 
 | ||||||
|                     if callee_returns_early: |                     if child_returns_early: | ||||||
|                         # ensure we block long enough before sending |                         # ensure we block long enough before sending | ||||||
|                         # a cancel such that the callee has already |                         # a cancel such that the child has already | ||||||
|                         # returned it's result. |                         # returned it's result. | ||||||
|                         await trio.sleep(0.5) |                         await trio.sleep(0.5) | ||||||
| 
 | 
 | ||||||
|  | @ -421,7 +421,7 @@ def test_caller_cancels( | ||||||
|             #   which should in turn cause `ctx._scope` to |             #   which should in turn cause `ctx._scope` to | ||||||
|             # catch any cancellation? |             # catch any cancellation? | ||||||
|             if ( |             if ( | ||||||
|                 not callee_returns_early |                 not child_returns_early | ||||||
|                 and cancel_method != 'portal' |                 and cancel_method != 'portal' | ||||||
|             ): |             ): | ||||||
|                 assert not ctx._scope.cancelled_caught |                 assert not ctx._scope.cancelled_caught | ||||||
|  | @ -430,11 +430,11 @@ def test_caller_cancels( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # basic stream terminations: | # basic stream terminations: | ||||||
| # - callee context closes without using stream | # - child context closes without using stream | ||||||
| # - caller context closes without using stream | # - parent context closes without using stream | ||||||
| # - caller context calls `Context.cancel()` while streaming | # - parent context calls `Context.cancel()` while streaming | ||||||
| #   is ongoing resulting in callee being cancelled | #   is ongoing resulting in child being cancelled | ||||||
| # - callee calls `Context.cancel()` while streaming and caller | # - child calls `Context.cancel()` while streaming and parent | ||||||
| #   sees stream terminated in `RemoteActorError` | #   sees stream terminated in `RemoteActorError` | ||||||
| 
 | 
 | ||||||
| # TODO: future possible features | # TODO: future possible features | ||||||
|  | @ -443,7 +443,6 @@ def test_caller_cancels( | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def close_ctx_immediately( | async def close_ctx_immediately( | ||||||
| 
 |  | ||||||
|     ctx: Context, |     ctx: Context, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|  | @ -454,13 +453,24 @@ async def close_ctx_immediately( | ||||||
|     async with ctx.open_stream(): |     async with ctx.open_stream(): | ||||||
|         pass |         pass | ||||||
| 
 | 
 | ||||||
|  |     print('child returning!') | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'parent_send_before_receive', | ||||||
|  |     [ | ||||||
|  |         False, | ||||||
|  |         True, | ||||||
|  |     ], | ||||||
|  |     ids=lambda item: f'child_send_before_receive={item}' | ||||||
|  | ) | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_callee_closes_ctx_after_stream_open( | async def test_child_exits_ctx_after_stream_open( | ||||||
|     debug_mode: bool, |     debug_mode: bool, | ||||||
|  |     parent_send_before_receive: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     callee context closes without using stream. |     child context closes without using stream. | ||||||
| 
 | 
 | ||||||
|     This should result in a msg sequence |     This should result in a msg sequence | ||||||
|     |_<root>_ |     |_<root>_ | ||||||
|  | @ -474,6 +484,9 @@ async def test_callee_closes_ctx_after_stream_open( | ||||||
|     => {'stop': True, 'cid': <str>} |     => {'stop': True, 'cid': <str>} | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|  |     timeout: float = ( | ||||||
|  |         0.5 if not debug_mode else 999 | ||||||
|  |     ) | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         debug_mode=debug_mode, |         debug_mode=debug_mode, | ||||||
|     ) as an: |     ) as an: | ||||||
|  | @ -482,7 +495,7 @@ async def test_callee_closes_ctx_after_stream_open( | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         with trio.fail_after(0.5): |         with trio.fail_after(timeout): | ||||||
|             async with portal.open_context( |             async with portal.open_context( | ||||||
|                 close_ctx_immediately, |                 close_ctx_immediately, | ||||||
| 
 | 
 | ||||||
|  | @ -494,41 +507,56 @@ async def test_callee_closes_ctx_after_stream_open( | ||||||
| 
 | 
 | ||||||
|                 with trio.fail_after(0.4): |                 with trio.fail_after(0.4): | ||||||
|                     async with ctx.open_stream() as stream: |                     async with ctx.open_stream() as stream: | ||||||
|  |                         if parent_send_before_receive: | ||||||
|  |                             print('sending first msg from parent!') | ||||||
|  |                             await stream.send('yo') | ||||||
| 
 | 
 | ||||||
|                         # should fall through since ``StopAsyncIteration`` |                         # should fall through since ``StopAsyncIteration`` | ||||||
|                         # should be raised through translation of |                         # should be raised through translation of | ||||||
|                         # a ``trio.EndOfChannel`` by |                         # a ``trio.EndOfChannel`` by | ||||||
|                         # ``trio.abc.ReceiveChannel.__anext__()`` |                         # ``trio.abc.ReceiveChannel.__anext__()`` | ||||||
|                         async for _ in stream: |                         msg = 10 | ||||||
|  |                         async for msg in stream: | ||||||
|                             # trigger failure if we DO NOT |                             # trigger failure if we DO NOT | ||||||
|                             # get an EOC! |                             # get an EOC! | ||||||
|                             assert 0 |                             assert 0 | ||||||
|                         else: |                         else: | ||||||
|  |                             # never should get anythinig new from | ||||||
|  |                             # the underlying stream | ||||||
|  |                             assert msg == 10 | ||||||
| 
 | 
 | ||||||
|                             # verify stream is now closed |                             # verify stream is now closed | ||||||
|                             try: |                             try: | ||||||
|                                 with trio.fail_after(0.3): |                                 with trio.fail_after(0.3): | ||||||
|  |                                     print('parent trying to `.receive()` on EoC stream!') | ||||||
|                                     await stream.receive() |                                     await stream.receive() | ||||||
|  |                                     assert 0, 'should have raised eoc!?' | ||||||
|                             except trio.EndOfChannel: |                             except trio.EndOfChannel: | ||||||
|  |                                 print('parent got EoC as expected!') | ||||||
|                                 pass |                                 pass | ||||||
|  |                                 # raise | ||||||
| 
 | 
 | ||||||
|                 # TODO: should be just raise the closed resource err |                 # TODO: should be just raise the closed resource err | ||||||
|                 # directly here to enforce not allowing a re-open |                 # directly here to enforce not allowing a re-open | ||||||
|                 # of a stream to the context (at least until a time of |                 # of a stream to the context (at least until a time of | ||||||
|                 # if/when we decide that's a good idea?) |                 # if/when we decide that's a good idea?) | ||||||
|                 try: |                 try: | ||||||
|                     with trio.fail_after(0.5): |                     with trio.fail_after(timeout): | ||||||
|                         async with ctx.open_stream() as stream: |                         async with ctx.open_stream() as stream: | ||||||
|                             pass |                             pass | ||||||
|                 except trio.ClosedResourceError: |                 except trio.ClosedResourceError: | ||||||
|                     pass |                     pass | ||||||
| 
 | 
 | ||||||
|  |                 # if ctx._rx_chan._state.data: | ||||||
|  |                 #     await tractor.pause() | ||||||
|  | 
 | ||||||
|         await portal.cancel_actor() |         await portal.cancel_actor() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def expect_cancelled( | async def expect_cancelled( | ||||||
|     ctx: Context, |     ctx: Context, | ||||||
|  |     send_before_receive: bool = False, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     global _state |     global _state | ||||||
|  | @ -538,6 +566,10 @@ async def expect_cancelled( | ||||||
| 
 | 
 | ||||||
|     try: |     try: | ||||||
|         async with ctx.open_stream() as stream: |         async with ctx.open_stream() as stream: | ||||||
|  | 
 | ||||||
|  |             if send_before_receive: | ||||||
|  |                 await stream.send('yo') | ||||||
|  | 
 | ||||||
|             async for msg in stream: |             async for msg in stream: | ||||||
|                 await stream.send(msg)  # echo server |                 await stream.send(msg)  # echo server | ||||||
| 
 | 
 | ||||||
|  | @ -564,26 +596,49 @@ async def expect_cancelled( | ||||||
|         raise |         raise | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         assert 0, "callee wasn't cancelled !?" |         assert 0, "child wasn't cancelled !?" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'child_send_before_receive', | ||||||
|  |     [ | ||||||
|  |         False, | ||||||
|  |         True, | ||||||
|  |     ], | ||||||
|  |     ids=lambda item: f'child_send_before_receive={item}' | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'rent_wait_for_msg', | ||||||
|  |     [ | ||||||
|  |         False, | ||||||
|  |         True, | ||||||
|  |     ], | ||||||
|  |     ids=lambda item: f'rent_wait_for_msg={item}' | ||||||
|  | ) | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'use_ctx_cancel_method', |     'use_ctx_cancel_method', | ||||||
|     [False, True], |     [ | ||||||
|  |         False, | ||||||
|  |         'pre_stream', | ||||||
|  |         'post_stream_open', | ||||||
|  |         'post_stream_close', | ||||||
|  |     ], | ||||||
|  |     ids=lambda item: f'use_ctx_cancel_method={item}' | ||||||
| ) | ) | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_caller_closes_ctx_after_callee_opens_stream( | async def test_parent_exits_ctx_after_child_enters_stream( | ||||||
|     use_ctx_cancel_method: bool, |     use_ctx_cancel_method: bool|str, | ||||||
|     debug_mode: bool, |     debug_mode: bool, | ||||||
|  |     rent_wait_for_msg: bool, | ||||||
|  |     child_send_before_receive: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     caller context closes without using/opening stream |     Parent-side of IPC context closes without sending on `MsgStream`. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         debug_mode=debug_mode, |         debug_mode=debug_mode, | ||||||
|     ) as an: |     ) as an: | ||||||
| 
 |  | ||||||
|         root: Actor = current_actor() |         root: Actor = current_actor() | ||||||
|         portal = await an.start_actor( |         portal = await an.start_actor( | ||||||
|             'ctx_cancelled', |             'ctx_cancelled', | ||||||
|  | @ -592,41 +647,52 @@ async def test_caller_closes_ctx_after_callee_opens_stream( | ||||||
| 
 | 
 | ||||||
|         async with portal.open_context( |         async with portal.open_context( | ||||||
|             expect_cancelled, |             expect_cancelled, | ||||||
|  |             send_before_receive=child_send_before_receive, | ||||||
|         ) as (ctx, sent): |         ) as (ctx, sent): | ||||||
|             assert sent is None |             assert sent is None | ||||||
| 
 | 
 | ||||||
|             await portal.run(assert_state, value=True) |             await portal.run(assert_state, value=True) | ||||||
| 
 | 
 | ||||||
|             # call `ctx.cancel()` explicitly |             # call `ctx.cancel()` explicitly | ||||||
|             if use_ctx_cancel_method: |             if use_ctx_cancel_method == 'pre_stream': | ||||||
|                 await ctx.cancel() |                 await ctx.cancel() | ||||||
| 
 | 
 | ||||||
|                 # NOTE: means the local side `ctx._scope` will |                 # NOTE: means the local side `ctx._scope` will | ||||||
|                 # have been cancelled by an ctxc ack and thus |                 # have been cancelled by an ctxc ack and thus | ||||||
|                 # `._scope.cancelled_caught` should be set. |                 # `._scope.cancelled_caught` should be set. | ||||||
|                 try: |                 async with ( | ||||||
|  |                     expect_ctxc( | ||||||
|  |                         # XXX: the cause is US since we call | ||||||
|  |                         # `Context.cancel()` just above! | ||||||
|  |                         yay=True, | ||||||
|  | 
 | ||||||
|  |                         # XXX: must be propagated to __aexit__ | ||||||
|  |                         # and should be silently absorbed there | ||||||
|  |                         # since we called `.cancel()` just above ;) | ||||||
|  |                         reraise=True, | ||||||
|  |                     ) as maybe_ctxc, | ||||||
|  |                 ): | ||||||
|                     async with ctx.open_stream() as stream: |                     async with ctx.open_stream() as stream: | ||||||
|                         async for msg in stream: |  | ||||||
|                             pass |  | ||||||
| 
 | 
 | ||||||
|                 except tractor.ContextCancelled as ctxc: |                         if rent_wait_for_msg: | ||||||
|                     # XXX: the cause is US since we call |                             async for msg in stream: | ||||||
|                     # `Context.cancel()` just above! |                                 print(f'PARENT rx: {msg!r}\n') | ||||||
|                     assert ( |                                 break | ||||||
|                         ctxc.canceller |  | ||||||
|                         == |  | ||||||
|                         current_actor().uid |  | ||||||
|                         == |  | ||||||
|                         root.uid |  | ||||||
|                     ) |  | ||||||
| 
 | 
 | ||||||
|                     # XXX: must be propagated to __aexit__ |                         if use_ctx_cancel_method == 'post_stream_open': | ||||||
|                     # and should be silently absorbed there |                             await ctx.cancel() | ||||||
|                     # since we called `.cancel()` just above ;) |  | ||||||
|                     raise |  | ||||||
| 
 | 
 | ||||||
|                 else: |                     if use_ctx_cancel_method == 'post_stream_close': | ||||||
|                     assert 0, "Should have context cancelled?" |                         await ctx.cancel() | ||||||
|  | 
 | ||||||
|  |                 ctxc: tractor.ContextCancelled = maybe_ctxc.value | ||||||
|  |                 assert ( | ||||||
|  |                     ctxc.canceller | ||||||
|  |                     == | ||||||
|  |                     current_actor().uid | ||||||
|  |                     == | ||||||
|  |                     root.uid | ||||||
|  |                 ) | ||||||
| 
 | 
 | ||||||
|                 # channel should still be up |                 # channel should still be up | ||||||
|                 assert portal.channel.connected() |                 assert portal.channel.connected() | ||||||
|  | @ -637,13 +703,20 @@ async def test_caller_closes_ctx_after_callee_opens_stream( | ||||||
|                     value=False, |                     value=False, | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|  |             # XXX CHILD-BLOCKS case, we SHOULD NOT exit from the | ||||||
|  |             # `.open_context()` before the child has returned, | ||||||
|  |             # errored or been cancelled! | ||||||
|             else: |             else: | ||||||
|                 try: |                 try: | ||||||
|                     with trio.fail_after(0.2): |                     with trio.fail_after( | ||||||
|                         await ctx.result() |                         0.5  # if not debug_mode else 999 | ||||||
|  |                     ): | ||||||
|  |                         res = await ctx.wait_for_result() | ||||||
|  |                         assert res is not tractor._context.Unresolved | ||||||
|                         assert 0, "Callee should have blocked!?" |                         assert 0, "Callee should have blocked!?" | ||||||
|                 except trio.TooSlowError: |                 except trio.TooSlowError: | ||||||
|                     # NO-OP -> since already called above |                     # NO-OP -> since already triggered by | ||||||
|  |                     # `trio.fail_after()` above! | ||||||
|                     await ctx.cancel() |                     await ctx.cancel() | ||||||
| 
 | 
 | ||||||
|         # NOTE: local scope should have absorbed the cancellation since |         # NOTE: local scope should have absorbed the cancellation since | ||||||
|  | @ -683,7 +756,7 @@ async def test_caller_closes_ctx_after_callee_opens_stream( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_multitask_caller_cancels_from_nonroot_task( | async def test_multitask_parent_cancels_from_nonroot_task( | ||||||
|     debug_mode: bool, |     debug_mode: bool, | ||||||
| ): | ): | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|  | @ -735,7 +808,6 @@ async def test_multitask_caller_cancels_from_nonroot_task( | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def cancel_self( | async def cancel_self( | ||||||
| 
 |  | ||||||
|     ctx: Context, |     ctx: Context, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|  | @ -775,11 +847,11 @@ async def cancel_self( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_callee_cancels_before_started( | async def test_child_cancels_before_started( | ||||||
|     debug_mode: bool, |     debug_mode: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     Callee calls `Context.cancel()` while streaming and caller |     Callee calls `Context.cancel()` while streaming and parent | ||||||
|     sees stream terminated in `ContextCancelled`. |     sees stream terminated in `ContextCancelled`. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|  | @ -826,14 +898,13 @@ async def never_open_stream( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def keep_sending_from_callee( | async def keep_sending_from_child( | ||||||
| 
 |  | ||||||
|     ctx:  Context, |     ctx:  Context, | ||||||
|     msg_buffer_size: int|None = None, |     msg_buffer_size: int|None = None, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|     Send endlessly on the calleee stream. |     Send endlessly on the child stream. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     await ctx.started() |     await ctx.started() | ||||||
|  | @ -841,7 +912,7 @@ async def keep_sending_from_callee( | ||||||
|         msg_buffer_size=msg_buffer_size, |         msg_buffer_size=msg_buffer_size, | ||||||
|     ) as stream: |     ) as stream: | ||||||
|         for msg in count(): |         for msg in count(): | ||||||
|             print(f'callee sending {msg}') |             print(f'child sending {msg}') | ||||||
|             await stream.send(msg) |             await stream.send(msg) | ||||||
|             await trio.sleep(0.01) |             await trio.sleep(0.01) | ||||||
| 
 | 
 | ||||||
|  | @ -849,12 +920,12 @@ async def keep_sending_from_callee( | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'overrun_by', |     'overrun_by', | ||||||
|     [ |     [ | ||||||
|         ('caller', 1, never_open_stream), |         ('parent', 1, never_open_stream), | ||||||
|         ('callee', 0, keep_sending_from_callee), |         ('child', 0, keep_sending_from_child), | ||||||
|     ], |     ], | ||||||
|     ids=[ |     ids=[ | ||||||
|          ('caller_1buf_never_open_stream'), |          ('parent_1buf_never_open_stream'), | ||||||
|          ('callee_0buf_keep_sending_from_callee'), |          ('child_0buf_keep_sending_from_child'), | ||||||
|     ] |     ] | ||||||
| ) | ) | ||||||
| def test_one_end_stream_not_opened( | def test_one_end_stream_not_opened( | ||||||
|  | @ -885,8 +956,7 @@ def test_one_end_stream_not_opened( | ||||||
|                 ) as (ctx, sent): |                 ) as (ctx, sent): | ||||||
|                     assert sent is None |                     assert sent is None | ||||||
| 
 | 
 | ||||||
|                     if 'caller' in overrunner: |                     if 'parent' in overrunner: | ||||||
| 
 |  | ||||||
|                         async with ctx.open_stream() as stream: |                         async with ctx.open_stream() as stream: | ||||||
| 
 | 
 | ||||||
|                             # itersend +1 msg more then the buffer size |                             # itersend +1 msg more then the buffer size | ||||||
|  | @ -901,7 +971,7 @@ def test_one_end_stream_not_opened( | ||||||
|                                 await trio.sleep_forever() |                                 await trio.sleep_forever() | ||||||
| 
 | 
 | ||||||
|                     else: |                     else: | ||||||
|                         # callee overruns caller case so we do nothing here |                         # child overruns parent case so we do nothing here | ||||||
|                         await trio.sleep_forever() |                         await trio.sleep_forever() | ||||||
| 
 | 
 | ||||||
|             await portal.cancel_actor() |             await portal.cancel_actor() | ||||||
|  | @ -909,19 +979,19 @@ def test_one_end_stream_not_opened( | ||||||
|     # 2 overrun cases and the no overrun case (which pushes right up to |     # 2 overrun cases and the no overrun case (which pushes right up to | ||||||
|     # the msg limit) |     # the msg limit) | ||||||
|     if ( |     if ( | ||||||
|         overrunner == 'caller' |         overrunner == 'parent' | ||||||
|     ): |     ): | ||||||
|         with pytest.raises(tractor.RemoteActorError) as excinfo: |         with pytest.raises(tractor.RemoteActorError) as excinfo: | ||||||
|             trio.run(main) |             trio.run(main) | ||||||
| 
 | 
 | ||||||
|         assert excinfo.value.boxed_type == StreamOverrun |         assert excinfo.value.boxed_type == StreamOverrun | ||||||
| 
 | 
 | ||||||
|     elif overrunner == 'callee': |     elif overrunner == 'child': | ||||||
|         with pytest.raises(tractor.RemoteActorError) as excinfo: |         with pytest.raises(tractor.RemoteActorError) as excinfo: | ||||||
|             trio.run(main) |             trio.run(main) | ||||||
| 
 | 
 | ||||||
|         # TODO: embedded remote errors so that we can verify the source |         # TODO: embedded remote errors so that we can verify the source | ||||||
|         # error? the callee delivers an error which is an overrun |         # error? the child delivers an error which is an overrun | ||||||
|         # wrapped in a remote actor error. |         # wrapped in a remote actor error. | ||||||
|         assert excinfo.value.boxed_type == tractor.RemoteActorError |         assert excinfo.value.boxed_type == tractor.RemoteActorError | ||||||
| 
 | 
 | ||||||
|  | @ -931,8 +1001,7 @@ def test_one_end_stream_not_opened( | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def echo_back_sequence( | async def echo_back_sequence( | ||||||
| 
 |     ctx: Context, | ||||||
|     ctx:  Context, |  | ||||||
|     seq: list[int], |     seq: list[int], | ||||||
|     wait_for_cancel: bool, |     wait_for_cancel: bool, | ||||||
|     allow_overruns_side: str, |     allow_overruns_side: str, | ||||||
|  | @ -941,12 +1010,12 @@ async def echo_back_sequence( | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|     Send endlessly on the calleee stream using a small buffer size |     Send endlessly on the child stream using a small buffer size | ||||||
|     setting on the contex to simulate backlogging that would normally |     setting on the contex to simulate backlogging that would normally | ||||||
|     cause overruns. |     cause overruns. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     # NOTE: ensure that if the caller is expecting to cancel this task |     # NOTE: ensure that if the parent is expecting to cancel this task | ||||||
|     # that we stay echoing much longer then they are so we don't |     # that we stay echoing much longer then they are so we don't | ||||||
|     # return early instead of receive the cancel msg. |     # return early instead of receive the cancel msg. | ||||||
|     total_batches: int = ( |     total_batches: int = ( | ||||||
|  | @ -996,18 +1065,18 @@ async def echo_back_sequence( | ||||||
|                 if be_slow: |                 if be_slow: | ||||||
|                     await trio.sleep(0.05) |                     await trio.sleep(0.05) | ||||||
| 
 | 
 | ||||||
|                 print('callee waiting on next') |                 print('child waiting on next') | ||||||
| 
 | 
 | ||||||
|             print(f'callee echoing back latest batch\n{batch}') |             print(f'child echoing back latest batch\n{batch}') | ||||||
|             for msg in batch: |             for msg in batch: | ||||||
|                 print(f'callee sending msg\n{msg}') |                 print(f'child sending msg\n{msg}') | ||||||
|                 await stream.send(msg) |                 await stream.send(msg) | ||||||
| 
 | 
 | ||||||
|     try: |     try: | ||||||
|         return 'yo' |         return 'yo' | ||||||
|     finally: |     finally: | ||||||
|         print( |         print( | ||||||
|             'exiting callee with context:\n' |             'exiting child with context:\n' | ||||||
|             f'{pformat(ctx)}\n' |             f'{pformat(ctx)}\n' | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  | @ -1061,7 +1130,7 @@ def test_maybe_allow_overruns_stream( | ||||||
|             debug_mode=debug_mode, |             debug_mode=debug_mode, | ||||||
|         ) as an: |         ) as an: | ||||||
|             portal = await an.start_actor( |             portal = await an.start_actor( | ||||||
|                 'callee_sends_forever', |                 'child_sends_forever', | ||||||
|                 enable_modules=[__name__], |                 enable_modules=[__name__], | ||||||
|                 loglevel=loglevel, |                 loglevel=loglevel, | ||||||
|                 debug_mode=debug_mode, |                 debug_mode=debug_mode, | ||||||
|  |  | ||||||
|  | @ -7,8 +7,11 @@ 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.trionics import collapse_eg | ||||||
| from tractor._testing import tractor_test | from tractor._testing import tractor_test | ||||||
| import trio | import trio | ||||||
| 
 | 
 | ||||||
|  | @ -26,7 +29,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 +155,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,30 +189,30 @@ 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 ( | ||||||
|                         strict_exception_groups=False, |                         collapse_eg(), | ||||||
|                     ) as trion: |                         trio.open_nursery() as trion, | ||||||
| 
 |                     ): | ||||||
|                         portals = {} |                         portals = {} | ||||||
|                         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 +245,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 +262,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 +272,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 +289,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 +321,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) | ||||||
| 
 | 
 | ||||||
|  | @ -318,11 +339,12 @@ async def close_chans_before_nursery( | ||||||
|                         async with portal2.open_stream_from( |                         async with portal2.open_stream_from( | ||||||
|                             stream_forever |                             stream_forever | ||||||
|                         ) as agen2: |                         ) as agen2: | ||||||
|                             async with trio.open_nursery( |                             async with ( | ||||||
|                                 strict_exception_groups=False, |                                 collapse_eg(), | ||||||
|                             ) as n: |                                 trio.open_nursery() as tn, | ||||||
|                                 n.start_soon(streamer, agen1) |                             ): | ||||||
|                                 n.start_soon(cancel, use_signal, .5) |                                 tn.start_soon(streamer, agen1) | ||||||
|  |                                 tn.start_soon(cancel, use_signal, .5) | ||||||
|                                 try: |                                 try: | ||||||
|                                     await streamer(agen2) |                                     await streamer(agen2) | ||||||
|                                 finally: |                                 finally: | ||||||
|  | @ -373,7 +395,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: | ||||||
|  |  | ||||||
|  | @ -0,0 +1,946 @@ | ||||||
|  | ''' | ||||||
|  | Low-level functional audits for our | ||||||
|  | "capability based messaging"-spec feats. | ||||||
|  | 
 | ||||||
|  | B~) | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from contextlib import ( | ||||||
|  |     contextmanager as cm, | ||||||
|  |     # nullcontext, | ||||||
|  | ) | ||||||
|  | import importlib | ||||||
|  | from typing import ( | ||||||
|  |     Any, | ||||||
|  |     Type, | ||||||
|  |     Union, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from msgspec import ( | ||||||
|  |     # structs, | ||||||
|  |     # msgpack, | ||||||
|  |     Raw, | ||||||
|  |     # Struct, | ||||||
|  |     ValidationError, | ||||||
|  | ) | ||||||
|  | import pytest | ||||||
|  | import trio | ||||||
|  | 
 | ||||||
|  | import tractor | ||||||
|  | from tractor import ( | ||||||
|  |     Actor, | ||||||
|  |     # _state, | ||||||
|  |     MsgTypeError, | ||||||
|  |     Context, | ||||||
|  | ) | ||||||
|  | from tractor.msg import ( | ||||||
|  |     _codec, | ||||||
|  |     _ctxvar_MsgCodec, | ||||||
|  |     _exts, | ||||||
|  | 
 | ||||||
|  |     NamespacePath, | ||||||
|  |     MsgCodec, | ||||||
|  |     MsgDec, | ||||||
|  |     mk_codec, | ||||||
|  |     mk_dec, | ||||||
|  |     apply_codec, | ||||||
|  |     current_codec, | ||||||
|  | ) | ||||||
|  | from tractor.msg.types import ( | ||||||
|  |     log, | ||||||
|  |     Started, | ||||||
|  |     # _payload_msgs, | ||||||
|  |     # PayloadMsg, | ||||||
|  |     # mk_msg_spec, | ||||||
|  | ) | ||||||
|  | from tractor.msg._ops import ( | ||||||
|  |     limit_plds, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | def enc_nsp(obj: Any) -> Any: | ||||||
|  |     actor: Actor = tractor.current_actor( | ||||||
|  |         err_on_no_runtime=False, | ||||||
|  |     ) | ||||||
|  |     uid: tuple[str, str]|None = None if not actor else actor.uid | ||||||
|  |     print(f'{uid} ENC HOOK') | ||||||
|  | 
 | ||||||
|  |     match obj: | ||||||
|  |         # case NamespacePath()|str(): | ||||||
|  |         case NamespacePath(): | ||||||
|  |             encoded: str = str(obj) | ||||||
|  |             print( | ||||||
|  |                 f'----- ENCODING `NamespacePath` as `str` ------\n' | ||||||
|  |                 f'|_obj:{type(obj)!r} = {obj!r}\n' | ||||||
|  |                 f'|_encoded: str = {encoded!r}\n' | ||||||
|  |             ) | ||||||
|  |             # if type(obj) != NamespacePath: | ||||||
|  |             #     breakpoint() | ||||||
|  |             return encoded | ||||||
|  |         case _: | ||||||
|  |             logmsg: str = ( | ||||||
|  |                 f'{uid}\n' | ||||||
|  |                 'FAILED ENCODE\n' | ||||||
|  |                 f'obj-> `{obj}: {type(obj)}`\n' | ||||||
|  |             ) | ||||||
|  |             raise NotImplementedError(logmsg) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def dec_nsp( | ||||||
|  |     obj_type: Type, | ||||||
|  |     obj: Any, | ||||||
|  | 
 | ||||||
|  | ) -> Any: | ||||||
|  |     # breakpoint() | ||||||
|  |     actor: Actor = tractor.current_actor( | ||||||
|  |         err_on_no_runtime=False, | ||||||
|  |     ) | ||||||
|  |     uid: tuple[str, str]|None = None if not actor else actor.uid | ||||||
|  |     print( | ||||||
|  |         f'{uid}\n' | ||||||
|  |         'CUSTOM DECODE\n' | ||||||
|  |         f'type-arg-> {obj_type}\n' | ||||||
|  |         f'obj-arg-> `{obj}`: {type(obj)}\n' | ||||||
|  |     ) | ||||||
|  |     nsp = None | ||||||
|  |     # XXX, never happens right? | ||||||
|  |     if obj_type is Raw: | ||||||
|  |         breakpoint() | ||||||
|  | 
 | ||||||
|  |     if ( | ||||||
|  |         obj_type is NamespacePath | ||||||
|  |         and isinstance(obj, str) | ||||||
|  |         and ':' in obj | ||||||
|  |     ): | ||||||
|  |         nsp = NamespacePath(obj) | ||||||
|  |         # TODO: we could built a generic handler using | ||||||
|  |         # JUST matching the obj_type part? | ||||||
|  |         # nsp = obj_type(obj) | ||||||
|  | 
 | ||||||
|  |     if nsp: | ||||||
|  |         print(f'Returning NSP instance: {nsp}') | ||||||
|  |         return nsp | ||||||
|  | 
 | ||||||
|  |     logmsg: str = ( | ||||||
|  |         f'{uid}\n' | ||||||
|  |         'FAILED DECODE\n' | ||||||
|  |         f'type-> {obj_type}\n' | ||||||
|  |         f'obj-arg-> `{obj}`: {type(obj)}\n\n' | ||||||
|  |         f'current codec:\n' | ||||||
|  |         f'{current_codec()}\n' | ||||||
|  |     ) | ||||||
|  |     # TODO: figure out the ignore subsys for this! | ||||||
|  |     # -[ ] option whether to defense-relay backc the msg | ||||||
|  |     #   inside an `Invalid`/`Ignore` | ||||||
|  |     # -[ ] how to make this handling pluggable such that a | ||||||
|  |     #   `Channel`/`MsgTransport` can intercept and process | ||||||
|  |     #   back msgs either via exception handling or some other | ||||||
|  |     #   signal? | ||||||
|  |     log.warning(logmsg) | ||||||
|  |     # NOTE: this delivers the invalid | ||||||
|  |     # value up to `msgspec`'s decoding | ||||||
|  |     # machinery for error raising. | ||||||
|  |     return obj | ||||||
|  |     # raise NotImplementedError(logmsg) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def ex_func(*args): | ||||||
|  |     ''' | ||||||
|  |     A mod level func we can ref and load via our `NamespacePath` | ||||||
|  |     python-object pointer `str` subtype. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     print(f'ex_func({args})') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'add_codec_hooks', | ||||||
|  |     [ | ||||||
|  |         True, | ||||||
|  |         False, | ||||||
|  |     ], | ||||||
|  |     ids=['use_codec_hooks', 'no_codec_hooks'], | ||||||
|  | ) | ||||||
|  | def test_custom_extension_types( | ||||||
|  |     debug_mode: bool, | ||||||
|  |     add_codec_hooks: bool | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify that a `MsgCodec` (used for encoding all outbound IPC msgs | ||||||
|  |     and decoding all inbound `PayloadMsg`s) and a paired `MsgDec` | ||||||
|  |     (used for decoding the `PayloadMsg.pld: Raw` received within a given | ||||||
|  |     task's ipc `Context` scope) can both send and receive "extension types" | ||||||
|  |     as supported via custom converter hooks passed to `msgspec`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     nsp_pld_dec: MsgDec = mk_dec( | ||||||
|  |         spec=None,  # ONLY support the ext type | ||||||
|  |         dec_hook=dec_nsp if add_codec_hooks else None, | ||||||
|  |         ext_types=[NamespacePath], | ||||||
|  |     ) | ||||||
|  |     nsp_codec: MsgCodec = mk_codec( | ||||||
|  |         # ipc_pld_spec=Raw,  # default! | ||||||
|  | 
 | ||||||
|  |         # NOTE XXX: the encode hook MUST be used no matter what since | ||||||
|  |         # our `NamespacePath` is not any of a `Any` native type nor | ||||||
|  |         # a `msgspec.Struct` subtype - so `msgspec` has no way to know | ||||||
|  |         # how to encode it unless we provide the custom hook. | ||||||
|  |         # | ||||||
|  |         # AGAIN that is, regardless of whether we spec an | ||||||
|  |         # `Any`-decoded-pld the enc has no knowledge (by default) | ||||||
|  |         # how to enc `NamespacePath` (nsp), so we add a custom | ||||||
|  |         # hook to do that ALWAYS. | ||||||
|  |         enc_hook=enc_nsp if add_codec_hooks else None, | ||||||
|  | 
 | ||||||
|  |         # XXX NOTE: pretty sure this is mutex with the `type=` to | ||||||
|  |         # `Decoder`? so it won't work in tandem with the | ||||||
|  |         # `ipc_pld_spec` passed above? | ||||||
|  |         ext_types=[NamespacePath], | ||||||
|  | 
 | ||||||
|  |         # TODO? is it useful to have the `.pld` decoded *prior* to | ||||||
|  |         # the `PldRx`?? like perf or mem related? | ||||||
|  |         # ext_dec=nsp_pld_dec, | ||||||
|  |     ) | ||||||
|  |     if add_codec_hooks: | ||||||
|  |         assert nsp_codec.dec.dec_hook is None | ||||||
|  | 
 | ||||||
|  |         # TODO? if we pass `ext_dec` above? | ||||||
|  |         # assert nsp_codec.dec.dec_hook is dec_nsp | ||||||
|  | 
 | ||||||
|  |         assert nsp_codec.enc.enc_hook is enc_nsp | ||||||
|  | 
 | ||||||
|  |     nsp = NamespacePath.from_ref(ex_func) | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|  |         nsp_bytes: bytes = nsp_codec.encode(nsp) | ||||||
|  |         nsp_rt_sin_msg = nsp_pld_dec.decode(nsp_bytes) | ||||||
|  |         nsp_rt_sin_msg.load_ref() is ex_func | ||||||
|  |     except TypeError: | ||||||
|  |         if not add_codec_hooks: | ||||||
|  |             pass | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|  |         msg_bytes: bytes = nsp_codec.encode( | ||||||
|  |             Started( | ||||||
|  |                 cid='cid', | ||||||
|  |                 pld=nsp, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         # since the ext-type obj should also be set as the msg.pld | ||||||
|  |         assert nsp_bytes in msg_bytes | ||||||
|  |         started_rt: Started = nsp_codec.decode(msg_bytes) | ||||||
|  |         pld: Raw = started_rt.pld | ||||||
|  |         assert isinstance(pld, Raw) | ||||||
|  |         nsp_rt: NamespacePath = nsp_pld_dec.decode(pld) | ||||||
|  |         assert isinstance(nsp_rt, NamespacePath) | ||||||
|  |         # in obj comparison terms they should be the same | ||||||
|  |         assert nsp_rt == nsp | ||||||
|  |         # ensure we've decoded to ext type! | ||||||
|  |         assert nsp_rt.load_ref() is ex_func | ||||||
|  | 
 | ||||||
|  |     except TypeError: | ||||||
|  |         if not add_codec_hooks: | ||||||
|  |             pass | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def sleep_forever_in_sub( | ||||||
|  |     ctx: Context, | ||||||
|  | ) -> None: | ||||||
|  |     await trio.sleep_forever() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def mk_custom_codec( | ||||||
|  |     add_hooks: bool, | ||||||
|  | 
 | ||||||
|  | ) -> tuple[ | ||||||
|  |     MsgCodec,  # encode to send | ||||||
|  |     MsgDec,  # pld receive-n-decode | ||||||
|  | ]: | ||||||
|  |     ''' | ||||||
|  |     Create custom `msgpack` enc/dec-hooks and set a `Decoder` | ||||||
|  |     which only loads `pld_spec` (like `NamespacePath`) types. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  | 
 | ||||||
|  |     # XXX NOTE XXX: despite defining `NamespacePath` as a type | ||||||
|  |     # field on our `PayloadMsg.pld`, we still need a enc/dec_hook() pair | ||||||
|  |     # to cast to/from that type on the wire. See the docs: | ||||||
|  |     # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types | ||||||
|  | 
 | ||||||
|  |     # if pld_spec is Any: | ||||||
|  |     #     pld_spec = Raw | ||||||
|  | 
 | ||||||
|  |     nsp_codec: MsgCodec = mk_codec( | ||||||
|  |         # ipc_pld_spec=Raw,  # default! | ||||||
|  | 
 | ||||||
|  |         # NOTE XXX: the encode hook MUST be used no matter what since | ||||||
|  |         # our `NamespacePath` is not any of a `Any` native type nor | ||||||
|  |         # a `msgspec.Struct` subtype - so `msgspec` has no way to know | ||||||
|  |         # how to encode it unless we provide the custom hook. | ||||||
|  |         # | ||||||
|  |         # AGAIN that is, regardless of whether we spec an | ||||||
|  |         # `Any`-decoded-pld the enc has no knowledge (by default) | ||||||
|  |         # how to enc `NamespacePath` (nsp), so we add a custom | ||||||
|  |         # hook to do that ALWAYS. | ||||||
|  |         enc_hook=enc_nsp if add_hooks else None, | ||||||
|  | 
 | ||||||
|  |         # XXX NOTE: pretty sure this is mutex with the `type=` to | ||||||
|  |         # `Decoder`? so it won't work in tandem with the | ||||||
|  |         # `ipc_pld_spec` passed above? | ||||||
|  |         ext_types=[NamespacePath], | ||||||
|  |     ) | ||||||
|  |     # dec_hook=dec_nsp if add_hooks else None, | ||||||
|  |     return nsp_codec | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'limit_plds_args', | ||||||
|  |     [ | ||||||
|  |         ( | ||||||
|  |             {'dec_hook': None, 'ext_types': None}, | ||||||
|  |             None, | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             {'dec_hook': dec_nsp, 'ext_types': None}, | ||||||
|  |             TypeError, | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             {'dec_hook': dec_nsp, 'ext_types': [NamespacePath]}, | ||||||
|  |             None, | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             {'dec_hook': dec_nsp, 'ext_types': [NamespacePath|None]}, | ||||||
|  |             None, | ||||||
|  |         ), | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'no_hook_no_ext_types', | ||||||
|  |         'only_hook', | ||||||
|  |         'hook_and_ext_types', | ||||||
|  |         'hook_and_ext_types_w_null', | ||||||
|  |     ] | ||||||
|  | ) | ||||||
|  | def test_pld_limiting_usage( | ||||||
|  |     limit_plds_args: tuple[dict, Exception|None], | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify `dec_hook()` and `ext_types` need to either both be | ||||||
|  |     provided or we raise a explanator type-error. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     kwargs, maybe_err = limit_plds_args | ||||||
|  |     async def main(): | ||||||
|  |         async with tractor.open_nursery() as an:  # just to open runtime | ||||||
|  | 
 | ||||||
|  |             # XXX SHOULD NEVER WORK outside an ipc ctx scope! | ||||||
|  |             try: | ||||||
|  |                 with limit_plds(**kwargs): | ||||||
|  |                     pass | ||||||
|  |             except RuntimeError: | ||||||
|  |                 pass | ||||||
|  | 
 | ||||||
|  |             p: tractor.Portal = await an.start_actor( | ||||||
|  |                 'sub', | ||||||
|  |                 enable_modules=[__name__], | ||||||
|  |             ) | ||||||
|  |             async with ( | ||||||
|  |                 p.open_context( | ||||||
|  |                     sleep_forever_in_sub | ||||||
|  |                 ) as (ctx, first), | ||||||
|  |             ): | ||||||
|  |                 try: | ||||||
|  |                     with limit_plds(**kwargs): | ||||||
|  |                         pass | ||||||
|  |                 except maybe_err as exc: | ||||||
|  |                     assert type(exc) is maybe_err | ||||||
|  |                     pass | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def chk_codec_applied( | ||||||
|  |     expect_codec: MsgCodec|None, | ||||||
|  |     enter_value: MsgCodec|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> MsgCodec: | ||||||
|  |     ''' | ||||||
|  |     buncha sanity checks ensuring that the IPC channel's | ||||||
|  |     context-vars are set to the expected codec and that are | ||||||
|  |     ctx-var wrapper APIs match the same. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # TODO: play with tricyle again, bc this is supposed to work | ||||||
|  |     # the way we want? | ||||||
|  |     # | ||||||
|  |     # TreeVar | ||||||
|  |     # task: trio.Task = trio.lowlevel.current_task() | ||||||
|  |     # curr_codec = _ctxvar_MsgCodec.get_in(task) | ||||||
|  | 
 | ||||||
|  |     # ContextVar | ||||||
|  |     # task_ctx: Context = task.context | ||||||
|  |     # assert _ctxvar_MsgCodec in task_ctx | ||||||
|  |     # curr_codec: MsgCodec = task.context[_ctxvar_MsgCodec] | ||||||
|  |     if expect_codec is None: | ||||||
|  |         assert enter_value is None | ||||||
|  |         return | ||||||
|  | 
 | ||||||
|  |     # NOTE: currently we use this! | ||||||
|  |     # RunVar | ||||||
|  |     curr_codec: MsgCodec = current_codec() | ||||||
|  |     last_read_codec = _ctxvar_MsgCodec.get() | ||||||
|  |     # assert curr_codec is last_read_codec | ||||||
|  | 
 | ||||||
|  |     assert ( | ||||||
|  |         (same_codec := expect_codec) is | ||||||
|  |         # returned from `mk_codec()` | ||||||
|  | 
 | ||||||
|  |         # yielded value from `apply_codec()` | ||||||
|  | 
 | ||||||
|  |         # read from current task's `contextvars.Context` | ||||||
|  |         curr_codec is | ||||||
|  |         last_read_codec | ||||||
|  | 
 | ||||||
|  |         # the default `msgspec` settings | ||||||
|  |         is not _codec._def_msgspec_codec | ||||||
|  |         is not _codec._def_tractor_codec | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     if enter_value: | ||||||
|  |         assert enter_value is same_codec | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def send_back_values( | ||||||
|  |     ctx: Context, | ||||||
|  |     rent_pld_spec_type_strs: list[str], | ||||||
|  |     add_hooks: bool, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Setup up a custom codec to load instances of `NamespacePath` | ||||||
|  |     and ensure we can round trip a func ref with our parent. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     uid: tuple = tractor.current_actor().uid | ||||||
|  | 
 | ||||||
|  |     # init state in sub-actor should be default | ||||||
|  |     chk_codec_applied( | ||||||
|  |         expect_codec=_codec._def_tractor_codec, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # load pld spec from input str | ||||||
|  |     rent_pld_spec = _exts.dec_type_union( | ||||||
|  |         rent_pld_spec_type_strs, | ||||||
|  |         mods=[ | ||||||
|  |             importlib.import_module(__name__), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |     rent_pld_spec_types: set[Type] = _codec.unpack_spec_types( | ||||||
|  |         rent_pld_spec, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # ONLY add ext-hooks if the rent specified a non-std type! | ||||||
|  |     add_hooks: bool = ( | ||||||
|  |         NamespacePath in rent_pld_spec_types | ||||||
|  |         and | ||||||
|  |         add_hooks | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # same as on parent side config. | ||||||
|  |     nsp_codec: MsgCodec|None = None | ||||||
|  |     if add_hooks: | ||||||
|  |         nsp_codec = mk_codec( | ||||||
|  |             enc_hook=enc_nsp, | ||||||
|  |             ext_types=[NamespacePath], | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     with ( | ||||||
|  |         maybe_apply_codec(nsp_codec) as codec, | ||||||
|  |         limit_plds( | ||||||
|  |             rent_pld_spec, | ||||||
|  |             dec_hook=dec_nsp if add_hooks else None, | ||||||
|  |             ext_types=[NamespacePath]  if add_hooks else None, | ||||||
|  |         ) as pld_dec, | ||||||
|  |     ): | ||||||
|  |         # ?XXX? SHOULD WE NOT be swapping the global codec since it | ||||||
|  |         # breaks `Context.started()` roundtripping checks?? | ||||||
|  |         chk_codec_applied( | ||||||
|  |             expect_codec=nsp_codec, | ||||||
|  |             enter_value=codec, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # ?TODO, mismatch case(s)? | ||||||
|  |         # | ||||||
|  |         # ensure pld spec matches on both sides | ||||||
|  |         ctx_pld_dec: MsgDec = ctx._pld_rx._pld_dec | ||||||
|  |         assert pld_dec is ctx_pld_dec | ||||||
|  |         child_pld_spec: Type = pld_dec.spec | ||||||
|  |         child_pld_spec_types: set[Type] = _codec.unpack_spec_types( | ||||||
|  |             child_pld_spec, | ||||||
|  |         ) | ||||||
|  |         assert ( | ||||||
|  |             child_pld_spec_types.issuperset( | ||||||
|  |                 rent_pld_spec_types | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # ?TODO, try loop for each of the types in pld-superset? | ||||||
|  |         # | ||||||
|  |         # for send_value in [ | ||||||
|  |         #     nsp, | ||||||
|  |         #     str(nsp), | ||||||
|  |         #     None, | ||||||
|  |         # ]: | ||||||
|  |         nsp = NamespacePath.from_ref(ex_func) | ||||||
|  |         try: | ||||||
|  |             print( | ||||||
|  |                 f'{uid}: attempting to `.started({nsp})`\n' | ||||||
|  |                 f'\n' | ||||||
|  |                 f'rent_pld_spec: {rent_pld_spec}\n' | ||||||
|  |                 f'child_pld_spec: {child_pld_spec}\n' | ||||||
|  |                 f'codec: {codec}\n' | ||||||
|  |             ) | ||||||
|  |             # await tractor.pause() | ||||||
|  |             await ctx.started(nsp) | ||||||
|  | 
 | ||||||
|  |         except tractor.MsgTypeError as _mte: | ||||||
|  |             mte = _mte | ||||||
|  | 
 | ||||||
|  |             # false -ve case | ||||||
|  |             if add_hooks: | ||||||
|  |                 raise RuntimeError( | ||||||
|  |                     f'EXPECTED to `.started()` value given spec ??\n\n' | ||||||
|  |                     f'child_pld_spec -> {child_pld_spec}\n' | ||||||
|  |                     f'value = {nsp}: {type(nsp)}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             # true -ve case | ||||||
|  |             raise mte | ||||||
|  | 
 | ||||||
|  |         # TODO: maybe we should add our own wrapper error so as to | ||||||
|  |         # be interchange-lib agnostic? | ||||||
|  |         # -[ ] the error type is wtv is raised from the hook so we | ||||||
|  |         #   could also require a type-class of errors for | ||||||
|  |         #   indicating whether the hook-failure can be handled by | ||||||
|  |         #   a nasty-dialog-unprot sub-sys? | ||||||
|  |         except TypeError as typerr: | ||||||
|  |             # false -ve | ||||||
|  |             if add_hooks: | ||||||
|  |                 raise RuntimeError('Should have been able to send `nsp`??') | ||||||
|  | 
 | ||||||
|  |             # true -ve | ||||||
|  |             print('Failed to send `nsp` due to no ext hooks set!') | ||||||
|  |             raise typerr | ||||||
|  | 
 | ||||||
|  |         # now try sending a set of valid and invalid plds to ensure | ||||||
|  |         # the pld spec is respected. | ||||||
|  |         sent: list[Any] = [] | ||||||
|  |         async with ctx.open_stream() as ipc: | ||||||
|  |             print( | ||||||
|  |                 f'{uid}: streaming all pld types to rent..' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # for send_value, expect_send in iter_send_val_items: | ||||||
|  |             for send_value in [ | ||||||
|  |                 nsp, | ||||||
|  |                 str(nsp), | ||||||
|  |                 None, | ||||||
|  |             ]: | ||||||
|  |                 send_type: Type = type(send_value) | ||||||
|  |                 print( | ||||||
|  |                     f'{uid}: SENDING NEXT pld\n' | ||||||
|  |                     f'send_type: {send_type}\n' | ||||||
|  |                     f'send_value: {send_value}\n' | ||||||
|  |                 ) | ||||||
|  |                 try: | ||||||
|  |                     await ipc.send(send_value) | ||||||
|  |                     sent.append(send_value) | ||||||
|  | 
 | ||||||
|  |                 except ValidationError as valerr: | ||||||
|  |                     print(f'{uid} FAILED TO SEND {send_value}!') | ||||||
|  | 
 | ||||||
|  |                     # false -ve | ||||||
|  |                     if add_hooks: | ||||||
|  |                         raise RuntimeError( | ||||||
|  |                             f'EXPECTED to roundtrip value given spec:\n' | ||||||
|  |                             f'rent_pld_spec -> {rent_pld_spec}\n' | ||||||
|  |                             f'child_pld_spec -> {child_pld_spec}\n' | ||||||
|  |                             f'value = {send_value}: {send_type}\n' | ||||||
|  |                         ) | ||||||
|  | 
 | ||||||
|  |                     # true -ve | ||||||
|  |                     raise valerr | ||||||
|  |                     # continue | ||||||
|  | 
 | ||||||
|  |             else: | ||||||
|  |                 print( | ||||||
|  |                     f'{uid}: finished sending all values\n' | ||||||
|  |                     'Should be exiting stream block!\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         print(f'{uid}: exited streaming block!') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @cm | ||||||
|  | def maybe_apply_codec(codec: MsgCodec|None) -> MsgCodec|None: | ||||||
|  |     if codec is None: | ||||||
|  |         yield None | ||||||
|  |         return | ||||||
|  | 
 | ||||||
|  |     with apply_codec(codec) as codec: | ||||||
|  |         yield codec | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'pld_spec', | ||||||
|  |     [ | ||||||
|  |         Any, | ||||||
|  |         NamespacePath, | ||||||
|  |         NamespacePath|None,  # the "maybe" spec Bo | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'any_type', | ||||||
|  |         'only_nsp_ext', | ||||||
|  |         'maybe_nsp_ext', | ||||||
|  |     ] | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'add_hooks', | ||||||
|  |     [ | ||||||
|  |         True, | ||||||
|  |         False, | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'use_codec_hooks', | ||||||
|  |         'no_codec_hooks', | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | def test_ext_types_over_ipc( | ||||||
|  |     debug_mode: bool, | ||||||
|  |     pld_spec: Union[Type], | ||||||
|  |     add_hooks: bool, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Ensure we can support extension types coverted using | ||||||
|  |     `enc/dec_hook()`s passed to the `.msg.limit_plds()` API | ||||||
|  |     and that sane errors happen when we try do the same without | ||||||
|  |     the codec hooks. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     pld_types: set[Type] = _codec.unpack_spec_types(pld_spec) | ||||||
|  | 
 | ||||||
|  |     async def main(): | ||||||
|  | 
 | ||||||
|  |         # sanity check the default pld-spec beforehand | ||||||
|  |         chk_codec_applied( | ||||||
|  |             expect_codec=_codec._def_tractor_codec, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # extension type we want to send as msg payload | ||||||
|  |         nsp = NamespacePath.from_ref(ex_func) | ||||||
|  | 
 | ||||||
|  |         # ^NOTE, 2 cases: | ||||||
|  |         # - codec hooks noto added -> decode nsp as `str` | ||||||
|  |         # - codec with hooks -> decode nsp as `NamespacePath` | ||||||
|  |         nsp_codec: MsgCodec|None = None | ||||||
|  |         if ( | ||||||
|  |             NamespacePath in pld_types | ||||||
|  |             and | ||||||
|  |             add_hooks | ||||||
|  |         ): | ||||||
|  |             nsp_codec = mk_codec( | ||||||
|  |                 enc_hook=enc_nsp, | ||||||
|  |                 ext_types=[NamespacePath], | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         async with tractor.open_nursery( | ||||||
|  |             debug_mode=debug_mode, | ||||||
|  |         ) as an: | ||||||
|  |             p: tractor.Portal = await an.start_actor( | ||||||
|  |                 'sub', | ||||||
|  |                 enable_modules=[__name__], | ||||||
|  |             ) | ||||||
|  |             with ( | ||||||
|  |                 maybe_apply_codec(nsp_codec) as codec, | ||||||
|  |             ): | ||||||
|  |                 chk_codec_applied( | ||||||
|  |                     expect_codec=nsp_codec, | ||||||
|  |                     enter_value=codec, | ||||||
|  |                 ) | ||||||
|  |                 rent_pld_spec_type_strs: list[str] = _exts.enc_type_union(pld_spec) | ||||||
|  | 
 | ||||||
|  |                 # XXX should raise an mte (`MsgTypeError`) | ||||||
|  |                 # when `add_hooks == False` bc the input | ||||||
|  |                 # `expect_ipc_send` kwarg has a nsp which can't be | ||||||
|  |                 # serialized! | ||||||
|  |                 # | ||||||
|  |                 # TODO:can we ensure this happens from the | ||||||
|  |                 # `Return`-side (aka the sub) as well? | ||||||
|  |                 try: | ||||||
|  |                     ctx: tractor.Context | ||||||
|  |                     ipc: tractor.MsgStream | ||||||
|  |                     async with ( | ||||||
|  | 
 | ||||||
|  |                         # XXX should raise an mte (`MsgTypeError`) | ||||||
|  |                         # when `add_hooks == False`.. | ||||||
|  |                         p.open_context( | ||||||
|  |                             send_back_values, | ||||||
|  |                             # expect_debug=debug_mode, | ||||||
|  |                             rent_pld_spec_type_strs=rent_pld_spec_type_strs, | ||||||
|  |                             add_hooks=add_hooks, | ||||||
|  |                             # expect_ipc_send=expect_ipc_send, | ||||||
|  |                         ) as (ctx, first), | ||||||
|  | 
 | ||||||
|  |                         ctx.open_stream() as ipc, | ||||||
|  |                     ): | ||||||
|  |                         with ( | ||||||
|  |                             limit_plds( | ||||||
|  |                                 pld_spec, | ||||||
|  |                                 dec_hook=dec_nsp if add_hooks else None, | ||||||
|  |                                 ext_types=[NamespacePath]  if add_hooks else None, | ||||||
|  |                             ) as pld_dec, | ||||||
|  |                         ): | ||||||
|  |                             ctx_pld_dec: MsgDec = ctx._pld_rx._pld_dec | ||||||
|  |                             assert pld_dec is ctx_pld_dec | ||||||
|  | 
 | ||||||
|  |                             # if ( | ||||||
|  |                             #     not add_hooks | ||||||
|  |                             #     and | ||||||
|  |                             #     NamespacePath in  | ||||||
|  |                             # ): | ||||||
|  |                             #     pytest.fail('ctx should fail to open without custom enc_hook!?') | ||||||
|  | 
 | ||||||
|  |                             await ipc.send(nsp) | ||||||
|  |                             nsp_rt = await ipc.receive() | ||||||
|  | 
 | ||||||
|  |                             assert nsp_rt == nsp | ||||||
|  |                             assert nsp_rt.load_ref() is ex_func | ||||||
|  | 
 | ||||||
|  |                 # this test passes bc we can go no further! | ||||||
|  |                 except MsgTypeError as mte: | ||||||
|  |                     # if not add_hooks: | ||||||
|  |                     #     # teardown nursery | ||||||
|  |                     #     await p.cancel_actor() | ||||||
|  |                         # return | ||||||
|  | 
 | ||||||
|  |                     raise mte | ||||||
|  | 
 | ||||||
|  |             await p.cancel_actor() | ||||||
|  | 
 | ||||||
|  |     if ( | ||||||
|  |         NamespacePath in pld_types | ||||||
|  |         and | ||||||
|  |         add_hooks | ||||||
|  |     ): | ||||||
|  |         trio.run(main) | ||||||
|  | 
 | ||||||
|  |     else: | ||||||
|  |         with pytest.raises( | ||||||
|  |             expected_exception=tractor.RemoteActorError, | ||||||
|  |         ) as excinfo: | ||||||
|  |             trio.run(main) | ||||||
|  | 
 | ||||||
|  |         exc = excinfo.value | ||||||
|  |         # bc `.started(nsp: NamespacePath)` will raise | ||||||
|  |         assert exc.boxed_type is TypeError | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # def chk_pld_type( | ||||||
|  | #     payload_spec: Type[Struct]|Any, | ||||||
|  | #     pld: Any, | ||||||
|  | 
 | ||||||
|  | #     expect_roundtrip: bool|None = None, | ||||||
|  | 
 | ||||||
|  | # ) -> bool: | ||||||
|  | 
 | ||||||
|  | #     pld_val_type: Type = type(pld) | ||||||
|  | 
 | ||||||
|  | #     # TODO: verify that the overridden subtypes | ||||||
|  | #     # DO NOT have modified type-annots from original! | ||||||
|  | #     # 'Start',  .pld: FuncSpec | ||||||
|  | #     # 'StartAck',  .pld: IpcCtxSpec | ||||||
|  | #     # 'Stop',  .pld: UNSEt | ||||||
|  | #     # 'Error',  .pld: ErrorData | ||||||
|  | 
 | ||||||
|  | #     codec: MsgCodec = mk_codec( | ||||||
|  | #         # NOTE: this ONLY accepts `PayloadMsg.pld` fields of a specified | ||||||
|  | #         # type union. | ||||||
|  | #         ipc_pld_spec=payload_spec, | ||||||
|  | #     ) | ||||||
|  | 
 | ||||||
|  | #     # make a one-off dec to compare with our `MsgCodec` instance | ||||||
|  | #     # which does the below `mk_msg_spec()` call internally | ||||||
|  | #     ipc_msg_spec: Union[Type[Struct]] | ||||||
|  | #     msg_types: list[PayloadMsg[payload_spec]] | ||||||
|  | #     ( | ||||||
|  | #         ipc_msg_spec, | ||||||
|  | #         msg_types, | ||||||
|  | #     ) = mk_msg_spec( | ||||||
|  | #         payload_type_union=payload_spec, | ||||||
|  | #     ) | ||||||
|  | #     _enc = msgpack.Encoder() | ||||||
|  | #     _dec = msgpack.Decoder( | ||||||
|  | #         type=ipc_msg_spec or Any,  # like `PayloadMsg[Any]` | ||||||
|  | #     ) | ||||||
|  | 
 | ||||||
|  | #     assert ( | ||||||
|  | #         payload_spec | ||||||
|  | #         == | ||||||
|  | #         codec.pld_spec | ||||||
|  | #     ) | ||||||
|  | 
 | ||||||
|  | #     # assert codec.dec == dec | ||||||
|  | #     # | ||||||
|  | #     # ^-XXX-^ not sure why these aren't "equal" but when cast | ||||||
|  | #     # to `str` they seem to match ?? .. kk | ||||||
|  | 
 | ||||||
|  | #     assert ( | ||||||
|  | #         str(ipc_msg_spec) | ||||||
|  | #         == | ||||||
|  | #         str(codec.msg_spec) | ||||||
|  | #         == | ||||||
|  | #         str(_dec.type) | ||||||
|  | #         == | ||||||
|  | #         str(codec.dec.type) | ||||||
|  | #     ) | ||||||
|  | 
 | ||||||
|  | #     # verify the boxed-type for all variable payload-type msgs. | ||||||
|  | #     if not msg_types: | ||||||
|  | #         breakpoint() | ||||||
|  | 
 | ||||||
|  | #     roundtrip: bool|None = None | ||||||
|  | #     pld_spec_msg_names: list[str] = [ | ||||||
|  | #         td.__name__ for td in _payload_msgs | ||||||
|  | #     ] | ||||||
|  | #     for typedef in msg_types: | ||||||
|  | 
 | ||||||
|  | #         skip_runtime_msg: bool = typedef.__name__ not in pld_spec_msg_names | ||||||
|  | #         if skip_runtime_msg: | ||||||
|  | #             continue | ||||||
|  | 
 | ||||||
|  | #         pld_field = structs.fields(typedef)[1] | ||||||
|  | #         assert pld_field.type is payload_spec # TODO-^ does this need to work to get all subtypes to adhere? | ||||||
|  | 
 | ||||||
|  | #         kwargs: dict[str, Any] = { | ||||||
|  | #             'cid': '666', | ||||||
|  | #             'pld': pld, | ||||||
|  | #         } | ||||||
|  | #         enc_msg: PayloadMsg = typedef(**kwargs) | ||||||
|  | 
 | ||||||
|  | #         _wire_bytes: bytes = _enc.encode(enc_msg) | ||||||
|  | #         wire_bytes: bytes = codec.enc.encode(enc_msg) | ||||||
|  | #         assert _wire_bytes == wire_bytes | ||||||
|  | 
 | ||||||
|  | #         ve: ValidationError|None = None | ||||||
|  | #         try: | ||||||
|  | #             dec_msg = codec.dec.decode(wire_bytes) | ||||||
|  | #             _dec_msg = _dec.decode(wire_bytes) | ||||||
|  | 
 | ||||||
|  | #             # decoded msg and thus payload should be exactly same! | ||||||
|  | #             assert (roundtrip := ( | ||||||
|  | #                 _dec_msg | ||||||
|  | #                 == | ||||||
|  | #                 dec_msg | ||||||
|  | #                 == | ||||||
|  | #                 enc_msg | ||||||
|  | #             )) | ||||||
|  | 
 | ||||||
|  | #             if ( | ||||||
|  | #                 expect_roundtrip is not None | ||||||
|  | #                 and expect_roundtrip != roundtrip | ||||||
|  | #             ): | ||||||
|  | #                 breakpoint() | ||||||
|  | 
 | ||||||
|  | #             assert ( | ||||||
|  | #                 pld | ||||||
|  | #                 == | ||||||
|  | #                 dec_msg.pld | ||||||
|  | #                 == | ||||||
|  | #                 enc_msg.pld | ||||||
|  | #             ) | ||||||
|  | #             # assert (roundtrip := (_dec_msg == enc_msg)) | ||||||
|  | 
 | ||||||
|  | #         except ValidationError as _ve: | ||||||
|  | #             ve = _ve | ||||||
|  | #             roundtrip: bool = False | ||||||
|  | #             if pld_val_type is payload_spec: | ||||||
|  | #                 raise ValueError( | ||||||
|  | #                    'Got `ValidationError` despite type-var match!?\n' | ||||||
|  | #                     f'pld_val_type: {pld_val_type}\n' | ||||||
|  | #                     f'payload_type: {payload_spec}\n' | ||||||
|  | #                 ) from ve | ||||||
|  | 
 | ||||||
|  | #             else: | ||||||
|  | #                 # ow we good cuz the pld spec mismatched. | ||||||
|  | #                 print( | ||||||
|  | #                     'Got expected `ValidationError` since,\n' | ||||||
|  | #                     f'{pld_val_type} is not {payload_spec}\n' | ||||||
|  | #                 ) | ||||||
|  | #         else: | ||||||
|  | #             if ( | ||||||
|  | #                 payload_spec is not Any | ||||||
|  | #                 and | ||||||
|  | #                 pld_val_type is not payload_spec | ||||||
|  | #             ): | ||||||
|  | #                 raise ValueError( | ||||||
|  | #                    'DID NOT `ValidationError` despite expected type match!?\n' | ||||||
|  | #                     f'pld_val_type: {pld_val_type}\n' | ||||||
|  | #                     f'payload_type: {payload_spec}\n' | ||||||
|  | #                 ) | ||||||
|  | 
 | ||||||
|  | #     # full code decode should always be attempted! | ||||||
|  | #     if roundtrip is None: | ||||||
|  | #         breakpoint() | ||||||
|  | 
 | ||||||
|  | #     return roundtrip | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # ?TODO? maybe remove since covered in the newer `test_pldrx_limiting` | ||||||
|  | # via end-2-end testing of all this? | ||||||
|  | # -[ ] IOW do we really NEED this lowlevel unit testing? | ||||||
|  | # | ||||||
|  | # def test_limit_msgspec( | ||||||
|  | #     debug_mode: bool, | ||||||
|  | # ): | ||||||
|  | #     ''' | ||||||
|  | #     Internals unit testing to verify that type-limiting an IPC ctx's | ||||||
|  | #     msg spec with `Pldrx.limit_plds()` results in various | ||||||
|  | #     encapsulated `msgspec` object settings and state. | ||||||
|  | 
 | ||||||
|  | #     ''' | ||||||
|  | #     async def main(): | ||||||
|  | #         async with tractor.open_root_actor( | ||||||
|  | #             debug_mode=debug_mode, | ||||||
|  | #         ): | ||||||
|  | #             # ensure we can round-trip a boxing `PayloadMsg` | ||||||
|  | #             assert chk_pld_type( | ||||||
|  | #                 payload_spec=Any, | ||||||
|  | #                 pld=None, | ||||||
|  | #                 expect_roundtrip=True, | ||||||
|  | #             ) | ||||||
|  | 
 | ||||||
|  | #             # verify that a mis-typed payload value won't decode | ||||||
|  | #             assert not chk_pld_type( | ||||||
|  | #                 payload_spec=int, | ||||||
|  | #                 pld='doggy', | ||||||
|  | #             ) | ||||||
|  | 
 | ||||||
|  | #             # parametrize the boxed `.pld` type as a custom-struct | ||||||
|  | #             # and ensure that parametrization propagates | ||||||
|  | #             # to all payload-msg-spec-able subtypes! | ||||||
|  | #             class CustomPayload(Struct): | ||||||
|  | #                 name: str | ||||||
|  | #                 value: Any | ||||||
|  | 
 | ||||||
|  | #             assert not chk_pld_type( | ||||||
|  | #                 payload_spec=CustomPayload, | ||||||
|  | #                 pld='doggy', | ||||||
|  | #             ) | ||||||
|  | 
 | ||||||
|  | #             assert chk_pld_type( | ||||||
|  | #                 payload_spec=CustomPayload, | ||||||
|  | #                 pld=CustomPayload(name='doggy', value='urmom') | ||||||
|  | #             ) | ||||||
|  | 
 | ||||||
|  | #             # yah, we can `.pause_from_sync()` now! | ||||||
|  | #             # breakpoint() | ||||||
|  | 
 | ||||||
|  | #     trio.run(main) | ||||||
|  | @ -234,10 +234,8 @@ async def trio_ctx( | ||||||
|     with trio.fail_after(1 + delay): |     with trio.fail_after(1 + delay): | ||||||
|         try: |         try: | ||||||
|             async with ( |             async with ( | ||||||
|                 trio.open_nursery( |                 tractor.trionics.collapse_eg(), | ||||||
|                     # TODO, for new `trio` / py3.13 |                 trio.open_nursery() as tn, | ||||||
|                     # strict_exception_groups=False, |  | ||||||
|                 ) as tn, |  | ||||||
|                 tractor.to_asyncio.open_channel_from( |                 tractor.to_asyncio.open_channel_from( | ||||||
|                     sleep_and_err, |                     sleep_and_err, | ||||||
|                 ) as (first, chan), |                 ) as (first, chan), | ||||||
|  | @ -573,14 +571,16 @@ def test_basic_interloop_channel_stream( | ||||||
|     fan_out: bool, |     fan_out: bool, | ||||||
| ): | ): | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery() as an: |         # TODO, figure out min timeout here! | ||||||
|             portal = await an.run_in_actor( |         with trio.fail_after(6): | ||||||
|                 stream_from_aio, |             async with tractor.open_nursery() as an: | ||||||
|                 infect_asyncio=True, |                 portal = await an.run_in_actor( | ||||||
|                 fan_out=fan_out, |                     stream_from_aio, | ||||||
|             ) |                     infect_asyncio=True, | ||||||
|             # should raise RAE diectly |                     fan_out=fan_out, | ||||||
|             await portal.result() |                 ) | ||||||
|  |                 # should raise RAE diectly | ||||||
|  |                 await portal.result() | ||||||
| 
 | 
 | ||||||
|     trio.run(main) |     trio.run(main) | ||||||
| 
 | 
 | ||||||
|  | @ -889,7 +889,7 @@ async def manage_file( | ||||||
| 
 | 
 | ||||||
|         # NOTE: turns out you don't even need to sched an aio task |         # NOTE: turns out you don't even need to sched an aio task | ||||||
|         # since the original issue, even though seemingly was due to |         # since the original issue, even though seemingly was due to | ||||||
|         # the guest-run being abandoned + a `._debug.pause()` inside |         # the guest-run being abandoned + a `.debug.pause()` inside | ||||||
|         # `._runtime._async_main()` (which was originally trying to |         # `._runtime._async_main()` (which was originally trying to | ||||||
|         # debug the `.lifetime_stack` not closing), IS NOT actually |         # debug the `.lifetime_stack` not closing), IS NOT actually | ||||||
|         # the core issue? |         # the core issue? | ||||||
|  | @ -1088,6 +1088,108 @@ def test_sigint_closes_lifetime_stack( | ||||||
|     trio.run(main) |     trio.run(main) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | # ?TODO asyncio.Task fn-deco? | ||||||
|  | # -[ ] do sig checkingat import time like @context? | ||||||
|  | # -[ ] maybe name it @aio_task ?? | ||||||
|  | # -[ ] chan: to_asyncio.InterloopChannel ?? | ||||||
|  | async def raise_before_started( | ||||||
|  |     # from_trio: asyncio.Queue, | ||||||
|  |     # to_trio: trio.abc.SendChannel, | ||||||
|  |     chan: to_asyncio.LinkedTaskChannel, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     `asyncio.Task` entry point which RTEs before calling | ||||||
|  |     `to_trio.send_nowait()`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     await asyncio.sleep(0.2) | ||||||
|  |     raise RuntimeError('Some shite went wrong before `.send_nowait()`!!') | ||||||
|  | 
 | ||||||
|  |     # to_trio.send_nowait('Uhh we shouldve RTE-d ^^ ??') | ||||||
|  |     chan.started_nowait('Uhh we shouldve RTE-d ^^ ??') | ||||||
|  |     await asyncio.sleep(float('inf')) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def caching_ep( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  | ): | ||||||
|  | 
 | ||||||
|  |     log = tractor.log.get_logger('caching_ep') | ||||||
|  |     log.info('syncing via `ctx.started()`') | ||||||
|  |     await ctx.started() | ||||||
|  | 
 | ||||||
|  |     # XXX, allocate the `open_channel_from()` inside | ||||||
|  |     # a `.trionics.maybe_open_context()`. | ||||||
|  |     chan: to_asyncio.LinkedTaskChannel | ||||||
|  |     async with ( | ||||||
|  |         tractor.trionics.maybe_open_context( | ||||||
|  |             acm_func=tractor.to_asyncio.open_channel_from, | ||||||
|  |             kwargs={ | ||||||
|  |                 'target': raise_before_started, | ||||||
|  |                 # ^XXX, kwarg to `open_channel_from()` | ||||||
|  |             }, | ||||||
|  | 
 | ||||||
|  |             # lock around current actor task access | ||||||
|  |             key=tractor.current_actor().uid, | ||||||
|  | 
 | ||||||
|  |         ) as (cache_hit, (clients, chan)), | ||||||
|  |     ): | ||||||
|  |         if cache_hit: | ||||||
|  |             log.error( | ||||||
|  |                 'Re-using cached `.open_from_channel()` call!\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             log.info( | ||||||
|  |                 'Allocating SHOULD-FAIL `.open_from_channel()`\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         await trio.sleep_forever() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_aio_side_raises_before_started( | ||||||
|  |     reg_addr: tuple[str, int], | ||||||
|  |     debug_mode: bool, | ||||||
|  |     loglevel: str, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Simulates connection-err from `piker.brokers.ib.api`.. | ||||||
|  | 
 | ||||||
|  |     Ensure any error raised by child-`asyncio.Task` BEFORE | ||||||
|  |     `chan.started()` | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # delay = 999 if debug_mode else 1 | ||||||
|  |     async def main(): | ||||||
|  |         with trio.fail_after(3): | ||||||
|  |             an: tractor.ActorNursery | ||||||
|  |             async with tractor.open_nursery( | ||||||
|  |                 debug_mode=debug_mode, | ||||||
|  |                 loglevel=loglevel, | ||||||
|  |             ) as an: | ||||||
|  |                 p: tractor.Portal = await an.start_actor( | ||||||
|  |                     'lchan_cacher_that_raises_fast', | ||||||
|  |                     enable_modules=[__name__], | ||||||
|  |                     infect_asyncio=True, | ||||||
|  |                 ) | ||||||
|  |                 async with p.open_context( | ||||||
|  |                     caching_ep, | ||||||
|  |                 ) as (ctx, first): | ||||||
|  |                     assert not first | ||||||
|  | 
 | ||||||
|  |     with pytest.raises( | ||||||
|  |         expected_exception=(RemoteActorError), | ||||||
|  |     ) as excinfo: | ||||||
|  |         trio.run(main) | ||||||
|  | 
 | ||||||
|  |     # ensure `asyncio.Task` exception is bubbled | ||||||
|  |     # allll the way erp!! | ||||||
|  |     rae = excinfo.value | ||||||
|  |     assert rae.boxed_type is RuntimeError | ||||||
|  | 
 | ||||||
| # TODO: debug_mode tests once we get support for `asyncio`! | # TODO: debug_mode tests once we get support for `asyncio`! | ||||||
| # | # | ||||||
| # -[ ] need tests to wrap both scripts: | # -[ ] need tests to wrap both scripts: | ||||||
|  | @ -1101,7 +1203,7 @@ def test_sigint_closes_lifetime_stack( | ||||||
| #    => completed using `.bestow_portal(task)` inside | #    => completed using `.bestow_portal(task)` inside | ||||||
| #     `.to_asyncio._run_asyncio_task()` right? | #     `.to_asyncio._run_asyncio_task()` right? | ||||||
| #   -[ ] translation func to get from `asyncio` task calling to  | #   -[ ] translation func to get from `asyncio` task calling to  | ||||||
| #     `._debug.wait_for_parent_stdin_hijack()` which does root | #     `.debug.wait_for_parent_stdin_hijack()` which does root | ||||||
| #     call to do TTY locking. | #     call to do TTY locking. | ||||||
| # | # | ||||||
| def test_sync_breakpoint(): | def test_sync_breakpoint(): | ||||||
|  |  | ||||||
|  | @ -410,7 +410,6 @@ def test_peer_canceller( | ||||||
|     ''' |     ''' | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             # NOTE: to halt the peer tasks on ctxc, uncomment this. |  | ||||||
|             debug_mode=debug_mode, |             debug_mode=debug_mode, | ||||||
|         ) as an: |         ) as an: | ||||||
|             canceller: Portal = await an.start_actor( |             canceller: Portal = await an.start_actor( | ||||||
|  | @ -871,7 +870,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!') | ||||||
|  |  | ||||||
|  | @ -235,10 +235,16 @@ async def cancel_after(wait, reg_addr): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.fixture(scope='module') | @pytest.fixture(scope='module') | ||||||
| def time_quad_ex(reg_addr, ci_env, spawn_backend): | def time_quad_ex( | ||||||
|  |     reg_addr: tuple, | ||||||
|  |     ci_env: bool, | ||||||
|  |     spawn_backend: str, | ||||||
|  | ): | ||||||
|     if spawn_backend == 'mp': |     if spawn_backend == 'mp': | ||||||
|         """no idea but the  mp *nix runs are flaking out here often... |         ''' | ||||||
|         """ |         no idea but the  mp *nix runs are flaking out here often... | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|         pytest.skip("Test is too flaky on mp in CI") |         pytest.skip("Test is too flaky on mp in CI") | ||||||
| 
 | 
 | ||||||
|     timeout = 7 if platform.system() in ('Windows', 'Darwin') else 4 |     timeout = 7 if platform.system() in ('Windows', 'Darwin') else 4 | ||||||
|  | @ -249,12 +255,24 @@ def time_quad_ex(reg_addr, ci_env, spawn_backend): | ||||||
|     return results, diff |     return results, diff | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_a_quadruple_example(time_quad_ex, ci_env, spawn_backend): | def test_a_quadruple_example( | ||||||
|     """This also serves as a kind of "we'd like to be this fast test".""" |     time_quad_ex: tuple, | ||||||
|  |     ci_env: bool, | ||||||
|  |     spawn_backend: str, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     This also serves as a kind of "we'd like to be this fast test". | ||||||
| 
 | 
 | ||||||
|  |     ''' | ||||||
|     results, diff = time_quad_ex |     results, diff = time_quad_ex | ||||||
|     assert results |     assert results | ||||||
|     this_fast = 6 if platform.system() in ('Windows', 'Darwin') else 3 |     this_fast = ( | ||||||
|  |         6 if platform.system() in ( | ||||||
|  |             'Windows', | ||||||
|  |             'Darwin', | ||||||
|  |         ) | ||||||
|  |         else 3 | ||||||
|  |     ) | ||||||
|     assert diff < this_fast |     assert diff < this_fast | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,237 @@ | ||||||
|  | ''' | ||||||
|  | Special case testing for issues not (dis)covered in the primary | ||||||
|  | `Context` related functional/scenario suites. | ||||||
|  | 
 | ||||||
|  | **NOTE: this mod is a WIP** space for handling | ||||||
|  | odd/rare/undiscovered/not-yet-revealed faults which either | ||||||
|  | loudly (ideal case) breakl our supervision protocol | ||||||
|  | or (worst case) result in distributed sys hangs. | ||||||
|  | 
 | ||||||
|  | Suites here further try to clarify (if [partially] ill-defined) and | ||||||
|  | verify our edge case semantics for inter-actor-relayed-exceptions | ||||||
|  | including, | ||||||
|  | 
 | ||||||
|  | - lowlevel: what remote obj-data is interchanged for IPC and what is | ||||||
|  |   native-obj form is expected from unpacking in the the new | ||||||
|  |   mem-domain. | ||||||
|  | 
 | ||||||
|  | - which kinds of `RemoteActorError` (and its derivs) are expected by which | ||||||
|  |   (types of) peers (parent, child, sibling, etc) with what | ||||||
|  |   particular meta-data set such as, | ||||||
|  | 
 | ||||||
|  |   - `.src_uid`: the original (maybe) peer who raised. | ||||||
|  |   - `.relay_uid`: the next-hop-peer who sent it. | ||||||
|  |   - `.relay_path`: the sequence of peer actor hops. | ||||||
|  |   - `.is_inception`: a predicate that denotes multi-hop remote errors. | ||||||
|  | 
 | ||||||
|  | - when should `ExceptionGroup`s be relayed from a particular | ||||||
|  |   remote endpoint, they should never be caused by implicit `._rpc` | ||||||
|  |   nursery machinery! | ||||||
|  | 
 | ||||||
|  | - various special `trio` edge cases around its cancellation semantics | ||||||
|  |   and how we (currently) leverage `trio.Cancelled` as a signal for | ||||||
|  |   whether a `Context` task should raise `ContextCancelled` (ctx). | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import pytest | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | from tractor import (  # typing | ||||||
|  |     ActorNursery, | ||||||
|  |     Portal, | ||||||
|  |     Context, | ||||||
|  |     ContextCancelled, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def sleep_n_chkpt_in_finally( | ||||||
|  |     ctx: Context, | ||||||
|  |     sleep_n_raise: bool, | ||||||
|  | 
 | ||||||
|  |     chld_raise_delay: float, | ||||||
|  |     chld_finally_delay: float, | ||||||
|  | 
 | ||||||
|  |     rent_cancels: bool, | ||||||
|  |     rent_ctxc_delay: float, | ||||||
|  | 
 | ||||||
|  |     expect_exc: str|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Sync, open a tn, then wait for cancel, run a chkpt inside | ||||||
|  |     the user's `finally:` teardown. | ||||||
|  | 
 | ||||||
|  |     This covers a footgun case that `trio` core doesn't seem to care about | ||||||
|  |     wherein an exc can be masked by a `trio.Cancelled` raised inside a tn emedded | ||||||
|  |     `finally:`. | ||||||
|  | 
 | ||||||
|  |     Also see `test_trioisms::test_acm_embedded_nursery_propagates_enter_err` | ||||||
|  |     for the down and gritty details. | ||||||
|  | 
 | ||||||
|  |     Since a `@context` endpoint fn can also contain code like this, | ||||||
|  |     **and** bc we currently have no easy way other then | ||||||
|  |     `trio.Cancelled` to signal cancellation on each side of an IPC `Context`, | ||||||
|  |     the footgun issue can compound itself as demonstrated in this suite.. | ||||||
|  | 
 | ||||||
|  |     Here are some edge cases codified with our WIP "sclang" syntax | ||||||
|  |     (note the parent(rent)/child(chld) naming here is just | ||||||
|  |     pragmatism, generally these most of these cases can occurr | ||||||
|  |     regardless of the distributed-task's supervision hiearchy), | ||||||
|  | 
 | ||||||
|  |     - rent c)=> chld.raises-then-taskc-in-finally | ||||||
|  |      |_ chld's body raises an `exc: BaseException`. | ||||||
|  |       _ in its `finally:` block it runs a chkpoint | ||||||
|  |         which raises a taskc (`trio.Cancelled`) which | ||||||
|  |         masks `exc` instead raising taskc up to the first tn. | ||||||
|  |       _ the embedded/chld tn captures the masking taskc and then | ||||||
|  |         raises it up to the ._rpc-ep-tn instead of `exc`. | ||||||
|  |       _ the rent thinks the child ctxc-ed instead of errored.. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     await ctx.started() | ||||||
|  | 
 | ||||||
|  |     if expect_exc: | ||||||
|  |         expect_exc: BaseException = tractor._exceptions.get_err_type( | ||||||
|  |             type_name=expect_exc, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     berr: BaseException|None = None | ||||||
|  |     try: | ||||||
|  |         if not sleep_n_raise: | ||||||
|  |             await trio.sleep_forever() | ||||||
|  |         elif sleep_n_raise: | ||||||
|  | 
 | ||||||
|  |             # XXX this sleep is less then the sleep the parent | ||||||
|  |             # does before calling `ctx.cancel()` | ||||||
|  |             await trio.sleep(chld_raise_delay) | ||||||
|  | 
 | ||||||
|  |             # XXX this will be masked by a taskc raised in | ||||||
|  |             # the `finally:` if this fn doesn't terminate | ||||||
|  |             # before any ctxc-req arrives AND a checkpoint is hit | ||||||
|  |             # in that `finally:`. | ||||||
|  |             raise RuntimeError('my app krurshed..') | ||||||
|  | 
 | ||||||
|  |     except BaseException as _berr: | ||||||
|  |         berr = _berr | ||||||
|  | 
 | ||||||
|  |         # TODO: it'd sure be nice to be able to inject our own | ||||||
|  |         # `ContextCancelled` here instead of of `trio.Cancelled` | ||||||
|  |         # so that our runtime can expect it and this "user code" | ||||||
|  |         # would be able to tell the diff between a generic trio | ||||||
|  |         # cancel and a tractor runtime-IPC cancel. | ||||||
|  |         if expect_exc: | ||||||
|  |             if not isinstance( | ||||||
|  |                 berr, | ||||||
|  |                 expect_exc, | ||||||
|  |             ): | ||||||
|  |                 raise ValueError( | ||||||
|  |                     f'Unexpected exc type ??\n' | ||||||
|  |                     f'{berr!r}\n' | ||||||
|  |                     f'\n' | ||||||
|  |                     f'Expected a {expect_exc!r}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         raise berr | ||||||
|  | 
 | ||||||
|  |     # simulate what user code might try even though | ||||||
|  |     # it's a known boo-boo.. | ||||||
|  |     finally: | ||||||
|  |         # maybe wait for rent ctxc to arrive | ||||||
|  |         with trio.CancelScope(shield=True): | ||||||
|  |             await trio.sleep(chld_finally_delay) | ||||||
|  | 
 | ||||||
|  |         # !!XXX this will raise `trio.Cancelled` which | ||||||
|  |         # will mask the RTE from above!!! | ||||||
|  |         # | ||||||
|  |         # YES, it's the same case as our extant | ||||||
|  |         # `test_trioisms::test_acm_embedded_nursery_propagates_enter_err` | ||||||
|  |         try: | ||||||
|  |             await trio.lowlevel.checkpoint() | ||||||
|  |         except trio.Cancelled as taskc: | ||||||
|  |             if (scope_err := taskc.__context__): | ||||||
|  |                 print( | ||||||
|  |                     f'XXX MASKED REMOTE ERROR XXX\n' | ||||||
|  |                     f'ENDPOINT exception -> {scope_err!r}\n' | ||||||
|  |                     f'will be masked by -> {taskc!r}\n' | ||||||
|  |                 ) | ||||||
|  |                 # await tractor.pause(shield=True) | ||||||
|  | 
 | ||||||
|  |             raise taskc | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'chld_callspec', | ||||||
|  |     [ | ||||||
|  |         dict( | ||||||
|  |             sleep_n_raise=None, | ||||||
|  |             chld_raise_delay=0.1, | ||||||
|  |             chld_finally_delay=0.1, | ||||||
|  |             expect_exc='Cancelled', | ||||||
|  |             rent_cancels=True, | ||||||
|  |             rent_ctxc_delay=0.1, | ||||||
|  |         ), | ||||||
|  |         dict( | ||||||
|  |             sleep_n_raise='RuntimeError', | ||||||
|  |             chld_raise_delay=0.1, | ||||||
|  |             chld_finally_delay=1, | ||||||
|  |             expect_exc='RuntimeError', | ||||||
|  |             rent_cancels=False, | ||||||
|  |             rent_ctxc_delay=0.1, | ||||||
|  |         ), | ||||||
|  |     ], | ||||||
|  |     ids=lambda item: f'chld_callspec={item!r}' | ||||||
|  | ) | ||||||
|  | def test_unmasked_remote_exc( | ||||||
|  |     debug_mode: bool, | ||||||
|  |     chld_callspec: dict, | ||||||
|  |     tpt_proto: str, | ||||||
|  | ): | ||||||
|  |     expect_exc_str: str|None = chld_callspec['sleep_n_raise'] | ||||||
|  |     rent_ctxc_delay: float|None = chld_callspec['rent_ctxc_delay'] | ||||||
|  |     async def main(): | ||||||
|  |         an: ActorNursery | ||||||
|  |         async with tractor.open_nursery( | ||||||
|  |             debug_mode=debug_mode, | ||||||
|  |             enable_transports=[tpt_proto], | ||||||
|  |         ) as an: | ||||||
|  |             ptl: Portal = await an.start_actor( | ||||||
|  |                 'cancellee', | ||||||
|  |                 enable_modules=[__name__], | ||||||
|  |             ) | ||||||
|  |             ctx: Context | ||||||
|  |             async with ( | ||||||
|  |                 ptl.open_context( | ||||||
|  |                     sleep_n_chkpt_in_finally, | ||||||
|  |                     **chld_callspec, | ||||||
|  |                 ) as (ctx, sent), | ||||||
|  |             ): | ||||||
|  |                 assert not sent | ||||||
|  |                 await trio.sleep(rent_ctxc_delay) | ||||||
|  |                 await ctx.cancel() | ||||||
|  | 
 | ||||||
|  |                 # recv error or result from chld | ||||||
|  |                 ctxc: ContextCancelled = await ctx.wait_for_result() | ||||||
|  |                 assert ( | ||||||
|  |                     ctxc is ctx.outcome | ||||||
|  |                     and | ||||||
|  |                     isinstance(ctxc, ContextCancelled) | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             # always graceful terminate the sub in non-error cases | ||||||
|  |             await an.cancel() | ||||||
|  | 
 | ||||||
|  |     if expect_exc_str: | ||||||
|  |         expect_exc: BaseException = tractor._exceptions.get_err_type( | ||||||
|  |             type_name=expect_exc_str, | ||||||
|  |         ) | ||||||
|  |         with pytest.raises( | ||||||
|  |             expected_exception=tractor.RemoteActorError, | ||||||
|  |         ) as excinfo: | ||||||
|  |             trio.run(main) | ||||||
|  | 
 | ||||||
|  |         rae = excinfo.value | ||||||
|  |         assert expect_exc == rae.boxed_type | ||||||
|  | 
 | ||||||
|  |     else: | ||||||
|  |         trio.run(main) | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| ''' | ''' | ||||||
| Async context manager cache api testing: ``trionics.maybe_open_context():`` | Suites for our `.trionics.maybe_open_context()` multi-task | ||||||
|  | shared-cached `@acm` API. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| from contextlib import asynccontextmanager as acm | from contextlib import asynccontextmanager as acm | ||||||
|  | @ -9,6 +10,15 @@ from typing import Awaitable | ||||||
| import pytest | import pytest | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
|  | from tractor.trionics import ( | ||||||
|  |     maybe_open_context, | ||||||
|  | ) | ||||||
|  | from tractor.log import ( | ||||||
|  |     get_console_log, | ||||||
|  |     get_logger, | ||||||
|  | ) | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| _resource: int = 0 | _resource: int = 0 | ||||||
|  | @ -52,7 +62,7 @@ def test_resource_only_entered_once(key_on): | ||||||
|                 # different task names per task will be used |                 # different task names per task will be used | ||||||
|                 kwargs = {'task_name': name} |                 kwargs = {'task_name': name} | ||||||
| 
 | 
 | ||||||
|             async with tractor.trionics.maybe_open_context( |             async with maybe_open_context( | ||||||
|                 maybe_increment_counter, |                 maybe_increment_counter, | ||||||
|                 kwargs=kwargs, |                 kwargs=kwargs, | ||||||
|                 key=key, |                 key=key, | ||||||
|  | @ -72,11 +82,13 @@ def test_resource_only_entered_once(key_on): | ||||||
|         with trio.move_on_after(0.5): |         with trio.move_on_after(0.5): | ||||||
|             async with ( |             async with ( | ||||||
|                 tractor.open_root_actor(), |                 tractor.open_root_actor(), | ||||||
|                 trio.open_nursery() as n, |                 trio.open_nursery() as tn, | ||||||
|             ): |             ): | ||||||
| 
 |  | ||||||
|                 for i in range(10): |                 for i in range(10): | ||||||
|                     n.start_soon(enter_cached_mngr, f'task_{i}') |                     tn.start_soon( | ||||||
|  |                         enter_cached_mngr, | ||||||
|  |                         f'task_{i}', | ||||||
|  |                     ) | ||||||
|                     await trio.sleep(0.001) |                     await trio.sleep(0.001) | ||||||
| 
 | 
 | ||||||
|     trio.run(main) |     trio.run(main) | ||||||
|  | @ -98,27 +110,55 @@ async def streamer( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @acm | @acm | ||||||
| async def open_stream() -> Awaitable[tractor.MsgStream]: | async def open_stream() -> Awaitable[ | ||||||
|  |     tuple[ | ||||||
|  |         tractor.ActorNursery, | ||||||
|  |         tractor.MsgStream, | ||||||
|  |     ] | ||||||
|  | ]: | ||||||
|  |     try: | ||||||
|  |         async with tractor.open_nursery() as an: | ||||||
|  |             portal = await an.start_actor( | ||||||
|  |                 'streamer', | ||||||
|  |                 enable_modules=[__name__], | ||||||
|  |             ) | ||||||
|  |             try: | ||||||
|  |                 async with ( | ||||||
|  |                     portal.open_context(streamer) as (ctx, first), | ||||||
|  |                     ctx.open_stream() as stream, | ||||||
|  |                 ): | ||||||
|  |                     print('Entered open_stream() caller') | ||||||
|  |                     yield an, stream | ||||||
|  |                     print('Exited open_stream() caller') | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_nursery() as tn: |             finally: | ||||||
|         portal = await tn.start_actor('streamer', enable_modules=[__name__]) |                 print( | ||||||
|         async with ( |                     'Cancelling streamer with,\n' | ||||||
|             portal.open_context(streamer) as (ctx, first), |                     '=> `Portal.cancel_actor()`' | ||||||
|             ctx.open_stream() as stream, |                 ) | ||||||
|         ): |                 await portal.cancel_actor() | ||||||
|             yield stream |                 print('Cancelled streamer') | ||||||
| 
 | 
 | ||||||
|         await portal.cancel_actor() |     except Exception as err: | ||||||
|     print('CANCELLED STREAMER') |         print( | ||||||
|  |             f'`open_stream()` errored?\n' | ||||||
|  |             f'{err!r}\n' | ||||||
|  |         ) | ||||||
|  |         await tractor.pause(shield=True) | ||||||
|  |         raise err | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @acm | @acm | ||||||
| async def maybe_open_stream(taskname: str): | async def maybe_open_stream(taskname: str): | ||||||
|     async with tractor.trionics.maybe_open_context( |     async with maybe_open_context( | ||||||
|         # NOTE: all secondary tasks should cache hit on the same key |         # NOTE: all secondary tasks should cache hit on the same key | ||||||
|         acm_func=open_stream, |         acm_func=open_stream, | ||||||
|     ) as (cache_hit, stream): |     ) as ( | ||||||
| 
 |         cache_hit, | ||||||
|  |         (an, stream) | ||||||
|  |     ): | ||||||
|  |         # when the actor + portal + ctx + stream has already been | ||||||
|  |         # allocated we want to just bcast to this task. | ||||||
|         if cache_hit: |         if cache_hit: | ||||||
|             print(f'{taskname} loaded from cache') |             print(f'{taskname} loaded from cache') | ||||||
| 
 | 
 | ||||||
|  | @ -126,27 +166,77 @@ async def maybe_open_stream(taskname: str): | ||||||
|             # if this feed is already allocated by the first |             # if this feed is already allocated by the first | ||||||
|             # task that entereed |             # task that entereed | ||||||
|             async with stream.subscribe() as bstream: |             async with stream.subscribe() as bstream: | ||||||
|                 yield bstream |                 yield an, bstream | ||||||
|  |                 print( | ||||||
|  |                     f'cached task exited\n' | ||||||
|  |                     f')>\n' | ||||||
|  |                     f' |_{taskname}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             # we should always unreg the "cloned" bcrc for this | ||||||
|  |             # consumer-task | ||||||
|  |             assert id(bstream) not in bstream._state.subs | ||||||
|  | 
 | ||||||
|         else: |         else: | ||||||
|             # yield the actual stream |             # yield the actual stream | ||||||
|             yield stream |             try: | ||||||
|  |                 yield an, stream | ||||||
|  |             finally: | ||||||
|  |                 print( | ||||||
|  |                     f'NON-cached task exited\n' | ||||||
|  |                     f')>\n' | ||||||
|  |                     f' |_{taskname}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         first_bstream = stream._broadcaster | ||||||
|  |         bcrx_state = first_bstream._state | ||||||
|  |         subs: dict[int, int] = bcrx_state.subs | ||||||
|  |         if len(subs) == 1: | ||||||
|  |             assert id(first_bstream) in subs | ||||||
|  |             # ^^TODO! the bcrx should always de-allocate all subs, | ||||||
|  |             # including the implicit first one allocated on entry | ||||||
|  |             # by the first subscribing peer task, no? | ||||||
|  |             # | ||||||
|  |             # -[ ] adjust `MsgStream.subscribe()` to do this mgmt! | ||||||
|  |             #  |_ allows reverting `MsgStream.receive()` to the | ||||||
|  |             #    non-bcaster method. | ||||||
|  |             #  |_ we can decide whether to reset `._broadcaster`? | ||||||
|  |             # | ||||||
|  |             # await tractor.pause(shield=True) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 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 | ||||||
|  |         print(f'IN debug_mode, setting large timeout={timeout!r}..') | ||||||
| 
 | 
 | ||||||
|     async def main(): |     async def main(): | ||||||
| 
 | 
 | ||||||
|         full = list(range(1000)) |         full = list(range(1000)) | ||||||
|  |         an: tractor.ActorNursery|None = None | ||||||
|  |         num_tasks: int = 10 | ||||||
| 
 | 
 | ||||||
|         async def get_sub_and_pull(taskname: str): |         async def get_sub_and_pull(taskname: str): | ||||||
|  | 
 | ||||||
|  |             nonlocal an | ||||||
|  | 
 | ||||||
|  |             stream: tractor.MsgStream | ||||||
|             async with ( |             async with ( | ||||||
|                 maybe_open_stream(taskname) as stream, |                 maybe_open_stream(taskname) as ( | ||||||
|  |                     an, | ||||||
|  |                     stream, | ||||||
|  |                 ), | ||||||
|             ): |             ): | ||||||
|                 if '0' in taskname: |                 if '0' in taskname: | ||||||
|                     assert isinstance(stream, tractor.MsgStream) |                     assert isinstance(stream, tractor.MsgStream) | ||||||
|  | @ -158,24 +248,159 @@ def test_open_local_sub_to_stream(): | ||||||
| 
 | 
 | ||||||
|                 first = await stream.receive() |                 first = await stream.receive() | ||||||
|                 print(f'{taskname} started with value {first}') |                 print(f'{taskname} started with value {first}') | ||||||
|                 seq = [] |                 seq: list[int] = [] | ||||||
|                 async for msg in stream: |                 async for msg in stream: | ||||||
|                     seq.append(msg) |                     seq.append(msg) | ||||||
| 
 | 
 | ||||||
|                 assert set(seq).issubset(set(full)) |                 assert set(seq).issubset(set(full)) | ||||||
|  | 
 | ||||||
|  |             # end of @acm block | ||||||
|             print(f'{taskname} finished') |             print(f'{taskname} finished') | ||||||
| 
 | 
 | ||||||
|         with trio.fail_after(timeout): |         root: tractor.Actor | ||||||
|  |         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, | ||||||
|  |                 # maybe_enable_greenback=True, | ||||||
|  |                 # | ||||||
|  |                 # ^TODO? doesn't seem to mk breakpoint() usage work | ||||||
|  |                 # bc each bg task needs to open a portal?? | ||||||
|  |                 # - [ ] we should consider making this part of | ||||||
|  |                 #      our taskman defaults? | ||||||
|  |                 #   |_see https://github.com/goodboy/tractor/pull/363 | ||||||
|  |                 # | ||||||
|  |             ) as root: | ||||||
|  |                 assert root.is_registrar | ||||||
|  | 
 | ||||||
|                 async with ( |                 async with ( | ||||||
|                     trio.open_nursery() as nurse, |                     trio.open_nursery() as tn, | ||||||
|                 ): |                 ): | ||||||
|                     for i in range(10): |                     for i in range(num_tasks): | ||||||
|                         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!') | ||||||
|  | 
 | ||||||
|  |                 # ?XXX, ensure actor-nursery is shutdown or we might | ||||||
|  |                 # hang here due to a minor task deadlock/race-condition? | ||||||
|  |                 # | ||||||
|  |                 # - seems that all we need is a checkpoint to ensure | ||||||
|  |                 #   the last suspended task, which is inside | ||||||
|  |                 #   `.maybe_open_context()`, can do the | ||||||
|  |                 #   `Portal.cancel_actor()` call? | ||||||
|  |                 # | ||||||
|  |                 # - if that bg task isn't resumed, then this blocks | ||||||
|  |                 #   timeout might hit before that? | ||||||
|  |                 # | ||||||
|  |                 if root.ipc_server.has_peers(): | ||||||
|  |                     await trio.lowlevel.checkpoint() | ||||||
|  | 
 | ||||||
|  |                     # alt approach, cancel the entire `an` | ||||||
|  |                     # await tractor.pause() | ||||||
|  |                     # await an.cancel() | ||||||
|  | 
 | ||||||
|  |             # end of runtime scope | ||||||
|  |             print('root actor terminated.') | ||||||
|  | 
 | ||||||
|  |         if cs.cancelled_caught: | ||||||
|  |             pytest.fail( | ||||||
|  |                 'Should NOT time out in `open_root_actor()` ?' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         print('exiting main.') | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @acm | ||||||
|  | async def cancel_outer_cs( | ||||||
|  |     cs: trio.CancelScope|None = None, | ||||||
|  |     delay: float = 0, | ||||||
|  | ): | ||||||
|  |     # on first task delay this enough to block | ||||||
|  |     # the 2nd task but then cancel it mid sleep | ||||||
|  |     # so that the tn.start() inside the key-err handler block | ||||||
|  |     # is cancelled and would previously corrupt the | ||||||
|  |     # mutext state. | ||||||
|  |     log.info(f'task entering sleep({delay})') | ||||||
|  |     await trio.sleep(delay) | ||||||
|  |     if cs: | ||||||
|  |         log.info('task calling cs.cancel()') | ||||||
|  |         cs.cancel() | ||||||
|  |     trio.lowlevel.checkpoint() | ||||||
|  |     yield | ||||||
|  |     await trio.sleep_forever() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_lock_not_corrupted_on_fast_cancel( | ||||||
|  |     debug_mode: bool, | ||||||
|  |     loglevel: str, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify that if the caching-task (the first to enter | ||||||
|  |     `maybe_open_context()`) is cancelled mid-cache-miss, the embedded | ||||||
|  |     mutex can never be left in a corrupted state. | ||||||
|  | 
 | ||||||
|  |     That is, the lock is always eventually released ensuring a peer | ||||||
|  |     (cache-hitting) task will never, | ||||||
|  | 
 | ||||||
|  |     - be left to inf-block/hang on the `lock.acquire()`. | ||||||
|  |     - try to release the lock when still owned by the caching-task | ||||||
|  |       due to it having erronously exited without calling | ||||||
|  |       `lock.release()`. | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     delay: float = 1. | ||||||
|  | 
 | ||||||
|  |     async def use_moc( | ||||||
|  |         cs: trio.CancelScope|None, | ||||||
|  |         delay: float, | ||||||
|  |     ): | ||||||
|  |         log.info('task entering moc') | ||||||
|  |         async with maybe_open_context( | ||||||
|  |             cancel_outer_cs, | ||||||
|  |             kwargs={ | ||||||
|  |                 'cs': cs, | ||||||
|  |                 'delay': delay, | ||||||
|  |             }, | ||||||
|  |         ) as (cache_hit, _null): | ||||||
|  |             if cache_hit: | ||||||
|  |                 log.info('2nd task entered') | ||||||
|  |             else: | ||||||
|  |                 log.info('1st task entered') | ||||||
|  | 
 | ||||||
|  |             await trio.sleep_forever() | ||||||
|  | 
 | ||||||
|  |     async def main(): | ||||||
|  |         with trio.fail_after(delay + 2): | ||||||
|  |             async with ( | ||||||
|  |                 tractor.open_root_actor( | ||||||
|  |                     debug_mode=debug_mode, | ||||||
|  |                     loglevel=loglevel, | ||||||
|  |                 ), | ||||||
|  |                 trio.open_nursery() as tn, | ||||||
|  |             ): | ||||||
|  |                 get_console_log('info') | ||||||
|  |                 log.info('yo starting') | ||||||
|  |                 cs = tn.cancel_scope | ||||||
|  |                 tn.start_soon( | ||||||
|  |                     use_moc, | ||||||
|  |                     cs, | ||||||
|  |                     delay, | ||||||
|  |                     name='child', | ||||||
|  |                 ) | ||||||
|  |                 with trio.CancelScope() as rent_cs: | ||||||
|  |                     await use_moc( | ||||||
|  |                         cs=rent_cs, | ||||||
|  |                         delay=delay, | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
|     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) | ||||||
|  | @ -147,8 +147,7 @@ def test_trio_prestarted_task_bubbles( | ||||||
|         await trio.sleep_forever() |         await trio.sleep_forever() | ||||||
| 
 | 
 | ||||||
|     async def _trio_main(): |     async def _trio_main(): | ||||||
|         # with trio.fail_after(2): |         with trio.fail_after(2 if not debug_mode else 999): | ||||||
|         with trio.fail_after(999): |  | ||||||
|             first: str |             first: str | ||||||
|             chan: to_asyncio.LinkedTaskChannel |             chan: to_asyncio.LinkedTaskChannel | ||||||
|             aio_ev = asyncio.Event() |             aio_ev = asyncio.Event() | ||||||
|  | @ -217,32 +216,25 @@ def test_trio_prestarted_task_bubbles( | ||||||
|                         ): |                         ): | ||||||
|                             aio_ev.set() |                             aio_ev.set() | ||||||
| 
 | 
 | ||||||
|     with pytest.raises( |  | ||||||
|         expected_exception=ExceptionGroup, |  | ||||||
|     ) as excinfo: |  | ||||||
|         tractor.to_asyncio.run_as_asyncio_guest( |  | ||||||
|             trio_main=_trio_main, |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     eg = excinfo.value |  | ||||||
|     rte_eg, rest_eg = eg.split(RuntimeError) |  | ||||||
| 
 |  | ||||||
|     # ensure the trio-task's error bubbled despite the aio-side |     # ensure the trio-task's error bubbled despite the aio-side | ||||||
|     # having (maybe) errored first. |     # having (maybe) errored first. | ||||||
|     if aio_err_trigger in ( |     if aio_err_trigger in ( | ||||||
|         'after_trio_task_starts', |         'after_trio_task_starts', | ||||||
|         'after_start_point', |         'after_start_point', | ||||||
|     ): |     ): | ||||||
|         assert len(errs := rest_eg.exceptions) == 1 |         patt: str = 'trio-side' | ||||||
|         typerr = errs[0] |         expect_exc = TypeError | ||||||
|         assert ( |  | ||||||
|             type(typerr) is TypeError |  | ||||||
|             and |  | ||||||
|             'trio-side' in typerr.args |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
|     # when aio errors BEFORE (last) trio task is scheduled, we should |     # when aio errors BEFORE (last) trio task is scheduled, we should | ||||||
|     # never see anythinb but the aio-side. |     # never see anythinb but the aio-side. | ||||||
|     else: |     else: | ||||||
|         assert len(rtes := rte_eg.exceptions) == 1 |         patt: str = 'asyncio-side' | ||||||
|         assert 'asyncio-side' in rtes[0].args[0] |         expect_exc = RuntimeError | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(expect_exc) as excinfo: | ||||||
|  |         tractor.to_asyncio.run_as_asyncio_guest( | ||||||
|  |             trio_main=_trio_main, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     caught_exc = excinfo.value | ||||||
|  |     assert patt in caught_exc.args | ||||||
|  |  | ||||||
|  | @ -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) | ||||||
|  | @ -0,0 +1,167 @@ | ||||||
|  | """ | ||||||
|  | Shared mem primitives and APIs. | ||||||
|  | 
 | ||||||
|  | """ | ||||||
|  | import uuid | ||||||
|  | 
 | ||||||
|  | # import numpy | ||||||
|  | import pytest | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | from tractor.ipc._shm import ( | ||||||
|  |     open_shm_list, | ||||||
|  |     attach_shm_list, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def child_attach_shml_alot( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  |     shm_key: str, | ||||||
|  | ) -> None: | ||||||
|  | 
 | ||||||
|  |     await ctx.started(shm_key) | ||||||
|  | 
 | ||||||
|  |     # now try to attach a boatload of times in a loop.. | ||||||
|  |     for _ in range(1000): | ||||||
|  |         shml = attach_shm_list( | ||||||
|  |             key=shm_key, | ||||||
|  |             readonly=False, | ||||||
|  |         ) | ||||||
|  |         assert shml.shm.name == shm_key | ||||||
|  |         await trio.sleep(0.001) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_child_attaches_alot(): | ||||||
|  |     async def main(): | ||||||
|  |         async with tractor.open_nursery() as an: | ||||||
|  | 
 | ||||||
|  |             # allocate writeable list in parent | ||||||
|  |             key = f'shml_{uuid.uuid4()}' | ||||||
|  |             shml = open_shm_list( | ||||||
|  |                 key=key, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             portal = await an.start_actor( | ||||||
|  |                 'shm_attacher', | ||||||
|  |                 enable_modules=[__name__], | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             async with ( | ||||||
|  |                 portal.open_context( | ||||||
|  |                     child_attach_shml_alot, | ||||||
|  |                     shm_key=shml.key, | ||||||
|  |                 ) as (ctx, start_val), | ||||||
|  |             ): | ||||||
|  |                 assert start_val == key | ||||||
|  |                 await ctx.result() | ||||||
|  | 
 | ||||||
|  |             await portal.cancel_actor() | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def child_read_shm_list( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  |     shm_key: str, | ||||||
|  |     use_str: bool, | ||||||
|  |     frame_size: int, | ||||||
|  | ) -> None: | ||||||
|  | 
 | ||||||
|  |     # attach in child | ||||||
|  |     shml = attach_shm_list( | ||||||
|  |         key=shm_key, | ||||||
|  |         # dtype=str if use_str else float, | ||||||
|  |     ) | ||||||
|  |     await ctx.started(shml.key) | ||||||
|  | 
 | ||||||
|  |     async with ctx.open_stream() as stream: | ||||||
|  |         async for i in stream: | ||||||
|  |             print(f'(child): reading shm list index: {i}') | ||||||
|  | 
 | ||||||
|  |             if use_str: | ||||||
|  |                 expect = str(float(i)) | ||||||
|  |             else: | ||||||
|  |                 expect = float(i) | ||||||
|  | 
 | ||||||
|  |             if frame_size == 1: | ||||||
|  |                 val = shml[i] | ||||||
|  |                 assert expect == val | ||||||
|  |                 print(f'(child): reading value: {val}') | ||||||
|  |             else: | ||||||
|  |                 frame = shml[i - frame_size:i] | ||||||
|  |                 print(f'(child): reading frame: {frame}') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'use_str', | ||||||
|  |     [False, True], | ||||||
|  |     ids=lambda i: f'use_str_values={i}', | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'frame_size', | ||||||
|  |     [1, 2**6, 2**10], | ||||||
|  |     ids=lambda i: f'frame_size={i}', | ||||||
|  | ) | ||||||
|  | def test_parent_writer_child_reader( | ||||||
|  |     use_str: bool, | ||||||
|  |     frame_size: int, | ||||||
|  | ): | ||||||
|  | 
 | ||||||
|  |     async def main(): | ||||||
|  |         async with tractor.open_nursery( | ||||||
|  |             # debug_mode=True, | ||||||
|  |         ) as an: | ||||||
|  | 
 | ||||||
|  |             portal = await an.start_actor( | ||||||
|  |                 'shm_reader', | ||||||
|  |                 enable_modules=[__name__], | ||||||
|  |                 debug_mode=True, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # allocate writeable list in parent | ||||||
|  |             key = 'shm_list' | ||||||
|  |             seq_size = int(2 * 2 ** 10) | ||||||
|  |             shml = open_shm_list( | ||||||
|  |                 key=key, | ||||||
|  |                 size=seq_size, | ||||||
|  |                 dtype=str if use_str else float, | ||||||
|  |                 readonly=False, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             async with ( | ||||||
|  |                 portal.open_context( | ||||||
|  |                     child_read_shm_list, | ||||||
|  |                     shm_key=key, | ||||||
|  |                     use_str=use_str, | ||||||
|  |                     frame_size=frame_size, | ||||||
|  |                 ) as (ctx, sent), | ||||||
|  | 
 | ||||||
|  |                 ctx.open_stream() as stream, | ||||||
|  |             ): | ||||||
|  | 
 | ||||||
|  |                 assert sent == key | ||||||
|  | 
 | ||||||
|  |                 for i in range(seq_size): | ||||||
|  | 
 | ||||||
|  |                     val = float(i) | ||||||
|  |                     if use_str: | ||||||
|  |                         val = str(val) | ||||||
|  | 
 | ||||||
|  |                     # print(f'(parent): writing {val}') | ||||||
|  |                     shml[i] = val | ||||||
|  | 
 | ||||||
|  |                     # only on frame fills do we | ||||||
|  |                     # signal to the child that a frame's | ||||||
|  |                     # worth is ready. | ||||||
|  |                     if (i % frame_size) == 0: | ||||||
|  |                         print(f'(parent): signalling frame full on {val}') | ||||||
|  |                         await stream.send(i) | ||||||
|  |                 else: | ||||||
|  |                     print(f'(parent): signalling final frame on {val}') | ||||||
|  |                     await stream.send(i) | ||||||
|  | 
 | ||||||
|  |             await portal.cancel_actor() | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | @ -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', | ||||||
|  |  | ||||||
|  | @ -8,6 +8,7 @@ from contextlib import ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
|  | from tractor.trionics import collapse_eg | ||||||
| import trio | import trio | ||||||
| from trio import TaskStatus | from trio import TaskStatus | ||||||
| 
 | 
 | ||||||
|  | @ -64,9 +65,8 @@ def test_stashed_child_nursery(use_start_soon): | ||||||
|     async def main(): |     async def main(): | ||||||
| 
 | 
 | ||||||
|         async with ( |         async with ( | ||||||
|             trio.open_nursery( |             collapse_eg(), | ||||||
|                 strict_exception_groups=False, |             trio.open_nursery() as pn, | ||||||
|             ) as pn, |  | ||||||
|         ): |         ): | ||||||
|             cn = await pn.start(mk_child_nursery) |             cn = await pn.start(mk_child_nursery) | ||||||
|             assert cn |             assert cn | ||||||
|  | @ -112,55 +112,11 @@ def test_acm_embedded_nursery_propagates_enter_err( | ||||||
|     ''' |     ''' | ||||||
|     import tractor |     import tractor | ||||||
| 
 | 
 | ||||||
|     @acm |  | ||||||
|     async def maybe_raise_from_masking_exc( |  | ||||||
|         tn: trio.Nursery, |  | ||||||
|         unmask_from: BaseException|None = trio.Cancelled |  | ||||||
| 
 |  | ||||||
|         # TODO, maybe offer a collection? |  | ||||||
|         # unmask_from: set[BaseException] = { |  | ||||||
|         #     trio.Cancelled, |  | ||||||
|         # }, |  | ||||||
|     ): |  | ||||||
|         if not unmask_from: |  | ||||||
|             yield |  | ||||||
|             return |  | ||||||
| 
 |  | ||||||
|         try: |  | ||||||
|             yield |  | ||||||
|         except* unmask_from as be_eg: |  | ||||||
| 
 |  | ||||||
|             # TODO, if we offer `unmask_from: set` |  | ||||||
|             # for masker_exc_type in unmask_from: |  | ||||||
| 
 |  | ||||||
|             matches, rest = be_eg.split(unmask_from) |  | ||||||
|             if not matches: |  | ||||||
|                 raise |  | ||||||
| 
 |  | ||||||
|             for exc_match in be_eg.exceptions: |  | ||||||
|                 if ( |  | ||||||
|                     (exc_ctx := exc_match.__context__) |  | ||||||
|                     and |  | ||||||
|                     type(exc_ctx) not in { |  | ||||||
|                         # trio.Cancelled,  # always by default? |  | ||||||
|                         unmask_from, |  | ||||||
|                     } |  | ||||||
|                 ): |  | ||||||
|                     exc_ctx.add_note( |  | ||||||
|                         f'\n' |  | ||||||
|                         f'WARNING: the above error was masked by a {unmask_from!r} !?!\n' |  | ||||||
|                         f'Are you always cancelling? Say from a `finally:` ?\n\n' |  | ||||||
| 
 |  | ||||||
|                         f'{tn!r}' |  | ||||||
|                     ) |  | ||||||
|                     raise exc_ctx from exc_match |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     @acm |     @acm | ||||||
|     async def wraps_tn_that_always_cancels(): |     async def wraps_tn_that_always_cancels(): | ||||||
|         async with ( |         async with ( | ||||||
|             trio.open_nursery() as tn, |             trio.open_nursery() as tn, | ||||||
|             maybe_raise_from_masking_exc( |             tractor.trionics.maybe_raise_from_masking_exc( | ||||||
|                 tn=tn, |                 tn=tn, | ||||||
|                 unmask_from=( |                 unmask_from=( | ||||||
|                     trio.Cancelled |                     trio.Cancelled | ||||||
|  | @ -180,7 +136,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, | ||||||
|  | @ -201,3 +158,58 @@ def test_acm_embedded_nursery_propagates_enter_err( | ||||||
|     assert_eg, rest_eg = eg.split(AssertionError) |     assert_eg, rest_eg = eg.split(AssertionError) | ||||||
| 
 | 
 | ||||||
|     assert len(assert_eg.exceptions) == 1 |     assert len(assert_eg.exceptions) == 1 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_gatherctxs_with_memchan_breaks_multicancelled( | ||||||
|  |     debug_mode: bool, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Demo how a using an `async with sndchan` inside a `.trionics.gather_contexts()` task | ||||||
|  |     will break a strict-eg-tn's multi-cancelled absorption.. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     from tractor import ( | ||||||
|  |         trionics, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     @acm | ||||||
|  |     async def open_memchan() -> trio.abc.ReceiveChannel: | ||||||
|  | 
 | ||||||
|  |         task: trio.Task = trio.lowlevel.current_task() | ||||||
|  |         print( | ||||||
|  |             f'Opening {task!r}\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # 1 to force eager sending | ||||||
|  |         send, recv = trio.open_memory_channel(16) | ||||||
|  | 
 | ||||||
|  |         try: | ||||||
|  |             async with send: | ||||||
|  |                 yield recv | ||||||
|  |         finally: | ||||||
|  |             print( | ||||||
|  |                 f'Closed {task!r}\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     async def main(): | ||||||
|  |         async with ( | ||||||
|  |             # XXX should ensure ONLY the KBI | ||||||
|  |             # is relayed upward | ||||||
|  |             collapse_eg(), | ||||||
|  |             trio.open_nursery(), # as tn, | ||||||
|  | 
 | ||||||
|  |             trionics.gather_contexts([ | ||||||
|  |                 open_memchan(), | ||||||
|  |                 open_memchan(), | ||||||
|  |             ]) as recv_chans, | ||||||
|  |         ): | ||||||
|  |             assert len(recv_chans) == 2 | ||||||
|  | 
 | ||||||
|  |             await trio.sleep(1) | ||||||
|  |             raise KeyboardInterrupt | ||||||
|  |             # tn.cancel_scope.cancel() | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(KeyboardInterrupt): | ||||||
|  |         trio.run(main) | ||||||
|  |  | ||||||
|  | @ -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" | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  | @ -55,10 +55,17 @@ async def open_actor_cluster( | ||||||
|         raise ValueError( |         raise ValueError( | ||||||
|             'Number of names is {len(names)} but count it {count}') |             'Number of names is {len(names)} but count it {count}') | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_nursery( |     async with ( | ||||||
|         **runtime_kwargs, |         # tractor.trionics.collapse_eg(), | ||||||
|     ) as an: |         tractor.open_nursery( | ||||||
|         async with trio.open_nursery() as n: |             **runtime_kwargs, | ||||||
|  |         ) as an | ||||||
|  |     ): | ||||||
|  |         async with ( | ||||||
|  |             # tractor.trionics.collapse_eg(), | ||||||
|  |             trio.open_nursery() as tn, | ||||||
|  |             tractor.trionics.maybe_raise_from_masking_exc() | ||||||
|  |         ): | ||||||
|             uid = tractor.current_actor().uid |             uid = tractor.current_actor().uid | ||||||
| 
 | 
 | ||||||
|             async def _start(name: str) -> None: |             async def _start(name: str) -> None: | ||||||
|  | @ -69,9 +76,8 @@ async def open_actor_cluster( | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|             for name in names: |             for name in names: | ||||||
|                 n.start_soon(_start, name) |                 tn.start_soon(_start, name) | ||||||
| 
 | 
 | ||||||
|         assert len(portals) == count |         assert len(portals) == count | ||||||
|         yield portals |         yield portals | ||||||
| 
 |  | ||||||
|         await an.cancel(hard_kill=hard_kill) |         await an.cancel(hard_kill=hard_kill) | ||||||
|  |  | ||||||
|  | @ -47,6 +47,9 @@ from functools import partial | ||||||
| import inspect | import inspect | ||||||
| from pprint import pformat | from pprint import pformat | ||||||
| import textwrap | import textwrap | ||||||
|  | from types import ( | ||||||
|  |     UnionType, | ||||||
|  | ) | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, |     Any, | ||||||
|     AsyncGenerator, |     AsyncGenerator, | ||||||
|  | @ -79,13 +82,14 @@ from .msg import ( | ||||||
|     MsgType, |     MsgType, | ||||||
|     NamespacePath, |     NamespacePath, | ||||||
|     PayloadT, |     PayloadT, | ||||||
|  |     Return, | ||||||
|     Started, |     Started, | ||||||
|     Stop, |     Stop, | ||||||
|     Yield, |     Yield, | ||||||
|     pretty_struct, |     pretty_struct, | ||||||
|     _ops as msgops, |     _ops as msgops, | ||||||
| ) | ) | ||||||
| from ._ipc import ( | from .ipc import ( | ||||||
|     Channel, |     Channel, | ||||||
| ) | ) | ||||||
| from ._streaming import ( | from ._streaming import ( | ||||||
|  | @ -97,11 +101,14 @@ from ._state import ( | ||||||
|     debug_mode, |     debug_mode, | ||||||
|     _ctxvar_Context, |     _ctxvar_Context, | ||||||
| ) | ) | ||||||
|  | from .trionics import ( | ||||||
|  |     collapse_eg, | ||||||
|  | ) | ||||||
| # ------ - ------ | # ------ - ------ | ||||||
| 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, | ||||||
|     ) |     ) | ||||||
|  | @ -147,7 +154,7 @@ class Context: | ||||||
|     2 cancel-scope-linked, communicating and parallel executing |     2 cancel-scope-linked, communicating and parallel executing | ||||||
|     `Task`s. Contexts are allocated on each side of any task |     `Task`s. Contexts are allocated on each side of any task | ||||||
|     RPC-linked msg dialog, i.e. for every request to a remote |     RPC-linked msg dialog, i.e. for every request to a remote | ||||||
|     actor from a `Portal`. On the "callee" side a context is |     actor from a `Portal`. On the "child" side a context is | ||||||
|     always allocated inside `._rpc._invoke()`. |     always allocated inside `._rpc._invoke()`. | ||||||
| 
 | 
 | ||||||
|     TODO: more detailed writeup on cancellation, error and |     TODO: more detailed writeup on cancellation, error and | ||||||
|  | @ -215,8 +222,8 @@ class Context: | ||||||
|     # `._runtime.invoke()`. |     # `._runtime.invoke()`. | ||||||
|     _remote_func_type: str | None = None |     _remote_func_type: str | None = None | ||||||
| 
 | 
 | ||||||
|     # NOTE: (for now) only set (a portal) on the caller side since |     # NOTE: (for now) only set (a portal) on the parent side since | ||||||
|     # the callee doesn't generally need a ref to one and should |     # the child doesn't generally need a ref to one and should | ||||||
|     # normally need to explicitly ask for handle to its peer if |     # normally need to explicitly ask for handle to its peer if | ||||||
|     # more the the `Context` is needed? |     # more the the `Context` is needed? | ||||||
|     _portal: Portal | None = None |     _portal: Portal | None = None | ||||||
|  | @ -242,13 +249,15 @@ class Context: | ||||||
|     # a drain loop? |     # a drain loop? | ||||||
|     # _res_scope: trio.CancelScope|None = None |     # _res_scope: trio.CancelScope|None = None | ||||||
| 
 | 
 | ||||||
|  |     _outcome_msg: Return|Error|ContextCancelled = Unresolved | ||||||
|  | 
 | ||||||
|     # on a clean exit there should be a final value |     # on a clean exit there should be a final value | ||||||
|     # delivered from the far end "callee" task, so |     # delivered from the far end "child" task, so | ||||||
|     # this value is only set on one side. |     # this value is only set on one side. | ||||||
|     # _result: Any | int = None |     # _result: Any | int = None | ||||||
|     _result: Any|Unresolved = Unresolved |     _result: PayloadT|Unresolved = Unresolved | ||||||
| 
 | 
 | ||||||
|     # if the local "caller"  task errors this value is always set |     # if the local "parent"  task errors this value is always set | ||||||
|     # to the error that was captured in the |     # to the error that was captured in the | ||||||
|     # `Portal.open_context().__aexit__()` teardown block OR, in |     # `Portal.open_context().__aexit__()` teardown block OR, in | ||||||
|     # 2 special cases when an (maybe) expected remote error |     # 2 special cases when an (maybe) expected remote error | ||||||
|  | @ -284,9 +293,9 @@ class Context: | ||||||
|     # a `ContextCancelled` due to a call to `.cancel()` triggering |     # a `ContextCancelled` due to a call to `.cancel()` triggering | ||||||
|     # "graceful closure" on either side: |     # "graceful closure" on either side: | ||||||
|     # - `._runtime._invoke()` will check this flag before engaging |     # - `._runtime._invoke()` will check this flag before engaging | ||||||
|     #   the crash handler REPL in such cases where the "callee" |     #   the crash handler REPL in such cases where the "child" | ||||||
|     #   raises the cancellation, |     #   raises the cancellation, | ||||||
|     # - `.devx._debug.lock_stdio_for_peer()` will set it to `False` if |     # - `.devx.debug.lock_stdio_for_peer()` will set it to `False` if | ||||||
|     #   the global tty-lock has been configured to filter out some |     #   the global tty-lock has been configured to filter out some | ||||||
|     #   actors from being able to acquire the debugger lock. |     #   actors from being able to acquire the debugger lock. | ||||||
|     _enter_debugger_on_cancel: bool = True |     _enter_debugger_on_cancel: bool = True | ||||||
|  | @ -298,8 +307,8 @@ class Context: | ||||||
|     _stream_opened: bool = False |     _stream_opened: bool = False | ||||||
|     _stream: MsgStream|None = None |     _stream: MsgStream|None = None | ||||||
| 
 | 
 | ||||||
|     # caller of `Portal.open_context()` for |     # the parent-task's calling-fn's frame-info, the frame above | ||||||
|     # logging purposes mostly |     # `Portal.open_context()`, for introspection/logging. | ||||||
|     _caller_info: CallerInfo|None = None |     _caller_info: CallerInfo|None = None | ||||||
| 
 | 
 | ||||||
|     # overrun handling machinery |     # overrun handling machinery | ||||||
|  | @ -360,7 +369,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' | ||||||
|  | @ -520,11 +529,11 @@ class Context: | ||||||
|         ''' |         ''' | ||||||
|         Exactly the value of `self._scope.cancelled_caught` |         Exactly the value of `self._scope.cancelled_caught` | ||||||
|         (delegation) and should only be (able to be read as) |         (delegation) and should only be (able to be read as) | ||||||
|         `True` for a `.side == "caller"` ctx wherein the |         `True` for a `.side == "parent"` ctx wherein the | ||||||
|         `Portal.open_context()` block was exited due to a call to |         `Portal.open_context()` block was exited due to a call to | ||||||
|         `._scope.cancel()` - which should only ocurr in 2 cases: |         `._scope.cancel()` - which should only ocurr in 2 cases: | ||||||
| 
 | 
 | ||||||
|         - a caller side calls `.cancel()`, the far side cancels |         - a parent side calls `.cancel()`, the far side cancels | ||||||
|           and delivers back a `ContextCancelled` (making |           and delivers back a `ContextCancelled` (making | ||||||
|           `.cancel_acked == True`) and `._scope.cancel()` is |           `.cancel_acked == True`) and `._scope.cancel()` is | ||||||
|           called by `._maybe_cancel_and_set_remote_error()` which |           called by `._maybe_cancel_and_set_remote_error()` which | ||||||
|  | @ -533,20 +542,20 @@ class Context: | ||||||
|           => `._scope.cancelled_caught == True` by normal `trio` |           => `._scope.cancelled_caught == True` by normal `trio` | ||||||
|           cs semantics. |           cs semantics. | ||||||
| 
 | 
 | ||||||
|         - a caller side is delivered a `._remote_error: |         - a parent side is delivered a `._remote_error: | ||||||
|           RemoteActorError` via `._deliver_msg()` and a transitive |           RemoteActorError` via `._deliver_msg()` and a transitive | ||||||
|           call to `_maybe_cancel_and_set_remote_error()` calls |           call to `_maybe_cancel_and_set_remote_error()` calls | ||||||
|           `._scope.cancel()` and that cancellation eventually |           `._scope.cancel()` and that cancellation eventually | ||||||
|           results in `trio.Cancelled`(s) caught in the |           results in `trio.Cancelled`(s) caught in the | ||||||
|           `.open_context()` handling around the @acm's `yield`. |           `.open_context()` handling around the @acm's `yield`. | ||||||
| 
 | 
 | ||||||
|         Only as an FYI, in the "callee" side case it can also be |         Only as an FYI, in the "child" side case it can also be | ||||||
|         set but never is readable by any task outside the RPC |         set but never is readable by any task outside the RPC | ||||||
|         machinery in `._invoke()` since,: |         machinery in `._invoke()` since,: | ||||||
|         - when a callee side calls `.cancel()`, `._scope.cancel()` |         - when a child side calls `.cancel()`, `._scope.cancel()` | ||||||
|           is called immediately and handled specially inside |           is called immediately and handled specially inside | ||||||
|           `._invoke()` to raise a `ContextCancelled` which is then |           `._invoke()` to raise a `ContextCancelled` which is then | ||||||
|           sent to the caller side. |           sent to the parent side. | ||||||
| 
 | 
 | ||||||
|           However, `._scope.cancelled_caught` can NEVER be |           However, `._scope.cancelled_caught` can NEVER be | ||||||
|           accessed/read as `True` by any RPC invoked task since it |           accessed/read as `True` by any RPC invoked task since it | ||||||
|  | @ -657,7 +666,7 @@ class Context: | ||||||
|         when called/closed by actor local task(s). |         when called/closed by actor local task(s). | ||||||
| 
 | 
 | ||||||
|         NOTEs:  |         NOTEs:  | ||||||
|           - It is expected that the caller has previously unwrapped |           - It is expected that the parent has previously unwrapped | ||||||
|             the remote error using a call to `unpack_error()` and |             the remote error using a call to `unpack_error()` and | ||||||
|             provides that output exception value as the input |             provides that output exception value as the input | ||||||
|             `error` argument *here*. |             `error` argument *here*. | ||||||
|  | @ -667,7 +676,7 @@ class Context: | ||||||
|             `Portal.open_context()` (ideally) we want to interrupt |             `Portal.open_context()` (ideally) we want to interrupt | ||||||
|             any ongoing local tasks operating within that |             any ongoing local tasks operating within that | ||||||
|             `Context`'s cancel-scope so as to be notified ASAP of |             `Context`'s cancel-scope so as to be notified ASAP of | ||||||
|             the remote error and engage any caller handling (eg. |             the remote error and engage any parent handling (eg. | ||||||
|             for cross-process task supervision). |             for cross-process task supervision). | ||||||
| 
 | 
 | ||||||
|           - In some cases we may want to raise the remote error |           - In some cases we may want to raise the remote error | ||||||
|  | @ -734,6 +743,8 @@ class Context: | ||||||
|             # cancelled, NOT their reported canceller. IOW in the |             # cancelled, NOT their reported canceller. IOW in the | ||||||
|             # latter case we're cancelled by someone else getting |             # latter case we're cancelled by someone else getting | ||||||
|             # cancelled. |             # cancelled. | ||||||
|  |             # | ||||||
|  |             # !TODO, switching to `Actor.aid` here! | ||||||
|             if (canc := error.canceller) == self._actor.uid: |             if (canc := error.canceller) == self._actor.uid: | ||||||
|                 whom: str = 'us' |                 whom: str = 'us' | ||||||
|                 self._canceller = canc |                 self._canceller = canc | ||||||
|  | @ -853,19 +864,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 | ||||||
| 
 | 
 | ||||||
|  | @ -884,6 +886,11 @@ class Context: | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def repr_caller(self) -> str: |     def repr_caller(self) -> str: | ||||||
|  |         ''' | ||||||
|  |         Render a "namespace-path" style representation of the calling | ||||||
|  |         task-fn. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|         ci: CallerInfo|None = self._caller_info |         ci: CallerInfo|None = self._caller_info | ||||||
|         if ci: |         if ci: | ||||||
|             return ( |             return ( | ||||||
|  | @ -897,7 +904,7 @@ class Context: | ||||||
|     def repr_api(self) -> str: |     def repr_api(self) -> str: | ||||||
|         return 'Portal.open_context()' |         return 'Portal.open_context()' | ||||||
| 
 | 
 | ||||||
|         # TODO: use `.dev._frame_stack` scanning to find caller! |         # TODO: use `.dev._frame_stack` scanning to find caller fn! | ||||||
|         # ci: CallerInfo|None = self._caller_info |         # ci: CallerInfo|None = self._caller_info | ||||||
|         # if ci: |         # if ci: | ||||||
|         #     return ( |         #     return ( | ||||||
|  | @ -932,7 +939,7 @@ class Context: | ||||||
|         => That is, an IPC `Context` (this) **does not** |         => That is, an IPC `Context` (this) **does not** | ||||||
|            have the same semantics as a `trio.CancelScope`. |            have the same semantics as a `trio.CancelScope`. | ||||||
| 
 | 
 | ||||||
|         If the caller (who entered the `Portal.open_context()`) |         If the parent (who entered the `Portal.open_context()`) | ||||||
|         desires that the internal block's cancel-scope  be |         desires that the internal block's cancel-scope  be | ||||||
|         cancelled it should open its own `trio.CancelScope` and |         cancelled it should open its own `trio.CancelScope` and | ||||||
|         manage it as needed. |         manage it as needed. | ||||||
|  | @ -943,15 +950,15 @@ class Context: | ||||||
|         self.cancel_called = True |         self.cancel_called = True | ||||||
| 
 | 
 | ||||||
|         header: str = ( |         header: str = ( | ||||||
|             f'Cancelling ctx from {side.upper()}-side\n' |             f'Cancelling ctx from {side!r}-side\n' | ||||||
|         ) |         ) | ||||||
|         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 | ||||||
|         ) |         ) | ||||||
|  | @ -1004,7 +1011,6 @@ class Context: | ||||||
|                 else: |                 else: | ||||||
|                     log.cancel( |                     log.cancel( | ||||||
|                         f'Timed out on cancel request of remote task?\n' |                         f'Timed out on cancel request of remote task?\n' | ||||||
|                         f'\n' |  | ||||||
|                         f'{reminfo}' |                         f'{reminfo}' | ||||||
|                     ) |                     ) | ||||||
| 
 | 
 | ||||||
|  | @ -1015,7 +1021,7 @@ class Context: | ||||||
|         # `_invoke()` RPC task. |         # `_invoke()` RPC task. | ||||||
|         # |         # | ||||||
|         # NOTE: on this side we ALWAYS cancel the local scope |         # NOTE: on this side we ALWAYS cancel the local scope | ||||||
|         # since the caller expects a `ContextCancelled` to be sent |         # since the parent expects a `ContextCancelled` to be sent | ||||||
|         # from `._runtime._invoke()` back to the other side. The |         # from `._runtime._invoke()` back to the other side. The | ||||||
|         # logic for catching the result of the below |         # logic for catching the result of the below | ||||||
|         # `._scope.cancel()` is inside the `._runtime._invoke()` |         # `._scope.cancel()` is inside the `._runtime._invoke()` | ||||||
|  | @ -1072,9 +1078,25 @@ class Context: | ||||||
|         |RemoteActorError  # stream overrun caused and ignored by us |         |RemoteActorError  # stream overrun caused and ignored by us | ||||||
|     ): |     ): | ||||||
|         ''' |         ''' | ||||||
|         Maybe raise a remote error depending on the type of error |         Maybe raise a remote error depending on the type of error and | ||||||
|         and *who* (i.e. which task from which actor) requested |         *who*, i.e. which side of the task pair across actors, | ||||||
|         a  cancellation (if any). |         requested a cancellation (if any). | ||||||
|  | 
 | ||||||
|  |         Depending on the input config-params suppress raising | ||||||
|  |         certain remote excs: | ||||||
|  | 
 | ||||||
|  |         - if `remote_error: ContextCancelled` (ctxc) AND this side's | ||||||
|  |           task is the "requester", it at somem point called | ||||||
|  |           `Context.cancel()`, then the peer's ctxc is treated | ||||||
|  |           as a "cancel ack". | ||||||
|  | 
 | ||||||
|  |          |_ this behaves exactly like how `trio.Nursery.cancel_scope` | ||||||
|  |             absorbs any `BaseExceptionGroup[trio.Cancelled]` wherein the | ||||||
|  |             owning parent task never will raise a `trio.Cancelled` | ||||||
|  |             if `CancelScope.cancel_called == True`. | ||||||
|  | 
 | ||||||
|  |         - `remote_error: StreamOverrrun` (overrun) AND | ||||||
|  |            `raise_overrun_from_self` is set. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         __tracebackhide__: bool = hide_tb |         __tracebackhide__: bool = hide_tb | ||||||
|  | @ -1116,18 +1138,19 @@ class Context: | ||||||
|             # for this ^, NO right? |             # for this ^, NO right? | ||||||
| 
 | 
 | ||||||
|         ) or ( |         ) or ( | ||||||
|             # NOTE: whenever this context is the cause of an |             # NOTE: whenever this side is the cause of an | ||||||
|             # overrun on the remote side (aka we sent msgs too |             # overrun on the peer side, i.e. we sent msgs too | ||||||
|             # fast that the remote task was overrun according |             # fast and the peer task was overrun according | ||||||
|             # to `MsgStream` buffer settings) AND the caller |             # to `MsgStream` buffer settings, AND this was | ||||||
|             # has requested to not raise overruns this side |             # called with `raise_overrun_from_self=True` (the | ||||||
|             # caused, we also silently absorb any remotely |             # default), silently absorb any `StreamOverrun`. | ||||||
|             # boxed `StreamOverrun`. This is mostly useful for |             # | ||||||
|             # supressing such faults during |             # XXX, this is namely useful for supressing such faults | ||||||
|             # cancellation/error/final-result handling inside |             # during cancellation/error/final-result handling inside | ||||||
|             # `msg._ops.drain_to_final_msg()` such that we do not |             # `.msg._ops.drain_to_final_msg()` such that we do not | ||||||
|             # raise such errors particularly in the case where |             # raise during a cancellation-request, i.e. when | ||||||
|             # `._cancel_called == True`. |             # `._cancel_called == True`. | ||||||
|  |             # | ||||||
|             not raise_overrun_from_self |             not raise_overrun_from_self | ||||||
|             and isinstance(remote_error, RemoteActorError) |             and isinstance(remote_error, RemoteActorError) | ||||||
|             and remote_error.boxed_type is StreamOverrun |             and remote_error.boxed_type is StreamOverrun | ||||||
|  | @ -1171,8 +1194,8 @@ class Context: | ||||||
| 
 | 
 | ||||||
|     ) -> Any|Exception: |     ) -> Any|Exception: | ||||||
|         ''' |         ''' | ||||||
|         From some (caller) side task, wait for and return the final |         From some (parent) side task, wait for and return the final | ||||||
|         result from the remote (callee) side's task. |         result from the remote (child) side's task. | ||||||
| 
 | 
 | ||||||
|         This provides a mechanism for one task running in some actor to wait |         This provides a mechanism for one task running in some actor to wait | ||||||
|         on another task at the other side, in some other actor, to terminate. |         on another task at the other side, in some other actor, to terminate. | ||||||
|  | @ -1196,9 +1219,11 @@ class Context: | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         __tracebackhide__: bool = hide_tb |         __tracebackhide__: bool = hide_tb | ||||||
|         assert self._portal, ( |         if not self._portal: | ||||||
|             '`Context.wait_for_result()` can not be called from callee side!' |             raise RuntimeError( | ||||||
|         ) |                 'Invalid usage of `Context.wait_for_result()`!\n' | ||||||
|  |                 'Not valid on child-side IPC ctx!\n' | ||||||
|  |             ) | ||||||
|         if self._final_result_is_set(): |         if self._final_result_is_set(): | ||||||
|             return self._result |             return self._result | ||||||
| 
 | 
 | ||||||
|  | @ -1219,6 +1244,8 @@ class Context: | ||||||
|             # since every message should be delivered via the normal |             # since every message should be delivered via the normal | ||||||
|             # `._deliver_msg()` route which will appropriately set |             # `._deliver_msg()` route which will appropriately set | ||||||
|             # any `.maybe_error`. |             # any `.maybe_error`. | ||||||
|  |             outcome_msg: Return|Error|ContextCancelled | ||||||
|  |             drained_msgs: list[MsgType] | ||||||
|             ( |             ( | ||||||
|                 outcome_msg, |                 outcome_msg, | ||||||
|                 drained_msgs, |                 drained_msgs, | ||||||
|  | @ -1226,11 +1253,19 @@ class Context: | ||||||
|                 ctx=self, |                 ctx=self, | ||||||
|                 hide_tb=hide_tb, |                 hide_tb=hide_tb, | ||||||
|             ) |             ) | ||||||
| 
 |  | ||||||
|             drained_status: str = ( |             drained_status: str = ( | ||||||
|                 'Ctx drained to final outcome msg\n\n' |                 'Ctx drained to final outcome msg\n\n' | ||||||
|                 f'{outcome_msg}\n' |                 f'{outcome_msg}\n' | ||||||
|             ) |             ) | ||||||
|  | 
 | ||||||
|  |             # ?XXX, should already be set in `._deliver_msg()` right? | ||||||
|  |             if self._outcome_msg is not Unresolved: | ||||||
|  |                 # from .devx import debug | ||||||
|  |                 # await debug.pause() | ||||||
|  |                 assert self._outcome_msg is outcome_msg | ||||||
|  |             else: | ||||||
|  |                 self._outcome_msg = outcome_msg | ||||||
|  | 
 | ||||||
|             if drained_msgs: |             if drained_msgs: | ||||||
|                 drained_status += ( |                 drained_status += ( | ||||||
|                     '\n' |                     '\n' | ||||||
|  | @ -1456,6 +1491,12 @@ class Context: | ||||||
|                 ): |                 ): | ||||||
|                     status = 'peer-cancelled' |                     status = 'peer-cancelled' | ||||||
| 
 | 
 | ||||||
|  |             case ( | ||||||
|  |                 Unresolved, | ||||||
|  |                 trio.Cancelled(),  # any error-type | ||||||
|  |             ) if self.canceller: | ||||||
|  |                 status = 'actor-cancelled' | ||||||
|  | 
 | ||||||
|             # (remote) error condition |             # (remote) error condition | ||||||
|             case ( |             case ( | ||||||
|                 Unresolved, |                 Unresolved, | ||||||
|  | @ -1569,7 +1610,7 @@ class Context: | ||||||
|                 raise err |                 raise err | ||||||
| 
 | 
 | ||||||
|         # TODO: maybe a flag to by-pass encode op if already done |         # TODO: maybe a flag to by-pass encode op if already done | ||||||
|         # here in caller? |         # here in parent? | ||||||
|         await self.chan.send(started_msg) |         await self.chan.send(started_msg) | ||||||
| 
 | 
 | ||||||
|         # set msg-related internal runtime-state |         # set msg-related internal runtime-state | ||||||
|  | @ -1645,7 +1686,7 @@ class Context: | ||||||
| 
 | 
 | ||||||
|          XXX RULES XXX |          XXX RULES XXX | ||||||
|         ------ - ------ |         ------ - ------ | ||||||
|         - NEVER raise remote errors from this method; a runtime task caller. |         - NEVER raise remote errors from this method; a calling runtime-task. | ||||||
|           An error "delivered" to a ctx should always be raised by |           An error "delivered" to a ctx should always be raised by | ||||||
|           the corresponding local task operating on the |           the corresponding local task operating on the | ||||||
|           `Portal`/`Context` APIs. |           `Portal`/`Context` APIs. | ||||||
|  | @ -1721,7 +1762,7 @@ class Context: | ||||||
| 
 | 
 | ||||||
|             else: |             else: | ||||||
|                 report = ( |                 report = ( | ||||||
|                     'Queueing OVERRUN msg on caller task:\n\n' |                     'Queueing OVERRUN msg on parent task:\n\n' | ||||||
|                     + report |                     + report | ||||||
|                 ) |                 ) | ||||||
|                 log.debug(report) |                 log.debug(report) | ||||||
|  | @ -1738,7 +1779,6 @@ class Context: | ||||||
| 
 | 
 | ||||||
|                 f'{structfmt(msg)}\n' |                 f'{structfmt(msg)}\n' | ||||||
|             ) |             ) | ||||||
| 
 |  | ||||||
|             # NOTE: if an error is deteced we should always still |             # NOTE: if an error is deteced we should always still | ||||||
|             # send it through the feeder-mem-chan and expect |             # send it through the feeder-mem-chan and expect | ||||||
|             # it to be raised by any context (stream) consumer |             # it to be raised by any context (stream) consumer | ||||||
|  | @ -1750,6 +1790,21 @@ class Context: | ||||||
|             # normally the task that should get cancelled/error |             # normally the task that should get cancelled/error | ||||||
|             # from some remote fault! |             # from some remote fault! | ||||||
|             send_chan.send_nowait(msg) |             send_chan.send_nowait(msg) | ||||||
|  |             match msg: | ||||||
|  |                 case Stop(): | ||||||
|  |                     if (stream := self._stream): | ||||||
|  |                         stream._stop_msg = msg | ||||||
|  | 
 | ||||||
|  |                 case Return(): | ||||||
|  |                     if not self._outcome_msg: | ||||||
|  |                         log.warning( | ||||||
|  |                             f'Setting final outcome msg AFTER ' | ||||||
|  |                             f'`._rx_chan.send()`??\n' | ||||||
|  |                             f'\n' | ||||||
|  |                             f'{msg}' | ||||||
|  |                         ) | ||||||
|  |                         self._outcome_msg = msg | ||||||
|  | 
 | ||||||
|             return True |             return True | ||||||
| 
 | 
 | ||||||
|         except trio.BrokenResourceError: |         except trio.BrokenResourceError: | ||||||
|  | @ -1903,12 +1958,12 @@ async def open_context_from_portal( | ||||||
|     IPC protocol. |     IPC protocol. | ||||||
| 
 | 
 | ||||||
|     The yielded `tuple` is a pair delivering a `tractor.Context` |     The yielded `tuple` is a pair delivering a `tractor.Context` | ||||||
|     and any first value "sent" by the "callee" task via a call |     and any first value "sent" by the "child" task via a call | ||||||
|     to `Context.started(<value: Any>)`; this side of the |     to `Context.started(<value: Any>)`; this side of the | ||||||
|     context does not unblock until the "callee" task calls |     context does not unblock until the "child" task calls | ||||||
|     `.started()` in similar style to `trio.Nursery.start()`. |     `.started()` in similar style to `trio.Nursery.start()`. | ||||||
|     When the "callee" (side that is "called"/started by a call |     When the "child" (side that is "called"/started by a call | ||||||
|     to *this* method) returns, the caller side (this) unblocks |     to *this* method) returns, the parent side (this) unblocks | ||||||
|     and any final value delivered from the other end can be |     and any final value delivered from the other end can be | ||||||
|     retrieved using the `Contex.wait_for_result()` api. |     retrieved using the `Contex.wait_for_result()` api. | ||||||
| 
 | 
 | ||||||
|  | @ -1921,7 +1976,7 @@ async def open_context_from_portal( | ||||||
|     __tracebackhide__: bool = hide_tb |     __tracebackhide__: bool = hide_tb | ||||||
| 
 | 
 | ||||||
|     # denote this frame as a "runtime frame" for stack |     # denote this frame as a "runtime frame" for stack | ||||||
|     # introspection where we report the caller code in logging |     # introspection where we report the parent code in logging | ||||||
|     # and error message content. |     # and error message content. | ||||||
|     # NOTE: 2 bc of the wrapping `@acm` |     # NOTE: 2 bc of the wrapping `@acm` | ||||||
|     __runtimeframe__: int = 2  # noqa |     __runtimeframe__: int = 2  # noqa | ||||||
|  | @ -1980,13 +2035,11 @@ async def open_context_from_portal( | ||||||
|     # placeholder for any exception raised in the runtime |     # placeholder for any exception raised in the runtime | ||||||
|     # or by user tasks which cause this context's closure. |     # or by user tasks which cause this context's closure. | ||||||
|     scope_err: BaseException|None = None |     scope_err: BaseException|None = None | ||||||
|     ctxc_from_callee: ContextCancelled|None = None |     ctxc_from_child: ContextCancelled|None = None | ||||||
|     try: |     try: | ||||||
|         async with ( |         async with ( | ||||||
|             trio.open_nursery( |             collapse_eg(), | ||||||
|                 strict_exception_groups=False, |             trio.open_nursery() as tn, | ||||||
|             ) as tn, |  | ||||||
| 
 |  | ||||||
|             msgops.maybe_limit_plds( |             msgops.maybe_limit_plds( | ||||||
|                 ctx=ctx, |                 ctx=ctx, | ||||||
|                 spec=ctx_meta.get('pld_spec'), |                 spec=ctx_meta.get('pld_spec'), | ||||||
|  | @ -2006,7 +2059,7 @@ async def open_context_from_portal( | ||||||
|             # the dialog, the `Error` msg should be raised from the `msg` |             # the dialog, the `Error` msg should be raised from the `msg` | ||||||
|             # handling block below. |             # handling block below. | ||||||
|             try: |             try: | ||||||
|                 started_msg, first = await ctx._pld_rx.recv_msg_w_pld( |                 started_msg, first = await ctx._pld_rx.recv_msg( | ||||||
|                     ipc=ctx, |                     ipc=ctx, | ||||||
|                     expect_msg=Started, |                     expect_msg=Started, | ||||||
|                     passthrough_non_pld_msgs=False, |                     passthrough_non_pld_msgs=False, | ||||||
|  | @ -2061,7 +2114,7 @@ async def open_context_from_portal( | ||||||
|             # that we can re-use it around the `yield` ^ here |             # that we can re-use it around the `yield` ^ here | ||||||
|             # or vice versa? |             # or vice versa? | ||||||
|             # |             # | ||||||
|             # maybe TODO NOTE: between the caller exiting and |             # maybe TODO NOTE: between the parent exiting and | ||||||
|             # arriving here the far end may have sent a ctxc-msg or |             # arriving here the far end may have sent a ctxc-msg or | ||||||
|             # other error, so the quetion is whether we should check |             # other error, so the quetion is whether we should check | ||||||
|             # for it here immediately and maybe raise so as to engage |             # for it here immediately and maybe raise so as to engage | ||||||
|  | @ -2127,16 +2180,16 @@ async def open_context_from_portal( | ||||||
|     #   request in which case we DO let the error bubble to the |     #   request in which case we DO let the error bubble to the | ||||||
|     #   opener. |     #   opener. | ||||||
|     # |     # | ||||||
|     # 2-THIS "caller" task somewhere invoked `Context.cancel()` |     # 2-THIS "parent" task somewhere invoked `Context.cancel()` | ||||||
|     #   and received a `ContextCanclled` from the "callee" |     #   and received a `ContextCanclled` from the "child" | ||||||
|     #   task, in which case we mask the `ContextCancelled` from |     #   task, in which case we mask the `ContextCancelled` from | ||||||
|     #   bubbling to this "caller" (much like how `trio.Nursery` |     #   bubbling to this "parent" (much like how `trio.Nursery` | ||||||
|     #   swallows any `trio.Cancelled` bubbled by a call to |     #   swallows any `trio.Cancelled` bubbled by a call to | ||||||
|     #   `Nursery.cancel_scope.cancel()`) |     #   `Nursery.cancel_scope.cancel()`) | ||||||
|     except ContextCancelled as ctxc: |     except ContextCancelled as ctxc: | ||||||
|         scope_err = ctxc |         scope_err = ctxc | ||||||
|         ctx._local_error: BaseException = scope_err |         ctx._local_error: BaseException = scope_err | ||||||
|         ctxc_from_callee = ctxc |         ctxc_from_child = ctxc | ||||||
| 
 | 
 | ||||||
|         # XXX TODO XXX: FIX THIS debug_mode BUGGGG!!! |         # XXX TODO XXX: FIX THIS debug_mode BUGGGG!!! | ||||||
|         # using this code and then resuming the REPL will |         # using this code and then resuming the REPL will | ||||||
|  | @ -2147,7 +2200,7 @@ async def open_context_from_portal( | ||||||
|         #   debugging the tractor-runtime itself using it's |         #   debugging the tractor-runtime itself using it's | ||||||
|         #   own `.devx.` tooling! |         #   own `.devx.` tooling! | ||||||
|         #  |         #  | ||||||
|         # await _debug.pause() |         # await debug.pause() | ||||||
| 
 | 
 | ||||||
|         # CASE 2: context was cancelled by local task calling |         # CASE 2: context was cancelled by local task calling | ||||||
|         # `.cancel()`, we don't raise and the exit block should |         # `.cancel()`, we don't raise and the exit block should | ||||||
|  | @ -2173,11 +2226,11 @@ async def open_context_from_portal( | ||||||
|     # the above `._scope` can be cancelled due to: |     # the above `._scope` can be cancelled due to: | ||||||
|     # 1. an explicit self cancel via `Context.cancel()` or |     # 1. an explicit self cancel via `Context.cancel()` or | ||||||
|     #    `Actor.cancel()`, |     #    `Actor.cancel()`, | ||||||
|     # 2. any "callee"-side remote error, possibly also a cancellation |     # 2. any "child"-side remote error, possibly also a cancellation | ||||||
|     #    request by some peer, |     #    request by some peer, | ||||||
|     # 3. any "caller" (aka THIS scope's) local error raised in the above `yield` |     # 3. any "parent" (aka THIS scope's) local error raised in the above `yield` | ||||||
|     except ( |     except ( | ||||||
|         # CASE 3: standard local error in this caller/yieldee |         # CASE 3: standard local error in this parent/yieldee | ||||||
|         Exception, |         Exception, | ||||||
| 
 | 
 | ||||||
|         # CASES 1 & 2: can manifest as a `ctx._scope_nursery` |         # CASES 1 & 2: can manifest as a `ctx._scope_nursery` | ||||||
|  | @ -2191,9 +2244,9 @@ async def open_context_from_portal( | ||||||
|         #   any `Context._maybe_raise_remote_err()` call. |         #   any `Context._maybe_raise_remote_err()` call. | ||||||
|         # |         # | ||||||
|         # 2.-`BaseExceptionGroup[ContextCancelled | RemoteActorError]` |         # 2.-`BaseExceptionGroup[ContextCancelled | RemoteActorError]` | ||||||
|         #    from any error delivered from the "callee" side |         #    from any error delivered from the "child" side | ||||||
|         #    AND a group-exc is only raised if there was > 1 |         #    AND a group-exc is only raised if there was > 1 | ||||||
|         #    tasks started *here* in the "caller" / opener |         #    tasks started *here* in the "parent" / opener | ||||||
|         #    block. If any one of those tasks calls |         #    block. If any one of those tasks calls | ||||||
|         #    `.wait_for_result()` or `MsgStream.receive()` |         #    `.wait_for_result()` or `MsgStream.receive()` | ||||||
|         #    `._maybe_raise_remote_err()` will be transitively |         #    `._maybe_raise_remote_err()` will be transitively | ||||||
|  | @ -2206,18 +2259,18 @@ async def open_context_from_portal( | ||||||
|         trio.Cancelled,  # NOTE: NOT from inside the ctx._scope |         trio.Cancelled,  # NOTE: NOT from inside the ctx._scope | ||||||
|         KeyboardInterrupt, |         KeyboardInterrupt, | ||||||
| 
 | 
 | ||||||
|     ) as caller_err: |     ) as rent_err: | ||||||
|         scope_err = caller_err |         scope_err = rent_err | ||||||
|         ctx._local_error: BaseException = scope_err |         ctx._local_error: BaseException = scope_err | ||||||
| 
 | 
 | ||||||
|         # XXX: ALWAYS request the context to CANCEL ON any ERROR. |         # XXX: ALWAYS request the context to CANCEL ON any ERROR. | ||||||
|         # NOTE: `Context.cancel()` is conversely NEVER CALLED in |         # NOTE: `Context.cancel()` is conversely NEVER CALLED in | ||||||
|         # the `ContextCancelled` "self cancellation absorbed" case |         # the `ContextCancelled` "self cancellation absorbed" case | ||||||
|         # handled in the block above ^^^ !! |         # handled in the block above ^^^ !! | ||||||
|         # await _debug.pause() |         # await debug.pause() | ||||||
|         # log.cancel( |         # log.cancel( | ||||||
|         match scope_err: |         match scope_err: | ||||||
|             case trio.Cancelled: |             case trio.Cancelled(): | ||||||
|                 logmeth = log.cancel |                 logmeth = log.cancel | ||||||
| 
 | 
 | ||||||
|             # XXX explicitly report on any non-graceful-taskc cases |             # XXX explicitly report on any non-graceful-taskc cases | ||||||
|  | @ -2225,15 +2278,15 @@ async def open_context_from_portal( | ||||||
|                 logmeth = log.exception |                 logmeth = log.exception | ||||||
| 
 | 
 | ||||||
|         logmeth( |         logmeth( | ||||||
|             f'ctx {ctx.side!r}-side exited with {ctx.repr_outcome()}\n' |             f'ctx {ctx.side!r}-side exited with {ctx.repr_outcome()!r}\n' | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         if debug_mode(): |         if debug_mode(): | ||||||
|             # async with _debug.acquire_debug_lock(portal.actor.uid): |             # async with debug.acquire_debug_lock(portal.actor.uid): | ||||||
|             #     pass |             #     pass | ||||||
|             # TODO: factor ^ into below for non-root cases? |             # TODO: factor ^ into below for non-root cases? | ||||||
|             # |             # | ||||||
|             from .devx._debug import maybe_wait_for_debugger |             from .devx.debug import maybe_wait_for_debugger | ||||||
|             was_acquired: bool = await maybe_wait_for_debugger( |             was_acquired: bool = await maybe_wait_for_debugger( | ||||||
|                 # header_msg=( |                 # header_msg=( | ||||||
|                 #     'Delaying `ctx.cancel()` until debug lock ' |                 #     'Delaying `ctx.cancel()` until debug lock ' | ||||||
|  | @ -2246,9 +2299,9 @@ async def open_context_from_portal( | ||||||
|                     'Calling `ctx.cancel()`!\n' |                     'Calling `ctx.cancel()`!\n' | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|         # we don't need to cancel the callee if it already |         # we don't need to cancel the child if it already | ||||||
|         # told us it's cancelled ;p |         # told us it's cancelled ;p | ||||||
|         if ctxc_from_callee is None: |         if ctxc_from_child is None: | ||||||
|             try: |             try: | ||||||
|                 await ctx.cancel() |                 await ctx.cancel() | ||||||
|             except ( |             except ( | ||||||
|  | @ -2279,8 +2332,8 @@ async def open_context_from_portal( | ||||||
|             # via a call to |             # via a call to | ||||||
|             # `Context._maybe_cancel_and_set_remote_error()`. |             # `Context._maybe_cancel_and_set_remote_error()`. | ||||||
|             # As per `Context._deliver_msg()`, that error IS |             # As per `Context._deliver_msg()`, that error IS | ||||||
|             # ALWAYS SET any time "callee" side fails and causes "caller |             # ALWAYS SET any time "child" side fails and causes | ||||||
|             # side" cancellation via a `ContextCancelled` here. |             # "parent side" cancellation via a `ContextCancelled` here. | ||||||
|             try: |             try: | ||||||
|                 result_or_err: Exception|Any = await ctx.wait_for_result() |                 result_or_err: Exception|Any = await ctx.wait_for_result() | ||||||
|             except BaseException as berr: |             except BaseException as berr: | ||||||
|  | @ -2296,8 +2349,8 @@ async def open_context_from_portal( | ||||||
|                 raise |                 raise | ||||||
| 
 | 
 | ||||||
|             # yes this worx! |             # yes this worx! | ||||||
|             # from .devx import _debug |             # from .devx import debug | ||||||
|             # await _debug.pause() |             # await debug.pause() | ||||||
| 
 | 
 | ||||||
|             # an exception type boxed in a `RemoteActorError` |             # an exception type boxed in a `RemoteActorError` | ||||||
|             # is returned (meaning it was obvi not raised) |             # is returned (meaning it was obvi not raised) | ||||||
|  | @ -2316,7 +2369,7 @@ async def open_context_from_portal( | ||||||
|                     ) |                     ) | ||||||
|                 case (None, _): |                 case (None, _): | ||||||
|                     log.runtime( |                     log.runtime( | ||||||
|                         'Context returned final result from callee task:\n' |                         'Context returned final result from child task:\n' | ||||||
|                         f'<= peer: {uid}\n' |                         f'<= peer: {uid}\n' | ||||||
|                         f'  |_ {nsf}()\n\n' |                         f'  |_ {nsf}()\n\n' | ||||||
| 
 | 
 | ||||||
|  | @ -2332,7 +2385,7 @@ async def open_context_from_portal( | ||||||
|         # where the root is waiting on the lock to clear but the |         # where the root is waiting on the lock to clear but the | ||||||
|         # child has already cleared it and clobbered IPC. |         # child has already cleared it and clobbered IPC. | ||||||
|         if debug_mode(): |         if debug_mode(): | ||||||
|             from .devx._debug import maybe_wait_for_debugger |             from .devx.debug import maybe_wait_for_debugger | ||||||
|             await maybe_wait_for_debugger() |             await maybe_wait_for_debugger() | ||||||
| 
 | 
 | ||||||
|         # though it should be impossible for any tasks |         # though it should be impossible for any tasks | ||||||
|  | @ -2371,7 +2424,8 @@ async def open_context_from_portal( | ||||||
|             # displaying `ContextCancelled` traces where the |             # displaying `ContextCancelled` traces where the | ||||||
|             # cause of crash/exit IS due to something in |             # cause of crash/exit IS due to something in | ||||||
|             # user/app code on either end of the context. |             # user/app code on either end of the context. | ||||||
|             and not rxchan._closed |             and | ||||||
|  |             not rxchan._closed | ||||||
|         ): |         ): | ||||||
|             # XXX NOTE XXX: and again as per above, we mask any |             # XXX NOTE XXX: and again as per above, we mask any | ||||||
|             # `trio.Cancelled` raised here so as to NOT mask |             # `trio.Cancelled` raised here so as to NOT mask | ||||||
|  | @ -2410,7 +2464,7 @@ async def open_context_from_portal( | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|             # TODO: should we add a `._cancel_req_received` |             # TODO: should we add a `._cancel_req_received` | ||||||
|             # flag to determine if the callee manually called |             # flag to determine if the child manually called | ||||||
|             # `ctx.cancel()`? |             # `ctx.cancel()`? | ||||||
|             # -[ ] going to need a cid check no? |             # -[ ] going to need a cid check no? | ||||||
| 
 | 
 | ||||||
|  | @ -2430,6 +2484,7 @@ async def open_context_from_portal( | ||||||
|         # FINALLY, remove the context from runtime tracking and |         # FINALLY, remove the context from runtime tracking and | ||||||
|         # exit! |         # exit! | ||||||
|         log.runtime( |         log.runtime( | ||||||
|  |         # log.cancel( | ||||||
|             f'De-allocating IPC ctx opened with {ctx.side!r} peer \n' |             f'De-allocating IPC ctx opened with {ctx.side!r} peer \n' | ||||||
|             f'uid: {uid}\n' |             f'uid: {uid}\n' | ||||||
|             f'cid: {ctx.cid}\n' |             f'cid: {ctx.cid}\n' | ||||||
|  | @ -2465,7 +2520,7 @@ def mk_context( | ||||||
|     recv_chan: trio.MemoryReceiveChannel |     recv_chan: trio.MemoryReceiveChannel | ||||||
|     send_chan, recv_chan = trio.open_memory_channel(msg_buffer_size) |     send_chan, recv_chan = trio.open_memory_channel(msg_buffer_size) | ||||||
| 
 | 
 | ||||||
|     # TODO: only scan caller-info if log level so high! |     # TODO: only scan parent-info if log level so high! | ||||||
|     from .devx._frame_stack import find_caller_info |     from .devx._frame_stack import find_caller_info | ||||||
|     caller_info: CallerInfo|None = find_caller_info() |     caller_info: CallerInfo|None = find_caller_info() | ||||||
| 
 | 
 | ||||||
|  | @ -2485,7 +2540,6 @@ def mk_context( | ||||||
|         _caller_info=caller_info, |         _caller_info=caller_info, | ||||||
|         **kwargs, |         **kwargs, | ||||||
|     ) |     ) | ||||||
|     pld_rx._ctx = ctx |  | ||||||
|     ctx._result = Unresolved |     ctx._result = Unresolved | ||||||
|     return ctx |     return ctx | ||||||
| 
 | 
 | ||||||
|  | @ -2548,7 +2602,14 @@ def context( | ||||||
|     name: str |     name: str | ||||||
|     param: Type |     param: Type | ||||||
|     for name, param in annots.items(): |     for name, param in annots.items(): | ||||||
|         if param is Context: |         if ( | ||||||
|  |             param is Context | ||||||
|  |             or ( | ||||||
|  |                 isinstance(param, UnionType) | ||||||
|  |                 and | ||||||
|  |                 Context in param.__args__ | ||||||
|  |             ) | ||||||
|  |         ): | ||||||
|             ctx_var_name: str = name |             ctx_var_name: str = name | ||||||
|             break |             break | ||||||
|     else: |     else: | ||||||
|  |  | ||||||
|  | @ -28,8 +28,16 @@ from typing import ( | ||||||
| from contextlib import asynccontextmanager as acm | 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 ( | ||||||
| from ._ipc import _connect_chan, Channel |     gather_contexts, | ||||||
|  |     collapse_eg, | ||||||
|  | ) | ||||||
|  | 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,6 +46,7 @@ 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: | ||||||
|  | @ -49,9 +58,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,19 +76,20 @@ 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? |         # `Server._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 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| @acm | @acm | ||||||
| async def get_root( | async def get_root( | ||||||
|     **kwargs, |     **kwargs, | ||||||
|  | @ -89,11 +97,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,17 +113,23 @@ 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 `Server._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() |     to_scan: dict[tuple, list[Channel]] = actor.ipc_server._peers.copy() | ||||||
|     pchan: Channel|None = actor._parent_chan | 
 | ||||||
|     if pchan: |     # TODO: is this ever needed? creates a duplicate channel on actor._peers | ||||||
|         to_scan[pchan.uid].append(pchan) |     # when multiple find_actor calls are made to same actor from a single ctx | ||||||
|  |     # which causes actor exit to hang waiting forever on | ||||||
|  |     # `actor._no_more_peers.wait()` in `_runtime.async_main` | ||||||
|  | 
 | ||||||
|  |     # pchan: Channel|None = actor._parent_chan | ||||||
|  |     # if pchan and pchan.uid not in to_scan: | ||||||
|  |     #     to_scan[pchan.uid].append(pchan) | ||||||
| 
 | 
 | ||||||
|     for aid, chans in to_scan.items(): |     for aid, chans in to_scan.items(): | ||||||
|         _, peer_name = aid |         _, peer_name = aid | ||||||
|  | @ -134,10 +147,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 +176,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 +210,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 +238,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, | ||||||
|  | @ -241,9 +255,12 @@ async def find_actor( | ||||||
|         for addr in registry_addrs |         for addr in registry_addrs | ||||||
|     ) |     ) | ||||||
|     portals: list[Portal] |     portals: list[Portal] | ||||||
|     async with gather_contexts( |     async with ( | ||||||
|         mngrs=maybe_portals, |         collapse_eg(), | ||||||
|     ) as portals: |         gather_contexts( | ||||||
|  |             mngrs=maybe_portals, | ||||||
|  |         ) as portals, | ||||||
|  |     ): | ||||||
|         # log.runtime( |         # log.runtime( | ||||||
|         #     'Gathered portals:\n' |         #     'Gathered portals:\n' | ||||||
|         #     f'{portals}' |         #     f'{portals}' | ||||||
|  | @ -274,7 +291,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 +308,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 +316,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 +325,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 | ||||||
|  |  | ||||||
|  | @ -21,8 +21,7 @@ Sub-process entry points. | ||||||
| from __future__ import annotations | 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,13 @@ from .log import ( | ||||||
|     get_logger, |     get_logger, | ||||||
| ) | ) | ||||||
| from . import _state | from . import _state | ||||||
| from .devx import _debug | from .devx import ( | ||||||
|  |     _frame_stack, | ||||||
|  |     pformat, | ||||||
|  | ) | ||||||
|  | # from .msg import pretty_struct | ||||||
| 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 +56,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 +106,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: | ||||||
|  | @ -214,7 +117,7 @@ def _trio_main( | ||||||
|     Entry point for a `trio_run_in_process` subactor. |     Entry point for a `trio_run_in_process` subactor. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     _debug.hide_runtime_frames() |     _frame_stack.hide_runtime_frames() | ||||||
| 
 | 
 | ||||||
|     _state._current_actor = actor |     _state._current_actor = actor | ||||||
|     trio_main = partial( |     trio_main = partial( | ||||||
|  | @ -225,30 +128,23 @@ def _trio_main( | ||||||
| 
 | 
 | ||||||
|     if actor.loglevel is not None: |     if actor.loglevel is not None: | ||||||
|         get_console_log(actor.loglevel) |         get_console_log(actor.loglevel) | ||||||
|         actor_info: str = ( |  | ||||||
|             f'|_{actor}\n' |  | ||||||
|             f'  uid: {actor.uid}\n' |  | ||||||
|             f'  pid: {os.getpid()}\n' |  | ||||||
|             f'  parent_addr: {parent_addr}\n' |  | ||||||
|             f'  loglevel: {actor.loglevel}\n' |  | ||||||
|         ) |  | ||||||
|         log.info( |         log.info( | ||||||
|             'Starting new `trio` subactor:\n' |             f'Starting `trio` subactor from parent @ ' | ||||||
|  |             f'{parent_addr}\n' | ||||||
|             + |             + | ||||||
|             nest_from_op( |             pformat.nest_from_op( | ||||||
|                 input_op='>(',  # see syntax ideas above |                 input_op='>(',  # see syntax ideas above | ||||||
|                 tree_str=actor_info, |                 text=f'{actor}', | ||||||
|                 back_from_op=2,  # since "complete" |  | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|     logmeth = log.info |     logmeth = log.info | ||||||
|     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, |             text=f'{actor}', | ||||||
|             back_from_op=1, |             nest_indent=1, | ||||||
|         ) |         ) | ||||||
|     ) |     ) | ||||||
|     try: |     try: | ||||||
|  | @ -263,9 +159,9 @@ 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, |                 text=f'{actor}', | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|     except BaseException as err: |     except BaseException as err: | ||||||
|  | @ -273,9 +169,9 @@ 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, |                 text=f'{actor}', | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         # NOTE since we raise a tb will already be shown on the |         # NOTE since we raise a tb will already be shown on the | ||||||
|  |  | ||||||
|  | @ -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: ( | ||||||
|  | @ -432,9 +453,13 @@ class RemoteActorError(Exception): | ||||||
|         Error type boxed by last actor IPC hop. |         Error type boxed by last actor IPC hop. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         if self._boxed_type is None: |         if ( | ||||||
|  |             self._boxed_type is None | ||||||
|  |             and | ||||||
|  |             (ipc_msg := self._ipc_msg) | ||||||
|  |         ): | ||||||
|             self._boxed_type = get_err_type( |             self._boxed_type = get_err_type( | ||||||
|                 self._ipc_msg.boxed_type_str |                 ipc_msg.boxed_type_str | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|         return self._boxed_type |         return self._boxed_type | ||||||
|  | @ -517,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: | ||||||
|  | @ -596,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 | ||||||
|  | @ -666,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 | ||||||
|  | @ -958,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 | ||||||
|  | @ -969,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: | ||||||
|         ''' |         ''' | ||||||
|  | @ -994,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__) | ||||||
|             ) |             ) | ||||||
|  | @ -1005,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" | ||||||
|  | @ -1143,6 +1203,8 @@ def unpack_error( | ||||||
|     which is the responsibilitiy of the caller. |     which is the responsibilitiy of the caller. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|  |     # XXX, apparently we pass all sorts of msgs here? | ||||||
|  |     # kinda odd but seems like maybe they shouldn't be? | ||||||
|     if not isinstance(msg, Error): |     if not isinstance(msg, Error): | ||||||
|         return None |         return None | ||||||
| 
 | 
 | ||||||
|  | @ -1184,55 +1246,6 @@ def unpack_error( | ||||||
|     return exc |     return exc | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def is_multi_cancelled( |  | ||||||
|     exc: BaseException|BaseExceptionGroup, |  | ||||||
| 
 |  | ||||||
|     ignore_nested: set[BaseException] = set(), |  | ||||||
| 
 |  | ||||||
| ) -> bool|BaseExceptionGroup: |  | ||||||
|     ''' |  | ||||||
|     Predicate to determine if an `BaseExceptionGroup` only contains |  | ||||||
|     some (maybe nested) set of sub-grouped exceptions (like only |  | ||||||
|     `trio.Cancelled`s which get swallowed silently by default) and is |  | ||||||
|     thus the result of "gracefully cancelling" a collection of |  | ||||||
|     sub-tasks (or other conc primitives) and receiving a "cancelled |  | ||||||
|     ACK" from each after termination. |  | ||||||
| 
 |  | ||||||
|     Docs: |  | ||||||
|     ---- |  | ||||||
|     - https://docs.python.org/3/library/exceptions.html#exception-groups |  | ||||||
|     - https://docs.python.org/3/library/exceptions.html#BaseExceptionGroup.subgroup |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
| 
 |  | ||||||
|     if ( |  | ||||||
|         not ignore_nested |  | ||||||
|         or |  | ||||||
|         trio.Cancelled in ignore_nested |  | ||||||
|         # XXX always count-in `trio`'s native signal |  | ||||||
|     ): |  | ||||||
|         ignore_nested.update({trio.Cancelled}) |  | ||||||
| 
 |  | ||||||
|     if isinstance(exc, BaseExceptionGroup): |  | ||||||
|         matched_exc: BaseExceptionGroup|None = exc.subgroup( |  | ||||||
|             tuple(ignore_nested), |  | ||||||
| 
 |  | ||||||
|             # TODO, complain about why not allowed XD |  | ||||||
|             # condition=tuple(ignore_nested), |  | ||||||
|         ) |  | ||||||
|         if matched_exc is not None: |  | ||||||
|             return matched_exc |  | ||||||
| 
 |  | ||||||
|     # NOTE, IFF no excs types match (throughout the error-tree) |  | ||||||
|     # -> return `False`, OW return the matched sub-eg. |  | ||||||
|     # |  | ||||||
|     # IOW, for the inverse of ^ for the purpose of |  | ||||||
|     # maybe-enter-REPL--logic: "only debug when the err-tree contains |  | ||||||
|     # at least one exc-type NOT in `ignore_nested`" ; i.e. the case where |  | ||||||
|     # we fallthrough and return `False` here. |  | ||||||
|     return False |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def _raise_from_unexpected_msg( | def _raise_from_unexpected_msg( | ||||||
|     ctx: Context, |     ctx: Context, | ||||||
|     msg: MsgType, |     msg: MsgType, | ||||||
|  |  | ||||||
							
								
								
									
										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() |  | ||||||
|  | @ -39,11 +39,14 @@ import warnings | ||||||
| 
 | 
 | ||||||
| import trio | import trio | ||||||
| 
 | 
 | ||||||
| from .trionics import maybe_open_nursery | from .trionics import ( | ||||||
|  |     maybe_open_nursery, | ||||||
|  |     collapse_eg, | ||||||
|  | ) | ||||||
| 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 +55,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,10 +110,18 @@ 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 | ||||||
|     def chan(self) -> Channel: |     def chan(self) -> Channel: | ||||||
|  |         ''' | ||||||
|  |         Ref to this ctx's underlying `tractor.ipc.Channel`. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|         return self._chan |         return self._chan | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|  | @ -170,10 +181,17 @@ 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: | ||||||
|  |             peer_id: str = f'{self.channel.aid.reprol()!r}' | ||||||
|             log.warning( |             log.warning( | ||||||
|                 f"Portal for {self.channel.uid} not expecting a final" |                 f'Portal to peer {peer_id} will not deliver a final result?\n' | ||||||
|                 " result?\nresult() should only be called if subactor" |                 f'\n' | ||||||
|                 " was spawned with `ActorNursery.run_in_actor()`") |                 f'Context.result() can only be called by the parent of ' | ||||||
|  |                 f'a sub-actor when it was spawned with ' | ||||||
|  |                 f'`ActorNursery.run_in_actor()`' | ||||||
|  |                 f'\n' | ||||||
|  |                 f'Further this `ActorNursery`-method-API will deprecated in the' | ||||||
|  |                 f'near fututre!\n' | ||||||
|  |             ) | ||||||
|             return NoResult |             return NoResult | ||||||
| 
 | 
 | ||||||
|         # expecting a "main" result |         # expecting a "main" result | ||||||
|  | @ -184,7 +202,7 @@ class Portal: | ||||||
|                 ( |                 ( | ||||||
|                     self._final_result_msg, |                     self._final_result_msg, | ||||||
|                     self._final_result_pld, |                     self._final_result_pld, | ||||||
|                 ) = await self._expect_result_ctx._pld_rx.recv_msg_w_pld( |                 ) = await self._expect_result_ctx._pld_rx.recv_msg( | ||||||
|                     ipc=self._expect_result_ctx, |                     ipc=self._expect_result_ctx, | ||||||
|                     expect_msg=Return, |                     expect_msg=Return, | ||||||
|                 ) |                 ) | ||||||
|  | @ -206,6 +224,7 @@ class Portal: | ||||||
|         typname: str = type(self).__name__ |         typname: str = type(self).__name__ | ||||||
|         log.warning( |         log.warning( | ||||||
|             f'`{typname}.result()` is DEPRECATED!\n' |             f'`{typname}.result()` is DEPRECATED!\n' | ||||||
|  |             f'\n' | ||||||
|             f'Use `{typname}.wait_for_result()` instead!\n' |             f'Use `{typname}.wait_for_result()` instead!\n' | ||||||
|         ) |         ) | ||||||
|         return await self.wait_for_result( |         return await self.wait_for_result( | ||||||
|  | @ -217,8 +236,10 @@ class Portal: | ||||||
|         # terminate all locally running async generator |         # terminate all locally running async generator | ||||||
|         # IPC calls |         # IPC calls | ||||||
|         if self._streams: |         if self._streams: | ||||||
|             log.cancel( |             peer_id: str = f'{self.channel.aid.reprol()!r}' | ||||||
|                 f"Cancelling all streams with {self.channel.uid}") |             report: str = ( | ||||||
|  |                 f'Cancelling all msg-streams with {peer_id}\n' | ||||||
|  |             ) | ||||||
|             for stream in self._streams.copy(): |             for stream in self._streams.copy(): | ||||||
|                 try: |                 try: | ||||||
|                     await stream.aclose() |                     await stream.aclose() | ||||||
|  | @ -227,10 +248,18 @@ class Portal: | ||||||
|                     # (unless of course at some point down the road we |                     # (unless of course at some point down the road we | ||||||
|                     # won't expect this to always be the case or need to |                     # won't expect this to always be the case or need to | ||||||
|                     # detect it for respawning purposes?) |                     # detect it for respawning purposes?) | ||||||
|                     log.debug(f"{stream} was already closed.") |                     report += ( | ||||||
|  |                         f'->) {stream!r} already closed\n' | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |             log.cancel(report) | ||||||
| 
 | 
 | ||||||
|     async def aclose(self): |     async def aclose(self): | ||||||
|         log.debug(f"Closing {self}") |         log.debug( | ||||||
|  |             f'Closing portal\n' | ||||||
|  |             f'>}}\n' | ||||||
|  |             f'|_{self}\n' | ||||||
|  |         ) | ||||||
|         # TODO: once we move to implementing our own `ReceiveChannel` |         # TODO: once we move to implementing our own `ReceiveChannel` | ||||||
|         # (including remote task cancellation inside its `.aclose()`) |         # (including remote task cancellation inside its `.aclose()`) | ||||||
|         # we'll need to .aclose all those channels here |         # we'll need to .aclose all those channels here | ||||||
|  | @ -256,23 +285,22 @@ class Portal: | ||||||
|         __runtimeframe__: int = 1  # noqa |         __runtimeframe__: int = 1  # noqa | ||||||
| 
 | 
 | ||||||
|         chan: Channel = self.channel |         chan: Channel = self.channel | ||||||
|  |         peer_id: str = f'{self.channel.aid.reprol()!r}' | ||||||
|         if not chan.connected(): |         if not chan.connected(): | ||||||
|             log.runtime( |             log.runtime( | ||||||
|                 'This channel is already closed, skipping cancel request..' |                 'Peer {peer_id} is already disconnected\n' | ||||||
|  |                 '-> skipping cancel request..\n' | ||||||
|             ) |             ) | ||||||
|             return False |             return False | ||||||
| 
 | 
 | ||||||
|         reminfo: str = ( |  | ||||||
|             f'c)=> {self.channel.uid}\n' |  | ||||||
|             f'  |_{chan}\n' |  | ||||||
|         ) |  | ||||||
|         log.cancel( |         log.cancel( | ||||||
|             f'Requesting actor-runtime cancel for peer\n\n' |             f'Sending actor-runtime-cancel-req to peer\n' | ||||||
|             f'{reminfo}' |             f'\n' | ||||||
|  |             f'c)=> {peer_id}\n' | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         # XXX the one spot we set it? |         # XXX the one spot we set it? | ||||||
|         self.channel._cancel_called: bool = True |         chan._cancel_called: bool = True | ||||||
|         try: |         try: | ||||||
|             # send cancel cmd - might not get response |             # send cancel cmd - might not get response | ||||||
|             # XXX: sure would be nice to make this work with |             # XXX: sure would be nice to make this work with | ||||||
|  | @ -293,22 +321,43 @@ class Portal: | ||||||
|                 # may timeout and we never get an ack (obvi racy) |                 # may timeout and we never get an ack (obvi racy) | ||||||
|                 # but that doesn't mean it wasn't cancelled. |                 # but that doesn't mean it wasn't cancelled. | ||||||
|                 log.debug( |                 log.debug( | ||||||
|                     'May have failed to cancel peer?\n' |                     f'May have failed to cancel peer?\n' | ||||||
|                     f'{reminfo}' |                     f'\n' | ||||||
|  |                     f'c)=?> {peer_id}\n' | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|             # if we get here some weird cancellation case happened |             # if we get here some weird cancellation case happened | ||||||
|             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' |             ipc_borked_report: str = ( | ||||||
|                 f' |_{self.channel}\n' |                 f'IPC for actor already closed/broken?\n\n' | ||||||
|  |                 f'\n' | ||||||
|  |                 f'c)=x> {peer_id}\n' | ||||||
|             ) |             ) | ||||||
|  |             match tpt_err: | ||||||
|  |                 case TransportClosed(): | ||||||
|  |                     log.debug(ipc_borked_report) | ||||||
|  |                 case _: | ||||||
|  |                     ipc_borked_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(ipc_borked_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 | ||||||
|  | @ -464,10 +513,13 @@ class Portal: | ||||||
|                 with trio.CancelScope(shield=True): |                 with trio.CancelScope(shield=True): | ||||||
|                     await ctx.cancel() |                     await ctx.cancel() | ||||||
| 
 | 
 | ||||||
|             except trio.ClosedResourceError: |             except trio.ClosedResourceError as cre: | ||||||
|                 # if the far end terminates before we send a cancel the |                 # if the far end terminates before we send a cancel the | ||||||
|                 # underlying transport-channel may already be closed. |                 # underlying transport-channel may already be closed. | ||||||
|                 log.cancel(f'Context {ctx} was already closed?') |                 log.cancel( | ||||||
|  |                     f'Context.cancel() -> {cre!r}\n' | ||||||
|  |                     f'cid: {ctx.cid!r} already closed?\n' | ||||||
|  |                 ) | ||||||
| 
 | 
 | ||||||
|             # XXX: should this always be done? |             # XXX: should this always be done? | ||||||
|             # await recv_chan.aclose() |             # await recv_chan.aclose() | ||||||
|  | @ -504,8 +556,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) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -530,30 +586,30 @@ async def open_portal( | ||||||
|     assert actor |     assert actor | ||||||
|     was_connected: bool = False |     was_connected: bool = False | ||||||
| 
 | 
 | ||||||
|     async with maybe_open_nursery( |     async with ( | ||||||
|         tn, |         collapse_eg(), | ||||||
|         shield=shield, |         maybe_open_nursery( | ||||||
|         strict_exception_groups=False, |             tn, | ||||||
|         # ^XXX^ TODO? soo roll our own then ?? |             shield=shield, | ||||||
|         # -> since we kinda want the "if only one `.exception` then |         ) as tn, | ||||||
|         # just raise that" interface? |     ): | ||||||
|     ) as tn: |  | ||||||
| 
 | 
 | ||||||
|         if not channel.connected(): |         if not channel.connected(): | ||||||
|             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, |                     chan=channel, | ||||||
|                     channel, |  | ||||||
|                     # if the local task is cancelled we want to keep |                     # if the local task is cancelled we want to keep | ||||||
|                     # the msg loop running until our block ends |                     # the msg loop running until our block ends | ||||||
|                     shield=True, |                     shield=True, | ||||||
|  |  | ||||||
							
								
								
									
										872
									
								
								tractor/_root.py
								
								
								
								
							
							
						
						
									
										872
									
								
								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,96 +28,55 @@ 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 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| import trio | import trio | ||||||
| 
 | 
 | ||||||
| from ._runtime import ( | from . import _runtime | ||||||
|     Actor, | from .devx import ( | ||||||
|     Arbiter, |     debug, | ||||||
|     # TODO: rename and make a non-actor subtype? |     _frame_stack, | ||||||
|     # Arbiter as Registry, |     pformat as _pformat, | ||||||
|     async_main, |  | ||||||
| ) | ) | ||||||
| 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 .trionics import ( | ||||||
|     _default_port, |     is_multi_cancelled, | ||||||
| )] |     collapse_eg, | ||||||
|  | ) | ||||||
|  | from ._exceptions import ( | ||||||
|  |     RuntimeFailure, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 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 | @acm | ||||||
| async def open_root_actor( | async def maybe_block_bp( | ||||||
| 
 |     debug_mode: bool, | ||||||
|     *, |     maybe_enable_greenback: bool, | ||||||
|     # defaults are above | ) -> bool: | ||||||
|     registry_addrs: list[tuple[str, int]]|None = None, |  | ||||||
| 
 |  | ||||||
|     # defaults are above |  | ||||||
|     arbiter_addr: tuple[str, int]|None = None, |  | ||||||
| 
 |  | ||||||
|     name: str|None = 'root', |  | ||||||
| 
 |  | ||||||
|     # either the `multiprocessing` start method: |  | ||||||
|     # https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods |  | ||||||
|     # OR `trio` (the new default). |  | ||||||
|     start_method: _spawn.SpawnMethodKey|None = None, |  | ||||||
| 
 |  | ||||||
|     # enables the multi-process debugger support |  | ||||||
|     debug_mode: bool = False, |  | ||||||
|     maybe_enable_greenback: bool = True,  # `.pause_from_sync()/breakpoint()` support |  | ||||||
|     enable_stack_on_sig: bool = False, |  | ||||||
| 
 |  | ||||||
|     # internal logging |  | ||||||
|     loglevel: str|None = None, |  | ||||||
| 
 |  | ||||||
|     enable_modules: list|None = None, |  | ||||||
|     rpc_module_paths: list|None = None, |  | ||||||
| 
 |  | ||||||
|     # NOTE: allow caller to ensure that only one registry exists |  | ||||||
|     # and that this call creates it. |  | ||||||
|     ensure_registry: bool = False, |  | ||||||
| 
 |  | ||||||
|     hide_tb: bool = True, |  | ||||||
| 
 |  | ||||||
|     # XXX, proxied directly to `.devx._debug._maybe_enter_pm()` |  | ||||||
|     # for REPL-entry logic. |  | ||||||
|     debug_filter: Callable[ |  | ||||||
|         [BaseException|BaseExceptionGroup], |  | ||||||
|         bool, |  | ||||||
|     ] = lambda err: not is_multi_cancelled(err), |  | ||||||
| 
 |  | ||||||
|     # TODO, a way for actors to augment passing derived |  | ||||||
|     # read-only state to sublayers? |  | ||||||
|     # extra_rt_vars: dict|None = None, |  | ||||||
| 
 |  | ||||||
| ) -> Actor: |  | ||||||
|     ''' |  | ||||||
|     Runtime init entry point for ``tractor``. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     _debug.hide_runtime_frames() |  | ||||||
|     __tracebackhide__: bool = hide_tb |  | ||||||
| 
 |  | ||||||
|     # TODO: stick this in a `@cm` defined in `devx._debug`? |  | ||||||
|     # |  | ||||||
|     # Override the global debugger hook to make it play nice with |     # Override the global debugger hook to make it play nice with | ||||||
|     # ``trio``, see much discussion in: |     # ``trio``, see much discussion in: | ||||||
|     # https://github.com/python-trio/trio/issues/1155#issuecomment-742964018 |     # https://github.com/python-trio/trio/issues/1155#issuecomment-742964018 | ||||||
|  | @ -124,23 +85,25 @@ async def open_root_actor( | ||||||
|         'PYTHONBREAKPOINT', |         'PYTHONBREAKPOINT', | ||||||
|         None, |         None, | ||||||
|     ) |     ) | ||||||
|  |     bp_blocked: bool | ||||||
|     if ( |     if ( | ||||||
|         debug_mode |         debug_mode | ||||||
|         and maybe_enable_greenback |         and maybe_enable_greenback | ||||||
|         and ( |         and ( | ||||||
|             maybe_mod := await _debug.maybe_init_greenback( |             maybe_mod := await debug.maybe_init_greenback( | ||||||
|                 raise_not_found=False, |                 raise_not_found=False, | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|     ): |     ): | ||||||
|         logger.info( |         logger.info( | ||||||
|             f'Found `greenback` installed @ {maybe_mod}\n' |             f'Found `greenback` installed @ {maybe_mod}\n' | ||||||
|             'Enabling `tractor.pause_from_sync()` support!\n' |             f'Enabling `tractor.pause_from_sync()` support!\n' | ||||||
|         ) |         ) | ||||||
|         os.environ['PYTHONBREAKPOINT'] = ( |         os.environ['PYTHONBREAKPOINT'] = ( | ||||||
|             'tractor.devx._debug._sync_pause_from_builtin' |             'tractor.devx.debug._sync_pause_from_builtin' | ||||||
|         ) |         ) | ||||||
|         _state._runtime_vars['use_greenback'] = True |         _state._runtime_vars['use_greenback'] = True | ||||||
|  |         bp_blocked = False | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         # TODO: disable `breakpoint()` by default (without |         # TODO: disable `breakpoint()` by default (without | ||||||
|  | @ -159,302 +122,481 @@ async def open_root_actor( | ||||||
|         # lol ok, |         # lol ok, | ||||||
|         # https://docs.python.org/3/library/sys.html#sys.breakpointhook |         # https://docs.python.org/3/library/sys.html#sys.breakpointhook | ||||||
|         os.environ['PYTHONBREAKPOINT'] = "0" |         os.environ['PYTHONBREAKPOINT'] = "0" | ||||||
|  |         bp_blocked = True | ||||||
| 
 | 
 | ||||||
|     # attempt to retreive ``trio``'s sigint handler and stash it |  | ||||||
|     # on our debugger lock state. |  | ||||||
|     _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) |  | ||||||
| 
 |  | ||||||
|     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: |     try: | ||||||
|         # assign process-local actor |         yield bp_blocked | ||||||
|         _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: |     finally: | ||||||
|         _state._current_actor = None |         # restore any prior built-in `breakpoint()` hook state | ||||||
|         _state._last_actor_terminated = actor |         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 | ||||||
|  | async def open_root_actor( | ||||||
|  |     *, | ||||||
|  |     # defaults are above | ||||||
|  |     registry_addrs: list[UnwrappedAddress]|None = None, | ||||||
|  | 
 | ||||||
|  |     # defaults are above | ||||||
|  |     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', | ||||||
|  | 
 | ||||||
|  |     # either the `multiprocessing` start method: | ||||||
|  |     # https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods | ||||||
|  |     # OR `trio` (the new default). | ||||||
|  |     start_method: _spawn.SpawnMethodKey|None = None, | ||||||
|  | 
 | ||||||
|  |     # enables the multi-process debugger support | ||||||
|  |     debug_mode: bool = False, | ||||||
|  |     maybe_enable_greenback: bool = False,  # `.pause_from_sync()/breakpoint()` support | ||||||
|  |     # ^XXX NOTE^ the perf implications of use, | ||||||
|  |     # https://greenback.readthedocs.io/en/latest/principle.html#performance | ||||||
|  |     enable_stack_on_sig: bool = False, | ||||||
|  | 
 | ||||||
|  |     # internal logging | ||||||
|  |     loglevel: str|None = None, | ||||||
|  | 
 | ||||||
|  |     enable_modules: list|None = None, | ||||||
|  |     rpc_module_paths: list|None = None, | ||||||
|  | 
 | ||||||
|  |     # NOTE: allow caller to ensure that only one registry exists | ||||||
|  |     # and that this call creates it. | ||||||
|  |     ensure_registry: bool = False, | ||||||
|  | 
 | ||||||
|  |     hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  |     # XXX, proxied directly to `.devx.debug._maybe_enter_pm()` | ||||||
|  |     # for REPL-entry logic. | ||||||
|  |     debug_filter: Callable[ | ||||||
|  |         [BaseException|BaseExceptionGroup], | ||||||
|  |         bool, | ||||||
|  |     ] = lambda err: not is_multi_cancelled(err), | ||||||
|  | 
 | ||||||
|  |     # TODO, a way for actors to augment passing derived | ||||||
|  |     # read-only state to sublayers? | ||||||
|  |     # extra_rt_vars: dict|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> _runtime.Actor: | ||||||
|  |     ''' | ||||||
|  |     Initialize the `tractor` runtime by starting a "root actor" in | ||||||
|  |     a parent-most Python process. | ||||||
|  | 
 | ||||||
|  |     All (disjoint) actor-process-trees-as-programs are created via | ||||||
|  |     this entrypoint. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # XXX NEVER allow nested actor-trees! | ||||||
|  |     if already_actor := _state.current_actor( | ||||||
|  |         err_on_no_runtime=False, | ||||||
|  |     ): | ||||||
|  |         rtvs: dict[str, Any] = _state._runtime_vars | ||||||
|  |         root_mailbox: list[str, int] = rtvs['_root_mailbox'] | ||||||
|  |         registry_addrs: list[list[str, int]] = rtvs['_registry_addrs'] | ||||||
|  |         raise RuntimeFailure( | ||||||
|  |             f'A current actor already exists !?\n' | ||||||
|  |             f'({already_actor}\n' | ||||||
|  |             f'\n' | ||||||
|  |             f'You can NOT open a second root actor from within ' | ||||||
|  |             f'an existing tree and the current root of this ' | ||||||
|  |             f'already exists !!\n' | ||||||
|  |             f'\n' | ||||||
|  |             f'_root_mailbox: {root_mailbox!r}\n' | ||||||
|  |             f'_registry_addrs: {registry_addrs!r}\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     async with maybe_block_bp( | ||||||
|  |         debug_mode=debug_mode, | ||||||
|  |         maybe_enable_greenback=maybe_enable_greenback, | ||||||
|  |     ): | ||||||
|  |         if enable_transports is None: | ||||||
|  |             enable_transports: list[str] = _state.current_ipc_protos() | ||||||
|  |         else: | ||||||
|  |             _state._runtime_vars['_enable_tpts'] = enable_transports | ||||||
|  | 
 | ||||||
|  |         # TODO! support multi-tpts per actor! | ||||||
|  |         # Bo | ||||||
|  |         if not len(enable_transports) == 1: | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 f'No multi-tpt support yet!\n' | ||||||
|  |                 f'enable_transports={enable_transports!r}\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         _frame_stack.hide_runtime_frames() | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|  | 
 | ||||||
|  |         # attempt to retreive ``trio``'s sigint handler and stash it | ||||||
|  |         # on our debugger lock state. | ||||||
|  |         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, | ||||||
|  |             ) | ||||||
|  |             uw_reg_addrs = [arbiter_addr] | ||||||
|  | 
 | ||||||
|  |         uw_reg_addrs = registry_addrs | ||||||
|  |         if not uw_reg_addrs: | ||||||
|  |             uw_reg_addrs: list[UnwrappedAddress] = default_lo_addrs( | ||||||
|  |                 enable_transports | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         # must exist by now since all below code is dependent | ||||||
|  |         assert uw_reg_addrs | ||||||
|  |         registry_addrs: list[Address] = [ | ||||||
|  |             wrap_address(uw_addr) | ||||||
|  |             for uw_addr in uw_reg_addrs | ||||||
|  |         ] | ||||||
|  | 
 | ||||||
|  |         loglevel = ( | ||||||
|  |             loglevel | ||||||
|  |             or log._default_loglevel | ||||||
|  |         ).upper() | ||||||
| 
 | 
 | ||||||
|         # 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._tty_lock') | ||||||
| 
 | 
 | ||||||
|             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[Address] = [] | ||||||
|  | 
 | ||||||
|  |         async def ping_tpt_socket( | ||||||
|  |             addr: Address, | ||||||
|  |             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.unwrap()): | ||||||
|  |                         ponged_addrs.append(addr) | ||||||
|  | 
 | ||||||
|  |             except OSError: | ||||||
|  |                 # ?TODO, make this a "discovery" log level? | ||||||
|  |                 logger.info( | ||||||
|  |                     f'No root-actor registry found @ {addr!r}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         # !TODO, this is basically just another (abstract) | ||||||
|  |         # happy-eyeballs, so we should try for formalize it somewhere | ||||||
|  |         # in a `.[_]discovery` ya? | ||||||
|  |         # | ||||||
|  |         async with trio.open_nursery() as tn: | ||||||
|  |             for uw_addr in uw_reg_addrs: | ||||||
|  |                 addr: Address = wrap_address(uw_addr) | ||||||
|  |                 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 = _runtime.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 | ||||||
|  |             # ipc-transport-server's bind-addrs as this is | ||||||
|  |             # a new NON-registrar, ROOT-actor. | ||||||
|  |             # | ||||||
|  |             # XXX INSTEAD, bind random addrs using the same tpt | ||||||
|  |             # proto. | ||||||
|  |             for addr in ponged_addrs: | ||||||
|  |                 trans_bind_addrs.append( | ||||||
|  |                     addr.get_random( | ||||||
|  |                         bindspace=addr.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 = uw_reg_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 | ||||||
|  | 
 | ||||||
|  |             # TODO: rename as `RootActor` or is that even necessary? | ||||||
|  |             actor = _runtime.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'] | ||||||
|  | 
 | ||||||
|  |         # NOTE, only set the loopback addr for the | ||||||
|  |         # process-tree-global "root" mailbox since all sub-actors | ||||||
|  |         # should be able to speak to their root actor over that | ||||||
|  |         # channel. | ||||||
|  |         raddrs: list[Address] = _state._runtime_vars['_root_addrs'] | ||||||
|  |         raddrs.extend(trans_bind_addrs) | ||||||
|  |         # TODO, remove once we have also removed all usage; | ||||||
|  |         # eventually all (root-)registry apis should expect > 1 addr. | ||||||
|  |         _state._runtime_vars['_root_mailbox'] = raddrs[0] | ||||||
|  | 
 | ||||||
|  |         # 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 | ||||||
|  |             report: str = f'Starting actor-runtime for {actor.aid.reprol()!r}\n' | ||||||
|  |             if reg_addrs := actor.registry_addrs: | ||||||
|  |                 report += ( | ||||||
|  |                     '-> Opening new registry @ ' | ||||||
|  |                     + | ||||||
|  |                     '\n'.join( | ||||||
|  |                         f'{addr}' for addr in reg_addrs | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             logger.info(f'{report}\n') | ||||||
|  | 
 | ||||||
|  |             # start runtime in a bg sub-task, yield to caller. | ||||||
|  |             async with ( | ||||||
|  |                 collapse_eg(), | ||||||
|  |                 trio.open_nursery() as root_tn, | ||||||
|  | 
 | ||||||
|  |                 # ?TODO? finally-footgun below? | ||||||
|  |                 # -> see note on why shielding. | ||||||
|  |                 # maybe_raise_from_masking_exc(), | ||||||
|  |             ): | ||||||
|  |                 actor._root_tn = root_tn | ||||||
|  |                 # `_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 root_tn.start( | ||||||
|  |                     partial( | ||||||
|  |                         _runtime.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, | ||||||
|  | 
 | ||||||
|  |                         # XXX NOTE, required to debug root-actor | ||||||
|  |                         # crashes under cancellation conditions; so | ||||||
|  |                         # most of them! | ||||||
|  |                         shield=root_tn.cancel_scope.cancel_called, | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                     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/TODO?, 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) | ||||||
|  | 
 | ||||||
|  |                     op_nested_actor_repr: str = _pformat.nest_from_op( | ||||||
|  |                         input_op='>) ', | ||||||
|  |                         text=actor.pformat(), | ||||||
|  |                         nest_prefix='|_', | ||||||
|  |                     ) | ||||||
|  |                     logger.info( | ||||||
|  |                         f'Closing down root actor\n' | ||||||
|  |                         f'{op_nested_actor_repr}' | ||||||
|  |                     ) | ||||||
|  |                     # XXX, THIS IS A *finally-footgun*! | ||||||
|  |                     # (also mentioned in with-block above) | ||||||
|  |                     # -> though already shields iternally it can | ||||||
|  |                     # taskc here and mask underlying errors raised in | ||||||
|  |                     # the try-block above? | ||||||
|  |                     with trio.CancelScope(shield=True): | ||||||
|  |                         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 | ||||||
|  | 
 | ||||||
|  |             sclang_repr: str = _pformat.nest_from_op( | ||||||
|  |                 input_op=')>', | ||||||
|  |                 text=actor.pformat(), | ||||||
|  |                 nest_prefix='|_', | ||||||
|  |                 nest_indent=1, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             logger.info( | ||||||
|  |                 f'Root actor terminated\n' | ||||||
|  |                 f'{sclang_repr}' | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def run_daemon( | def run_daemon( | ||||||
|  | @ -462,7 +604,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, | ||||||
|  |  | ||||||
							
								
								
									
										291
									
								
								tractor/_rpc.py
								
								
								
								
							
							
						
						
									
										291
									
								
								tractor/_rpc.py
								
								
								
								
							|  | @ -37,12 +37,13 @@ import warnings | ||||||
| 
 | 
 | ||||||
| import trio | import trio | ||||||
| from trio import ( | from trio import ( | ||||||
|  |     Cancelled, | ||||||
|     CancelScope, |     CancelScope, | ||||||
|     Nursery, |     Nursery, | ||||||
|     TaskStatus, |     TaskStatus, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| from ._ipc import Channel | from .ipc import Channel | ||||||
| from ._context import ( | from ._context import ( | ||||||
|     Context, |     Context, | ||||||
| ) | ) | ||||||
|  | @ -52,13 +53,18 @@ from ._exceptions import ( | ||||||
|     ModuleNotExposed, |     ModuleNotExposed, | ||||||
|     MsgTypeError, |     MsgTypeError, | ||||||
|     TransportClosed, |     TransportClosed, | ||||||
|     is_multi_cancelled, |  | ||||||
|     pack_error, |     pack_error, | ||||||
|     unpack_error, |     unpack_error, | ||||||
| ) | ) | ||||||
|  | from .trionics import ( | ||||||
|  |     collapse_eg, | ||||||
|  |     is_multi_cancelled, | ||||||
|  |     maybe_raise_from_masking_exc, | ||||||
|  | ) | ||||||
| from .devx import ( | from .devx import ( | ||||||
|     _debug, |     debug, | ||||||
|     add_div, |     add_div, | ||||||
|  |     pformat as _pformat, | ||||||
| ) | ) | ||||||
| from . import _state | from . import _state | ||||||
| from .log import get_logger | from .log import get_logger | ||||||
|  | @ -67,7 +73,7 @@ from .msg import ( | ||||||
|     MsgCodec, |     MsgCodec, | ||||||
|     PayloadT, |     PayloadT, | ||||||
|     NamespacePath, |     NamespacePath, | ||||||
|     # pretty_struct, |     pretty_struct, | ||||||
|     _ops as msgops, |     _ops as msgops, | ||||||
| ) | ) | ||||||
| from tractor.msg.types import ( | from tractor.msg.types import ( | ||||||
|  | @ -215,11 +221,18 @@ async def _invoke_non_context( | ||||||
|             task_status.started(ctx) |             task_status.started(ctx) | ||||||
|             result = await coro |             result = await coro | ||||||
|             fname: str = func.__name__ |             fname: str = func.__name__ | ||||||
|  | 
 | ||||||
|  |             op_nested_task: str = _pformat.nest_from_op( | ||||||
|  |                 input_op=f')> cid: {ctx.cid!r}', | ||||||
|  |                 text=f'{ctx._task}', | ||||||
|  |                 nest_indent=1,  # under > | ||||||
|  |             ) | ||||||
|             log.runtime( |             log.runtime( | ||||||
|                 'RPC complete:\n' |                 f'RPC task complete\n' | ||||||
|                 f'task: {ctx._task}\n' |                 f'\n' | ||||||
|                 f'|_cid={ctx.cid}\n' |                 f'{op_nested_task}\n' | ||||||
|                 f'|_{fname}() -> {pformat(result)}\n' |                 f'\n' | ||||||
|  |                 f')> {fname}() -> {pformat(result)}\n' | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|             # NOTE: only send result if we know IPC isn't down |             # NOTE: only send result if we know IPC isn't down | ||||||
|  | @ -250,7 +263,7 @@ async def _errors_relayed_via_ipc( | ||||||
|     ctx: Context, |     ctx: Context, | ||||||
|     is_rpc: bool, |     is_rpc: bool, | ||||||
| 
 | 
 | ||||||
|     hide_tb: bool = False, |     hide_tb: bool = True, | ||||||
|     debug_kbis: bool = False, |     debug_kbis: bool = False, | ||||||
|     task_status: TaskStatus[ |     task_status: TaskStatus[ | ||||||
|         Context | BaseException |         Context | BaseException | ||||||
|  | @ -266,7 +279,7 @@ async def _errors_relayed_via_ipc( | ||||||
| 
 | 
 | ||||||
|     # TODO: a debug nursery when in debug mode! |     # TODO: a debug nursery when in debug mode! | ||||||
|     # async with maybe_open_debugger_nursery() as debug_tn: |     # async with maybe_open_debugger_nursery() as debug_tn: | ||||||
|     # => see matching comment in side `._debug._pause()` |     # => see matching comment in side `.debug._pause()` | ||||||
|     rpc_err: BaseException|None = None |     rpc_err: BaseException|None = None | ||||||
|     try: |     try: | ||||||
|         yield  # run RPC invoke body |         yield  # run RPC invoke body | ||||||
|  | @ -318,7 +331,7 @@ async def _errors_relayed_via_ipc( | ||||||
|                     'RPC task crashed, attempting to enter debugger\n' |                     'RPC task crashed, attempting to enter debugger\n' | ||||||
|                     f'|_{ctx}' |                     f'|_{ctx}' | ||||||
|                 ) |                 ) | ||||||
|                 entered_debug = await _debug._maybe_enter_pm( |                 entered_debug = await debug._maybe_enter_pm( | ||||||
|                     err, |                     err, | ||||||
|                     api_frame=inspect.currentframe(), |                     api_frame=inspect.currentframe(), | ||||||
|                 ) |                 ) | ||||||
|  | @ -371,13 +384,13 @@ async def _errors_relayed_via_ipc( | ||||||
| 
 | 
 | ||||||
|     # RPC task bookeeping. |     # RPC task bookeeping. | ||||||
|     # since RPC tasks are scheduled inside a flat |     # since RPC tasks are scheduled inside a flat | ||||||
|     # `Actor._service_n`, we add "handles" to each such that |     # `Actor._service_tn`, we add "handles" to each such that | ||||||
|     # they can be individually ccancelled. |     # they can be individually ccancelled. | ||||||
|     finally: |     finally: | ||||||
| 
 | 
 | ||||||
|         # if the error is not from user code and instead a failure |         # if the error is not from user code and instead a failure of | ||||||
|         # of a runtime RPC or transport failure we do prolly want to |         # an internal-runtime-RPC or IPC-connection, we do (prolly) want | ||||||
|         # show this frame |         # to show this frame! | ||||||
|         if ( |         if ( | ||||||
|             rpc_err |             rpc_err | ||||||
|             and ( |             and ( | ||||||
|  | @ -449,7 +462,7 @@ async def _invoke( | ||||||
|     connected IPC channel. |     connected IPC channel. | ||||||
| 
 | 
 | ||||||
|     This is the core "RPC" `trio.Task` scheduling machinery used to start every |     This is the core "RPC" `trio.Task` scheduling machinery used to start every | ||||||
|     remotely invoked function, normally in `Actor._service_n: Nursery`. |     remotely invoked function, normally in `Actor._service_tn: Nursery`. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     __tracebackhide__: bool = hide_tb |     __tracebackhide__: bool = hide_tb | ||||||
|  | @ -462,7 +475,7 @@ async def _invoke( | ||||||
|     ): |     ): | ||||||
|         # XXX for .pause_from_sync()` usage we need to make sure |         # XXX for .pause_from_sync()` usage we need to make sure | ||||||
|         # `greenback` is boostrapped in the subactor! |         # `greenback` is boostrapped in the subactor! | ||||||
|         await _debug.maybe_init_greenback() |         await debug.maybe_init_greenback() | ||||||
| 
 | 
 | ||||||
|     # TODO: possibly a specially formatted traceback |     # TODO: possibly a specially formatted traceback | ||||||
|     # (not sure what typing is for this..)? |     # (not sure what typing is for this..)? | ||||||
|  | @ -616,32 +629,40 @@ async def _invoke( | ||||||
|         #  -> the below scope is never exposed to the |         #  -> the below scope is never exposed to the | ||||||
|         #     `@context` marked RPC function. |         #     `@context` marked RPC function. | ||||||
|         # - `._portal` is never set. |         # - `._portal` is never set. | ||||||
|  |         scope_err: BaseException|None = None | ||||||
|         try: |         try: | ||||||
|             tn: trio.Nursery |             # TODO: better `trionics` primitive/tooling usage here! | ||||||
|  |             # -[ ] should would be nice to have our `TaskMngr` | ||||||
|  |             #   nursery here! | ||||||
|  |             # -[ ] payload value checking like we do with | ||||||
|  |             #   `.started()` such that the debbuger can engage | ||||||
|  |             #   here in the child task instead of waiting for the | ||||||
|  |             #   parent to crash with it's own MTE.. | ||||||
|  |             # | ||||||
|  |             tn: Nursery | ||||||
|             rpc_ctx_cs: CancelScope |             rpc_ctx_cs: CancelScope | ||||||
|             async with ( |             async with ( | ||||||
|                 trio.open_nursery( |                 collapse_eg(hide_tb=False), | ||||||
|                     strict_exception_groups=False, |                 trio.open_nursery() as tn, | ||||||
|                     # ^XXX^ TODO? instead unpack any RAE as per "loose" style? |  | ||||||
| 
 |  | ||||||
|                 ) as tn, |  | ||||||
|                 msgops.maybe_limit_plds( |                 msgops.maybe_limit_plds( | ||||||
|                     ctx=ctx, |                     ctx=ctx, | ||||||
|                     spec=ctx_meta.get('pld_spec'), |                     spec=ctx_meta.get('pld_spec'), | ||||||
|                     dec_hook=ctx_meta.get('dec_hook'), |                     dec_hook=ctx_meta.get('dec_hook'), | ||||||
|                 ), |                 ), | ||||||
|  | 
 | ||||||
|  |                 # XXX NOTE, this being the "most embedded" | ||||||
|  |                 # scope ensures unasking of the `await coro` below | ||||||
|  |                 # *should* never be interfered with!! | ||||||
|  |                 maybe_raise_from_masking_exc( | ||||||
|  |                     tn=tn, | ||||||
|  |                     unmask_from=Cancelled, | ||||||
|  |                 ) as _mbme,  # maybe boxed masked exc | ||||||
|             ): |             ): | ||||||
|                 ctx._scope_nursery = tn |                 ctx._scope_nursery = tn | ||||||
|                 rpc_ctx_cs = ctx._scope = tn.cancel_scope |                 rpc_ctx_cs = ctx._scope = tn.cancel_scope | ||||||
|                 task_status.started(ctx) |                 task_status.started(ctx) | ||||||
| 
 | 
 | ||||||
|                 # TODO: better `trionics` tooling: |                 # invoke user endpoint fn. | ||||||
|                 # -[ ] should would be nice to have our `TaskMngr` |  | ||||||
|                 #   nursery here! |  | ||||||
|                 # -[ ] payload value checking like we do with |  | ||||||
|                 #   `.started()` such that the debbuger can engage |  | ||||||
|                 #   here in the child task instead of waiting for the |  | ||||||
|                 #   parent to crash with it's own MTE.. |  | ||||||
|                 res: Any|PayloadT = await coro |                 res: Any|PayloadT = await coro | ||||||
|                 return_msg: Return|CancelAck = return_msg_type( |                 return_msg: Return|CancelAck = return_msg_type( | ||||||
|                     cid=cid, |                     cid=cid, | ||||||
|  | @ -649,6 +670,11 @@ async def _invoke( | ||||||
|                 ) |                 ) | ||||||
|                 # set and shuttle final result to "parent"-side task. |                 # set and shuttle final result to "parent"-side task. | ||||||
|                 ctx._result = res |                 ctx._result = res | ||||||
|  |                 log.runtime( | ||||||
|  |                     f'Sending result msg and exiting {ctx.side!r}\n' | ||||||
|  |                     f'\n' | ||||||
|  |                     f'{pretty_struct.pformat(return_msg)}\n' | ||||||
|  |                 ) | ||||||
|                 await chan.send(return_msg) |                 await chan.send(return_msg) | ||||||
| 
 | 
 | ||||||
|             # NOTE: this happens IFF `ctx._scope.cancel()` is |             # NOTE: this happens IFF `ctx._scope.cancel()` is | ||||||
|  | @ -739,43 +765,52 @@ async def _invoke( | ||||||
|             BaseExceptionGroup, |             BaseExceptionGroup, | ||||||
|             BaseException, |             BaseException, | ||||||
|             trio.Cancelled, |             trio.Cancelled, | ||||||
| 
 |         ) as _scope_err: | ||||||
|         ) as scope_error: |             scope_err = _scope_err | ||||||
|             if ( |             if ( | ||||||
|                 isinstance(scope_error, RuntimeError) |                 isinstance(scope_err, RuntimeError) | ||||||
|                 and scope_error.args |                 and | ||||||
|                 and 'Cancel scope stack corrupted' in scope_error.args[0] |                 scope_err.args | ||||||
|  |                 and | ||||||
|  |                 'Cancel scope stack corrupted' in scope_err.args[0] | ||||||
|             ): |             ): | ||||||
|                 log.exception('Cancel scope stack corrupted!?\n') |                 log.exception('Cancel scope stack corrupted!?\n') | ||||||
|                 # _debug.mk_pdb().set_trace() |                 # debug.mk_pdb().set_trace() | ||||||
| 
 | 
 | ||||||
|             # always set this (child) side's exception as the |             # always set this (child) side's exception as the | ||||||
|             # local error on the context |             # local error on the context | ||||||
|             ctx._local_error: BaseException = scope_error |             ctx._local_error: BaseException = scope_err | ||||||
|             # ^-TODO-^ question, |             # ^-TODO-^ question, | ||||||
|             # does this matter other then for |             # does this matter other then for | ||||||
|             # consistentcy/testing? |             # consistentcy/testing? | ||||||
|             # |_ no user code should be in this scope at this point |             # |_ no user code should be in this scope at this point | ||||||
|             #    AND we already set this in the block below? |             #    AND we already set this in the block below? | ||||||
| 
 | 
 | ||||||
|             # if a remote error was set then likely the |             # XXX if a remote error was set then likely the | ||||||
|             # exception group was raised due to that, so |             # exc group was raised due to that, so | ||||||
|             # and we instead raise that error immediately! |             # and we instead raise that error immediately! | ||||||
|             ctx.maybe_raise() |             maybe_re: ( | ||||||
|  |                 ContextCancelled|RemoteActorError | ||||||
|  |             ) = ctx.maybe_raise() | ||||||
|  |             if maybe_re: | ||||||
|  |                 log.cancel( | ||||||
|  |                     f'Suppressing remote-exc from peer,\n' | ||||||
|  |                     f'{maybe_re!r}\n' | ||||||
|  |                 ) | ||||||
| 
 | 
 | ||||||
|             # maybe TODO: pack in come kinda |             # maybe TODO: pack in come kinda | ||||||
|             # `trio.Cancelled.__traceback__` here so they can be |             # `trio.Cancelled.__traceback__` here so they can be | ||||||
|             # unwrapped and displayed on the caller side? no se.. |             # unwrapped and displayed on the caller side? no se.. | ||||||
|             raise |             raise scope_err | ||||||
| 
 | 
 | ||||||
|         # `@context` entrypoint task bookeeping. |         # `@context` entrypoint task bookeeping. | ||||||
|         # i.e. only pop the context tracking if used ;) |         # i.e. only pop the context tracking if used ;) | ||||||
|         finally: |         finally: | ||||||
|             assert chan.uid |             assert chan.aid | ||||||
| 
 | 
 | ||||||
|             # don't pop the local context until we know the |             # don't pop the local context until we know the | ||||||
|             # associated child isn't in debug any more |             # associated child isn't in debug any more | ||||||
|             await _debug.maybe_wait_for_debugger() |             await debug.maybe_wait_for_debugger() | ||||||
|             ctx: Context = actor._contexts.pop(( |             ctx: Context = actor._contexts.pop(( | ||||||
|                 chan.uid, |                 chan.uid, | ||||||
|                 cid, |                 cid, | ||||||
|  | @ -788,26 +823,49 @@ async def _invoke( | ||||||
|                 f'after having {ctx.repr_state!r}\n' |                 f'after having {ctx.repr_state!r}\n' | ||||||
|             ) |             ) | ||||||
|             if merr: |             if merr: | ||||||
| 
 |  | ||||||
|                 logmeth: Callable = log.error |                 logmeth: Callable = log.error | ||||||
|                 if isinstance(merr, ContextCancelled): |                 if ( | ||||||
|                     logmeth: Callable = log.runtime |                     # ctxc: by `Context.cancel()` | ||||||
|  |                     isinstance(merr, ContextCancelled) | ||||||
| 
 | 
 | ||||||
|                 if not isinstance(merr, RemoteActorError): |                     # out-of-layer cancellation, one of: | ||||||
|                     tb_str: str = ''.join(traceback.format_exception(merr)) |                     # - actorc: by `Portal.cancel_actor()` | ||||||
|  |                     # - OSc: by SIGINT or `Process.signal()` | ||||||
|  |                     or ( | ||||||
|  |                         isinstance(merr, trio.Cancelled) | ||||||
|  |                         and | ||||||
|  |                         ctx.canceller | ||||||
|  |                     ) | ||||||
|  |                 ): | ||||||
|  |                     logmeth: Callable = log.cancel | ||||||
|  |                     descr_str += ( | ||||||
|  |                         f' with {merr!r}\n' | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                 elif ( | ||||||
|  |                     not isinstance(merr, RemoteActorError) | ||||||
|  |                 ): | ||||||
|  |                     tb_str: str = ''.join( | ||||||
|  |                         traceback.format_exception(merr) | ||||||
|  |                     ) | ||||||
|                     descr_str += ( |                     descr_str += ( | ||||||
|                         f'\n{merr!r}\n'  # needed? |                         f'\n{merr!r}\n'  # needed? | ||||||
|                         f'{tb_str}\n' |                         f'{tb_str}\n' | ||||||
|                     ) |                     ) | ||||||
|                 else: |                 else: | ||||||
|                     descr_str += f'\n{merr!r}\n' |                     descr_str += ( | ||||||
|  |                         f'{merr!r}\n' | ||||||
|  |                     ) | ||||||
|             else: |             else: | ||||||
|                 descr_str += f'\nand final result {ctx.outcome!r}\n' |                 descr_str += ( | ||||||
|  |                     f'\n' | ||||||
|  |                     f'with final result {ctx.outcome!r}\n' | ||||||
|  |                 ) | ||||||
| 
 | 
 | ||||||
|             logmeth( |             logmeth( | ||||||
|                 message |                 f'{message}\n' | ||||||
|                 + |                 f'\n' | ||||||
|                 descr_str |                 f'{descr_str}\n' | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -865,7 +923,6 @@ async def try_ship_error_to_remote( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def process_messages( | async def process_messages( | ||||||
|     actor: Actor, |  | ||||||
|     chan: Channel, |     chan: Channel, | ||||||
|     shield: bool = False, |     shield: bool = False, | ||||||
|     task_status: TaskStatus[CancelScope] = trio.TASK_STATUS_IGNORED, |     task_status: TaskStatus[CancelScope] = trio.TASK_STATUS_IGNORED, | ||||||
|  | @ -879,7 +936,7 @@ async def process_messages( | ||||||
| 
 | 
 | ||||||
|     Receive (multiplexed) per-`Channel` RPC requests as msgs from |     Receive (multiplexed) per-`Channel` RPC requests as msgs from | ||||||
|     remote processes; schedule target async funcs as local |     remote processes; schedule target async funcs as local | ||||||
|     `trio.Task`s inside the `Actor._service_n: Nursery`. |     `trio.Task`s inside the `Actor._service_tn: Nursery`. | ||||||
| 
 | 
 | ||||||
|     Depending on msg type, non-`cmd` (task spawning/starting) |     Depending on msg type, non-`cmd` (task spawning/starting) | ||||||
|     request payloads (eg. `started`, `yield`, `return`, `error`) |     request payloads (eg. `started`, `yield`, `return`, `error`) | ||||||
|  | @ -903,7 +960,8 @@ async def process_messages( | ||||||
|       (as utilized inside `Portal.cancel_actor()` ). |       (as utilized inside `Portal.cancel_actor()` ). | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     assert actor._service_n  # runtime state sanity |     actor: Actor = _state.current_actor() | ||||||
|  |     assert actor._service_tn  # runtime state sanity | ||||||
| 
 | 
 | ||||||
|     # TODO: once `trio` get's an "obvious way" for req/resp we |     # TODO: once `trio` get's an "obvious way" for req/resp we | ||||||
|     # should use it? |     # should use it? | ||||||
|  | @ -974,12 +1032,10 @@ async def process_messages( | ||||||
|                         cid=cid, |                         cid=cid, | ||||||
|                         kwargs=kwargs, |                         kwargs=kwargs, | ||||||
|                     ): |                     ): | ||||||
|                         kwargs |= {'req_chan': chan} |  | ||||||
| 
 |  | ||||||
|                         # XXX NOTE XXX don't start entire actor |                         # XXX NOTE XXX don't start entire actor | ||||||
|                         # runtime cancellation if this actor is |                         # runtime cancellation if this actor is | ||||||
|                         # currently in debug mode! |                         # currently in debug mode! | ||||||
|                         pdb_complete: trio.Event|None = _debug.DebugStatus.repl_release |                         pdb_complete: trio.Event|None = debug.DebugStatus.repl_release | ||||||
|                         if pdb_complete: |                         if pdb_complete: | ||||||
|                             await pdb_complete.wait() |                             await pdb_complete.wait() | ||||||
| 
 | 
 | ||||||
|  | @ -994,14 +1050,14 @@ async def process_messages( | ||||||
|                                 cid, |                                 cid, | ||||||
|                                 chan, |                                 chan, | ||||||
|                                 actor.cancel, |                                 actor.cancel, | ||||||
|                                 kwargs, |                                 kwargs | {'req_chan': chan}, | ||||||
|                                 is_rpc=False, |                                 is_rpc=False, | ||||||
|                                 return_msg_type=CancelAck, |                                 return_msg_type=CancelAck, | ||||||
|                             ) |                             ) | ||||||
| 
 | 
 | ||||||
|                         log.runtime( |                         log.runtime( | ||||||
|                             'Cancelling IPC transport msg-loop with peer:\n' |                             'Cancelling RPC-msg-loop with peer\n' | ||||||
|                             f'|_{chan}\n' |                             f'->c}} {chan.aid.reprol()}@[{chan.maddr}]\n' | ||||||
|                         ) |                         ) | ||||||
|                         loop_cs.cancel() |                         loop_cs.cancel() | ||||||
|                         break |                         break | ||||||
|  | @ -1014,7 +1070,7 @@ async def process_messages( | ||||||
|                     ): |                     ): | ||||||
|                         target_cid: str = kwargs['cid'] |                         target_cid: str = kwargs['cid'] | ||||||
|                         kwargs |= { |                         kwargs |= { | ||||||
|                             'requesting_uid': chan.uid, |                             'requesting_aid': chan.aid, | ||||||
|                             'ipc_msg': msg, |                             'ipc_msg': msg, | ||||||
| 
 | 
 | ||||||
|                             # XXX NOTE! ONLY the rpc-task-owning |                             # XXX NOTE! ONLY the rpc-task-owning | ||||||
|  | @ -1050,21 +1106,34 @@ async def process_messages( | ||||||
|                         ns=ns, |                         ns=ns, | ||||||
|                         func=funcname, |                         func=funcname, | ||||||
|                         kwargs=kwargs,  # type-spec this? see `msg.types` |                         kwargs=kwargs,  # type-spec this? see `msg.types` | ||||||
|                         uid=actorid, |                         uid=actor_uuid, | ||||||
|                     ): |                     ): | ||||||
|  |                         if actor_uuid != chan.aid.uid: | ||||||
|  |                             raise RuntimeError( | ||||||
|  |                                 f'IPC <Start> msg <-> chan.aid mismatch!?\n' | ||||||
|  |                                 f'Channel.aid = {chan.aid!r}\n' | ||||||
|  |                                 f'Start.uid = {actor_uuid!r}\n' | ||||||
|  |                             ) | ||||||
|  |                         # await debug.pause() | ||||||
|  |                         op_repr: str = 'Start <=) ' | ||||||
|  |                         req_repr: str = _pformat.nest_from_op( | ||||||
|  |                             input_op=op_repr, | ||||||
|  |                             op_suffix='', | ||||||
|  |                             nest_prefix='', | ||||||
|  |                             text=f'{chan}', | ||||||
|  | 
 | ||||||
|  |                             nest_indent=len(op_repr)-1, | ||||||
|  |                             rm_from_first_ln='<', | ||||||
|  |                             # ^XXX, subtract -1 to account for | ||||||
|  |                             # <Channel | ||||||
|  |                             # ^_chevron to be stripped | ||||||
|  |                         ) | ||||||
|                         start_status: str = ( |                         start_status: str = ( | ||||||
|                             'Handling RPC `Start` request\n' |                             'Handling RPC request\n' | ||||||
|                             f'<= peer: {actorid}\n\n' |                             f'{req_repr}\n' | ||||||
|                             f'  |_{chan}\n' |                             f'\n' | ||||||
|                             f'  |_cid: {cid}\n\n' |                             f'->{{ ipc-context-id: {cid!r}\n' | ||||||
|                             # f'  |_{ns}.{funcname}({kwargs})\n' |                             f'->{{ nsp for fn: `{ns}.{funcname}({kwargs})`\n' | ||||||
|                             f'>> {actor.uid}\n' |  | ||||||
|                             f'  |_{actor}\n' |  | ||||||
|                             f'   -> nsp: `{ns}.{funcname}({kwargs})`\n' |  | ||||||
| 
 |  | ||||||
|                             # f'  |_{ns}.{funcname}({kwargs})\n\n' |  | ||||||
| 
 |  | ||||||
|                             # f'{pretty_struct.pformat(msg)}\n' |  | ||||||
|                         ) |                         ) | ||||||
| 
 | 
 | ||||||
|                         # runtime-internal endpoint: `Actor.<funcname>` |                         # runtime-internal endpoint: `Actor.<funcname>` | ||||||
|  | @ -1093,10 +1162,6 @@ async def process_messages( | ||||||
|                                 await chan.send(err_msg) |                                 await chan.send(err_msg) | ||||||
|                                 continue |                                 continue | ||||||
| 
 | 
 | ||||||
|                         start_status += ( |  | ||||||
|                             f'   -> func: {func}\n' |  | ||||||
|                         ) |  | ||||||
| 
 |  | ||||||
|                         # schedule a task for the requested RPC function |                         # schedule a task for the requested RPC function | ||||||
|                         # in the actor's main "service nursery". |                         # in the actor's main "service nursery". | ||||||
|                         # |                         # | ||||||
|  | @ -1104,10 +1169,10 @@ async def process_messages( | ||||||
|                         # supervision isolation? would avoid having to |                         # supervision isolation? would avoid having to | ||||||
|                         # manage RPC tasks individually in `._rpc_tasks` |                         # manage RPC tasks individually in `._rpc_tasks` | ||||||
|                         # table? |                         # table? | ||||||
|                         start_status += '   -> scheduling new task..\n' |                         start_status += '->( scheduling new task..\n' | ||||||
|                         log.runtime(start_status) |                         log.runtime(start_status) | ||||||
|                         try: |                         try: | ||||||
|                             ctx: Context = await actor._service_n.start( |                             ctx: Context = await actor._service_tn.start( | ||||||
|                                 partial( |                                 partial( | ||||||
|                                     _invoke, |                                     _invoke, | ||||||
|                                     actor, |                                     actor, | ||||||
|  | @ -1152,7 +1217,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 | ||||||
|  | @ -1188,12 +1253,24 @@ async def process_messages( | ||||||
|             # END-OF `async for`: |             # END-OF `async for`: | ||||||
|             # IPC disconnected via `trio.EndOfChannel`, likely |             # IPC disconnected via `trio.EndOfChannel`, likely | ||||||
|             # due to a (graceful) `Channel.aclose()`. |             # due to a (graceful) `Channel.aclose()`. | ||||||
|  | 
 | ||||||
|  |             chan_op_repr: str = '<=x] ' | ||||||
|  |             chan_repr: str = _pformat.nest_from_op( | ||||||
|  |                 input_op=chan_op_repr, | ||||||
|  |                 op_suffix='', | ||||||
|  |                 nest_prefix='', | ||||||
|  |                 text=chan.pformat(), | ||||||
|  |                 nest_indent=len(chan_op_repr)-1, | ||||||
|  |                 rm_from_first_ln='<', | ||||||
|  |             ) | ||||||
|             log.runtime( |             log.runtime( | ||||||
|                 f'channel for {chan.uid} disconnected, cancelling RPC tasks\n' |                 f'IPC channel disconnected\n' | ||||||
|                 f'|_{chan}\n' |                 f'{chan_repr}\n' | ||||||
|  |                 f'\n' | ||||||
|  |                 f'->c) cancelling RPC tasks.\n' | ||||||
|             ) |             ) | ||||||
|             await actor.cancel_rpc_tasks( |             await actor.cancel_rpc_tasks( | ||||||
|                 req_uid=actor.uid, |                 req_aid=actor.aid, | ||||||
|                 # a "self cancel" in terms of the lifetime of the |                 # a "self cancel" in terms of the lifetime of the | ||||||
|                 # IPC connection which is presumed to be the |                 # IPC connection which is presumed to be the | ||||||
|                 # source of any requests for spawned tasks. |                 # source of any requests for spawned tasks. | ||||||
|  | @ -1215,8 +1292,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' | ||||||
|             ) |             ) | ||||||
|             + |             + | ||||||
|  | @ -1233,7 +1312,7 @@ async def process_messages( | ||||||
|     ) as err: |     ) as err: | ||||||
| 
 | 
 | ||||||
|         if nursery_cancelled_before_task: |         if nursery_cancelled_before_task: | ||||||
|             sn: Nursery = actor._service_n |             sn: Nursery = actor._service_tn | ||||||
|             assert sn and sn.cancel_scope.cancel_called  # sanity |             assert sn and sn.cancel_scope.cancel_called  # sanity | ||||||
|             log.cancel( |             log.cancel( | ||||||
|                 f'Service nursery cancelled before it handled {funcname}' |                 f'Service nursery cancelled before it handled {funcname}' | ||||||
|  | @ -1263,13 +1342,37 @@ async def process_messages( | ||||||
|     finally: |     finally: | ||||||
|         # msg debugging for when he machinery is brokey |         # msg debugging for when he machinery is brokey | ||||||
|         if msg is None: |         if msg is None: | ||||||
|             message: str = 'Exiting IPC msg loop without receiving a msg?' |             message: str = 'Exiting RPC-loop without receiving a msg?' | ||||||
|         else: |         else: | ||||||
|  |             task_op_repr: str = ')>' | ||||||
|  |             task: trio.Task = trio.lowlevel.current_task() | ||||||
|  | 
 | ||||||
|  |             # maybe add cancelled opt prefix | ||||||
|  |             if task._cancel_status.effectively_cancelled: | ||||||
|  |                 task_op_repr = 'c' + task_op_repr | ||||||
|  | 
 | ||||||
|  |             task_repr: str = _pformat.nest_from_op( | ||||||
|  |                 input_op=task_op_repr, | ||||||
|  |                 text=f'{task!r}', | ||||||
|  |                 nest_indent=1, | ||||||
|  |             ) | ||||||
|  |             # chan_op_repr: str = '<=} ' | ||||||
|  |             # chan_repr: str = _pformat.nest_from_op( | ||||||
|  |             #     input_op=chan_op_repr, | ||||||
|  |             #     op_suffix='', | ||||||
|  |             #     nest_prefix='', | ||||||
|  |             #     text=chan.pformat(), | ||||||
|  |             #     nest_indent=len(chan_op_repr)-1, | ||||||
|  |             #     rm_from_first_ln='<', | ||||||
|  |             # ) | ||||||
|             message: str = ( |             message: str = ( | ||||||
|                 'Exiting IPC msg loop with final msg\n\n' |                 f'Exiting RPC-loop with final msg\n' | ||||||
|                 f'<= peer: {chan.uid}\n' |                 f'\n' | ||||||
|                 f'  |_{chan}\n\n' |                 # f'{chan_repr}\n' | ||||||
|                 # f'{pretty_struct.pformat(msg)}' |                 f'{task_repr}\n' | ||||||
|  |                 f'\n' | ||||||
|  |                 f'{pretty_struct.pformat(msg)}' | ||||||
|  |                 f'\n' | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|         log.runtime(message) |         log.runtime(message) | ||||||
|  |  | ||||||
							
								
								
									
										1487
									
								
								tractor/_runtime.py
								
								
								
								
							
							
						
						
									
										1487
									
								
								tractor/_runtime.py
								
								
								
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -34,9 +34,9 @@ from typing import ( | ||||||
| import trio | import trio | ||||||
| from trio import TaskStatus | from trio import TaskStatus | ||||||
| 
 | 
 | ||||||
| from .devx._debug import ( | from .devx import ( | ||||||
|     maybe_wait_for_debugger, |     debug, | ||||||
|     acquire_debug_lock, |     pformat as _pformat | ||||||
| ) | ) | ||||||
| from tractor._state import ( | from tractor._state import ( | ||||||
|     current_actor, |     current_actor, | ||||||
|  | @ -46,19 +46,26 @@ 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 import ( | ||||||
|     SpawnSpec, |     types as msgtypes, | ||||||
|  |     pretty_struct, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|  |     from ipc import ( | ||||||
|  |         _server, | ||||||
|  |         Channel, | ||||||
|  |     ) | ||||||
|     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 +170,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 +178,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 | ||||||
|  | @ -229,10 +236,6 @@ async def hard_kill( | ||||||
|     # whilst also hacking on it XD |     # whilst also hacking on it XD | ||||||
|     # terminate_after: int = 99999, |     # terminate_after: int = 99999, | ||||||
| 
 | 
 | ||||||
|     # NOTE: for mucking with `.pause()`-ing inside the runtime |  | ||||||
|     # whilst also hacking on it XD |  | ||||||
|     # terminate_after: int = 99999, |  | ||||||
| 
 |  | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|     Un-gracefully terminate an OS level `trio.Process` after timeout. |     Un-gracefully terminate an OS level `trio.Process` after timeout. | ||||||
|  | @ -294,6 +297,23 @@ async def hard_kill( | ||||||
|     # zombies (as a feature) we ask the OS to do send in the |     # zombies (as a feature) we ask the OS to do send in the | ||||||
|     # removal swad as the last resort. |     # removal swad as the last resort. | ||||||
|     if cs.cancelled_caught: |     if cs.cancelled_caught: | ||||||
|  | 
 | ||||||
|  |         # TODO? attempt at intermediary-rent-sub | ||||||
|  |         # with child in debug lock? | ||||||
|  |         # |_https://github.com/goodboy/tractor/issues/320 | ||||||
|  |         # | ||||||
|  |         # if not is_root_process(): | ||||||
|  |         #     log.warning( | ||||||
|  |         #         'Attempting to acquire debug-REPL-lock before zombie reap!' | ||||||
|  |         #     ) | ||||||
|  |         #     with trio.CancelScope(shield=True): | ||||||
|  |         #         async with debug.acquire_debug_lock( | ||||||
|  |         #             subactor_uid=current_actor().uid, | ||||||
|  |         #         ) as _ctx: | ||||||
|  |         #             log.warning( | ||||||
|  |         #                 'Acquired debug lock, child ready to be killed ??\n' | ||||||
|  |         #             ) | ||||||
|  | 
 | ||||||
|         # TODO: toss in the skynet-logo face as ascii art? |         # TODO: toss in the skynet-logo face as ascii art? | ||||||
|         log.critical( |         log.critical( | ||||||
|             # 'Well, the #ZOMBIE_LORD_IS_HERE# to collect\n' |             # 'Well, the #ZOMBIE_LORD_IS_HERE# to collect\n' | ||||||
|  | @ -324,20 +344,21 @@ async def soft_kill( | ||||||
|     see `.hard_kill()`). |     see `.hard_kill()`). | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     uid: tuple[str, str] = portal.channel.uid |     chan: Channel = portal.channel | ||||||
|  |     peer_aid: msgtypes.Aid = chan.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.reprol()}@[{chan.maddr}]\n' | ||||||
|             f'  |_{proc}\n' |             f'   |_{proc}\n' | ||||||
|         ) |         ) | ||||||
|         # wait on sub-proc to signal termination |         # wait on sub-proc to signal termination | ||||||
|         await wait_func(proc) |         await wait_func(proc) | ||||||
| 
 | 
 | ||||||
|     except trio.Cancelled: |     except trio.Cancelled: | ||||||
|         with trio.CancelScope(shield=True): |         with trio.CancelScope(shield=True): | ||||||
|             await maybe_wait_for_debugger( |             await debug.maybe_wait_for_debugger( | ||||||
|                 child_in_debug=_runtime_vars.get( |                 child_in_debug=_runtime_vars.get( | ||||||
|                     '_debug_mode', False |                     '_debug_mode', False | ||||||
|                 ), |                 ), | ||||||
|  | @ -378,7 +399,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 +413,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 +441,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 +452,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 +480,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 `msgtypes.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,18 +500,20 @@ 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: _server.Server = 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' |                 f'Started new child subproc\n' | ||||||
|                 f'|_{proc}\n' |                 f'(>\n' | ||||||
|  |                 f' |_{proc}\n' | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|             # 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 | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|  | @ -496,10 +525,10 @@ async def trio_proc( | ||||||
|                 with trio.CancelScope(shield=True): |                 with trio.CancelScope(shield=True): | ||||||
|                     # don't clobber an ongoing pdb |                     # don't clobber an ongoing pdb | ||||||
|                     if is_root_process(): |                     if is_root_process(): | ||||||
|                         await maybe_wait_for_debugger() |                         await debug.maybe_wait_for_debugger() | ||||||
| 
 | 
 | ||||||
|                     elif proc is not None: |                     elif proc is not None: | ||||||
|                         async with acquire_debug_lock(subactor.uid): |                         async with debug.acquire_debug_lock(subactor.uid): | ||||||
|                             # soft wait on the proc to terminate |                             # soft wait on the proc to terminate | ||||||
|                             with trio.move_on_after(0.5): |                             with trio.move_on_after(0.5): | ||||||
|                                 await proc.wait() |                                 await proc.wait() | ||||||
|  | @ -517,15 +546,20 @@ 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 = msgtypes.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 to child\n' | ||||||
|  |             f'{{}}=> {chan.aid.reprol()!r}\n' | ||||||
|  |             f'\n' | ||||||
|  |             f'{pretty_struct.pformat(sspec)}\n' | ||||||
|  |         ) | ||||||
|  |         await chan.send(sspec) | ||||||
| 
 | 
 | ||||||
|         # track subactor in current nursery |         # track subactor in current nursery | ||||||
|         curr_actor: Actor = current_actor() |         curr_actor: Actor = current_actor() | ||||||
|  | @ -552,7 +586,7 @@ async def trio_proc( | ||||||
|             # condition. |             # condition. | ||||||
|             await soft_kill( |             await soft_kill( | ||||||
|                 proc, |                 proc, | ||||||
|                 trio.Process.wait, |                 trio.Process.wait,  # XXX, uses `pidfd_open()` below. | ||||||
|                 portal |                 portal | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|  | @ -560,8 +594,7 @@ async def trio_proc( | ||||||
|             # tandem if not done already |             # tandem if not done already | ||||||
|             log.cancel( |             log.cancel( | ||||||
|                 'Cancelling portal result reaper task\n' |                 'Cancelling portal result reaper task\n' | ||||||
|                 f'>c)\n' |                 f'c)> {subactor.aid.reprol()!r}\n' | ||||||
|                 f' |_{subactor.uid}\n' |  | ||||||
|             ) |             ) | ||||||
|             nursery.cancel_scope.cancel() |             nursery.cancel_scope.cancel() | ||||||
| 
 | 
 | ||||||
|  | @ -570,21 +603,24 @@ async def trio_proc( | ||||||
|         # allowed! Do this **after** cancellation/teardown to avoid |         # allowed! Do this **after** cancellation/teardown to avoid | ||||||
|         # killing the process too early. |         # killing the process too early. | ||||||
|         if proc: |         if proc: | ||||||
|  |             reap_repr: str = _pformat.nest_from_op( | ||||||
|  |                 input_op='>x)', | ||||||
|  |                 text=subactor.pformat(), | ||||||
|  |             ) | ||||||
|             log.cancel( |             log.cancel( | ||||||
|                 f'Hard reap sequence starting for subactor\n' |                 f'Hard reap sequence starting for subactor\n' | ||||||
|                 f'>x)\n' |                 f'{reap_repr}' | ||||||
|                 f' |_{subactor}@{subactor.uid}\n' |  | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|             with trio.CancelScope(shield=True): |             with trio.CancelScope(shield=True): | ||||||
|                 # don't clobber an ongoing pdb |                 # don't clobber an ongoing pdb | ||||||
|                 if cancelled_during_spawn: |                 if cancelled_during_spawn: | ||||||
|                     # Try again to avoid TTY clobbering. |                     # Try again to avoid TTY clobbering. | ||||||
|                     async with acquire_debug_lock(subactor.uid): |                     async with debug.acquire_debug_lock(subactor.uid): | ||||||
|                         with trio.move_on_after(0.5): |                         with trio.move_on_after(0.5): | ||||||
|                             await proc.wait() |                             await proc.wait() | ||||||
| 
 | 
 | ||||||
|                 await maybe_wait_for_debugger( |                 await debug.maybe_wait_for_debugger( | ||||||
|                     child_in_debug=_runtime_vars.get( |                     child_in_debug=_runtime_vars.get( | ||||||
|                         '_debug_mode', False |                         '_debug_mode', False | ||||||
|                     ), |                     ), | ||||||
|  | @ -613,7 +649,7 @@ async def trio_proc( | ||||||
|                 #     acquire the lock and get notified of who has it, |                 #     acquire the lock and get notified of who has it, | ||||||
|                 #     check that uid against our known children? |                 #     check that uid against our known children? | ||||||
|                 # this_uid: tuple[str, str] = current_actor().uid |                 # this_uid: tuple[str, str] = current_actor().uid | ||||||
|                 # await acquire_debug_lock(this_uid) |                 # await debug.acquire_debug_lock(this_uid) | ||||||
| 
 | 
 | ||||||
|                 if proc.poll() is None: |                 if proc.poll() is None: | ||||||
|                     log.cancel(f"Attempting to hard kill {proc}") |                     log.cancel(f"Attempting to hard kill {proc}") | ||||||
|  | @ -635,12 +671,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 +752,14 @@ async def mp_proc( | ||||||
| 
 | 
 | ||||||
|     log.runtime(f"Started {proc}") |     log.runtime(f"Started {proc}") | ||||||
| 
 | 
 | ||||||
|  |     ipc_server: _server.Server = 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, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -34,20 +37,39 @@ if TYPE_CHECKING: | ||||||
|     from ._context import Context |     from ._context import Context | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # default IPC transport protocol settings | ||||||
|  | TransportProtocolKey = Literal[ | ||||||
|  |     'tcp', | ||||||
|  |     'uds', | ||||||
|  | ] | ||||||
|  | _def_tpt_proto: TransportProtocolKey = 'tcp' | ||||||
|  | 
 | ||||||
| _current_actor: Actor|None = None  # type: ignore # noqa | _current_actor: Actor|None = None  # type: ignore # noqa | ||||||
| _last_actor_terminated: Actor|None = None | _last_actor_terminated: Actor|None = None | ||||||
| 
 | 
 | ||||||
| # TODO: mk this a `msgspec.Struct`! | # TODO: mk this a `msgspec.Struct`! | ||||||
|  | # -[ ] type out all fields obvi! | ||||||
|  | # -[ ] (eventually) mk wire-ready for monitoring? | ||||||
| _runtime_vars: dict[str, Any] = { | _runtime_vars: dict[str, Any] = { | ||||||
|     '_debug_mode': False, |     # root of actor-process tree info | ||||||
|     '_is_root': False, |     '_is_root': False,  # bool | ||||||
|     '_root_mailbox': (None, None), |     '_root_mailbox': (None, None),  # tuple[str|None, str|None] | ||||||
|  |     '_root_addrs': [],  # tuple[str|None, str|None] | ||||||
|  | 
 | ||||||
|  |     # parent->chld ipc protocol caps | ||||||
|  |     '_enable_tpts': [_def_tpt_proto], | ||||||
|  | 
 | ||||||
|  |     # registrar info | ||||||
|     '_registry_addrs': [], |     '_registry_addrs': [], | ||||||
| 
 | 
 | ||||||
|     '_is_infected_aio': False, |     # `debug_mode: bool` settings | ||||||
| 
 |     '_debug_mode': False,  # bool | ||||||
|  |     'repl_fixture': False,  # |AbstractContextManager[bool] | ||||||
|     # for `tractor.pause_from_sync()` & `breakpoint()` support |     # for `tractor.pause_from_sync()` & `breakpoint()` support | ||||||
|     'use_greenback': False, |     'use_greenback': False, | ||||||
|  | 
 | ||||||
|  |     # infected-`asyncio`-mode: `trio` running as guest. | ||||||
|  |     '_is_infected_aio': False, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -99,7 +121,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 +130,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 +142,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 +170,34 @@ 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 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 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 _runtime_vars['_enable_tpts'] | ||||||
|  |  | ||||||
|  | @ -45,16 +45,18 @@ from .trionics import ( | ||||||
|     BroadcastReceiver, |     BroadcastReceiver, | ||||||
| ) | ) | ||||||
| from tractor.msg import ( | from tractor.msg import ( | ||||||
|     # Return, |     Error, | ||||||
|     # Stop, |     Return, | ||||||
|  |     Stop, | ||||||
|     MsgType, |     MsgType, | ||||||
|  |     PayloadT, | ||||||
|     Yield, |     Yield, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| 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__) | ||||||
|  | @ -70,8 +72,7 @@ class MsgStream(trio.abc.Channel): | ||||||
|     A bidirectional message stream for receiving logically sequenced |     A bidirectional message stream for receiving logically sequenced | ||||||
|     values over an inter-actor IPC `Channel`. |     values over an inter-actor IPC `Channel`. | ||||||
| 
 | 
 | ||||||
|     This is the type returned to a local task which entered either | 
 | ||||||
|     `Portal.open_stream_from()` or `Context.open_stream()`. |  | ||||||
| 
 | 
 | ||||||
|     Termination rules: |     Termination rules: | ||||||
| 
 | 
 | ||||||
|  | @ -94,6 +95,9 @@ class MsgStream(trio.abc.Channel): | ||||||
|         self._rx_chan = rx_chan |         self._rx_chan = rx_chan | ||||||
|         self._broadcaster = _broadcaster |         self._broadcaster = _broadcaster | ||||||
| 
 | 
 | ||||||
|  |         # any actual IPC msg which is effectively an `EndOfStream` | ||||||
|  |         self._stop_msg: bool|Stop = False | ||||||
|  | 
 | ||||||
|         # flag to denote end of stream |         # flag to denote end of stream | ||||||
|         self._eoc: bool|trio.EndOfChannel = False |         self._eoc: bool|trio.EndOfChannel = False | ||||||
|         self._closed: bool|trio.ClosedResourceError = False |         self._closed: bool|trio.ClosedResourceError = False | ||||||
|  | @ -125,16 +129,67 @@ class MsgStream(trio.abc.Channel): | ||||||
|     def receive_nowait( |     def receive_nowait( | ||||||
|         self, |         self, | ||||||
|         expect_msg: MsgType = Yield, |         expect_msg: MsgType = Yield, | ||||||
|     ): |     ) -> PayloadT: | ||||||
|         ctx: Context = self._ctx |         ctx: Context = self._ctx | ||||||
|         return ctx._pld_rx.recv_pld_nowait( |         ( | ||||||
|  |             msg, | ||||||
|  |             pld, | ||||||
|  |         ) = ctx._pld_rx.recv_msg_nowait( | ||||||
|             ipc=self, |             ipc=self, | ||||||
|             expect_msg=expect_msg, |             expect_msg=expect_msg, | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |         # ?TODO, maybe factor this into a hyper-common `unwrap_pld()` | ||||||
|  |         # | ||||||
|  |         match msg: | ||||||
|  | 
 | ||||||
|  |             # XXX, these never seems to ever hit? cool? | ||||||
|  |             case Stop(): | ||||||
|  |                 log.cancel( | ||||||
|  |                     f'Msg-stream was ended via stop msg\n' | ||||||
|  |                     f'{msg}' | ||||||
|  |                 ) | ||||||
|  |             case Error(): | ||||||
|  |                 log.error( | ||||||
|  |                     f'Msg-stream was ended via error msg\n' | ||||||
|  |                     f'{msg}' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             # XXX NOTE, always set any final result on the ctx to | ||||||
|  |             # avoid teardown race conditions where previously this msg | ||||||
|  |             # would be consumed silently (by `.aclose()` doing its | ||||||
|  |             # own "msg drain loop" but WITHOUT those `drained: lists[MsgType]` | ||||||
|  |             # being post-close-processed! | ||||||
|  |             # | ||||||
|  |             # !!TODO, see the equiv todo-comment in `.receive()` | ||||||
|  |             # around the `if drained:` where we should prolly | ||||||
|  |             # ACTUALLY be doing this post-close processing?? | ||||||
|  |             # | ||||||
|  |             case Return(pld=pld): | ||||||
|  |                 log.warning( | ||||||
|  |                     f'Msg-stream final result msg for IPC ctx?\n' | ||||||
|  |                     f'{msg}' | ||||||
|  |                 ) | ||||||
|  |                 # XXX TODO, this **should be covered** by higher | ||||||
|  |                 # scoped runtime-side method calls such as | ||||||
|  |                 # `Context._deliver_msg()`, so you should never | ||||||
|  |                 # really see the warning above or else something | ||||||
|  |                 # racy/out-of-order is likely going on between | ||||||
|  |                 # actor-runtime-side push tasks and the user-app-side | ||||||
|  |                 # consume tasks! | ||||||
|  |                 # -[ ] figure out that set of race cases and fix! | ||||||
|  |                 # -[ ] possibly return the `msg` given an input | ||||||
|  |                 #     arg-flag is set so we can process the `Return` | ||||||
|  |                 #     from the `.aclose()` caller? | ||||||
|  |                 # | ||||||
|  |                 # breakpoint()  # to debug this RACE CASE! | ||||||
|  |                 ctx._result = pld | ||||||
|  |                 ctx._outcome_msg = msg | ||||||
|  | 
 | ||||||
|  |         return pld | ||||||
|  | 
 | ||||||
|     async def receive( |     async def receive( | ||||||
|         self, |         self, | ||||||
| 
 |  | ||||||
|         hide_tb: bool = False, |         hide_tb: bool = False, | ||||||
|     ): |     ): | ||||||
|         ''' |         ''' | ||||||
|  | @ -154,7 +209,7 @@ class MsgStream(trio.abc.Channel): | ||||||
|         #     except trio.EndOfChannel: |         #     except trio.EndOfChannel: | ||||||
|         #         raise StopAsyncIteration |         #         raise StopAsyncIteration | ||||||
|         # |         # | ||||||
|         # see ``.aclose()`` for notes on the old behaviour prior to |         # see `.aclose()` for notes on the old behaviour prior to | ||||||
|         # introducing this |         # introducing this | ||||||
|         if self._eoc: |         if self._eoc: | ||||||
|             raise self._eoc |             raise self._eoc | ||||||
|  | @ -165,7 +220,11 @@ class MsgStream(trio.abc.Channel): | ||||||
|         src_err: Exception|None = None  # orig tb |         src_err: Exception|None = None  # orig tb | ||||||
|         try: |         try: | ||||||
|             ctx: Context = self._ctx |             ctx: Context = self._ctx | ||||||
|             return await ctx._pld_rx.recv_pld(ipc=self) |             pld = await ctx._pld_rx.recv_pld( | ||||||
|  |                 ipc=self, | ||||||
|  |                 expect_msg=Yield, | ||||||
|  |             ) | ||||||
|  |             return pld | ||||||
| 
 | 
 | ||||||
|         # XXX: the stream terminates on either of: |         # XXX: the stream terminates on either of: | ||||||
|         # - `self._rx_chan.receive()` raising  after manual closure |         # - `self._rx_chan.receive()` raising  after manual closure | ||||||
|  | @ -174,7 +233,7 @@ class MsgStream(trio.abc.Channel): | ||||||
|         # - via a `Stop`-msg received from remote peer task. |         # - via a `Stop`-msg received from remote peer task. | ||||||
|         #   NOTE |         #   NOTE | ||||||
|         #   |_ previously this was triggered by calling |         #   |_ previously this was triggered by calling | ||||||
|         #   ``._rx_chan.aclose()`` on the send side of the channel |         #   `._rx_chan.aclose()` on the send side of the channel | ||||||
|         #   inside `Actor._deliver_ctx_payload()`, but now the 'stop' |         #   inside `Actor._deliver_ctx_payload()`, but now the 'stop' | ||||||
|         #   message handling gets delegated to `PldRFx.recv_pld()` |         #   message handling gets delegated to `PldRFx.recv_pld()` | ||||||
|         #   internals. |         #   internals. | ||||||
|  | @ -198,11 +257,14 @@ class MsgStream(trio.abc.Channel): | ||||||
|         # terminated and signal this local iterator to stop |         # terminated and signal this local iterator to stop | ||||||
|         drained: list[Exception|dict] = await self.aclose() |         drained: list[Exception|dict] = await self.aclose() | ||||||
|         if drained: |         if drained: | ||||||
|             # ?TODO? pass these to the `._ctx._drained_msgs: deque` |         #  ^^^^^^^^TODO? pass these to the `._ctx._drained_msgs: | ||||||
|             # and then iterate them as part of any `.wait_for_result()` call? |         #  deque` and then iterate them as part of any | ||||||
|             # |         #  `.wait_for_result()` call? | ||||||
|             # from .devx import pause |         # | ||||||
|             # await pause() |         # -[ ] move the match-case processing from | ||||||
|  |         #     `.receive_nowait()` instead to right here, use it from | ||||||
|  |         #     a for msg in drained:` post-proc loop? | ||||||
|  |         # | ||||||
|             log.warning( |             log.warning( | ||||||
|                 'Drained context msgs during closure\n\n' |                 'Drained context msgs during closure\n\n' | ||||||
|                 f'{drained}' |                 f'{drained}' | ||||||
|  | @ -265,9 +327,6 @@ class MsgStream(trio.abc.Channel): | ||||||
|          - more or less we try to maintain adherance to trio's `.aclose()` semantics: |          - more or less we try to maintain adherance to trio's `.aclose()` semantics: | ||||||
|            https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose |            https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose | ||||||
|         ''' |         ''' | ||||||
| 
 |  | ||||||
|         # rx_chan = self._rx_chan |  | ||||||
| 
 |  | ||||||
|         # XXX NOTE XXX |         # XXX NOTE XXX | ||||||
|         # it's SUPER IMPORTANT that we ensure we don't DOUBLE |         # it's SUPER IMPORTANT that we ensure we don't DOUBLE | ||||||
|         # DRAIN msgs on closure so avoid getting stuck handing on |         # DRAIN msgs on closure so avoid getting stuck handing on | ||||||
|  | @ -279,15 +338,16 @@ class MsgStream(trio.abc.Channel): | ||||||
|             # this stream has already been closed so silently succeed as |             # this stream has already been closed so silently succeed as | ||||||
|             # per ``trio.AsyncResource`` semantics. |             # per ``trio.AsyncResource`` semantics. | ||||||
|             # https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose |             # https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose | ||||||
|  |             # import tractor | ||||||
|  |             # await tractor.pause() | ||||||
|             return [] |             return [] | ||||||
| 
 | 
 | ||||||
|         ctx: Context = self._ctx |         ctx: Context = self._ctx | ||||||
|         drained: list[Exception|dict] = [] |         drained: list[Exception|dict] = [] | ||||||
|         while not drained: |         while not drained: | ||||||
|             try: |             try: | ||||||
|                 maybe_final_msg = self.receive_nowait( |                 maybe_final_msg: Yield|Return = self.receive_nowait( | ||||||
|                     # allow_msgs=[Yield, Return], |                     expect_msg=Yield|Return, | ||||||
|                     expect_msg=Yield, |  | ||||||
|                 ) |                 ) | ||||||
|                 if maybe_final_msg: |                 if maybe_final_msg: | ||||||
|                     log.debug( |                     log.debug( | ||||||
|  | @ -366,24 +426,37 @@ class MsgStream(trio.abc.Channel): | ||||||
|             self._closed = re |             self._closed = re | ||||||
| 
 | 
 | ||||||
|         # if caught_eoc: |         # if caught_eoc: | ||||||
|         #     # from .devx import _debug |         #     # from .devx import debug | ||||||
|         #     # await _debug.pause() |         #     # await debug.pause() | ||||||
|         #     with trio.CancelScope(shield=True): |         #     with trio.CancelScope(shield=True): | ||||||
|         #         await rx_chan.aclose() |         #         await rx_chan.aclose() | ||||||
| 
 | 
 | ||||||
|         if not self._eoc: |         if not self._eoc: | ||||||
|  |             this_side: str = self._ctx.side | ||||||
|  |             peer_side: str = self._ctx.peer_side | ||||||
|             message: str = ( |             message: str = ( | ||||||
|                 f'Stream self-closed by {self._ctx.side!r}-side before EoC\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' | ||||||
|             ) |             ) | ||||||
|  |             if ( | ||||||
|  |                 (rx_chan := self._rx_chan) | ||||||
|  |                 and | ||||||
|  |                 (stats := rx_chan.statistics()).tasks_waiting_receive | ||||||
|  |             ): | ||||||
|  |                 message += ( | ||||||
|  |                     f'AND there is still reader tasks,\n' | ||||||
|  |                     f'\n' | ||||||
|  |                     f'{stats}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|             log.cancel(message) |             log.cancel(message) | ||||||
|             self._eoc = trio.EndOfChannel(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 | ||||||
|         # core-msg-loop mem recv-chan is used to deliver the |         # core-msg-loop mem recv-chan is used to deliver the | ||||||
|         # potential final result from the surrounding inter-actor |         # potential final result from the surrounding inter-actor | ||||||
|         # `Context` so we don't want to close it until that |         # `Context` so we don't want to close it until that | ||||||
|  | @ -523,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 | ||||||
|  | @ -730,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: | ||||||
|  |  | ||||||
|  | @ -21,34 +21,49 @@ | ||||||
| from contextlib import asynccontextmanager as acm | from contextlib import asynccontextmanager as acm | ||||||
| from functools import partial | from functools import partial | ||||||
| import inspect | import inspect | ||||||
| from pprint import pformat | from typing import ( | ||||||
| from typing import TYPE_CHECKING |     TYPE_CHECKING, | ||||||
|  | ) | ||||||
| import typing | import typing | ||||||
| import warnings | import warnings | ||||||
| 
 | 
 | ||||||
| import trio | import trio | ||||||
| 
 | 
 | ||||||
| from .devx._debug import maybe_wait_for_debugger | 
 | ||||||
|  | from .devx import ( | ||||||
|  |     debug, | ||||||
|  |     pformat as _pformat, | ||||||
|  | ) | ||||||
|  | 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 | ||||||
| from ._portal import Portal | from ._portal import Portal | ||||||
| from ._exceptions import ( | from .trionics import ( | ||||||
|     is_multi_cancelled, |     is_multi_cancelled, | ||||||
|  |     collapse_eg, | ||||||
|  | ) | ||||||
|  | from ._exceptions import ( | ||||||
|     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: | ||||||
|     ''' |     ''' | ||||||
|  | @ -102,7 +117,6 @@ class ActorNursery: | ||||||
|             ] |             ] | ||||||
|         ] = {} |         ] = {} | ||||||
| 
 | 
 | ||||||
|         self.cancelled: bool = False |  | ||||||
|         self._join_procs = trio.Event() |         self._join_procs = trio.Event() | ||||||
|         self._at_least_one_child_in_debug: bool = False |         self._at_least_one_child_in_debug: bool = False | ||||||
|         self.errors = errors |         self.errors = errors | ||||||
|  | @ -120,18 +134,62 @@ class ActorNursery: | ||||||
|         # TODO: remove the `.run_in_actor()` API and thus this 2ndary |         # TODO: remove the `.run_in_actor()` API and thus this 2ndary | ||||||
|         # nursery when that API get's moved outside this primitive! |         # nursery when that API get's moved outside this primitive! | ||||||
|         self._ria_nursery = ria_nursery |         self._ria_nursery = ria_nursery | ||||||
|  | 
 | ||||||
|  |         # TODO, factor this into a .hilevel api! | ||||||
|  |         # | ||||||
|         # portals spawned with ``run_in_actor()`` are |         # portals spawned with ``run_in_actor()`` are | ||||||
|         # cancelled when their "main" result arrives |         # cancelled when their "main" result arrives | ||||||
|         self._cancel_after_result_on_exit: set = set() |         self._cancel_after_result_on_exit: set = set() | ||||||
| 
 | 
 | ||||||
|  |         # trio.Nursery-like cancel (request) statuses | ||||||
|  |         self._cancelled_caught: bool = False | ||||||
|  |         self._cancel_called: bool = False | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def cancel_called(self) -> bool: | ||||||
|  |         ''' | ||||||
|  |         Records whether cancellation has been requested for this | ||||||
|  |         actor-nursery by a call to  `.cancel()` either due to, | ||||||
|  |         - an explicit call by some actor-local-task, | ||||||
|  |         - an implicit call due to an error/cancel emited inside | ||||||
|  |           the `tractor.open_nursery()` block. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         return self._cancel_called | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def cancelled_caught(self) -> bool: | ||||||
|  |         ''' | ||||||
|  |         Set when this nursery was able to cance all spawned subactors | ||||||
|  |         gracefully via an (implicit) call to `.cancel()`. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         return self._cancelled_caught | ||||||
|  | 
 | ||||||
|  |     # TODO! remove internal/test-suite usage! | ||||||
|  |     @property | ||||||
|  |     def cancelled(self) -> bool: | ||||||
|  |         warnings.warn( | ||||||
|  |             "`ActorNursery.cancelled` is now deprecated, use " | ||||||
|  |             " `.cancel_called` instead.", | ||||||
|  |             DeprecationWarning, | ||||||
|  |             stacklevel=2, | ||||||
|  |         ) | ||||||
|  |         return ( | ||||||
|  |             self._cancel_called | ||||||
|  |             # and | ||||||
|  |             # self._cancelled_caught | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|     async def start_actor( |     async def start_actor( | ||||||
|         self, |         self, | ||||||
|         name: str, |         name: str, | ||||||
| 
 | 
 | ||||||
|         *, |         *, | ||||||
| 
 | 
 | ||||||
|         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 +199,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,15 +236,17 @@ 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, | ||||||
| 
 | 
 | ||||||
|             # verbatim relay this actor's registrar addresses |             # verbatim relay this actor's registrar addresses | ||||||
|             registry_addrs=current_actor().reg_addrs, |             registry_addrs=current_actor().registry_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 +265,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 +284,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 +320,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 | ||||||
|  | @ -294,15 +358,21 @@ class ActorNursery: | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         __runtimeframe__: int = 1  # noqa |         __runtimeframe__: int = 1  # noqa | ||||||
|         self.cancelled = True |         self._cancel_called = True | ||||||
| 
 | 
 | ||||||
|         # TODO: impl a repr for spawn more compact |         # TODO: impl a repr for spawn more compact | ||||||
|         # then `._children`.. |         # then `._children`.. | ||||||
|         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 ( | ||||||
|  |                 collapse_eg(), | ||||||
|  |                 trio.open_nursery() as tn, | ||||||
|  |             ): | ||||||
| 
 | 
 | ||||||
|                 subactor: Actor |                 subactor: Actor | ||||||
|                 proc: trio.Process |                 proc: trio.Process | ||||||
|  | @ -321,7 +391,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 +407,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 | ||||||
|  | @ -366,6 +436,8 @@ class ActorNursery: | ||||||
|             ) in children.values(): |             ) in children.values(): | ||||||
|                 log.warning(f"Hard killing process {proc}") |                 log.warning(f"Hard killing process {proc}") | ||||||
|                 proc.terminate() |                 proc.terminate() | ||||||
|  |         else: | ||||||
|  |             self._cancelled_caught | ||||||
| 
 | 
 | ||||||
|         # mark ourselves as having (tried to have) cancelled all subactors |         # mark ourselves as having (tried to have) cancelled all subactors | ||||||
|         self._join_procs.set() |         self._join_procs.set() | ||||||
|  | @ -395,10 +467,10 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|     # `ActorNursery.start_actor()`). |     # `ActorNursery.start_actor()`). | ||||||
| 
 | 
 | ||||||
|     # errors from this daemon actor nursery bubble up to caller |     # errors from this daemon actor nursery bubble up to caller | ||||||
|     async with trio.open_nursery( |     async with ( | ||||||
|         strict_exception_groups=False, |         collapse_eg(), | ||||||
|         # ^XXX^ TODO? instead unpack any RAE as per "loose" style? |         trio.open_nursery() as da_nursery, | ||||||
|     ) as da_nursery: |     ): | ||||||
|         try: |         try: | ||||||
|             # This is the inner level "run in actor" nursery. It is |             # This is the inner level "run in actor" nursery. It is | ||||||
|             # awaited first since actors spawned in this way (using |             # awaited first since actors spawned in this way (using | ||||||
|  | @ -408,11 +480,10 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|             # immediately raised for handling by a supervisor strategy. |             # immediately raised for handling by a supervisor strategy. | ||||||
|             # As such if the strategy propagates any error(s) upwards |             # As such if the strategy propagates any error(s) upwards | ||||||
|             # the above "daemon actor" nursery will be notified. |             # the above "daemon actor" nursery will be notified. | ||||||
|             async with trio.open_nursery( |             async with ( | ||||||
|                 strict_exception_groups=False, |                 collapse_eg(), | ||||||
|                 # ^XXX^ TODO? instead unpack any RAE as per "loose" style? |                 trio.open_nursery() as ria_nursery, | ||||||
|             ) as ria_nursery: |             ): | ||||||
| 
 |  | ||||||
|                 an = ActorNursery( |                 an = ActorNursery( | ||||||
|                     actor, |                     actor, | ||||||
|                     ria_nursery, |                     ria_nursery, | ||||||
|  | @ -429,7 +500,7 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|                     # the "hard join phase". |                     # the "hard join phase". | ||||||
|                     log.runtime( |                     log.runtime( | ||||||
|                         'Waiting on subactors to complete:\n' |                         'Waiting on subactors to complete:\n' | ||||||
|                         f'{pformat(an._children)}\n' |                         f'>}} {len(an._children)}\n' | ||||||
|                     ) |                     ) | ||||||
|                     an._join_procs.set() |                     an._join_procs.set() | ||||||
| 
 | 
 | ||||||
|  | @ -443,7 +514,7 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|                     # will make the pdb repl unusable. |                     # will make the pdb repl unusable. | ||||||
|                     # Instead try to wait for pdb to be released before |                     # Instead try to wait for pdb to be released before | ||||||
|                     # tearing down. |                     # tearing down. | ||||||
|                     await maybe_wait_for_debugger( |                     await debug.maybe_wait_for_debugger( | ||||||
|                         child_in_debug=an._at_least_one_child_in_debug |                         child_in_debug=an._at_least_one_child_in_debug | ||||||
|                     ) |                     ) | ||||||
| 
 | 
 | ||||||
|  | @ -519,7 +590,7 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
| 
 | 
 | ||||||
|             # XXX: yet another guard before allowing the cancel |             # XXX: yet another guard before allowing the cancel | ||||||
|             # sequence in case a (single) child is in debug. |             # sequence in case a (single) child is in debug. | ||||||
|             await maybe_wait_for_debugger( |             await debug.maybe_wait_for_debugger( | ||||||
|                 child_in_debug=an._at_least_one_child_in_debug |                 child_in_debug=an._at_least_one_child_in_debug | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|  | @ -568,9 +639,15 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|     # final exit |     # final exit | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | _shutdown_msg: str = ( | ||||||
|  |     'Actor-runtime-shutdown' | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @acm | @acm | ||||||
| # @api_frame | # @api_frame | ||||||
| async def open_nursery( | async def open_nursery( | ||||||
|  |     *,  # named params only! | ||||||
|     hide_tb: bool = True, |     hide_tb: bool = True, | ||||||
|     **kwargs, |     **kwargs, | ||||||
|     # ^TODO, paramspec for `open_root_actor()` |     # ^TODO, paramspec for `open_root_actor()` | ||||||
|  | @ -655,17 +732,26 @@ async def open_nursery( | ||||||
|         ): |         ): | ||||||
|             __tracebackhide__: bool = False |             __tracebackhide__: bool = False | ||||||
| 
 | 
 | ||||||
|         msg: str = ( | 
 | ||||||
|             'Actor-nursery exited\n' |         op_nested_an_repr: str = _pformat.nest_from_op( | ||||||
|             f'|_{an}\n' |             input_op=')>', | ||||||
|  |             text=f'{an}', | ||||||
|  |             # nest_prefix='|_', | ||||||
|  |             nest_indent=1,  # under > | ||||||
|         ) |         ) | ||||||
|  |         an_msg: str = ( | ||||||
|  |             f'Actor-nursery exited\n' | ||||||
|  |             f'{op_nested_an_repr}\n' | ||||||
|  |         ) | ||||||
|  |         # keep noise low during std operation. | ||||||
|  |         log.runtime(an_msg) | ||||||
| 
 | 
 | ||||||
|         if implicit_runtime: |         if implicit_runtime: | ||||||
|             # shutdown runtime if it was started and report noisly |             # shutdown runtime if it was started and report noisly | ||||||
|             # that we're did so. |             # that we're did so. | ||||||
|             msg += '=> Shutting down actor runtime <=\n' |             msg: str = ( | ||||||
|  |                 '\n' | ||||||
|  |                 '\n' | ||||||
|  |                 f'{_shutdown_msg} )>\n' | ||||||
|  |             ) | ||||||
|             log.info(msg) |             log.info(msg) | ||||||
| 
 |  | ||||||
|         else: |  | ||||||
|             # keep noise low during std operation. |  | ||||||
|             log.runtime(msg) |  | ||||||
|  |  | ||||||
|  | @ -26,6 +26,9 @@ import os | ||||||
| import pathlib | import pathlib | ||||||
| 
 | 
 | ||||||
| import tractor | import tractor | ||||||
|  | from tractor.devx.debug import ( | ||||||
|  |     BoxedMaybeException, | ||||||
|  | ) | ||||||
| from .pytest import ( | from .pytest import ( | ||||||
|     tractor_test as tractor_test |     tractor_test as tractor_test | ||||||
| ) | ) | ||||||
|  | @ -34,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. | ||||||
|  | @ -98,12 +104,13 @@ async def expect_ctxc( | ||||||
|     ''' |     ''' | ||||||
|     if yay: |     if yay: | ||||||
|         try: |         try: | ||||||
|             yield |             yield (maybe_exc := BoxedMaybeException()) | ||||||
|             raise RuntimeError('Never raised ctxc?') |             raise RuntimeError('Never raised ctxc?') | ||||||
|         except tractor.ContextCancelled: |         except tractor.ContextCancelled as ctxc: | ||||||
|  |             maybe_exc.value = ctxc | ||||||
|             if reraise: |             if reraise: | ||||||
|                 raise |                 raise | ||||||
|             else: |             else: | ||||||
|                 return |                 return | ||||||
|     else: |     else: | ||||||
|         yield |         yield (maybe_exc := BoxedMaybeException()) | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | @ -20,7 +20,7 @@ Runtime "developer experience" utils and addons to aid our | ||||||
| and working with/on the actor runtime. | and working with/on the actor runtime. | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| from ._debug import ( | from .debug import ( | ||||||
|     maybe_wait_for_debugger as maybe_wait_for_debugger, |     maybe_wait_for_debugger as maybe_wait_for_debugger, | ||||||
|     acquire_debug_lock as acquire_debug_lock, |     acquire_debug_lock as acquire_debug_lock, | ||||||
|     breakpoint as breakpoint, |     breakpoint as breakpoint, | ||||||
|  |  | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -20,13 +20,18 @@ as it pertains to improving the grok-ability of our runtime! | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  | from contextlib import ( | ||||||
|  |     _GeneratorContextManager, | ||||||
|  |     _AsyncGeneratorContextManager, | ||||||
|  | ) | ||||||
| from functools import partial | from functools import partial | ||||||
| import inspect | import inspect | ||||||
|  | import textwrap | ||||||
| from types import ( | from types import ( | ||||||
|     FrameType, |     FrameType, | ||||||
|     FunctionType, |     FunctionType, | ||||||
|     MethodType, |     MethodType, | ||||||
|     # CodeType, |     CodeType, | ||||||
| ) | ) | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, |     Any, | ||||||
|  | @ -34,6 +39,9 @@ from typing import ( | ||||||
|     Type, |     Type, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | import pdbp | ||||||
|  | from tractor.log import get_logger | ||||||
|  | import trio | ||||||
| from tractor.msg import ( | from tractor.msg import ( | ||||||
|     pretty_struct, |     pretty_struct, | ||||||
|     NamespacePath, |     NamespacePath, | ||||||
|  | @ -41,6 +49,8 @@ from tractor.msg import ( | ||||||
| import wrapt | import wrapt | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
| # TODO: yeah, i don't love this and we should prolly just | # TODO: yeah, i don't love this and we should prolly just | ||||||
| # write a decorator that actually keeps a stupid ref to the func | # write a decorator that actually keeps a stupid ref to the func | ||||||
| # obj.. | # obj.. | ||||||
|  | @ -301,3 +311,70 @@ def api_frame( | ||||||
| #     error_set: set[BaseException], | #     error_set: set[BaseException], | ||||||
| # ) -> TracebackType: | # ) -> TracebackType: | ||||||
| #     ... | #     ... | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def hide_runtime_frames() -> dict[FunctionType, CodeType]: | ||||||
|  |     ''' | ||||||
|  |     Hide call-stack frames for various std-lib and `trio`-API primitives | ||||||
|  |     such that the tracebacks presented from our runtime are as minimized | ||||||
|  |     as possible, particularly from inside a `PdbREPL`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # XXX HACKZONE XXX | ||||||
|  |     #  hide exit stack frames on nurseries and cancel-scopes! | ||||||
|  |     # |_ so avoid seeing it when the `pdbp` REPL is first engaged from | ||||||
|  |     #    inside a `trio.open_nursery()` scope (with no line after it | ||||||
|  |     #    in before the block end??). | ||||||
|  |     # | ||||||
|  |     # TODO: FINALLY got this workin originally with | ||||||
|  |     #  `@pdbp.hideframe` around the `wrapper()` def embedded inside | ||||||
|  |     #  `_ki_protection_decoratior()`.. which is in the module: | ||||||
|  |     #  /home/goodboy/.virtualenvs/tractor311/lib/python3.11/site-packages/trio/_core/_ki.py | ||||||
|  |     # | ||||||
|  |     # -[ ] make an issue and patch for `trio` core? maybe linked | ||||||
|  |     #    to the long outstanding `pdb` one below? | ||||||
|  |     #   |_ it's funny that there's frame hiding throughout `._run.py` | ||||||
|  |     #      but not where it matters on the below exit funcs.. | ||||||
|  |     # | ||||||
|  |     # -[ ] provide a patchset for the lonstanding | ||||||
|  |     #   |_ https://github.com/python-trio/trio/issues/1155 | ||||||
|  |     # | ||||||
|  |     # -[ ] make a linked issue to ^ and propose allowing all the | ||||||
|  |     #     `._core._run` code to have their `__tracebackhide__` value | ||||||
|  |     #     configurable by a `RunVar` to allow getting scheduler frames | ||||||
|  |     #     if desired through configuration? | ||||||
|  |     # | ||||||
|  |     # -[ ] maybe dig into the core `pdb` issue why the extra frame is shown | ||||||
|  |     #      at all? | ||||||
|  |     # | ||||||
|  |     funcs: list[FunctionType] = [ | ||||||
|  |         trio._core._run.NurseryManager.__aexit__, | ||||||
|  |         trio._core._run.CancelScope.__exit__, | ||||||
|  |          _GeneratorContextManager.__exit__, | ||||||
|  |          _AsyncGeneratorContextManager.__aexit__, | ||||||
|  |          _AsyncGeneratorContextManager.__aenter__, | ||||||
|  |          trio.Event.wait, | ||||||
|  |     ] | ||||||
|  |     func_list_str: str = textwrap.indent( | ||||||
|  |         "\n".join(f.__qualname__ for f in funcs), | ||||||
|  |         prefix=' |_ ', | ||||||
|  |     ) | ||||||
|  |     log.devx( | ||||||
|  |         'Hiding the following runtime frames by default:\n' | ||||||
|  |         f'{func_list_str}\n' | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     codes: dict[FunctionType, CodeType] = {} | ||||||
|  |     for ref in funcs: | ||||||
|  |         # stash a pre-modified version of each ref's code-obj | ||||||
|  |         # so it can be reverted later if needed. | ||||||
|  |         codes[ref] = ref.__code__ | ||||||
|  |         pdbp.hideframe(ref) | ||||||
|  |     # | ||||||
|  |     # pdbp.hideframe(trio._core._run.NurseryManager.__aexit__) | ||||||
|  |     # pdbp.hideframe(trio._core._run.CancelScope.__exit__) | ||||||
|  |     # pdbp.hideframe(_GeneratorContextManager.__exit__) | ||||||
|  |     # pdbp.hideframe(_AsyncGeneratorContextManager.__aexit__) | ||||||
|  |     # pdbp.hideframe(_AsyncGeneratorContextManager.__aenter__) | ||||||
|  |     # pdbp.hideframe(trio.Event.wait) | ||||||
|  |     return codes | ||||||
|  |  | ||||||
|  | @ -49,7 +49,7 @@ from tractor import ( | ||||||
|     _state, |     _state, | ||||||
|     log as logmod, |     log as logmod, | ||||||
| ) | ) | ||||||
| from tractor.devx import _debug | from tractor.devx import debug | ||||||
| 
 | 
 | ||||||
| log = logmod.get_logger(__name__) | log = logmod.get_logger(__name__) | ||||||
| 
 | 
 | ||||||
|  | @ -82,7 +82,7 @@ def dump_task_tree() -> None: | ||||||
|     if ( |     if ( | ||||||
|         current_sigint_handler |         current_sigint_handler | ||||||
|         is not |         is not | ||||||
|         _debug.DebugStatus._trio_handler |         debug.DebugStatus._trio_handler | ||||||
|     ): |     ): | ||||||
|         sigint_handler_report: str = ( |         sigint_handler_report: str = ( | ||||||
|             'The default `trio` SIGINT handler was replaced?!' |             'The default `trio` SIGINT handler was replaced?!' | ||||||
|  | @ -238,7 +238,8 @@ def enable_stack_on_sig( | ||||||
|         import stackscope |         import stackscope | ||||||
|     except ImportError: |     except ImportError: | ||||||
|         log.warning( |         log.warning( | ||||||
|             '`stackscope` not installed for use in debug mode!' |             'The `stackscope` lib is not installed!\n' | ||||||
|  |             '`Ignoring enable_stack_on_sig() call!\n' | ||||||
|         ) |         ) | ||||||
|         return None |         return None | ||||||
| 
 | 
 | ||||||
|  | @ -255,8 +256,8 @@ def enable_stack_on_sig( | ||||||
|         dump_tree_on_sig, |         dump_tree_on_sig, | ||||||
|     ) |     ) | ||||||
|     log.devx( |     log.devx( | ||||||
|         'Enabling trace-trees on `SIGUSR1` ' |         f'Enabling trace-trees on `SIGUSR1` ' | ||||||
|         'since `stackscope` is installed @ \n' |         f'since `stackscope` is installed @ \n' | ||||||
|         f'{stackscope!r}\n\n' |         f'{stackscope!r}\n\n' | ||||||
|         f'With `SIGUSR1` handler\n' |         f'With `SIGUSR1` handler\n' | ||||||
|         f'|_{dump_tree_on_sig}\n' |         f'|_{dump_tree_on_sig}\n' | ||||||
|  |  | ||||||
|  | @ -0,0 +1,100 @@ | ||||||
|  | # 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/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Multi-actor debugging for da peeps! | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from tractor.log import get_logger | ||||||
|  | from ._repl import ( | ||||||
|  |     PdbREPL as PdbREPL, | ||||||
|  |     mk_pdb as mk_pdb, | ||||||
|  |     TractorConfig as TractorConfig, | ||||||
|  | ) | ||||||
|  | from ._tty_lock import ( | ||||||
|  |     DebugStatus as DebugStatus, | ||||||
|  |     DebugStateError as DebugStateError, | ||||||
|  | ) | ||||||
|  | from ._trace import ( | ||||||
|  |     Lock as Lock, | ||||||
|  |     _pause_msg as _pause_msg, | ||||||
|  |     _repl_fail_msg as _repl_fail_msg, | ||||||
|  |     _set_trace as _set_trace, | ||||||
|  |     _sync_pause_from_builtin as _sync_pause_from_builtin, | ||||||
|  |     breakpoint as breakpoint, | ||||||
|  |     maybe_init_greenback as maybe_init_greenback, | ||||||
|  |     maybe_import_greenback as maybe_import_greenback, | ||||||
|  |     pause as pause, | ||||||
|  |     pause_from_sync as pause_from_sync, | ||||||
|  | ) | ||||||
|  | from ._post_mortem import ( | ||||||
|  |     BoxedMaybeException as BoxedMaybeException, | ||||||
|  |     maybe_open_crash_handler as maybe_open_crash_handler, | ||||||
|  |     open_crash_handler as open_crash_handler, | ||||||
|  |     post_mortem as post_mortem, | ||||||
|  |     _crash_msg as _crash_msg, | ||||||
|  |     _maybe_enter_pm as _maybe_enter_pm, | ||||||
|  | ) | ||||||
|  | from ._sync import ( | ||||||
|  |     maybe_wait_for_debugger as maybe_wait_for_debugger, | ||||||
|  |     acquire_debug_lock as acquire_debug_lock, | ||||||
|  | ) | ||||||
|  | from ._sigint import ( | ||||||
|  |     sigint_shield as sigint_shield, | ||||||
|  |     _ctlc_ignore_header as _ctlc_ignore_header | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | # ---------------- | ||||||
|  | # XXX PKG TODO XXX | ||||||
|  | # ---------------- | ||||||
|  | # refine the internal impl and APIs! | ||||||
|  | # | ||||||
|  | # -[ ] rework `._pause()` and it's branch-cases for root vs. | ||||||
|  | #     subactor: | ||||||
|  | #  -[ ] `._pause_from_root()` + `_pause_from_subactor()`? | ||||||
|  | #  -[ ]  do the de-factor based on bg-thread usage in | ||||||
|  | #    `.pause_from_sync()` & `_pause_from_bg_root_thread()`. | ||||||
|  | #  -[ ] drop `debug_func == None` case which is confusing af.. | ||||||
|  | #  -[ ]  factor out `_enter_repl_sync()` into a util func for calling | ||||||
|  | #    the `_set_trace()` / `_post_mortem()` APIs? | ||||||
|  | # | ||||||
|  | # -[ ] figure out if we need `acquire_debug_lock()` and/or re-implement | ||||||
|  | #    it as part of the `.pause_from_sync()` rework per above? | ||||||
|  | # | ||||||
|  | # -[ ] pair the `._pause_from_subactor()` impl with a "debug nursery" | ||||||
|  | #   that's dynamically allocated inside the `._rpc` task thus | ||||||
|  | #   avoiding the `._service_n.start()` usage for the IPC request? | ||||||
|  | #  -[ ] see the TODO inside `._rpc._errors_relayed_via_ipc()` | ||||||
|  | # | ||||||
|  | # -[ ] impl a `open_debug_request()` which encaps all | ||||||
|  | #   `request_root_stdio_lock()` task scheduling deats | ||||||
|  | #   + `DebugStatus` state mgmt; which should prolly be re-branded as | ||||||
|  | #   a `DebugRequest` type anyway AND with suppoort for bg-thread | ||||||
|  | #   (from root actor) usage? | ||||||
|  | # | ||||||
|  | # -[ ] handle the `xonsh` case for bg-root-threads in the SIGINT | ||||||
|  | #     handler! | ||||||
|  | #   -[ ] do we need to do the same for subactors? | ||||||
|  | #   -[ ] make the failing tests finally pass XD | ||||||
|  | # | ||||||
|  | # -[ ] simplify `maybe_wait_for_debugger()` to be a root-task only | ||||||
|  | #     API? | ||||||
|  | #   -[ ] currently it's implemented as that so might as well make it | ||||||
|  | #     formal? | ||||||
|  | @ -0,0 +1,412 @@ | ||||||
|  | # 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/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Post-mortem debugging APIs and surrounding machinery for both | ||||||
|  | sync and async contexts. | ||||||
|  | 
 | ||||||
|  | Generally we maintain the same semantics a `pdb.post.mortem()` but | ||||||
|  | with actor-tree-wide sync/cooperation around any (sub)actor's use of | ||||||
|  | the root's TTY. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | import bdb | ||||||
|  | from contextlib import ( | ||||||
|  |     AbstractContextManager, | ||||||
|  |     contextmanager as cm, | ||||||
|  |     nullcontext, | ||||||
|  | ) | ||||||
|  | from functools import ( | ||||||
|  |     partial, | ||||||
|  | ) | ||||||
|  | import inspect | ||||||
|  | import sys | ||||||
|  | import traceback | ||||||
|  | from typing import ( | ||||||
|  |     Callable, | ||||||
|  |     Sequence, | ||||||
|  |     Type, | ||||||
|  |     TYPE_CHECKING, | ||||||
|  | ) | ||||||
|  | from types import ( | ||||||
|  |     TracebackType, | ||||||
|  |     FrameType, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from msgspec import Struct | ||||||
|  | import trio | ||||||
|  | from tractor._exceptions import ( | ||||||
|  |     NoRuntime, | ||||||
|  | ) | ||||||
|  | from tractor import _state | ||||||
|  | from tractor._state import ( | ||||||
|  |     current_actor, | ||||||
|  |     debug_mode, | ||||||
|  | ) | ||||||
|  | from tractor.log import get_logger | ||||||
|  | from tractor.trionics import ( | ||||||
|  |     is_multi_cancelled, | ||||||
|  | ) | ||||||
|  | from ._trace import ( | ||||||
|  |     _pause, | ||||||
|  | ) | ||||||
|  | from ._tty_lock import ( | ||||||
|  |     DebugStatus, | ||||||
|  | ) | ||||||
|  | from ._repl import ( | ||||||
|  |     PdbREPL, | ||||||
|  |     mk_pdb, | ||||||
|  |     TractorConfig as TractorConfig, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from trio.lowlevel import Task | ||||||
|  |     from tractor._runtime import ( | ||||||
|  |         Actor, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | _crash_msg: str = ( | ||||||
|  |     'Opening a pdb REPL in crashed actor' | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | log = get_logger(__package__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class BoxedMaybeException(Struct): | ||||||
|  |     ''' | ||||||
|  |     Box a maybe-exception for post-crash introspection usage | ||||||
|  |     from the body of a `open_crash_handler()` scope. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     value: BaseException|None = None | ||||||
|  | 
 | ||||||
|  |     # handler can suppress crashes dynamically | ||||||
|  |     raise_on_exit: bool|Sequence[Type[BaseException]] = True | ||||||
|  | 
 | ||||||
|  |     def pformat(self) -> str: | ||||||
|  |         ''' | ||||||
|  |         Repr the boxed `.value` error in more-than-string | ||||||
|  |         repr form. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         if not self.value: | ||||||
|  |             return f'<{type(self).__name__}( .value=None )>' | ||||||
|  | 
 | ||||||
|  |         return ( | ||||||
|  |             f'<{type(self.value).__name__}(\n' | ||||||
|  |             f' |_.value = {self.value}\n' | ||||||
|  |             f')>\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     __repr__ = pformat | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def _post_mortem( | ||||||
|  |     repl: PdbREPL,  # normally passed by `_pause()` | ||||||
|  | 
 | ||||||
|  |     # XXX all `partial`-ed in by `post_mortem()` below! | ||||||
|  |     tb: TracebackType, | ||||||
|  |     api_frame: FrameType, | ||||||
|  | 
 | ||||||
|  |     shield: bool = False, | ||||||
|  |     hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  |     # maybe pre/post REPL entry | ||||||
|  |     repl_fixture: ( | ||||||
|  |         AbstractContextManager[bool] | ||||||
|  |         |None | ||||||
|  |     ) = None, | ||||||
|  | 
 | ||||||
|  |     boxed_maybe_exc: BoxedMaybeException|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Enter the ``pdbpp`` port mortem entrypoint using our custom | ||||||
|  |     debugger instance. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     __tracebackhide__: bool = hide_tb | ||||||
|  | 
 | ||||||
|  |     # maybe enter any user fixture | ||||||
|  |     enter_repl: bool = DebugStatus.maybe_enter_repl_fixture( | ||||||
|  |         repl=repl, | ||||||
|  |         repl_fixture=repl_fixture, | ||||||
|  |         boxed_maybe_exc=boxed_maybe_exc, | ||||||
|  |     ) | ||||||
|  |     try: | ||||||
|  |         if not enter_repl: | ||||||
|  |             # XXX, trigger `.release()` below immediately! | ||||||
|  |             return | ||||||
|  |         try: | ||||||
|  |             actor: Actor = current_actor() | ||||||
|  |             actor_repr: str = str(actor.uid) | ||||||
|  |             # ^TODO, instead a nice runtime-info + maddr + uid? | ||||||
|  |             # -[ ] impl a `Actor.__repr()__`?? | ||||||
|  |             #  |_ <task>:<thread> @ <actor> | ||||||
|  | 
 | ||||||
|  |         except NoRuntime: | ||||||
|  |             actor_repr: str = '<no-actor-runtime?>' | ||||||
|  | 
 | ||||||
|  |         try: | ||||||
|  |             task_repr: Task = trio.lowlevel.current_task() | ||||||
|  |         except RuntimeError: | ||||||
|  |             task_repr: str = '<unknown-Task>' | ||||||
|  | 
 | ||||||
|  |         # TODO: print the actor supervion tree up to the root | ||||||
|  |         # here! Bo | ||||||
|  |         log.pdb( | ||||||
|  |             f'{_crash_msg}\n' | ||||||
|  |             f'x>(\n' | ||||||
|  |             f' |_ {task_repr} @ {actor_repr}\n' | ||||||
|  | 
 | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # XXX NOTE(s) on `pdbp.xpm()` version.. | ||||||
|  |         # | ||||||
|  |         # - seems to lose the up-stack tb-info? | ||||||
|  |         # - currently we're (only) replacing this from `pdbp.xpm()` | ||||||
|  |         #   to add the `end=''` to the print XD | ||||||
|  |         # | ||||||
|  |         print(traceback.format_exc(), end='') | ||||||
|  |         caller_frame: FrameType = api_frame.f_back | ||||||
|  | 
 | ||||||
|  |         # NOTE, see the impl details of these in the lib to | ||||||
|  |         # understand usage: | ||||||
|  |         # - `pdbp.post_mortem()` | ||||||
|  |         # - `pdbp.xps()` | ||||||
|  |         # - `bdb.interaction()` | ||||||
|  |         repl.reset() | ||||||
|  |         repl.interaction( | ||||||
|  |             frame=caller_frame, | ||||||
|  |             # frame=None, | ||||||
|  |             traceback=tb, | ||||||
|  |         ) | ||||||
|  |     finally: | ||||||
|  |         # XXX NOTE XXX: this is abs required to avoid hangs! | ||||||
|  |         # | ||||||
|  |         # Since we presume the post-mortem was enaged to | ||||||
|  |         # a task-ending error, we MUST release the local REPL request | ||||||
|  |         # so that not other local task nor the root remains blocked! | ||||||
|  |         DebugStatus.release() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def post_mortem( | ||||||
|  |     *, | ||||||
|  |     tb: TracebackType|None = None, | ||||||
|  |     api_frame: FrameType|None = None, | ||||||
|  |     hide_tb: bool = False, | ||||||
|  | 
 | ||||||
|  |     # TODO: support shield here just like in `pause()`? | ||||||
|  |     # shield: bool = False, | ||||||
|  | 
 | ||||||
|  |     **_pause_kwargs, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Our builtin async equivalient of `pdb.post_mortem()` which can be | ||||||
|  |     used inside exception handlers. | ||||||
|  | 
 | ||||||
|  |     It's also used for the crash handler when `debug_mode == True` ;) | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     __tracebackhide__: bool = hide_tb | ||||||
|  | 
 | ||||||
|  |     tb: TracebackType = tb or sys.exc_info()[2] | ||||||
|  | 
 | ||||||
|  |     # TODO: do upward stack scan for highest @api_frame and | ||||||
|  |     # use its parent frame as the expected user-app code | ||||||
|  |     # interact point. | ||||||
|  |     api_frame: FrameType = api_frame or inspect.currentframe() | ||||||
|  | 
 | ||||||
|  |     # TODO, move to submod `._pausing` or ._api? _trace | ||||||
|  |     await _pause( | ||||||
|  |         debug_func=partial( | ||||||
|  |             _post_mortem, | ||||||
|  |             api_frame=api_frame, | ||||||
|  |             tb=tb, | ||||||
|  |         ), | ||||||
|  |         hide_tb=hide_tb, | ||||||
|  |         **_pause_kwargs | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def _maybe_enter_pm( | ||||||
|  |     err: BaseException, | ||||||
|  |     *, | ||||||
|  |     tb: TracebackType|None = None, | ||||||
|  |     api_frame: FrameType|None = None, | ||||||
|  |     hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  |     # only enter debugger REPL when returns `True` | ||||||
|  |     debug_filter: Callable[ | ||||||
|  |         [BaseException|BaseExceptionGroup], | ||||||
|  |         bool, | ||||||
|  |     ] = lambda err: not is_multi_cancelled(err), | ||||||
|  |     **_pause_kws, | ||||||
|  | ): | ||||||
|  |     if ( | ||||||
|  |         debug_mode() | ||||||
|  | 
 | ||||||
|  |         # NOTE: don't enter debug mode recursively after quitting pdb | ||||||
|  |         # Iow, don't re-enter the repl if the `quit` command was issued | ||||||
|  |         # by the user. | ||||||
|  |         and not isinstance(err, bdb.BdbQuit) | ||||||
|  | 
 | ||||||
|  |         # XXX: if the error is the likely result of runtime-wide | ||||||
|  |         # cancellation, we don't want to enter the debugger since | ||||||
|  |         # there's races between when the parent actor has killed all | ||||||
|  |         # comms and when the child tries to contact said parent to | ||||||
|  |         # acquire the tty lock. | ||||||
|  | 
 | ||||||
|  |         # Really we just want to mostly avoid catching KBIs here so there | ||||||
|  |         # might be a simpler check we can do? | ||||||
|  |         and | ||||||
|  |         debug_filter(err) | ||||||
|  |     ): | ||||||
|  |         api_frame: FrameType = api_frame or inspect.currentframe() | ||||||
|  |         tb: TracebackType = tb or sys.exc_info()[2] | ||||||
|  |         await post_mortem( | ||||||
|  |             api_frame=api_frame, | ||||||
|  |             tb=tb, | ||||||
|  |             **_pause_kws, | ||||||
|  |         ) | ||||||
|  |         return True | ||||||
|  | 
 | ||||||
|  |     else: | ||||||
|  |         return False | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: better naming and what additionals? | ||||||
|  | # - [ ] optional runtime plugging? | ||||||
|  | # - [ ] detection for sync vs. async code? | ||||||
|  | # - [ ] specialized REPL entry when in distributed mode? | ||||||
|  | # -[x] hide tb by def | ||||||
|  | # - [x] allow ignoring kbi Bo | ||||||
|  | @cm | ||||||
|  | def open_crash_handler( | ||||||
|  |     catch: set[BaseException] = { | ||||||
|  |         BaseException, | ||||||
|  |     }, | ||||||
|  |     ignore: set[BaseException] = { | ||||||
|  |         KeyboardInterrupt, | ||||||
|  |         trio.Cancelled, | ||||||
|  |     }, | ||||||
|  |     hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  |     repl_fixture: ( | ||||||
|  |         AbstractContextManager[bool]  # pre/post REPL entry | ||||||
|  |         |None | ||||||
|  |     ) = None, | ||||||
|  |     raise_on_exit: bool|Sequence[Type[BaseException]] = True, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Generic "post mortem" crash handler using `pdbp` REPL debugger. | ||||||
|  | 
 | ||||||
|  |     We expose this as a CLI framework addon to both `click` and | ||||||
|  |     `typer` users so they can quickly wrap cmd endpoints which get | ||||||
|  |     automatically wrapped to use the runtime's `debug_mode: bool` | ||||||
|  |     AND `pdbp.pm()` around any code that is PRE-runtime entry | ||||||
|  |     - any sync code which runs BEFORE the main call to | ||||||
|  |       `trio.run()`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     __tracebackhide__: bool = hide_tb | ||||||
|  | 
 | ||||||
|  |     # TODO, yield a `outcome.Error`-like boxed type? | ||||||
|  |     # -[~] use `outcome.Value/Error` X-> frozen! | ||||||
|  |     # -[x] write our own..? | ||||||
|  |     # -[ ] consider just wtv is used by `pytest.raises()`? | ||||||
|  |     # | ||||||
|  |     boxed_maybe_exc = BoxedMaybeException( | ||||||
|  |         raise_on_exit=raise_on_exit, | ||||||
|  |     ) | ||||||
|  |     err: BaseException | ||||||
|  |     try: | ||||||
|  |         yield boxed_maybe_exc | ||||||
|  |     except tuple(catch) as err: | ||||||
|  |         boxed_maybe_exc.value = err | ||||||
|  |         if ( | ||||||
|  |             type(err) not in ignore | ||||||
|  |             and | ||||||
|  |             not is_multi_cancelled( | ||||||
|  |                 err, | ||||||
|  |                 ignore_nested=ignore | ||||||
|  |             ) | ||||||
|  |         ): | ||||||
|  |             try: | ||||||
|  |                 # use our re-impl-ed version of `pdbp.xpm()` | ||||||
|  |                 _post_mortem( | ||||||
|  |                     repl=mk_pdb(), | ||||||
|  |                     tb=sys.exc_info()[2], | ||||||
|  |                     api_frame=inspect.currentframe().f_back, | ||||||
|  |                     hide_tb=hide_tb, | ||||||
|  | 
 | ||||||
|  |                     repl_fixture=repl_fixture, | ||||||
|  |                     boxed_maybe_exc=boxed_maybe_exc, | ||||||
|  |                 ) | ||||||
|  |             except bdb.BdbQuit: | ||||||
|  |                 __tracebackhide__: bool = False | ||||||
|  |                 raise err | ||||||
|  | 
 | ||||||
|  |         if ( | ||||||
|  |             raise_on_exit is True | ||||||
|  |             or ( | ||||||
|  |                 raise_on_exit is not False | ||||||
|  |                 and ( | ||||||
|  |                     set(raise_on_exit) | ||||||
|  |                     and | ||||||
|  |                     type(err) in raise_on_exit | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             and | ||||||
|  |             boxed_maybe_exc.raise_on_exit == raise_on_exit | ||||||
|  |         ): | ||||||
|  |             raise err | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @cm | ||||||
|  | def maybe_open_crash_handler( | ||||||
|  |     pdb: bool|None = None, | ||||||
|  |     hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  |     **kwargs, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Same as `open_crash_handler()` but with bool input flag | ||||||
|  |     to allow conditional handling. | ||||||
|  | 
 | ||||||
|  |     Normally this is used with CLI endpoints such that if the --pdb | ||||||
|  |     flag is passed the pdb REPL is engaed on any crashes B) | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     __tracebackhide__: bool = hide_tb | ||||||
|  | 
 | ||||||
|  |     if pdb is None: | ||||||
|  |         pdb: bool = _state.is_debug_mode() | ||||||
|  | 
 | ||||||
|  |     rtctx = nullcontext( | ||||||
|  |         enter_result=BoxedMaybeException() | ||||||
|  |     ) | ||||||
|  |     if pdb: | ||||||
|  |         rtctx = open_crash_handler( | ||||||
|  |             hide_tb=hide_tb, | ||||||
|  |             **kwargs, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     with rtctx as boxed_maybe_exc: | ||||||
|  |         yield boxed_maybe_exc | ||||||
|  | @ -0,0 +1,207 @@ | ||||||
|  | # 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/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | `pdpp.Pdb` extentions/customization and other delegate usage. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from functools import ( | ||||||
|  |     cached_property, | ||||||
|  | ) | ||||||
|  | import os | ||||||
|  | 
 | ||||||
|  | import pdbp | ||||||
|  | from tractor._state import ( | ||||||
|  |     is_root_process, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from ._tty_lock import ( | ||||||
|  |     Lock, | ||||||
|  |     DebugStatus, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TractorConfig(pdbp.DefaultConfig): | ||||||
|  |     ''' | ||||||
|  |     Custom `pdbp` config which tries to use the best tradeoff | ||||||
|  |     between pretty and minimal. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     use_pygments: bool = True | ||||||
|  |     sticky_by_default: bool = False | ||||||
|  |     enable_hidden_frames: bool = True | ||||||
|  | 
 | ||||||
|  |     # much thanks @mdmintz for the hot tip! | ||||||
|  |     # fixes line spacing issue when resizing terminal B) | ||||||
|  |     truncate_long_lines: bool = False | ||||||
|  | 
 | ||||||
|  |     # ------ - ------ | ||||||
|  |     # our own custom config vars mostly | ||||||
|  |     # for syncing with the actor tree's singleton | ||||||
|  |     # TTY `Lock`. | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class PdbREPL(pdbp.Pdb): | ||||||
|  |     ''' | ||||||
|  |     Add teardown hooks and local state describing any | ||||||
|  |     ongoing TTY `Lock` request dialog. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # override the pdbp config with our coolio one | ||||||
|  |     # NOTE: this is only loaded when no `~/.pdbrc` exists | ||||||
|  |     # so we should prolly pass it into the .__init__() instead? | ||||||
|  |     # i dunno, see the `DefaultFactory` and `pdb.Pdb` impls. | ||||||
|  |     DefaultConfig = TractorConfig | ||||||
|  | 
 | ||||||
|  |     status = DebugStatus | ||||||
|  | 
 | ||||||
|  |     # NOTE: see details in stdlib's `bdb.py` | ||||||
|  |     # def user_exception(self, frame, exc_info): | ||||||
|  |     #     ''' | ||||||
|  |     #     Called when we stop on an exception. | ||||||
|  |     #     ''' | ||||||
|  |     #     log.warning( | ||||||
|  |     #         'Exception during REPL sesh\n\n' | ||||||
|  |     #         f'{frame}\n\n' | ||||||
|  |     #         f'{exc_info}\n\n' | ||||||
|  |     #     ) | ||||||
|  | 
 | ||||||
|  |     # NOTE: this actually hooks but i don't see anyway to detect | ||||||
|  |     # if an error was caught.. this is why currently we just always | ||||||
|  |     # call `DebugStatus.release` inside `_post_mortem()`. | ||||||
|  |     # def preloop(self): | ||||||
|  |     #     print('IN PRELOOP') | ||||||
|  |     #     super().preloop() | ||||||
|  | 
 | ||||||
|  |     # TODO: cleaner re-wrapping of all this? | ||||||
|  |     # -[ ] figure out how to disallow recursive .set_trace() entry | ||||||
|  |     #     since that'll cause deadlock for us. | ||||||
|  |     # -[ ] maybe a `@cm` to call `super().<same_meth_name>()`? | ||||||
|  |     # -[ ] look at hooking into the `pp` hook specially with our | ||||||
|  |     #     own set of pretty-printers? | ||||||
|  |     #    * `.pretty_struct.Struct.pformat()` | ||||||
|  |     #    * `.pformat(MsgType.pld)` | ||||||
|  |     #    * `.pformat(Error.tb_str)`? | ||||||
|  |     #    * .. maybe more? | ||||||
|  |     # | ||||||
|  |     def set_continue(self): | ||||||
|  |         try: | ||||||
|  |             super().set_continue() | ||||||
|  |         finally: | ||||||
|  |             # NOTE: for subactors the stdio lock is released via the | ||||||
|  |             # allocated RPC locker task, so for root we have to do it | ||||||
|  |             # manually. | ||||||
|  |             if ( | ||||||
|  |                 is_root_process() | ||||||
|  |                 and | ||||||
|  |                 Lock._debug_lock.locked() | ||||||
|  |                 and | ||||||
|  |                 DebugStatus.is_main_trio_thread() | ||||||
|  |             ): | ||||||
|  |                 # Lock.release(raise_on_thread=False) | ||||||
|  |                 Lock.release() | ||||||
|  | 
 | ||||||
|  |             # XXX AFTER `Lock.release()` for root local repl usage | ||||||
|  |             DebugStatus.release() | ||||||
|  | 
 | ||||||
|  |     def set_quit(self): | ||||||
|  |         try: | ||||||
|  |             super().set_quit() | ||||||
|  |         finally: | ||||||
|  |             if ( | ||||||
|  |                 is_root_process() | ||||||
|  |                 and | ||||||
|  |                 Lock._debug_lock.locked() | ||||||
|  |                 and | ||||||
|  |                 DebugStatus.is_main_trio_thread() | ||||||
|  |             ): | ||||||
|  |                 # Lock.release(raise_on_thread=False) | ||||||
|  |                 Lock.release() | ||||||
|  | 
 | ||||||
|  |             # XXX after `Lock.release()` for root local repl usage | ||||||
|  |             DebugStatus.release() | ||||||
|  | 
 | ||||||
|  |     # XXX NOTE: we only override this because apparently the stdlib pdb | ||||||
|  |     # bois likes to touch the SIGINT handler as much as i like to touch | ||||||
|  |     # my d$%&. | ||||||
|  |     def _cmdloop(self): | ||||||
|  |         self.cmdloop() | ||||||
|  | 
 | ||||||
|  |     @cached_property | ||||||
|  |     def shname(self) -> str | None: | ||||||
|  |         ''' | ||||||
|  |         Attempt to return the login shell name with a special check for | ||||||
|  |         the infamous `xonsh` since it seems to have some issues much | ||||||
|  |         different from std shells when it comes to flushing the prompt? | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         # SUPER HACKY and only really works if `xonsh` is not used | ||||||
|  |         # before spawning further sub-shells.. | ||||||
|  |         shpath = os.getenv('SHELL', None) | ||||||
|  | 
 | ||||||
|  |         if shpath: | ||||||
|  |             if ( | ||||||
|  |                 os.getenv('XONSH_LOGIN', default=False) | ||||||
|  |                 or 'xonsh' in shpath | ||||||
|  |             ): | ||||||
|  |                 return 'xonsh' | ||||||
|  | 
 | ||||||
|  |             return os.path.basename(shpath) | ||||||
|  | 
 | ||||||
|  |         return None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def mk_pdb() -> PdbREPL: | ||||||
|  |     ''' | ||||||
|  |     Deliver a new `PdbREPL`: a multi-process safe `pdbp.Pdb`-variant | ||||||
|  |     using the magic of `tractor`'s SC-safe IPC. | ||||||
|  | 
 | ||||||
|  |     B) | ||||||
|  | 
 | ||||||
|  |     Our `pdb.Pdb` subtype accomplishes multi-process safe debugging | ||||||
|  |     by: | ||||||
|  | 
 | ||||||
|  |     - mutexing access to the root process' std-streams (& thus parent | ||||||
|  |       process TTY) via an IPC managed `Lock` singleton per | ||||||
|  |       actor-process tree. | ||||||
|  | 
 | ||||||
|  |     - temporarily overriding any subactor's SIGINT handler to shield | ||||||
|  |       during live REPL sessions in sub-actors such that cancellation | ||||||
|  |       is never (mistakenly) triggered by a ctrl-c and instead only by | ||||||
|  |       explicit runtime API requests or after the | ||||||
|  |       `pdb.Pdb.interaction()` call has returned. | ||||||
|  | 
 | ||||||
|  |     FURTHER, the `pdbp.Pdb` instance is configured to be `trio` | ||||||
|  |     "compatible" from a SIGINT handling perspective; we mask out | ||||||
|  |     the default `pdb` handler and instead apply `trio`s default | ||||||
|  |     which mostly addresses all issues described in: | ||||||
|  | 
 | ||||||
|  |      - https://github.com/python-trio/trio/issues/1155 | ||||||
|  | 
 | ||||||
|  |     The instance returned from this factory should always be | ||||||
|  |     preferred over the default `pdb[p].set_trace()` whenever using | ||||||
|  |     a `pdb` REPL inside a `trio` based runtime. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     pdb = PdbREPL() | ||||||
|  | 
 | ||||||
|  |     # XXX: These are the important flags mentioned in | ||||||
|  |     # https://github.com/python-trio/trio/issues/1155 | ||||||
|  |     # which resolve the traceback spews to console. | ||||||
|  |     pdb.allow_kbdint = True | ||||||
|  |     pdb.nosigint = True | ||||||
|  |     return pdb | ||||||
|  | @ -0,0 +1,333 @@ | ||||||
|  | # 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 custom SIGINT handler which mainly shields actor (task) | ||||||
|  | cancellation during REPL interaction. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from typing import ( | ||||||
|  |     TYPE_CHECKING, | ||||||
|  | ) | ||||||
|  | import trio | ||||||
|  | from tractor.log import get_logger | ||||||
|  | from tractor._state import ( | ||||||
|  |     current_actor, | ||||||
|  |     is_root_process, | ||||||
|  | ) | ||||||
|  | from ._repl import ( | ||||||
|  |     PdbREPL, | ||||||
|  | ) | ||||||
|  | from ._tty_lock import ( | ||||||
|  |     any_connected_locker_child, | ||||||
|  |     DebugStatus, | ||||||
|  |     Lock, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from tractor.ipc import ( | ||||||
|  |         Channel, | ||||||
|  |     ) | ||||||
|  |     from tractor._runtime import ( | ||||||
|  |         Actor, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | _ctlc_ignore_header: str = ( | ||||||
|  |     'Ignoring SIGINT while debug REPL in use' | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def sigint_shield( | ||||||
|  |     signum: int, | ||||||
|  |     frame: 'frame',  # type: ignore # noqa | ||||||
|  |     *args, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Specialized, debugger-aware SIGINT handler. | ||||||
|  | 
 | ||||||
|  |     In childred we always ignore/shield for SIGINT to avoid | ||||||
|  |     deadlocks since cancellation should always be managed by the | ||||||
|  |     supervising parent actor. The root actor-proces is always | ||||||
|  |     cancelled on ctrl-c. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     __tracebackhide__: bool = True | ||||||
|  |     actor: Actor = current_actor() | ||||||
|  | 
 | ||||||
|  |     def do_cancel(): | ||||||
|  |         # If we haven't tried to cancel the runtime then do that instead | ||||||
|  |         # of raising a KBI (which may non-gracefully destroy | ||||||
|  |         # a ``trio.run()``). | ||||||
|  |         if not actor._cancel_called: | ||||||
|  |             actor.cancel_soon() | ||||||
|  | 
 | ||||||
|  |         # If the runtime is already cancelled it likely means the user | ||||||
|  |         # hit ctrl-c again because teardown didn't fully take place in | ||||||
|  |         # which case we do the "hard" raising of a local KBI. | ||||||
|  |         else: | ||||||
|  |             raise KeyboardInterrupt | ||||||
|  | 
 | ||||||
|  |     # only set in the actor actually running the REPL | ||||||
|  |     repl: PdbREPL|None = DebugStatus.repl | ||||||
|  | 
 | ||||||
|  |     # TODO: maybe we should flatten out all these cases using | ||||||
|  |     # a match/case? | ||||||
|  |     # | ||||||
|  |     # root actor branch that reports whether or not a child | ||||||
|  |     # has locked debugger. | ||||||
|  |     if is_root_process(): | ||||||
|  |         # log.warning( | ||||||
|  |         log.devx( | ||||||
|  |             'Handling SIGINT in root actor\n' | ||||||
|  |             f'{Lock.repr()}' | ||||||
|  |             f'{DebugStatus.repr()}\n' | ||||||
|  |         ) | ||||||
|  |         # try to see if the supposed (sub)actor in debug still | ||||||
|  |         # has an active connection to *this* actor, and if not | ||||||
|  |         # it's likely they aren't using the TTY lock / debugger | ||||||
|  |         # and we should propagate SIGINT normally. | ||||||
|  |         any_connected: bool = any_connected_locker_child() | ||||||
|  | 
 | ||||||
|  |         problem = ( | ||||||
|  |             f'root {actor.uid} handling SIGINT\n' | ||||||
|  |             f'any_connected: {any_connected}\n\n' | ||||||
|  | 
 | ||||||
|  |             f'{Lock.repr()}\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         if ( | ||||||
|  |             (ctx := Lock.ctx_in_debug) | ||||||
|  |             and | ||||||
|  |             (uid_in_debug := ctx.chan.uid) # "someone" is (ostensibly) using debug `Lock` | ||||||
|  |         ): | ||||||
|  |             name_in_debug: str = uid_in_debug[0] | ||||||
|  |             assert not repl | ||||||
|  |             # if not repl:  # but it's NOT us, the root actor. | ||||||
|  |             # sanity: since no repl ref is set, we def shouldn't | ||||||
|  |             # be the lock owner! | ||||||
|  |             assert name_in_debug != 'root' | ||||||
|  | 
 | ||||||
|  |             # IDEAL CASE: child has REPL as expected | ||||||
|  |             if any_connected:  # there are subactors we can contact | ||||||
|  |                 # XXX: only if there is an existing connection to the | ||||||
|  |                 # (sub-)actor in debug do we ignore SIGINT in this | ||||||
|  |                 # parent! Otherwise we may hang waiting for an actor | ||||||
|  |                 # which has already terminated to unlock. | ||||||
|  |                 # | ||||||
|  |                 # NOTE: don't emit this with `.pdb()` level in | ||||||
|  |                 # root without a higher level. | ||||||
|  |                 log.runtime( | ||||||
|  |                     _ctlc_ignore_header | ||||||
|  |                     + | ||||||
|  |                     f' by child ' | ||||||
|  |                     f'{uid_in_debug}\n' | ||||||
|  |                 ) | ||||||
|  |                 problem = None | ||||||
|  | 
 | ||||||
|  |             else: | ||||||
|  |                 problem += ( | ||||||
|  |                     '\n' | ||||||
|  |                     f'A `pdb` REPL is SUPPOSEDLY in use by child {uid_in_debug}\n' | ||||||
|  |                     f'BUT, no child actors are IPC contactable!?!?\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         # IDEAL CASE: root has REPL as expected | ||||||
|  |         else: | ||||||
|  |             # root actor still has this SIGINT handler active without | ||||||
|  |             # an actor using the `Lock` (a bug state) ?? | ||||||
|  |             # => so immediately cancel any stale lock cs and revert | ||||||
|  |             # the handler! | ||||||
|  |             if not DebugStatus.repl: | ||||||
|  |                 # TODO: WHEN should we revert back to ``trio`` | ||||||
|  |                 # handler if this one is stale? | ||||||
|  |                 # -[ ] maybe after a counts work of ctl-c mashes? | ||||||
|  |                 # -[ ] use a state var like `stale_handler: bool`? | ||||||
|  |                 problem += ( | ||||||
|  |                     'No subactor is using a `pdb` REPL according `Lock.ctx_in_debug`?\n' | ||||||
|  |                     'BUT, the root should be using it, WHY this handler ??\n\n' | ||||||
|  |                     'So either..\n' | ||||||
|  |                     '- some root-thread is using it but has no `.repl` set?, OR\n' | ||||||
|  |                     '- something else weird is going on outside the runtime!?\n' | ||||||
|  |                 ) | ||||||
|  |             else: | ||||||
|  |                 # NOTE: since we emit this msg on ctl-c, we should | ||||||
|  |                 # also always re-print the prompt the tail block! | ||||||
|  |                 log.pdb( | ||||||
|  |                     _ctlc_ignore_header | ||||||
|  |                     + | ||||||
|  |                     f' by root actor..\n' | ||||||
|  |                     f'{DebugStatus.repl_task}\n' | ||||||
|  |                     f' |_{repl}\n' | ||||||
|  |                 ) | ||||||
|  |                 problem = None | ||||||
|  | 
 | ||||||
|  |         # XXX if one is set it means we ARE NOT operating an ideal | ||||||
|  |         # case where a child subactor or us (the root) has the | ||||||
|  |         # lock without any other detected problems. | ||||||
|  |         if problem: | ||||||
|  | 
 | ||||||
|  |             # detect, report and maybe clear a stale lock request | ||||||
|  |             # cancel scope. | ||||||
|  |             lock_cs: trio.CancelScope = Lock.get_locking_task_cs() | ||||||
|  |             maybe_stale_lock_cs: bool = ( | ||||||
|  |                 lock_cs is not None | ||||||
|  |                 and not lock_cs.cancel_called | ||||||
|  |             ) | ||||||
|  |             if maybe_stale_lock_cs: | ||||||
|  |                 problem += ( | ||||||
|  |                     '\n' | ||||||
|  |                     'Stale `Lock.ctx_in_debug._scope: CancelScope` detected?\n' | ||||||
|  |                     f'{Lock.ctx_in_debug}\n\n' | ||||||
|  | 
 | ||||||
|  |                     '-> Calling ctx._scope.cancel()!\n' | ||||||
|  |                 ) | ||||||
|  |                 lock_cs.cancel() | ||||||
|  | 
 | ||||||
|  |             # TODO: wen do we actually want/need this, see above. | ||||||
|  |             # DebugStatus.unshield_sigint() | ||||||
|  |             log.warning(problem) | ||||||
|  | 
 | ||||||
|  |     # child actor that has locked the debugger | ||||||
|  |     elif not is_root_process(): | ||||||
|  |         log.debug( | ||||||
|  |             f'Subactor {actor.uid} handling SIGINT\n\n' | ||||||
|  |             f'{Lock.repr()}\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         rent_chan: Channel = actor._parent_chan | ||||||
|  |         if ( | ||||||
|  |             rent_chan is None | ||||||
|  |             or | ||||||
|  |             not rent_chan.connected() | ||||||
|  |         ): | ||||||
|  |             log.warning( | ||||||
|  |                 'This sub-actor thinks it is debugging ' | ||||||
|  |                 'but it has no connection to its parent ??\n' | ||||||
|  |                 f'{actor.uid}\n' | ||||||
|  |                 'Allowing SIGINT propagation..' | ||||||
|  |             ) | ||||||
|  |             DebugStatus.unshield_sigint() | ||||||
|  | 
 | ||||||
|  |         repl_task: str|None = DebugStatus.repl_task | ||||||
|  |         req_task: str|None = DebugStatus.req_task | ||||||
|  |         if ( | ||||||
|  |             repl_task | ||||||
|  |             and | ||||||
|  |             repl | ||||||
|  |         ): | ||||||
|  |             log.pdb( | ||||||
|  |                 _ctlc_ignore_header | ||||||
|  |                 + | ||||||
|  |                 f' by local task\n\n' | ||||||
|  |                 f'{repl_task}\n' | ||||||
|  |                 f' |_{repl}\n' | ||||||
|  |             ) | ||||||
|  |         elif req_task: | ||||||
|  |             log.debug( | ||||||
|  |                 _ctlc_ignore_header | ||||||
|  |                 + | ||||||
|  |                 f' by local request-task and either,\n' | ||||||
|  |                 f'- someone else is already REPL-in and has the `Lock`, or\n' | ||||||
|  |                 f'- some other local task already is replin?\n\n' | ||||||
|  |                 f'{req_task}\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         # TODO can we remove this now? | ||||||
|  |         # -[ ] does this path ever get hit any more? | ||||||
|  |         else: | ||||||
|  |             msg: str = ( | ||||||
|  |                 'SIGINT shield handler still active BUT, \n\n' | ||||||
|  |             ) | ||||||
|  |             if repl_task is None: | ||||||
|  |                 msg += ( | ||||||
|  |                     '- No local task claims to be in debug?\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             if repl is None: | ||||||
|  |                 msg += ( | ||||||
|  |                     '- No local REPL is currently active?\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             if req_task is None: | ||||||
|  |                 msg += ( | ||||||
|  |                     '- No debug request task is active?\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             log.warning( | ||||||
|  |                 msg | ||||||
|  |                 + | ||||||
|  |                 'Reverting handler to `trio` default!\n' | ||||||
|  |             ) | ||||||
|  |             DebugStatus.unshield_sigint() | ||||||
|  | 
 | ||||||
|  |             # XXX ensure that the reverted-to-handler actually is | ||||||
|  |             # able to rx what should have been **this** KBI ;) | ||||||
|  |             do_cancel() | ||||||
|  | 
 | ||||||
|  |         # TODO: how to handle the case of an intermediary-child actor | ||||||
|  |         # that **is not** marked in debug mode? See oustanding issue: | ||||||
|  |         # https://github.com/goodboy/tractor/issues/320 | ||||||
|  |         # elif debug_mode(): | ||||||
|  | 
 | ||||||
|  |     # maybe redraw/print last REPL output to console since | ||||||
|  |     # we want to alert the user that more input is expect since | ||||||
|  |     # nothing has been done dur to ignoring sigint. | ||||||
|  |     if ( | ||||||
|  |         DebugStatus.repl  # only when current actor has a REPL engaged | ||||||
|  |     ): | ||||||
|  |         flush_status: str = ( | ||||||
|  |             'Flushing stdout to ensure new prompt line!\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # XXX: yah, mega hack, but how else do we catch this madness XD | ||||||
|  |         if ( | ||||||
|  |             repl.shname == 'xonsh' | ||||||
|  |         ): | ||||||
|  |             flush_status += ( | ||||||
|  |                 '-> ALSO re-flushing due to `xonsh`..\n' | ||||||
|  |             ) | ||||||
|  |             repl.stdout.write(repl.prompt) | ||||||
|  | 
 | ||||||
|  |         # log.warning( | ||||||
|  |         log.devx( | ||||||
|  |             flush_status | ||||||
|  |         ) | ||||||
|  |         repl.stdout.flush() | ||||||
|  | 
 | ||||||
|  |         # TODO: better console UX to match the current "mode": | ||||||
|  |         # -[ ] for example if in sticky mode where if there is output | ||||||
|  |         #   detected as written to the tty we redraw this part underneath | ||||||
|  |         #   and erase the past draw of this same bit above? | ||||||
|  |         # repl.sticky = True | ||||||
|  |         # repl._print_if_sticky() | ||||||
|  | 
 | ||||||
|  |         # also see these links for an approach from `ptk`: | ||||||
|  |         # https://github.com/goodboy/tractor/issues/130#issuecomment-663752040 | ||||||
|  |         # https://github.com/prompt-toolkit/python-prompt-toolkit/blob/c2c6af8a0308f9e5d7c0e28cb8a02963fe0ce07a/prompt_toolkit/patch_stdout.py | ||||||
|  |     else: | ||||||
|  |         log.devx( | ||||||
|  |         # log.warning( | ||||||
|  |             'Not flushing stdout since not needed?\n' | ||||||
|  |             f'|_{repl}\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # XXX only for tracing this handler | ||||||
|  |     log.devx('exiting SIGINT') | ||||||
|  | @ -0,0 +1,220 @@ | ||||||
|  | # 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/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Debugger synchronization APIs to ensure orderly access and | ||||||
|  | non-TTY-clobbering graceful teardown. | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  | ) | ||||||
|  | from functools import ( | ||||||
|  |     partial, | ||||||
|  | ) | ||||||
|  | from typing import ( | ||||||
|  |     AsyncGenerator, | ||||||
|  |     Callable, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from tractor.log import get_logger | ||||||
|  | import trio | ||||||
|  | from trio.lowlevel import ( | ||||||
|  |     current_task, | ||||||
|  |     Task, | ||||||
|  | ) | ||||||
|  | from tractor._context import Context | ||||||
|  | from tractor._state import ( | ||||||
|  |     current_actor, | ||||||
|  |     debug_mode, | ||||||
|  |     is_root_process, | ||||||
|  | ) | ||||||
|  | from ._repl import ( | ||||||
|  |     TractorConfig as TractorConfig, | ||||||
|  | ) | ||||||
|  | from ._tty_lock import ( | ||||||
|  |     Lock, | ||||||
|  |     request_root_stdio_lock, | ||||||
|  |     any_connected_locker_child, | ||||||
|  | ) | ||||||
|  | from ._sigint import ( | ||||||
|  |     sigint_shield as sigint_shield, | ||||||
|  |     _ctlc_ignore_header as _ctlc_ignore_header | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | log = get_logger(__package__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def maybe_wait_for_debugger( | ||||||
|  |     poll_steps: int = 2, | ||||||
|  |     poll_delay: float = 0.1, | ||||||
|  |     child_in_debug: bool = False, | ||||||
|  | 
 | ||||||
|  |     header_msg: str = '', | ||||||
|  |     _ll: str = 'devx', | ||||||
|  | 
 | ||||||
|  | ) -> bool:  # was locked and we polled? | ||||||
|  | 
 | ||||||
|  |     if ( | ||||||
|  |         not debug_mode() | ||||||
|  |         and | ||||||
|  |         not child_in_debug | ||||||
|  |     ): | ||||||
|  |         return False | ||||||
|  | 
 | ||||||
|  |     logmeth: Callable = getattr(log, _ll) | ||||||
|  | 
 | ||||||
|  |     msg: str = header_msg | ||||||
|  |     if ( | ||||||
|  |         is_root_process() | ||||||
|  |     ): | ||||||
|  |         # If we error in the root but the debugger is | ||||||
|  |         # engaged we don't want to prematurely kill (and | ||||||
|  |         # thus clobber access to) the local tty since it | ||||||
|  |         # will make the pdb repl unusable. | ||||||
|  |         # Instead try to wait for pdb to be released before | ||||||
|  |         # tearing down. | ||||||
|  |         ctx_in_debug: Context|None = Lock.ctx_in_debug | ||||||
|  |         in_debug: tuple[str, str]|None = ( | ||||||
|  |             ctx_in_debug.chan.uid | ||||||
|  |             if ctx_in_debug | ||||||
|  |             else None | ||||||
|  |         ) | ||||||
|  |         if in_debug == current_actor().uid: | ||||||
|  |             log.debug( | ||||||
|  |                 msg | ||||||
|  |                 + | ||||||
|  |                 'Root already owns the TTY LOCK' | ||||||
|  |             ) | ||||||
|  |             return True | ||||||
|  | 
 | ||||||
|  |         elif in_debug: | ||||||
|  |             msg += ( | ||||||
|  |                 f'Debug `Lock` in use by subactor\n|\n|_{in_debug}\n' | ||||||
|  |             ) | ||||||
|  |             # TODO: could this make things more deterministic? | ||||||
|  |             # wait to see if a sub-actor task will be | ||||||
|  |             # scheduled and grab the tty lock on the next | ||||||
|  |             # tick? | ||||||
|  |             # XXX => but it doesn't seem to work.. | ||||||
|  |             # await trio.testing.wait_all_tasks_blocked(cushion=0) | ||||||
|  |         else: | ||||||
|  |             logmeth( | ||||||
|  |                 msg | ||||||
|  |                 + | ||||||
|  |                 'Root immediately acquired debug TTY LOCK' | ||||||
|  |             ) | ||||||
|  |             return False | ||||||
|  | 
 | ||||||
|  |         for istep in range(poll_steps): | ||||||
|  |             if ( | ||||||
|  |                 Lock.req_handler_finished is not None | ||||||
|  |                 and not Lock.req_handler_finished.is_set() | ||||||
|  |                 and in_debug is not None | ||||||
|  |             ): | ||||||
|  |                 # caller_frame_info: str = pformat_caller_frame() | ||||||
|  |                 logmeth( | ||||||
|  |                     msg | ||||||
|  |                     + | ||||||
|  |                     '\n^^ Root is waiting on tty lock release.. ^^\n' | ||||||
|  |                     # f'{caller_frame_info}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |                 if not any_connected_locker_child(): | ||||||
|  |                     Lock.get_locking_task_cs().cancel() | ||||||
|  | 
 | ||||||
|  |                 with trio.CancelScope(shield=True): | ||||||
|  |                     await Lock.req_handler_finished.wait() | ||||||
|  | 
 | ||||||
|  |                 log.devx( | ||||||
|  |                     f'Subactor released debug lock\n' | ||||||
|  |                     f'|_{in_debug}\n' | ||||||
|  |                 ) | ||||||
|  |                 break | ||||||
|  | 
 | ||||||
|  |             # is no subactor locking debugger currently? | ||||||
|  |             if ( | ||||||
|  |                 in_debug is None | ||||||
|  |                 and ( | ||||||
|  |                     Lock.req_handler_finished is None | ||||||
|  |                     or Lock.req_handler_finished.is_set() | ||||||
|  |                 ) | ||||||
|  |             ): | ||||||
|  |                 logmeth( | ||||||
|  |                     msg | ||||||
|  |                     + | ||||||
|  |                     'Root acquired tty lock!' | ||||||
|  |                 ) | ||||||
|  |                 break | ||||||
|  | 
 | ||||||
|  |             else: | ||||||
|  |                 logmeth( | ||||||
|  |                     'Root polling for debug:\n' | ||||||
|  |                     f'poll step: {istep}\n' | ||||||
|  |                     f'poll delya: {poll_delay}\n\n' | ||||||
|  |                     f'{Lock.repr()}\n' | ||||||
|  |                 ) | ||||||
|  |                 with trio.CancelScope(shield=True): | ||||||
|  |                     await trio.sleep(poll_delay) | ||||||
|  |                     continue | ||||||
|  | 
 | ||||||
|  |         return True | ||||||
|  | 
 | ||||||
|  |     # else: | ||||||
|  |     #     # TODO: non-root call for #320? | ||||||
|  |     #     this_uid: tuple[str, str] = current_actor().uid | ||||||
|  |     #     async with acquire_debug_lock( | ||||||
|  |     #         subactor_uid=this_uid, | ||||||
|  |     #     ): | ||||||
|  |     #         pass | ||||||
|  |     return False | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @acm | ||||||
|  | async def acquire_debug_lock( | ||||||
|  |     subactor_uid: tuple[str, str], | ||||||
|  | ) -> AsyncGenerator[ | ||||||
|  |     trio.CancelScope|None, | ||||||
|  |     tuple, | ||||||
|  | ]: | ||||||
|  |     ''' | ||||||
|  |     Request to acquire the TTY `Lock` in the root actor, release on | ||||||
|  |     exit. | ||||||
|  | 
 | ||||||
|  |     This helper is for actor's who don't actually need to acquired | ||||||
|  |     the debugger but want to wait until the lock is free in the | ||||||
|  |     process-tree root such that they don't clobber an ongoing pdb | ||||||
|  |     REPL session in some peer or child! | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     if not debug_mode(): | ||||||
|  |         yield None | ||||||
|  |         return | ||||||
|  | 
 | ||||||
|  |     task: Task = current_task() | ||||||
|  |     async with trio.open_nursery() as n: | ||||||
|  |         ctx: Context = await n.start( | ||||||
|  |             partial( | ||||||
|  |                 request_root_stdio_lock, | ||||||
|  |                 actor_uid=subactor_uid, | ||||||
|  |                 task_uid=(task.name, id(task)), | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         yield ctx | ||||||
|  |         ctx.cancel() | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -15,10 +15,13 @@ | ||||||
| # along with this program.  If not, see <https://www.gnu.org/licenses/>. | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| Pretty formatters for use throughout the code base. | Pretty formatters for use throughout our internals. | ||||||
| Mostly handy for logging and exception message content. | 
 | ||||||
|  | Handy for logging and exception message content but also for `repr()` | ||||||
|  | in REPL(s). | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
|  | import sys | ||||||
| import textwrap | import textwrap | ||||||
| import traceback | import traceback | ||||||
| 
 | 
 | ||||||
|  | @ -115,6 +118,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, | ||||||
|  | @ -144,8 +226,8 @@ def pformat_cs( | ||||||
|     field_prefix: str = ' |_', |     field_prefix: str = ' |_', | ||||||
| ) -> str: | ) -> str: | ||||||
|     ''' |     ''' | ||||||
|     Pretty format info about a `trio.CancelScope` including most |     Pretty format info about a `trio.CancelScope` including most of | ||||||
|     of its public state and `._cancel_status`. |     its public state and `._cancel_status`. | ||||||
| 
 | 
 | ||||||
|     The output can be modified to show a "var name" for the |     The output can be modified to show a "var name" for the | ||||||
|     instance as a field prefix, just a simple str before each |     instance as a field prefix, just a simple str before each | ||||||
|  | @ -167,3 +249,279 @@ def pformat_cs( | ||||||
|         + |         + | ||||||
|         fields |         fields | ||||||
|     ) |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def nest_from_op( | ||||||
|  |     input_op: str,  # TODO, Literal of all op-"symbols" from below? | ||||||
|  |     text: str, | ||||||
|  |     prefix_op: bool = True,  # unset is to suffix the first line | ||||||
|  |     # optionally suffix `text`, by def on a newline | ||||||
|  |     op_suffix='\n', | ||||||
|  | 
 | ||||||
|  |     nest_prefix: str = '|_', | ||||||
|  |     nest_indent: int|None = None, | ||||||
|  |     # XXX indent `next_prefix` "to-the-right-of" `input_op` | ||||||
|  |     # by this count of whitespaces (' '). | ||||||
|  |     rm_from_first_ln: str|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> 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. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # `sclang` "structurred-concurrency-language": an ascii-encoded | ||||||
|  |     # symbolic alphabet to describe concurrent systems. | ||||||
|  |     # | ||||||
|  |     # ?TODO? aa more fomal 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 | ||||||
|  |     # | ||||||
|  |     if ( | ||||||
|  |         nest_prefix | ||||||
|  |         and | ||||||
|  |         nest_indent != 0 | ||||||
|  |     ): | ||||||
|  |         if nest_indent is not None: | ||||||
|  |             nest_prefix: str = textwrap.indent( | ||||||
|  |                 nest_prefix, | ||||||
|  |                 prefix=nest_indent*' ', | ||||||
|  |             ) | ||||||
|  |         nest_indent: int = len(nest_prefix) | ||||||
|  | 
 | ||||||
|  |     # determine body-text indent either by, | ||||||
|  |     # - using wtv explicit indent value is provided, | ||||||
|  |     # OR | ||||||
|  |     # - auto-calcing the indent to embed `text` under | ||||||
|  |     #   the `nest_prefix` if provided, **IFF** `nest_indent=None`. | ||||||
|  |     tree_str_indent: int = 0 | ||||||
|  |     if nest_indent not in {0, None}: | ||||||
|  |         tree_str_indent = nest_indent | ||||||
|  |     elif ( | ||||||
|  |         nest_prefix | ||||||
|  |         and | ||||||
|  |         nest_indent != 0 | ||||||
|  |     ): | ||||||
|  |         tree_str_indent = len(nest_prefix) | ||||||
|  | 
 | ||||||
|  |     indented_tree_str: str = text | ||||||
|  |     if tree_str_indent: | ||||||
|  |         indented_tree_str: str = textwrap.indent( | ||||||
|  |             text, | ||||||
|  |             prefix=' '*tree_str_indent, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # inject any provided nesting-prefix chars | ||||||
|  |     # into the head of the first line. | ||||||
|  |     if nest_prefix: | ||||||
|  |         indented_tree_str: str = ( | ||||||
|  |             f'{nest_prefix}{indented_tree_str[tree_str_indent:]}' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     if ( | ||||||
|  |         not prefix_op | ||||||
|  |         or | ||||||
|  |         rm_from_first_ln | ||||||
|  |     ): | ||||||
|  |         tree_lns: list[str] = indented_tree_str.splitlines() | ||||||
|  |         first: str = tree_lns[0] | ||||||
|  |         if rm_from_first_ln: | ||||||
|  |             first = first.strip().replace( | ||||||
|  |                 rm_from_first_ln, | ||||||
|  |                 '', | ||||||
|  |             ) | ||||||
|  |         indented_tree_str: str = '\n'.join(tree_lns[1:]) | ||||||
|  | 
 | ||||||
|  |         if prefix_op: | ||||||
|  |             indented_tree_str = ( | ||||||
|  |                 f'{first}\n' | ||||||
|  |                 f'{indented_tree_str}' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     if prefix_op: | ||||||
|  |         return ( | ||||||
|  |             f'{input_op}{op_suffix}' | ||||||
|  |             f'{indented_tree_str}' | ||||||
|  |         ) | ||||||
|  |     else: | ||||||
|  |         return ( | ||||||
|  |             f'{first}{input_op}{op_suffix}' | ||||||
|  |             f'{indented_tree_str}' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # ------ modden.repr ------ | ||||||
|  | # XXX originally taken verbaatim from `modden.repr` | ||||||
|  | ''' | ||||||
|  | More "multi-line" representation then the stdlib's `pprint` equivs. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from inspect import ( | ||||||
|  |     FrameInfo, | ||||||
|  |     stack, | ||||||
|  | ) | ||||||
|  | import pprint | ||||||
|  | import reprlib | ||||||
|  | from typing import ( | ||||||
|  |     Callable, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def mk_repr( | ||||||
|  |     **repr_kws, | ||||||
|  | ) -> Callable[[str], str]: | ||||||
|  |     ''' | ||||||
|  |     Allocate and deliver a `repr.Repr` instance with provided input | ||||||
|  |     settings using the std-lib's `reprlib` mod, | ||||||
|  |      * https://docs.python.org/3/library/reprlib.html | ||||||
|  | 
 | ||||||
|  |     ------ Ex. ------ | ||||||
|  |     An up to 6-layer-nested `dict` as multi-line: | ||||||
|  |     - https://stackoverflow.com/a/79102479 | ||||||
|  |     - https://docs.python.org/3/library/reprlib.html#reprlib.Repr.maxlevel | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     def_kws: dict[str, int] = dict( | ||||||
|  |         indent=3,  # indent used for repr of recursive objects | ||||||
|  |         maxlevel=616,  # recursion levels | ||||||
|  |         maxdict=616,  # max items shown for `dict` | ||||||
|  |         maxlist=616,  # max items shown for `dict` | ||||||
|  |         maxstring=616,  # match editor line-len limit | ||||||
|  |         maxtuple=616,  # match editor line-len limit | ||||||
|  |         maxother=616,  # match editor line-len limit | ||||||
|  |     ) | ||||||
|  |     def_kws |= repr_kws | ||||||
|  |     reprr = reprlib.Repr(**def_kws) | ||||||
|  |     return reprr.repr | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def ppfmt( | ||||||
|  |     obj: object, | ||||||
|  |     do_print: bool = False, | ||||||
|  | ) -> str: | ||||||
|  |     ''' | ||||||
|  |     The `pprint.pformat()` version of `pprint.pp()`, namely | ||||||
|  |     a default `sort_dicts=False`.. (which i think should be | ||||||
|  |     the normal default in the stdlib). | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     pprepr: Callable = mk_repr() | ||||||
|  |     repr_str: str = pprepr(obj) | ||||||
|  | 
 | ||||||
|  |     if do_print: | ||||||
|  |         return pprint.pp(repr_str) | ||||||
|  | 
 | ||||||
|  |     return repr_str | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | pformat = ppfmt | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def pfmt_frame_info(fi: FrameInfo) -> str: | ||||||
|  |     ''' | ||||||
|  |     Like a std `inspect.FrameInfo.__repr__()` but multi-line.. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return ( | ||||||
|  |         'FrameInfo(\n' | ||||||
|  |         '  frame={!r},\n' | ||||||
|  |         '  filename={!r},\n' | ||||||
|  |         '  lineno={!r},\n' | ||||||
|  |         '  function={!r},\n' | ||||||
|  |         '  code_context={!r},\n' | ||||||
|  |         '  index={!r},\n' | ||||||
|  |         '  positions={!r})' | ||||||
|  |         ).format( | ||||||
|  |             fi.frame, | ||||||
|  |             fi.filename, | ||||||
|  |             fi.lineno, | ||||||
|  |             fi.function, | ||||||
|  |             fi.code_context, | ||||||
|  |             fi.index, | ||||||
|  |             fi.positions | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def pfmt_callstack(frames: int = 1) -> str: | ||||||
|  |     ''' | ||||||
|  |     Generate a string of nested `inspect.FrameInfo` objects returned | ||||||
|  |     from a `inspect.stack()` call such that only the `.frame` field | ||||||
|  |     for each  layer is pprinted. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     caller_frames: list[FrameInfo] =  stack()[1:1+frames] | ||||||
|  |     frames_str: str = '' | ||||||
|  |     for i, frame_info in enumerate(caller_frames): | ||||||
|  |         frames_str += textwrap.indent( | ||||||
|  |             f'{frame_info.frame!r}\n', | ||||||
|  |             prefix=' '*i, | ||||||
|  | 
 | ||||||
|  |         ) | ||||||
|  |     return frames_str | ||||||
|  |  | ||||||
|  | @ -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,503 @@ | ||||||
|  | # 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 closed(self) -> bool: | ||||||
|  |         ''' | ||||||
|  |         Was `.aclose()` successfully called? | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         return self._closed | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def cancel_called(self) -> bool: | ||||||
|  |         ''' | ||||||
|  |         Set when `Portal.cancel_actor()` is called on a portal which | ||||||
|  |         wraps this IPC channel. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         return self._cancel_called | ||||||
|  | 
 | ||||||
|  |     @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, | ||||||
|  |         ) | ||||||
|  |         # XXX, for UDS *no!* since we recv the peer-pid and build out | ||||||
|  |         # a new addr.. | ||||||
|  |         # assert transport.raddr == addr | ||||||
|  |         chan = Channel(transport=transport) | ||||||
|  | 
 | ||||||
|  |         # ?TODO, compact this into adapter level-methods? | ||||||
|  |         # -[ ] would avoid extra repr-calcs if level not active? | ||||||
|  |         #   |_ how would the `calc_if_level` look though? func? | ||||||
|  |         if log.at_least_level('runtime'): | ||||||
|  |             from tractor.devx import ( | ||||||
|  |                 pformat as _pformat, | ||||||
|  |             ) | ||||||
|  |             chan_repr: str = _pformat.nest_from_op( | ||||||
|  |                 input_op='[>', | ||||||
|  |                 text=chan.pformat(), | ||||||
|  |                 nest_indent=1, | ||||||
|  |             ) | ||||||
|  |             log.runtime( | ||||||
|  |                 f'Connected channel IPC transport\n' | ||||||
|  |                 f'{chan_repr}' | ||||||
|  |             ) | ||||||
|  |         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, | ||||||
|  |         privates: bool = False, | ||||||
|  |     ) -> 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' | ||||||
|  |         ) | ||||||
|  |         repr_str: str = ( | ||||||
|  |             f'<Channel(\n' | ||||||
|  |             f' |_status: {tpt_status!r}\n' | ||||||
|  |         ) + ( | ||||||
|  |             f'   _closed={self._closed}\n' | ||||||
|  |             f'   _cancel_called={self._cancel_called}\n' | ||||||
|  |             if privates else '' | ||||||
|  |         ) + (  # peer-actor (processs) section | ||||||
|  |             f' |_peer: {self.aid.reprol()!r}\n' | ||||||
|  |             if self.aid else ' |_peer: <unknown>\n' | ||||||
|  |         ) + ( | ||||||
|  |             f' |_msgstream: {tpt_name}\n' | ||||||
|  |             f'   maddr: {tpt.maddr!r}\n' | ||||||
|  |             f'   proto: {tpt.laddr.proto_key!r}\n' | ||||||
|  |             f'   layer: {tpt.layer_key!r}\n' | ||||||
|  |             f'   codec: {tpt.codec_key!r}\n' | ||||||
|  |             f'   .laddr={tpt.laddr}\n' | ||||||
|  |             f'   .raddr={tpt.raddr}\n' | ||||||
|  |         ) + ( | ||||||
|  |             f'   ._transport.stream={tpt.stream}\n' | ||||||
|  |             f'   ._transport.drained={tpt.drained}\n' | ||||||
|  |             if privates else '' | ||||||
|  |         ) + ( | ||||||
|  |             f'   _send_lock={tpt._send_lock.statistics()}\n' | ||||||
|  |             if privates else '' | ||||||
|  |         ) + ( | ||||||
|  |             ')>\n' | ||||||
|  |         ) | ||||||
|  |         return repr_str | ||||||
|  | 
 | ||||||
|  |     # 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 | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def maddr(self) -> str: | ||||||
|  |         return self._transport.maddr if self._transport else '<no-tpt>' | ||||||
|  | 
 | ||||||
|  |     # 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, | ||||||
|  |             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\n' | ||||||
|  |             f'<= {peer_aid.reprol(sin_uuid=False)}\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,75 @@ | ||||||
|  | # 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 | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import platform | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def disable_mantracker(): | ||||||
|  |     ''' | ||||||
|  |     Disable all `multiprocessing` "resource tracking" machinery since | ||||||
|  |     it's an absolute multi-threaded mess of non-SC madness. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     from multiprocessing.shared_memory import SharedMemory | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     # 3.13+ only.. can pass `track=False` to disable | ||||||
|  |     # all the resource tracker bs. | ||||||
|  |     # https://docs.python.org/3/library/multiprocessing.shared_memory.html | ||||||
|  |     if (_py_313 := ( | ||||||
|  |             platform.python_version_tuple()[:-1] | ||||||
|  |             >= | ||||||
|  |             ('3', '13') | ||||||
|  |         ) | ||||||
|  |     ): | ||||||
|  |         from functools import partial | ||||||
|  |         return partial( | ||||||
|  |             SharedMemory, | ||||||
|  |             track=False, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # !TODO, once we drop 3.12- we can obvi remove all this! | ||||||
|  |     else: | ||||||
|  |         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 | ||||||
|  | 
 | ||||||
|  |         # use std type verbatim | ||||||
|  |         shmT = SharedMemory | ||||||
|  | 
 | ||||||
|  |     return shmT | ||||||
|  | @ -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
											
										
									
								
							|  | @ -0,0 +1,825 @@ | ||||||
|  | # 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/>. | ||||||
|  | 
 | ||||||
|  | """ | ||||||
|  | SC friendly shared memory management geared at real-time | ||||||
|  | processing. | ||||||
|  | 
 | ||||||
|  | Support for ``numpy`` compatible array-buffers is provided but is | ||||||
|  | considered optional within the context of this runtime-library. | ||||||
|  | 
 | ||||||
|  | """ | ||||||
|  | from __future__ import annotations | ||||||
|  | from multiprocessing import shared_memory as shm | ||||||
|  | from multiprocessing.shared_memory import ( | ||||||
|  |     # SharedMemory, | ||||||
|  |     ShareableList, | ||||||
|  | ) | ||||||
|  | import platform | ||||||
|  | from sys import byteorder | ||||||
|  | import time | ||||||
|  | from typing import Optional | ||||||
|  | 
 | ||||||
|  | from msgspec import ( | ||||||
|  |     Struct, | ||||||
|  |     to_builtins | ||||||
|  | ) | ||||||
|  | import tractor | ||||||
|  | 
 | ||||||
|  | from tractor.ipc._mp_bs import disable_mantracker | ||||||
|  | from tractor.log import get_logger | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _USE_POSIX = getattr(shm, '_USE_POSIX', False) | ||||||
|  | if _USE_POSIX: | ||||||
|  |     from _posixshmem import shm_unlink | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | try: | ||||||
|  |     import numpy as np | ||||||
|  |     from numpy.lib import recfunctions as rfn | ||||||
|  |     # 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: | ||||||
|  |     pass | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | SharedMemory = disable_mantracker() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class SharedInt: | ||||||
|  |     ''' | ||||||
|  |     Wrapper around a single entry shared memory array which | ||||||
|  |     holds an ``int`` value used as an index counter. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         shm: SharedMemory, | ||||||
|  |     ) -> None: | ||||||
|  |         self._shm = shm | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def value(self) -> int: | ||||||
|  |         return int.from_bytes(self._shm.buf, byteorder) | ||||||
|  | 
 | ||||||
|  |     @value.setter | ||||||
|  |     def value(self, value) -> None: | ||||||
|  |         self._shm.buf[:] = value.to_bytes(self._shm.size, byteorder) | ||||||
|  | 
 | ||||||
|  |     def destroy(self) -> None: | ||||||
|  |         if _USE_POSIX: | ||||||
|  |             # We manually unlink to bypass all the "resource tracker" | ||||||
|  |             # nonsense meant for non-SC systems. | ||||||
|  |             name = self._shm.name | ||||||
|  |             try: | ||||||
|  |                 shm_unlink(name) | ||||||
|  |             except FileNotFoundError: | ||||||
|  |                 # might be a teardown race here? | ||||||
|  |                 log.warning(f'Shm for {name} already unlinked?') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class NDToken(Struct, frozen=True): | ||||||
|  |     ''' | ||||||
|  |     Internal represenation of a shared memory ``numpy`` array "token" | ||||||
|  |     which can be used to key and load a system (OS) wide shm entry | ||||||
|  |     and correctly read the array by type signature. | ||||||
|  | 
 | ||||||
|  |     This type is msg safe. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     shm_name: str  # this servers as a "key" value | ||||||
|  |     shm_first_index_name: str | ||||||
|  |     shm_last_index_name: str | ||||||
|  |     dtype_descr: tuple | ||||||
|  |     size: int  # in struct-array index / row terms | ||||||
|  | 
 | ||||||
|  |     # TODO: use nptyping here on dtypes | ||||||
|  |     @property | ||||||
|  |     def dtype(self) -> list[tuple[str, str, tuple[int, ...]]]: | ||||||
|  |         return np.dtype( | ||||||
|  |             list( | ||||||
|  |                 map(tuple, self.dtype_descr) | ||||||
|  |             ) | ||||||
|  |         ).descr | ||||||
|  | 
 | ||||||
|  |     def as_msg(self): | ||||||
|  |         return to_builtins(self) | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def from_msg(cls, msg: dict) -> NDToken: | ||||||
|  |         if isinstance(msg, NDToken): | ||||||
|  |             return msg | ||||||
|  | 
 | ||||||
|  |         # TODO: native struct decoding | ||||||
|  |         # return _token_dec.decode(msg) | ||||||
|  | 
 | ||||||
|  |         msg['dtype_descr'] = tuple(map(tuple, msg['dtype_descr'])) | ||||||
|  |         return NDToken(**msg) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # _token_dec = msgspec.msgpack.Decoder(NDToken) | ||||||
|  | 
 | ||||||
|  | # TODO: this api? | ||||||
|  | # _known_tokens = tractor.ActorVar('_shm_tokens', {}) | ||||||
|  | # _known_tokens = tractor.ContextStack('_known_tokens', ) | ||||||
|  | # _known_tokens = trio.RunVar('shms', {}) | ||||||
|  | 
 | ||||||
|  | # TODO: this should maybe be provided via | ||||||
|  | # a `.trionics.maybe_open_context()` wrapper factory? | ||||||
|  | # process-local store of keys to tokens | ||||||
|  | _known_tokens: dict[str, NDToken] = {} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_shm_token(key: str) -> NDToken | None: | ||||||
|  |     ''' | ||||||
|  |     Convenience func to check if a token | ||||||
|  |     for the provided key is known by this process. | ||||||
|  | 
 | ||||||
|  |     Returns either the ``numpy`` token or a string for a shared list. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return _known_tokens.get(key) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def _make_token( | ||||||
|  |     key: str, | ||||||
|  |     size: int, | ||||||
|  |     dtype: np.dtype, | ||||||
|  | 
 | ||||||
|  | ) -> NDToken: | ||||||
|  |     ''' | ||||||
|  |     Create a serializable token that can be used | ||||||
|  |     to access a shared array. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return NDToken( | ||||||
|  |         shm_name=key, | ||||||
|  |         shm_first_index_name=key + "_first", | ||||||
|  |         shm_last_index_name=key + "_last", | ||||||
|  |         dtype_descr=tuple(np.dtype(dtype).descr), | ||||||
|  |         size=size, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ShmArray: | ||||||
|  |     ''' | ||||||
|  |     A shared memory ``numpy.ndarray`` API. | ||||||
|  | 
 | ||||||
|  |     An underlying shared memory buffer is allocated based on | ||||||
|  |     a user specified ``numpy.ndarray``. This fixed size array | ||||||
|  |     can be read and written to by pushing data both onto the "front" | ||||||
|  |     or "back" of a set index range. The indexes for the "first" and | ||||||
|  |     "last" index are themselves stored in shared memory (accessed via | ||||||
|  |     ``SharedInt`` interfaces) values such that multiple processes can | ||||||
|  |     interact with the same array using a synchronized-index. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         shmarr: np.ndarray, | ||||||
|  |         first: SharedInt, | ||||||
|  |         last: SharedInt, | ||||||
|  |         shm: SharedMemory, | ||||||
|  |         # readonly: bool = True, | ||||||
|  |     ) -> None: | ||||||
|  |         self._array = shmarr | ||||||
|  | 
 | ||||||
|  |         # indexes for first and last indices corresponding | ||||||
|  |         # to fille data | ||||||
|  |         self._first = first | ||||||
|  |         self._last = last | ||||||
|  | 
 | ||||||
|  |         self._len = len(shmarr) | ||||||
|  |         self._shm = shm | ||||||
|  |         self._post_init: bool = False | ||||||
|  | 
 | ||||||
|  |         # pushing data does not write the index (aka primary key) | ||||||
|  |         self._write_fields: list[str] | None = None | ||||||
|  |         dtype = shmarr.dtype | ||||||
|  |         if dtype.fields: | ||||||
|  |             self._write_fields = list(shmarr.dtype.fields.keys())[1:] | ||||||
|  | 
 | ||||||
|  |     # TODO: ringbuf api? | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def _token(self) -> NDToken: | ||||||
|  |         return NDToken( | ||||||
|  |             shm_name=self._shm.name, | ||||||
|  |             shm_first_index_name=self._first._shm.name, | ||||||
|  |             shm_last_index_name=self._last._shm.name, | ||||||
|  |             dtype_descr=tuple(self._array.dtype.descr), | ||||||
|  |             size=self._len, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def token(self) -> dict: | ||||||
|  |         """Shared memory token that can be serialized and used by | ||||||
|  |         another process to attach to this array. | ||||||
|  |         """ | ||||||
|  |         return self._token.as_msg() | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def index(self) -> int: | ||||||
|  |         return self._last.value % self._len | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def array(self) -> np.ndarray: | ||||||
|  |         ''' | ||||||
|  |         Return an up-to-date ``np.ndarray`` view of the | ||||||
|  |         so-far-written data to the underlying shm buffer. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         a = self._array[self._first.value:self._last.value] | ||||||
|  | 
 | ||||||
|  |         # first, last = self._first.value, self._last.value | ||||||
|  |         # a = self._array[first:last] | ||||||
|  | 
 | ||||||
|  |         # TODO: eventually comment this once we've not seen it in the | ||||||
|  |         # wild in a long time.. | ||||||
|  |         # XXX: race where first/last indexes cause a reader | ||||||
|  |         # to load an empty array.. | ||||||
|  |         if len(a) == 0 and self._post_init: | ||||||
|  |             raise RuntimeError('Empty array race condition hit!?') | ||||||
|  |             # breakpoint() | ||||||
|  | 
 | ||||||
|  |         return a | ||||||
|  | 
 | ||||||
|  |     def ustruct( | ||||||
|  |         self, | ||||||
|  |         fields: Optional[list[str]] = None, | ||||||
|  | 
 | ||||||
|  |         # type that all field values will be cast to | ||||||
|  |         # in the returned view. | ||||||
|  |         common_dtype: np.dtype = float, | ||||||
|  | 
 | ||||||
|  |     ) -> np.ndarray: | ||||||
|  | 
 | ||||||
|  |         array = self._array | ||||||
|  | 
 | ||||||
|  |         if fields: | ||||||
|  |             selection = array[fields] | ||||||
|  |             # fcount = len(fields) | ||||||
|  |         else: | ||||||
|  |             selection = array | ||||||
|  |             # fcount = len(array.dtype.fields) | ||||||
|  | 
 | ||||||
|  |         # XXX: manual ``.view()`` attempt that also doesn't work. | ||||||
|  |         # uview = selection.view( | ||||||
|  |         #     dtype='<f16', | ||||||
|  |         # ).reshape(-1, 4, order='A') | ||||||
|  | 
 | ||||||
|  |         # assert len(selection) == len(uview) | ||||||
|  | 
 | ||||||
|  |         u = rfn.structured_to_unstructured( | ||||||
|  |             selection, | ||||||
|  |             # dtype=float, | ||||||
|  |             copy=True, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # unstruct = np.ndarray(u.shape, dtype=a.dtype, buffer=shm.buf) | ||||||
|  |         # array[:] = a[:] | ||||||
|  |         return u | ||||||
|  |         # return ShmArray( | ||||||
|  |         #     shmarr=u, | ||||||
|  |         #     first=self._first, | ||||||
|  |         #     last=self._last, | ||||||
|  |         #     shm=self._shm | ||||||
|  |         # ) | ||||||
|  | 
 | ||||||
|  |     def last( | ||||||
|  |         self, | ||||||
|  |         length: int = 1, | ||||||
|  | 
 | ||||||
|  |     ) -> np.ndarray: | ||||||
|  |         ''' | ||||||
|  |         Return the last ``length``'s worth of ("row") entries from the | ||||||
|  |         array. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         return self.array[-length:] | ||||||
|  | 
 | ||||||
|  |     def push( | ||||||
|  |         self, | ||||||
|  |         data: np.ndarray, | ||||||
|  | 
 | ||||||
|  |         field_map: Optional[dict[str, str]] = None, | ||||||
|  |         prepend: bool = False, | ||||||
|  |         update_first: bool = True, | ||||||
|  |         start: int | None = None, | ||||||
|  | 
 | ||||||
|  |     ) -> int: | ||||||
|  |         ''' | ||||||
|  |         Ring buffer like "push" to append data | ||||||
|  |         into the buffer and return updated "last" index. | ||||||
|  | 
 | ||||||
|  |         NB: no actual ring logic yet to give a "loop around" on overflow | ||||||
|  |         condition, lel. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         length = len(data) | ||||||
|  | 
 | ||||||
|  |         if prepend: | ||||||
|  |             index = (start or self._first.value) - length | ||||||
|  | 
 | ||||||
|  |             if index < 0: | ||||||
|  |                 raise ValueError( | ||||||
|  |                     f'Array size of {self._len} was overrun during prepend.\n' | ||||||
|  |                     f'You have passed {abs(index)} too many datums.' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             index = start if start is not None else self._last.value | ||||||
|  | 
 | ||||||
|  |         end = index + length | ||||||
|  | 
 | ||||||
|  |         if field_map: | ||||||
|  |             src_names, dst_names = zip(*field_map.items()) | ||||||
|  |         else: | ||||||
|  |             dst_names = src_names = self._write_fields | ||||||
|  | 
 | ||||||
|  |         try: | ||||||
|  |             self._array[ | ||||||
|  |                 list(dst_names) | ||||||
|  |             ][index:end] = data[list(src_names)][:] | ||||||
|  | 
 | ||||||
|  |             # NOTE: there was a race here between updating | ||||||
|  |             # the first and last indices and when the next reader | ||||||
|  |             # tries to access ``.array`` (which due to the index | ||||||
|  |             # overlap will be empty). Pretty sure we've fixed it now | ||||||
|  |             # but leaving this here as a reminder. | ||||||
|  |             if ( | ||||||
|  |                 prepend | ||||||
|  |                 and update_first | ||||||
|  |                 and length | ||||||
|  |             ): | ||||||
|  |                 assert index < self._first.value | ||||||
|  | 
 | ||||||
|  |             if ( | ||||||
|  |                 index < self._first.value | ||||||
|  |                 and update_first | ||||||
|  |             ): | ||||||
|  |                 assert prepend, 'prepend=True not passed but index decreased?' | ||||||
|  |                 self._first.value = index | ||||||
|  | 
 | ||||||
|  |             elif not prepend: | ||||||
|  |                 self._last.value = end | ||||||
|  | 
 | ||||||
|  |             self._post_init = True | ||||||
|  |             return end | ||||||
|  | 
 | ||||||
|  |         except ValueError as err: | ||||||
|  |             if field_map: | ||||||
|  |                 raise | ||||||
|  | 
 | ||||||
|  |             # should raise if diff detected | ||||||
|  |             self.diff_err_fields(data) | ||||||
|  |             raise err | ||||||
|  | 
 | ||||||
|  |     def diff_err_fields( | ||||||
|  |         self, | ||||||
|  |         data: np.ndarray, | ||||||
|  |     ) -> None: | ||||||
|  |         # reraise with any field discrepancy | ||||||
|  |         our_fields, their_fields = ( | ||||||
|  |             set(self._array.dtype.fields), | ||||||
|  |             set(data.dtype.fields), | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         only_in_ours = our_fields - their_fields | ||||||
|  |         only_in_theirs = their_fields - our_fields | ||||||
|  | 
 | ||||||
|  |         if only_in_ours: | ||||||
|  |             raise TypeError( | ||||||
|  |                 f"Input array is missing field(s): {only_in_ours}" | ||||||
|  |             ) | ||||||
|  |         elif only_in_theirs: | ||||||
|  |             raise TypeError( | ||||||
|  |                 f"Input array has unknown field(s): {only_in_theirs}" | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     # TODO: support "silent" prepends that don't update ._first.value? | ||||||
|  |     def prepend( | ||||||
|  |         self, | ||||||
|  |         data: np.ndarray, | ||||||
|  |     ) -> int: | ||||||
|  |         end = self.push(data, prepend=True) | ||||||
|  |         assert end | ||||||
|  | 
 | ||||||
|  |     def close(self) -> None: | ||||||
|  |         self._first._shm.close() | ||||||
|  |         self._last._shm.close() | ||||||
|  |         self._shm.close() | ||||||
|  | 
 | ||||||
|  |     def destroy(self) -> None: | ||||||
|  |         if _USE_POSIX: | ||||||
|  |             # We manually unlink to bypass all the "resource tracker" | ||||||
|  |             # nonsense meant for non-SC systems. | ||||||
|  |             shm_unlink(self._shm.name) | ||||||
|  | 
 | ||||||
|  |         self._first.destroy() | ||||||
|  |         self._last.destroy() | ||||||
|  | 
 | ||||||
|  |     def flush(self) -> None: | ||||||
|  |         # TODO: flush to storage backend like markestore? | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def open_shm_ndarray( | ||||||
|  |     size: int, | ||||||
|  |     key: str | None = None, | ||||||
|  |     dtype: np.dtype | None = None, | ||||||
|  |     append_start_index: int | None = None, | ||||||
|  |     readonly: bool = False, | ||||||
|  | 
 | ||||||
|  | ) -> ShmArray: | ||||||
|  |     ''' | ||||||
|  |     Open a memory shared ``numpy`` using the standard library. | ||||||
|  | 
 | ||||||
|  |     This call unlinks (aka permanently destroys) the buffer on teardown | ||||||
|  |     and thus should be used from the parent-most accessor (process). | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # create new shared mem segment for which we | ||||||
|  |     # have write permission | ||||||
|  |     a = np.zeros(size, dtype=dtype) | ||||||
|  |     a['index'] = np.arange(len(a)) | ||||||
|  | 
 | ||||||
|  |     shm = SharedMemory( | ||||||
|  |         name=key, | ||||||
|  |         create=True, | ||||||
|  |         size=a.nbytes | ||||||
|  |     ) | ||||||
|  |     array = np.ndarray( | ||||||
|  |         a.shape, | ||||||
|  |         dtype=a.dtype, | ||||||
|  |         buffer=shm.buf | ||||||
|  |     ) | ||||||
|  |     array[:] = a[:] | ||||||
|  |     array.setflags(write=int(not readonly)) | ||||||
|  | 
 | ||||||
|  |     token = _make_token( | ||||||
|  |         key=key, | ||||||
|  |         size=size, | ||||||
|  |         dtype=dtype, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # create single entry arrays for storing an first and last indices | ||||||
|  |     first = SharedInt( | ||||||
|  |         shm=SharedMemory( | ||||||
|  |             name=token.shm_first_index_name, | ||||||
|  |             create=True, | ||||||
|  |             size=4,  # std int | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     last = SharedInt( | ||||||
|  |         shm=SharedMemory( | ||||||
|  |             name=token.shm_last_index_name, | ||||||
|  |             create=True, | ||||||
|  |             size=4,  # std int | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # Start the "real-time" append-updated (or "pushed-to") section | ||||||
|  |     # after some start index: ``append_start_index``. This allows appending | ||||||
|  |     # from a start point in the array which isn't the 0 index and looks | ||||||
|  |     # something like, | ||||||
|  |     # ------------------------- | ||||||
|  |     # |              |        i | ||||||
|  |     # _________________________ | ||||||
|  |     # <-------------> <-------> | ||||||
|  |     #  history         real-time | ||||||
|  |     # | ||||||
|  |     # Once fully "prepended", the history section will leave the | ||||||
|  |     # ``ShmArray._start.value: int = 0`` and the yet-to-be written | ||||||
|  |     # real-time section will start at ``ShmArray.index: int``. | ||||||
|  | 
 | ||||||
|  |     # this sets the index to nearly 2/3rds into the the length of | ||||||
|  |     # the buffer leaving at least a "days worth of second samples" | ||||||
|  |     # for the real-time section. | ||||||
|  |     if append_start_index is None: | ||||||
|  |         append_start_index = round(size * 0.616) | ||||||
|  | 
 | ||||||
|  |     last.value = first.value = append_start_index | ||||||
|  | 
 | ||||||
|  |     shmarr = ShmArray( | ||||||
|  |         array, | ||||||
|  |         first, | ||||||
|  |         last, | ||||||
|  |         shm, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     assert shmarr._token == token | ||||||
|  |     _known_tokens[key] = shmarr.token | ||||||
|  | 
 | ||||||
|  |     # "unlink" created shm on process teardown by | ||||||
|  |     # pushing teardown calls onto actor context stack | ||||||
|  |     stack = tractor.current_actor().lifetime_stack | ||||||
|  |     stack.callback(shmarr.close) | ||||||
|  |     stack.callback(shmarr.destroy) | ||||||
|  | 
 | ||||||
|  |     return shmarr | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def attach_shm_ndarray( | ||||||
|  |     token: tuple[str, str, tuple[str, str]], | ||||||
|  |     readonly: bool = True, | ||||||
|  | 
 | ||||||
|  | ) -> ShmArray: | ||||||
|  |     ''' | ||||||
|  |     Attach to an existing shared memory array previously | ||||||
|  |     created by another process using ``open_shared_array``. | ||||||
|  | 
 | ||||||
|  |     No new shared mem is allocated but wrapper types for read/write | ||||||
|  |     access are constructed. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     token = NDToken.from_msg(token) | ||||||
|  |     key = token.shm_name | ||||||
|  | 
 | ||||||
|  |     if key in _known_tokens: | ||||||
|  |         assert NDToken.from_msg(_known_tokens[key]) == token, "WTF" | ||||||
|  | 
 | ||||||
|  |     # XXX: ugh, looks like due to the ``shm_open()`` C api we can't | ||||||
|  |     # actually place files in a subdir, see discussion here: | ||||||
|  |     # https://stackoverflow.com/a/11103289 | ||||||
|  | 
 | ||||||
|  |     # attach to array buffer and view as per dtype | ||||||
|  |     _err: Optional[Exception] = None | ||||||
|  |     for _ in range(3): | ||||||
|  |         try: | ||||||
|  |             shm = SharedMemory( | ||||||
|  |                 name=key, | ||||||
|  |                 create=False, | ||||||
|  |             ) | ||||||
|  |             break | ||||||
|  |         except OSError as oserr: | ||||||
|  |             _err = oserr | ||||||
|  |             time.sleep(0.1) | ||||||
|  |     else: | ||||||
|  |         if _err: | ||||||
|  |             raise _err | ||||||
|  | 
 | ||||||
|  |     shmarr = np.ndarray( | ||||||
|  |         (token.size,), | ||||||
|  |         dtype=token.dtype, | ||||||
|  |         buffer=shm.buf | ||||||
|  |     ) | ||||||
|  |     shmarr.setflags(write=int(not readonly)) | ||||||
|  | 
 | ||||||
|  |     first = SharedInt( | ||||||
|  |         shm=SharedMemory( | ||||||
|  |             name=token.shm_first_index_name, | ||||||
|  |             create=False, | ||||||
|  |             size=4,  # std int | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  |     last = SharedInt( | ||||||
|  |         shm=SharedMemory( | ||||||
|  |             name=token.shm_last_index_name, | ||||||
|  |             create=False, | ||||||
|  |             size=4,  # std int | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # make sure we can read | ||||||
|  |     first.value | ||||||
|  | 
 | ||||||
|  |     sha = ShmArray( | ||||||
|  |         shmarr, | ||||||
|  |         first, | ||||||
|  |         last, | ||||||
|  |         shm, | ||||||
|  |     ) | ||||||
|  |     # read test | ||||||
|  |     sha.array | ||||||
|  | 
 | ||||||
|  |     # Stash key -> token knowledge for future queries | ||||||
|  |     # via `maybe_opepn_shm_array()` but only after we know | ||||||
|  |     # we can attach. | ||||||
|  |     if key not in _known_tokens: | ||||||
|  |         _known_tokens[key] = token | ||||||
|  | 
 | ||||||
|  |     # "close" attached shm on actor teardown | ||||||
|  |     tractor.current_actor().lifetime_stack.callback(sha.close) | ||||||
|  | 
 | ||||||
|  |     return sha | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def maybe_open_shm_ndarray( | ||||||
|  |     key: str,  # unique identifier for segment | ||||||
|  |     size: int, | ||||||
|  |     dtype: np.dtype | None = None, | ||||||
|  |     append_start_index: int = 0, | ||||||
|  |     readonly: bool = True, | ||||||
|  | 
 | ||||||
|  | ) -> tuple[ShmArray, bool]: | ||||||
|  |     ''' | ||||||
|  |     Attempt to attach to a shared memory block using a "key" lookup | ||||||
|  |     to registered blocks in the users overall "system" registry | ||||||
|  |     (presumes you don't have the block's explicit token). | ||||||
|  | 
 | ||||||
|  |     This function is meant to solve the problem of discovering whether | ||||||
|  |     a shared array token has been allocated or discovered by the actor | ||||||
|  |     running in **this** process. Systems where multiple actors may seek | ||||||
|  |     to access a common block can use this function to attempt to acquire | ||||||
|  |     a token as discovered by the actors who have previously stored | ||||||
|  |     a "key" -> ``NDToken`` map in an actor local (aka python global) | ||||||
|  |     variable. | ||||||
|  | 
 | ||||||
|  |     If you know the explicit ``NDToken`` for your memory segment instead | ||||||
|  |     use ``attach_shm_array``. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     try: | ||||||
|  |         # see if we already know this key | ||||||
|  |         token = _known_tokens[key] | ||||||
|  |         return ( | ||||||
|  |             attach_shm_ndarray( | ||||||
|  |                 token=token, | ||||||
|  |                 readonly=readonly, | ||||||
|  |             ), | ||||||
|  |             False,  # not newly opened | ||||||
|  |         ) | ||||||
|  |     except KeyError: | ||||||
|  |         log.warning(f"Could not find {key} in shms cache") | ||||||
|  |         if dtype: | ||||||
|  |             token = _make_token( | ||||||
|  |                 key, | ||||||
|  |                 size=size, | ||||||
|  |                 dtype=dtype, | ||||||
|  |             ) | ||||||
|  |         else: | ||||||
|  | 
 | ||||||
|  |             try: | ||||||
|  |                 return ( | ||||||
|  |                     attach_shm_ndarray( | ||||||
|  |                         token=token, | ||||||
|  |                         readonly=readonly, | ||||||
|  |                     ), | ||||||
|  |                     False, | ||||||
|  |                 ) | ||||||
|  |             except FileNotFoundError: | ||||||
|  |                 log.warning(f"Could not attach to shm with token {token}") | ||||||
|  | 
 | ||||||
|  |         # This actor does not know about memory | ||||||
|  |         # associated with the provided "key". | ||||||
|  |         # Attempt to open a block and expect | ||||||
|  |         # to fail if a block has been allocated | ||||||
|  |         # on the OS by someone else. | ||||||
|  |         return ( | ||||||
|  |             open_shm_ndarray( | ||||||
|  |                 key=key, | ||||||
|  |                 size=size, | ||||||
|  |                 dtype=dtype, | ||||||
|  |                 append_start_index=append_start_index, | ||||||
|  |                 readonly=readonly, | ||||||
|  |             ), | ||||||
|  |             True, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ShmList(ShareableList): | ||||||
|  |     ''' | ||||||
|  |     Carbon copy of ``.shared_memory.ShareableList`` with a few | ||||||
|  |     enhancements: | ||||||
|  | 
 | ||||||
|  |     - readonly mode via instance var flag  `._readonly: bool` | ||||||
|  |     - ``.__getitem__()`` accepts ``slice`` inputs | ||||||
|  |     - exposes the underlying buffer "name" as a ``.key: str`` | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         sequence: list | None = None, | ||||||
|  |         *, | ||||||
|  |         name: str | None = None, | ||||||
|  |         readonly: bool = True | ||||||
|  | 
 | ||||||
|  |     ) -> None: | ||||||
|  |         self._readonly = readonly | ||||||
|  |         self._key = name | ||||||
|  |         return super().__init__( | ||||||
|  |             sequence=sequence, | ||||||
|  |             name=name, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def key(self) -> str: | ||||||
|  |         return self._key | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def readonly(self) -> bool: | ||||||
|  |         return self._readonly | ||||||
|  | 
 | ||||||
|  |     def __setitem__( | ||||||
|  |         self, | ||||||
|  |         position, | ||||||
|  |         value, | ||||||
|  | 
 | ||||||
|  |     ) -> None: | ||||||
|  | 
 | ||||||
|  |         # mimick ``numpy`` error | ||||||
|  |         if self._readonly: | ||||||
|  |             raise ValueError('assignment destination is read-only') | ||||||
|  | 
 | ||||||
|  |         return super().__setitem__(position, value) | ||||||
|  | 
 | ||||||
|  |     def __getitem__( | ||||||
|  |         self, | ||||||
|  |         indexish, | ||||||
|  |     ) -> list: | ||||||
|  | 
 | ||||||
|  |         # NOTE: this is a non-writeable view (copy?) of the buffer | ||||||
|  |         # in a new list instance. | ||||||
|  |         if isinstance(indexish, slice): | ||||||
|  |             return list(self)[indexish] | ||||||
|  | 
 | ||||||
|  |         return super().__getitem__(indexish) | ||||||
|  | 
 | ||||||
|  |     # TODO: should we offer a `.array` and `.push()` equivalent | ||||||
|  |     # to the `ShmArray`? | ||||||
|  |     # currently we have the following limitations: | ||||||
|  |     # - can't write slices of input using traditional slice-assign | ||||||
|  |     #   syntax due to the ``ShareableList.__setitem__()`` implementation. | ||||||
|  |     # - ``list(shmlist)`` returns a non-mutable copy instead of | ||||||
|  |     #   a writeable view which would be handier numpy-style ops. | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def open_shm_list( | ||||||
|  |     key: str, | ||||||
|  |     sequence: list | None = None, | ||||||
|  |     size: int = int(2 ** 10), | ||||||
|  |     dtype: float | int | bool | str | bytes | None = float, | ||||||
|  |     readonly: bool = True, | ||||||
|  | 
 | ||||||
|  | ) -> ShmList: | ||||||
|  | 
 | ||||||
|  |     if sequence is None: | ||||||
|  |         default = { | ||||||
|  |             float: 0., | ||||||
|  |             int: 0, | ||||||
|  |             bool: True, | ||||||
|  |             str: 'doggy', | ||||||
|  |             None: None, | ||||||
|  |         }[dtype] | ||||||
|  |         sequence = [default] * size | ||||||
|  | 
 | ||||||
|  |     shml = ShmList( | ||||||
|  |         sequence=sequence, | ||||||
|  |         name=key, | ||||||
|  |         readonly=readonly, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # TODO, factor into a @actor_fixture acm-API? | ||||||
|  |     # -[ ] also `@maybe_actor_fixture()` which inludes | ||||||
|  |     #     the .current_actor() convenience check? | ||||||
|  |     #   |_ orr can that just be in the sin-maybe-version? | ||||||
|  |     # | ||||||
|  |     # "close" attached shm on actor teardown | ||||||
|  |     try: | ||||||
|  |         actor = tractor.current_actor() | ||||||
|  | 
 | ||||||
|  |         actor.lifetime_stack.callback(shml.shm.close) | ||||||
|  | 
 | ||||||
|  |         # XXX on 3.13+ we don't need to call this? | ||||||
|  |         # -> bc we pass `track=False` for `SharedMemeory` orr? | ||||||
|  |         if ( | ||||||
|  |             platform.python_version_tuple()[:-1] < ('3', '13') | ||||||
|  |         ): | ||||||
|  |             actor.lifetime_stack.callback(shml.shm.unlink) | ||||||
|  |     except RuntimeError: | ||||||
|  |         log.warning('tractor runtime not active, skipping teardown steps') | ||||||
|  | 
 | ||||||
|  |     return shml | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def attach_shm_list( | ||||||
|  |     key: str, | ||||||
|  |     readonly: bool = False, | ||||||
|  | 
 | ||||||
|  | ) -> ShmList: | ||||||
|  | 
 | ||||||
|  |     return ShmList( | ||||||
|  |         name=key, | ||||||
|  |         readonly=readonly, | ||||||
|  |     ) | ||||||
|  | @ -0,0 +1,254 @@ | ||||||
|  | # 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 | ||||||
|  | import ipaddress | ||||||
|  | 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 | ||||||
|  | 
 | ||||||
|  |     def __post_init__(self): | ||||||
|  |         try: | ||||||
|  |             ipaddress.ip_address(self._host) | ||||||
|  |         except ValueError as valerr: | ||||||
|  |             raise ValueError( | ||||||
|  |                 'Invalid {type(self).__name__}._host = {self._host!r}\n' | ||||||
|  |             ) from valerr | ||||||
|  | 
 | ||||||
|  |     proto_key: ClassVar[str] = 'tcp' | ||||||
|  |     unwrapped_type: ClassVar[type] = tuple[str, int] | ||||||
|  |     def_bindspace: ClassVar[str] = '127.0.0.1' | ||||||
|  | 
 | ||||||
|  |     # ?TODO, actually validate ipv4/6 with stdlib's `ipaddress` | ||||||
|  |     @property | ||||||
|  |     def is_valid(self) -> bool: | ||||||
|  |         ''' | ||||||
|  |         Predicate to ensure a valid socket-address pair. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         return ( | ||||||
|  |             self._port != 0 | ||||||
|  |             and | ||||||
|  |             (ipaddr := ipaddress.ip_address(self._host)) | ||||||
|  |             and not ( | ||||||
|  |                 ipaddr.is_reserved | ||||||
|  |                 or | ||||||
|  |                 ipaddr.is_unspecified | ||||||
|  |                 or | ||||||
|  |                 ipaddr.is_link_local | ||||||
|  |                 or | ||||||
|  |                 ipaddr.is_link_local | ||||||
|  |                 or | ||||||
|  |                 ipaddr.is_multicast | ||||||
|  |                 or | ||||||
|  |                 ipaddr.is_global | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         # ^XXX^ see various properties of invalid addrs here, | ||||||
|  |         # https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv4Address | ||||||
|  | 
 | ||||||
|  |     @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`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     log.runtime( | ||||||
|  |         f'Trying socket bind\n' | ||||||
|  |         f'>[ {addr}\n' | ||||||
|  |     ) | ||||||
|  |     # ?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] | ||||||
|  |     bound_addr: TCPAddress = type(addr).from_addr((host, port)) | ||||||
|  |     log.info( | ||||||
|  |         f'Listening on TCP socket\n' | ||||||
|  |         f'[> {bound_addr}\n' | ||||||
|  |     ) | ||||||
|  |     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,536 @@ | ||||||
|  | # 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, | ||||||
|  |                 trio.ClosedResourceError, | ||||||
|  |             ) as _re: | ||||||
|  |                 trans_err = _re | ||||||
|  |                 tpt_name: str = f'{type(self).__name__!r}' | ||||||
|  | 
 | ||||||
|  |                 match trans_err: | ||||||
|  | 
 | ||||||
|  |                     # 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. | ||||||
|  |                     case trio.BrokenResourceError() if ( | ||||||
|  |                         '[Errno 32] Broken pipe' | ||||||
|  |                         in | ||||||
|  |                         trans_err.args[0] | ||||||
|  |                     ): | ||||||
|  |                         tpt_closed = 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', | ||||||
|  |                         ) | ||||||
|  |                         raise tpt_closed from trans_err | ||||||
|  | 
 | ||||||
|  |                     # case trio.ClosedResourceError() if ( | ||||||
|  |                     #     'this socket was already closed' | ||||||
|  |                     #     in | ||||||
|  |                     #     trans_err.args[0] | ||||||
|  |                     # ): | ||||||
|  |                     #     tpt_closed = 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', | ||||||
|  |                     #     ) | ||||||
|  |                     #     raise tpt_closed from trans_err | ||||||
|  | 
 | ||||||
|  |                     # unless the disconnect condition falls under "a | ||||||
|  |                     # normal operation breakage" we usualy console warn | ||||||
|  |                     # about it. | ||||||
|  |                     case _: | ||||||
|  |                         log.exception( | ||||||
|  |                             f'{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: 1\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,458 @@ | ||||||
|  | # 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 contextlib import ( | ||||||
|  |     contextmanager as cm, | ||||||
|  | ) | ||||||
|  | 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, | ||||||
|  |     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 | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @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']' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | @cm | ||||||
|  | def _reraise_as_connerr( | ||||||
|  |     src_excs: tuple[Type[Exception]], | ||||||
|  |     addr: UDSAddress, | ||||||
|  | ): | ||||||
|  |     try: | ||||||
|  |         yield | ||||||
|  |     except src_excs as src_exc: | ||||||
|  |         raise ConnectionError( | ||||||
|  |             f'Bad UDS socket-filepath-as-address ??\n' | ||||||
|  |             f'{addr}\n' | ||||||
|  |             f' |_sockpath: {addr.sockpath}\n' | ||||||
|  |             f'\n' | ||||||
|  |             f'from src: {src_exc!r}\n' | ||||||
|  |         ) from src_exc | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def start_listener( | ||||||
|  |     addr: UDSAddress, | ||||||
|  |     **kwargs, | ||||||
|  | ) -> SocketListener: | ||||||
|  |     ''' | ||||||
|  |     Start listening for inbound connections via | ||||||
|  |     a `trio.SocketListener` (task) which `socket.bind()`s on `addr`. | ||||||
|  | 
 | ||||||
|  |     Note, if the `UDSAddress.bindspace: Path` directory dne it is | ||||||
|  |     implicitly created. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     sock = socket.socket( | ||||||
|  |         socket.AF_UNIX, | ||||||
|  |         socket.SOCK_STREAM | ||||||
|  |     ) | ||||||
|  |     log.info( | ||||||
|  |         f'Attempting to bind UDS socket\n' | ||||||
|  |         f'>[\n' | ||||||
|  |         f'|_{addr}\n' | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # ?TODO? should we use the `actor.lifetime_stack` | ||||||
|  |     # to rm on shutdown? | ||||||
|  |     bindpath: Path = addr.sockpath | ||||||
|  |     if not (bs := addr.bindspace).is_dir(): | ||||||
|  |         log.info( | ||||||
|  |             'Creating bindspace dir in file-sys\n' | ||||||
|  |             f'>{{\n' | ||||||
|  |             f'|_{bs!r}\n' | ||||||
|  |         ) | ||||||
|  |         bs.mkdir() | ||||||
|  | 
 | ||||||
|  |     with _reraise_as_connerr( | ||||||
|  |         src_excs=( | ||||||
|  |             FileNotFoundError, | ||||||
|  |             OSError, | ||||||
|  |         ), | ||||||
|  |         addr=addr | ||||||
|  |     ): | ||||||
|  |         await sock.bind(str(bindpath)) | ||||||
|  | 
 | ||||||
|  |     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. | ||||||
|  | 
 | ||||||
|  |         with _reraise_as_connerr( | ||||||
|  |             src_excs=( | ||||||
|  |                 FileNotFoundError, | ||||||
|  |             ), | ||||||
|  |             addr=addr | ||||||
|  |         ): | ||||||
|  |             stream = await open_unix_socket_w_passcred( | ||||||
|  |                 str(sockpath), | ||||||
|  |                 **kwargs | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         tpt_stream = MsgpackUDSStream( | ||||||
|  |             stream, | ||||||
|  |             prefix_size=prefix_size, | ||||||
|  |             codec=codec | ||||||
|  |         ) | ||||||
|  |         # XXX assign from new addrs after peer-PID extract! | ||||||
|  |         ( | ||||||
|  |             tpt_stream._laddr, | ||||||
|  |             tpt_stream._raddr, | ||||||
|  |         ) = cls.get_stream_addrs(stream) | ||||||
|  | 
 | ||||||
|  |         return tpt_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) | ||||||
|  | @ -81,10 +81,35 @@ BOLD_PALETTE = { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def at_least_level( | ||||||
|  |     log: Logger|LoggerAdapter, | ||||||
|  |     level: int|str, | ||||||
|  | ) -> bool: | ||||||
|  |     ''' | ||||||
|  |     Predicate to test if a given level is active. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     if isinstance(level, str): | ||||||
|  |         level: int = CUSTOM_LEVELS[level.upper()] | ||||||
|  | 
 | ||||||
|  |     if log.getEffectiveLevel() <= level: | ||||||
|  |         return True | ||||||
|  |     return False | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| # TODO: this isn't showing the correct '{filename}' | # TODO: this isn't showing the correct '{filename}' | ||||||
| # as it did before.. | # as it did before.. | ||||||
| class StackLevelAdapter(LoggerAdapter): | class StackLevelAdapter(LoggerAdapter): | ||||||
| 
 | 
 | ||||||
|  |     def at_least_level( | ||||||
|  |         self, | ||||||
|  |         level: str, | ||||||
|  |     ) -> bool: | ||||||
|  |         return at_least_level( | ||||||
|  |             log=self, | ||||||
|  |             level=level, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|     def transport( |     def transport( | ||||||
|         self, |         self, | ||||||
|         msg: str, |         msg: str, | ||||||
|  | @ -92,7 +117,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 +295,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 +309,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 +358,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 +371,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 +405,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, | ||||||
|  | @ -391,19 +426,3 @@ def get_loglevel() -> str: | ||||||
| 
 | 
 | ||||||
| # global module logger for tractor itself | # global module logger for tractor itself | ||||||
| log: StackLevelAdapter = get_logger('tractor') | log: StackLevelAdapter = get_logger('tractor') | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def at_least_level( |  | ||||||
|     log: Logger|LoggerAdapter, |  | ||||||
|     level: int|str, |  | ||||||
| ) -> bool: |  | ||||||
|     ''' |  | ||||||
|     Predicate to test if a given level is active. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     if isinstance(level, str): |  | ||||||
|         level: int = CUSTOM_LEVELS[level.upper()] |  | ||||||
| 
 |  | ||||||
|     if log.getEffectiveLevel() <= level: |  | ||||||
|         return True |  | ||||||
|     return False |  | ||||||
|  |  | ||||||
|  | @ -33,6 +33,7 @@ from ._codec import ( | ||||||
| 
 | 
 | ||||||
|     apply_codec as apply_codec, |     apply_codec as apply_codec, | ||||||
|     mk_codec as mk_codec, |     mk_codec as mk_codec, | ||||||
|  |     mk_dec as mk_dec, | ||||||
|     MsgCodec as MsgCodec, |     MsgCodec as MsgCodec, | ||||||
|     MsgDec as MsgDec, |     MsgDec as MsgDec, | ||||||
|     current_codec as current_codec, |     current_codec as current_codec, | ||||||
|  |  | ||||||
|  | @ -61,6 +61,7 @@ from tractor.msg.pretty_struct import Struct | ||||||
| from tractor.msg.types import ( | from tractor.msg.types import ( | ||||||
|     mk_msg_spec, |     mk_msg_spec, | ||||||
|     MsgType, |     MsgType, | ||||||
|  |     PayloadMsg, | ||||||
| ) | ) | ||||||
| from tractor.log import get_logger | from tractor.log import get_logger | ||||||
| 
 | 
 | ||||||
|  | @ -80,6 +81,7 @@ class MsgDec(Struct): | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     _dec: msgpack.Decoder |     _dec: msgpack.Decoder | ||||||
|  |     # _ext_types_box: Struct|None = None | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def dec(self) -> msgpack.Decoder: |     def dec(self) -> msgpack.Decoder: | ||||||
|  | @ -179,23 +181,126 @@ class MsgDec(Struct): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def mk_dec( | def mk_dec( | ||||||
|     spec: Union[Type[Struct]]|Any = Any, |     spec: Union[Type[Struct]]|Type|None, | ||||||
|  | 
 | ||||||
|  |     # NOTE, required for ad-hoc type extensions to the underlying | ||||||
|  |     # serialization proto (which is default `msgpack`), | ||||||
|  |     # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types | ||||||
|     dec_hook: Callable|None = None, |     dec_hook: Callable|None = None, | ||||||
|  |     ext_types: list[Type]|None = None, | ||||||
| 
 | 
 | ||||||
| ) -> MsgDec: | ) -> MsgDec: | ||||||
|     ''' |     ''' | ||||||
|     Create an IPC msg decoder, normally used as the |     Create an IPC msg decoder, a slightly higher level wrapper around | ||||||
|     `PayloadMsg.pld: PayloadT` field decoder inside a `PldRx`. |     a `msgspec.msgpack.Decoder` which provides, | ||||||
|  | 
 | ||||||
|  |     - easier introspection of the underlying type spec via | ||||||
|  |       the `.spec` and `.spec_str` attrs, | ||||||
|  |     - `.hook` access to the `Decoder.dec_hook()`, | ||||||
|  |     - automatic custom extension-types decode support when | ||||||
|  |       `dec_hook()` is provided such that any `PayloadMsg.pld` tagged | ||||||
|  |       as a type from from `ext_types` (presuming the `MsgCodec.encode()` also used | ||||||
|  |       a `.enc_hook()`) is processed and constructed by a `PldRx` implicitily. | ||||||
|  | 
 | ||||||
|  |     NOTE, as mentioned a `MsgDec` is normally used for `PayloadMsg.pld: PayloadT` field | ||||||
|  |     decoding inside an IPC-ctx-oriented `PldRx`. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|  |     if ( | ||||||
|  |         spec is None | ||||||
|  |         and | ||||||
|  |         ext_types is None | ||||||
|  |     ): | ||||||
|  |         raise TypeError( | ||||||
|  |             f'MIssing type-`spec` for msg decoder!\n' | ||||||
|  |             f'\n' | ||||||
|  |             f'`spec=None` is **only** permitted is if custom extension types ' | ||||||
|  |             f'are provided via `ext_types`, meaning it must be non-`None`.\n' | ||||||
|  |             f'\n' | ||||||
|  |             f'In this case it is presumed that only the `ext_types`, ' | ||||||
|  |             f'which much be handled by a paired `dec_hook()`, ' | ||||||
|  |             f'will be permitted within the payload type-`spec`!\n' | ||||||
|  |             f'\n' | ||||||
|  |             f'spec = {spec!r}\n' | ||||||
|  |             f'dec_hook = {dec_hook!r}\n' | ||||||
|  |             f'ext_types = {ext_types!r}\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     if dec_hook: | ||||||
|  |         if ext_types is None: | ||||||
|  |             raise TypeError( | ||||||
|  |                 f'If extending the serializable types with a custom decode hook (`dec_hook()`), ' | ||||||
|  |                 f'you must also provide the expected type set that the hook will handle ' | ||||||
|  |                 f'via a `ext_types: Union[Type]|None = None` argument!\n' | ||||||
|  |                 f'\n' | ||||||
|  |                 f'dec_hook = {dec_hook!r}\n' | ||||||
|  |                 f'ext_types = {ext_types!r}\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         # XXX, i *thought* we would require a boxing struct as per docs, | ||||||
|  |         # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types | ||||||
|  |         # |_ see comment, | ||||||
|  |         #  > Note that typed deserialization is required for | ||||||
|  |         #  > successful roundtripping here, so we pass `MyMessage` to | ||||||
|  |         #  > `Decoder`. | ||||||
|  |         # | ||||||
|  |         # BUT, turns out as long as you spec a union with `Raw` it | ||||||
|  |         # will work? kk B) | ||||||
|  |         # | ||||||
|  |         # maybe_box_struct = mk_boxed_ext_struct(ext_types) | ||||||
|  |         spec = Raw | Union[*ext_types] | ||||||
|  | 
 | ||||||
|     return MsgDec( |     return MsgDec( | ||||||
|         _dec=msgpack.Decoder( |         _dec=msgpack.Decoder( | ||||||
|             type=spec,  # like `MsgType[Any]` |             type=spec,  # like `MsgType[Any]` | ||||||
|             dec_hook=dec_hook, |             dec_hook=dec_hook, | ||||||
|         ) |         ), | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # TODO? remove since didn't end up needing this? | ||||||
|  | def mk_boxed_ext_struct( | ||||||
|  |     ext_types: list[Type], | ||||||
|  | ) -> Struct: | ||||||
|  |     # NOTE, originally was to wrap non-msgpack-supported "extension | ||||||
|  |     # types" in a field-typed boxing struct, see notes around the | ||||||
|  |     # `dec_hook()` branch in `mk_dec()`. | ||||||
|  |     ext_types_union = Union[*ext_types] | ||||||
|  |     repr_ext_types_union: str = ( | ||||||
|  |         str(ext_types_union) | ||||||
|  |         or | ||||||
|  |         "|".join(ext_types) | ||||||
|  |     ) | ||||||
|  |     BoxedExtType = msgspec.defstruct( | ||||||
|  |         f'BoxedExts[{repr_ext_types_union}]', | ||||||
|  |         fields=[ | ||||||
|  |             ('boxed', ext_types_union), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |     return BoxedExtType | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def unpack_spec_types( | ||||||
|  |     spec: Union[Type]|Type, | ||||||
|  | ) -> set[Type]: | ||||||
|  |     ''' | ||||||
|  |     Given an input type-`spec`, either a lone type | ||||||
|  |     or a `Union` of types (like `str|int|MyThing`), | ||||||
|  |     return a set of individual types. | ||||||
|  | 
 | ||||||
|  |     When `spec` is not a type-union returns `{spec,}`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     spec_subtypes: set[Union[Type]] = set( | ||||||
|  |          getattr( | ||||||
|  |              spec, | ||||||
|  |              '__args__', | ||||||
|  |              {spec,}, | ||||||
|  |          ) | ||||||
|  |     ) | ||||||
|  |     return spec_subtypes | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| def mk_msgspec_table( | def mk_msgspec_table( | ||||||
|     dec: msgpack.Decoder, |     dec: msgpack.Decoder, | ||||||
|     msg: MsgType|None = None, |     msg: MsgType|None = None, | ||||||
|  | @ -273,6 +378,8 @@ class MsgCodec(Struct): | ||||||
|     _dec: msgpack.Decoder |     _dec: msgpack.Decoder | ||||||
|     _pld_spec: Type[Struct]|Raw|Any |     _pld_spec: Type[Struct]|Raw|Any | ||||||
| 
 | 
 | ||||||
|  |     # _ext_types_box: Struct|None = None | ||||||
|  | 
 | ||||||
|     def __repr__(self) -> str: |     def __repr__(self) -> str: | ||||||
|         speclines: str = textwrap.indent( |         speclines: str = textwrap.indent( | ||||||
|             pformat_msgspec(codec=self), |             pformat_msgspec(codec=self), | ||||||
|  | @ -339,12 +446,15 @@ class MsgCodec(Struct): | ||||||
| 
 | 
 | ||||||
|     def encode( |     def encode( | ||||||
|         self, |         self, | ||||||
|         py_obj: Any, |         py_obj: Any|PayloadMsg, | ||||||
| 
 | 
 | ||||||
|         use_buf: bool = False, |         use_buf: bool = False, | ||||||
|         # ^-XXX-^ uhh why am i getting this? |         # ^-XXX-^ uhh why am i getting this? | ||||||
|         # |_BufferError: Existing exports of data: object cannot be re-sized |         # |_BufferError: Existing exports of data: object cannot be re-sized | ||||||
| 
 | 
 | ||||||
|  |         as_ext_type: bool = False, | ||||||
|  |         hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|     ) -> bytes: |     ) -> bytes: | ||||||
|         ''' |         ''' | ||||||
|         Encode input python objects to `msgpack` bytes for |         Encode input python objects to `msgpack` bytes for | ||||||
|  | @ -354,11 +464,46 @@ class MsgCodec(Struct): | ||||||
|         https://jcristharif.com/msgspec/perf-tips.html#reusing-an-output-buffer |         https://jcristharif.com/msgspec/perf-tips.html#reusing-an-output-buffer | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|         if use_buf: |         if use_buf: | ||||||
|             self._enc.encode_into(py_obj, self._buf) |             self._enc.encode_into(py_obj, self._buf) | ||||||
|             return self._buf |             return self._buf | ||||||
|         else: | 
 | ||||||
|             return self._enc.encode(py_obj) |         return self._enc.encode(py_obj) | ||||||
|  |         # try: | ||||||
|  |         #     return self._enc.encode(py_obj) | ||||||
|  |         # except TypeError as typerr: | ||||||
|  |         #     typerr.add_note( | ||||||
|  |         #         '|_src error from `msgspec`' | ||||||
|  |         #         # f'|_{self._enc.encode!r}' | ||||||
|  |         #     ) | ||||||
|  |         #     raise typerr | ||||||
|  | 
 | ||||||
|  |         # TODO! REMOVE once i'm confident we won't ever need it! | ||||||
|  |         # | ||||||
|  |         # box: Struct = self._ext_types_box | ||||||
|  |         # if ( | ||||||
|  |         #     as_ext_type | ||||||
|  |         #     or | ||||||
|  |         #     ( | ||||||
|  |         #         # XXX NOTE, auto-detect if the input type | ||||||
|  |         #         box | ||||||
|  |         #         and | ||||||
|  |         #         (ext_types := unpack_spec_types( | ||||||
|  |         #             spec=box.__annotations__['boxed']) | ||||||
|  |         #         ) | ||||||
|  |         #     ) | ||||||
|  |         # ): | ||||||
|  |         #     match py_obj: | ||||||
|  |         #         # case PayloadMsg(pld=pld) if ( | ||||||
|  |         #         #     type(pld) in ext_types | ||||||
|  |         #         # ): | ||||||
|  |         #         #     py_obj.pld = box(boxed=py_obj) | ||||||
|  |         #         #     breakpoint() | ||||||
|  |         #         case _ if ( | ||||||
|  |         #             type(py_obj) in ext_types | ||||||
|  |         #         ): | ||||||
|  |         #             py_obj = box(boxed=py_obj) | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def dec(self) -> msgpack.Decoder: |     def dec(self) -> msgpack.Decoder: | ||||||
|  | @ -378,21 +523,30 @@ class MsgCodec(Struct): | ||||||
|         return self._dec.decode(msg) |         return self._dec.decode(msg) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # [x] TODO: a sub-decoder system as well? => No! | # ?TODO? time to remove this finally? | ||||||
|  | # | ||||||
|  | # -[x] TODO: a sub-decoder system as well? | ||||||
|  | # => No! already re-architected to include a "payload-receiver" | ||||||
|  | #   now found in `._ops`. | ||||||
| # | # | ||||||
| # -[x] do we still want to try and support the sub-decoder with | # -[x] do we still want to try and support the sub-decoder with | ||||||
| # `.Raw` technique in the case that the `Generic` approach gives | # `.Raw` technique in the case that the `Generic` approach gives | ||||||
| # future grief? | # future grief? | ||||||
| # => NO, since we went with the `PldRx` approach instead B) | # => well YES but NO, since we went with the `PldRx` approach | ||||||
|  | #   instead! | ||||||
| # | # | ||||||
| # IF however you want to see the code that was staged for this | # IF however you want to see the code that was staged for this | ||||||
| # from wayyy back, see the pure removal commit. | # from wayyy back, see the pure removal commit. | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def mk_codec( | def mk_codec( | ||||||
|     # struct type unions set for `Decoder` |     ipc_pld_spec: Union[Type[Struct]]|Any|Raw = Raw, | ||||||
|     # https://jcristharif.com/msgspec/structs.html#tagged-unions |     # tagged-struct-types-union set for `Decoder`ing of payloads, as | ||||||
|     ipc_pld_spec: Union[Type[Struct]]|Any = Any, |     # per https://jcristharif.com/msgspec/structs.html#tagged-unions. | ||||||
|  |     # NOTE that the default `Raw` here **is very intentional** since | ||||||
|  |     # the `PldRx._pld_dec: MsgDec` is responsible for per ipc-ctx-task | ||||||
|  |     # decoding of msg-specs defined by the user as part of **their** | ||||||
|  |     # `tractor` "app's" type-limited IPC msg-spec. | ||||||
| 
 | 
 | ||||||
|     # TODO: offering a per-msg(-field) type-spec such that |     # TODO: offering a per-msg(-field) type-spec such that | ||||||
|     # the fields can be dynamically NOT decoded and left as `Raw` |     # the fields can be dynamically NOT decoded and left as `Raw` | ||||||
|  | @ -405,13 +559,18 @@ def mk_codec( | ||||||
| 
 | 
 | ||||||
|     libname: str = 'msgspec', |     libname: str = 'msgspec', | ||||||
| 
 | 
 | ||||||
|     # proxy as `Struct(**kwargs)` for ad-hoc type extensions |     # settings for encoding-to-send extension-types, | ||||||
|     # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types |     # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types | ||||||
|     # ------ - ------ |     # dec_hook: Callable|None = None, | ||||||
|     dec_hook: Callable|None = None, |  | ||||||
|     enc_hook: Callable|None = None, |     enc_hook: Callable|None = None, | ||||||
|     # ------ - ------ |     ext_types: list[Type]|None = None, | ||||||
|  | 
 | ||||||
|  |     # optionally provided msg-decoder from which we pull its, | ||||||
|  |     # |_.dec_hook() | ||||||
|  |     # |_.type | ||||||
|  |     ext_dec: MsgDec|None = None | ||||||
|     # |     # | ||||||
|  |     # ?TODO? other params we might want to support | ||||||
|     # Encoder: |     # Encoder: | ||||||
|     # write_buffer_size=write_buffer_size, |     # write_buffer_size=write_buffer_size, | ||||||
|     # |     # | ||||||
|  | @ -425,26 +584,44 @@ def mk_codec( | ||||||
|     `msgspec` ;). |     `msgspec` ;). | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     # (manually) generate a msg-payload-spec for all relevant |     pld_spec = ipc_pld_spec | ||||||
|     # god-boxing-msg subtypes, parameterizing the `PayloadMsg.pld: PayloadT` |     if enc_hook: | ||||||
|     # for the decoder such that all sub-type msgs in our SCIPP |         if not ext_types: | ||||||
|     # will automatically decode to a type-"limited" payload (`Struct`) |             raise TypeError( | ||||||
|     # object (set). |                 f'If extending the serializable types with a custom encode hook (`enc_hook()`), ' | ||||||
|  |                 f'you must also provide the expected type set that the hook will handle ' | ||||||
|  |                 f'via a `ext_types: Union[Type]|None = None` argument!\n' | ||||||
|  |                 f'\n' | ||||||
|  |                 f'enc_hook = {enc_hook!r}\n' | ||||||
|  |                 f'ext_types = {ext_types!r}\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     dec_hook: Callable|None = None | ||||||
|  |     if ext_dec: | ||||||
|  |         dec: msgspec.Decoder = ext_dec.dec | ||||||
|  |         dec_hook = dec.dec_hook | ||||||
|  |         pld_spec |= dec.type | ||||||
|  |         if ext_types: | ||||||
|  |             pld_spec |= Union[*ext_types] | ||||||
|  | 
 | ||||||
|  |     # (manually) generate a msg-spec (how appropes) for all relevant | ||||||
|  |     # payload-boxing-struct-msg-types, parameterizing the | ||||||
|  |     # `PayloadMsg.pld: PayloadT` for the decoder such that all msgs | ||||||
|  |     # in our SC-RPC-protocol will automatically decode to | ||||||
|  |     # a type-"limited" payload (`Struct`) object (set). | ||||||
|     ( |     ( | ||||||
|         ipc_msg_spec, |         ipc_msg_spec, | ||||||
|         msg_types, |         msg_types, | ||||||
|     ) = mk_msg_spec( |     ) = mk_msg_spec( | ||||||
|         payload_type_union=ipc_pld_spec, |         payload_type_union=pld_spec, | ||||||
|     ) |     ) | ||||||
|     assert len(ipc_msg_spec.__args__) == len(msg_types) |  | ||||||
|     assert ipc_msg_spec |  | ||||||
| 
 | 
 | ||||||
|     # TODO: use this shim instead? |     msg_spec_types: set[Type] = unpack_spec_types(ipc_msg_spec) | ||||||
|     # bc.. unification, err somethin? |     assert ( | ||||||
|     # dec: MsgDec = mk_dec( |         len(ipc_msg_spec.__args__) == len(msg_types) | ||||||
|     #     spec=ipc_msg_spec, |         and | ||||||
|     #     dec_hook=dec_hook, |         len(msg_spec_types) == len(msg_types) | ||||||
|     # ) |     ) | ||||||
| 
 | 
 | ||||||
|     dec = msgpack.Decoder( |     dec = msgpack.Decoder( | ||||||
|         type=ipc_msg_spec, |         type=ipc_msg_spec, | ||||||
|  | @ -453,22 +630,29 @@ def mk_codec( | ||||||
|     enc = msgpack.Encoder( |     enc = msgpack.Encoder( | ||||||
|        enc_hook=enc_hook, |        enc_hook=enc_hook, | ||||||
|     ) |     ) | ||||||
| 
 |  | ||||||
|     codec = MsgCodec( |     codec = MsgCodec( | ||||||
|         _enc=enc, |         _enc=enc, | ||||||
|         _dec=dec, |         _dec=dec, | ||||||
|         _pld_spec=ipc_pld_spec, |         _pld_spec=pld_spec, | ||||||
|     ) |     ) | ||||||
| 
 |  | ||||||
|     # sanity on expected backend support |     # sanity on expected backend support | ||||||
|     assert codec.lib.__name__ == libname |     assert codec.lib.__name__ == libname | ||||||
| 
 |  | ||||||
|     return codec |     return codec | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # instance of the default `msgspec.msgpack` codec settings, i.e. | # instance of the default `msgspec.msgpack` codec settings, i.e. | ||||||
| # no custom structs, hooks or other special types. | # no custom structs, hooks or other special types. | ||||||
| _def_msgspec_codec: MsgCodec = mk_codec(ipc_pld_spec=Any) | # | ||||||
|  | # XXX NOTE XXX, this will break our `Context.start()` call! | ||||||
|  | # | ||||||
|  | # * by default we roundtrip the started pld-`value` and if you apply | ||||||
|  | #   this codec (globally anyway with `apply_codec()`) then the | ||||||
|  | #   `roundtripped` value will include a non-`.pld: Raw` which will | ||||||
|  | #   then type-error on the consequent `._ops.validte_payload_msg()`.. | ||||||
|  | # | ||||||
|  | _def_msgspec_codec: MsgCodec = mk_codec( | ||||||
|  |     ipc_pld_spec=Any, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| # The built-in IPC `Msg` spec. | # The built-in IPC `Msg` spec. | ||||||
| # Our composing "shuttle" protocol which allows `tractor`-app code | # Our composing "shuttle" protocol which allows `tractor`-app code | ||||||
|  | @ -476,13 +660,13 @@ _def_msgspec_codec: MsgCodec = mk_codec(ipc_pld_spec=Any) | ||||||
| # https://jcristharif.com/msgspec/supported-types.html | # https://jcristharif.com/msgspec/supported-types.html | ||||||
| # | # | ||||||
| _def_tractor_codec: MsgCodec = mk_codec( | _def_tractor_codec: MsgCodec = mk_codec( | ||||||
|     # TODO: use this for debug mode locking prot? |     ipc_pld_spec=Raw,  # XXX should be default righ!? | ||||||
|     # ipc_pld_spec=Any, |  | ||||||
|     ipc_pld_spec=Raw, |  | ||||||
| ) | ) | ||||||
| # TODO: IDEALLY provides for per-`trio.Task` specificity of the | 
 | ||||||
|  | # -[x] TODO, IDEALLY provides for per-`trio.Task` specificity of the | ||||||
| # IPC msging codec used by the transport layer when doing | # IPC msging codec used by the transport layer when doing | ||||||
| # `Channel.send()/.recv()` of wire data. | # `Channel.send()/.recv()` of wire data. | ||||||
|  | # => impled as our `PldRx` which is `Context` scoped B) | ||||||
| 
 | 
 | ||||||
| # ContextVar-TODO: DIDN'T WORK, kept resetting in every new task to default!? | # ContextVar-TODO: DIDN'T WORK, kept resetting in every new task to default!? | ||||||
| # _ctxvar_MsgCodec: ContextVar[MsgCodec] = ContextVar( | # _ctxvar_MsgCodec: ContextVar[MsgCodec] = ContextVar( | ||||||
|  | @ -559,17 +743,6 @@ def apply_codec( | ||||||
|     ) |     ) | ||||||
|     token: Token = var.set(codec) |     token: Token = var.set(codec) | ||||||
| 
 | 
 | ||||||
|     # ?TODO? for TreeVar approach which copies from the |  | ||||||
|     # cancel-scope of the prior value, NOT the prior task |  | ||||||
|     # See the docs: |  | ||||||
|     # - https://tricycle.readthedocs.io/en/latest/reference.html#tree-variables |  | ||||||
|     # - https://github.com/oremanj/tricycle/blob/master/tricycle/_tests/test_tree_var.py |  | ||||||
|     #   ^- see docs for @cm `.being()` API |  | ||||||
|     # with _ctxvar_MsgCodec.being(codec): |  | ||||||
|     #     new = _ctxvar_MsgCodec.get() |  | ||||||
|     #     assert new is codec |  | ||||||
|     #     yield codec |  | ||||||
| 
 |  | ||||||
|     try: |     try: | ||||||
|         yield var.get() |         yield var.get() | ||||||
|     finally: |     finally: | ||||||
|  | @ -580,6 +753,19 @@ def apply_codec( | ||||||
|         ) |         ) | ||||||
|         assert var.get() is orig |         assert var.get() is orig | ||||||
| 
 | 
 | ||||||
|  |     # ?TODO? for TreeVar approach which copies from the | ||||||
|  |     # cancel-scope of the prior value, NOT the prior task | ||||||
|  |     # | ||||||
|  |     # See the docs: | ||||||
|  |     # - https://tricycle.readthedocs.io/en/latest/reference.html#tree-variables | ||||||
|  |     # - https://github.com/oremanj/tricycle/blob/master/tricycle/_tests/test_tree_var.py | ||||||
|  |     #   ^- see docs for @cm `.being()` API | ||||||
|  |     # | ||||||
|  |     # with _ctxvar_MsgCodec.being(codec): | ||||||
|  |     #     new = _ctxvar_MsgCodec.get() | ||||||
|  |     #     assert new is codec | ||||||
|  |     #     yield codec | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| def current_codec() -> MsgCodec: | def current_codec() -> MsgCodec: | ||||||
|     ''' |     ''' | ||||||
|  | @ -599,6 +785,7 @@ def limit_msg_spec( | ||||||
|     # -> related to the `MsgCodec._payload_decs` stuff above.. |     # -> related to the `MsgCodec._payload_decs` stuff above.. | ||||||
|     # tagged_structs: list[Struct]|None = None, |     # tagged_structs: list[Struct]|None = None, | ||||||
| 
 | 
 | ||||||
|  |     hide_tb: bool = True, | ||||||
|     **codec_kwargs, |     **codec_kwargs, | ||||||
| 
 | 
 | ||||||
| ) -> MsgCodec: | ) -> MsgCodec: | ||||||
|  | @ -609,7 +796,7 @@ def limit_msg_spec( | ||||||
|     for all IPC contexts in use by the current `trio.Task`. |     for all IPC contexts in use by the current `trio.Task`. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     __tracebackhide__: bool = True |     __tracebackhide__: bool = hide_tb | ||||||
|     curr_codec: MsgCodec = current_codec() |     curr_codec: MsgCodec = current_codec() | ||||||
|     msgspec_codec: MsgCodec = mk_codec( |     msgspec_codec: MsgCodec = mk_codec( | ||||||
|         ipc_pld_spec=payload_spec, |         ipc_pld_spec=payload_spec, | ||||||
|  |  | ||||||
|  | @ -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/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Type-extension-utils for codec-ing (python) objects not | ||||||
|  | covered by the `msgspec.msgpack` protocol. | ||||||
|  | 
 | ||||||
|  | See the various API docs from `msgspec`. | ||||||
|  | 
 | ||||||
|  | extending from native types, | ||||||
|  | - https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types | ||||||
|  | 
 | ||||||
|  | converters, | ||||||
|  | - https://jcristharif.com/msgspec/converters.html | ||||||
|  | - https://jcristharif.com/msgspec/api.html#msgspec.convert | ||||||
|  | 
 | ||||||
|  | `Raw` fields, | ||||||
|  | - https://jcristharif.com/msgspec/api.html#raw | ||||||
|  | - support for `.convert()` and `Raw`, | ||||||
|  |   |_ https://jcristharif.com/msgspec/changelog.html | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from types import ( | ||||||
|  |     ModuleType, | ||||||
|  | ) | ||||||
|  | import typing | ||||||
|  | from typing import ( | ||||||
|  |     Type, | ||||||
|  |     Union, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | def dec_type_union( | ||||||
|  |     type_names: list[str], | ||||||
|  |     mods: list[ModuleType] = [] | ||||||
|  | ) -> Type|Union[Type]: | ||||||
|  |     ''' | ||||||
|  |     Look up types by name, compile into a list and then create and | ||||||
|  |     return a `typing.Union` from the full set. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # import importlib | ||||||
|  |     types: list[Type] = [] | ||||||
|  |     for type_name in type_names: | ||||||
|  |         for mod in [ | ||||||
|  |             typing, | ||||||
|  |             # importlib.import_module(__name__), | ||||||
|  |         ] + mods: | ||||||
|  |             if type_ref := getattr( | ||||||
|  |                 mod, | ||||||
|  |                 type_name, | ||||||
|  |                 False, | ||||||
|  |             ): | ||||||
|  |                 types.append(type_ref) | ||||||
|  | 
 | ||||||
|  |     # special case handling only.. | ||||||
|  |     # ipc_pld_spec: Union[Type] = eval( | ||||||
|  |     #     pld_spec_str, | ||||||
|  |     #     {},  # globals | ||||||
|  |     #     {'typing': typing},  # locals | ||||||
|  |     # ) | ||||||
|  | 
 | ||||||
|  |     return Union[*types] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def enc_type_union( | ||||||
|  |     union_or_type: Union[Type]|Type, | ||||||
|  | ) -> list[str]: | ||||||
|  |     ''' | ||||||
|  |     Encode a type-union or single type to a list of type-name-strings | ||||||
|  |     ready for IPC interchange. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     type_strs: list[str] = [] | ||||||
|  |     for typ in getattr( | ||||||
|  |         union_or_type, | ||||||
|  |         '__args__', | ||||||
|  |         {union_or_type,}, | ||||||
|  |     ): | ||||||
|  |         type_strs.append(typ.__qualname__) | ||||||
|  | 
 | ||||||
|  |     return type_strs | ||||||
|  | @ -50,7 +50,9 @@ from tractor._exceptions import ( | ||||||
|     _mk_recv_mte, |     _mk_recv_mte, | ||||||
|     pack_error, |     pack_error, | ||||||
| ) | ) | ||||||
| from tractor._state import current_ipc_ctx | from tractor._state import ( | ||||||
|  |     current_ipc_ctx, | ||||||
|  | ) | ||||||
| from ._codec import ( | from ._codec import ( | ||||||
|     mk_dec, |     mk_dec, | ||||||
|     MsgDec, |     MsgDec, | ||||||
|  | @ -78,7 +80,7 @@ if TYPE_CHECKING: | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| _def_any_pldec: MsgDec[Any] = mk_dec() | _def_any_pldec: MsgDec[Any] = mk_dec(spec=Any) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class PldRx(Struct): | class PldRx(Struct): | ||||||
|  | @ -108,33 +110,11 @@ class PldRx(Struct): | ||||||
|     # TODO: better to bind it here? |     # TODO: better to bind it here? | ||||||
|     # _rx_mc: trio.MemoryReceiveChannel |     # _rx_mc: trio.MemoryReceiveChannel | ||||||
|     _pld_dec: MsgDec |     _pld_dec: MsgDec | ||||||
|     _ctx: Context|None = None |  | ||||||
|     _ipc: Context|MsgStream|None = None |  | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def pld_dec(self) -> MsgDec: |     def pld_dec(self) -> MsgDec: | ||||||
|         return self._pld_dec |         return self._pld_dec | ||||||
| 
 | 
 | ||||||
|     # TODO: a better name? |  | ||||||
|     # -[ ] when would this be used as it avoids needingn to pass the |  | ||||||
|     #   ipc prim to every method |  | ||||||
|     @cm |  | ||||||
|     def wraps_ipc( |  | ||||||
|         self, |  | ||||||
|         ipc_prim: Context|MsgStream, |  | ||||||
| 
 |  | ||||||
|     ) -> PldRx: |  | ||||||
|         ''' |  | ||||||
|         Apply this payload receiver to an IPC primitive type, one |  | ||||||
|         of `Context` or `MsgStream`. |  | ||||||
| 
 |  | ||||||
|         ''' |  | ||||||
|         self._ipc = ipc_prim |  | ||||||
|         try: |  | ||||||
|             yield self |  | ||||||
|         finally: |  | ||||||
|             self._ipc = None |  | ||||||
| 
 |  | ||||||
|     @cm |     @cm | ||||||
|     def limit_plds( |     def limit_plds( | ||||||
|         self, |         self, | ||||||
|  | @ -148,6 +128,10 @@ class PldRx(Struct): | ||||||
|         exit. |         exit. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|  |         # TODO, ensure we pull the current `MsgCodec`'s custom | ||||||
|  |         # dec/enc_hook settings as well ? | ||||||
|  |         # -[ ] see `._codec.mk_codec()` inputs | ||||||
|  |         # | ||||||
|         orig_dec: MsgDec = self._pld_dec |         orig_dec: MsgDec = self._pld_dec | ||||||
|         limit_dec: MsgDec = mk_dec( |         limit_dec: MsgDec = mk_dec( | ||||||
|             spec=spec, |             spec=spec, | ||||||
|  | @ -163,7 +147,7 @@ class PldRx(Struct): | ||||||
|     def dec(self) -> msgpack.Decoder: |     def dec(self) -> msgpack.Decoder: | ||||||
|         return self._pld_dec.dec |         return self._pld_dec.dec | ||||||
| 
 | 
 | ||||||
|     def recv_pld_nowait( |     def recv_msg_nowait( | ||||||
|         self, |         self, | ||||||
|         # TODO: make this `MsgStream` compat as well, see above^ |         # TODO: make this `MsgStream` compat as well, see above^ | ||||||
|         # ipc_prim: Context|MsgStream, |         # ipc_prim: Context|MsgStream, | ||||||
|  | @ -174,34 +158,97 @@ class PldRx(Struct): | ||||||
|         hide_tb: bool = False, |         hide_tb: bool = False, | ||||||
|         **dec_pld_kwargs, |         **dec_pld_kwargs, | ||||||
| 
 | 
 | ||||||
|     ) -> Any|Raw: |     ) -> tuple[ | ||||||
|  |         MsgType[PayloadT], | ||||||
|  |         PayloadT, | ||||||
|  |     ]: | ||||||
|  |         ''' | ||||||
|  |         Attempt to non-blocking receive a message from the `._rx_chan` and | ||||||
|  |         unwrap it's payload delivering the pair to the caller. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|         __tracebackhide__: bool = hide_tb |         __tracebackhide__: bool = hide_tb | ||||||
| 
 | 
 | ||||||
|         msg: MsgType = ( |         msg: MsgType = ( | ||||||
|             ipc_msg |             ipc_msg | ||||||
|             or |             or | ||||||
| 
 |  | ||||||
|             # sync-rx msg from underlying IPC feeder (mem-)chan |             # sync-rx msg from underlying IPC feeder (mem-)chan | ||||||
|             ipc._rx_chan.receive_nowait() |             ipc._rx_chan.receive_nowait() | ||||||
|         ) |         ) | ||||||
|         return self.decode_pld( |         pld: PayloadT = self.decode_pld( | ||||||
|             msg, |             msg, | ||||||
|             ipc=ipc, |             ipc=ipc, | ||||||
|             expect_msg=expect_msg, |             expect_msg=expect_msg, | ||||||
|             hide_tb=hide_tb, |             hide_tb=hide_tb, | ||||||
|             **dec_pld_kwargs, |             **dec_pld_kwargs, | ||||||
|         ) |         ) | ||||||
|  |         return ( | ||||||
|  |             msg, | ||||||
|  |             pld, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     async def recv_msg( | ||||||
|  |         self, | ||||||
|  |         ipc: Context|MsgStream, | ||||||
|  |         expect_msg: MsgType, | ||||||
|  | 
 | ||||||
|  |         # NOTE: ONLY for handling `Stop`-msgs that arrive during | ||||||
|  |         # a call to `drain_to_final_msg()` above! | ||||||
|  |         passthrough_non_pld_msgs: bool = True, | ||||||
|  |         hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  |         **decode_pld_kwargs, | ||||||
|  | 
 | ||||||
|  |     ) -> tuple[MsgType, PayloadT]: | ||||||
|  |         ''' | ||||||
|  |         Retrieve the next avail IPC msg, decode its payload, and | ||||||
|  |         return the (msg, pld) pair. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|  |         msg: MsgType = await ipc._rx_chan.receive() | ||||||
|  |         match msg: | ||||||
|  |             case Return()|Error(): | ||||||
|  |                 log.runtime( | ||||||
|  |                     f'Rxed final-outcome msg\n' | ||||||
|  |                     f'\n' | ||||||
|  |                     f'{msg}\n' | ||||||
|  |                 ) | ||||||
|  |             case Stop(): | ||||||
|  |                 log.runtime( | ||||||
|  |                     f'Rxed stream stopped msg\n' | ||||||
|  |                     f'\n' | ||||||
|  |                     f'{msg}\n' | ||||||
|  |                 ) | ||||||
|  |                 if passthrough_non_pld_msgs: | ||||||
|  |                     return msg, None | ||||||
|  | 
 | ||||||
|  |         # TODO: is there some way we can inject the decoded | ||||||
|  |         # payload into an existing output buffer for the original | ||||||
|  |         # msg instance? | ||||||
|  |         pld: PayloadT = self.decode_pld( | ||||||
|  |             msg, | ||||||
|  |             ipc=ipc, | ||||||
|  |             expect_msg=expect_msg, | ||||||
|  |             hide_tb=hide_tb, | ||||||
|  | 
 | ||||||
|  |             **decode_pld_kwargs, | ||||||
|  |         ) | ||||||
|  |         return ( | ||||||
|  |             msg, | ||||||
|  |             pld, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     async def recv_pld( |     async def recv_pld( | ||||||
|         self, |         self, | ||||||
|         ipc: Context|MsgStream, |         ipc: Context|MsgStream, | ||||||
|         ipc_msg: MsgType|None = None, |         ipc_msg: MsgType[PayloadT]|None = None, | ||||||
|         expect_msg: Type[MsgType]|None = None, |         expect_msg: Type[MsgType]|None = None, | ||||||
|         hide_tb: bool = True, |         hide_tb: bool = True, | ||||||
| 
 | 
 | ||||||
|         **dec_pld_kwargs, |         **dec_pld_kwargs, | ||||||
| 
 | 
 | ||||||
|     ) -> Any|Raw: |     ) -> PayloadT: | ||||||
|         ''' |         ''' | ||||||
|         Receive a `MsgType`, then decode and return its `.pld` field. |         Receive a `MsgType`, then decode and return its `.pld` field. | ||||||
| 
 | 
 | ||||||
|  | @ -213,6 +260,14 @@ class PldRx(Struct): | ||||||
|             # async-rx msg from underlying IPC feeder (mem-)chan |             # async-rx msg from underlying IPC feeder (mem-)chan | ||||||
|             await ipc._rx_chan.receive() |             await ipc._rx_chan.receive() | ||||||
|         ) |         ) | ||||||
|  |         if ( | ||||||
|  |             type(msg) is Return | ||||||
|  |         ): | ||||||
|  |             log.runtime( | ||||||
|  |                 f'Rxed final result msg\n' | ||||||
|  |                 f'\n' | ||||||
|  |                 f'{msg}\n' | ||||||
|  |             ) | ||||||
|         return self.decode_pld( |         return self.decode_pld( | ||||||
|             msg=msg, |             msg=msg, | ||||||
|             ipc=ipc, |             ipc=ipc, | ||||||
|  | @ -252,10 +307,13 @@ class PldRx(Struct): | ||||||
|                 try: |                 try: | ||||||
|                     pld: PayloadT = self._pld_dec.decode(pld) |                     pld: PayloadT = self._pld_dec.decode(pld) | ||||||
|                     log.runtime( |                     log.runtime( | ||||||
|                         'Decoded msg payload\n\n' |                         f'Decoded payload for\n' | ||||||
|  |                         # f'\n' | ||||||
|                         f'{msg}\n' |                         f'{msg}\n' | ||||||
|                         f'where payload decoded as\n' |                         # ^TODO?, ideally just render with `, | ||||||
|                         f'|_pld={pld!r}\n' |                         # pld={decode}` in the `msg.pformat()`?? | ||||||
|  |                         f'where, ' | ||||||
|  |                         f'{type(msg).__name__}.pld={pld!r}\n' | ||||||
|                     ) |                     ) | ||||||
|                     return pld |                     return pld | ||||||
|                 except TypeError as typerr: |                 except TypeError as typerr: | ||||||
|  | @ -401,45 +459,6 @@ class PldRx(Struct): | ||||||
|             __tracebackhide__: bool = False |             __tracebackhide__: bool = False | ||||||
|             raise |             raise | ||||||
| 
 | 
 | ||||||
|     dec_msg = decode_pld |  | ||||||
| 
 |  | ||||||
|     async def recv_msg_w_pld( |  | ||||||
|         self, |  | ||||||
|         ipc: Context|MsgStream, |  | ||||||
|         expect_msg: MsgType, |  | ||||||
| 
 |  | ||||||
|         # NOTE: generally speaking only for handling `Stop`-msgs that |  | ||||||
|         # arrive during a call to `drain_to_final_msg()` above! |  | ||||||
|         passthrough_non_pld_msgs: bool = True, |  | ||||||
|         hide_tb: bool = True, |  | ||||||
|         **kwargs, |  | ||||||
| 
 |  | ||||||
|     ) -> tuple[MsgType, PayloadT]: |  | ||||||
|         ''' |  | ||||||
|         Retrieve the next avail IPC msg, decode it's payload, and return |  | ||||||
|         the pair of refs. |  | ||||||
| 
 |  | ||||||
|         ''' |  | ||||||
|         __tracebackhide__: bool = hide_tb |  | ||||||
|         msg: MsgType = await ipc._rx_chan.receive() |  | ||||||
| 
 |  | ||||||
|         if passthrough_non_pld_msgs: |  | ||||||
|             match msg: |  | ||||||
|                 case Stop(): |  | ||||||
|                     return msg, None |  | ||||||
| 
 |  | ||||||
|         # TODO: is there some way we can inject the decoded |  | ||||||
|         # payload into an existing output buffer for the original |  | ||||||
|         # msg instance? |  | ||||||
|         pld: PayloadT = self.decode_pld( |  | ||||||
|             msg, |  | ||||||
|             ipc=ipc, |  | ||||||
|             expect_msg=expect_msg, |  | ||||||
|             hide_tb=hide_tb, |  | ||||||
|             **kwargs, |  | ||||||
|         ) |  | ||||||
|         return msg, pld |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| @cm | @cm | ||||||
| def limit_plds( | def limit_plds( | ||||||
|  | @ -455,11 +474,16 @@ def limit_plds( | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     __tracebackhide__: bool = True |     __tracebackhide__: bool = True | ||||||
|  |     curr_ctx: Context|None = current_ipc_ctx() | ||||||
|  |     if curr_ctx is None: | ||||||
|  |         raise RuntimeError( | ||||||
|  |             'No IPC `Context` is active !?\n' | ||||||
|  |             'Did you open `limit_plds()` from outside ' | ||||||
|  |             'a `Portal.open_context()` scope-block?' | ||||||
|  |         ) | ||||||
|     try: |     try: | ||||||
|         curr_ctx: Context = current_ipc_ctx() |  | ||||||
|         rx: PldRx = curr_ctx._pld_rx |         rx: PldRx = curr_ctx._pld_rx | ||||||
|         orig_pldec: MsgDec = rx.pld_dec |         orig_pldec: MsgDec = rx.pld_dec | ||||||
| 
 |  | ||||||
|         with rx.limit_plds( |         with rx.limit_plds( | ||||||
|             spec=spec, |             spec=spec, | ||||||
|             **dec_kwargs, |             **dec_kwargs, | ||||||
|  | @ -469,9 +493,15 @@ def limit_plds( | ||||||
|                 f'{pldec}\n' |                 f'{pldec}\n' | ||||||
|             ) |             ) | ||||||
|             yield pldec |             yield pldec | ||||||
|  | 
 | ||||||
|  |     except BaseException: | ||||||
|  |         __tracebackhide__: bool = False | ||||||
|  |         raise | ||||||
|  | 
 | ||||||
|     finally: |     finally: | ||||||
|         log.runtime( |         log.runtime( | ||||||
|             'Reverted to previous payload-decoder\n\n' |             f'Reverted to previous payload-decoder\n' | ||||||
|  |             f'\n' | ||||||
|             f'{orig_pldec}\n' |             f'{orig_pldec}\n' | ||||||
|         ) |         ) | ||||||
|         # sanity on orig settings |         # sanity on orig settings | ||||||
|  | @ -522,8 +552,8 @@ async def maybe_limit_plds( | ||||||
| async def drain_to_final_msg( | async def drain_to_final_msg( | ||||||
|     ctx: Context, |     ctx: Context, | ||||||
| 
 | 
 | ||||||
|     hide_tb: bool = True, |  | ||||||
|     msg_limit: int = 6, |     msg_limit: int = 6, | ||||||
|  |     hide_tb: bool = True, | ||||||
| 
 | 
 | ||||||
| ) -> tuple[ | ) -> tuple[ | ||||||
|     Return|None, |     Return|None, | ||||||
|  | @ -552,8 +582,8 @@ async def drain_to_final_msg( | ||||||
|     even after ctx closure and the `.open_context()` block exit. |     even after ctx closure and the `.open_context()` block exit. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     __tracebackhide__: bool = hide_tb |  | ||||||
|     raise_overrun: bool = not ctx._allow_overruns |     raise_overrun: bool = not ctx._allow_overruns | ||||||
|  |     parent_never_opened_stream: bool = ctx._stream is None | ||||||
| 
 | 
 | ||||||
|     # wait for a final context result by collecting (but |     # wait for a final context result by collecting (but | ||||||
|     # basically ignoring) any bi-dir-stream msgs still in transit |     # basically ignoring) any bi-dir-stream msgs still in transit | ||||||
|  | @ -562,13 +592,14 @@ async def drain_to_final_msg( | ||||||
|     result_msg: Return|Error|None = None |     result_msg: Return|Error|None = None | ||||||
|     while not ( |     while not ( | ||||||
|         ctx.maybe_error |         ctx.maybe_error | ||||||
|         and not ctx._final_result_is_set() |         and | ||||||
|  |         not ctx._final_result_is_set() | ||||||
|     ): |     ): | ||||||
|         try: |         try: | ||||||
|             # receive all msgs, scanning for either a final result |             # receive all msgs, scanning for either a final result | ||||||
|             # or error; the underlying call should never raise any |             # or error; the underlying call should never raise any | ||||||
|             # remote error directly! |             # remote error directly! | ||||||
|             msg, pld = await ctx._pld_rx.recv_msg_w_pld( |             msg, pld = await ctx._pld_rx.recv_msg( | ||||||
|                 ipc=ctx, |                 ipc=ctx, | ||||||
|                 expect_msg=Return, |                 expect_msg=Return, | ||||||
|                 raise_error=False, |                 raise_error=False, | ||||||
|  | @ -584,7 +615,7 @@ async def drain_to_final_msg( | ||||||
|             # |             # | ||||||
|             # -[ ] make sure pause points work here for REPLing |             # -[ ] make sure pause points work here for REPLing | ||||||
|             #   the runtime itself; i.e. ensure there's no hangs! |             #   the runtime itself; i.e. ensure there's no hangs! | ||||||
|             # |_from tractor.devx._debug import pause |             # |_from tractor.devx.debug import pause | ||||||
|             #   await pause() |             #   await pause() | ||||||
| 
 | 
 | ||||||
|         # NOTE: we get here if the far end was |         # NOTE: we get here if the far end was | ||||||
|  | @ -605,7 +636,8 @@ async def drain_to_final_msg( | ||||||
|                     (local_cs := rent_n.cancel_scope).cancel_called |                     (local_cs := rent_n.cancel_scope).cancel_called | ||||||
|                 ): |                 ): | ||||||
|                     log.cancel( |                     log.cancel( | ||||||
|                         'RPC-ctx cancelled by local-parent scope during drain!\n\n' |                         f'RPC-ctx cancelled by local-parent scope during drain!\n' | ||||||
|  |                         f'\n' | ||||||
|                         f'c}}>\n' |                         f'c}}>\n' | ||||||
|                         f' |_{rent_n}\n' |                         f' |_{rent_n}\n' | ||||||
|                         f'   |_.cancel_scope = {local_cs}\n' |                         f'   |_.cancel_scope = {local_cs}\n' | ||||||
|  | @ -615,6 +647,11 @@ async def drain_to_final_msg( | ||||||
|                     ) |                     ) | ||||||
|                     __tracebackhide__: bool = False |                     __tracebackhide__: bool = False | ||||||
| 
 | 
 | ||||||
|  |             else: | ||||||
|  |                 log.cancel( | ||||||
|  |                     f'IPC ctx cancelled externally during result drain ?\n' | ||||||
|  |                     f'{ctx}' | ||||||
|  |                 ) | ||||||
|             # CASE 2: mask the local cancelled-error(s) |             # CASE 2: mask the local cancelled-error(s) | ||||||
|             # only when we are sure the remote error is |             # only when we are sure the remote error is | ||||||
|             # the source cause of this local task's |             # the source cause of this local task's | ||||||
|  | @ -634,7 +671,8 @@ async def drain_to_final_msg( | ||||||
|             # final result arrived! |             # final result arrived! | ||||||
|             case Return(): |             case Return(): | ||||||
|                 log.runtime( |                 log.runtime( | ||||||
|                     'Context delivered final draining msg:\n' |                     f'Context delivered final draining msg\n' | ||||||
|  |                     f'\n' | ||||||
|                     f'{pretty_struct.pformat(msg)}' |                     f'{pretty_struct.pformat(msg)}' | ||||||
|                 ) |                 ) | ||||||
|                 ctx._result: Any = pld |                 ctx._result: Any = pld | ||||||
|  | @ -646,27 +684,36 @@ async def drain_to_final_msg( | ||||||
|             case Yield(): |             case Yield(): | ||||||
|                 pre_result_drained.append(msg) |                 pre_result_drained.append(msg) | ||||||
|                 if ( |                 if ( | ||||||
|                     (ctx._stream.closed |                     not parent_never_opened_stream | ||||||
|                      and (reason := 'stream was already closed') |                     and ( | ||||||
|                     ) |                         (ctx._stream.closed | ||||||
|                     or (ctx.cancel_acked |                          and | ||||||
|                         and (reason := 'ctx cancelled other side') |                          (reason := 'stream was already closed') | ||||||
|                     ) |                         ) or | ||||||
|                     or (ctx._cancel_called |                         (ctx.cancel_acked | ||||||
|                         and (reason := 'ctx called `.cancel()`') |                             and | ||||||
|                     ) |                             (reason := 'ctx cancelled other side') | ||||||
|                     or (len(pre_result_drained) > msg_limit |                         ) | ||||||
|                         and (reason := f'"yield" limit={msg_limit}') |                         or (ctx._cancel_called | ||||||
|  |                             and | ||||||
|  |                             (reason := 'ctx called `.cancel()`') | ||||||
|  |                         ) | ||||||
|  |                         or (len(pre_result_drained) > msg_limit | ||||||
|  |                             and | ||||||
|  |                             (reason := f'"yield" limit={msg_limit}') | ||||||
|  |                         ) | ||||||
|                     ) |                     ) | ||||||
|                 ): |                 ): | ||||||
|                     log.cancel( |                     log.cancel( | ||||||
|                         'Cancelling `MsgStream` drain since ' |                         'Cancelling `MsgStream` drain since ' | ||||||
|                         f'{reason}\n\n' |                         f'{reason}\n' | ||||||
|  |                         f'\n' | ||||||
|                         f'<= {ctx.chan.uid}\n' |                         f'<= {ctx.chan.uid}\n' | ||||||
|                         f'  |_{ctx._nsf}()\n\n' |                         f'  |_{ctx._nsf}()\n' | ||||||
|  |                         f'\n' | ||||||
|                         f'=> {ctx._task}\n' |                         f'=> {ctx._task}\n' | ||||||
|                         f'  |_{ctx._stream}\n\n' |                         f'  |_{ctx._stream}\n' | ||||||
| 
 |                         f'\n' | ||||||
|                         f'{pretty_struct.pformat(msg)}\n' |                         f'{pretty_struct.pformat(msg)}\n' | ||||||
|                     ) |                     ) | ||||||
|                     break |                     break | ||||||
|  | @ -674,7 +721,7 @@ async def drain_to_final_msg( | ||||||
|                 # drain up to the `msg_limit` hoping to get |                 # drain up to the `msg_limit` hoping to get | ||||||
|                 # a final result or error/ctxc. |                 # a final result or error/ctxc. | ||||||
|                 else: |                 else: | ||||||
|                     log.warning( |                     report: str = ( | ||||||
|                         'Ignoring "yield" msg during `ctx.result()` drain..\n' |                         'Ignoring "yield" msg during `ctx.result()` drain..\n' | ||||||
|                         f'<= {ctx.chan.uid}\n' |                         f'<= {ctx.chan.uid}\n' | ||||||
|                         f'  |_{ctx._nsf}()\n\n' |                         f'  |_{ctx._nsf}()\n\n' | ||||||
|  | @ -683,6 +730,14 @@ async def drain_to_final_msg( | ||||||
| 
 | 
 | ||||||
|                         f'{pretty_struct.pformat(msg)}\n' |                         f'{pretty_struct.pformat(msg)}\n' | ||||||
|                     ) |                     ) | ||||||
|  |                     if parent_never_opened_stream: | ||||||
|  |                         report = ( | ||||||
|  |                             f'IPC ctx never opened stream on {ctx.side!r}-side!\n' | ||||||
|  |                             f'\n' | ||||||
|  |                             # f'{ctx}\n' | ||||||
|  |                         ) + report | ||||||
|  | 
 | ||||||
|  |                     log.warning(report) | ||||||
|                     continue |                     continue | ||||||
| 
 | 
 | ||||||
|             # stream terminated, but no result yet.. |             # stream terminated, but no result yet.. | ||||||
|  | @ -695,7 +750,8 @@ async def drain_to_final_msg( | ||||||
|             case Stop(): |             case Stop(): | ||||||
|                 pre_result_drained.append(msg) |                 pre_result_drained.append(msg) | ||||||
|                 log.runtime(  # normal/expected shutdown transaction |                 log.runtime(  # normal/expected shutdown transaction | ||||||
|                     'Remote stream terminated due to "stop" msg:\n\n' |                     f'Remote stream terminated due to "stop" msg\n' | ||||||
|  |                     f'\n' | ||||||
|                     f'{pretty_struct.pformat(msg)}\n' |                     f'{pretty_struct.pformat(msg)}\n' | ||||||
|                 ) |                 ) | ||||||
|                 continue |                 continue | ||||||
|  | @ -770,10 +826,12 @@ async def drain_to_final_msg( | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         log.cancel( |         log.cancel( | ||||||
|             'Skipping `MsgStream` drain since final outcome is set\n\n' |             f'Skipping `MsgStream` drain since final outcome is set\n' | ||||||
|  |             f'\n' | ||||||
|             f'{ctx.outcome}\n' |             f'{ctx.outcome}\n' | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |     __tracebackhide__: bool = hide_tb | ||||||
|     return ( |     return ( | ||||||
|         result_msg, |         result_msg, | ||||||
|         pre_result_drained, |         pre_result_drained, | ||||||
|  |  | ||||||
|  | @ -20,6 +20,7 @@ Prettified version of `msgspec.Struct` for easier console grokin. | ||||||
| ''' | ''' | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
| from collections import UserList | from collections import UserList | ||||||
|  | import textwrap | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, |     Any, | ||||||
|     Iterator, |     Iterator, | ||||||
|  | @ -105,27 +106,11 @@ def iter_fields(struct: Struct) -> Iterator[ | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def pformat( | def iter_struct_ppfmt_lines( | ||||||
|     struct: Struct, |     struct: Struct, | ||||||
|     field_indent: int = 2, |     field_indent: int = 0, | ||||||
|     indent: int = 0, | ) -> Iterator[tuple[str, str]]: | ||||||
| 
 | 
 | ||||||
| ) -> str: |  | ||||||
|     ''' |  | ||||||
|     Recursion-safe `pprint.pformat()` style formatting of |  | ||||||
|     a `msgspec.Struct` for sane reading by a human using a REPL. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     # global whitespace indent |  | ||||||
|     ws: str = ' '*indent |  | ||||||
| 
 |  | ||||||
|     # field whitespace indent |  | ||||||
|     field_ws: str = ' '*(field_indent + indent) |  | ||||||
| 
 |  | ||||||
|     # qtn: str = ws + struct.__class__.__qualname__ |  | ||||||
|     qtn: str = struct.__class__.__qualname__ |  | ||||||
| 
 |  | ||||||
|     obj_str: str = ''  # accumulator |  | ||||||
|     fi: structs.FieldInfo |     fi: structs.FieldInfo | ||||||
|     k: str |     k: str | ||||||
|     v: Any |     v: Any | ||||||
|  | @ -135,15 +120,18 @@ def pformat( | ||||||
|         # ..]` over .__name__ == `Literal` but still get only the |         # ..]` over .__name__ == `Literal` but still get only the | ||||||
|         # latter for simple types like `str | int | None` etc..? |         # latter for simple types like `str | int | None` etc..? | ||||||
|         ft: type = fi.type |         ft: type = fi.type | ||||||
|         typ_name: str = getattr(ft, '__name__', str(ft)) |         typ_name: str = getattr( | ||||||
|  |             ft, | ||||||
|  |             '__name__', | ||||||
|  |             str(ft) | ||||||
|  |         ).replace(' ', '') | ||||||
| 
 | 
 | ||||||
|         # recurse to get sub-struct's `.pformat()` output Bo |         # recurse to get sub-struct's `.pformat()` output Bo | ||||||
|         if isinstance(v, Struct): |         if isinstance(v, Struct): | ||||||
|             val_str: str =  v.pformat( |             yield from iter_struct_ppfmt_lines( | ||||||
|                 indent=field_indent + indent, |                 struct=v, | ||||||
|                 field_indent=indent + field_indent, |                 field_indent=field_indent+field_indent, | ||||||
|             ) |             ) | ||||||
| 
 |  | ||||||
|         else: |         else: | ||||||
|             val_str: str = repr(v) |             val_str: str = repr(v) | ||||||
| 
 | 
 | ||||||
|  | @ -161,8 +149,39 @@ def pformat( | ||||||
|                 # raise |                 # raise | ||||||
|                 # return _Struct.__repr__(struct) |                 # return _Struct.__repr__(struct) | ||||||
| 
 | 
 | ||||||
|         # TODO: LOLOL use `textwrap.indent()` instead dawwwwwg! |         yield ( | ||||||
|         obj_str += (field_ws + f'{k}: {typ_name} = {val_str},\n') |             ' '*field_indent,  # indented ws prefix | ||||||
|  |             f'{k}: {typ_name} = {val_str},',  # field's repr line content | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def pformat( | ||||||
|  |     struct: Struct, | ||||||
|  |     field_indent: int = 2, | ||||||
|  |     indent: int = 0, | ||||||
|  | ) -> str: | ||||||
|  |     ''' | ||||||
|  |     Recursion-safe `pprint.pformat()` style formatting of | ||||||
|  |     a `msgspec.Struct` for sane reading by a human using a REPL. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     obj_str: str = ''  # accumulator | ||||||
|  |     for prefix, field_repr, in iter_struct_ppfmt_lines( | ||||||
|  |         struct, | ||||||
|  |         field_indent=field_indent, | ||||||
|  |     ): | ||||||
|  |         obj_str += f'{prefix}{field_repr}\n' | ||||||
|  | 
 | ||||||
|  |     # global whitespace indent | ||||||
|  |     ws: str = ' '*indent | ||||||
|  |     if indent: | ||||||
|  |         obj_str: str = textwrap.indent( | ||||||
|  |             text=obj_str, | ||||||
|  |             prefix=ws, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # qtn: str = ws + struct.__class__.__qualname__ | ||||||
|  |     qtn: str = struct.__class__.__qualname__ | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|         f'{qtn}(\n' |         f'{qtn}(\n' | ||||||
|  |  | ||||||
|  | @ -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,49 @@ 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? | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def uid(self) -> tuple[str, str]: | ||||||
|  |         ''' | ||||||
|  |         Legacy actor "unique-id" pair format. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         return ( | ||||||
|  |             self.name, | ||||||
|  |             self.uuid, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     def reprol( | ||||||
|  |         self, | ||||||
|  |         sin_uuid: bool = True, | ||||||
|  |     ) -> str: | ||||||
|  |         if not sin_uuid: | ||||||
|  |             return ( | ||||||
|  |                 f'{self.name}[{self.uuid[:6]}]@{self.pid!r}' | ||||||
|  |             ) | ||||||
|  |         return ( | ||||||
|  |             f'{self.name}@{self.pid!r}' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # mk hashable via `.uuid` | ||||||
|  |     def __hash__(self) -> int: | ||||||
|  |         return hash(self.uuid) | ||||||
|  | 
 | ||||||
|  |     def __eq__(self, other: Aid) -> bool: | ||||||
|  |         return self.uuid == other.uuid | ||||||
|  | 
 | ||||||
|  |     # use pretty fmt since often repr-ed for console/log | ||||||
|  |     __repr__ = pretty_struct.Struct.__repr__ | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class SpawnSpec( | class SpawnSpec( | ||||||
|  | @ -161,14 +203,15 @@ class SpawnSpec( | ||||||
|     # a hard `Struct` def for all of these fields! |     # a hard `Struct` def for all of these fields! | ||||||
|     _parent_main_data: dict |     _parent_main_data: dict | ||||||
|     _runtime_vars: dict[str, Any] |     _runtime_vars: dict[str, Any] | ||||||
|  |     # ^NOTE see `._state._runtime_vars: dict` | ||||||
| 
 | 
 | ||||||
|     # module import capability |     # module import capability | ||||||
|     enable_modules: dict[str, str] |     enable_modules: dict[str, str] | ||||||
| 
 | 
 | ||||||
|     # 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? | ||||||
|  | @ -599,15 +642,15 @@ def mk_msg_spec( | ||||||
|         Msg[payload_type_union], |         Msg[payload_type_union], | ||||||
|         Generic[PayloadT], |         Generic[PayloadT], | ||||||
|     ) |     ) | ||||||
|     defstruct_bases: tuple = ( |     # defstruct_bases: tuple = ( | ||||||
|         Msg, # [payload_type_union], |     #     Msg, # [payload_type_union], | ||||||
|         # Generic[PayloadT], |     #     # Generic[PayloadT], | ||||||
|         # ^-XXX-^: not allowed? lul.. |     #     # ^-XXX-^: not allowed? lul.. | ||||||
|     ) |     # ) | ||||||
|     ipc_msg_types: list[Msg] = [] |     ipc_msg_types: list[Msg] = [] | ||||||
| 
 | 
 | ||||||
|     idx_msg_types: list[Msg] = [] |     idx_msg_types: list[Msg] = [] | ||||||
|     defs_msg_types: list[Msg] = [] |     # defs_msg_types: list[Msg] = [] | ||||||
|     nc_msg_types: list[Msg] = [] |     nc_msg_types: list[Msg] = [] | ||||||
| 
 | 
 | ||||||
|     for msgtype in __msg_types__: |     for msgtype in __msg_types__: | ||||||
|  | @ -625,7 +668,7 @@ def mk_msg_spec( | ||||||
|         # TODO: wait why do we need the dynamic version here? |         # TODO: wait why do we need the dynamic version here? | ||||||
|         # XXX ANSWER XXX -> BC INHERITANCE.. don't work w generics.. |         # XXX ANSWER XXX -> BC INHERITANCE.. don't work w generics.. | ||||||
|         # |         # | ||||||
|         # NOTE previously bc msgtypes WERE NOT inheritting |         # NOTE previously bc msgtypes WERE NOT inheriting | ||||||
|         # directly the `Generic[PayloadT]` type, the manual method |         # directly the `Generic[PayloadT]` type, the manual method | ||||||
|         # of generic-paraming with `.__class_getitem__()` wasn't |         # of generic-paraming with `.__class_getitem__()` wasn't | ||||||
|         # working.. |         # working.. | ||||||
|  | @ -662,38 +705,35 @@ def mk_msg_spec( | ||||||
| 
 | 
 | ||||||
|         # with `msgspec.structs.defstruct` |         # with `msgspec.structs.defstruct` | ||||||
|         # XXX ALSO DOESN'T WORK |         # XXX ALSO DOESN'T WORK | ||||||
|         defstruct_msgtype = defstruct( |         # defstruct_msgtype = defstruct( | ||||||
|             name=msgtype.__name__, |         #     name=msgtype.__name__, | ||||||
|             fields=[ |         #     fields=[ | ||||||
|                 ('cid', str), |         #         ('cid', str), | ||||||
| 
 | 
 | ||||||
|                 # XXX doesn't seem to work.. |         #         # XXX doesn't seem to work.. | ||||||
|                 # ('pld', PayloadT), |         #         # ('pld', PayloadT), | ||||||
| 
 |  | ||||||
|                 ('pld', payload_type_union), |  | ||||||
|             ], |  | ||||||
|             bases=defstruct_bases, |  | ||||||
|         ) |  | ||||||
|         defs_msg_types.append(defstruct_msgtype) |  | ||||||
| 
 | 
 | ||||||
|  |         #         ('pld', payload_type_union), | ||||||
|  |         #     ], | ||||||
|  |         #     bases=defstruct_bases, | ||||||
|  |         # ) | ||||||
|  |         # defs_msg_types.append(defstruct_msgtype) | ||||||
|         # assert index_paramed_msg_type == manual_paramed_msg_subtype |         # assert index_paramed_msg_type == manual_paramed_msg_subtype | ||||||
| 
 |  | ||||||
|         # paramed_msg_type = manual_paramed_msg_subtype |         # paramed_msg_type = manual_paramed_msg_subtype | ||||||
| 
 |  | ||||||
|         # ipc_payload_msgs_type_union |= index_paramed_msg_type |         # ipc_payload_msgs_type_union |= index_paramed_msg_type | ||||||
| 
 | 
 | ||||||
|     idx_spec: Union[Type[Msg]] = Union[*idx_msg_types] |     idx_spec: Union[Type[Msg]] = Union[*idx_msg_types] | ||||||
|     def_spec: Union[Type[Msg]] = Union[*defs_msg_types] |     # def_spec: Union[Type[Msg]] = Union[*defs_msg_types] | ||||||
|     nc_spec: Union[Type[Msg]] = Union[*nc_msg_types] |     nc_spec: Union[Type[Msg]] = Union[*nc_msg_types] | ||||||
| 
 | 
 | ||||||
|     specs: dict[str, Union[Type[Msg]]] = { |     specs: dict[str, Union[Type[Msg]]] = { | ||||||
|         'indexed_generics': idx_spec, |         'indexed_generics': idx_spec, | ||||||
|         'defstruct': def_spec, |         # 'defstruct': def_spec, | ||||||
|         'types_new_class': nc_spec, |         'types_new_class': nc_spec, | ||||||
|     } |     } | ||||||
|     msgtypes_table: dict[str, list[Msg]] = { |     msgtypes_table: dict[str, list[Msg]] = { | ||||||
|         'indexed_generics': idx_msg_types, |         'indexed_generics': idx_msg_types, | ||||||
|         'defstruct': defs_msg_types, |         # 'defstruct': defs_msg_types, | ||||||
|         'types_new_class': nc_msg_types, |         'types_new_class': nc_msg_types, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue