diff --git a/docs/guide/testing.rst b/docs/guide/testing.rst index d07415b8..8a99a637 100644 --- a/docs/guide/testing.rst +++ b/docs/guide/testing.rst @@ -185,8 +185,10 @@ first with a bounded grace window — so actor runtimes can run their ``trio`` teardown paths — escalating to ``SIGKILL`` only as a last resort. The ``--shm`` sweep unlinks ``/dev/shm/`` segments that no live process has open (it leans on psutil_, already in -your dev venv, to check live mappings and fds) and ``--uds`` -clears socket files whose binder pid is dead. +your dev venv, to check live mappings and fds) and ``--uds`` clears +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 -------------------------------- diff --git a/scripts/tractor-reap b/scripts/tractor-reap index 11ad8e09..60db0470 100755 --- a/scripts/tractor-reap +++ b/scripts/tractor-reap @@ -23,10 +23,10 @@ Two cleanup phases (run in order when both are enabled): hard-crashing actor leaves leaked segments that nothing else GCs. -3. **UDS sweep** (`--uds` / `--uds-only`) — unlinks - `${XDG_RUNTIME_DIR}/tractor/@.sock` files - whose binder pid is dead (or the `1616` registry - sentinel). Needed because the IPC server's +3. **UDS sweep** (`--uds` / `--uds-only`) — unlinks socket + files from Tractor's platform-specific default bindspace whose + binder pid is dead (or the `1616` registry sentinel). Needed + because the IPC server's `os.unlink()` cleanup lives in a `finally:` block that doesn't always run on hard exits (SIGKILL, escaped `KeyboardInterrupt`, etc.) — see issue #452. @@ -137,8 +137,8 @@ def main() -> int: action='store_true', help=( 'after process reap, also unlink orphaned ' - '${XDG_RUNTIME_DIR}/tractor/*.sock files ' - 'whose binder pid is dead (or the 1616 ' + 'sockets from Tractor\'s platform default ' + 'bindspace whose binder pid is dead (or the 1616 ' 'registry sentinel). See issue #452.' ), ) @@ -212,7 +212,9 @@ def main() -> int: # --- phase 3: UDS sweep (opt-in) --- 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: print( '[tractor-reap] no orphaned UDS sock-files ' diff --git a/tests/ipc/test_each_tpt.py b/tests/ipc/test_each_tpt.py index f27fed5d..c52dc3cd 100644 --- a/tests/ipc/test_each_tpt.py +++ b/tests/ipc/test_each_tpt.py @@ -5,6 +5,7 @@ Unit-ish tests for specific IPC transport protocol backends. from __future__ import annotations import os from pathlib import Path +import socket import stat import sys 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 +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-`. + 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( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tractor/_testing/_reap.py b/tractor/_testing/_reap.py index e9511e96..bb7ba0ec 100644 --- a/tractor/_testing/_reap.py +++ b/tractor/_testing/_reap.py @@ -111,14 +111,13 @@ SHM_DIR: str = '/dev/shm' # UDS-socket leak sweep — see `find_orphaned_uds()` / # `reap_uds()` below. Tractor's UDS transport -# (`tractor.ipc._uds`) creates sock files under -# `${XDG_RUNTIME_DIR}/tractor/@.sock`; a +# (`tractor.ipc._uds`) creates sock files in its platform-specific +# default bindspace; a # crash / SIGKILL / mid-cancel teardown can leave the # file behind because `os.unlink()` lives in the # `_serve_ipc_eps` `finally:` block which doesn't always # get to run on hard exits. The reaper here is best-effort # cleanup for the test harness + the `tractor-reap` CLI. -_UDS_SUBDIR: str = 'tractor' # `@.sock` — pid is the binder's pid at # creation time. Special sentinel: `registry@1616.sock` # uses the magic `1616` not a real pid (the root @@ -738,19 +737,16 @@ def reap_shm( def get_uds_dir() -> str|None: ''' - Path of tractor's per-user UDS sock-file dir - (`${XDG_RUNTIME_DIR}/tractor/`). + Path of Tractor's platform-specific default UDS bindspace. - Returns `None` when `XDG_RUNTIME_DIR` is unset (e.g. - non-systemd hosts, or inside a container without the - var plumbed through). Caller should treat that as - "no UDS leaks possible to detect — skip". + Returns `None` only when the bindspace cannot be resolved. ''' - xdg: str|None = os.environ.get('XDG_RUNTIME_DIR') - if not xdg: + try: + from tractor.ipc._uds import UDSAddress + return str(UDSAddress.def_bindspace) + except Exception: return None - return os.path.join(xdg, _UDS_SUBDIR) 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( *, uds_dir: str|None = None, + include_registry_sentinel: bool = False, ) -> list[str]: ''' `/*.sock` paths whose binder pid is no - longer alive (orphaned). Includes the - `registry@1616.sock` sentinel — `1616` is a magic - sentinel pid (not a real one) so the file's - presence alone signals a leak from a dead session. + longer alive (orphaned). Explicit callers may include the + `registry@1616.sock` sentinel; automatic pytest cleanup excludes + it because binder liveness cannot be inferred from magic `1616`. - Returns `[]` on platforms without `XDG_RUNTIME_DIR` - or when the dir doesn't exist. Files whose name + Returns `[]` when the platform bindspace cannot be resolved or the + dir doesn't exist. Files whose name doesn't match the `@.sock` pattern are skipped (we don't unlink things we don't recognize). @@ -811,10 +807,8 @@ def find_orphaned_uds( continue _name, pid = parsed if pid == _UDS_REGISTRY_SENTINEL_PID: - # sentinel — never a real pid; if the file - # exists nobody live is "owning" it via - # /proc lookup, so always orphaned - leaked.append(path) + if include_registry_sentinel: + leaked.append(path) continue if not _is_alive(pid): leaked.append(path) @@ -933,8 +927,8 @@ def track_orphaned_uds_per_test(): teardown that flakifies sibling tests via sock-file rebind races). - Snapshots `${XDG_RUNTIME_DIR}/tractor/` before and - after each test; any `@.sock` files + Snapshots Tractor's platform-specific default UDS bindspace before + and after each test; any `@.sock` files created during the test that survive teardown AND whose creator pid is dead are surfaced as a loud 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 obvious + prevents cascade flakiness. - Cheap: 2x `os.listdir` + a few `os.stat`s per test. - Skips silently when `XDG_RUNTIME_DIR` isn't set. + Cheap: 2x `os.listdir` + a few `os.stat`s per test. Skips silently + when the platform bindspace cannot be resolved. ''' uds_dir: str|None = get_uds_dir()