From 338f0a1463c850a83cbedaa2789db4dc29417350 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 8 May 2026 00:04:48 -0400 Subject: [PATCH] Add per-actor `setproctitle` via `devx._proctitle` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `tractor.devx._proctitle` mod sets each sub-actor's `argv[0]` (and kernel `comm`) to `tractor[]` — e.g. `tractor[doggy@1027301b]` — so `ps`/`top`/`htop` and `acli.pytree`/reaper tooling can identify actors at a glance without parsing full cmdlines. Deats, - `set_actor_proctitle()` wraps the `setproctitle` pkg with `ImportError` guard; optional at runtime but listed in `pyproject.toml` so default installs benefit. - called early in `_child._actor_child_main()` after `Actor` construction, before `_trio_main()` entry. - tests in `tests/devx/test_proctitle.py`: format unit test, `/proc/{cmdline,comm}` integration test, negative detection test. Resolves #457 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code (cherry picked from commit d60245777e5c5cc5f794c12edc0fbcc0790b135b) --- pyproject.toml | 9 +- tests/devx/test_proctitle.py | 170 +++++++++++++++++++++++++++++++++++ tractor/_child.py | 29 ++++++ tractor/devx/_proctitle.py | 74 +++++++++++++++ 4 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 tests/devx/test_proctitle.py create mode 100644 tractor/devx/_proctitle.py diff --git a/pyproject.toml b/pyproject.toml index 512768e2..af67752d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,15 +43,20 @@ dependencies = [ "tricycle>=0.4.1,<0.5", "wrapt>=1.16.0,<2", "colorlog>=6.8.2,<7", - # built-in multi-actor `pdb` REPL "pdbp>=1.8.2,<2", # windows only (from `pdbp`) - # typed IPC msging "msgspec>=0.20.0", "bidict>=0.23.1", "multiaddr>=0.2.0", "platformdirs>=4.4.0", + # per-actor `argv[0]` proc-title for OS-level diag tools + # (`ps`, `top`, `psutil`-backed tooling like `acli.pytree`). + # Optional at runtime — guarded by `try/except ImportError` in + # `tractor.devx._proctitle` — but listed here so default + # installs benefit from it. See tracking issue for follow-ups + # (e.g. richer formats, per-backend overrides). + "setproctitle>=1.3,<2", ] # ------ project ------ diff --git a/tests/devx/test_proctitle.py b/tests/devx/test_proctitle.py new file mode 100644 index 00000000..a3478cf3 --- /dev/null +++ b/tests/devx/test_proctitle.py @@ -0,0 +1,170 @@ +''' +Tests for `tractor.devx._proctitle` (per-actor `setproctitle`) +and the intrinsic-signal sub-actor detection in +`tractor._testing._reap`. + +The proctitle is set in `tractor._child._actor_child_main()` +after `Actor` construction, so any spawned sub-actor process +should: + + - have `argv[0]` (== `/proc//cmdline`) start with + `tractor[]` + - have `/proc//comm` start with `tractor[` (kernel + truncates to ~15 bytes) + - be detected as a tractor sub-actor by + `_is_tractor_subactor(pid)` via the cmdline marker. + +`set_actor_proctitle()` itself is also unit-tested in-process +to verify the format string. + +''' +from __future__ import annotations +import platform + +import psutil +import pytest +import trio +import tractor + +from tractor.runtime._runtime import Actor +from tractor.devx._proctitle import set_actor_proctitle +from tractor._testing._reap import ( + _is_tractor_subactor, + _read_cmdline, + _read_comm, +) + + +_non_linux: bool = platform.system() != 'Linux' + + +def test_set_actor_proctitle_format(): + ''' + `set_actor_proctitle()` returns the canonical + `tractor[]` form and actually mutates + the running proc's title. + + ''' + pytest.importorskip( + 'setproctitle', + reason='`setproctitle` is an optional runtime dep', + ) + import setproctitle + + # save + restore so we don't pollute pytest's own title + saved: str = setproctitle.getproctitle() + try: + actor = Actor( + name='unit_test_actor', + uuid='1027301b-a0e3-430e-8806-a5279f21abe6', + ) + title: str = set_actor_proctitle(actor) + + # canonical wrapping: `tractor[]`. We + # compare against the runtime-computed `reprol()` + # rather than a hard-coded value so the test stays + # decoupled from `Aid.reprol()`'s internal format + # (currently `@`, but could evolve). + expected: str = f'tractor[{actor.aid.reprol()}]' + assert title == expected + # sanity: the actor's name must be in the title + # somewhere (so a future `reprol()` change that + # drops the name is also caught). + assert 'unit_test_actor' in title + + # actually set on the running proc + assert setproctitle.getproctitle() == title + + finally: + setproctitle.setproctitle(saved) + + +@pytest.mark.skipif( + _non_linux, + reason=( + 'detection helpers read `/proc//{cmdline,comm}` ' + 'which is Linux-specific' + ), +) +def test_subactor_proctitle_visible_via_proc(): + ''' + Spawn a sub-actor and verify its proc-title is visible + via both `/proc//cmdline` AND `/proc//comm`, + AND that `_is_tractor_subactor()` correctly identifies + it. + + ''' + pytest.importorskip('setproctitle') + + async def main() -> dict: + async with tractor.open_nursery() as an: + portal = await an.start_actor('proctitle_boi') + # let the child finish setproctitle in + # `_actor_child_main` + await trio.sleep(0.3) + + # the sub-actor's pid is on the portal's chan + # repr; psutil-walk `me.children()` is simpler. + me = psutil.Process() + sub_pids: list[int] = [ + p.pid for p in me.children(recursive=True) + ] + assert sub_pids, ( + 'expected at least one spawned sub-actor pid' + ) + + results: dict = {} + for pid in sub_pids: + results[pid] = { + 'cmdline': _read_cmdline(pid), + 'comm': _read_comm(pid), + 'is_tractor': _is_tractor_subactor(pid), + } + + await portal.cancel_actor() + return results + + found: dict = trio.run(main) + + # at least one of the spawned procs should match the + # `proctitle_boi` actor we started; assert the proc- + # title shape on it specifically. + matched: list[tuple[int, dict]] = [ + (pid, info) + for pid, info in found.items() + if 'proctitle_boi' in info['cmdline'] + ] + assert matched, ( + f'no sub-actor pid had a `proctitle_boi` cmdline; ' + f'all={found}' + ) + + pid, info = matched[0] + # canonical proctitle prefix in cmdline (full form) + assert info['cmdline'].startswith('tractor[proctitle_boi@'), ( + f'cmdline missing `tractor[proctitle_boi@…]` prefix: ' + f'{info["cmdline"]!r}' + ) + # comm is kernel-truncated to ~15 bytes — just check the + # `tractor[` prefix made it. + assert info['comm'].startswith('tractor['), ( + f'comm missing `tractor[` prefix: {info["comm"]!r}' + ) + # intrinsic-signal detector should match. + assert info['is_tractor'] is True + + +@pytest.mark.skipif( + _non_linux, + reason='reads /proc//{cmdline,comm}', +) +def test_is_tractor_subactor_negative(): + ''' + `_is_tractor_subactor()` returns False for non-tractor + procs (e.g. the pytest test-runner pid itself, which + is `python -m pytest …` — no `tractor[` proctitle, no + `tractor._child` cmdline). + + ''' + import os + assert _is_tractor_subactor(os.getpid()) is False diff --git a/tractor/_child.py b/tractor/_child.py index a79ea005..a5bd346f 100644 --- a/tractor/_child.py +++ b/tractor/_child.py @@ -77,6 +77,35 @@ def _actor_child_main( loglevel=loglevel, spawn_method=spawn_method, ) + + # XXX, set a stable OS-level proc-title BEFORE entering + # the trio runtime so `ps`/`top`/`acli.pytree` and + # orphan-reapers can identify this actor for its full + # lifetime — e.g. + # `tractor[doggy@1027301b]` + # vs. the default uninformative + # `python -m tractor._child --uid (...)` + # + # `setproctitle` mutates `argv[0]` (visible in + # `/proc//cmdline`) AND the kernel `comm` + # (visible in `/proc//comm`, kernel-truncated to + # ~15 bytes, but preserved through zombie state). Both + # surfaces are enough for `_testing._reap` / + # `acli.reap` orphan- and zombie-detection to identify + # tractor sub-actors via intrinsic signals — no cwd, + # venv path, or env-var coincidence-of-implementation + # matching needed. + # + # NB: an earlier draft also wrote `TRACTOR_AID` to + # `os.environ` here for `pgrep --env`-style discovery, + # but Linux snapshots `/proc//environ` at exec/fork + # time, so post-fork runtime mutations don't propagate + # to the kernel-visible env. The proc-title path + # provides equivalent ergonomics + # (`pgrep -f 'tractor\['`) without that gotcha. + from .devx._proctitle import set_actor_proctitle + set_actor_proctitle(subactor) + _trio_main( subactor, parent_addr=parent_addr, diff --git a/tractor/devx/_proctitle.py b/tractor/devx/_proctitle.py new file mode 100644 index 00000000..d52f860e --- /dev/null +++ b/tractor/devx/_proctitle.py @@ -0,0 +1,74 @@ +# tractor: structured concurrent "actors". +# Copyright 2018-eternity Tyler Goodlet. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +''' +Per-actor proc-title via `py-setproctitle`. + +Sets a stable, OS-level identifier for each `tractor` actor +process so diag tools (`ps`, `top`, `htop`, `psutil`) and our +own `acli.pytree`/`acli.hung_dump` can show "which actor is +which" at a glance without needing to read full +`/proc//cmdline`. + +Format: + ``tractor[]`` e.g. ``tractor[doggy@1027301b]`` + +Uses the canonical `Aid.reprol()` form +(``@``) so the proc-title matches the +identifier shape used in tractor's logs, the `TRACTOR_AID` +env-var, and orphan-reaper scans — one identity across +all surfaces. + +Optional dep: silently no-op when `setproctitle` is missing. + +''' +from __future__ import annotations +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from tractor.runtime._runtime import Actor + + +# `setproctitle` is an optional dep — tractor's runtime path +# treats this as best-effort diag, so missing import is a +# no-op rather than a hard error. +try: + import setproctitle as _stp +except ImportError: + _stp = None + + +def set_actor_proctitle(actor: 'Actor') -> str | None: + ''' + Set the calling process's proc-title to identify it as a + tractor sub-actor. + + Returns the title string set, or `None` if `setproctitle` + isn't available. + + Should be called early in the actor's process lifetime + (after `Actor` construction, before `_trio_main`) so the + new title is visible to OS-level tooling for the entire + runtime. + + ''' + if _stp is None: + return None + + title: str = f'tractor[{actor.aid.reprol()}]' + _stp.setproctitle(title) + return title