Compare commits

..

No commits in common. "e89fe03da7ae129acadae23b8d28b4b33b920ddb" and "683476cc96a16e951d7247f48fdcea0e0c967189" have entirely different histories.

35 changed files with 310 additions and 1297 deletions

View File

@ -74,22 +74,16 @@ jobs:
# run: mypy tractor/ --ignore-missing-imports --show-traceback # run: mypy tractor/ --ignore-missing-imports --show-traceback
testing: testing-linux:
name: '${{ matrix.os }} Python${{ matrix.python-version }} - spawn_backend=${{ matrix.spawn_backend }}' name: '${{ matrix.os }} Python ${{ matrix.python }} - ${{ matrix.spawn_backend }}'
timeout-minutes: 16 timeout-minutes: 10
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ os: [ubuntu-latest]
ubuntu-latest, python-version: ['3.13']
macos-latest,
]
python-version: [
'3.13',
# '3.14',
]
spawn_backend: [ spawn_backend: [
'trio', 'trio',
# 'mp_spawn', # 'mp_spawn',
@ -97,6 +91,7 @@ jobs:
] ]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: 'Install uv + py-${{ matrix.python-version }}' - name: 'Install uv + py-${{ matrix.python-version }}'

View File

@ -641,15 +641,13 @@ Help us push toward the future of distributed `Python`.
- Typed capability-based (dialog) protocols ( see `#196 - Typed capability-based (dialog) protocols ( see `#196
<https://github.com/goodboy/tractor/issues/196>`_ with draft work <https://github.com/goodboy/tractor/issues/196>`_ with draft work
started in `#311 <https://github.com/goodboy/tractor/pull/311>`_) started in `#311 <https://github.com/goodboy/tractor/pull/311>`_)
- **macOS is now officially supported** and tested in CI - We **recently disabled CI-testing on windows** and need help getting
alongside Linux! it running again! (see `#327
- We **recently disabled CI-testing on windows** and need <https://github.com/goodboy/tractor/pull/327>`_). **We do have windows
help getting it running again! (see `#327 support** (and have for quite a while) but since no active hacker
<https://github.com/goodboy/tractor/pull/327>`_). **We do exists in the user-base to help test on that OS, for now we're not
have windows support** (and have for quite a while) but actively maintaining testing due to the added hassle and general
since no active hacker exists in the user-base to help latency..
test on that OS, for now we're not actively maintaining
testing due to the added hassle and general latency..
Feel like saying hi? Feel like saying hi?

View File

@ -18,14 +18,15 @@ async def aio_sleep_forever():
async def bp_then_error( async def bp_then_error(
chan: to_asyncio.LinkedTaskChannel, to_trio: trio.MemorySendChannel,
from_trio: asyncio.Queue,
raise_after_bp: bool = True, raise_after_bp: bool = True,
) -> None: ) -> None:
# sync with `trio`-side (caller) task # sync with `trio`-side (caller) task
chan.started_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

View File

@ -3,7 +3,6 @@ Verify we can dump a `stackscope` tree on a hang.
''' '''
import os import os
import platform
import signal import signal
import trio import trio
@ -32,26 +31,13 @@ async def main(
from_test: bool = False, from_test: bool = False,
) -> None: ) -> None:
if platform.system() != 'Darwin':
tpt = 'uds'
else:
# XXX, precisely we can't use pytest's tmp-path generation
# for tests.. apparently because:
#
# > The OSError: AF_UNIX path too long in macOS Python occurs
# > because the path to the Unix domain socket exceeds the
# > operating system's maximum path length limit (around 104
#
# WHICH IS just, wtf hillarious XD
tpt = 'tcp'
async with ( async with (
tractor.open_nursery( tractor.open_nursery(
debug_mode=True, debug_mode=True,
enable_stack_on_sig=True, enable_stack_on_sig=True,
# maybe_enable_greenback=False, # maybe_enable_greenback=False,
loglevel='devx', loglevel='devx',
enable_transports=[tpt], enable_transports=['uds'],
) as an, ) as an,
): ):
ptl: tractor.Portal = await an.start_actor( ptl: tractor.Portal = await an.start_actor(

View File

@ -1,5 +1,3 @@
import platform
import tractor import tractor
import trio import trio
@ -36,22 +34,9 @@ async def just_bp(
async def main(): async def main():
if platform.system() != 'Darwin':
tpt = 'uds'
else:
# XXX, precisely we can't use pytest's tmp-path generation
# for tests.. apparently because:
#
# > The OSError: AF_UNIX path too long in macOS Python occurs
# > because the path to the Unix domain socket exceeds the
# > operating system's maximum path length limit (around 104
#
# WHICH IS just, wtf hillarious XD
tpt = 'tcp'
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
enable_transports=[tpt], enable_transports=['uds'],
loglevel='devx', loglevel='devx',
) as n: ) as n:
p = await n.start_actor( p = await n.start_actor(

View File

@ -90,7 +90,7 @@ async def main() -> list[int]:
# yes, a nursery which spawns `trio`-"actors" B) # yes, a nursery which spawns `trio`-"actors" B)
an: ActorNursery an: ActorNursery
async with tractor.open_nursery( async with tractor.open_nursery(
loglevel='error', loglevel='cancel',
# debug_mode=True, # debug_mode=True,
) as an: ) as an:
@ -118,10 +118,8 @@ async def main() -> list[int]:
cancelled: bool = await portal.cancel_actor() cancelled: bool = await portal.cancel_actor()
assert cancelled assert cancelled
print( print(f"STREAM TIME = {time.time() - start}")
f"STREAM TIME = {time.time() - start}\n" print(f"STREAM + SPAWN TIME = {time.time() - pre_start}")
f"STREAM + SPAWN TIME = {time.time() - pre_start}\n"
)
assert result_stream == list(range(seed)) assert result_stream == list(range(seed))
return result_stream return result_stream

View File

@ -11,17 +11,21 @@ import tractor
async def aio_echo_server( async def aio_echo_server(
chan: tractor.to_asyncio.LinkedTaskChannel, to_trio: trio.MemorySendChannel,
from_trio: asyncio.Queue,
) -> None: ) -> None:
# a first message must be sent **from** this ``asyncio`` # a first message must be sent **from** this ``asyncio``
# task or the ``trio`` side will never unblock from # task or the ``trio`` side will never unblock from
# ``tractor.to_asyncio.open_channel_from():`` # ``tractor.to_asyncio.open_channel_from():``
chan.started_nowait('start') to_trio.send_nowait('start')
# XXX: this uses an ``from_trio: asyncio.Queue`` currently but we
# should probably offer something better.
while True: while True:
# echo the msg back # echo the msg back
chan.send_nowait(await chan.get()) to_trio.send_nowait(await from_trio.get())
await asyncio.sleep(0) await asyncio.sleep(0)

View File

@ -24,7 +24,6 @@ keywords = [
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Operating System :: POSIX :: Linux", "Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Framework :: Trio", "Framework :: Trio",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: CPython",
@ -49,7 +48,6 @@ dependencies = [
"msgspec>=0.19.0", "msgspec>=0.19.0",
"cffi>=1.17.1", "cffi>=1.17.1",
"bidict>=0.23.1", "bidict>=0.23.1",
"platformdirs>=4.4.0",
] ]
# ------ project ------ # ------ project ------

View File

@ -11,7 +11,6 @@ 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,
@ -23,7 +22,6 @@ pytest_plugins: list[str] = [
'tractor._testing.pytest', 'tractor._testing.pytest',
] ]
_non_linux: bool = platform.system() != 'Linux'
# 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':
@ -46,10 +44,6 @@ no_windows = pytest.mark.skipif(
platform.system() == "Windows", platform.system() == "Windows",
reason="Test is unsupported on windows", reason="Test is unsupported on windows",
) )
no_macos = pytest.mark.skipif(
platform.system() == "Darwin",
reason="Test is unsupported on MacOS",
)
def pytest_addoption( def pytest_addoption(
@ -67,7 +61,7 @@ def pytest_addoption(
@pytest.fixture(scope='session', autouse=True) @pytest.fixture(scope='session', autouse=True)
def loglevel(request) -> str: def loglevel(request):
import tractor 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
@ -75,46 +69,11 @@ def loglevel(request) -> str:
level=level, level=level,
name='tractor', # <- enable root logger name='tractor', # <- enable root logger
) )
log.info( log.info(f'Test-harness logging level: {level}\n')
f'Test-harness set runtime loglevel: {level!r}\n'
)
yield level yield level
tractor.log._default_loglevel = orig tractor.log._default_loglevel = orig
@pytest.fixture(scope='function')
def test_log(
request,
loglevel: str,
) -> tractor.log.StackLevelAdapter:
'''
Deliver a per test-module-fn logger instance for reporting from
within actual test bodies/fixtures.
For example this can be handy to report certain error cases from
exception handlers using `test_log.exception()`.
'''
modname: str = request.function.__module__
log = tractor.log.get_logger(
name=modname, # <- enable root logger
# pkg_name='tests',
)
_log = tractor.log.get_console_log(
level=loglevel,
logger=log,
name=modname,
# pkg_name='tests',
)
_log.debug(
f'In-test-logging requested\n'
f'test_log.name: {log.name!r}\n'
f'level: {loglevel!r}\n'
)
yield _log
_ci_env: bool = os.environ.get('CI', False) _ci_env: bool = os.environ.get('CI', False)
@ -151,7 +110,6 @@ def daemon(
testdir: pytest.Pytester, testdir: pytest.Pytester,
reg_addr: tuple[str, int], reg_addr: tuple[str, int],
tpt_proto: str, tpt_proto: str,
ci_env: bool,
) -> subprocess.Popen: ) -> subprocess.Popen:
''' '''
@ -189,25 +147,13 @@ def daemon(
**kwargs, **kwargs,
) )
# TODO! we should poll for the registry socket-bind to take place
# and only once that's done yield to the requester!
# -[ ] TCP: use the `._root.open_root_actor()`::`ping_tpt_socket()`
# closure!
# -[ ] UDS: can we do something similar for 'pinging" the
# file-socket?
#
global _PROC_SPAWN_WAIT
# UDS sockets are **really** fast to bind()/listen()/connect() # UDS sockets are **really** fast to bind()/listen()/connect()
# so it's often required that we delay a bit more starting # so it's often required that we delay a bit more starting
# the first actor-tree.. # the first actor-tree..
if tpt_proto == 'uds': if tpt_proto == 'uds':
global _PROC_SPAWN_WAIT
_PROC_SPAWN_WAIT = 0.6 _PROC_SPAWN_WAIT = 0.6
if _non_linux and ci_env:
_PROC_SPAWN_WAIT += 1
# XXX, allow time for the sub-py-proc to boot up.
# !TODO, see ping-polling ideas above!
time.sleep(_PROC_SPAWN_WAIT) time.sleep(_PROC_SPAWN_WAIT)
assert not proc.returncode assert not proc.returncode
@ -217,30 +163,18 @@ def daemon(
# XXX! yeah.. just be reaaal careful with this bc sometimes it # XXX! yeah.. just be reaaal careful with this bc sometimes it
# can lock up on the `_io.BufferedReader` and hang.. # can lock up on the `_io.BufferedReader` and hang..
stderr: str = proc.stderr.read().decode() stderr: str = proc.stderr.read().decode()
stdout: str = proc.stdout.read().decode() if stderr:
if (
stderr
or
stdout
):
print( print(
f'Daemon actor tree produced output:\n' f'Daemon actor tree produced STDERR:\n'
f'{proc.args}\n' f'{proc.args}\n'
f'\n' f'\n'
f'stderr: {stderr!r}\n' f'{stderr}\n'
f'stdout: {stdout!r}\n'
) )
if proc.returncode != -2:
if (rc := proc.returncode) != -2: raise RuntimeError(
msg: str = ( 'Daemon actor tree failed !?\n'
f'Daemon actor tree was not cancelled !?\n' f'{proc.args}\n'
f'proc.args: {proc.args!r}\n'
f'proc.returncode: {rc!r}\n'
) )
if rc < 0:
raise RuntimeError(msg)
log.error(msg)
# @pytest.fixture(autouse=True) # @pytest.fixture(autouse=True)

View File

@ -3,9 +3,8 @@
''' '''
from __future__ import annotations from __future__ import annotations
import platform
import signal
import time import time
import signal
from typing import ( from typing import (
Callable, Callable,
TYPE_CHECKING, TYPE_CHECKING,
@ -34,17 +33,6 @@ if TYPE_CHECKING:
from pexpect import pty_spawn from pexpect import pty_spawn
_non_linux: bool = platform.system() != 'Linux'
def pytest_configure(config):
# register custom marks to avoid warnings see,
# https://docs.pytest.org/en/stable/how-to/writing_plugins.html#registering-custom-markers
config.addinivalue_line(
'markers',
'ctlcs_bish: test will (likely) not behave under SIGINT..'
)
# a fn that sub-instantiates a `pexpect.spawn()` # a fn that sub-instantiates a `pexpect.spawn()`
# and returns it. # and returns it.
type PexpectSpawner = Callable[ type PexpectSpawner = Callable[
@ -80,10 +68,7 @@ def spawn(
''' '''
import os import os
# disable colored tbs
os.environ['PYTHON_COLORS'] = '0' os.environ['PYTHON_COLORS'] = '0'
# disable all ANSI color output
# os.environ['NO_COLOR'] = '1'
spawned: PexpectSpawner|None = None spawned: PexpectSpawner|None = None
@ -98,10 +83,7 @@ def spawn(
cmd, cmd,
**mkcmd_kwargs, **mkcmd_kwargs,
), ),
expect_timeout=( expect_timeout=3,
10 if _non_linux and _ci_env
else 3
),
# preexec_fn=unset_colors, # preexec_fn=unset_colors,
# ^TODO? get `pytest` core to expose underlying # ^TODO? get `pytest` core to expose underlying
# `pexpect.spawn()` stuff? # `pexpect.spawn()` stuff?
@ -164,8 +146,6 @@ def ctlc(
mark.name == 'ctlcs_bish' mark.name == 'ctlcs_bish'
and and
use_ctlc use_ctlc
and
all(mark.args)
): ):
pytest.skip( pytest.skip(
f'Test {node} prolly uses something from the stdlib (namely `asyncio`..)\n' f'Test {node} prolly uses something from the stdlib (namely `asyncio`..)\n'
@ -271,13 +251,12 @@ def assert_before(
err_on_false=True, err_on_false=True,
**kwargs **kwargs
) )
return str(child.before.decode())
def do_ctlc( def do_ctlc(
child, child,
count: int = 3, count: int = 3,
delay: float|None = None, delay: float = 0.1,
patt: str|None = None, patt: str|None = None,
# expect repl UX to reprint the prompt after every # expect repl UX to reprint the prompt after every
@ -289,7 +268,6 @@ def do_ctlc(
) -> str|None: ) -> str|None:
before: str|None = None before: str|None = None
delay = delay or 0.1
# make sure ctl-c sends don't do anything but repeat output # make sure ctl-c sends don't do anything but repeat output
for _ in range(count): for _ in range(count):
@ -300,10 +278,7 @@ def do_ctlc(
# if you run this test manually it works just fine.. # if you run this test manually it works just fine..
if expect_prompt: if expect_prompt:
time.sleep(delay) time.sleep(delay)
child.expect( child.expect(PROMPT)
PROMPT,
timeout=(child.timeout * 2) if _ci_env else child.timeout,
)
before = str(child.before.decode()) before = str(child.before.decode())
time.sleep(delay) time.sleep(delay)

View File

@ -37,9 +37,6 @@ from .conftest import (
in_prompt_msg, in_prompt_msg,
assert_before, assert_before,
) )
from ..conftest import (
_ci_env,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from ..conftest import PexpectSpawner from ..conftest import PexpectSpawner
@ -54,14 +51,13 @@ if TYPE_CHECKING:
# - recurrent root errors # - recurrent root errors
_non_linux: bool = platform.system() != 'Linux'
if platform.system() == 'Windows': if platform.system() == 'Windows':
pytest.skip( pytest.skip(
'Debugger tests have no windows support (yet)', 'Debugger tests have no windows support (yet)',
allow_module_level=True, allow_module_level=True,
) )
# TODO: was trying to this xfail style but some weird bug i see in CI # TODO: was trying to this xfail style but some weird bug i see in CI
# that's happening at collect time.. pretty soon gonna dump actions i'm # that's happening at collect time.. pretty soon gonna dump actions i'm
# thinkin... # thinkin...
@ -197,11 +193,6 @@ def test_root_actor_bp_forever(
child.expect(EOF) child.expect(EOF)
# skip on non-Linux CI
@pytest.mark.ctlcs_bish(
_non_linux,
_ci_env,
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
'do_next', 'do_next',
(True, False), (True, False),
@ -267,11 +258,6 @@ def test_subactor_error(
child.expect(EOF) child.expect(EOF)
# skip on non-Linux CI
@pytest.mark.ctlcs_bish(
_non_linux,
_ci_env,
)
def test_subactor_breakpoint( def test_subactor_breakpoint(
spawn, spawn,
ctlc: bool, ctlc: bool,
@ -494,24 +480,8 @@ def test_multi_daemon_subactors(
stream. stream.
''' '''
non_linux = _non_linux
if non_linux and ctlc:
pytest.skip(
'Ctl-c + MacOS is too unreliable/racy for this test..\n'
)
# !TODO, if someone with more patience then i wants to muck
# with the timings on this please feel free to see all the
# `non_linux` branching logic i added on my first attempt
# below!
#
# my conclusion was that if i were to run the script
# manually, and thus as slowly as a human would, the test
# would and should pass as described in this test fn, however
# after fighting with it for >= 1hr. i decided more then
# likely the more extensive `linux` testing should cover most
# regressions.
child = spawn('multi_daemon_subactors') child = spawn('multi_daemon_subactors')
child.expect(PROMPT) child.expect(PROMPT)
# there can be a race for which subactor will acquire # there can be a race for which subactor will acquire
@ -541,19 +511,8 @@ def test_multi_daemon_subactors(
else: else:
raise ValueError('Neither log msg was found !?') raise ValueError('Neither log msg was found !?')
non_linux_delay: float = 0.3
if ctlc: if ctlc:
do_ctlc( do_ctlc(child)
child,
delay=(
non_linux_delay
if non_linux
else None
),
)
if non_linux:
time.sleep(1)
# NOTE: previously since we did not have clobber prevention # NOTE: previously since we did not have clobber prevention
# in the root actor this final resume could result in the debugger # in the root actor this final resume could result in the debugger
@ -584,66 +543,33 @@ def test_multi_daemon_subactors(
# assert "in use by child ('bp_forever'," in before # assert "in use by child ('bp_forever'," in before
if ctlc: if ctlc:
do_ctlc( do_ctlc(child)
child,
delay=(
non_linux_delay
if non_linux
else None
),
)
if non_linux:
time.sleep(1)
# expect another breakpoint actor entry # expect another breakpoint actor entry
child.sendline('c') child.sendline('c')
child.expect(PROMPT) child.expect(PROMPT)
try: try:
before: str = assert_before( assert_before(
child, child,
bp_forev_parts, bp_forev_parts,
) )
except AssertionError: except AssertionError:
before: str = assert_before( assert_before(
child, child,
name_error_parts, name_error_parts,
) )
else: else:
if ctlc: if ctlc:
before: str = do_ctlc( do_ctlc(child)
child,
delay=(
non_linux_delay
if non_linux
else None
),
)
if non_linux:
time.sleep(1)
# should crash with the 2nd name error (simulates # should crash with the 2nd name error (simulates
# a retry) and then the root eventually (boxed) errors # a retry) and then the root eventually (boxed) errors
# after 1 or more further bp actor entries. # after 1 or more further bp actor entries.
child.sendline('c') child.sendline('c')
try: child.expect(PROMPT)
child.expect(
PROMPT,
timeout=3,
)
except EOF:
before: str = child.before.decode()
print(
f'\n'
f'??? NEVER RXED `pdb` PROMPT ???\n'
f'\n'
f'{before}\n'
)
raise
assert_before( assert_before(
child, child,
name_error_parts, name_error_parts,
@ -763,8 +689,7 @@ def test_multi_subactors_root_errors(
@has_nested_actors @has_nested_actors
def test_multi_nested_subactors_error_through_nurseries( def test_multi_nested_subactors_error_through_nurseries(
ci_env: bool, spawn,
spawn: PexpectSpawner,
# TODO: address debugger issue for nested tree: # TODO: address debugger issue for nested tree:
# https://github.com/goodboy/tractor/issues/320 # https://github.com/goodboy/tractor/issues/320
@ -787,16 +712,7 @@ def test_multi_nested_subactors_error_through_nurseries(
for send_char in itertools.cycle(['c', 'q']): for send_char in itertools.cycle(['c', 'q']):
try: try:
child.expect( child.expect(PROMPT)
PROMPT,
timeout=(
6 if (
_non_linux
and
ci_env
) else -1
),
)
child.sendline(send_char) child.sendline(send_char)
time.sleep(0.01) time.sleep(0.01)
@ -973,11 +889,6 @@ def test_different_debug_mode_per_actor(
) )
# skip on non-Linux CI
@pytest.mark.ctlcs_bish(
_non_linux,
_ci_env,
)
def test_post_mortem_api( def test_post_mortem_api(
spawn, spawn,
ctlc: bool, ctlc: bool,
@ -1222,20 +1133,14 @@ def test_ctxep_pauses_n_maybe_ipc_breaks(
# closed so verify we see error reporting as well as # closed so verify we see error reporting as well as
# a failed crash-REPL request msg and can CTL-c our way # a failed crash-REPL request msg and can CTL-c our way
# out. # out.
# ?TODO, match depending on `tpt_proto(s)`?
# - [ ] how can we pass it into the script tho?
tpt: str = 'UDS'
if _non_linux:
tpt: str = 'TCP'
assert_before( assert_before(
child, child,
['peer IPC channel closed abruptly?', ['peer IPC channel closed abruptly?',
'another task closed this fd', 'another task closed this fd',
'Debug lock request was CANCELLED?', 'Debug lock request was CANCELLED?',
f"'Msgpack{tpt}Stream' was already closed locally?", "'MsgpackUDSStream' was already closed locally?",
f"TransportClosed: 'Msgpack{tpt}Stream' was already closed 'by peer'?", "TransportClosed: 'MsgpackUDSStream' was already closed 'by peer'?",
# ?TODO^? match depending on `tpt_proto(s)`?
] ]
# XXX races on whether these show/hit? # XXX races on whether these show/hit?

View File

@ -31,9 +31,6 @@ from .conftest import (
PROMPT, PROMPT,
_pause_msg, _pause_msg,
) )
from ..conftest import (
no_macos,
)
import pytest import pytest
from pexpect.exceptions import ( from pexpect.exceptions import (
@ -45,7 +42,6 @@ if TYPE_CHECKING:
from ..conftest import PexpectSpawner from ..conftest import PexpectSpawner
@no_macos
def test_shield_pause( def test_shield_pause(
spawn: PexpectSpawner, spawn: PexpectSpawner,
): ):
@ -61,7 +57,6 @@ def test_shield_pause(
expect( expect(
child, child,
'Yo my child hanging..?', 'Yo my child hanging..?',
timeout=3,
) )
assert_before( assert_before(
child, child,

View File

@ -1,4 +0,0 @@
'''
`tractor.msg.*` sub-sys test suite.
'''

View File

@ -1,4 +0,0 @@
'''
`tractor.msg.*` test sub-pkg conf.
'''

View File

@ -1,240 +0,0 @@
'''
Unit tests for `tractor.msg.pretty_struct`
private-field filtering in `pformat()`.
'''
import pytest
from tractor.msg.pretty_struct import (
Struct,
pformat,
iter_struct_ppfmt_lines,
)
from tractor.msg._codec import (
MsgDec,
mk_dec,
)
# ------ test struct definitions ------ #
class PublicOnly(Struct):
'''
All-public fields for baseline testing.
'''
name: str = 'alice'
age: int = 30
class PrivateOnly(Struct):
'''
Only underscore-prefixed (private) fields.
'''
_secret: str = 'hidden'
_internal: int = 99
class MixedFields(Struct):
'''
Mix of public and private fields.
'''
name: str = 'bob'
_hidden: int = 42
value: float = 3.14
_meta: str = 'internal'
class Inner(
Struct,
frozen=True,
):
'''
Frozen inner struct with a private field,
for nesting tests.
'''
x: int = 1
_secret: str = 'nope'
class Outer(Struct):
'''
Outer struct nesting an `Inner`.
'''
label: str = 'outer'
inner: Inner = Inner()
class EmptyStruct(Struct):
'''
Struct with zero fields.
'''
pass
# ------ tests ------ #
@pytest.mark.parametrize(
'struct_and_expected',
[
(
PublicOnly(),
{
'shown': ['name', 'age'],
'hidden': [],
},
),
(
MixedFields(),
{
'shown': ['name', 'value'],
'hidden': ['_hidden', '_meta'],
},
),
(
PrivateOnly(),
{
'shown': [],
'hidden': ['_secret', '_internal'],
},
),
],
ids=[
'all-public',
'mixed-pub-priv',
'all-private',
],
)
def test_field_visibility_in_pformat(
struct_and_expected: tuple[
Struct,
dict[str, list[str]],
],
):
'''
Verify `pformat()` shows public fields
and hides `_`-prefixed private fields.
'''
(
struct,
expected,
) = struct_and_expected
output: str = pformat(struct)
for field_name in expected['shown']:
assert field_name in output, (
f'{field_name!r} should appear in:\n'
f'{output}'
)
for field_name in expected['hidden']:
assert field_name not in output, (
f'{field_name!r} should NOT appear in:\n'
f'{output}'
)
def test_iter_ppfmt_lines_skips_private():
'''
Directly verify `iter_struct_ppfmt_lines()`
never yields tuples with `_`-prefixed field
names.
'''
struct = MixedFields()
lines: list[tuple[str, str]] = list(
iter_struct_ppfmt_lines(
struct,
field_indent=2,
)
)
# should have lines for public fields only
assert len(lines) == 2
for _prefix, line_content in lines:
field_name: str = (
line_content.split(':')[0].strip()
)
assert not field_name.startswith('_'), (
f'private field leaked: {field_name!r}'
)
def test_nested_struct_filters_inner_private():
'''
Verify that nested struct's private fields
are also filtered out during recursion.
'''
outer = Outer()
output: str = pformat(outer)
# outer's public field
assert 'label' in output
# inner's public field (recursed into)
assert 'x' in output
# inner's private field must be hidden
assert '_secret' not in output
def test_empty_struct_pformat():
'''
An empty struct should produce a valid
`pformat()` result with no field lines.
'''
output: str = pformat(EmptyStruct())
assert 'EmptyStruct(' in output
assert output.rstrip().endswith(')')
# no field lines => only struct header+footer
lines: list[tuple[str, str]] = list(
iter_struct_ppfmt_lines(
EmptyStruct(),
field_indent=2,
)
)
assert lines == []
def test_real_msgdec_pformat_hides_private():
'''
Verify `pformat()` on a real `MsgDec`
hides the `_dec` internal field.
NOTE: `MsgDec.__repr__` is custom and does
NOT call `pformat()`, so we call it directly.
'''
dec: MsgDec = mk_dec(spec=int)
output: str = pformat(dec)
# the private `_dec` field should be filtered
assert '_dec' not in output
# but the struct type name should be present
assert 'MsgDec(' in output
def test_pformat_repr_integration():
'''
Verify that `Struct.__repr__()` (which calls
`pformat()`) also hides private fields for
custom structs that do NOT override `__repr__`.
'''
mixed = MixedFields()
output: str = repr(mixed)
assert 'name' in output
assert 'value' in output
assert '_hidden' not in output
assert '_meta' not in output

View File

@ -1,12 +1,7 @@
''' """
Audit the simplest inter-actor bidirectional (streaming) Bidirectional streaming.
msg patterns.
''' """
from __future__ import annotations
from typing import (
Callable,
)
import pytest import pytest
import trio import trio
import tractor import tractor
@ -14,8 +9,10 @@ import tractor
@tractor.context @tractor.context
async def simple_rpc( async def simple_rpc(
ctx: tractor.Context, ctx: tractor.Context,
data: int, data: int,
) -> None: ) -> None:
''' '''
Test a small ping-pong server. Test a small ping-pong server.
@ -42,13 +39,15 @@ async def simple_rpc(
@tractor.context @tractor.context
async def simple_rpc_with_forloop( async def simple_rpc_with_forloop(
ctx: tractor.Context, ctx: tractor.Context,
data: int, data: int,
) -> None:
'''
Same as previous test but using `async for` syntax/api.
''' ) -> None:
"""Same as previous test but using ``async for`` syntax/api.
"""
# signal to parent that we're up # signal to parent that we're up
await ctx.started(data + 1) await ctx.started(data + 1)
@ -69,37 +68,21 @@ async def simple_rpc_with_forloop(
@pytest.mark.parametrize( @pytest.mark.parametrize(
'use_async_for', 'use_async_for',
[ [True, False],
True,
False,
],
ids='use_async_for={}'.format,
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
'server_func', 'server_func',
[ [simple_rpc, simple_rpc_with_forloop],
simple_rpc,
simple_rpc_with_forloop,
],
ids='server_func={}'.format,
) )
def test_simple_rpc( def test_simple_rpc(server_func, use_async_for):
server_func: Callable,
use_async_for: bool,
loglevel: str,
debug_mode: bool,
):
''' '''
The simplest request response pattern. The simplest request response pattern.
''' '''
async def main(): async def main():
with trio.fail_after(6): async with tractor.open_nursery() as n:
async with tractor.open_nursery(
loglevel=loglevel, portal = await n.start_actor(
debug_mode=debug_mode,
) as an:
portal: tractor.Portal = await an.start_actor(
'rpc_server', 'rpc_server',
enable_modules=[__name__], enable_modules=[__name__],
) )

View File

@ -17,8 +17,8 @@ from tractor._testing import (
from .conftest import no_windows from .conftest import no_windows
_non_linux: bool = platform.system() != 'Linux' def is_win():
_friggin_windows: bool = platform.system() == 'Windows' return platform.system() == 'Windows'
async def assert_err(delay=0): async def assert_err(delay=0):
@ -431,7 +431,7 @@ async def test_nested_multierrors(loglevel, start_method):
for subexc in err.exceptions: for subexc in err.exceptions:
# verify first level actor errors are wrapped as remote # verify first level actor errors are wrapped as remote
if _friggin_windows: if is_win():
# windows is often too slow and cancellation seems # windows is often too slow and cancellation seems
# to happen before an actor is spawned # to happen before an actor is spawned
@ -464,7 +464,7 @@ async def test_nested_multierrors(loglevel, start_method):
# XXX not sure what's up with this.. # XXX not sure what's up with this..
# on windows sometimes spawning is just too slow and # on windows sometimes spawning is just too slow and
# we get back the (sent) cancel signal instead # we get back the (sent) cancel signal instead
if _friggin_windows: if is_win():
if isinstance(subexc, tractor.RemoteActorError): if isinstance(subexc, tractor.RemoteActorError):
assert subexc.boxed_type in ( assert subexc.boxed_type in (
BaseExceptionGroup, BaseExceptionGroup,
@ -507,22 +507,17 @@ def test_cancel_via_SIGINT(
@no_windows @no_windows
def test_cancel_via_SIGINT_other_task( def test_cancel_via_SIGINT_other_task(
loglevel: str, loglevel,
start_method: str, start_method,
spawn_backend: str, spawn_backend,
): ):
''' """Ensure that a control-C (SIGINT) signal cancels both the parent
Ensure that a control-C (SIGINT) signal cancels both the parent and child processes in trionic fashion even a subprocess is started
and child processes in trionic fashion even a subprocess is from a seperate ``trio`` child task.
started from a seperate ``trio`` child task. """
pid = os.getpid()
''' timeout: float = 2
pid: int = os.getpid() if is_win(): # smh
timeout: float = (
4 if _non_linux
else 2
)
if _friggin_windows: # smh
timeout += 1 timeout += 1
async def spawn_and_sleep_forever( async def spawn_and_sleep_forever(
@ -701,7 +696,7 @@ def test_fast_graceful_cancel_when_spawn_task_in_soft_proc_wait_for_daemon(
kbi_delay = 0.5 kbi_delay = 0.5
timeout: float = 2.9 timeout: float = 2.9
if _friggin_windows: # smh if is_win(): # smh
timeout += 1 timeout += 1
async def main(): async def main():

View File

@ -18,15 +18,16 @@ from tractor import RemoteActorError
async def aio_streamer( async def aio_streamer(
chan: tractor.to_asyncio.LinkedTaskChannel, from_trio: asyncio.Queue,
to_trio: trio.abc.SendChannel,
) -> trio.abc.ReceiveChannel: ) -> trio.abc.ReceiveChannel:
# required first msg to sync caller # required first msg to sync caller
chan.started_nowait(None) to_trio.send_nowait(None)
from itertools import cycle from itertools import cycle
for i in cycle(range(10)): for i in cycle(range(10)):
chan.send_nowait(i) to_trio.send_nowait(i)
await asyncio.sleep(0.01) await asyncio.sleep(0.01)

View File

@ -9,7 +9,6 @@ from itertools import count
import math import math
import platform import platform
from pprint import pformat from pprint import pformat
import sys
from typing import ( from typing import (
Callable, Callable,
) )
@ -942,11 +941,6 @@ def test_one_end_stream_not_opened(
from tractor._runtime import Actor from tractor._runtime import Actor
buf_size = buf_size_increase + Actor.msg_buffer_size buf_size = buf_size_increase + Actor.msg_buffer_size
timeout: float = (
1 if sys.platform == 'linux'
else 3
)
async def main(): async def main():
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=debug_mode, debug_mode=debug_mode,
@ -956,7 +950,7 @@ def test_one_end_stream_not_opened(
enable_modules=[__name__], enable_modules=[__name__],
) )
with trio.fail_after(timeout): with trio.fail_after(1):
async with portal.open_context( async with portal.open_context(
entrypoint, entrypoint,
) as (ctx, sent): ) as (ctx, sent):

View File

@ -1,13 +1,11 @@
""" """
Discovery subsys. Actor "discovery" testing
""" """
import os import os
import signal import signal
import platform import platform
from functools import partial from functools import partial
import itertools import itertools
from typing import Callable
import psutil import psutil
import pytest import pytest
@ -19,9 +17,7 @@ import trio
@tractor_test @tractor_test
async def test_reg_then_unreg( async def test_reg_then_unreg(reg_addr):
reg_addr: tuple,
):
actor = tractor.current_actor() actor = tractor.current_actor()
assert actor.is_arbiter assert actor.is_arbiter
assert len(actor._registry) == 1 # only self is registered assert len(actor._registry) == 1 # only self is registered
@ -86,15 +82,11 @@ async def say_hello_use_wait(
@tractor_test @tractor_test
@pytest.mark.parametrize( @pytest.mark.parametrize('func', [say_hello, say_hello_use_wait])
'func',
[say_hello,
say_hello_use_wait]
)
async def test_trynamic_trio( async def test_trynamic_trio(
func: Callable, func,
start_method: str, start_method,
reg_addr: tuple, reg_addr,
): ):
''' '''
Root actor acting as the "director" and running one-shot-task-actors Root actor acting as the "director" and running one-shot-task-actors
@ -127,10 +119,7 @@ async def stream_forever():
await trio.sleep(0.01) await trio.sleep(0.01)
async def cancel( async def cancel(use_signal, delay=0):
use_signal: bool,
delay: float = 0,
):
# hold on there sally # hold on there sally
await trio.sleep(delay) await trio.sleep(delay)
@ -143,15 +132,13 @@ async def cancel(
raise KeyboardInterrupt raise KeyboardInterrupt
async def stream_from(portal: tractor.Portal): async def stream_from(portal):
async with portal.open_stream_from(stream_forever) as stream: async with portal.open_stream_from(stream_forever) as stream:
async for value in stream: async for value in stream:
print(value) print(value)
async def unpack_reg( async def unpack_reg(actor_or_portal):
actor_or_portal: tractor.Portal|tractor.Actor,
):
''' '''
Get and unpack a "registry" RPC request from the "arbiter" registry Get and unpack a "registry" RPC request from the "arbiter" registry
system. system.
@ -186,9 +173,7 @@ async def spawn_and_check_registry(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
debug_mode=debug_mode, debug_mode=debug_mode,
): ):
async with tractor.get_registry( async with tractor.get_registry(reg_addr) as portal:
addr=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()
@ -261,10 +246,10 @@ async def spawn_and_check_registry(
@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, debug_mode: bool,
start_method: str, start_method,
use_signal: bool, use_signal,
reg_addr: tuple, reg_addr,
with_streaming: bool, with_streaming,
): ):
''' '''
Verify that cancelling a nursery results in all subactors Verify that cancelling a nursery results in all subactors
@ -289,17 +274,15 @@ def test_subactors_unregister_on_cancel(
def test_subactors_unregister_on_cancel_remote_daemon( def test_subactors_unregister_on_cancel_remote_daemon(
daemon: subprocess.Popen, daemon: subprocess.Popen,
debug_mode: bool, debug_mode: bool,
start_method: str, start_method,
use_signal: bool, use_signal,
reg_addr: tuple, reg_addr,
with_streaming: bool, with_streaming,
): ):
''' """Verify that cancelling a nursery results in all subactors
Verify that cancelling a nursery results in all subactors deregistering themselves with a **remote** (not in the local process
deregistering themselves with a **remote** (not in the local tree) arbiter.
process tree) arbiter. """
'''
with pytest.raises(KeyboardInterrupt): with pytest.raises(KeyboardInterrupt):
trio.run( trio.run(
partial( partial(
@ -391,16 +374,14 @@ async def close_chans_before_nursery(
@pytest.mark.parametrize('use_signal', [False, True]) @pytest.mark.parametrize('use_signal', [False, True])
def test_close_channel_explicit( def test_close_channel_explicit(
start_method: str, start_method,
use_signal: bool, use_signal,
reg_addr: tuple, reg_addr,
): ):
''' """Verify that closing a stream explicitly and killing the actor's
Verify that closing a stream explicitly and killing the actor's
"root nursery" **before** the containing nursery tears down also "root nursery" **before** the containing nursery tears down also
results in subactor(s) deregistering from the arbiter. results in subactor(s) deregistering from the arbiter.
"""
'''
with pytest.raises(KeyboardInterrupt): with pytest.raises(KeyboardInterrupt):
trio.run( trio.run(
partial( partial(
@ -415,16 +396,14 @@ 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: subprocess.Popen, daemon: subprocess.Popen,
start_method: str, start_method,
use_signal: bool, use_signal,
reg_addr: tuple, reg_addr,
): ):
''' """Verify that closing a stream explicitly and killing the actor's
Verify that closing a stream explicitly and killing the actor's
"root nursery" **before** the containing nursery tears down also "root nursery" **before** the containing nursery tears down also
results in subactor(s) deregistering from the arbiter. results in subactor(s) deregistering from the arbiter.
"""
'''
with pytest.raises(KeyboardInterrupt): with pytest.raises(KeyboardInterrupt):
trio.run( trio.run(
partial( partial(

View File

@ -9,17 +9,12 @@ import sys
import subprocess import subprocess
import platform import platform
import shutil import shutil
from typing import Callable
import pytest import pytest
import tractor
from tractor._testing import ( from tractor._testing import (
examples_dir, examples_dir,
) )
_non_linux: bool = platform.system() != 'Linux'
_friggin_macos: bool = platform.system() == 'Darwin'
@pytest.fixture @pytest.fixture
def run_example_in_subproc( def run_example_in_subproc(
@ -106,10 +101,8 @@ def run_example_in_subproc(
ids=lambda t: t[1], ids=lambda t: t[1],
) )
def test_example( def test_example(
run_example_in_subproc: Callable, run_example_in_subproc,
example_script: str, example_script,
test_log: tractor.log.StackLevelAdapter,
ci_env: bool,
): ):
''' '''
Load and run scripts from this repo's ``examples/`` dir as a user Load and run scripts from this repo's ``examples/`` dir as a user
@ -123,32 +116,9 @@ def test_example(
''' '''
ex_file: str = os.path.join(*example_script) ex_file: str = os.path.join(*example_script)
if ( if 'rpc_bidir_streaming' in ex_file and sys.version_info < (3, 9):
'rpc_bidir_streaming' in ex_file
and
sys.version_info < (3, 9)
):
pytest.skip("2-way streaming example requires py3.9 async with syntax") pytest.skip("2-way streaming example requires py3.9 async with syntax")
if (
'full_fledged_streaming_service' in ex_file
and
_friggin_macos
and
ci_env
):
pytest.skip(
'Streaming example is too flaky in CI\n'
'AND their competitor runs this CI service..\n'
'This test does run just fine "in person" however..'
)
timeout: float = (
60
if ci_env and _non_linux
else 16
)
with open(ex_file, 'r') as ex: with open(ex_file, 'r') as ex:
code = ex.read() code = ex.read()
@ -156,12 +126,9 @@ def test_example(
err = None err = None
try: try:
if not proc.poll(): if not proc.poll():
_, err = proc.communicate(timeout=timeout) _, err = proc.communicate(timeout=15)
except subprocess.TimeoutExpired as e: except subprocess.TimeoutExpired as e:
test_log.exception(
f'Example failed to finish within {timeout}s ??\n'
)
proc.kill() proc.kill()
err = e.stderr err = e.stderr

View File

@ -47,11 +47,12 @@ async def sleep_and_err(
# just signature placeholders for compat with # just signature placeholders for compat with
# ``to_asyncio.open_channel_from()`` # ``to_asyncio.open_channel_from()``
chan: to_asyncio.LinkedTaskChannel|None = None, to_trio: trio.MemorySendChannel|None = None,
from_trio: asyncio.Queue|None = None,
): ):
if chan: if to_trio:
chan.started_nowait('start') to_trio.send_nowait('start')
await asyncio.sleep(sleep_for) await asyncio.sleep(sleep_for)
assert 0 assert 0
@ -398,7 +399,7 @@ async def no_to_trio_in_args():
async def push_from_aio_task( async def push_from_aio_task(
sequence: Iterable, sequence: Iterable,
chan: to_asyncio.LinkedTaskChannel, to_trio: trio.abc.SendChannel,
expect_cancel: False, expect_cancel: False,
fail_early: bool, fail_early: bool,
exit_early: bool, exit_early: bool,
@ -406,12 +407,15 @@ async def push_from_aio_task(
) -> None: ) -> None:
try: try:
# print('trying breakpoint')
# breakpoint()
# sync caller ctx manager # sync caller ctx manager
chan.started_nowait(True) to_trio.send_nowait(True)
for i in sequence: for i in sequence:
print(f'asyncio sending {i}') print(f'asyncio sending {i}')
chan.send_nowait(i) to_trio.send_nowait(i)
await asyncio.sleep(0.001) await asyncio.sleep(0.001)
if ( if (
@ -728,21 +732,15 @@ def test_aio_errors_and_channel_propagates_and_closes(
async def aio_echo_server( async def aio_echo_server(
chan: to_asyncio.LinkedTaskChannel, to_trio: trio.MemorySendChannel,
from_trio: asyncio.Queue,
) -> None: ) -> None:
'''
An IPC-msg "echo server" with msgs received and relayed by
a parent `trio.Task` into a child `asyncio.Task`
and then repeated back to that local parent (`trio.Task`)
and sent again back to the original calling remote actor.
''' to_trio.send_nowait('start')
# same semantics as `trio.TaskStatus.started()`
chan.started_nowait('start')
while True: while True:
try: try:
msg = await chan.get() msg = await from_trio.get()
except to_asyncio.TrioTaskExited: except to_asyncio.TrioTaskExited:
print( print(
'breaking aio echo loop due to `trio` exit!' 'breaking aio echo loop due to `trio` exit!'
@ -750,7 +748,7 @@ async def aio_echo_server(
break break
# echo the msg back # echo the msg back
chan.send_nowait(msg) to_trio.send_nowait(msg)
# if we get the terminate sentinel # if we get the terminate sentinel
# break the echo loop # break the echo loop
@ -767,10 +765,7 @@ async def trio_to_aio_echo_server(
): ):
async with to_asyncio.open_channel_from( async with to_asyncio.open_channel_from(
aio_echo_server, aio_echo_server,
) as ( ) as (first, chan):
first, # value from `chan.started_nowait()` above
chan,
):
assert first == 'start' assert first == 'start'
await ctx.started(first) await ctx.started(first)
@ -781,8 +776,7 @@ async def trio_to_aio_echo_server(
await chan.send(msg) await chan.send(msg)
out = await chan.receive() out = await chan.receive()
# echo back to parent actor-task
# echo back to parent-actor's remote parent-ctx-task!
await stream.send(out) await stream.send(out)
if out is None: if out is None:
@ -1096,21 +1090,24 @@ def test_sigint_closes_lifetime_stack(
# ?TODO asyncio.Task fn-deco? # ?TODO asyncio.Task fn-deco?
# -[ ] do sig checkingat import time like @context?
# -[ ] maybe name it @aio_task ??
# -[ ] chan: to_asyncio.InterloopChannel ?? # -[ ] chan: to_asyncio.InterloopChannel ??
# -[ ] do fn-sig checking at import time like @context?
# |_[ ] maybe name it @a(sync)io_task ??
# @asyncio_task <- not bad ??
async def raise_before_started( async def raise_before_started(
# from_trio: asyncio.Queue,
# to_trio: trio.abc.SendChannel,
chan: to_asyncio.LinkedTaskChannel, chan: to_asyncio.LinkedTaskChannel,
) -> None: ) -> None:
''' '''
`asyncio.Task` entry point which RTEs before calling `asyncio.Task` entry point which RTEs before calling
`chan.started_nowait()`. `to_trio.send_nowait()`.
''' '''
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
raise RuntimeError('Some shite went wrong before `.send_nowait()`!!') 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 ^^ ??') chan.started_nowait('Uhh we shouldve RTE-d ^^ ??')
await asyncio.sleep(float('inf')) await asyncio.sleep(float('inf'))

View File

@ -1,11 +1,9 @@
""" """
Streaming via the, now legacy, "async-gen API". Streaming via async gen api
""" """
import time import time
from functools import partial from functools import partial
import platform import platform
from typing import Callable
import trio import trio
import tractor import tractor
@ -21,11 +19,7 @@ def test_must_define_ctx():
async def no_ctx(): async def no_ctx():
pass pass
assert ( assert "no_ctx must be `ctx: tractor.Context" in str(err.value)
"no_ctx must be `ctx: tractor.Context"
in
str(err.value)
)
@tractor.stream @tractor.stream
async def has_ctx(ctx): async def has_ctx(ctx):
@ -75,14 +69,14 @@ async def stream_from_single_subactor(
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
start_method=start_method, start_method=start_method,
) as an: ) as nursery:
async with tractor.find_actor('streamerd') as portals: async with tractor.find_actor('streamerd') as portals:
if not portals: if not portals:
# no brokerd actor found # no brokerd actor found
portal = await an.start_actor( portal = await nursery.start_actor(
'streamerd', 'streamerd',
enable_modules=[__name__], enable_modules=[__name__],
) )
@ -122,22 +116,11 @@ async def stream_from_single_subactor(
@pytest.mark.parametrize( @pytest.mark.parametrize(
'stream_func', 'stream_func', [async_gen_stream, context_stream]
[
async_gen_stream,
context_stream,
],
ids='stream_func={}'.format
) )
def test_stream_from_single_subactor( def test_stream_from_single_subactor(reg_addr, start_method, stream_func):
reg_addr: tuple, """Verify streaming from a spawned async generator.
start_method: str, """
stream_func: Callable,
):
'''
Verify streaming from a spawned async generator.
'''
trio.run( trio.run(
partial( partial(
stream_from_single_subactor, stream_from_single_subactor,
@ -149,9 +132,10 @@ def test_stream_from_single_subactor(
# this is the first 2 actors, streamer_1 and streamer_2 # this is the first 2 actors, streamer_1 and streamer_2
async def stream_data(seed: int): async def stream_data(seed):
for i in range(seed): for i in range(seed):
yield i yield i
# trigger scheduler to simulate practical usage # trigger scheduler to simulate practical usage
@ -159,17 +143,15 @@ async def stream_data(seed: int):
# this is the third actor; the aggregator # this is the third actor; the aggregator
async def aggregate(seed: int): async def aggregate(seed):
''' """Ensure that the two streams we receive match but only stream
Ensure that the two streams we receive match but only stream
a single set of values to the parent. a single set of values to the parent.
"""
''' async with tractor.open_nursery() as nursery:
async with tractor.open_nursery() as an:
portals = [] portals = []
for i in range(1, 3): for i in range(1, 3):
# fork point # fork point
portal = await an.start_actor( portal = await nursery.start_actor(
name=f'streamer_{i}', name=f'streamer_{i}',
enable_modules=[__name__], enable_modules=[__name__],
) )
@ -182,8 +164,7 @@ async def aggregate(seed: int):
async with send_chan: async with send_chan:
async with portal.open_stream_from( async with portal.open_stream_from(
stream_data, stream_data, seed=seed,
seed=seed,
) as stream: ) as stream:
async for value in stream: async for value in stream:
@ -193,14 +174,10 @@ async def aggregate(seed: int):
print(f"FINISHED ITERATING {portal.channel.uid}") print(f"FINISHED ITERATING {portal.channel.uid}")
# spawn 2 trio tasks to collect streams and push to a local queue # spawn 2 trio tasks to collect streams and push to a local queue
async with trio.open_nursery() as tn: async with trio.open_nursery() as n:
for portal in portals: for portal in portals:
tn.start_soon( n.start_soon(push_to_chan, portal, send_chan.clone())
push_to_chan,
portal,
send_chan.clone(),
)
# close this local task's reference to send side # close this local task's reference to send side
await send_chan.aclose() await send_chan.aclose()
@ -217,21 +194,20 @@ async def aggregate(seed: int):
print("FINISHED ITERATING in aggregator") print("FINISHED ITERATING in aggregator")
await an.cancel() await nursery.cancel()
print("WAITING on `ActorNursery` to finish") print("WAITING on `ActorNursery` to finish")
print("AGGREGATOR COMPLETE!") print("AGGREGATOR COMPLETE!")
async def a_quadruple_example() -> list[int]: # this is the main actor and *arbiter*
''' async def a_quadruple_example():
Open the root-actor which is also a "registrar". # a nursery which spawns "actors"
async with tractor.open_nursery() as nursery:
'''
async with tractor.open_nursery() as an:
seed = int(1e3) seed = int(1e3)
pre_start = time.time() pre_start = time.time()
portal = await an.start_actor( portal = await nursery.start_actor(
name='aggregator', name='aggregator',
enable_modules=[__name__], enable_modules=[__name__],
) )
@ -252,14 +228,8 @@ async def a_quadruple_example() -> list[int]:
return result_stream return result_stream
async def cancel_after( async def cancel_after(wait, reg_addr):
wait: float, async with tractor.open_root_actor(registry_addrs=[reg_addr]):
reg_addr: tuple,
) -> list[int]:
async with tractor.open_root_actor(
registry_addrs=[reg_addr],
):
with trio.move_on_after(wait): with trio.move_on_after(wait):
return await a_quadruple_example() return await a_quadruple_example()
@ -270,10 +240,6 @@ def time_quad_ex(
ci_env: bool, ci_env: bool,
spawn_backend: str, spawn_backend: str,
): ):
non_linux: bool = (_sys := platform.system()) != 'Linux'
if ci_env and non_linux:
pytest.skip(f'Test is too flaky on {_sys!r} in CI')
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...
@ -281,20 +247,16 @@ def time_quad_ex(
''' '''
pytest.skip("Test is too flaky on mp in CI") pytest.skip("Test is too flaky on mp in CI")
timeout = 7 if non_linux else 4 timeout = 7 if platform.system() in ('Windows', 'Darwin') else 4
start = time.time() start = time.time()
results: list[int] = trio.run( results = trio.run(cancel_after, timeout, reg_addr)
cancel_after, diff = time.time() - start
timeout,
reg_addr,
)
diff: float = time.time() - start
assert results assert results
return results, diff return results, diff
def test_a_quadruple_example( def test_a_quadruple_example(
time_quad_ex: tuple[list[int], float], time_quad_ex: tuple,
ci_env: bool, ci_env: bool,
spawn_backend: str, spawn_backend: str,
): ):
@ -302,12 +264,13 @@ def test_a_quadruple_example(
This also serves as a kind of "we'd like to be this fast test". This also serves as a kind of "we'd like to be this fast test".
''' '''
non_linux: bool = (_sys := platform.system()) != 'Linux'
results, diff = time_quad_ex results, diff = time_quad_ex
assert results assert results
this_fast = ( this_fast = (
6 if non_linux 6 if platform.system() in (
'Windows',
'Darwin',
)
else 3 else 3
) )
assert diff < this_fast assert diff < this_fast
@ -318,33 +281,19 @@ def test_a_quadruple_example(
list(map(lambda i: i/10, range(3, 9))) list(map(lambda i: i/10, range(3, 9)))
) )
def test_not_fast_enough_quad( def test_not_fast_enough_quad(
reg_addr: tuple, reg_addr, time_quad_ex, cancel_delay, ci_env, spawn_backend
time_quad_ex: tuple[list[int], float],
cancel_delay: float,
ci_env: bool,
spawn_backend: str,
): ):
''' """Verify we can cancel midway through the quad example and all actors
Verify we can cancel midway through the quad example and all cancel gracefully.
actors cancel gracefully. """
'''
results, diff = time_quad_ex results, diff = time_quad_ex
delay = max(diff - cancel_delay, 0) delay = max(diff - cancel_delay, 0)
results = trio.run( results = trio.run(cancel_after, delay, reg_addr)
cancel_after, system = platform.system()
delay, if system in ('Windows', 'Darwin') and results is not None:
reg_addr,
)
system: str = platform.system()
if (
system in ('Windows', 'Darwin')
and
results is not None
):
# In CI envoirments it seems later runs are quicker then the first # In CI envoirments it seems later runs are quicker then the first
# so just ignore these # so just ignore these
print(f'Woa there {system} caught your breath eh?') print(f"Woa there {system} caught your breath eh?")
else: else:
# should be cancelled mid-streaming # should be cancelled mid-streaming
assert results is None assert results is None
@ -352,24 +301,23 @@ def test_not_fast_enough_quad(
@tractor_test @tractor_test
async def test_respawn_consumer_task( async def test_respawn_consumer_task(
reg_addr: tuple, reg_addr,
spawn_backend: str, spawn_backend,
loglevel: str, loglevel,
): ):
''' """Verify that ``._portal.ReceiveStream.shield()``
Verify that ``._portal.ReceiveStream.shield()``
sucessfully protects the underlying IPC channel from being closed sucessfully protects the underlying IPC channel from being closed
when cancelling and respawning a consumer task. when cancelling and respawning a consumer task.
This also serves to verify that all values from the stream can be This also serves to verify that all values from the stream can be
received despite the respawns. received despite the respawns.
''' """
stream = None stream = None
async with tractor.open_nursery() as an: async with tractor.open_nursery() as n:
portal = await an.start_actor( portal = await n.start_actor(
name='streamer', name='streamer',
enable_modules=[__name__] enable_modules=[__name__]
) )

View File

@ -35,9 +35,6 @@ if TYPE_CHECKING:
) )
_non_linux: bool = platform.system() != 'Linux'
def test_abort_on_sigint( def test_abort_on_sigint(
daemon: subprocess.Popen, daemon: subprocess.Popen,
): ):
@ -140,7 +137,6 @@ def test_non_registrar_spawns_child(
reg_addr: UnwrappedAddress, reg_addr: UnwrappedAddress,
loglevel: str, loglevel: str,
debug_mode: bool, debug_mode: bool,
ci_env: bool,
): ):
''' '''
Ensure a non-regristar (serving) root actor can spawn a sub and Ensure a non-regristar (serving) root actor can spawn a sub and
@ -152,12 +148,6 @@ def test_non_registrar_spawns_child(
''' '''
async def main(): async def main():
# XXX, since apparently on macos in GH's CI it can be a race
# with the `daemon` registrar on grabbing the socket-addr..
if ci_env and _non_linux:
await trio.sleep(.5)
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
loglevel=loglevel, loglevel=loglevel,

View File

@ -91,12 +91,13 @@ def test_infected_root_actor(
async def sync_and_err( async def sync_and_err(
# just signature placeholders for compat with # just signature placeholders for compat with
# ``to_asyncio.open_channel_from()`` # ``to_asyncio.open_channel_from()``
chan: tractor.to_asyncio.LinkedTaskChannel, to_trio: trio.MemorySendChannel,
from_trio: asyncio.Queue,
ev: asyncio.Event, ev: asyncio.Event,
): ):
if chan: if to_trio:
chan.started_nowait('start') to_trio.send_nowait('start')
await ev.wait() await ev.wait()
raise RuntimeError('asyncio-side') raise RuntimeError('asyncio-side')

View File

@ -2,7 +2,6 @@
Shared mem primitives and APIs. Shared mem primitives and APIs.
""" """
import platform
import uuid import uuid
# import numpy # import numpy
@ -54,18 +53,7 @@ def test_child_attaches_alot():
shm_key=shml.key, shm_key=shml.key,
) as (ctx, start_val), ) as (ctx, start_val),
): ):
assert (_key := shml.key) == start_val assert start_val == key
if platform.system() != 'Darwin':
# XXX, macOS has a char limit..
# see `ipc._shm._shorten_key_for_macos`
assert (
start_val
==
key
==
_key
)
await ctx.result() await ctx.result()
await portal.cancel_actor() await portal.cancel_actor()

View File

@ -22,6 +22,7 @@ from __future__ import annotations
from contextvars import ( from contextvars import (
ContextVar, ContextVar,
) )
import os
from pathlib import Path from pathlib import Path
from typing import ( from typing import (
Any, Any,
@ -29,7 +30,6 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
) )
import platformdirs
from trio.lowlevel import current_task from trio.lowlevel import current_task
if TYPE_CHECKING: if TYPE_CHECKING:
@ -172,56 +172,23 @@ def current_ipc_ctx(
return ctx return ctx
# std ODE (mutable) app state location
_rtdir: Path = Path(os.environ['XDG_RUNTIME_DIR'])
def get_rt_dir( def get_rt_dir(
subdir: str|Path|None = None, subdir: str = 'tractor'
appname: str = 'tractor',
) -> Path: ) -> Path:
''' '''
Return the user "runtime dir", the file-sys location where most Return the user "runtime dir" where most userspace apps stick
userspace apps stick their IPC and cache related system their IPC and cache related system util-files; we take hold
util-files. of a `'XDG_RUNTIME_DIR'/tractor/` subdir by default.
On linux we use a `${XDG_RUNTIME_DIR}/tractor/` subdir by
default, but equivalents are mapped for each platform using
the lovely `platformdirs` lib.
''' '''
rt_dir: Path = Path( rtdir: Path = _rtdir / subdir
platformdirs.user_runtime_dir( if not rtdir.is_dir():
appname=appname, rtdir.mkdir()
), return rtdir
)
# Normalize and validate that `subdir` is a relative path
# without any parent-directory ("..") components, to prevent
# escaping the runtime directory.
if subdir:
subdir_path = (
subdir
if isinstance(subdir, Path)
else Path(subdir)
)
if subdir_path.is_absolute():
raise ValueError(
f'`subdir` must be a relative path!\n'
f'{subdir!r}\n'
)
if any(part == '..' for part in subdir_path.parts):
raise ValueError(
"`subdir` must not contain '..' components!\n"
f'{subdir!r}\n'
)
rt_dir: Path = rt_dir / subdir_path
if not rt_dir.is_dir():
rt_dir.mkdir(
parents=True,
exist_ok=True, # avoid `FileExistsError` from conc calls
)
return rt_dir
def current_ipc_protos() -> list[str]: def current_ipc_protos() -> list[str]:

View File

@ -21,7 +21,6 @@ cancellation during REPL interaction.
''' '''
from __future__ import annotations from __future__ import annotations
import platform
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -50,7 +49,6 @@ if TYPE_CHECKING:
log = get_logger() log = get_logger()
_is_macos: bool = platform.system() == 'Darwin'
_ctlc_ignore_header: str = ( _ctlc_ignore_header: str = (
'Ignoring SIGINT while debug REPL in use' 'Ignoring SIGINT while debug REPL in use'
) )
@ -302,11 +300,6 @@ def sigint_shield(
# XXX: yah, mega hack, but how else do we catch this madness XD # XXX: yah, mega hack, but how else do we catch this madness XD
if ( if (
repl.shname == 'xonsh' repl.shname == 'xonsh'
or (
repl.shname == 'bash'
and
_is_macos
)
): ):
flush_status += ( flush_status += (
'-> ALSO re-flushing due to `xonsh`..\n' '-> ALSO re-flushing due to `xonsh`..\n'

View File

@ -23,7 +23,6 @@ considered optional within the context of this runtime-library.
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
from multiprocessing import shared_memory as shm from multiprocessing import shared_memory as shm
from multiprocessing.shared_memory import ( from multiprocessing.shared_memory import (
# SharedMemory, # SharedMemory,
@ -107,12 +106,11 @@ class NDToken(Struct, frozen=True):
This type is msg safe. This type is msg safe.
''' '''
shm_name: str # actual OS-level name (may be shortened on macOS) shm_name: str # this servers as a "key" value
shm_first_index_name: str shm_first_index_name: str
shm_last_index_name: str shm_last_index_name: str
dtype_descr: tuple dtype_descr: tuple
size: int # in struct-array index / row terms size: int # in struct-array index / row terms
key: str|None = None # original descriptive key (for lookup)
# TODO: use nptyping here on dtypes # TODO: use nptyping here on dtypes
@property @property
@ -126,41 +124,6 @@ class NDToken(Struct, frozen=True):
def as_msg(self): def as_msg(self):
return to_builtins(self) return to_builtins(self)
def __eq__(self, other) -> bool:
'''
Compare tokens based on shm names and dtype,
ignoring the `key` field.
The `key` field is only used for lookups,
not for token identity.
'''
if not isinstance(other, NDToken):
return False
return (
self.shm_name == other.shm_name
and self.shm_first_index_name
== other.shm_first_index_name
and self.shm_last_index_name
== other.shm_last_index_name
and self.dtype_descr == other.dtype_descr
and self.size == other.size
)
def __hash__(self) -> int:
'''
Hash based on the same fields used
in `.__eq__()`.
'''
return hash((
self.shm_name,
self.shm_first_index_name,
self.shm_last_index_name,
self.dtype_descr,
self.size,
))
@classmethod @classmethod
def from_msg(cls, msg: dict) -> NDToken: def from_msg(cls, msg: dict) -> NDToken:
if isinstance(msg, NDToken): if isinstance(msg, NDToken):
@ -197,50 +160,6 @@ def get_shm_token(key: str) -> NDToken | None:
return _known_tokens.get(key) return _known_tokens.get(key)
def _shorten_key_for_macos(
key: str,
prefix: str = '',
suffix: str = '',
) -> str:
'''
MacOS has a (hillarious) 31 character limit for POSIX shared
memory names. Hash long keys to fit within this limit while
maintaining uniqueness.
'''
# macOS shm_open() has a 31 char limit (PSHMNAMLEN)
# format: /t_<hash16> = 19 chars, well under limit
max_len: int = 31
if len(key) <= max_len:
return key
_hash: str = hashlib.sha256(
key.encode()
).hexdigest()
hash_len: int = (
(max_len - 1)
- len(prefix)
- len(suffix)
)
key_hash: str = _hash[:hash_len]
short_key = (
prefix
+
f'{key_hash}'
+
suffix
)
log.debug(
f'Shortened shm key for macOS:\n'
f' original: {key!r} ({len(key)!r} chars)\n'
f' shortened: {short_key!r}'
f' ({len(short_key)!r} chars)'
)
return short_key
def _make_token( def _make_token(
key: str, key: str,
size: int, size: int,
@ -252,32 +171,12 @@ def _make_token(
to access a shared array. to access a shared array.
''' '''
# On macOS, shorten keys that exceed the
# 31 character limit
if platform.system() == 'Darwin':
shm_name = _shorten_key_for_macos(
key=key,
)
shm_first = _shorten_key_for_macos(
key=key,
suffix='_first',
)
shm_last = _shorten_key_for_macos(
key=key,
suffix='_last',
)
else:
shm_name = key
shm_first = key + '_first'
shm_last = key + '_last'
return NDToken( return NDToken(
shm_name=shm_name, shm_name=key,
shm_first_index_name=shm_first, shm_first_index_name=key + "_first",
shm_last_index_name=shm_last, shm_last_index_name=key + "_last",
dtype_descr=tuple(np.dtype(dtype).descr), dtype_descr=tuple(np.dtype(dtype).descr),
size=size, size=size,
key=key, # store original key for lookup
) )
@ -532,17 +431,9 @@ class ShmArray:
def destroy(self) -> None: def destroy(self) -> None:
if _USE_POSIX: if _USE_POSIX:
# We manually unlink to bypass all the # We manually unlink to bypass all the "resource tracker"
# "resource tracker" nonsense meant for # nonsense meant for non-SC systems.
# non-SC systems. shm_unlink(self._shm.name)
name = self._shm.name
try:
shm_unlink(name)
except FileNotFoundError:
# might be a teardown race here?
log.warning(
f'Shm for {name} already unlinked?'
)
self._first.destroy() self._first.destroy()
self._last.destroy() self._last.destroy()
@ -572,16 +463,8 @@ def open_shm_ndarray(
a = np.zeros(size, dtype=dtype) a = np.zeros(size, dtype=dtype)
a['index'] = np.arange(len(a)) a['index'] = np.arange(len(a))
# Create token first to get the (possibly
# shortened) shm name
token = _make_token(
key=key,
size=size,
dtype=dtype,
)
shm = SharedMemory( shm = SharedMemory(
name=token.shm_name, name=key,
create=True, create=True,
size=a.nbytes size=a.nbytes
) )
@ -593,6 +476,12 @@ def open_shm_ndarray(
array[:] = a[:] array[:] = a[:]
array.setflags(write=int(not readonly)) 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 # create single entry arrays for storing an first and last indices
first = SharedInt( first = SharedInt(
shm=SharedMemory( shm=SharedMemory(
@ -665,23 +554,13 @@ def attach_shm_ndarray(
''' '''
token = NDToken.from_msg(token) token = NDToken.from_msg(token)
# Use original key for _known_tokens lookup, key = token.shm_name
# shm_name for OS calls
lookup_key = (
token.key if token.key
else token.shm_name
)
if lookup_key in _known_tokens: if key in _known_tokens:
assert ( assert NDToken.from_msg(_known_tokens[key]) == token, "WTF"
NDToken.from_msg(
_known_tokens[lookup_key]
) == token
), 'WTF'
# XXX: ugh, looks like due to the ``shm_open()`` # XXX: ugh, looks like due to the ``shm_open()`` C api we can't
# C api we can't actually place files in a subdir, # actually place files in a subdir, see discussion here:
# see discussion here:
# https://stackoverflow.com/a/11103289 # https://stackoverflow.com/a/11103289
# attach to array buffer and view as per dtype # attach to array buffer and view as per dtype
@ -689,7 +568,7 @@ def attach_shm_ndarray(
for _ in range(3): for _ in range(3):
try: try:
shm = SharedMemory( shm = SharedMemory(
name=token.shm_name, name=key,
create=False, create=False,
) )
break break
@ -735,10 +614,10 @@ def attach_shm_ndarray(
sha.array sha.array
# Stash key -> token knowledge for future queries # Stash key -> token knowledge for future queries
# via `maybe_open_shm_ndarray()` but only after # via `maybe_opepn_shm_array()` but only after we know
# we know we can attach. # we can attach.
if lookup_key not in _known_tokens: if key not in _known_tokens:
_known_tokens[lookup_key] = token _known_tokens[key] = token
# "close" attached shm on actor teardown # "close" attached shm on actor teardown
tractor.current_actor().lifetime_stack.callback(sha.close) tractor.current_actor().lifetime_stack.callback(sha.close)
@ -782,10 +661,7 @@ def maybe_open_shm_ndarray(
False, # not newly opened False, # not newly opened
) )
except KeyError: except KeyError:
log.warning( log.warning(f"Could not find {key} in shms cache")
f'Could not find key in shms cache,\n'
f'key: {key!r}\n'
)
if dtype: if dtype:
token = _make_token( token = _make_token(
key, key,
@ -895,7 +771,6 @@ def open_shm_list(
size: int = int(2 ** 10), size: int = int(2 ** 10),
dtype: float | int | bool | str | bytes | None = float, dtype: float | int | bool | str | bytes | None = float,
readonly: bool = True, readonly: bool = True,
prefix: str = 'shml_',
) -> ShmList: ) -> ShmList:
@ -909,12 +784,6 @@ def open_shm_list(
}[dtype] }[dtype]
sequence = [default] * size sequence = [default] * size
if platform.system() == 'Darwin':
key: str = _shorten_key_for_macos(
key=key,
prefix=prefix,
)
shml = ShmList( shml = ShmList(
sequence=sequence, sequence=sequence,
name=key, name=key,

View File

@ -23,12 +23,12 @@ from contextlib import (
) )
from pathlib import Path from pathlib import Path
import os import os
import sys
from socket import ( from socket import (
AF_UNIX, AF_UNIX,
SOCK_STREAM, SOCK_STREAM,
SO_PASSCRED,
SO_PEERCRED,
SOL_SOCKET, SOL_SOCKET,
error as socket_error,
) )
import struct import struct
from typing import ( from typing import (
@ -53,7 +53,7 @@ from tractor.log import get_logger
from tractor.ipc._transport import ( from tractor.ipc._transport import (
MsgpackTransport, MsgpackTransport,
) )
from tractor._state import ( from .._state import (
get_rt_dir, get_rt_dir,
current_actor, current_actor,
is_root_process, is_root_process,
@ -63,28 +63,6 @@ if TYPE_CHECKING:
from ._runtime import Actor from ._runtime import Actor
# Platform-specific credential passing constants
# See: https://stackoverflow.com/a/7982749
if sys.platform == 'linux':
from socket import (
SO_PASSCRED,
SO_PEERCRED,
)
else:
# Other (Unix) platforms - though further testing is required and
# others may need additional special handling?
SO_PASSCRED = None
SO_PEERCRED = None
# NOTE, macOS uses `LOCAL_PEERCRED` instead of `SO_PEERCRED` and
# doesn't need `SO_PASSCRED` (credential passing is always enabled).
# See code in <sys/un.h>: `#define LOCAL_PEERCRED 0x001`
#
# XXX INSTEAD we use the (hopefully) more generic
# `get_peer_pid()` below for other OSes.
log = get_logger() log = get_logger()
@ -187,11 +165,7 @@ class UDSAddress(
err_on_no_runtime=False, err_on_no_runtime=False,
) )
if actor: if actor:
sockname: str = f'{actor.aid.name}@{pid}' sockname: str = '::'.join(actor.uid) + f'@{pid}'
# XXX, orig version which broke both macOS (file-name
# length) and `multiaddrs` ('::' invalid separator).
# sockname: str = '::'.join(actor.uid) + f'@{pid}'
#
# ?^TODO, for `multiaddr`'s parser we can't use the `::` # ?^TODO, for `multiaddr`'s parser we can't use the `::`
# above^, SO maybe a `.` or something else here? # above^, SO maybe a `.` or something else here?
# sockname: str = '.'.join(actor.uid) + f'@{pid}' # sockname: str = '.'.join(actor.uid) + f'@{pid}'
@ -318,12 +292,7 @@ def close_listener(
async def open_unix_socket_w_passcred( async def open_unix_socket_w_passcred(
filename: ( filename: str|bytes|os.PathLike[str]|os.PathLike[bytes],
str
|bytes
|os.PathLike[str]
|os.PathLike[bytes]
),
) -> trio.SocketStream: ) -> trio.SocketStream:
''' '''
Literally the exact same as `trio.open_unix_socket()` except we set the additiona Literally the exact same as `trio.open_unix_socket()` except we set the additiona
@ -341,66 +310,21 @@ async def open_unix_socket_w_passcred(
# much more simplified logic vs tcp sockets - one socket type and only one # much more simplified logic vs tcp sockets - one socket type and only one
# possible location to connect to # possible location to connect to
sock = trio.socket.socket(AF_UNIX, SOCK_STREAM) sock = trio.socket.socket(AF_UNIX, SOCK_STREAM)
# Only set SO_PASSCRED on Linux (not needed/available on macOS)
if SO_PASSCRED is not None:
sock.setsockopt(SOL_SOCKET, SO_PASSCRED, 1) sock.setsockopt(SOL_SOCKET, SO_PASSCRED, 1)
with close_on_error(sock): with close_on_error(sock):
await sock.connect(os.fspath(filename)) await sock.connect(os.fspath(filename))
return trio.SocketStream(sock) return trio.SocketStream(sock)
def get_peer_pid(sock) -> int|None: def get_peer_info(sock: trio.socket.socket) -> tuple[
'''
Gets the PID of the process connected to the other end of a Unix
domain socket on macOS, or `None` if that fails.
NOTE, should work on MacOS (and others?).
'''
# try to get the peer PID using a naive soln found from,
# https://stackoverflow.com/a/67971484
#
# NOTE, a more correct soln is likely needed here according to
# the complaints of `copilot` which led to digging into the
# underlying `go`lang issue linked from the above SO answer,
# XXX, darwin-xnu kernel srces defining these constants,
# - SOL_LOCAL
# |_https://github.com/apple/darwin-xnu/blob/main/bsd/sys/un.h#L85
# - LOCAL_PEERPID
# |_https://github.com/apple/darwin-xnu/blob/main/bsd/sys/un.h#L89
#
SOL_LOCAL: int = 0
LOCAL_PEERPID: int = 0x002
try:
pid: int = sock.getsockopt(
SOL_LOCAL,
LOCAL_PEERPID,
)
return pid
except socket_error as e:
log.exception(
f"Failed to get peer PID: {e}"
)
return None
def get_peer_info(
sock: trio.socket.socket,
) -> tuple[
int, # pid int, # pid
int, # uid int, # uid
int, # guid int, # guid
]: ]:
''' '''
Deliver the connecting peer's "credentials"-info as defined in Deliver the connecting peer's "credentials"-info as defined in
a platform-specific way. a very Linux specific way..
Linux-ONLY, uses SO_PEERCRED.
For more deats see, For more deats see,
- `man accept`, - `man accept`,
@ -413,11 +337,6 @@ def get_peer_info(
- https://stackoverflow.com/a/7982749 - https://stackoverflow.com/a/7982749
''' '''
if SO_PEERCRED is None:
raise RuntimeError(
f'Peer credential retrieval not supported on {sys.platform}!'
)
creds: bytes = sock.getsockopt( creds: bytes = sock.getsockopt(
SOL_SOCKET, SOL_SOCKET,
SO_PEERCRED, SO_PEERCRED,
@ -521,38 +440,14 @@ class MsgpackUDSStream(MsgpackTransport):
match (peername, sockname): match (peername, sockname):
case (str(), bytes()): case (str(), bytes()):
sock_path: Path = Path(peername) sock_path: Path = Path(peername)
case (bytes(), str()): case (bytes(), str()):
sock_path: Path = Path(sockname) sock_path: Path = Path(sockname)
case (str(), str()): # XXX, likely macOS
sock_path: Path = Path(peername)
case _:
raise TypeError(
f'Failed to match (peername, sockname) types?\n'
f'peername: {peername!r}\n'
f'sockname: {sockname!r}\n'
)
if sys.platform == 'linux':
( (
peer_pid, peer_pid,
_, _,
_, _,
) = get_peer_info(sock) ) = get_peer_info(sock)
# NOTE known to at least works on,
# - macos
else:
peer_pid: int|None = get_peer_pid(sock)
if peer_pid is None:
log.warning(
f'Unable to get peer PID?\n'
f'sock: {sock!r}\n'
f'peer_pid: {peer_pid!r}\n'
)
filedir, filename = unwrap_sockpath(sock_path) filedir, filename = unwrap_sockpath(sock_path)
laddr = UDSAddress( laddr = UDSAddress(
filedir=filedir, filedir=filedir,

View File

@ -126,17 +126,13 @@ def iter_struct_ppfmt_lines(
str(ft) str(ft)
).replace(' ', '') ).replace(' ', '')
if k[0] == '_':
continue
# recurse to get sub-struct's `.pformat()` output Bo # recurse to get sub-struct's `.pformat()` output Bo
elif isinstance(v, Struct): if isinstance(v, Struct):
yield from iter_struct_ppfmt_lines( yield from iter_struct_ppfmt_lines(
struct=v, struct=v,
field_indent=field_indent+field_indent, field_indent=field_indent+field_indent,
) )
else:
else: # top-level field
val_str: str = repr(v) val_str: str = repr(v)
# XXX LOL, below just seems to be f#$%in causing # XXX LOL, below just seems to be f#$%in causing

View File

@ -48,7 +48,7 @@ from tractor._state import (
_runtime_vars, _runtime_vars,
) )
from tractor._context import Unresolved from tractor._context import Unresolved
from tractor import devx from tractor.devx import debug
from tractor.log import ( from tractor.log import (
get_logger, get_logger,
StackLevelAdapter, StackLevelAdapter,
@ -94,14 +94,10 @@ else:
QueueShutDown = False QueueShutDown = False
# TODO, generally speaking we can generalize this abstraction as, # TODO, generally speaking we can generalize this abstraction, a "SC linked
# # parent->child task pair", as the same "supervision scope primitive"
# > A "SC linked, inter-event-loop" channel for comms between # **that is** our `._context.Context` with the only difference being
# > a `parent: trio.Task` -> `child: asyncio.Task` pair. # in how the tasks conduct msg-passing comms.
#
# It is **very similar** in terms of its operation as a "supervision
# scope primitive" to that of our `._context.Context` with the only
# difference being in how the tasks conduct msg-passing comms.
# #
# For `LinkedTaskChannel` we are passing the equivalent of (once you # For `LinkedTaskChannel` we are passing the equivalent of (once you
# include all the recently added `._trio/aio_to_raise` # include all the recently added `._trio/aio_to_raise`
@ -126,7 +122,6 @@ class LinkedTaskChannel(
task scheduled in the host loop. task scheduled in the host loop.
''' '''
# ?TODO, rename as `._aio_q` since it's 2-way?
_to_aio: asyncio.Queue _to_aio: asyncio.Queue
_from_aio: trio.MemoryReceiveChannel _from_aio: trio.MemoryReceiveChannel
@ -240,11 +235,9 @@ class LinkedTaskChannel(
# #
async def receive(self) -> Any: async def receive(self) -> Any:
''' '''
Receive a value `trio.Task` <- `asyncio.Task`. Receive a value from the paired `asyncio.Task` with
Note the tasks in each loop are "SC linked" as a pair with
exception/cancel handling to teardown both sides on any exception/cancel handling to teardown both sides on any
unexpected error or cancellation. unexpected error.
''' '''
try: try:
@ -268,40 +261,15 @@ class LinkedTaskChannel(
): ):
raise err raise err
async def get(self) -> Any:
'''
Receive a value `asyncio.Task` <- `trio.Task`.
This is equiv to `await self._to_aio.get()`.
'''
return await self._to_aio.get()
async def send(self, item: Any) -> None: async def send(self, item: Any) -> None:
''' '''
Send a value `trio.Task` -> `asyncio.Task` Send a value through to the asyncio task presuming
by enqueuing `item` onto the internal it defines a ``from_trio`` argument, if it does not
`asyncio.Queue` via `put_nowait()`. this method will raise an error.
''' '''
self._to_aio.put_nowait(item) self._to_aio.put_nowait(item)
# TODO? could we only compile-in this method on an instance
# handed to the `asyncio`-side, i.e. the fn invoked with
# `.open_channel_from()`.
def send_nowait(
self,
item: Any,
) -> None:
'''
Send a value through FROM the `asyncio.Task` to
the `trio.Task` NON-BLOCKING.
This is equiv to `self._to_trio.send_nowait()`.
'''
self._to_trio.send_nowait(item)
# TODO? needed? # TODO? needed?
# async def wait_aio_complete(self) -> None: # async def wait_aio_complete(self) -> None:
# await self._aio_task_complete.wait() # await self._aio_task_complete.wait()
@ -369,12 +337,9 @@ def _run_asyncio_task(
''' '''
__tracebackhide__: bool = hide_tb __tracebackhide__: bool = hide_tb
if not (actor := tractor.current_actor()).is_infected_aio(): if not tractor.current_actor().is_infected_aio():
raise RuntimeError( raise RuntimeError(
f'`infect_asyncio: bool` mode is not enabled ??\n' "`infect_asyncio` mode is not enabled!?"
f'Ensure you pass `ActorNursery.start_actor(infect_asyncio=True)`\n'
f'\n'
f'{actor}\n'
) )
# ITC (inter task comms), these channel/queue names are mostly from # ITC (inter task comms), these channel/queue names are mostly from
@ -437,23 +402,7 @@ def _run_asyncio_task(
orig = result = id(coro) orig = result = id(coro)
try: try:
# XXX TODO UGH!
# this seems to break a `test_sync_pause_from_aio_task`
# in a REALLY weird way where a `dict` value for
# `_runtime_vars['_root_addrs']` is delivered from the
# parent actor??
#
# XXX => see masked `.set_trace()` block in
# `Actor.from_parent()`..
#
# with devx.maybe_open_crash_handler(
# # XXX, if trio-side exits (intentionally) we
# # shouldn't care bc it should have its own crash
# # handling logic.
# ignore={TrioTaskExited,},
# ) as _bxerr:
result: Any = await coro result: Any = await coro
chan._aio_result = result chan._aio_result = result
except BaseException as aio_err: except BaseException as aio_err:
chan._aio_err = aio_err chan._aio_err = aio_err
@ -560,7 +509,7 @@ def _run_asyncio_task(
if ( if (
debug_mode() debug_mode()
and and
(greenback := devx.debug.maybe_import_greenback( (greenback := debug.maybe_import_greenback(
force_reload=True, force_reload=True,
raise_not_found=False, raise_not_found=False,
)) ))
@ -960,11 +909,7 @@ async def translate_aio_errors(
except BaseException as _trio_err: except BaseException as _trio_err:
trio_err = chan._trio_err = _trio_err trio_err = chan._trio_err = _trio_err
# await tractor.pause(shield=True) # workx! # await tractor.pause(shield=True) # workx!
entered: bool = await debug._maybe_enter_pm(
# !TODO! we need an inter-loop lock here to avoid aio-tasks
# clobbering trio ones when both crash in debug-mode!
#
entered: bool = await devx.debug._maybe_enter_pm(
trio_err, trio_err,
api_frame=inspect.currentframe(), api_frame=inspect.currentframe(),
) )
@ -1298,18 +1243,10 @@ async def open_channel_from(
suppress_graceful_exits: bool = True, suppress_graceful_exits: bool = True,
**target_kwargs, **target_kwargs,
) -> AsyncIterator[ ) -> AsyncIterator[Any]:
tuple[Any, LinkedTaskChannel]
]:
''' '''
Start an `asyncio.Task` as `target()` and open an Open an inter-loop linked task channel for streaming between a target
inter-loop (linked) channel for streaming between spawned ``asyncio`` task and ``trio``.
it and the current `trio.Task`.
A pair `(Any, chan: LinkedTaskChannel)` is delivered
to the caller where the 1st element is the value
provided by the `asyncio.Task`'s unblocking call
to `chan.started_nowait()`.
''' '''
chan: LinkedTaskChannel = _run_asyncio_task( chan: LinkedTaskChannel = _run_asyncio_task(
@ -1334,7 +1271,6 @@ async def open_channel_from(
# deliver stream handle upward # deliver stream handle upward
yield first, chan yield first, chan
# ^TODO! swap these!!
except trio.Cancelled as taskc: except trio.Cancelled as taskc:
if cs.cancel_called: if cs.cancel_called:
if isinstance(chan._trio_to_raise, AsyncioCancelled): if isinstance(chan._trio_to_raise, AsyncioCancelled):
@ -1365,8 +1301,7 @@ async def open_channel_from(
) )
else: else:
# XXX SHOULD NEVER HAPPEN! # XXX SHOULD NEVER HAPPEN!
log.error("SHOULD NEVER GET HERE !?!?") await tractor.pause()
await tractor.pause(shield=True)
else: else:
chan._to_trio.close() chan._to_trio.close()

11
uv.lock
View File

@ -208,15 +208,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
] ]
[[package]]
name = "platformdirs"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.5.0" version = "1.5.0"
@ -385,7 +376,6 @@ dependencies = [
{ name = "colorlog" }, { name = "colorlog" },
{ name = "msgspec" }, { name = "msgspec" },
{ name = "pdbp" }, { name = "pdbp" },
{ name = "platformdirs" },
{ name = "tricycle" }, { name = "tricycle" },
{ name = "trio" }, { name = "trio" },
{ name = "wrapt" }, { name = "wrapt" },
@ -429,7 +419,6 @@ requires-dist = [
{ name = "colorlog", specifier = ">=6.8.2,<7" }, { name = "colorlog", specifier = ">=6.8.2,<7" },
{ name = "msgspec", specifier = ">=0.19.0" }, { name = "msgspec", specifier = ">=0.19.0" },
{ name = "pdbp", specifier = ">=1.8.2,<2" }, { name = "pdbp", specifier = ">=1.8.2,<2" },
{ name = "platformdirs", specifier = ">=4.4.0" },
{ name = "tricycle", specifier = ">=0.4.1,<0.5" }, { name = "tricycle", specifier = ">=0.4.1,<0.5" },
{ name = "trio", specifier = ">0.27" }, { name = "trio", specifier = ">0.27" },
{ name = "wrapt", specifier = ">=1.16.0,<2" }, { name = "wrapt", specifier = ">=1.16.0,<2" },