Merge pull request #475 from goodboy/windows_support_round2

Restore Windows support (optional UDS + `SIGUSR1`)
wkt/macos_ci_reruns
Bd 2026-08-17 20:05:31 -04:00 committed by GitHub
commit 4e27dcde48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 269 additions and 59 deletions

View File

@ -91,6 +91,10 @@ jobs:
name: '${{ matrix.os }} Python${{ matrix.python-version }} spawn_backend=${{ matrix.spawn_backend }} tpt_proto=${{ matrix.tpt_proto }}' name: '${{ matrix.os }} Python${{ matrix.python-version }} spawn_backend=${{ matrix.spawn_backend }} tpt_proto=${{ matrix.tpt_proto }}'
timeout-minutes: 16 timeout-minutes: 16
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
# Windows support is nascent: its full test suite remains
# informational, while setup and the `import tractor` smoke below
# are hard signals. Promote the test step to required once the
# suite is green.
strategy: strategy:
fail-fast: false fail-fast: false
@ -98,6 +102,7 @@ jobs:
os: [ os: [
ubuntu-latest, ubuntu-latest,
macos-latest, macos-latest,
windows-latest,
] ]
python-version: [ python-version: [
'3.13', '3.13',
@ -118,6 +123,11 @@ jobs:
'tcp', 'tcp',
'uds', 'uds',
] ]
exclude:
# UDS is POSIX-only; Windows has no `AF_UNIX` so the
# backend is intentionally unavailable there.
- os: windows-latest
tpt_proto: 'uds'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -145,7 +155,14 @@ jobs:
- name: List deps tree - name: List deps tree
run: uv tree run: uv tree
# hard signal for the Windows import-safety fix: `import
# tractor` must succeed everywhere, and `HAS_UDS` reflects
# platform capability (False on Windows, True on POSIX).
- name: 'Smoke: import tractor'
run: uv run python -c "import sys; import tractor; from tractor.ipc._uds import HAS_UDS; assert sys.platform != 'win32' or not HAS_UDS; print('import tractor OK | HAS_UDS=', HAS_UDS)"
- name: Run tests - name: Run tests
continue-on-error: ${{ matrix.os == 'windows-latest' }}
run: > run: >
uv run uv run
pytest pytest

View File

@ -0,0 +1,39 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: pr475-review-fixes-20260817
timestamp: 2026-08-17T23:18:25Z
git_ref: 359fe75c
scope: code
substantive: true
raw_file: 20260817T231825Z_359fe75c_prompt_io.raw.md
---
## Prompt
Continue the `/code-review-changes` pass for PR #475 in its isolated
worktree. Address the seven accepted manual-review findings in
`tractor/ipc/_types.py` and `tractor/ipc/_uds.py`, preserve the existing
Windows capability behavior, verify the result, and prepare the work for
human-controlled commit and review-reply steps. Do not publish replies,
stage, commit, or push without the required explicit authorization.
## Response summary
Restored project quote, docstring, multiline-expression, and
`match/case` conventions while retaining the Windows-safe UDS guard.
Removed unnecessary structural and comment churn, then verified the
focused transport, discovery, and lazy-import paths plus the missing
`AF_UNIX` behavior.
## Files changed
- `tractor/ipc/_types.py` - restore project style and guarded
socket-family dispatch.
- `tractor/ipc/_uds.py` - format the UDS capability gate
consistently.
## Human edits
None - the generated patch remains uncommitted and awaits human
review.

View File

@ -0,0 +1,45 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-17T23:18:25Z
git_ref: 359fe75c
diff_cmd: git diff HEAD~1..HEAD
---
Applied the seven accepted manual-review fixes for PR #475 while
preserving the Windows transport capability behavior.
> `git diff HEAD~1..HEAD -- tractor/ipc/_types.py`
The generated changes restore the project's single-quote docstring and
string conventions, remove the unnecessary helper divider, simplify the
transport-registry comments, and restore `match/case` socket-family
dispatch. The UDS case retains a `HAS_UDS` guard that short-circuits
before `socket.AF_UNIX` is evaluated on unsupported hosts. Nearby error
messages are wrapped without changing their content.
> `git diff HEAD~1..HEAD -- tractor/ipc/_uds.py`
The generated change reformats the `HAS_UDS` conjunction according to
the project's multiline boolean-expression convention and simplifies
the adjacent capability comment.
Verification:
`/home/goodboy/repos/tractor/py313/bin/pytest -q tests/test_lazy_imports.py tests/discovery tests/ipc/test_server.py`
Result: `66 passed, 2 xpassed in 60.62s`.
`ruff check --no-cache --output-format=json tractor/ipc/_types.py tractor/ipc/_uds.py`
Result: no findings.
`git diff --check`
Result: no whitespace errors.
An explicit missing-`AF_UNIX` probe set `HAS_UDS = False`, removed the
socket constant, and exercised an unsupported socket family. It raised
the expected `NotImplementedError` instead of `AttributeError`.
No review replies, commits, or pushes were published.

View File

@ -20,6 +20,18 @@ from typing import (
import pytest import pytest
import trio import trio
import tractor import tractor
# `infect_asyncio` mode is unsupported on Windows (asyncio's
# `ProactorEventLoop` is incompatible with our `trio` guest-mode
# interop and currently hangs/crashes the run). Skip the module on
# Windows so the CI leg completes + reports the rest of the suite.
import platform
if platform.system() == 'Windows':
pytest.skip(
'infect_asyncio mode is unsupported on Windows',
allow_module_level=True,
)
from tractor import ( from tractor import (
current_actor, current_actor,
Actor, Actor,

View File

@ -1,10 +1,22 @@
import time import time
import platform
import trio import trio
import pytest import pytest
import tractor import tractor
# `tractor.ipc._ringbuf` is built on linux `eventfd(2)`; importing
# it pulls in `tractor.ipc._linux` whose module-level
# `ffi.dlopen(None)` raises on non-linux. Skip the whole module at
# COLLECTION before that crashing import runs (a `pytestmark` skip
# is too late — markers apply only after the import succeeds).
if platform.system() != 'Linux':
pytest.skip(
'ringbuf (eventfd) IPC is linux-only',
allow_module_level=True,
)
# XXX `cffi` dun build on py3.14 yet.. # XXX `cffi` dun build on py3.14 yet..
pytest.importorskip("cffi") pytest.importorskip("cffi")

View File

@ -9,6 +9,17 @@ from functools import partial
import pytest import pytest
import trio import trio
import tractor import tractor
# `infect_asyncio` mode is unsupported on Windows (see
# `test_infected_asyncio`); skip at COLLECTION before the
# asyncio-interop imports below so the CI leg completes.
import platform
if platform.system() == 'Windows':
pytest.skip(
'infect_asyncio mode is unsupported on Windows',
allow_module_level=True,
)
from tractor import ( from tractor import (
to_asyncio, to_asyncio,
) )

View File

@ -73,6 +73,11 @@ async def test_lifetime_stack_wipes_tmpfile(
1.6 if error_in_child 1.6 if error_in_child
else 1 else 1
) )
# scale for slow/noisy CI (esp. macOS) so the child error
# propagates before the deadline; otherwise `move_on_after`
# cancels first and flips the `error_in_child=True` assert.
from .conftest import cpu_perf_headroom
timeout *= cpu_perf_headroom()
try: try:
with trio.move_on_after(timeout) as cs: with trio.move_on_after(timeout) as cs:
async with tractor.open_nursery( async with tractor.open_nursery(

View File

@ -120,6 +120,13 @@ async def child_read_shm_list(
print(f'(child): reading frame: {frame}') print(f'(child): reading frame: {frame}')
@pytest.mark.skipif(
platform.system() == 'Windows',
reason=(
'parent/child shm IPC deadlocks on Windows '
'(frame-size dependent hang); nascent — see #404'
),
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
'use_str', 'use_str',
[False, True], [False, True],

View File

@ -31,12 +31,21 @@ from threading import (
RLock, RLock,
) )
import multiprocessing as mp import multiprocessing as mp
import platform
from signal import ( from signal import (
signal, signal,
getsignal, getsignal,
SIGUSR1,
SIGINT, SIGINT,
) )
if platform.system() != "Windows":
from signal import SIGUSR1
else:
SIGUSR1 = None
# import traceback # import traceback
from types import ModuleType from types import ModuleType
from typing import ( from typing import (
@ -347,8 +356,8 @@ def dump_tree_on_sig(
def enable_stack_on_sig( def enable_stack_on_sig(
sig: int = SIGUSR1, sig: int|None = SIGUSR1,
) -> ModuleType: ) -> ModuleType|None:
''' '''
Enable `stackscope` tracing on reception of a signal; by Enable `stackscope` tracing on reception of a signal; by
default this is SIGUSR1. default this is SIGUSR1.
@ -367,6 +376,16 @@ def enable_stack_on_sig(
>> pkill --signal SIGUSR1 -f <part-of-cmd: str> >> pkill --signal SIGUSR1 -f <part-of-cmd: str>
''' '''
# no `SIGUSR1` on this platform (e.g. Windows) -> nothing to
# wire up; degrade gracefully instead of crashing callers that
# only guard against a missing `stackscope` (`ImportError`).
if sig is None:
log.warning(
'No `SIGUSR1` on this platform;\n'
'skipping `stackscope` trace-on-signal setup!\n'
)
return None
try: try:
# NOTE, `stackscope._glue` does intentional async-gen type # NOTE, `stackscope._glue` does intentional async-gen type
# introspection at import-time which trips # introspection at import-time which trips

View File

@ -32,7 +32,10 @@ from ..runtime._state import (
_def_tpt_proto, _def_tpt_proto,
) )
from ..ipc._tcp import TCPAddress from ..ipc._tcp import TCPAddress
from ..ipc._uds import UDSAddress from ..ipc._uds import (
UDSAddress,
HAS_UDS,
)
if TYPE_CHECKING: if TYPE_CHECKING:
# ONLY type-annots, the eager import costs ~4.5ms # ONLY type-annots, the eager import costs ~4.5ms
@ -43,7 +46,6 @@ else:
log = get_logger() log = get_logger()
# TODO, maybe breakout the netns key to a struct? # TODO, maybe breakout the netns key to a struct?
# class NetNs(Struct)[str, int]: # class NetNs(Struct)[str, int]:
# ... # ...
@ -174,25 +176,36 @@ class Address(Protocol):
... ...
# the address types available on this host: TCP always, UDS only
# where usable (`HAS_UDS`). Both registries derive from this single
# list via each type's `proto_key`.
_address_protos: list[Type[Address]] = [TCPAddress]
if HAS_UDS:
_address_protos.append(UDSAddress)
_address_types: dict[str, Type[Address]] = { _address_types: dict[str, Type[Address]] = {
'tcp': TCPAddress, cls.proto_key: cls
'uds': UDSAddress for cls in _address_protos
} }
# TODO! really these are discovery sys default addrs ONLY useful for # TODO! really these are discovery sys default addrs ONLY useful for
# when none is provided to a root actor on first boot. # when none is provided to a root actor on first boot.
_default_lo_addrs: dict[ _default_lo_addrs: dict[str, UnwrappedAddress] = {
str, cls.proto_key: cls.get_root().unwrap()
UnwrappedAddress for cls in _address_protos
] = {
'tcp': TCPAddress.get_root().unwrap(),
'uds': UDSAddress.get_root().unwrap(),
} }
def get_address_cls(name: str) -> Type[Address]: def get_address_cls(name: str) -> Type[Address]:
return _address_types[name] try:
return _address_types[name]
except KeyError:
raise NotImplementedError(
f'No IPC transport backend for {name!r} on this '
f'platform!\n'
f'(available: {list(_address_types)})\n'
)
def is_wrapped_addr(addr: any) -> bool: def is_wrapped_addr(addr: any) -> bool:
@ -290,7 +303,14 @@ def default_lo_addrs(
for an input transport key set. for an input transport key set.
''' '''
return [ lo_addrs: list[UnwrappedAddress] = []
_default_lo_addrs[transport] for transport in transports:
for transport in transports try:
] lo_addrs.append(_default_lo_addrs[transport])
except KeyError:
raise NotImplementedError(
f'No default loopback addr for transport '
f'{transport!r} on this platform!\n'
f'(available: {list(_default_lo_addrs)})\n'
)
return lo_addrs

View File

@ -62,19 +62,20 @@ from .. import log
from ..discovery._addr import Address from ..discovery._addr import Address
from ._chan import Channel from ._chan import Channel
from ._transport import MsgTransport from ._transport import MsgTransport
from ._uds import UDSAddress
from ._tcp import TCPAddress
if TYPE_CHECKING: if TYPE_CHECKING:
from ..runtime._runtime import Actor from ..runtime._runtime import Actor
from ..runtime._supervise import ActorNursery from ..runtime._supervise import ActorNursery
from ._tcp import TCPAddress
from ._uds import UDSAddress
log = log.get_logger() log = log.get_logger()
_PRE_REG_HANDSHAKE_TIMEOUT: float = 10 _PRE_REG_HANDSHAKE_TIMEOUT: float = 10
async def maybe_wait_on_canced_subs( async def maybe_wait_on_canced_subs(
uid: tuple[str, str], uid: tuple[str, str],
chan: Channel, chan: Channel,

View File

@ -18,17 +18,13 @@
IPC subsys type-lookup helpers? IPC subsys type-lookup helpers?
''' '''
from typing import ( from typing import Type
Type,
# TYPE_CHECKING,
)
import trio
import socket import socket
import trio
from tractor.ipc._transport import ( from tractor.ipc._transport import (
MsgTransportKey, MsgTransportKey,
MsgTransport MsgTransport,
) )
from tractor.ipc._tcp import ( from tractor.ipc._tcp import (
TCPAddress, TCPAddress,
@ -37,37 +33,33 @@ from tractor.ipc._tcp import (
from tractor.ipc._uds import ( from tractor.ipc._uds import (
UDSAddress, UDSAddress,
MsgpackUDSStream, MsgpackUDSStream,
HAS_UDS,
) )
# if TYPE_CHECKING: # the UDS backend is importable everywhere but only *usable* when
# from tractor._addr import Address # `HAS_UDS` is `True`; otherwise the runtime registers TCP only.
Address = TCPAddress|UDSAddress Address = TCPAddress|UDSAddress
# manually updated list of all supported msg transport types # the available msg-transport backends on this host: TCP always,
_msg_transports = [ # UDS only where usable (`HAS_UDS`). The lookup maps below derive
# from this single list via each backend's `codec_key` and
# `address_type`: register a backend here and every map picks it up.
_msg_transports: list[Type[MsgTransport]] = [
MsgpackTCPStream, MsgpackTCPStream,
MsgpackUDSStream
] ]
if HAS_UDS:
_msg_transports.append(MsgpackUDSStream)
# map a `MsgTransportKey` -> `MsgTransport` type
# convert a MsgTransportKey to the corresponding transport type _key_to_transport: dict[MsgTransportKey, Type[MsgTransport]] = {
_key_to_transport: dict[ (t.codec_key, t.address_type.proto_key): t
MsgTransportKey, for t in _msg_transports
Type[MsgTransport],
] = {
('msgpack', 'tcp'): MsgpackTCPStream,
('msgpack', 'uds'): MsgpackUDSStream,
} }
# convert an Address wrapper to its corresponding transport type # map an `Address`-wrapper -> `MsgTransport` type
_addr_to_transport: dict[ _addr_to_transport: dict[Type[Address], Type[MsgTransport]] = {
Type[TCPAddress|UDSAddress], t.address_type: t
Type[MsgTransport] for t in _msg_transports
] = {
TCPAddress: MsgpackTCPStream,
UDSAddress: MsgpackUDSStream,
} }
@ -81,41 +73,51 @@ def transport_from_addr(
''' '''
try: try:
return _addr_to_transport[type(addr)] addr_type = type(addr)
return _addr_to_transport[addr_type]
except KeyError: except KeyError:
raise NotImplementedError( raise NotImplementedError(
f'No known transport for address {repr(addr)}' f'No known transport for address '
f'{addr!r}'
) )
def transport_from_stream( def transport_from_stream(
stream: trio.abc.Stream, stream: trio.abc.Stream,
codec_key: str = 'msgpack' codec_key: str = 'msgpack',
) -> Type[MsgTransport]: ) -> Type[MsgTransport]:
''' '''
Given an arbitrary `trio.abc.Stream` and a desired codec, Given an arbitrary `trio.abc.Stream` and a desired codec,
find the corresponding `MsgTransport` type. find the corresponding `MsgTransport` type.
''' '''
transport = None transport: str|None = None
if isinstance(stream, trio.SocketStream): if isinstance(stream, trio.SocketStream):
sock: socket.socket = stream.socket sock: socket.socket = stream.socket
match sock.family: match sock.family:
case socket.AF_INET | socket.AF_INET6: case socket.AF_INET | socket.AF_INET6:
transport = 'tcp' transport = 'tcp'
case socket.AF_UNIX: # `HAS_UDS` short-circuits before `socket.AF_UNIX` on
# hosts where that constant is absent.
case fam if (
HAS_UDS
and
fam == socket.AF_UNIX
):
transport = 'uds' transport = 'uds'
case _: case fam:
raise NotImplementedError( raise NotImplementedError(
f'Unsupported socket family: {sock.family}' f'Unsupported socket family: {fam}'
) )
if not transport: if not transport:
raise NotImplementedError( raise NotImplementedError(
f'Could not figure out transport type for stream type {type(stream)}' f'Could not figure out transport type for stream type '
f'{type(stream)}'
) )
key = (codec_key, transport) key = (codec_key, transport)

View File

@ -26,11 +26,21 @@ from pathlib import Path
import os import os
import sys import sys
from socket import ( from socket import (
AF_UNIX,
SOCK_STREAM, SOCK_STREAM,
SOL_SOCKET, SOL_SOCKET,
error as socket_error, error as socket_error,
) )
# NOTE, `AF_UNIX` is absent on Windows / any CPython built without
# unix-domain-socket support. Keep this module importable
# everywhere (so `UDSAddress` stays referenceable for type and
# `isinstance()` checks plus registry lookups); the `AF_UNIX`-using
# code paths below are runtime-only and are never reached when the
# UDS backend is unusable (gated on `trio`'s `has_unix`, see
# `HAS_UDS`).
try:
from socket import AF_UNIX
except ImportError:
AF_UNIX = None
import struct import struct
from typing import ( from typing import (
Any, Any,
@ -104,6 +114,16 @@ _SUN_PATH_LIMIT: int = (
) )
# single source of truth for whether the UDS backend is usable on this
# host. Windows can expose `AF_UNIX`, but this backend remains
# POSIX-only until its credential and lifecycle paths are supported.
HAS_UDS: bool = (
sys.platform != 'win32'
and
has_unix
)
def unwrap_sockpath( def unwrap_sockpath(
sockpath: Path, sockpath: Path,
) -> tuple[Path, Path]: ) -> tuple[Path, Path]: