From 0ccda32b2bb76a255cd69ba6b55daf6df4a9dfc0 Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Fri, 30 Oct 2026 02:27:18 -0400 Subject: [PATCH] Name Python helper processes by role --- priv/python/albedo_bridge.py | 10 ++- priv/python/albedo_plugins/browser/launch.py | 2 +- priv/python/albedo_plugins/colab.py | 6 +- priv/python/albedo_proc.py | 38 +++++++++++ priv/python/albedo_spare.py | 1 + priv/python/albedo_zygote.py | 1 + robot-docs/kernel.md | 16 +++++ src/albedo/daemon/albedo_terminals.erl | 3 +- src/albedo/harness/albedo_process_name.erl | 29 ++++++++ src/albedo/harness/albedo_ssh.erl | 3 +- .../extensions/agents/albedo_verifier.erl | 3 +- .../extensions/discord/albedo_discord.erl | 3 +- .../harness/extensions/hooks/albedo_hooks.erl | 3 +- .../extensions/python/albedo_python.erl | 13 ++-- .../extensions/python/albedo_signals.erl | 3 +- test/e2e/kernel_pool_test.py | 20 +++++- test/e2e/kernel_process_names_test.py | 67 +++++++++++++++++++ test/e2e/kernel_support.py | 10 +++ test/e2e/remote_kernels_test.py | 4 +- 19 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 src/albedo/harness/albedo_process_name.erl create mode 100644 test/e2e/kernel_process_names_test.py diff --git a/priv/python/albedo_bridge.py b/priv/python/albedo_bridge.py index fcd99eea..a1aaf4ee 100644 --- a/priv/python/albedo_bridge.py +++ b/priv/python/albedo_bridge.py @@ -38,6 +38,7 @@ from collections.abc import Callable import albedo_bundle import albedo_link +import albedo_proc GONE = 3 NO_FOLDER = 4 @@ -68,7 +69,14 @@ def start(run_dir: str, modules: str, token: str) -> socket.socket: os.makedirs(run_dir, mode=0o700, exist_ok=True) with open(os.path.join(run_dir, "kernel.log"), "ab") as log: kernel = subprocess.Popen( - [sys.executable, "-u", KERNEL, "--run", run_dir, modules], + [ + albedo_proc.named_python("kernel"), + "-u", + KERNEL, + "--run", + run_dir, + modules, + ], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=log, diff --git a/priv/python/albedo_plugins/browser/launch.py b/priv/python/albedo_plugins/browser/launch.py index a51ffc17..774c3528 100644 --- a/priv/python/albedo_plugins/browser/launch.py +++ b/priv/python/albedo_plugins/browser/launch.py @@ -277,7 +277,7 @@ class OwnedChrome: try: self._watchdog = subprocess.Popen( [ - sys.executable, + albedo_proc.named_python("watchdog"), str(Path(__file__).with_name("watchdog.py")), str(self.process.pid), albedo_proc.leader_token(self.process.pid) or "", diff --git a/priv/python/albedo_plugins/colab.py b/priv/python/albedo_plugins/colab.py index b0e693eb..26e103e0 100644 --- a/priv/python/albedo_plugins/colab.py +++ b/priv/python/albedo_plugins/colab.py @@ -15,7 +15,6 @@ import json import re import secrets import shlex -import sys import urllib.error import urllib.parse import urllib.request @@ -23,6 +22,7 @@ import uuid from pathlib import Path from typing import Any +import albedo_proc import albedo_ssh from albedo_api import PythonApi, Record from albedo_plugins.remote import RemoteError, remote @@ -381,7 +381,9 @@ async def connect( "", ) ) - proxy = f"{shlex.quote(sys.executable)} -m albedo_plugins.ws".replace("%", "%%") + proxy = f"{shlex.quote(albedo_proc.named_python('colab'))} -m albedo_plugins.ws".replace( + "%", "%%" + ) host = _host(live.id) albedo_ssh.alias( host, diff --git a/priv/python/albedo_proc.py b/priv/python/albedo_proc.py index 114c5c40..7749dea2 100644 --- a/priv/python/albedo_proc.py +++ b/priv/python/albedo_proc.py @@ -18,6 +18,7 @@ import signal import struct import subprocess import sys +import tempfile TERM_GRACE = 0.25 # seconds a group gets to honour TERM KILL_GRACE = 2.0 # seconds a group gets to die after KILL @@ -25,6 +26,43 @@ PROBE_INTERVAL = 0.02 LIBC = ctypes.CDLL(None) +def named_python(role: str) -> str: + """Return a role alias for this interpreter, or its path if linking fails.""" + executable = sys.executable + if os.path.basename(executable).startswith("albedo-"): + try: + executable = os.readlink(executable) + except OSError: + pass + home = os.environ.get("ALBEDO_HOME", os.path.expanduser("~/.albedo")) + path = os.path.abspath(os.path.join(home, "bin", "albedo-" + role)) + temporary = None + try: + if os.path.islink(path) and os.readlink(path) == executable: + return path + os.makedirs(os.path.dirname(path), exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=".albedo-", dir=os.path.dirname(path)) + os.close(fd) + os.unlink(temporary) + os.symlink(executable, temporary) + os.replace(temporary, path) + return path + except OSError: + return sys.executable + finally: + if temporary is not None: + try: + os.unlink(temporary) + except OSError: + pass + + +def set_name(role: str) -> None: + """Rename a Linux fork's main thread without changing its command line.""" + if sys.platform == "linux": + LIBC.prctl(15, ctypes.c_char_p(("albedo-" + role).encode()), 0, 0, 0) + + def reap_stopped_safely(loop: asyncio.AbstractEventLoop) -> None: """Let `loop` run children that get paused with SIGSTOP. diff --git a/priv/python/albedo_spare.py b/priv/python/albedo_spare.py index 21367032..cf33f68d 100644 --- a/priv/python/albedo_spare.py +++ b/priv/python/albedo_spare.py @@ -72,6 +72,7 @@ def main(bundle: str | None = None) -> None: sys.argv = [sys.argv[0], "--run", spec["run_dir"], spec["modules"]] # Match cold kernels for imports of __main__ and pickled cell definitions. sys.modules["__main__"] = albedo_cells + albedo_proc.set_name("kernel") albedo_cells.main() diff --git a/priv/python/albedo_zygote.py b/priv/python/albedo_zygote.py index fca71ac2..715fa1a4 100644 --- a/priv/python/albedo_zygote.py +++ b/priv/python/albedo_zygote.py @@ -30,6 +30,7 @@ import albedo_spare def child(connection: socket.socket, bundle: str) -> None: + albedo_proc.set_name("spare") os.dup2(connection.fileno(), 0) os.dup2(connection.fileno(), 1) connection.close() diff --git a/robot-docs/kernel.md b/robot-docs/kernel.md index c7ad3dd1..af328114 100644 --- a/robot-docs/kernel.md +++ b/robot-docs/kernel.md @@ -7,6 +7,22 @@ restart, keeping its namespace and its background jobs. the daemon connects to it directly, or through a bridge when it runs on another host, and every message between the two travels through a small session layer that neither loses nor repeats a message across a reconnect. +## process names + +Local Python helpers launch through role aliases in `$ALBEDO_HOME/bin`: `albedo-kernel` +(session cells), `albedo-bridge` (socket transport), `albedo-spare` (unused Linux +kernel), `albedo-zygote` (fork parent), `albedo-signal` (process-group stops), +`albedo-discord` (bot), `albedo-pty` (terminal), `albedo-ssh` (host probes), +`albedo-verifier` (agent gate), `albedo-hook` (hook commands), `albedo-watchdog` +(browser cleanup), and `albedo-colab` (websocket proxy). + +Aliases are atomically refreshed when Python changes. If they cannot be written, +launch uses the original interpreter. Arguments and script paths are unchanged. +Linux forks rename themselves to `albedo-spare`, then `albedo-kernel` on adoption. +macOS spares start as `albedo-kernel`: they are kernels waiting for a session. +If the Linux zygote path is explicitly enabled on macOS, its children retain +`albedo-zygote`; macOS cannot rename an existing process this way. + ## frame vocabulary The daemon sends `execute`, `reply`, `interrupt`, `shutdown`, `snapshot`, `restore`, `job_stop`, `job_list` and `job_read`. The kernel sends `ready`, `startup_error`, `call`, `done`, `job_start`, `job`, `trace`, `cleanup`, `cells` and `jobs`; job stop and job view replies repeat the request type. The remote plugin also sends `invoke`, `introspect` and `release`, and consumes `invoked`, `introspected` and `mirror`. diff --git a/src/albedo/daemon/albedo_terminals.erl b/src/albedo/daemon/albedo_terminals.erl index ce5ebbea..a8f63141 100644 --- a/src/albedo/daemon/albedo_terminals.erl +++ b/src/albedo/daemon/albedo_terminals.erl @@ -288,7 +288,8 @@ open_pty_port(Cwd, Shell, Cols, Rows) -> {ok, {Python, KernelScript}} -> PtyScript = filename:join(filename:dirname(KernelScript), <<"albedo_pty.py">>), Env = albedo_python:clean_environment(), - try open_port({spawn_executable, binary_to_list(Python)}, [ + Executable = albedo_process_name:python(Python, "pty"), + try open_port({spawn_executable, binary_to_list(Executable)}, [ binary, {packet, 4}, use_stdio, diff --git a/src/albedo/harness/albedo_process_name.erl b/src/albedo/harness/albedo_process_name.erl new file mode 100644 index 00000000..43f70d32 --- /dev/null +++ b/src/albedo/harness/albedo_process_name.erl @@ -0,0 +1,29 @@ +%% Role aliases change the OS process name without changing Python's arguments. +-module(albedo_process_name). +-export([python/2]). + +%% Returns a named executable, or the original path when naming is unavailable. +python(Python0, Role) -> + Python = unicode:characters_to_binary(Python0), + Home = albedo_extension_settings:home(), + Name = case {Role, os:type()} of + {"spare", {unix, darwin}} -> "albedo-kernel"; + _ -> "albedo-" ++ Role + end, + Path = unicode:characters_to_binary(filename:absname(filename:join([Home, "bin", Name]))), + case file:read_link(Path) of + {ok, Python} -> Path; + _ -> refresh(Python, Path) + end. + +refresh(Python, Path) -> + Temp = <>, + try + ok = filelib:ensure_dir(Path), + ok = file:make_symlink(Python, Temp), + ok = file:rename(Temp, Path), + Path + catch _:_ -> Python + after file:delete(Temp) + end. diff --git a/src/albedo/harness/albedo_ssh.erl b/src/albedo/harness/albedo_ssh.erl index c14b58d2..3cd5b290 100644 --- a/src/albedo/harness/albedo_ssh.erl +++ b/src/albedo/harness/albedo_ssh.erl @@ -171,7 +171,8 @@ helper(Args, Wait, OnLine) -> case albedo_python:local_paths() of {ok, {Python, Kernel}} -> Helper = filename:join(filename:dirname(Kernel), <<"albedo_ssh.py">>), - try open_port({spawn_executable, binary_to_list(Python)}, + Executable = albedo_process_name:python(Python, "ssh"), + try open_port({spawn_executable, binary_to_list(Executable)}, [binary, exit_status, use_stdio, hide, {args, ["-u", binary_to_list(Helper) | [binary_to_list(A) || A <- Args]]}, {env, albedo_python:clean_environment()}]) of diff --git a/src/albedo/harness/extensions/agents/albedo_verifier.erl b/src/albedo/harness/extensions/agents/albedo_verifier.erl index bb147d53..6d8a0d4f 100644 --- a/src/albedo/harness/extensions/agents/albedo_verifier.erl +++ b/src/albedo/harness/extensions/agents/albedo_verifier.erl @@ -12,7 +12,8 @@ check(Cwd, Command) -> {ok, {Python, Kernel}} -> Helper = filename:join(filename:dirname(Kernel), <<"albedo_verifier.py">>), Request = iolist_to_binary(json:encode(#{cwd => Cwd, command => Command})), - try open_port({spawn_executable, binary_to_list(Python)}, + Executable = albedo_process_name:python(Python, "verifier"), + try open_port({spawn_executable, binary_to_list(Executable)}, [binary, exit_status, use_stdio, hide, {args, ["-u", binary_to_list(Helper)]}, {env, albedo_python:clean_environment()}]) of diff --git a/src/albedo/harness/extensions/discord/albedo_discord.erl b/src/albedo/harness/extensions/discord/albedo_discord.erl index 4ad57141..fb13e516 100644 --- a/src/albedo/harness/extensions/discord/albedo_discord.erl +++ b/src/albedo/harness/extensions/discord/albedo_discord.erl @@ -26,7 +26,8 @@ start(Parent, Env, OnLine) -> Owner = erlang:monitor(process, Parent), case paths() of {ok, Python, Script} -> - try open_port({spawn_executable, Python}, + Executable = albedo_process_name:python(Python, "discord"), + try open_port({spawn_executable, binary_to_list(Executable)}, [binary, exit_status, use_stdio, hide, {line, ?MAX_LINE}, {args, ["-u", Script]}, {cd, filename:dirname(Script)}, diff --git a/src/albedo/harness/extensions/hooks/albedo_hooks.erl b/src/albedo/harness/extensions/hooks/albedo_hooks.erl index aa0c8180..00443856 100644 --- a/src/albedo/harness/extensions/hooks/albedo_hooks.erl +++ b/src/albedo/harness/extensions/hooks/albedo_hooks.erl @@ -31,7 +31,8 @@ run(Request) -> {error, Reason} -> {error, unicode:characters_to_binary(io_lib:format("~p", [Reason]))}; {ok, {Python, Kernel}} -> Helper = filename:join(filename:dirname(Kernel), <<"albedo_hook_command.py">>), - try open_port({spawn_executable, binary_to_list(Python)}, + Executable = albedo_process_name:python(Python, "hook"), + try open_port({spawn_executable, binary_to_list(Executable)}, [binary, exit_status, use_stdio, hide, {args, ["-u", binary_to_list(Helper)]}, {env, albedo_python:clean_environment()}]) of diff --git a/src/albedo/harness/extensions/python/albedo_python.erl b/src/albedo/harness/extensions/python/albedo_python.erl index b6e03bca..9e712bc3 100644 --- a/src/albedo/harness/extensions/python/albedo_python.erl +++ b/src/albedo/harness/extensions/python/albedo_python.erl @@ -111,8 +111,9 @@ spare_port() -> {error, Reason} -> {error, Reason}; {ok, {Python, Bridge}} -> Script = filename:join(filename:dirname(Bridge), <<"albedo_spare.py">>), + Executable = albedo_process_name:python(Python, "spare"), %% No exit_status: with it, erts holds the eof until the process exits. - {ok, open_port({spawn_executable, binary_to_list(Python)}, + {ok, open_port({spawn_executable, binary_to_list(Executable)}, [binary, {line, 1048576}, use_stdio, eof, hide, {args, ["-u", binary_to_list(Script), os:getenv("ALBEDO_HOME", "")]}, {env, clean_environment()}])} @@ -293,7 +294,8 @@ zygote_start(Owner) -> {ok, {ok, {Python, Bridge}}} -> ok = file:change_mode(Dir, 8#700), Script = filename:join(filename:dirname(Bridge), <<"albedo_zygote.py">>), - Port = open_port({spawn_executable, binary_to_list(Python)}, + Executable = albedo_process_name:python(Python, "zygote"), + Port = open_port({spawn_executable, binary_to_list(Executable)}, [binary, {line, 1048576}, use_stdio, eof, hide, {args, ["-u", binary_to_list(Script), Path]}, {env, clean_environment()}]), Ready = receive @@ -391,7 +393,7 @@ open_bridge(S = #{python := Python, bridge := Bridge, run_dir := RunDir}, Mode) %% quotes it); its arguments and folder go as the first frame instead. {Exe, Argv, Dir, Env, First} = case maps:get(remote, S) of none -> - {Python, [<<"-u">>, Bridge | Args], Cd, + {albedo_process_name:python(Python, "bridge"), [<<"-u">>, Bridge | Args], Cd, clean_environment(), []}; {some, {remote, {commands, _, Command, _, _, _, _}, _}} -> Spec = case Mode of @@ -481,7 +483,8 @@ relay_loop(Owner, Watch, Connection) -> %% closes its stdout once it listens (or exits); 0 then means "try the socket". %% No exit_status: with it, erts holds the eof until the kernel exits. launch({Python, Args, Cwd, Token}) -> - try open_port({spawn_executable, binary_to_list(Python)}, + Executable = albedo_process_name:python(Python, "kernel"), + try open_port({spawn_executable, binary_to_list(Executable)}, [binary, eof, use_stdio, hide, {args, [binary_to_list(A) || A <- Args]}, {cd, binary_to_list(Cwd)}, @@ -1383,7 +1386,7 @@ supervise(S, Targets, TermMs, KillMs) -> case albedo_signals:request(Request, ?HELPER_WAIT) of unavailable -> Helper = filename:join(filename:dirname(Script), <<"albedo_signal.py">>), - run_helper(Python, [<<"-u">>, Helper, Request], clean_environment(), ?HELPER_WAIT, []); + run_helper(albedo_process_name:python(Python, "signal"), [<<"-u">>, Helper, Request], clean_environment(), ?HELPER_WAIT, []); {error, _} = Failed -> Failed; Answer -> decode_answer(Answer) end; diff --git a/src/albedo/harness/extensions/python/albedo_signals.erl b/src/albedo/harness/extensions/python/albedo_signals.erl index 37a2979c..c4123d75 100644 --- a/src/albedo/harness/extensions/python/albedo_signals.erl +++ b/src/albedo/harness/extensions/python/albedo_signals.erl @@ -52,7 +52,8 @@ open(Parent, Ref) -> case albedo_python:local_paths() of {ok, {Python, Script}} -> Helper = filename:join(filename:dirname(Script), <<"albedo_signal.py">>), - try open_port({spawn_executable, binary_to_list(Python)}, + Executable = albedo_process_name:python(Python, "signal"), + try open_port({spawn_executable, binary_to_list(Executable)}, [binary, exit_status, use_stdio, {line, 1048576}, {args, ["-u", binary_to_list(Helper), "--serve"]}, {env, albedo_python:clean_environment()}]) of diff --git a/test/e2e/kernel_pool_test.py b/test/e2e/kernel_pool_test.py index b33932b9..1c06c8d8 100644 --- a/test/e2e/kernel_pool_test.py +++ b/test/e2e/kernel_pool_test.py @@ -6,13 +6,14 @@ import os from pathlib import Path import socket import subprocess +import sys import time import unittest import urllib.parse from harness import Albedo, Provider, alive, exclusive, python, text, wait_until from inspect_support import Inspection -from kernel_support import MARKER, ModelCells, editable_daemon +from kernel_support import MARKER, ModelCells, editable_daemon, process_name # An ssh that runs its command here with no environment of its own. Host # `oldhost` reaches a python too old for a kernel, so remote.connect degrades @@ -117,6 +118,23 @@ class KernelPoolTests(ModelCells, unittest.TestCase): self.assertEqual(result["status"], "ok", result) return ast.literal_eval(result["value"]) + def test_spare_process_name_changes_on_adoption(self): + self.assert_adopted_name("0") + + @unittest.skipUnless(sys.platform == "linux", "fork names use Linux prctl") + def test_forked_process_name_changes_on_adoption(self): + self.assert_adopted_name("1") + + def assert_adopted_name(self, zygote): + self.boot(zygote=zygote) + spares = self.ready() + expected = "albedo-spare" if sys.platform == "linux" else "albedo-kernel" + for pid in spares: + self.assertEqual(process_name(pid), expected) + pid = self.value("hold = run('sleep', '120')\nos.getpid()") + self.assertIn(pid, spares) + self.assertEqual(process_name(pid), "albedo-kernel") + def test_changed_environment_keeps_its_kernel_without_saving_secrets(self): self.boot() self.ready() diff --git a/test/e2e/kernel_process_names_test.py b/test/e2e/kernel_process_names_test.py new file mode 100644 index 00000000..81380497 --- /dev/null +++ b/test/e2e/kernel_process_names_test.py @@ -0,0 +1,67 @@ +"""Kernel and stop-helper names remain visible without changing launch behavior.""" + +import ast +import subprocess +import unittest + +from harness import Albedo, Provider, exclusive, no_spares, python, text +from kernel_support import ModelCells, process_name + + +@exclusive +@no_spares +class ProcessNameTests(ModelCells, unittest.TestCase): + def boot(self, prepare): + self.cells = [] + + def script(request): + if request["input"][-1].get("role") == "user" and self.cells: + return python(self.cells.pop(0)) + return text("ok") + + self.provider = Provider(script) + self.addCleanup(self.provider.close) + self.app = Albedo(self.provider, protocol="responses", prepare=prepare) + self.app.__enter__() + self.addCleanup(self.app.__exit__, None, None, None) + self.session = self.app.session() + + def kernel_pid(self): + result, _ = self.cell("hold = run('sleep', '120')\nos.getpid()") + self.assertEqual(result["status"], "ok", result) + return ast.literal_eval(result["value"]) + + def test_kernel_and_signal_helper_have_role_names(self): + def prepare(app): + directory = app.home / "bin" + directory.mkdir() + for role in ("kernel", "signal"): + (directory / ("albedo-" + role)).symlink_to("/missing-python") + + self.boot(prepare) + pid = self.kernel_pid() + self.assertEqual(process_name(pid), "albedo-kernel") + print("kernel comm:", process_name(pid)) + resource = f"/sessions/{self.session}?view=configuration" + with self.app.api(resource) as response: + validator = response.getheader("ETag") + response.read() + self.app.api(resource, method="DELETE", headers={"If-Match": validator}).close() + listing = subprocess.check_output(["ps", "-axo", "pid=,command="], text=True) + helpers = [ + int(line.split(None, 1)[0]) + for line in listing.splitlines() + if str(self.app.home / "bin" / "albedo-signal") in line + and "albedo_signal.py" in line + ] + self.assertEqual(len(helpers), 1, listing) + self.assertEqual(process_name(helpers[0]), "albedo-signal") + print("signal comm:", process_name(helpers[0])) + self.assertTrue((self.app.home / "bin" / "albedo-kernel").is_symlink()) + + def test_unwritable_alias_directory_keeps_kernel_launch_working(self): + def prepare(app): + (app.home / "bin").write_text("not a directory") + + self.boot(prepare) + self.assertGreater(self.kernel_pid(), 1) diff --git a/test/e2e/kernel_support.py b/test/e2e/kernel_support.py index d96d5076..b8079d14 100644 --- a/test/e2e/kernel_support.py +++ b/test/e2e/kernel_support.py @@ -3,6 +3,7 @@ import json from pathlib import Path import shutil +import subprocess import tempfile import harness @@ -11,6 +12,15 @@ from harness import Albedo, Provider, prompt_text, turn_notes MARKER = '\nMARKER = "new bundle"\n' +def process_name(pid): + """The OS name shown by process viewers, without the executable directory.""" + return Path( + subprocess.check_output( + ["ps", "-p", str(pid), "-o", "comm="], text=True + ).strip() + ).name + + def editable_daemon(root): """A copy of the test daemon whose python bundle is ours to edit: every package links back to the snapshot except albedo's priv/python.""" diff --git a/test/e2e/remote_kernels_test.py b/test/e2e/remote_kernels_test.py index 5b80835e..c6861a8e 100644 --- a/test/e2e/remote_kernels_test.py +++ b/test/e2e/remote_kernels_test.py @@ -122,7 +122,9 @@ def processes(*needles, script=None): script is None or ( # macOS framework builds run as .../MacOS/Python. - Path(line.split(None, 2)[1]).name.lower().startswith("python") + Path(line.split(None, 2)[1]) + .name.lower() + .startswith(("python", "albedo-")) and any( Path(arg).name == script for arg in shlex.split(line.split(None, 1)[1])[1:] -- knotmirror 0.0.1