Fix Windows `import tractor` at the UDS root

The prior round gated UDS in four modules but `import tractor`
still crashed on Windows: `tractor.ipc._uds` does `from socket
import AF_UNIX` at module top, and several modules in the import
graph (`discovery._api`, `spawn._reap`, `discovery._multiaddr`,
`_testing.addr`) import `_uds` unconditionally. Instead of
guarding every importer, fix the root and collapse the per-module
probes to one capability flag.

- in `ipc/_uds.py`, guard the lone `AF_UNIX` import so the module
  stays importable everywhere; expose `HAS_UDS = trio.has_unix`
  as the single source of truth (the same predicate that gates
  `trio.open_unix_socket()`).
- `ipc/_types.py`, `discovery/_addr.py` and `ipc/_server.py` now
  import `UDSAddress`/`MsgpackUDSStream`/`HAS_UDS` directly and
  gate the transport + address registries on `HAS_UDS`; drop the
  duplicated `getattr(socket,'AF_UNIX')` / `platform.system()`
  probes, the dead `HAS_AF_UNIX` conjunct, and the import-time
  `log.warning()` spam.
- `devx/_stackscope.py` `enable_stack_on_sig()` early-returns
  when `sig is None`, so a missing `SIGUSR1` (Windows) degrades
  to a no-op instead of a `TypeError` from `getsignal()` /
  `signal()`.
- add a `windows-latest` CI leg (UDS excluded; informational via
  `continue-on-error` while support matures) plus an `import
  tractor` smoke step as the hard signal for the import fix.

Because `_uds` is importable everywhere `UDSAddress` stays a real
class, so `isinstance()` checks and `wrap_address()` no longer
`AttributeError` on no-UDS hosts; actual socket use stays gated
on `has_unix`.

Review: https://github.com/goodboy/tractor/pull/475
(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
Gud Boi 2026-06-29 20:32:12 -04:00
parent 5bf6417cc0
commit d9532d1270
6 changed files with 74 additions and 91 deletions

View File

@ -91,6 +91,11 @@ 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: keep its legs informational so a
# known-incomplete area doesn't block merges. The `import
# tractor` smoke step below is the hard signal for this job;
# promote the whole leg to required once the suite is green.
continue-on-error: ${{ matrix.os == 'windows-latest' }}
strategy: strategy:
fail-fast: false fail-fast: false
@ -98,6 +103,7 @@ jobs:
os: [ os: [
ubuntu-latest, ubuntu-latest,
macos-latest, macos-latest,
windows-latest,
] ]
python-version: [ python-version: [
'3.13', '3.13',
@ -123,6 +129,10 @@ jobs:
# don't do UDS run on macOS (for now) # don't do UDS run on macOS (for now)
- os: macos-latest - os: macos-latest
tpt_proto: 'uds' tpt_proto: 'uds'
# 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
@ -150,6 +160,12 @@ 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 tractor; from tractor.ipc._uds import HAS_UDS; print('import tractor OK | HAS_UDS=', HAS_UDS)"
- name: Run tests - name: Run tests
run: > run: >
uv run uv run

View File

@ -356,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.
@ -376,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

@ -22,10 +22,6 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
) )
import platform
import socket
from bidict import bidict from bidict import bidict
from trio import ( from trio import (
SocketListener, SocketListener,
@ -36,27 +32,16 @@ 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,
HAS_UDS,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from ..runtime._runtime import Actor from ..runtime._runtime import Actor
log = get_logger() log = get_logger()
HAS_AF_UNIX = getattr(socket, "AF_UNIX", None) is not None
IS_WINDOWS = platform.system() == "Windows"
UDSAddress = None # so references exist but do nothing on Windows
if HAS_AF_UNIX and not IS_WINDOWS:
try:
from ..ipc._uds import UDSAddress as _UDSAddress
UDSAddress = _UDSAddress
except Exception as e:
log.warning("UDS backend import failed: %s", e)
else:
log.warning("UDS backend disabled on this platform.")
# 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]:
# ... # ...
@ -190,11 +175,8 @@ class Address(Protocol):
_address_types: bidict[str, Type[Address]] = { _address_types: bidict[str, Type[Address]] = {
'tcp': TCPAddress, 'tcp': TCPAddress,
} }
if HAS_UDS:
if UDSAddress is not None:
_address_types['uds'] = UDSAddress _address_types['uds'] = UDSAddress
else:
log.warning("Skipping UDS address type: no UDS backend available.")
# TODO! really these are discovery sys default addrs ONLY useful for # TODO! really these are discovery sys default addrs ONLY useful for
@ -202,11 +184,8 @@ else:
_default_lo_addrs: dict[str, UnwrappedAddress] = { _default_lo_addrs: dict[str, UnwrappedAddress] = {
'tcp': TCPAddress.get_root().unwrap(), 'tcp': TCPAddress.get_root().unwrap(),
} }
if HAS_UDS:
if UDSAddress is not None:
_default_lo_addrs['uds'] = UDSAddress.get_root().unwrap() _default_lo_addrs['uds'] = UDSAddress.get_root().unwrap()
else:
log.warning("Skipping UDS default loopback address: no UDS backend available.")
def get_address_cls(name: str) -> Type[Address]: def get_address_cls(name: str) -> Type[Address]:

View File

@ -27,8 +27,6 @@ from functools import partial
from itertools import chain from itertools import chain
import inspect import inspect
import textwrap import textwrap
import platform
import socket
from types import ( from types import (
ModuleType, ModuleType,
) )
@ -72,16 +70,10 @@ if TYPE_CHECKING:
from ._tcp import TCPAddress from ._tcp import TCPAddress
from ._uds import UDSAddress
log = log.get_logger() log = log.get_logger()
UDSAddress = None
if getattr(socket, "AF_UNIX", None) is not None and platform.system() != "Windows":
from ._uds import UDSAddress
else:
pass
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

@ -19,11 +19,9 @@ IPC subsys type-lookup helpers?
''' '''
from typing import Type from typing import Type
import platform
import socket import socket
import trio import trio
from tractor.log import get_logger
from tractor.ipc._transport import ( from tractor.ipc._transport import (
MsgTransportKey, MsgTransportKey,
MsgTransport, MsgTransport,
@ -32,68 +30,37 @@ from tractor.ipc._tcp import (
TCPAddress, TCPAddress,
MsgpackTCPStream, MsgpackTCPStream,
) )
from tractor.ipc._uds import (
log = get_logger() UDSAddress,
MsgpackUDSStream,
# ------------------------------------------------------------ HAS_UDS,
# Optional UDS backend (Windows / some Pythons may not have AF_UNIX)
# ------------------------------------------------------------
HAS_AF_UNIX = getattr(socket, "AF_UNIX", None) is not None
IS_WINDOWS = platform.system() == "Windows"
HAS_UDS = False
UDSAddress = None # type: ignore
MsgpackUDSStream = None # type: ignore
if HAS_AF_UNIX and not IS_WINDOWS:
try:
from tractor.ipc._uds import ( # type: ignore
UDSAddress as _UDSAddress,
MsgpackUDSStream as _MsgpackUDSStream,
) )
UDSAddress = _UDSAddress # type: ignore
MsgpackUDSStream = _MsgpackUDSStream # type: ignore
HAS_UDS = True
except Exception as e:
log.warning("UDS backend unavailable (%s); continuing without it.", e)
else:
if not HAS_AF_UNIX:
log.warning("AF_UNIX not exposed by this Python; disabling UDS backend.")
elif IS_WINDOWS:
# Even if the Windows kernel supports AF_UNIX, CPython may not expose it,
# and this project currently targets POSIX for the UDS backend.
log.warning("Windows detected; disabling UDS backend.")
# ------------------------------------------------------------ # the UDS backend is importable everywhere but only *usable* where
# Public types and transport registries # `trio` reports `has_unix` (i.e. POSIX). On Windows / no-`AF_UNIX`
# ------------------------------------------------------------ # hosts `HAS_UDS` is `False` and the runtime registers TCP only.
Address = TCPAddress|UDSAddress
# Address is TCP-only unless UDS is available. # manually updated list of all supported msg transport types
if HAS_UDS:
Address = TCPAddress | UDSAddress # type: ignore
else:
Address = TCPAddress # type: ignore
# Manually updated list of all supported msg transport types
_msg_transports: list[Type[MsgTransport]] = [ _msg_transports: list[Type[MsgTransport]] = [
MsgpackTCPStream, MsgpackTCPStream,
] ]
if HAS_UDS: if HAS_UDS:
_msg_transports.append(MsgpackUDSStream) # type: ignore _msg_transports.append(MsgpackUDSStream)
# Map MsgTransportKey -> transport type # map a `MsgTransportKey` to its `MsgTransport` type
_key_to_transport: dict[MsgTransportKey, Type[MsgTransport]] = { _key_to_transport: dict[MsgTransportKey, Type[MsgTransport]] = {
("msgpack", "tcp"): MsgpackTCPStream, ('msgpack', 'tcp'): MsgpackTCPStream,
} }
if HAS_UDS: if HAS_UDS:
_key_to_transport[("msgpack", "uds")] = MsgpackUDSStream # type: ignore _key_to_transport[('msgpack', 'uds')] = MsgpackUDSStream
# Map Address wrapper -> transport type # map an `Address`-wrapper to its `MsgTransport` type
_addr_to_transport: dict[Type[Address], Type[MsgTransport]] = { # type: ignore _addr_to_transport: dict[Type[Address], Type[MsgTransport]] = {
TCPAddress: MsgpackTCPStream, TCPAddress: MsgpackTCPStream,
} }
if HAS_UDS: if HAS_UDS:
_addr_to_transport[UDSAddress] = MsgpackUDSStream # type: ignore _addr_to_transport[UDSAddress] = MsgpackUDSStream
# ------------------------------------------------------------ # ------------------------------------------------------------
@ -132,8 +99,10 @@ def transport_from_stream(
if fam in (socket.AF_INET, getattr(socket, "AF_INET6", None)): if fam in (socket.AF_INET, getattr(socket, "AF_INET6", None)):
transport = "tcp" transport = "tcp"
# Only consider AF_UNIX when both Python exposes it and our backend is active # only consider `AF_UNIX` when the UDS backend is active;
if transport is None and HAS_UDS and HAS_AF_UNIX and fam == socket.AF_UNIX: # type: ignore[attr-defined] # `HAS_UDS` short-circuits before `socket.AF_UNIX` so this
# stays safe on hosts where that constant is absent.
if transport is None and HAS_UDS and fam == socket.AF_UNIX: # type: ignore[attr-defined]
transport = "uds" transport = "uds"
if transport is None: if transport is None:

View File

@ -25,11 +25,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 (
Type, Type,
@ -90,6 +100,13 @@ else:
log = get_logger() log = get_logger()
# single source of truth for "is the UDS backend usable on this
# host?" — reuse `trio`'s `has_unix` (the same predicate that gates
# `trio.open_unix_socket()`) rather than re-deriving an `AF_UNIX`
# probe in every consumer module.
HAS_UDS: bool = has_unix
def unwrap_sockpath( def unwrap_sockpath(
sockpath: Path, sockpath: Path,
) -> tuple[Path, Path]: ) -> tuple[Path, Path]: