Compare commits

...

3 Commits

Author SHA1 Message Date
Gud Boi 584ea4e9ad 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`))
2026-08-14 10:37:19 -04:00
Gud Boi 16dd876b0c Make UDS reaping platform-aware
Moving Darwin sockets to `/tmp/tractor-<uid>` left pytest and the
standalone reaper searching only `XDG_RUNTIME_DIR`. Enabling that
shared bindspace naively would also let automatic session cleanup
unlink an independent live `registry@1616.sock`.

Resolve the runtime's actual default UDS bindspace on every platform.
Exclude the pid-less registry sentinel from automatic cleanup while
retaining explicit CLI removal, and document that destructive choice.

Cover bindspace resolution and automatic-vs-explicit sentinel policy.

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`))
2026-08-14 10:36:27 -04:00
Gud Boi 1a9ce915f3 Harden inbound actor handshakes
Registry probes need short retry deadlines, but applying their
one-second budget to every portal and child connection can terminate a
valid delayed actor with no client retry path.

Give ordinary pre-registration handshakes an independent ten-second
deadline. Normalize raw `msgspec.DecodeError` frames to
`TransportClosed` so malformed peers cannot cancel the shared IPC
nursery with decoder internals.

Cover malformed frames and the ordinary-vs-probe timeout distinction.

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`))
2026-08-14 10:34:59 -04:00
18 changed files with 258 additions and 96 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

@ -185,8 +185,10 @@ first with a bounded grace window — so actor runtimes can run
their ``trio`` teardown paths — escalating to ``SIGKILL`` only as their ``trio`` teardown paths — escalating to ``SIGKILL`` only as
a last resort. The ``--shm`` sweep unlinks ``/dev/shm/`` segments a last resort. The ``--shm`` sweep unlinks ``/dev/shm/`` segments
that no live process has open (it leans on psutil_, already in that no live process has open (it leans on psutil_, already in
your dev venv, to check live mappings and fds) and ``--uds`` your dev venv, to check live mappings and fds) and ``--uds`` clears
clears socket files whose binder pid is dead. dead-binder sockets from Tractor's platform-specific runtime dir. It
also unconditionally removes ``registry@1616.sock``; do not run the UDS
sweep while a live registrar is serving from that default address.
Testing your own ``tractor`` app Testing your own ``tractor`` app
-------------------------------- --------------------------------

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

@ -23,10 +23,10 @@ Two cleanup phases (run in order when both are enabled):
hard-crashing actor leaves leaked segments that hard-crashing actor leaves leaked segments that
nothing else GCs. nothing else GCs.
3. **UDS sweep** (`--uds` / `--uds-only`) — unlinks 3. **UDS sweep** (`--uds` / `--uds-only`) — unlinks socket
`${XDG_RUNTIME_DIR}/tractor/<name>@<pid>.sock` files files from Tractor's platform-specific default bindspace whose
whose binder pid is dead (or the `1616` registry binder pid is dead (or the `1616` registry sentinel). Needed
sentinel). Needed because the IPC server's because the IPC server's
`os.unlink()` cleanup lives in a `finally:` block `os.unlink()` cleanup lives in a `finally:` block
that doesn't always run on hard exits (SIGKILL, that doesn't always run on hard exits (SIGKILL,
escaped `KeyboardInterrupt`, etc.) — see issue #452. escaped `KeyboardInterrupt`, etc.) — see issue #452.
@ -137,8 +137,8 @@ def main() -> int:
action='store_true', action='store_true',
help=( help=(
'after process reap, also unlink orphaned ' 'after process reap, also unlink orphaned '
'${XDG_RUNTIME_DIR}/tractor/*.sock files ' 'sockets from Tractor\'s platform default '
'whose binder pid is dead (or the 1616 ' 'bindspace whose binder pid is dead (or the 1616 '
'registry sentinel). See issue #452.' 'registry sentinel). See issue #452.'
), ),
) )
@ -212,7 +212,9 @@ def main() -> int:
# --- phase 3: UDS sweep (opt-in) --- # --- phase 3: UDS sweep (opt-in) ---
if args.uds or args.uds_only: if args.uds or args.uds_only:
leaked_uds: list[str] = find_orphaned_uds() leaked_uds: list[str] = find_orphaned_uds(
include_registry_sentinel=True,
)
if not leaked_uds: if not leaked_uds:
print( print(
'[tractor-reap] no orphaned UDS sock-files ' '[tractor-reap] no orphaned UDS sock-files '

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

@ -5,6 +5,7 @@ Unit-ish tests for specific IPC transport protocol backends.
from __future__ import annotations from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
import socket
import stat import stat
import sys import sys
from types import SimpleNamespace from types import SimpleNamespace
@ -99,6 +100,74 @@ def test_macos_rt_dir_rejects_symlink(
assert stat.S_IMODE(target_dir.stat().st_mode) == 0o755 assert stat.S_IMODE(target_dir.stat().st_mode) == 0o755
def test_reaper_uses_default_uds_bindspace(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
):
'''
Sweep the same platform-specific bindspace used by UDS actors.
The reaper previously consulted only `XDG_RUNTIME_DIR`, missing
Darwin sockets after the runtime moved to `/tmp/tractor-<uid>`.
This test replaces `UDSAddress.def_bindspace` and proves the test
harness resolves that shared transport default directly.
'''
from tractor._testing import _reap
from tractor.ipc._uds import UDSAddress
monkeypatch.setattr(
UDSAddress,
'def_bindspace',
tmp_path,
)
assert _reap.get_uds_dir() == str(tmp_path)
def test_automatic_reaper_preserves_registry_sentinel(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
):
'''
Reserve unconditional registry cleanup for the explicit CLI.
The `registry@1616.sock` suffix does not encode its binder PID, so
automatic pytest cleanup cannot distinguish a leak from another
live registrar. This test creates registry and actor sockets,
proves the default sweep selects only the dead actor, then proves
explicit sentinel inclusion retains the CLI's documented behavior.
'''
from tractor._testing import _reap
registry_path: Path = tmp_path / 'registry@1616.sock'
actor_path: Path = tmp_path / 'worker@1234.sock'
socks: list[socket.socket] = []
for path in (registry_path, actor_path):
sock = socket.socket(socket.AF_UNIX)
sock.bind(str(path))
socks.append(sock)
monkeypatch.setattr(_reap, '_is_alive', lambda pid: False)
try:
assert _reap.find_orphaned_uds(
uds_dir=str(tmp_path),
) == [str(actor_path)]
assert set(
_reap.find_orphaned_uds(
uds_dir=str(tmp_path),
include_registry_sentinel=True,
)
) == {
str(registry_path),
str(actor_path),
}
finally:
for sock in socks:
sock.close()
def test_rt_dir_rejects_non_directory( def test_rt_dir_rejects_non_directory(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,

View File

@ -4,7 +4,12 @@ High-level `.ipc._server` unit tests.
''' '''
from __future__ import annotations from __future__ import annotations
import errno import errno
from unittest.mock import (
AsyncMock,
Mock,
)
import msgspec
import pytest import pytest
import trio import trio
from tractor import ( from tractor import (
@ -16,7 +21,10 @@ from tractor._testing.addr import (
get_rando_addr, get_rando_addr,
) )
from tractor._exceptions import TransportClosed from tractor._exceptions import TransportClosed
from tractor.ipc._chan import Channel
from tractor.ipc import _server
from tractor.ipc._transport import MsgpackTransport from tractor.ipc._transport import MsgpackTransport
from tractor.msg.types import Aid
# TODO, use/check-roundtripping with some of these wrapper types? # TODO, use/check-roundtripping with some of these wrapper types?
# #
# from .._addr import Address # from .._addr import Address
@ -30,8 +38,8 @@ def test_send_normalizes_peer_reset():
''' '''
Normalize Darwin's pre-handshake peer reset as transport closure. Normalize Darwin's pre-handshake peer reset as transport closure.
A raw UDS readiness client connects and immediately disconnects. A UDS peer may disconnect before completing the actor handshake.
Darwin reports the server's first handshake write as Darwin can report the server's first handshake write as
`ECONNRESET`, wrapped by `trio.BrokenResourceError`; allowing `ECONNRESET`, wrapped by `trio.BrokenResourceError`; allowing
that raw error to escape cancels the daemon's shared IPC nursery. that raw error to escape cancels the daemon's shared IPC nursery.
This fake stream reproduces the exact exception chain and proves This fake stream reproduces the exact exception chain and proves
@ -69,6 +77,90 @@ def test_send_normalizes_peer_reset():
trio.run(main) trio.run(main)
def test_handshake_normalizes_decode_error():
'''
Keep malformed pre-handshake frames out of the service nursery.
A non-msgpack peer can trigger `msgspec.DecodeError` before a
remote `Aid` exists. Letting that decoder error escape the inbound
handler cancels the actor's shared IPC nursery. This fake channel
proves `_do_handshake()` presents only `TransportClosed` upward.
'''
chan = object.__new__(Channel)
chan.send = AsyncMock()
chan.recv = AsyncMock(
side_effect=msgspec.DecodeError('malformed handshake'),
)
async def main():
with pytest.raises(TransportClosed) as exc_info:
await chan._do_handshake(
aid=Aid(
name='local',
uuid='local-uuid',
pid=1234,
),
timeout=.1,
)
assert isinstance(
exc_info.value.src_exc,
msgspec.DecodeError,
)
trio.run(main)
def test_server_uses_independent_handshake_timeout(
monkeypatch: pytest.MonkeyPatch,
):
'''
Give ordinary actor handshakes a distinct, generous deadline.
Registry probes use short retries, but ordinary portal and child
connections do not retry. Applying the probe's one-second timeout
in the server can terminate a valid delayed child and leave its
parent blocked in `IPCServer.wait_for_peer()`. This handler fake
proves the server uses its separate pre-registration budget.
'''
handshake = AsyncMock(
side_effect=TransportClosed(message='stop after assertion'),
)
chan = Mock(_do_handshake=handshake)
actor = Mock(
aid=Aid(
name='local',
uuid='local-uuid',
pid=1234,
),
)
monkeypatch.setattr(
Channel,
'from_stream',
Mock(return_value=chan),
)
monkeypatch.setattr(
_server._state,
'current_actor',
Mock(return_value=actor),
)
async def main():
await _server.handle_stream_from_peer(
stream=Mock(),
server=Mock(),
)
trio.run(main)
handshake.assert_awaited_once_with(
aid=actor.aid,
timeout=_server._PRE_REG_HANDSHAKE_TIMEOUT,
)
assert _server._PRE_REG_HANDSHAKE_TIMEOUT == 10
@pytest.mark.parametrize( @pytest.mark.parametrize(
'_tpt_proto', '_tpt_proto',
['uds', 'tcp'] ['uds', 'tcp']

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

@ -111,14 +111,13 @@ SHM_DIR: str = '/dev/shm'
# UDS-socket leak sweep — see `find_orphaned_uds()` / # UDS-socket leak sweep — see `find_orphaned_uds()` /
# `reap_uds()` below. Tractor's UDS transport # `reap_uds()` below. Tractor's UDS transport
# (`tractor.ipc._uds`) creates sock files under # (`tractor.ipc._uds`) creates sock files in its platform-specific
# `${XDG_RUNTIME_DIR}/tractor/<name>@<pid>.sock`; a # default bindspace; a
# crash / SIGKILL / mid-cancel teardown can leave the # crash / SIGKILL / mid-cancel teardown can leave the
# file behind because `os.unlink()` lives in the # file behind because `os.unlink()` lives in the
# `_serve_ipc_eps` `finally:` block which doesn't always # `_serve_ipc_eps` `finally:` block which doesn't always
# get to run on hard exits. The reaper here is best-effort # get to run on hard exits. The reaper here is best-effort
# cleanup for the test harness + the `tractor-reap` CLI. # cleanup for the test harness + the `tractor-reap` CLI.
_UDS_SUBDIR: str = 'tractor'
# `<actor-name>@<pid>.sock` — pid is the binder's pid at # `<actor-name>@<pid>.sock` — pid is the binder's pid at
# creation time. Special sentinel: `registry@1616.sock` # creation time. Special sentinel: `registry@1616.sock`
# uses the magic `1616` not a real pid (the root # uses the magic `1616` not a real pid (the root
@ -738,19 +737,16 @@ def reap_shm(
def get_uds_dir() -> str|None: def get_uds_dir() -> str|None:
''' '''
Path of tractor's per-user UDS sock-file dir Path of Tractor's platform-specific default UDS bindspace.
(`${XDG_RUNTIME_DIR}/tractor/`).
Returns `None` when `XDG_RUNTIME_DIR` is unset (e.g. Returns `None` only when the bindspace cannot be resolved.
non-systemd hosts, or inside a container without the
var plumbed through). Caller should treat that as
"no UDS leaks possible to detect — skip".
''' '''
xdg: str|None = os.environ.get('XDG_RUNTIME_DIR') try:
if not xdg: from tractor.ipc._uds import UDSAddress
return str(UDSAddress.def_bindspace)
except Exception:
return None return None
return os.path.join(xdg, _UDS_SUBDIR)
def _parse_uds_name(filename: str) -> tuple[str, int]|None: def _parse_uds_name(filename: str) -> tuple[str, int]|None:
@ -768,16 +764,16 @@ def _parse_uds_name(filename: str) -> tuple[str, int]|None:
def find_orphaned_uds( def find_orphaned_uds(
*, *,
uds_dir: str|None = None, uds_dir: str|None = None,
include_registry_sentinel: bool = False,
) -> list[str]: ) -> list[str]:
''' '''
`<uds_dir>/*.sock` paths whose binder pid is no `<uds_dir>/*.sock` paths whose binder pid is no
longer alive (orphaned). Includes the longer alive (orphaned). Explicit callers may include the
`registry@1616.sock` sentinel `1616` is a magic `registry@1616.sock` sentinel; automatic pytest cleanup excludes
sentinel pid (not a real one) so the file's it because binder liveness cannot be inferred from magic `1616`.
presence alone signals a leak from a dead session.
Returns `[]` on platforms without `XDG_RUNTIME_DIR` Returns `[]` when the platform bindspace cannot be resolved or the
or when the dir doesn't exist. Files whose name dir doesn't exist. Files whose name
doesn't match the `<name>@<pid>.sock` pattern are doesn't match the `<name>@<pid>.sock` pattern are
skipped (we don't unlink things we don't recognize). skipped (we don't unlink things we don't recognize).
@ -811,9 +807,7 @@ def find_orphaned_uds(
continue continue
_name, pid = parsed _name, pid = parsed
if pid == _UDS_REGISTRY_SENTINEL_PID: if pid == _UDS_REGISTRY_SENTINEL_PID:
# sentinel — never a real pid; if the file if include_registry_sentinel:
# exists nobody live is "owning" it via
# /proc lookup, so always orphaned
leaked.append(path) leaked.append(path)
continue continue
if not _is_alive(pid): if not _is_alive(pid):
@ -933,8 +927,8 @@ def track_orphaned_uds_per_test():
teardown that flakifies sibling tests via teardown that flakifies sibling tests via
sock-file rebind races). sock-file rebind races).
Snapshots `${XDG_RUNTIME_DIR}/tractor/` before and Snapshots Tractor's platform-specific default UDS bindspace before
after each test; any `<name>@<pid>.sock` files and after each test; any `<name>@<pid>.sock` files
created during the test that survive teardown AND created during the test that survive teardown AND
whose creator pid is dead are surfaced as a loud whose creator pid is dead are surfaced as a loud
warning AND reaped, so the next test starts with a warning AND reaped, so the next test starts with a
@ -950,8 +944,8 @@ def track_orphaned_uds_per_test():
it (vs. blanket session-end sweep) makes blame it (vs. blanket session-end sweep) makes blame
obvious + prevents cascade flakiness. obvious + prevents cascade flakiness.
Cheap: 2x `os.listdir` + a few `os.stat`s per test. Cheap: 2x `os.listdir` + a few `os.stat`s per test. Skips silently
Skips silently when `XDG_RUNTIME_DIR` isn't set. when the platform bindspace cannot be resolved.
''' '''
uds_dir: str|None = get_uds_dir() uds_dir: str|None = get_uds_dir()

View File

@ -33,6 +33,7 @@ from typing import (
) )
import warnings import warnings
import msgspec
import trio import trio
from ._types import ( from ._types import (
@ -518,6 +519,7 @@ class Channel:
) )
except ( except (
MsgTypeError, MsgTypeError,
msgspec.DecodeError,
TypeError, TypeError,
UnicodeDecodeError, UnicodeDecodeError,
trio.TooSlowError, trio.TooSlowError,

View File

@ -72,7 +72,7 @@ if TYPE_CHECKING:
log = log.get_logger() log = log.get_logger()
_PRE_REG_HANDSHAKE_TIMEOUT: float = 1 _PRE_REG_HANDSHAKE_TIMEOUT: float = 10
async def maybe_wait_on_canced_subs( async def maybe_wait_on_canced_subs(
@ -352,12 +352,9 @@ async def handle_stream_from_peer(
# "kinda-error" that we expect to tolerate during # "kinda-error" that we expect to tolerate during
# discovery-sys related pings, queires, DoS etc. # discovery-sys related pings, queires, DoS etc.
): ):
# XXX: This may propagate up from `Channel._aiter_recv()` # `TransportClosed` is expected when a peer disconnects or
# and `MsgpackStream._inter_packets()` on a read from the # fails the initial typed handshake, including foreign clients
# stream particularly when the runtime is first starting up # and probes racing shutdown.
# inside `open_root_actor()` where there is a check for
# a bound listener on the registrar addr. the reset will be
# because the handshake was never meant took place.
log.runtime( log.runtime(
con_status con_status
+ +

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.