From e7cefba67f0611173207de3c95167f8000f1be4d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 6 Oct 2025 11:40:56 -0400 Subject: [PATCH 01/48] Use `platformdirs` for `.config.get_rt_dir()` Thanks to the `tox`-dev community for such a lovely pkg which seems to solves all the current cross-platform user-dir problems B) Also this, - now passes `platformdirs.user_runtime_dir(appname='tractor')` and allows caller to pass an optional `subdir` under `tractor/` if desired. - drops the `.config._rtdir: Path` mod var. - bumps the lock file with the new dep. --- pyproject.toml | 1 + tractor/_state.py | 33 +++++++++++++++++++++------------ uv.lock | 11 +++++++++++ 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e7d2cdd7..8fc82465 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "msgspec>=0.19.0", "cffi>=1.17.1", "bidict>=0.23.1", + "platformdirs>=4.4.0", ] # ------ project ------ diff --git a/tractor/_state.py b/tractor/_state.py index 2a47e548..4b214608 100644 --- a/tractor/_state.py +++ b/tractor/_state.py @@ -22,7 +22,6 @@ from __future__ import annotations from contextvars import ( ContextVar, ) -import os from pathlib import Path from typing import ( Any, @@ -30,6 +29,7 @@ from typing import ( TYPE_CHECKING, ) +import platformdirs from trio.lowlevel import current_task if TYPE_CHECKING: @@ -172,23 +172,32 @@ def current_ipc_ctx( return ctx -# std ODE (mutable) app state location -_rtdir: Path = Path(os.environ['XDG_RUNTIME_DIR']) - def get_rt_dir( - subdir: str = 'tractor' + subdir: str|Path|None = None, ) -> 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. + Return the user "runtime dir", the file-sys location where most + userspace apps stick their IPC and cache related system + util-files. + + On linux we take use a `'${XDG_RUNTIME_DIR}/tractor/` subdir by + default but equivalents are mapped for each platform using + the lovely `platformdirs`. ''' - rtdir: Path = _rtdir / subdir - if not rtdir.is_dir(): - rtdir.mkdir() - return rtdir + rt_dir: Path = Path( + platformdirs.user_runtime_dir( + appname='tractor', + ), + ) + if subdir: + rt_dir: Path = rt_dir / subdir + + if not rt_dir.is_dir(): + rt_dir.mkdir() + + return rt_dir def current_ipc_protos() -> list[str]: diff --git a/uv.lock b/uv.lock index 129fbab5..f629e974 100644 --- a/uv.lock +++ b/uv.lock @@ -208,6 +208,15 @@ 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" }, ] +[[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]] name = "pluggy" version = "1.5.0" @@ -376,6 +385,7 @@ dependencies = [ { name = "colorlog" }, { name = "msgspec" }, { name = "pdbp" }, + { name = "platformdirs" }, { name = "tricycle" }, { name = "trio" }, { name = "wrapt" }, @@ -419,6 +429,7 @@ requires-dist = [ { name = "colorlog", specifier = ">=6.8.2,<7" }, { name = "msgspec", specifier = ">=0.19.0" }, { name = "pdbp", specifier = ">=1.8.2,<2" }, + { name = "platformdirs", specifier = ">=4.4.0" }, { name = "tricycle", specifier = ">=0.4.1,<0.5" }, { name = "trio", specifier = ">0.27" }, { name = "wrapt", specifier = ">=1.16.0,<2" }, From 93b9a6cd97120a9a5be602594836d6f7652e58ab Mon Sep 17 00:00:00 2001 From: wygud Date: Tue, 7 Oct 2025 15:37:11 -0400 Subject: [PATCH 02/48] Add macOS compatibility for Unix socket credential passing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make socket credential imports platform-conditional in `.ipc._uds`. - Linux: use `SO_PASSCRED`/`SO_PEERCRED` from socket module - macOS: use `LOCAL_PEERCRED` (0x0001) instead, no need for `SO_PASSCRED` - Conditionally call `setsockopt(SO_PASSCRED)` only on Linux Fixes AttributeError on macOS where SO_PASSCRED doesn't exist. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tractor/ipc/_uds.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index eb53c6b2..56eeec26 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -23,14 +23,29 @@ from contextlib import ( ) from pathlib import Path import os +import sys from socket import ( AF_UNIX, SOCK_STREAM, - SO_PASSCRED, - SO_PEERCRED, SOL_SOCKET, ) import struct + +# Platform-specific credential passing constants +# See: https://stackoverflow.com/a/7982749 +if sys.platform == 'linux': + from socket import SO_PASSCRED, SO_PEERCRED +elif sys.platform == 'darwin': # macOS + # macOS uses LOCAL_PEERCRED instead of SO_PEERCRED + # and doesn't need SO_PASSCRED (credential passing is always enabled) + # Value from : #define LOCAL_PEERCRED 0x001 + LOCAL_PEERCRED = 0x0001 + SO_PEERCRED = LOCAL_PEERCRED # Alias for compatibility + SO_PASSCRED = None # Not needed/available on macOS +else: + # Other Unix platforms - may need additional handling + SO_PASSCRED = None + SO_PEERCRED = None from typing import ( Type, TYPE_CHECKING, @@ -310,7 +325,11 @@ async def open_unix_socket_w_passcred( # 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) + + # Only set SO_PASSCRED on Linux (not needed/available on macOS) + if SO_PASSCRED is not None: + sock.setsockopt(SOL_SOCKET, SO_PASSCRED, 1) + with close_on_error(sock): await sock.connect(os.fspath(filename)) @@ -324,7 +343,10 @@ def get_peer_info(sock: trio.socket.socket) -> tuple[ ]: ''' Deliver the connecting peer's "credentials"-info as defined in - a very Linux specific way.. + a platform-specific way. + + On Linux, uses SO_PEERCRED. + On macOS, uses LOCAL_PEERCRED. For more deats see, - `man accept`, @@ -337,6 +359,11 @@ def get_peer_info(sock: trio.socket.socket) -> tuple[ - 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( SOL_SOCKET, SO_PEERCRED, From 920d0043b4e1877f78bf59129e9fce49e77555e9 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 24 Feb 2026 19:48:30 -0500 Subject: [PATCH 03/48] Force parent subdirs for macos --- tractor/_state.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tractor/_state.py b/tractor/_state.py index 4b214608..1644cd8a 100644 --- a/tractor/_state.py +++ b/tractor/_state.py @@ -195,7 +195,9 @@ def get_rt_dir( rt_dir: Path = rt_dir / subdir if not rt_dir.is_dir(): - rt_dir.mkdir() + rt_dir.mkdir( + parents=True, + ) return rt_dir From 7bcd7aca2b3fbad0c31ded1bf733ed480f5212b4 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 26 Feb 2026 19:26:15 -0500 Subject: [PATCH 04/48] Reorg `socket` conditional imports a bit Move the multi-platorm-supporting conditional/dynamic `socket` constant imports to *after* the main cross-platform ones. Also add constant typing and reformat comments a bit for the macOS case. --- tractor/ipc/_uds.py | 51 ++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index 56eeec26..637a9a9a 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -30,22 +30,6 @@ from socket import ( SOL_SOCKET, ) import struct - -# Platform-specific credential passing constants -# See: https://stackoverflow.com/a/7982749 -if sys.platform == 'linux': - from socket import SO_PASSCRED, SO_PEERCRED -elif sys.platform == 'darwin': # macOS - # macOS uses LOCAL_PEERCRED instead of SO_PEERCRED - # and doesn't need SO_PASSCRED (credential passing is always enabled) - # Value from : #define LOCAL_PEERCRED 0x001 - LOCAL_PEERCRED = 0x0001 - SO_PEERCRED = LOCAL_PEERCRED # Alias for compatibility - SO_PASSCRED = None # Not needed/available on macOS -else: - # Other Unix platforms - may need additional handling - SO_PASSCRED = None - SO_PEERCRED = None from typing import ( Type, TYPE_CHECKING, @@ -68,7 +52,7 @@ from tractor.log import get_logger from tractor.ipc._transport import ( MsgpackTransport, ) -from .._state import ( +from tractor._state import ( get_rt_dir, current_actor, is_root_process, @@ -78,6 +62,28 @@ if TYPE_CHECKING: 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, + ) + +# NOTE, macOS uses `LOCAL_PEERCRED` instead of `SO_PEERCRED` and +# doesn't need `SO_PASSCRED` (credential passing is always enabled). +# XXX See code in : `#define LOCAL_PEERCRED 0x001` +# +elif sys.platform == 'darwin': # macOS + LOCAL_PEERCRED: int = 0x0001 + SO_PEERCRED:int|None = LOCAL_PEERCRED # Alias for compatibility + SO_PASSCRED: int|None = None # Not needed/available on macOS +else: + # Other Unix platforms - may need additional handling + SO_PASSCRED = None + SO_PEERCRED = None + + log = get_logger() @@ -307,7 +313,12 @@ def close_listener( async def open_unix_socket_w_passcred( - filename: str|bytes|os.PathLike[str]|os.PathLike[bytes], + 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 @@ -336,7 +347,9 @@ async def open_unix_socket_w_passcred( return trio.SocketStream(sock) -def get_peer_info(sock: trio.socket.socket) -> tuple[ +def get_peer_info( + sock: trio.socket.socket, +) -> tuple[ int, # pid int, # uid int, # guid From 01c0db651ab778ea5eefdc43487e78d6b18af31b Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 27 Feb 2026 14:41:42 -0500 Subject: [PATCH 05/48] Port macOS shm 31-char name limit hack from `piker` Adapt the `PSHMNAMLEN` fix from `piker.data._sharedmem` (orig commit 96fb79ec thx @dnks!) to `tractor.ipc._shm` accounting for the module-local differences: - Add `hashlib` import for sha256 key hashing - Add `key: str|None` field to `NDToken` for storing the original descriptive key separate from the (possibly shortened) OS-level `shm_name` - Add `__eq__()`/`__hash__()` to `NDToken` excluding the `key` field from identity comparison - Add `_shorten_key_for_macos()` using `t_` prefix (vs piker's `p_`) with 16 hex chars of sha256 - Use `platform.system() == 'Darwin'` in `_make_token()` (tractor already imports the `platform` module vs piker's `sys.platform`) - Wrap `shm_unlink()` in `ShmArray.destroy()` with `try/except FileNotFoundError` for teardown races (was already done in `SharedInt.destroy()`) - Move token creation before `SharedMemory()` alloc in `open_shm_ndarray()` so `token.shm_name` is used as the OS-level name - Use `lookup_key` pattern in `attach_shm_ndarray()` to decouple `_known_tokens` dict key from OS name (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/ipc/_shm.py | 147 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 123 insertions(+), 24 deletions(-) diff --git a/tractor/ipc/_shm.py b/tractor/ipc/_shm.py index 2a5fbb82..08543eca 100644 --- a/tractor/ipc/_shm.py +++ b/tractor/ipc/_shm.py @@ -23,6 +23,7 @@ considered optional within the context of this runtime-library. """ from __future__ import annotations +import hashlib from multiprocessing import shared_memory as shm from multiprocessing.shared_memory import ( # SharedMemory, @@ -106,11 +107,12 @@ class NDToken(Struct, frozen=True): This type is msg safe. ''' - shm_name: str # this servers as a "key" value + shm_name: str # actual OS-level name (may be shortened on macOS) shm_first_index_name: str shm_last_index_name: str dtype_descr: tuple size: int # in struct-array index / row terms + key: str|None = None # original descriptive key (for lookup) # TODO: use nptyping here on dtypes @property @@ -124,6 +126,41 @@ class NDToken(Struct, frozen=True): def as_msg(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 def from_msg(cls, msg: dict) -> NDToken: if isinstance(msg, NDToken): @@ -160,6 +197,32 @@ def get_shm_token(key: str) -> NDToken | None: return _known_tokens.get(key) +def _shorten_key_for_macos(key: str) -> str: + ''' + macOS has a 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_ = 19 chars, well under limit + if len(key) <= 31: + return key + + key_hash: str = hashlib.sha256( + key.encode() + ).hexdigest()[:16] + short_key = f't_{key_hash}' + + log.debug( + f'Shortened shm key for macOS:\n' + f' original: {key} ({len(key)} chars)\n' + f' shortened: {short_key}' + f' ({len(short_key)} chars)' + ) + return short_key + + def _make_token( key: str, size: int, @@ -171,12 +234,28 @@ def _make_token( 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) + shm_first = _shorten_key_for_macos( + key + '_first' + ) + shm_last = _shorten_key_for_macos( + key + '_last' + ) + else: + shm_name = key + shm_first = key + '_first' + shm_last = key + '_last' + return NDToken( - shm_name=key, - shm_first_index_name=key + "_first", - shm_last_index_name=key + "_last", + shm_name=shm_name, + shm_first_index_name=shm_first, + shm_last_index_name=shm_last, dtype_descr=tuple(np.dtype(dtype).descr), size=size, + key=key, # store original key for lookup ) @@ -431,9 +510,17 @@ class ShmArray: 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) + # 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?' + ) self._first.destroy() self._last.destroy() @@ -463,8 +550,16 @@ def open_shm_ndarray( a = np.zeros(size, dtype=dtype) 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( - name=key, + name=token.shm_name, create=True, size=a.nbytes ) @@ -476,12 +571,6 @@ def open_shm_ndarray( 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( @@ -554,13 +643,23 @@ def attach_shm_ndarray( ''' token = NDToken.from_msg(token) - key = token.shm_name + # Use original key for _known_tokens lookup, + # shm_name for OS calls + lookup_key = ( + token.key if token.key + else token.shm_name + ) - if key in _known_tokens: - assert NDToken.from_msg(_known_tokens[key]) == token, "WTF" + if lookup_key in _known_tokens: + assert ( + NDToken.from_msg( + _known_tokens[lookup_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: + # 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 @@ -568,7 +667,7 @@ def attach_shm_ndarray( for _ in range(3): try: shm = SharedMemory( - name=key, + name=token.shm_name, create=False, ) break @@ -614,10 +713,10 @@ def attach_shm_ndarray( 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 + # via `maybe_open_shm_ndarray()` but only after + # we know we can attach. + if lookup_key not in _known_tokens: + _known_tokens[lookup_key] = token # "close" attached shm on actor teardown tractor.current_actor().lifetime_stack.callback(sha.close) From d8a3969048db96345529aa357c0a0e6f404a34ac Mon Sep 17 00:00:00 2001 From: goodboy Date: Sun, 1 Mar 2026 18:41:02 -0500 Subject: [PATCH 06/48] Also shorten shm-key for `ShmList` on macos Same problem as for the `ShmArray` tokens, so tweak and reuse the `_shorten_key_for_macos()` helper and call it from `open_shm_list()` similarly. Some tweaks/updates to the various helpers, - support `prefix/suffix` inputs and if provided take their lengths and subtract them from the known *macOS shm_open() has a 31 char limit (PSHMNAMLEN)* when generating and using the `hashlib.sha256()` value which overrides (for now..) wtv `key` is passed by the caller. - pass the appropriate `suffix='_first/_last'` values for the `ShmArray` token generators case. - add a `prefix: str = 'shml_'` param to `open_shm_list()`. - better log formatting with `!r` to report any key shortening. --- tractor/ipc/_shm.py | 62 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/tractor/ipc/_shm.py b/tractor/ipc/_shm.py index 08543eca..b60fafcc 100644 --- a/tractor/ipc/_shm.py +++ b/tractor/ipc/_shm.py @@ -197,28 +197,46 @@ def get_shm_token(key: str) -> NDToken | None: return _known_tokens.get(key) -def _shorten_key_for_macos(key: str) -> str: +def _shorten_key_for_macos( + key: str, + prefix: str = '', + suffix: str = '', +) -> str: ''' - macOS has a 31 character limit for POSIX shared - memory names. Hash long keys to fit within this - limit while maintaining uniqueness. + 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_ = 19 chars, well under limit - if len(key) <= 31: + max_len: int = 31 + if len(key) <= max_len: return key - key_hash: str = hashlib.sha256( + _hash: str = hashlib.sha256( key.encode() - ).hexdigest()[:16] - short_key = f't_{key_hash}' + ).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} ({len(key)} chars)\n' - f' shortened: {short_key}' - f' ({len(short_key)} chars)' + f' original: {key!r} ({len(key)!r} chars)\n' + f' shortened: {short_key!r}' + f' ({len(short_key)!r} chars)' ) return short_key @@ -237,12 +255,16 @@ def _make_token( # On macOS, shorten keys that exceed the # 31 character limit if platform.system() == 'Darwin': - shm_name = _shorten_key_for_macos(key) + shm_name = _shorten_key_for_macos( + key=key, + ) shm_first = _shorten_key_for_macos( - key + '_first' + key=key, + suffix='_first', ) shm_last = _shorten_key_for_macos( - key + '_last' + key=key, + suffix='_last', ) else: shm_name = key @@ -760,7 +782,10 @@ def maybe_open_shm_ndarray( False, # not newly opened ) except KeyError: - log.warning(f"Could not find {key} in shms cache") + log.warning( + f'Could not find key in shms cache,\n' + f'key: {key!r}\n' + ) if dtype: token = _make_token( key, @@ -870,6 +895,7 @@ def open_shm_list( size: int = int(2 ** 10), dtype: float | int | bool | str | bytes | None = float, readonly: bool = True, + prefix: str = 'shml_', ) -> ShmList: @@ -883,6 +909,12 @@ def open_shm_list( }[dtype] sequence = [default] * size + if platform.system() == 'Darwin': + key: str = _shorten_key_for_macos( + key=key, + prefix=prefix, + ) + shml = ShmList( sequence=sequence, name=key, From 521fb97fe9d693128a34ab1ce26632766f35fff4 Mon Sep 17 00:00:00 2001 From: goodboy Date: Sun, 1 Mar 2026 20:15:13 -0500 Subject: [PATCH 07/48] Support UDS on macos (for realz) Though it was a good (vibed) try by @dnks, the previous "fix" was not actually adding unix socket support but merely sidestepping a crash due to `get_peer_info()`'s impl never going to work on MacOS (and it was never intended to). This patch instead solves the underlying issue by implementing a new `get_peer_pid()` helper which does in fact retrieve the peer's PID in a more generic/cross-platform way (:fingers_crossed:); much thanks to the linked SO answer for this solution! Impl deats, - add `get_peer_pid()` and call it from `MsgpackUDSStream.get_stream_addrs()` when we detect a non-'linux' platform, OW use the original soln: `get_stream_addrs()`. - add a new case for the `match (peername, sockname)` with a `case (str(), str()):` which seems to at least work on macos. - drop all the `LOCAL_PEERCRED` dynamic import branching since it was never needed and was never going to work. --- tractor/ipc/_uds.py | 77 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index 637a9a9a..b7612682 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -24,10 +24,12 @@ from contextlib import ( from pathlib import Path import os import sys +import socket as stdlib_socket from socket import ( AF_UNIX, SOCK_STREAM, SOL_SOCKET, + error as socket_error, ) import struct from typing import ( @@ -70,19 +72,19 @@ if sys.platform == 'linux': SO_PEERCRED, ) -# NOTE, macOS uses `LOCAL_PEERCRED` instead of `SO_PEERCRED` and -# doesn't need `SO_PASSCRED` (credential passing is always enabled). -# XXX See code in : `#define LOCAL_PEERCRED 0x001` -# -elif sys.platform == 'darwin': # macOS - LOCAL_PEERCRED: int = 0x0001 - SO_PEERCRED:int|None = LOCAL_PEERCRED # Alias for compatibility - SO_PASSCRED: int|None = None # Not needed/available on macOS else: - # Other Unix platforms - may need additional handling + # 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 : `#define LOCAL_PEERCRED 0x001` + # + # XXX INSTEAD we use the (hopefully) more generic + # `get_peer_pid()` below for other OSes. + log = get_logger() @@ -186,7 +188,11 @@ class UDSAddress( err_on_no_runtime=False, ) if actor: - sockname: str = '::'.join(actor.uid) + f'@{pid}' + sockname: str = f'{actor.aid.name}@{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 `::` # above^, SO maybe a `.` or something else here? # sockname: str = '.'.join(actor.uid) + f'@{pid}' @@ -347,6 +353,26 @@ async def open_unix_socket_w_passcred( return trio.SocketStream(sock) +def get_peer_pid(sock) -> int|None: + ''' + 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 + # https://stackoverflow.com/a/67971484 + try: + pid: int = sock.getsockopt(0, 2) + 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[ @@ -358,8 +384,7 @@ def get_peer_info( Deliver the connecting peer's "credentials"-info as defined in a platform-specific way. - On Linux, uses SO_PEERCRED. - On macOS, uses LOCAL_PEERCRED. + Linux-ONLY, uses SO_PEERCRED. For more deats see, - `man accept`, @@ -480,13 +505,31 @@ class MsgpackUDSStream(MsgpackTransport): 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) + + 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, + _, + _, + ) = get_peer_info(sock) + + # NOTE known to at least works on, + # - macos + else: + peer_pid: int = get_peer_pid(sock) filedir, filename = unwrap_sockpath(sock_path) laddr = UDSAddress( From 9c3d3bcec12f46879a1bb14f4efeb03e92f2c06c Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 3 Mar 2026 16:23:31 -0500 Subject: [PATCH 08/48] Add prompt flush hack for `bash` on macos as well.. --- tractor/devx/debug/_sigint.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tractor/devx/debug/_sigint.py b/tractor/devx/debug/_sigint.py index c2a16f62..a97dacd1 100644 --- a/tractor/devx/debug/_sigint.py +++ b/tractor/devx/debug/_sigint.py @@ -21,6 +21,7 @@ cancellation during REPL interaction. ''' from __future__ import annotations +import platform from typing import ( TYPE_CHECKING, ) @@ -49,6 +50,7 @@ if TYPE_CHECKING: log = get_logger() +_is_macos: bool = platform.system() == 'Darwin' _ctlc_ignore_header: str = ( 'Ignoring SIGINT while debug REPL in use' ) @@ -300,6 +302,11 @@ def sigint_shield( # XXX: yah, mega hack, but how else do we catch this madness XD if ( repl.shname == 'xonsh' + or ( + repl.shname == 'bash' + and + _is_macos + ) ): flush_status += ( '-> ALSO re-flushing due to `xonsh`..\n' From a7e74acdffba6f548992358005900f41f301d067 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 5 Mar 2026 16:41:37 -0500 Subject: [PATCH 09/48] Doc `getsockopt()` args (for macOS) Per the questionable `copilot` review which is detailed for follow up in https://github.com/goodboy/tractor/issues/418. These constants are directly linked from the kernel sources fwiw. --- tractor/ipc/_uds.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index b7612682..ce34c502 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -24,7 +24,6 @@ from contextlib import ( from pathlib import Path import os import sys -import socket as stdlib_socket from socket import ( AF_UNIX, SOCK_STREAM, @@ -361,10 +360,27 @@ def get_peer_pid(sock) -> int|None: NOTE, should work on MacOS (and others?). ''' - # try to get the peer PID + # 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(0, 2) + pid: int = sock.getsockopt( + SOL_LOCAL, + LOCAL_PEERPID, + ) return pid except socket_error as e: log.exception( From 70efcb09a0724e1db99595a508aaa709d396997d Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 5 Mar 2026 00:23:42 -0500 Subject: [PATCH 10/48] Slight refinements to `._state.get_rt_dir()` Per the `copilot` review, https://github.com/goodboy/tractor/pull/406#pullrequestreview-3893270953 now we also, - pass `exists_ok=True` to `.mkdir()` to avoid conc races. - expose `appname: str` param for caller override. - normalize `subdir` to avoid escaping the base rt-dir location. --- tractor/_state.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/tractor/_state.py b/tractor/_state.py index 1644cd8a..86e3ea12 100644 --- a/tractor/_state.py +++ b/tractor/_state.py @@ -175,28 +175,50 @@ def current_ipc_ctx( def get_rt_dir( subdir: str|Path|None = None, + appname: str = 'tractor', ) -> Path: ''' Return the user "runtime dir", the file-sys location where most userspace apps stick their IPC and cache related system util-files. - On linux we take use a `'${XDG_RUNTIME_DIR}/tractor/` subdir by - default but equivalents are mapped for each platform using - the lovely `platformdirs`. + 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( platformdirs.user_runtime_dir( - appname='tractor', + appname=appname, ), ) + + # Normalize and validate that `subdir` is a relative path + # without any parent-directory ("..") components, to prevent + # escaping the runtime directory. if subdir: - rt_dir: Path = rt_dir / 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 From daae19604898e946ad82ce4b709a7a922600b876 Mon Sep 17 00:00:00 2001 From: goodboy Date: Sun, 8 Mar 2026 18:06:43 -0400 Subject: [PATCH 11/48] Warn if `.ipc._uds.get_peer_pid()` returns null --- tractor/ipc/_uds.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index ce34c502..4b393fb6 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -545,7 +545,13 @@ class MsgpackUDSStream(MsgpackTransport): # NOTE known to at least works on, # - macos else: - peer_pid: int = get_peer_pid(sock) + 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) laddr = UDSAddress( From 83c8a8ad78a0adf574d9f60d2e9ecae76423f9c9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 26 Oct 2022 11:54:13 -0400 Subject: [PATCH 12/48] Add macos run using only the `trio` spawner --- .github/workflows/ci.yml | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be5cb272..c4e09f7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: 'Install uv + py-${{ matrix.python-version }}' uses: astral-sh/setup-uv@v6 with: @@ -120,6 +119,38 @@ jobs: - name: Run tests run: uv run pytest tests/ --spawn-backend=${{ matrix.spawn_backend }} -rsx + testing-macos: + name: '${{ matrix.os }} Python ${{ matrix.python }} - ${{ matrix.spawn_backend }}' + timeout-minutes: 10 + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [macos-latest] + python: ['3.13'] + spawn_backend: [ + 'trio', + ] + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: '${{ matrix.python }}' + + - name: Install the project w uv + run: uv sync --all-extras --dev + + - name: List deps tree + run: uv tree + + - name: Run tests w uv + 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 From aee86f25444e808c48a1e1baa467b4f17d4f99d1 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 24 Feb 2026 20:55:01 -0500 Subject: [PATCH 13/48] Run macos job on `uv` and newer `actions@v4` --- .github/workflows/ci.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4e09f7f..7a0d0d28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,13 +134,11 @@ jobs: ] steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Setup python - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: 'Install uv + py-${{ matrix.python-version }}' + uses: astral-sh/setup-uv@v6 with: - python-version: '${{ matrix.python }}' + python-version: ${{ matrix.python-version }} - name: Install the project w uv run: uv sync --all-extras --dev From 2631fb4ff3655c49b95a3d72644d68cf56a6b128 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 24 Feb 2026 21:05:16 -0500 Subject: [PATCH 14/48] Only run CI on Date: Thu, 26 Feb 2026 17:20:29 -0500 Subject: [PATCH 15/48] Use py version in job `name`, consider macos in linux matrix? --- .github/workflows/ci.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 837cb72f..f85019a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,14 +75,17 @@ jobs: testing-linux: - name: '${{ matrix.os }} Python ${{ matrix.python }} - ${{ matrix.spawn_backend }}' + name: '${{ matrix.os }} Python${{ matrix.python-version }} - spawn_backend=${{ matrix.spawn_backend }}' timeout-minutes: 10 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest] + os: [ + ubuntu-latest, + # macos-latest, # ?TODO, better? + ] python-version: [ '3.13', # '3.14', @@ -123,14 +126,16 @@ jobs: run: uv run pytest tests/ --spawn-backend=${{ matrix.spawn_backend }} -rsx testing-macos: - name: '${{ matrix.os }} Python ${{ matrix.python }} - ${{ matrix.spawn_backend }}' + name: '${{ matrix.os }} Python${{ matrix.python-version }} - spawn_backend=${{ matrix.spawn_backend }}' timeout-minutes: 10 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [macos-latest] + os: [ + macos-latest, + ] python-version: [ '3.13', # '3.14', From c5af2fa778f11d5ee5ea938c6954cce8aff7276c Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 24 Feb 2026 20:02:14 -0500 Subject: [PATCH 16/48] Add a `@no_macos` skipif deco --- tests/conftest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 47837fa5..c77c5407 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,10 @@ no_windows = pytest.mark.skipif( platform.system() == "Windows", reason="Test is unsupported on windows", ) +no_macos = pytest.mark.skipif( + platform.system() == "Darwin", + reason="Test is unsupported on MacOS", +) def pytest_addoption( From 706a4b761b6ce68346602ce9ca79df0983cb1977 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 24 Feb 2026 20:28:21 -0500 Subject: [PATCH 17/48] Add 6sec timeout around `test_simple_rpc` suite for macos --- tests/test_2way.py | 94 ++++++++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 41 deletions(-) diff --git a/tests/test_2way.py b/tests/test_2way.py index db3be4d9..f5a59cfe 100644 --- a/tests/test_2way.py +++ b/tests/test_2way.py @@ -1,15 +1,25 @@ -""" -Bidirectional streaming. +''' +Audit the simplest inter-actor bidirectional (streaming) +msg patterns. -""" +''' +from __future__ import annotations +from typing import ( + Callable, + TYPE_CHECKING, +) import pytest import trio import tractor +if TYPE_CHECKING: + from tractor import ( + Portal, + ) + @tractor.context async def simple_rpc( - ctx: tractor.Context, data: int, @@ -39,15 +49,14 @@ async def simple_rpc( @tractor.context async def simple_rpc_with_forloop( - ctx: tractor.Context, data: int, ) -> None: - """Same as previous test but using ``async for`` syntax/api. - - """ + ''' + Same as previous test but using `async for` syntax/api. + ''' # signal to parent that we're up await ctx.started(data + 1) @@ -74,56 +83,59 @@ async def simple_rpc_with_forloop( 'server_func', [simple_rpc, simple_rpc_with_forloop], ) -def test_simple_rpc(server_func, use_async_for): +def test_simple_rpc( + server_func: Callabe, + use_async_for: bool, +): ''' The simplest request response pattern. ''' async def main(): - async with tractor.open_nursery() as n: + with trio.fail_after(6): + async with tractor.open_nursery() as an: + portal: Portal = await an.start_actor( + 'rpc_server', + enable_modules=[__name__], + ) - portal = await n.start_actor( - 'rpc_server', - enable_modules=[__name__], - ) + async with portal.open_context( + server_func, # taken from pytest parameterization + data=10, + ) as (ctx, sent): - async with portal.open_context( - server_func, # taken from pytest parameterization - data=10, - ) as (ctx, sent): + assert sent == 11 - assert sent == 11 + async with ctx.open_stream() as stream: - async with ctx.open_stream() as stream: + if use_async_for: - if use_async_for: - - count = 0 - # receive msgs using async for style - print('ping') - await stream.send('ping') - - async for msg in stream: - assert msg == 'pong' + count = 0 + # receive msgs using async for style print('ping') await stream.send('ping') - count += 1 - if count >= 9: - break + async for msg in stream: + assert msg == 'pong' + print('ping') + await stream.send('ping') + count += 1 - else: - # classic send/receive style - for _ in range(10): + if count >= 9: + break - print('ping') - await stream.send('ping') - assert await stream.receive() == 'pong' + else: + # classic send/receive style + for _ in range(10): - # stream should terminate here + print('ping') + await stream.send('ping') + assert await stream.receive() == 'pong' - # final context result(s) should be consumed here in __aexit__() + # stream should terminate here - await portal.cancel_actor() + # final context result(s) should be consumed here in __aexit__() + + await portal.cancel_actor() trio.run(main) From 86c95539caf0d5042a2470bed77aeed53ddea912 Mon Sep 17 00:00:00 2001 From: goodboy Date: Sun, 1 Mar 2026 18:52:48 -0500 Subject: [PATCH 18/48] Loosen shml test assert for key shortening on macos --- tests/test_shm.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_shm.py b/tests/test_shm.py index ddeb67aa..00a36f8a 100644 --- a/tests/test_shm.py +++ b/tests/test_shm.py @@ -2,6 +2,7 @@ Shared mem primitives and APIs. """ +import platform import uuid # import numpy @@ -53,7 +54,18 @@ def test_child_attaches_alot(): shm_key=shml.key, ) as (ctx, start_val), ): - assert start_val == key + assert (_key := shml.key) == start_val + + 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 portal.cancel_actor() From b7546fd221b11f48a56cf30132543772c8cbe1a6 Mon Sep 17 00:00:00 2001 From: goodboy Date: Sun, 1 Mar 2026 20:35:28 -0500 Subject: [PATCH 19/48] Longer timeout for `test_one_end_stream_not_opened` On non-linux that is. --- tests/test_context_stream_semantics.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_context_stream_semantics.py b/tests/test_context_stream_semantics.py index 4c347e91..f860e4d3 100644 --- a/tests/test_context_stream_semantics.py +++ b/tests/test_context_stream_semantics.py @@ -9,6 +9,7 @@ from itertools import count import math import platform from pprint import pformat +import sys from typing import ( Callable, ) @@ -941,6 +942,11 @@ def test_one_end_stream_not_opened( from tractor._runtime import Actor buf_size = buf_size_increase + Actor.msg_buffer_size + timeout: float = ( + 1 if sys.platform == 'linux' + else 3 + ) + async def main(): async with tractor.open_nursery( debug_mode=debug_mode, @@ -950,7 +956,7 @@ def test_one_end_stream_not_opened( enable_modules=[__name__], ) - with trio.fail_after(1): + with trio.fail_after(timeout): async with portal.open_context( entrypoint, ) as (ctx, sent): From 82d02ef404dc20f76adf1870c23cd5105aa1a77a Mon Sep 17 00:00:00 2001 From: goodboy Date: Sun, 1 Mar 2026 23:38:18 -0500 Subject: [PATCH 20/48] Lul, never use `'uds'` tpt for macos test-scripts It's explained in the comment and i really think it's getting more hilarious the more i learn about the arbitrary limitations of user space with this tina platform. --- examples/debugging/shield_hang_in_sub.py | 16 +++++++++++++++- examples/debugging/subactor_bp_in_ctx.py | 17 ++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/examples/debugging/shield_hang_in_sub.py b/examples/debugging/shield_hang_in_sub.py index bf045fe8..280757ea 100644 --- a/examples/debugging/shield_hang_in_sub.py +++ b/examples/debugging/shield_hang_in_sub.py @@ -3,6 +3,7 @@ Verify we can dump a `stackscope` tree on a hang. ''' import os +import platform import signal import trio @@ -31,13 +32,26 @@ async def main( from_test: bool = False, ) -> 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 ( tractor.open_nursery( debug_mode=True, enable_stack_on_sig=True, # maybe_enable_greenback=False, loglevel='devx', - enable_transports=['uds'], + enable_transports=[tpt], ) as an, ): ptl: tractor.Portal = await an.start_actor( diff --git a/examples/debugging/subactor_bp_in_ctx.py b/examples/debugging/subactor_bp_in_ctx.py index f55d2cd4..0ca7097f 100644 --- a/examples/debugging/subactor_bp_in_ctx.py +++ b/examples/debugging/subactor_bp_in_ctx.py @@ -1,3 +1,5 @@ +import platform + import tractor import trio @@ -34,9 +36,22 @@ async def just_bp( 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( debug_mode=True, - enable_transports=['uds'], + enable_transports=[tpt], loglevel='devx', ) as n: p = await n.start_actor( From 7b89204afdf32710ce21486f148c7bcd5a14bd90 Mon Sep 17 00:00:00 2001 From: goodboy Date: Sun, 1 Mar 2026 23:32:36 -0500 Subject: [PATCH 21/48] Tweak `do_ctlc()`'s `delay` default To be a null default and set to `0.1` when not passed by the caller so as to avoid having to pass `0.1` if you wanted the param-defined-default. Also, - in the `spawn()` fixtures's `unset_colors()` closure, add in a masked `os.environ['NO_COLOR'] = '1'` since i found it while trying to debug debugger tests. - always return the `child.before` content from `assert_before()` helper; again it comes in handy when debugging console matching tests. --- tests/devx/conftest.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/devx/conftest.py b/tests/devx/conftest.py index efc74d44..b7559706 100644 --- a/tests/devx/conftest.py +++ b/tests/devx/conftest.py @@ -68,7 +68,10 @@ def spawn( ''' import os + # disable colored tbs os.environ['PYTHON_COLORS'] = '0' + # disable all ANSI color output + # os.environ['NO_COLOR'] = '1' spawned: PexpectSpawner|None = None @@ -251,12 +254,13 @@ def assert_before( err_on_false=True, **kwargs ) + return str(child.before.decode()) def do_ctlc( child, count: int = 3, - delay: float = 0.1, + delay: float|None = None, patt: str|None = None, # expect repl UX to reprint the prompt after every @@ -268,6 +272,7 @@ def do_ctlc( ) -> str|None: before: str|None = None + delay = delay or 0.1 # make sure ctl-c sends don't do anything but repeat output for _ in range(count): @@ -278,7 +283,10 @@ def do_ctlc( # if you run this test manually it works just fine.. if expect_prompt: time.sleep(delay) - child.expect(PROMPT) + child.expect( + PROMPT, + # timeout=1, # TODO? if needed + ) before = str(child.before.decode()) time.sleep(delay) From 51701fc8dc838647efe8392a70735f8fae539c56 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 00:15:49 -0500 Subject: [PATCH 22/48] Ok just skip `test_shield_pause` for macos.. Something something the SIGINT handler isn't being swapped correctly? --- tests/devx/test_tooling.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/devx/test_tooling.py b/tests/devx/test_tooling.py index 697b2bc1..0eb19182 100644 --- a/tests/devx/test_tooling.py +++ b/tests/devx/test_tooling.py @@ -31,6 +31,9 @@ from .conftest import ( PROMPT, _pause_msg, ) +from ..conftest import ( + no_macos, +) import pytest from pexpect.exceptions import ( @@ -42,6 +45,7 @@ if TYPE_CHECKING: from ..conftest import PexpectSpawner +@no_macos def test_shield_pause( spawn: PexpectSpawner, ): @@ -57,6 +61,7 @@ def test_shield_pause( expect( child, 'Yo my child hanging..?', + timeout=3, ) assert_before( child, From b30faaca82edd25bf9d9cec4198dcf6cecf134c0 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 00:16:10 -0500 Subject: [PATCH 23/48] Adjust debugger test suites for macos Namely, after trying to get `test_multi_daemon_subactors` to work for the `ctlc=True` case (for way too long), give up on that (see todo/comments) and skip it; the normal case works just fine. Also tweak the `test_ctxep_pauses_n_maybe_ipc_breaks` pattern matching for non-`'UDS'` per the previous script commit; we can't use UDS alongside `pytest`'s tmp dir generation, mega lulz. --- tests/devx/test_debugger.py | 91 ++++++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/tests/devx/test_debugger.py b/tests/devx/test_debugger.py index d3f9fa5d..f415f6c1 100644 --- a/tests/devx/test_debugger.py +++ b/tests/devx/test_debugger.py @@ -51,13 +51,14 @@ if TYPE_CHECKING: # - recurrent root errors +_non_linux: bool = platform.system() != 'Linux' + if platform.system() == 'Windows': pytest.skip( 'Debugger tests have no windows support (yet)', allow_module_level=True, ) - # 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 # thinkin... @@ -480,8 +481,24 @@ def test_multi_daemon_subactors( stream. ''' - child = spawn('multi_daemon_subactors') + 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.expect(PROMPT) # there can be a race for which subactor will acquire @@ -511,8 +528,19 @@ def test_multi_daemon_subactors( else: raise ValueError('Neither log msg was found !?') + non_linux_delay: float = 0.3 if ctlc: - do_ctlc(child) + do_ctlc( + 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 # in the root actor this final resume could result in the debugger @@ -543,33 +571,66 @@ def test_multi_daemon_subactors( # assert "in use by child ('bp_forever'," in before if ctlc: - do_ctlc(child) + do_ctlc( + child, + delay=( + non_linux_delay + if non_linux + else None + ), + ) + + if non_linux: + time.sleep(1) # expect another breakpoint actor entry child.sendline('c') child.expect(PROMPT) - try: - assert_before( + before: str = assert_before( child, bp_forev_parts, ) except AssertionError: - assert_before( + before: str = assert_before( child, name_error_parts, ) else: if ctlc: - do_ctlc(child) + before: str = do_ctlc( + child, + delay=( + non_linux_delay + if non_linux + else None + ), + ) + + if non_linux: + time.sleep(1) # should crash with the 2nd name error (simulates # a retry) and then the root eventually (boxed) errors # after 1 or more further bp actor entries. child.sendline('c') - child.expect(PROMPT) + try: + 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( child, name_error_parts, @@ -1133,14 +1194,20 @@ def test_ctxep_pauses_n_maybe_ipc_breaks( # closed so verify we see error reporting as well as # a failed crash-REPL request msg and can CTL-c our way # 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( child, ['peer IPC channel closed abruptly?', 'another task closed this fd', 'Debug lock request was CANCELLED?', - "'MsgpackUDSStream' was already closed locally?", - "TransportClosed: 'MsgpackUDSStream' was already closed 'by peer'?", - # ?TODO^? match depending on `tpt_proto(s)`? + f"'Msgpack{tpt}Stream' was already closed locally?", + f"TransportClosed: 'Msgpack{tpt}Stream' was already closed 'by peer'?", ] # XXX races on whether these show/hit? From e8f3d64e71f750430decf6ef7ee5696462f9ffab Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 12:07:41 -0500 Subject: [PATCH 24/48] Increase prompt timeout for macos in CI --- tests/devx/test_debugger.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/devx/test_debugger.py b/tests/devx/test_debugger.py index f415f6c1..f57de285 100644 --- a/tests/devx/test_debugger.py +++ b/tests/devx/test_debugger.py @@ -750,7 +750,8 @@ def test_multi_subactors_root_errors( @has_nested_actors def test_multi_nested_subactors_error_through_nurseries( - spawn, + ci_env: bool, + spawn: PexpectSpawner, # TODO: address debugger issue for nested tree: # https://github.com/goodboy/tractor/issues/320 @@ -773,7 +774,16 @@ def test_multi_nested_subactors_error_through_nurseries( for send_char in itertools.cycle(['c', 'q']): try: - child.expect(PROMPT) + child.expect( + PROMPT, + timeout=( + 2 if ( + _non_linux + and + ci_env + ) else -1 + ), + ) child.sendline(send_char) time.sleep(0.01) From a1ea373f34a28473e67f46032a4abf148d77fbdb Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 13:55:14 -0500 Subject: [PATCH 25/48] Ok.. try a longer prompt timeout? --- tests/devx/test_debugger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/devx/test_debugger.py b/tests/devx/test_debugger.py index f57de285..43adbec1 100644 --- a/tests/devx/test_debugger.py +++ b/tests/devx/test_debugger.py @@ -777,7 +777,7 @@ def test_multi_nested_subactors_error_through_nurseries( child.expect( PROMPT, timeout=( - 2 if ( + 6 if ( _non_linux and ci_env From 9c1bcb23af11bbecbd87658724fdb4271bc6281e Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 11:28:57 -0500 Subject: [PATCH 26/48] Skip legacy-one-way suites on non-linux in CI --- tests/test_legacy_one_way_streaming.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/test_legacy_one_way_streaming.py b/tests/test_legacy_one_way_streaming.py index 10cf3aed..a685f924 100644 --- a/tests/test_legacy_one_way_streaming.py +++ b/tests/test_legacy_one_way_streaming.py @@ -240,6 +240,10 @@ def time_quad_ex( ci_env: bool, spawn_backend: str, ): + non_linux: bool = (_sys := platform.system()) != 'Linux' + if ci_env and non_linux: + pytest.skip("Test is too flaky on {_sys!r} in CI") + if spawn_backend == 'mp': ''' no idea but the mp *nix runs are flaking out here often... @@ -247,7 +251,7 @@ def time_quad_ex( ''' pytest.skip("Test is too flaky on mp in CI") - timeout = 7 if platform.system() in ('Windows', 'Darwin') else 4 + timeout = 7 if non_linux else 4 start = time.time() results = trio.run(cancel_after, timeout, reg_addr) diff = time.time() - start @@ -264,13 +268,12 @@ def test_a_quadruple_example( 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 assert results this_fast = ( - 6 if platform.system() in ( - 'Windows', - 'Darwin', - ) + 6 if non_linux else 3 ) assert diff < this_fast From 94dfeb14410c971dc42d6da427c88a484de975ea Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 12:11:49 -0500 Subject: [PATCH 27/48] Add delay before root-actor open, macos in CI.. --- tests/test_multi_program.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_multi_program.py b/tests/test_multi_program.py index 17003e1c..20e13f97 100644 --- a/tests/test_multi_program.py +++ b/tests/test_multi_program.py @@ -35,6 +35,9 @@ if TYPE_CHECKING: ) +_non_linux: bool = platform.system() != 'Linux' + + def test_abort_on_sigint( daemon: subprocess.Popen, ): @@ -137,6 +140,7 @@ def test_non_registrar_spawns_child( reg_addr: UnwrappedAddress, loglevel: str, debug_mode: bool, + ci_env: bool, ): ''' Ensure a non-regristar (serving) root actor can spawn a sub and @@ -148,6 +152,12 @@ def test_non_registrar_spawns_child( ''' 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( registry_addrs=[reg_addr], loglevel=loglevel, From fb73935dbc241a94db55a7f73e5cdf523739d1f9 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 14:57:40 -0500 Subject: [PATCH 28/48] Add a `test_log` fixture for emitting from *within* test bodies or fixtures --- tests/conftest.py | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c77c5407..60225e00 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ import platform import time import pytest +import tractor from tractor._testing import ( examples_dir as examples_dir, tractor_test as tractor_test, @@ -65,7 +66,7 @@ def pytest_addoption( @pytest.fixture(scope='session', autouse=True) -def loglevel(request): +def loglevel(request) -> str: import tractor orig = tractor.log._default_loglevel level = tractor.log._default_loglevel = request.config.option.loglevel @@ -73,11 +74,46 @@ def loglevel(request): level=level, name='tractor', # <- enable root logger ) - log.info(f'Test-harness logging level: {level}\n') + log.info( + f'Test-harness set runtime loglevel: {level!r}\n' + ) yield level 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) From 0e2949ea598806e7ddf83d236e7c69462635e2bc Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 14:58:16 -0500 Subject: [PATCH 29/48] Bump docs-exs subproc timeout, exception log any timeouts --- tests/test_docs_examples.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index b4cf85eb..8b7d4c72 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -9,8 +9,10 @@ import sys import subprocess import platform import shutil +from typing import Callable import pytest +import tractor from tractor._testing import ( examples_dir, ) @@ -101,8 +103,10 @@ def run_example_in_subproc( ids=lambda t: t[1], ) def test_example( - run_example_in_subproc, - example_script, + run_example_in_subproc: Callable, + example_script: str, + test_log: tractor.log.StackLevelAdapter, + ci_env: bool, ): ''' Load and run scripts from this repo's ``examples/`` dir as a user @@ -119,6 +123,8 @@ def test_example( if 'rpc_bidir_streaming' in ex_file and sys.version_info < (3, 9): pytest.skip("2-way streaming example requires py3.9 async with syntax") + timeout: float = 16 + with open(ex_file, 'r') as ex: code = ex.read() @@ -126,9 +132,12 @@ def test_example( err = None try: if not proc.poll(): - _, err = proc.communicate(timeout=15) + _, err = proc.communicate(timeout=timeout) except subprocess.TimeoutExpired as e: + test_log.exception( + f'Example failed to finish within {timeout}s ??\n' + ) proc.kill() err = e.stderr From a72bb9321e6bb2c47ae945a66ff9a24ba327e559 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 16:33:21 -0500 Subject: [PATCH 30/48] Bleh, bump timeout again for docs-exs suite when in CI --- tests/test_docs_examples.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index 8b7d4c72..d6794925 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -17,6 +17,8 @@ from tractor._testing import ( examples_dir, ) +_non_linux: bool = platform.system() != 'Linux' + @pytest.fixture def run_example_in_subproc( @@ -123,7 +125,10 @@ def test_example( if 'rpc_bidir_streaming' in ex_file and sys.version_info < (3, 9): pytest.skip("2-way streaming example requires py3.9 async with syntax") - timeout: float = 16 + timeout: float = ( + 30 if ci_env and _non_linux + else 16 + ) with open(ex_file, 'r') as ex: code = ex.read() From ab6c955949bd2f8fc86f6518efe5c835ee5faa81 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 17:44:21 -0500 Subject: [PATCH 31/48] Lol fine! bump it a bit more XD --- tests/test_docs_examples.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index d6794925..42a7fea5 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -126,7 +126,8 @@ def test_example( pytest.skip("2-way streaming example requires py3.9 async with syntax") timeout: float = ( - 30 if ci_env and _non_linux + 36 + if ci_env and _non_linux else 16 ) From 98a7d693415237181bcd7ade55d107d44b57f125 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 15:27:01 -0500 Subject: [PATCH 32/48] Always pre-sleep in `daemon` fixture when in non-linux CI.. --- tests/conftest.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 60225e00..09602c96 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,7 @@ pytest_plugins: list[str] = [ 'tractor._testing.pytest', ] +_non_linux: bool = platform.system() != 'Linux' # Sending signal.SIGINT on subprocess fails on windows. Use CTRL_* alternatives if platform.system() == 'Windows': @@ -150,6 +151,7 @@ def daemon( testdir: pytest.Pytester, reg_addr: tuple[str, int], tpt_proto: str, + ci_env: bool, ) -> subprocess.Popen: ''' @@ -197,6 +199,13 @@ def daemon( time.sleep(_PROC_SPAWN_WAIT) assert not proc.returncode + # TODO! we should poll for the registry socket-bind to take place + # and only once that's done yield to the requester! + # -[ ] use the `._root.open_root_actor()`::`ping_tpt_socket()` + # closure! + if _non_linux and ci_env: + time.sleep(1) + yield proc sig_prog(proc, _INT_SIGNAL) From 4639685770217fe5b4a63a7f97acd601cec7266f Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 17:13:23 -0500 Subject: [PATCH 33/48] Fill out types in `test_discovery` mod --- tests/test_discovery.py | 85 +++++++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 32 deletions(-) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 453b1aa3..3e5964ec 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -1,11 +1,13 @@ """ -Actor "discovery" testing +Discovery subsys. + """ import os import signal import platform from functools import partial import itertools +from typing import Callable import psutil import pytest @@ -17,7 +19,9 @@ import trio @tractor_test -async def test_reg_then_unreg(reg_addr): +async def test_reg_then_unreg( + reg_addr: tuple, +): actor = tractor.current_actor() assert actor.is_arbiter assert len(actor._registry) == 1 # only self is registered @@ -82,11 +86,15 @@ async def say_hello_use_wait( @tractor_test -@pytest.mark.parametrize('func', [say_hello, say_hello_use_wait]) +@pytest.mark.parametrize( + 'func', + [say_hello, + say_hello_use_wait] +) async def test_trynamic_trio( - func, - start_method, - reg_addr, + func: Callable, + start_method: str, + reg_addr: tuple, ): ''' Root actor acting as the "director" and running one-shot-task-actors @@ -119,7 +127,10 @@ async def stream_forever(): await trio.sleep(0.01) -async def cancel(use_signal, delay=0): +async def cancel( + use_signal: bool, + delay: float = 0, +): # hold on there sally await trio.sleep(delay) @@ -132,13 +143,15 @@ async def cancel(use_signal, delay=0): raise KeyboardInterrupt -async def stream_from(portal): +async def stream_from(portal: tractor.Portal): async with portal.open_stream_from(stream_forever) as stream: async for value in stream: print(value) -async def unpack_reg(actor_or_portal): +async def unpack_reg( + actor_or_portal: tractor.Portal|tractor.Actor, +): ''' Get and unpack a "registry" RPC request from the "arbiter" registry system. @@ -173,7 +186,9 @@ async def spawn_and_check_registry( registry_addrs=[reg_addr], debug_mode=debug_mode, ): - async with tractor.get_registry(reg_addr) as portal: + async with tractor.get_registry( + addr=reg_addr, + ) as portal: # runtime needs to be up to call this actor = tractor.current_actor() @@ -246,10 +261,10 @@ async def spawn_and_check_registry( @pytest.mark.parametrize('with_streaming', [False, True]) def test_subactors_unregister_on_cancel( debug_mode: bool, - start_method, - use_signal, - reg_addr, - with_streaming, + start_method: str, + use_signal: bool, + reg_addr: tuple, + with_streaming: bool, ): ''' Verify that cancelling a nursery results in all subactors @@ -274,15 +289,17 @@ def test_subactors_unregister_on_cancel( def test_subactors_unregister_on_cancel_remote_daemon( daemon: subprocess.Popen, debug_mode: bool, - start_method, - use_signal, - reg_addr, - with_streaming, + start_method: str, + use_signal: bool, + reg_addr: tuple, + with_streaming: bool, ): - """Verify that cancelling a nursery results in all subactors - deregistering themselves with a **remote** (not in the local process - tree) arbiter. - """ + ''' + Verify that cancelling a nursery results in all subactors + deregistering themselves with a **remote** (not in the local + process tree) arbiter. + + ''' with pytest.raises(KeyboardInterrupt): trio.run( partial( @@ -374,14 +391,16 @@ async def close_chans_before_nursery( @pytest.mark.parametrize('use_signal', [False, True]) def test_close_channel_explicit( - start_method, - use_signal, - reg_addr, + start_method: str, + use_signal: bool, + reg_addr: tuple, ): - """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 results in subactor(s) deregistering from the arbiter. - """ + + ''' with pytest.raises(KeyboardInterrupt): trio.run( partial( @@ -396,14 +415,16 @@ def test_close_channel_explicit( @pytest.mark.parametrize('use_signal', [False, True]) def test_close_channel_explicit_remote_arbiter( daemon: subprocess.Popen, - start_method, - use_signal, - reg_addr, + start_method: str, + use_signal: bool, + reg_addr: tuple, ): - """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 results in subactor(s) deregistering from the arbiter. - """ + + ''' with pytest.raises(KeyboardInterrupt): trio.run( partial( From 776af3fce64520b6d0a2caec0480c9ea624be551 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 2 Mar 2026 18:08:58 -0500 Subject: [PATCH 34/48] Register our `ctlcs_bish` marker to avoid warnings --- tests/devx/conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/devx/conftest.py b/tests/devx/conftest.py index b7559706..dc148d47 100644 --- a/tests/devx/conftest.py +++ b/tests/devx/conftest.py @@ -33,6 +33,14 @@ if TYPE_CHECKING: from pexpect import pty_spawn +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()` # and returns it. type PexpectSpawner = Callable[ From 5b2905b70212dd8e6db2083b9b987cc9516f79ef Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 3 Mar 2026 13:47:04 -0500 Subject: [PATCH 35/48] Xplatform tweaks for `daemon` fixture There's a very sloppy registrar-actor-bootup syncing approach used in this fixture (basically just guessing how long to sleep to wait for it to init and bind the registry socket) using a `global _PROC_SPAWN_WAIT` that needs to be made more reliable. But, for now i'm just playing along with what's there to try and make less CI runs flaky by, - sleeping *another* 1s when run from non-linux CI. - reporting stdout (if any) alongside stderr on teardown. - not strictly requiring a `proc.returncode == -2` indicating successful graceful cancellation via SIGINT; instead we now error-log and only raise the RTE on `< 0` exit code. * though i can't think of why this would happen other then an underlying crash which should propagate.. but i don't think any test suite does this intentionally rn? * though i don't think it should ever happen, having a CI run "error"-fail bc of this isn't all that illuminating, if there is some weird `.returncode == 0` termination case it's likely not a failure? For later, see the new todo list; we should sync to some kind of "ping" polling of the tpt address if possible which is already easy enough for TCP reusing an internal closure from `._root.open_root_actor()`. --- tests/conftest.py | 47 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 09602c96..31787fe2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -189,41 +189,58 @@ def daemon( **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() # 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 + 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) assert not proc.returncode - # TODO! we should poll for the registry socket-bind to take place - # and only once that's done yield to the requester! - # -[ ] use the `._root.open_root_actor()`::`ping_tpt_socket()` - # closure! - if _non_linux and ci_env: - time.sleep(1) - yield proc 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: + stdout: str = proc.stdout.read().decode() + if ( + stderr + or + stdout + ): print( - f'Daemon actor tree produced STDERR:\n' + f'Daemon actor tree produced output:\n' f'{proc.args}\n' f'\n' - f'{stderr}\n' + f'stderr: {stderr!r}\n' + f'stdout: {stdout!r}\n' ) - if proc.returncode != -2: - raise RuntimeError( - 'Daemon actor tree failed !?\n' - f'{proc.args}\n' + + if (rc := proc.returncode) != -2: + msg: str = ( + f'Daemon actor tree was not cancelled !?\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) From 79396b4a26709734907872da535bec45c73c65f8 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 3 Mar 2026 15:46:21 -0500 Subject: [PATCH 36/48] 2x the ctl-c loop prompt-timeout for non-linux in CI --- tests/devx/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/devx/conftest.py b/tests/devx/conftest.py index dc148d47..c850da7a 100644 --- a/tests/devx/conftest.py +++ b/tests/devx/conftest.py @@ -293,7 +293,7 @@ def do_ctlc( time.sleep(delay) child.expect( PROMPT, - # timeout=1, # TODO? if needed + timeout=(child.timeout * 2) if _ci_env else child.timeout, ) before = str(child.before.decode()) time.sleep(delay) From 712c009790681df4e3d51a712490ae5faf9d54f0 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 3 Mar 2026 20:55:57 -0500 Subject: [PATCH 37/48] Hike `testdir.spawn()` timeout on non-linux in CI --- tests/devx/conftest.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/devx/conftest.py b/tests/devx/conftest.py index c850da7a..c194026c 100644 --- a/tests/devx/conftest.py +++ b/tests/devx/conftest.py @@ -3,8 +3,9 @@ ''' from __future__ import annotations -import time +import platform import signal +import time from typing import ( Callable, TYPE_CHECKING, @@ -33,6 +34,9 @@ if TYPE_CHECKING: 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 @@ -94,7 +98,10 @@ def spawn( cmd, **mkcmd_kwargs, ), - expect_timeout=3, + expect_timeout=( + 6 if _non_linux and _ci_env + else 3 + ), # preexec_fn=unset_colors, # ^TODO? get `pytest` core to expose underlying # `pexpect.spawn()` stuff? From 016306adf5c4cf3ae028202c247842d6a49767db Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 4 Mar 2026 11:49:01 -0500 Subject: [PATCH 38/48] Allow `ctlcs_bish()` skipping Via ensuring `all(mark.args)` on wtv expressions are arg-passed to the mark decorator; use it to skip the `test_subactor_breakpoint` suite when `ctlc=True` since it seems too unreliable in CI. --- tests/devx/conftest.py | 4 +++- tests/devx/test_debugger.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/devx/conftest.py b/tests/devx/conftest.py index c194026c..fbb4ff6b 100644 --- a/tests/devx/conftest.py +++ b/tests/devx/conftest.py @@ -99,7 +99,7 @@ def spawn( **mkcmd_kwargs, ), expect_timeout=( - 6 if _non_linux and _ci_env + 10 if _non_linux and _ci_env else 3 ), # preexec_fn=unset_colors, @@ -164,6 +164,8 @@ def ctlc( mark.name == 'ctlcs_bish' and use_ctlc + and + all(mark.args) ): pytest.skip( f'Test {node} prolly uses something from the stdlib (namely `asyncio`..)\n' diff --git a/tests/devx/test_debugger.py b/tests/devx/test_debugger.py index 43adbec1..7f602441 100644 --- a/tests/devx/test_debugger.py +++ b/tests/devx/test_debugger.py @@ -37,6 +37,9 @@ from .conftest import ( in_prompt_msg, assert_before, ) +from ..conftest import ( + _ci_env, +) if TYPE_CHECKING: from ..conftest import PexpectSpawner @@ -259,6 +262,11 @@ def test_subactor_error( child.expect(EOF) +# skip on non-Linux CI +@pytest.mark.ctlcs_bish( + _non_linux, + _ci_env, +) def test_subactor_breakpoint( spawn, ctlc: bool, From bbc028e84c8692ccdaf15dd0f1bed7ebe94edb75 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 9 Mar 2026 16:17:46 -0400 Subject: [PATCH 39/48] Increase macos job timeout to 16s --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f85019a9..195a3c46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,7 +127,7 @@ jobs: testing-macos: name: '${{ matrix.os }} Python${{ matrix.python-version }} - spawn_backend=${{ matrix.spawn_backend }}' - timeout-minutes: 10 + timeout-minutes: 16 runs-on: ${{ matrix.os }} strategy: From b71e8575e5d5755dd91606ab9d29dff9ce9add30 Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 4 Mar 2026 12:25:03 -0500 Subject: [PATCH 40/48] Skip a couple more `ctlc` flaking suites --- tests/devx/test_debugger.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/devx/test_debugger.py b/tests/devx/test_debugger.py index 7f602441..e6e40b51 100644 --- a/tests/devx/test_debugger.py +++ b/tests/devx/test_debugger.py @@ -197,6 +197,11 @@ def test_root_actor_bp_forever( child.expect(EOF) +# skip on non-Linux CI +@pytest.mark.ctlcs_bish( + _non_linux, + _ci_env, +) @pytest.mark.parametrize( 'do_next', (True, False), @@ -968,6 +973,11 @@ 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( spawn, ctlc: bool, From fb94aa00956bb59500008bc7d44f90ac28816f2d Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 9 Mar 2026 19:35:47 -0400 Subject: [PATCH 41/48] Tidy a typing-typo, add explicit `ids=` for paramed suites --- tests/test_2way.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/tests/test_2way.py b/tests/test_2way.py index f5a59cfe..2d052668 100644 --- a/tests/test_2way.py +++ b/tests/test_2way.py @@ -6,23 +6,16 @@ msg patterns. from __future__ import annotations from typing import ( Callable, - TYPE_CHECKING, ) import pytest import trio import tractor -if TYPE_CHECKING: - from tractor import ( - Portal, - ) - @tractor.context async def simple_rpc( ctx: tractor.Context, data: int, - ) -> None: ''' Test a small ping-pong server. @@ -51,7 +44,6 @@ async def simple_rpc( async def simple_rpc_with_forloop( ctx: tractor.Context, data: int, - ) -> None: ''' Same as previous test but using `async for` syntax/api. @@ -77,15 +69,25 @@ async def simple_rpc_with_forloop( @pytest.mark.parametrize( 'use_async_for', - [True, False], + [ + True, + False, + ], + ids='use_async_for={}'.format, ) @pytest.mark.parametrize( 'server_func', - [simple_rpc, simple_rpc_with_forloop], + [ + simple_rpc, + simple_rpc_with_forloop, + ], + ids='server_func={}'.format, ) def test_simple_rpc( - server_func: Callabe, + server_func: Callable, use_async_for: bool, + loglevel: str, + debug_mode: bool, ): ''' The simplest request response pattern. @@ -93,8 +95,11 @@ def test_simple_rpc( ''' async def main(): with trio.fail_after(6): - async with tractor.open_nursery() as an: - portal: Portal = await an.start_actor( + async with tractor.open_nursery( + loglevel=loglevel, + debug_mode=debug_mode, + ) as an: + portal: tractor.Portal = await an.start_actor( 'rpc_server', enable_modules=[__name__], ) From d135ce94afcca42f0a0e945ebac18f6bea5ea576 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 9 Mar 2026 19:46:25 -0400 Subject: [PATCH 42/48] Restyle `test_legacy_one_way_streaming` mod - convert all doc-strings to `'''` multiline style. - rename `nursery` -> `an`, `n` -> `tn` to match project-wide conventions. - add type annotations to fn params (fixtures, test helpers). - break long lines into multiline style for fn calls, assertions, and `parametrize` decorator lists. - add `ids=` to `@pytest.mark.parametrize`. - use `'` over `"` for string literals. - add `from typing import Callable` import. - drop spurious blank lines inside generators. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_legacy_one_way_streaming.py | 139 +++++++++++++++++-------- 1 file changed, 94 insertions(+), 45 deletions(-) diff --git a/tests/test_legacy_one_way_streaming.py b/tests/test_legacy_one_way_streaming.py index a685f924..e7821661 100644 --- a/tests/test_legacy_one_way_streaming.py +++ b/tests/test_legacy_one_way_streaming.py @@ -1,9 +1,11 @@ """ -Streaming via async gen api +Streaming via the, now legacy, "async-gen API". + """ import time from functools import partial import platform +from typing import Callable import trio import tractor @@ -19,7 +21,11 @@ def test_must_define_ctx(): async def no_ctx(): pass - assert "no_ctx must be `ctx: tractor.Context" in str(err.value) + assert ( + "no_ctx must be `ctx: tractor.Context" + in + str(err.value) + ) @tractor.stream async def has_ctx(ctx): @@ -69,14 +75,14 @@ async def stream_from_single_subactor( async with tractor.open_nursery( registry_addrs=[reg_addr], start_method=start_method, - ) as nursery: + ) as an: async with tractor.find_actor('streamerd') as portals: if not portals: # no brokerd actor found - portal = await nursery.start_actor( + portal = await an.start_actor( 'streamerd', enable_modules=[__name__], ) @@ -116,11 +122,22 @@ async def stream_from_single_subactor( @pytest.mark.parametrize( - 'stream_func', [async_gen_stream, context_stream] + 'stream_func', + [ + async_gen_stream, + context_stream, + ], + ids='stream_func={}'.format ) -def test_stream_from_single_subactor(reg_addr, start_method, stream_func): - """Verify streaming from a spawned async generator. - """ +def test_stream_from_single_subactor( + reg_addr: tuple, + start_method: str, + stream_func: Callable, +): + ''' + Verify streaming from a spawned async generator. + + ''' trio.run( partial( stream_from_single_subactor, @@ -132,10 +149,9 @@ def test_stream_from_single_subactor(reg_addr, start_method, stream_func): # this is the first 2 actors, streamer_1 and streamer_2 -async def stream_data(seed): +async def stream_data(seed: int): for i in range(seed): - yield i # trigger scheduler to simulate practical usage @@ -143,15 +159,17 @@ async def stream_data(seed): # this is the third actor; the aggregator -async def aggregate(seed): - """Ensure that the two streams we receive match but only stream +async def aggregate(seed: int): + ''' + Ensure that the two streams we receive match but only stream a single set of values to the parent. - """ - async with tractor.open_nursery() as nursery: + + ''' + async with tractor.open_nursery() as an: portals = [] for i in range(1, 3): # fork point - portal = await nursery.start_actor( + portal = await an.start_actor( name=f'streamer_{i}', enable_modules=[__name__], ) @@ -164,7 +182,8 @@ async def aggregate(seed): async with send_chan: async with portal.open_stream_from( - stream_data, seed=seed, + stream_data, + seed=seed, ) as stream: async for value in stream: @@ -174,10 +193,14 @@ async def aggregate(seed): print(f"FINISHED ITERATING {portal.channel.uid}") # spawn 2 trio tasks to collect streams and push to a local queue - async with trio.open_nursery() as n: + async with trio.open_nursery() as tn: for portal in portals: - n.start_soon(push_to_chan, portal, send_chan.clone()) + tn.start_soon( + push_to_chan, + portal, + send_chan.clone(), + ) # close this local task's reference to send side await send_chan.aclose() @@ -194,20 +217,21 @@ async def aggregate(seed): print("FINISHED ITERATING in aggregator") - await nursery.cancel() + await an.cancel() print("WAITING on `ActorNursery` to finish") print("AGGREGATOR COMPLETE!") -# this is the main actor and *arbiter* -async def a_quadruple_example(): - # a nursery which spawns "actors" - async with tractor.open_nursery() as nursery: +async def a_quadruple_example() -> list[int]: + ''' + Open the root-actor which is also a "registrar". + ''' + async with tractor.open_nursery() as an: seed = int(1e3) pre_start = time.time() - portal = await nursery.start_actor( + portal = await an.start_actor( name='aggregator', enable_modules=[__name__], ) @@ -228,8 +252,14 @@ async def a_quadruple_example(): return result_stream -async def cancel_after(wait, reg_addr): - async with tractor.open_root_actor(registry_addrs=[reg_addr]): +async def cancel_after( + wait: float, + reg_addr: tuple, +) -> list[int]: + + async with tractor.open_root_actor( + registry_addrs=[reg_addr], + ): with trio.move_on_after(wait): return await a_quadruple_example() @@ -242,7 +272,7 @@ def time_quad_ex( ): non_linux: bool = (_sys := platform.system()) != 'Linux' if ci_env and non_linux: - pytest.skip("Test is too flaky on {_sys!r} in CI") + pytest.skip(f'Test is too flaky on {_sys!r} in CI') if spawn_backend == 'mp': ''' @@ -253,14 +283,18 @@ def time_quad_ex( timeout = 7 if non_linux else 4 start = time.time() - results = trio.run(cancel_after, timeout, reg_addr) - diff = time.time() - start + results: list[int] = trio.run( + cancel_after, + timeout, + reg_addr, + ) + diff: float = time.time() - start assert results return results, diff def test_a_quadruple_example( - time_quad_ex: tuple, + time_quad_ex: tuple[list[int], float], ci_env: bool, spawn_backend: str, ): @@ -284,19 +318,33 @@ def test_a_quadruple_example( list(map(lambda i: i/10, range(3, 9))) ) def test_not_fast_enough_quad( - reg_addr, time_quad_ex, cancel_delay, ci_env, spawn_backend + reg_addr: tuple, + 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 - cancel gracefully. - """ + ''' + Verify we can cancel midway through the quad example and all + actors cancel gracefully. + + ''' results, diff = time_quad_ex delay = max(diff - cancel_delay, 0) - results = trio.run(cancel_after, delay, reg_addr) - system = platform.system() - if system in ('Windows', 'Darwin') and results is not None: + results = trio.run( + cancel_after, + delay, + 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 # so just ignore these - print(f"Woa there {system} caught your breath eh?") + print(f'Woa there {system} caught your breath eh?') else: # should be cancelled mid-streaming assert results is None @@ -304,23 +352,24 @@ def test_not_fast_enough_quad( @tractor_test async def test_respawn_consumer_task( - reg_addr, - spawn_backend, - loglevel, + reg_addr: tuple, + spawn_backend: str, + loglevel: str, ): - """Verify that ``._portal.ReceiveStream.shield()`` + ''' + Verify that ``._portal.ReceiveStream.shield()`` sucessfully protects the underlying IPC channel from being closed when cancelling and respawning a consumer task. This also serves to verify that all values from the stream can be received despite the respawns. - """ + ''' stream = None - async with tractor.open_nursery() as n: + async with tractor.open_nursery() as an: - portal = await n.start_actor( + portal = await an.start_actor( name='streamer', enable_modules=[__name__] ) From f9bdb1b35ddaf081466a29ed3400ae21aecd919b Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 5 Mar 2026 21:52:31 -0500 Subject: [PATCH 43/48] Try one more timeout bumps for flaky docs streaming ex.. --- tests/test_docs_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index 42a7fea5..9f386c62 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -126,7 +126,7 @@ def test_example( pytest.skip("2-way streaming example requires py3.9 async with syntax") timeout: float = ( - 36 + 60 if ci_env and _non_linux else 16 ) From afd66ce3b732f75496382a5b69df782ffec5326e Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 5 Mar 2026 23:07:02 -0500 Subject: [PATCH 44/48] Final try, drop logging level in streaming example to see if macos can cope.. --- examples/full_fledged_streaming_service.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/full_fledged_streaming_service.py b/examples/full_fledged_streaming_service.py index d859f647..390a1b75 100644 --- a/examples/full_fledged_streaming_service.py +++ b/examples/full_fledged_streaming_service.py @@ -90,7 +90,7 @@ async def main() -> list[int]: # yes, a nursery which spawns `trio`-"actors" B) an: ActorNursery async with tractor.open_nursery( - loglevel='cancel', + loglevel='error', # debug_mode=True, ) as an: @@ -118,8 +118,10 @@ async def main() -> list[int]: cancelled: bool = await portal.cancel_actor() assert cancelled - print(f"STREAM TIME = {time.time() - start}") - print(f"STREAM + SPAWN TIME = {time.time() - pre_start}") + print( + f"STREAM TIME = {time.time() - start}\n" + f"STREAM + SPAWN TIME = {time.time() - pre_start}\n" + ) assert result_stream == list(range(seed)) return result_stream From 9c4cd869fb08f16f0b855df9afaa49753a2b2017 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 5 Mar 2026 23:43:23 -0500 Subject: [PATCH 45/48] OK-FINE, skip streaming docs example on macos! It seems something is up with their VM-img or wtv bc i keep increasing the subproc timeout and nothing is changing. Since i can't try a `-xlarge` one without paying i'm just muting this test for now. --- tests/test_docs_examples.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index 9f386c62..0cf55d51 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -18,6 +18,7 @@ from tractor._testing import ( ) _non_linux: bool = platform.system() != 'Linux' +_friggin_macos: bool = platform.system() == 'Darwin' @pytest.fixture @@ -122,9 +123,26 @@ def test_example( ''' ex_file: str = os.path.join(*example_script) - if 'rpc_bidir_streaming' in ex_file and sys.version_info < (3, 9): + if ( + 'rpc_bidir_streaming' in ex_file + and + sys.version_info < (3, 9) + ): 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 From 6ee0149e8dffce64c4ee5ea78e528057466a6a6a Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 6 Mar 2026 12:03:33 -0500 Subject: [PATCH 46/48] Another cancellation test timeout bump for non-linux --- tests/test_cancellation.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index 27fd59d7..7d14df12 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -17,8 +17,8 @@ from tractor._testing import ( from .conftest import no_windows -def is_win(): - return platform.system() == 'Windows' +_non_linux: bool = platform.system() != 'Linux' +_friggin_windows: bool = platform.system() == 'Windows' async def assert_err(delay=0): @@ -431,7 +431,7 @@ async def test_nested_multierrors(loglevel, start_method): for subexc in err.exceptions: # verify first level actor errors are wrapped as remote - if is_win(): + if _friggin_windows: # windows is often too slow and cancellation seems # 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.. # on windows sometimes spawning is just too slow and # we get back the (sent) cancel signal instead - if is_win(): + if _friggin_windows: if isinstance(subexc, tractor.RemoteActorError): assert subexc.boxed_type in ( BaseExceptionGroup, @@ -507,17 +507,22 @@ def test_cancel_via_SIGINT( @no_windows def test_cancel_via_SIGINT_other_task( - loglevel, - start_method, - spawn_backend, + loglevel: str, + start_method: str, + spawn_backend: str, ): - """Ensure that a control-C (SIGINT) signal cancels both the parent - and child processes in trionic fashion even a subprocess is started - from a seperate ``trio`` child task. - """ - pid = os.getpid() - timeout: float = 2 - if is_win(): # smh + ''' + Ensure that a control-C (SIGINT) signal cancels both the parent + and child processes in trionic fashion even a subprocess is + started from a seperate ``trio`` child task. + + ''' + pid: int = os.getpid() + timeout: float = ( + 4 if _non_linux + else 2 + ) + if _friggin_windows: # smh timeout += 1 async def spawn_and_sleep_forever( @@ -696,7 +701,7 @@ def test_fast_graceful_cancel_when_spawn_task_in_soft_proc_wait_for_daemon( kbi_delay = 0.5 timeout: float = 2.9 - if is_win(): # smh + if _friggin_windows: # smh timeout += 1 async def main(): From bf1dcea9d1aef8ba225d15a74fadb68493d826ec Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 9 Mar 2026 23:22:43 -0400 Subject: [PATCH 47/48] Announce macOS support in `pyproject` and README - add `"Operating System :: MacOS"` classifier. - add macOS bullet to README's TODO/status section. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- docs/README.rst | 16 +++++++++------- pyproject.toml | 1 + 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/README.rst b/docs/README.rst index cea223ee..1d8bbb9f 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -641,13 +641,15 @@ Help us push toward the future of distributed `Python`. - Typed capability-based (dialog) protocols ( see `#196 `_ with draft work started in `#311 `_) -- We **recently disabled CI-testing on windows** and need help getting - it running again! (see `#327 - `_). **We do have windows - support** (and have for quite a while) but since no active hacker - exists in the user-base to help test on that OS, for now we're not - actively maintaining testing due to the added hassle and general - latency.. +- **macOS is now officially supported** and tested in CI + alongside Linux! +- We **recently disabled CI-testing on windows** and need + help getting it running again! (see `#327 + `_). **We do + have windows support** (and have for quite a while) but + since no active hacker exists in the user-base to help + test on that OS, for now we're not actively maintaining + testing due to the added hassle and general latency.. Feel like saying hi? diff --git a/pyproject.toml b/pyproject.toml index 8fc82465..92e8bc21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ keywords = [ classifiers = [ "Development Status :: 3 - Alpha", "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", "Framework :: Trio", "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", "Programming Language :: Python :: Implementation :: CPython", From 88b084802f62c70d7fa97afd624efe6578696298 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 9 Mar 2026 23:28:58 -0400 Subject: [PATCH 48/48] Merge `testing-macos` into unified `testing` matrix Drop the separate `testing-macos` job and add `macos-latest` to the existing OS matrix; bump timeout to 16 min to accommodate macOS runs. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .github/workflows/ci.yml | 42 +++------------------------------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 195a3c46..25d07448 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,9 +74,9 @@ jobs: # run: mypy tractor/ --ignore-missing-imports --show-traceback - testing-linux: + testing: name: '${{ matrix.os }} Python${{ matrix.python-version }} - spawn_backend=${{ matrix.spawn_backend }}' - timeout-minutes: 10 + timeout-minutes: 16 runs-on: ${{ matrix.os }} strategy: @@ -84,7 +84,7 @@ jobs: matrix: os: [ ubuntu-latest, - # macos-latest, # ?TODO, better? + macos-latest, ] python-version: [ '3.13', @@ -125,42 +125,6 @@ jobs: - name: Run tests run: uv run pytest tests/ --spawn-backend=${{ matrix.spawn_backend }} -rsx - testing-macos: - name: '${{ matrix.os }} Python${{ matrix.python-version }} - spawn_backend=${{ matrix.spawn_backend }}' - timeout-minutes: 16 - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - os: [ - macos-latest, - ] - python-version: [ - '3.13', - # '3.14', - ] - spawn_backend: [ - 'trio', - ] - - steps: - - uses: actions/checkout@v4 - - - name: 'Install uv + py-${{ matrix.python-version }}' - uses: astral-sh/setup-uv@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Install the project w uv - run: uv sync --all-extras --dev - - - name: List deps tree - run: uv tree - - - name: Run tests w uv - 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