Document stable macOS UDS operation

The remediation grew beyond the original no-autobind fix, leaving
public docs and nearby comments describing connect-only discovery,
XDG-only socket paths, raw readiness probes, and old cleanup naming.

Document typed registrar probing, occupied-address rejection, Darwin's
short runtime directory, platform-aware socket cleanup, sentinel
readiness, and subprocess output draining. Add the GH #473 bugfix
fragment and update regression rationale without changing task states.

Review: PR #480 (goodboy)
https://github.com/goodboy/tractor/pull/480

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
wkt/uds_macos_473
Gud Boi 2026-08-14 10:37:19 -04:00
parent 16dd876b0c
commit 584ea4e9ad
11 changed files with 56 additions and 52 deletions

View File

@ -130,9 +130,10 @@ UDS: same-host, creds included
Pass ``enable_transports=['uds']`` and actors instead talk over Pass ``enable_transports=['uds']`` and actors instead talk over
unix-domain sockets, with socket files placed in the per-user unix-domain sockets, with socket files placed in the per-user
runtime dir (``$XDG_RUNTIME_DIR/tractor/`` on linux, the runtime dir: ``$XDG_RUNTIME_DIR/tractor/`` on linux, a short
``platformdirs`` equivalent elsewhere). Two perks over tcp on a owner-only ``/tmp/tractor-<uid>`` dir on Darwin, and the
single host: ``platformdirs`` equivalent elsewhere. Two perks over tcp on a single
host:
- no ports to fight over; addrs are just file paths, - no ports to fight over; addrs are just file paths,
- the kernel snitches on your peer for free: the listening side - the kernel snitches on your peer for free: the listening side

View File

@ -44,8 +44,9 @@ clan shares one registry with zero config on your part.
The bootstrap rule inside ``open_root_actor()`` is delightfully The bootstrap rule inside ``open_root_actor()`` is delightfully
simple: simple:
- on boot, ping every socket addr in ``registry_addrs``; when none - on boot, probe every addr in ``registry_addrs`` with a bounded
are passed the per-transport defaults are used: for TCP the Tractor ``Aid`` handshake; when none are passed the per-transport
defaults are used: for TCP the
loopback ``('127.0.0.1', 1616)``, for UDS a loopback ``('127.0.0.1', 1616)``, for UDS a
``registry@1616.sock`` file, ``registry@1616.sock`` file,
@ -53,9 +54,11 @@ simple:
actor and register with the *existing* registry; your own IPC actor and register with the *existing* registry; your own IPC
server binds random same-transport addrs instead, server binds random same-transport addrs instead,
- if **nothing answers, congratulations: you just became the - if every address is absent, congratulations: you just became the
registrar**. Your transport server binds the registry addrs registrar. Your transport server binds the registry addrs
themselves and you start serving lookups for everyone else. themselves and you start serving lookups for everyone else,
- if no registrar answers but an address is occupied by a foreign or
non-responsive endpoint, startup fails instead of binding over it.
Pass ``ensure_registry=True`` when your program *requires* being Pass ``ensure_registry=True`` when your program *requires* being
the one-and-only registrar; boot then fails loudly with a the one-and-only registrar; boot then fails loudly with a
@ -196,9 +199,10 @@ the existing registrar:
trio.run(main) trio.run(main)
Per the bootstrap rules above, if the registrar at those addrs is Per the bootstrap rules above, if those addrs are absent this process
*not* reachable this process simply becomes its own (registrar) becomes its own registrar root, so the same code works standalone and
root — so the same code works standalone and as a tree-joiner. as a tree-joiner. An occupied address that does not complete a Tractor
registrar handshake fails startup instead of being rebound.
"Arbiter"? A legacy naming note "Arbiter"? A legacy naming note
------------------------------- -------------------------------

View File

@ -0,0 +1,4 @@
Fix Unix-domain-socket actor trees and registrar discovery on macOS.
Runtime sockets now use a short, owner-only runtime directory,
generated socket names remain within platform limits, and transient
or reset pre-handshake connections no longer destabilize discovery.

View File

@ -1,12 +1,10 @@
''' '''
Discovery-suite fixtures, including the `daemon` Discovery-suite fixtures, including the `daemon` remote-registrar
remote-registrar subprocess used by the multi-program subprocess used by the multi-program discovery tests.
discovery tests.
Lives here (vs. the parent `tests/conftest.py`) Lives here (vs. the parent `tests/conftest.py`)
because `daemon` is a discovery-protocol primitive because `daemon` is a discovery-protocol primitive: it boots a child
boots a separate `tractor.run_daemon()` process whose that enters `open_root_actor()` and waits as a registrar peer for
sole purpose is to serve as a registrar peer for
discovery-roundtrip tests. Pytest fixtures inherit discovery-roundtrip tests. Pytest fixtures inherit
DOWNWARD through conftest hierarchy, so anything DOWNWARD through conftest hierarchy, so anything
under `tests/discovery/` automatically picks this up. under `tests/discovery/` automatically picks this up.
@ -49,9 +47,8 @@ def _wait_for_daemon_ready(
Raises `TimeoutError` on `deadline` exceeded. If Raises `TimeoutError` on `deadline` exceeded. If
`proc` is given, ALSO raises early if the daemon `proc` is given, ALSO raises early if the daemon
process exits non-zero before the deadline (catches process exits before the deadline (catches a daemon startup crash
daemon-startup-crash that the blind sleep used to that the blind sleep used to silently mask).
silently mask).
''' '''
end: float = time.monotonic() + deadline end: float = time.monotonic() + deadline
@ -148,9 +145,9 @@ def daemon(
**kwargs, **kwargs,
) )
# Active-poll the daemon's bind address until it's # Poll the child's ready sentinel, published after actor startup,
# ready to accept connections — replaces the legacy # instead of connecting to its transport socket. This replaces
# blind `time.sleep(2.2)` which was racy under load # the legacy blind `time.sleep(2.2)` which was racy under load
# (see # (see
# `ai/conc-anal/test_register_duplicate_name_daemon_connect_race_issue.md`). # `ai/conc-anal/test_register_duplicate_name_daemon_connect_race_issue.md`).
# #
@ -174,9 +171,9 @@ def daemon(
if proc.poll() is None: if proc.poll() is None:
sig_prog(proc, _INT_SIGNAL) sig_prog(proc, _INT_SIGNAL)
# XXX! yeah.. just be reaaal careful with this bc # NOTE: these blocking reads can hang when descendants retain
# sometimes it can lock up on the `_io.BufferedReader` # inherited pipe descriptors. Keep teardown signaling above
# and hang.. # them and avoid adding subprocesses outside the actor tree.
# #
# NB, drain happens at TEARDOWN (post-yield), so the # NB, drain happens at TEARDOWN (post-yield), so the
# test body has its chance to read `proc.stderr` # test body has its chance to read `proc.stderr`

View File

@ -15,7 +15,7 @@ def test_daemon_ready_check_does_not_connect(
tmp_path, tmp_path,
): ):
''' '''
Detect a listening UDS daemon without creating a raw connection. Observe completed daemon startup without a raw connection.
The old UDS readiness helper connected and immediately closed. That The old UDS readiness helper connected and immediately closed. That
entered Tractor's actor-handshake handler with no `Aid` payload and entered Tractor's actor-handshake handler with no `Aid` payload and

View File

@ -132,8 +132,8 @@ def test_transport_only_listener_is_not_registrar():
connect alone. A non-Tractor listener, or a registrar still connect alone. A non-Tractor listener, or a registrar still
failing its initial handshake, was therefore selected as the failing its initial handshake, was therefore selected as the
remote registry. This test accepts the probe and closes it without remote registry. This test accepts the probe and closes it without
replying, then proves `open_root_actor()` ignores that endpoint and replying, then proves `open_root_actor()` rejects that occupied
elects the local actor registrar instead. endpoint instead of selecting it or binding over it.
''' '''
async def transport_only_handler( async def transport_only_handler(

View File

@ -81,8 +81,9 @@ def _wait_for_proc(
errmsg: str = err.decode(errors='replace') errmsg: str = err.decode(errors='replace')
# XXX, ALWAYS surface the subproc's full stderr # NOTE: always include captured stdout and stderr for a non-zero
# whenever it exits non-zero! # exit. Depending on the final stderr line previously hid grouped
# exception diagnostics; see GH #473.
# #
# The prior impl only raised when the LAST stderr # The prior impl only raised when the LAST stderr
# line contained 'Error', swallowing any crash whose # line contained 'Error', swallowing any crash whose
@ -246,9 +247,8 @@ def run_example_in_subproc(
str(script_file), str(script_file),
] ]
# XXX: BE FOREVER WARNED: if you enable lots of tractor logging # Captured pipes are drained by `_wait_for_proc()` while the
# in the subprocess it may cause infinite blocking on the pipes # example runs.
# due to backpressure!!!
proc = testdir.popen( proc = testdir.popen(
cmdargs, cmdargs,
stdin=subprocess.PIPE, stdin=subprocess.PIPE,

View File

@ -102,8 +102,8 @@ async def _probe_registry(
Confirm an address serves the Tractor actor handshake. Confirm an address serves the Tractor actor handshake.
Connection and handshake work share `timeout`; each attempt gets Connection and handshake work share `timeout`; each attempt gets
`attempt_timeout`. Shielded channel cleanup may consume at most one `attempt_timeout`. Shielded cleanup may add up to `close_timeout`
additional `close_timeout` after either deadline fires. per attempted channel.
''' '''
connected_once: bool = False connected_once: bool = False
@ -525,12 +525,10 @@ async def open_root_actor(
timeout: float = 3, timeout: float = 3,
) -> None: ) -> None:
''' '''
Attempt temporary connection to see if a registry is Probe with a bounded Tractor actor handshake.
listening at the requested address by a tranport layer
ping.
If a connection can't be made quickly we assume none no Classify the address as a registrar, occupied by a
server is listening at that addr. non-registrar, or absent.
''' '''
probe_status = await _probe_registry( probe_status = await _probe_registry(

View File

@ -656,7 +656,7 @@ class MsgpackUDSStream(MsgpackTransport):
case (bytes(), str()): case (bytes(), str()):
sock_path: Path = Path(sockname) sock_path: Path = Path(sockname)
# XXX, no-autobind case (macOS): the un-bound end # NOTE, no-autobind case (macOS): the un-bound end
# is `''`, NOT a `bytes` abstract-ns addr; taking # is `''`, NOT a `bytes` abstract-ns addr; taking
# `peername` unconditionally (as prior impl did) # `peername` unconditionally (as prior impl did)
# delivers garbage `Path('')` addrs on the accept # delivers garbage `Path('')` addrs on the accept

View File

@ -332,9 +332,9 @@ def get_rt_dir(
userspace apps stick their IPC and cache related system userspace apps stick their IPC and cache related system
util-files. util-files.
On linux we use a `${XDG_RUNTIME_DIR}/tractor/` subdir by Linux uses `${XDG_RUNTIME_DIR}/tractor/`; Darwin uses a short,
default, but equivalents are mapped for each platform using owner-only `/tmp/tractor-<uid>` path; other platforms use the
the lovely `platformdirs` lib. lovely `platformdirs` lib.
''' '''
# lazy-imported to keep it off the eager # lazy-imported to keep it off the eager

View File

@ -35,14 +35,14 @@ Future-work TODO — authoritative UDS bind-addr tracking
`unlink_uds_bind_addrs()` currently has two cleanup paths: `unlink_uds_bind_addrs()` currently has two cleanup paths:
1. Explicit `bind_addrs` (when parent set them at spawn time) 1. Explicit `bind_addrs` (when parent set them at spawn time)
2. **Convention-based reconstruction** 2. **Convention-based reconstruction** in the platform default UDS
`<XDG_RUNTIME_DIR>/tractor/<name>@<pid>.sock` for the bindspace for the
common case where the subactor self-assigned a random sock common case where the subactor self-assigned a random sock
via `UDSAddress.get_random()`. via `UDSAddress.get_random()`.
Path (2) hardcodes the `<name>@<pid>.sock` convention from Path (2) delegates filename reconstruction to
`tractor.ipc._uds.UDSAddress`. If that convention ever `tractor.ipc._uds.UDSAddress.get_sockname()`. If the subactor binds to
changes or the subactor binds to a non-default a non-default
`bindspace`/`filedir` we'll silently fail to unlink. `bindspace`/`filedir` we'll silently fail to unlink.
A more authoritative approach would be: A more authoritative approach would be:
@ -105,7 +105,7 @@ def unlink_uds_bind_addrs(
`_serve_ipc_eps` `finally:` block (which normally calls `_serve_ipc_eps` `finally:` block (which normally calls
`os.unlink(addr.sockpath)`) never runs. Without this `os.unlink(addr.sockpath)`) never runs. Without this
parent-side cleanup, the dead subactor's parent-side cleanup, the dead subactor's
`${XDG_RUNTIME_DIR}/tractor/<name>@<pid>.sock` file platform-default UDS socket file
accumulates on the filesystem (see issue #454 + the accumulates on the filesystem (see issue #454 + the
autouse `_track_orphaned_uds_per_test` fixture). autouse `_track_orphaned_uds_per_test` fixture).
@ -119,7 +119,7 @@ def unlink_uds_bind_addrs(
picked its own random sock via picked its own random sock via
`UDSAddress.get_random()`), reconstruct the path `UDSAddress.get_random()`), reconstruct the path
from `(subactor.aid.name, proc.pid)` using the from `(subactor.aid.name, proc.pid)` using the
same `<name>@<pid>.sock` convention. We can do this same `UDSAddress.get_sockname()` helper. We can do this
because the subactor uses its OWN `os.getpid()` at because the subactor uses its OWN `os.getpid()` at
bind time, which equals `proc.pid` from the bind time, which equals `proc.pid` from the
parent's view. parent's view.