From 3b33e279756d1b6c980f30d655d913972727eb9e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:26:59 +0400 Subject: [PATCH 1/5] Run the emitter host from an installed microcosm-emitter command `uv tool install microcosm-emitter` now installs a `microcosm-emitter` command. A build starts `microcosm-emitter host` as its own child process, so the host inherits the build's environment variables and user and finds the same Hugging Face credential. - The host reads its parent's creation time itself when no launcher passes it, and refuses to start if the named process is not its parent. - A `hello` request returns the emitter version, the socket protocol versions it accepts and the modules it loaded. - `--skip-unavailable-modules` starts without requested modules this emitter does not install. - `python -m microcosm_emitter.host` keeps working for existing launchers. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 + .../installed-emitter-command.added.md | 1 + pyproject.toml | 1 + src/microcosm_emitter/cli.py | 51 +++ src/microcosm_emitter/compatibility.py | 10 + src/microcosm_emitter/host/__main__.py | 53 ++- src/microcosm_emitter/host/errors.py | 4 + src/microcosm_emitter/host/loading.py | 4 +- src/microcosm_emitter/host/process.py | 16 + src/microcosm_emitter/host/runtime.py | 11 + tests/host/test_installed_command.py | 379 ++++++++++++++++++ tests/test_package_names.py | 8 + 12 files changed, 540 insertions(+), 10 deletions(-) create mode 100644 changelog.d/installed-emitter-command.added.md create mode 100644 src/microcosm_emitter/cli.py create mode 100644 src/microcosm_emitter/compatibility.py create mode 100644 tests/host/test_installed_command.py diff --git a/README.md b/README.md index f126750a..deca08fc 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,18 @@ Installing `microcosm-emitter` always installs the telemetry library and all its The build process imports only the adapter, not the service implementation. It supplies run metadata, its existing queue path, and build identity. The adapter starts a separate local telemetry emitter service process automatically and sends requests over a private Unix socket. The adapter does not track stages, construct events, or redact telemetry. Imports alone create no processes, sockets, files, or network requests. +## Installing the emitter on its own + +`uv tool install microcosm-emitter` installs the `microcosm-emitter` command in its own environment, separately from the build's Python environment. A build then starts `microcosm-emitter host` as its own child process, exactly as the in-process launcher starts `python -m microcosm_emitter.host`: + +- the build passes its process ID with `--parent-pid` and the module configuration on standard input, never in process-list arguments; +- the host inherits the build's environment variables and runs as the same user, so it finds the same Hugging Face credential (`HF_TOKEN`, then `HUGGINGFACE_TOKEN`, then the token saved by `hf auth login`); +- the host exits after the build exits. Without `--parent-created-at`, the host reads the build's creation time itself, and refuses to start if the named process is not its parent. + +The host is never a separately started system service: such a service would not see the build's environment variables. `--skip-unavailable-modules` starts the host without requested modules that this emitter release does not install, so a newer build can still use an older emitter's modules. + +A `hello` request on the socket returns the emitter version, the socket protocol versions it accepts and the modules it loaded. `microcosm-emitter version` prints the first two as JSON. Every emitter release must keep accepting every protocol version that builds still send (`microcosm_emitter.compatibility`). + ## Delivery and persistence The service interprets requests such as a stage change or build failure, timestamps and redacts the resulting events, and saves them before acknowledging the socket request. A stage change can complete one stage and start another; both events commit in one SQLAlchemy ORM transaction, and the service changes its stage state only after that transaction succeeds. Periodic retention maintenance runs outside this acknowledgement path. diff --git a/changelog.d/installed-emitter-command.added.md b/changelog.d/installed-emitter-command.added.md new file mode 100644 index 00000000..0c98b969 --- /dev/null +++ b/changelog.d/installed-emitter-command.added.md @@ -0,0 +1 @@ +Add the `microcosm-emitter` command, so `uv tool install microcosm-emitter` installs the emitter outside the build's Python environment. A build starts `microcosm-emitter host` as its own child process, which inherits the build's environment and user. The host can read its parent's creation time itself, answers `hello` with the emitter version, accepted socket protocol versions and loaded modules, and can skip requested modules that it does not install. diff --git a/pyproject.toml b/pyproject.toml index e97d27e1..65f8379b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ telemetry = "microcosm_emitter.telemetry.service:create_module" orrery = "microcosm_emitter.orrery.service:create_module" [project.scripts] +microcosm-emitter = "microcosm_emitter.cli:main" microcosm-emitter-publish-graph = "microcosm_emitter.orrery.cli:main" [build-system] diff --git a/src/microcosm_emitter/cli.py b/src/microcosm_emitter/cli.py new file mode 100644 index 00000000..e2e3bc17 --- /dev/null +++ b/src/microcosm_emitter/cli.py @@ -0,0 +1,51 @@ +"""The `microcosm-emitter` command installed with `uv tool install microcosm-emitter`. + +`microcosm-emitter host` runs the local service host. A build starts it as its +own child process, so the host inherits the build's environment variables and +user, including any Hugging Face credential. It is never a system service. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from importlib.metadata import version + +from microcosm_emitter.compatibility import SUPPORTED_PROTOCOL_VERSIONS + +DISTRIBUTION_NAME = "microcosm-emitter" + + +def describe() -> dict[str, object]: + """Versions a client checks before sending any hand-over.""" + return { + "emitter_version": version(DISTRIBUTION_NAME), + "protocol_versions": list(SUPPORTED_PROTOCOL_VERSIONS), + } + + +def main(argv: list[str] | None = None) -> int: + arguments = sys.argv[1:] if argv is None else list(argv) + parser = argparse.ArgumentParser(prog="microcosm-emitter", description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser( + "host", + help="Run the local service host; its configuration arrives on stdin.", + add_help=False, + ) + commands.add_parser( + "version", help="Print the emitter and socket protocol versions as JSON." + ) + selected, _ = parser.parse_known_args(arguments[:1]) + if selected.command == "host": + from microcosm_emitter.host.__main__ import main as host_main + + return host_main(arguments[1:], description=describe()) + parser.parse_args(arguments) + print(json.dumps(describe())) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/microcosm_emitter/compatibility.py b/src/microcosm_emitter/compatibility.py new file mode 100644 index 00000000..b9641ea9 --- /dev/null +++ b/src/microcosm_emitter/compatibility.py @@ -0,0 +1,10 @@ +"""Socket protocol versions this emitter accepts. + +Builds start whichever emitter is installed, so a release must keep accepting +every protocol version that the `microcosm-emitter-interface` releases still in +use send. Remove a version only after no supported Microcosm release sends it. +""" + +from typing import Final + +SUPPORTED_PROTOCOL_VERSIONS: Final = (1,) diff --git a/src/microcosm_emitter/host/__main__.py b/src/microcosm_emitter/host/__main__.py index d6eb370e..dacdc099 100644 --- a/src/microcosm_emitter/host/__main__.py +++ b/src/microcosm_emitter/host/__main__.py @@ -3,22 +3,36 @@ import argparse import json import sys +from collections.abc import Mapping from pathlib import Path +from typing import Any from microcosm_emitter.host.constants import MAX_MESSAGE_BYTES from microcosm_emitter.host.contracts import ModuleContext -from microcosm_emitter.host.errors import safe_startup_error +from microcosm_emitter.host.errors import ModuleNotInstalledError, safe_startup_error from microcosm_emitter.host.loading import load_module -from microcosm_emitter.host.process import ParentProcess +from microcosm_emitter.host.process import parent_process from microcosm_emitter.host.runtime import ServiceRuntime -def main() -> int: - parser = argparse.ArgumentParser() +def main( + argv: list[str] | None = None, + *, + description: Mapping[str, Any] | None = None, +) -> int: + """Run the host; ``description`` is what a ``hello`` request returns.""" + parser = argparse.ArgumentParser(prog="microcosm-emitter host") parser.add_argument("--socket", type=Path, required=True) parser.add_argument("--parent-pid", type=int, required=True) - parser.add_argument("--parent-created-at", type=float, required=True) - args = parser.parse_args() + # Earlier launchers pass the parent's creation time. Without it, the host + # reads it from its own parent, which must be the process named here. + parser.add_argument("--parent-created-at", type=float) + parser.add_argument( + "--skip-unavailable-modules", + action="store_true", + help="Start without requested modules that this emitter does not install.", + ) + args = parser.parse_args(argv) try: payload = sys.stdin.buffer.read(MAX_MESSAGE_BYTES + 1) if len(payload) > MAX_MESSAGE_BYTES: @@ -26,14 +40,35 @@ def main() -> int: configurations = json.loads(payload) if not isinstance(configurations, dict) or not configurations: raise ValueError("module configurations must be a nonempty object") - parent = ParentProcess(args.parent_pid, args.parent_created_at) + parent = parent_process(args.parent_pid, args.parent_created_at) context = ModuleContext(args.parent_pid) modules = {} + skipped = [] for name, settings in configurations.items(): if not isinstance(name, str) or not isinstance(settings, dict): raise ValueError("invalid module configuration") - modules[name] = load_module(name, settings, context) - ServiceRuntime(args.socket, modules=modules, is_parent_alive=parent.alive).run() + try: + modules[name] = load_module(name, settings, context) + except ModuleNotInstalledError: + if not args.skip_unavailable_modules: + raise + skipped.append(name) + if not modules: + raise ValueError("no requested module is installed") + ServiceRuntime( + args.socket, + modules=modules, + is_parent_alive=parent.alive, + description=( + None + if description is None + else { + **description, + "modules": sorted(modules), + "skipped_modules": sorted(skipped), + } + ), + ).run() return 0 except Exception as error: # Never include configuration or module exception payloads in stderr. diff --git a/src/microcosm_emitter/host/errors.py b/src/microcosm_emitter/host/errors.py index 8d060b25..61e9de4d 100644 --- a/src/microcosm_emitter/host/errors.py +++ b/src/microcosm_emitter/host/errors.py @@ -7,6 +7,10 @@ class LocalServiceError(RuntimeError): """A local service could not start, accept a message, or shut down.""" +class ModuleNotInstalledError(LocalServiceError): + """No installed entry point provides the requested module.""" + + StartupFailureReason = Literal[ "readiness_timeout", "configuration_timeout", diff --git a/src/microcosm_emitter/host/loading.py b/src/microcosm_emitter/host/loading.py index 03b5a8aa..9dfb3224 100644 --- a/src/microcosm_emitter/host/loading.py +++ b/src/microcosm_emitter/host/loading.py @@ -8,13 +8,15 @@ ModuleContext, ServiceModule, ) -from microcosm_emitter.host.errors import LocalServiceError +from microcosm_emitter.host.errors import LocalServiceError, ModuleNotInstalledError def load_module( name: str, configuration: JsonObject, context: ModuleContext ) -> ServiceModule: matches = list(entry_points(group=MODULE_GROUP, name=name)) + if not matches: + raise ModuleNotInstalledError(f"No installed module is named {name!r}.") if len(matches) != 1: raise LocalServiceError( f"Expected exactly one installed module named {name!r}." diff --git a/src/microcosm_emitter/host/process.py b/src/microcosm_emitter/host/process.py index 555fa7e6..c86031ea 100644 --- a/src/microcosm_emitter/host/process.py +++ b/src/microcosm_emitter/host/process.py @@ -1,5 +1,7 @@ """Monitor a process identity, not just a reusable numeric PID.""" +import os + import psutil @@ -18,3 +20,17 @@ def alive(self) -> bool: ) except (psutil.Error, OSError): return False + + +def parent_process(pid: int, created_at: float | None) -> ParentProcess: + """Identify the build that owns this host. + + A launcher may pass the creation time it measured. Otherwise the named + process must still be this host's parent, which also proves it has not + exited and been replaced by an unrelated process with a reused PID. + """ + if created_at is not None: + return ParentProcess(pid, created_at) + if os.getppid() != pid: + raise ValueError("the named parent process is not this host's parent") + return ParentProcess(pid, psutil.Process(pid).create_time()) diff --git a/src/microcosm_emitter/host/runtime.py b/src/microcosm_emitter/host/runtime.py index 371f23a6..77fd083d 100644 --- a/src/microcosm_emitter/host/runtime.py +++ b/src/microcosm_emitter/host/runtime.py @@ -7,6 +7,7 @@ import time from collections.abc import Callable, Mapping from pathlib import Path +from typing import Any from microcosm_emitter.host.constants import ( ACK_ERROR, @@ -37,8 +38,11 @@ def __init__( is_parent_alive: Callable[[], bool], tick_seconds: float = TICK_SECONDS, drain_seconds: float = DRAIN_SECONDS, + description: Mapping[str, Any] | None = None, ): self.socket_path = socket_path + # Returned to ``hello`` requests; the host does not interpret it. + self.description = None if description is None else dict(description) if (module is None) == (modules is None): raise ValueError("Select one module or an explicit module mapping.") self.modules = dict(modules) if modules is not None else {"default": module} @@ -164,6 +168,13 @@ def _respond(self, connection: socket.socket) -> bytes | None: ): raise ValueError("module request queue is unavailable") return None # The module worker now owns this connection. + elif action == "hello" and self.description is not None: + response = ( + json.dumps(self.description, allow_nan=False).encode() + DELIMITER + ) + if len(response) > MAX_MESSAGE_BYTES: + raise ValueError("description too large") + return response elif action == "close": self.stop() elif action != "ping": diff --git a/tests/host/test_installed_command.py b/tests/host/test_installed_command.py new file mode 100644 index 00000000..e5ab5b47 --- /dev/null +++ b/tests/host/test_installed_command.py @@ -0,0 +1,379 @@ +"""The installed `microcosm-emitter` command runs the host as its caller's child.""" + +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import textwrap +import threading +import time +from importlib.metadata import version +from pathlib import Path + +import psutil +import pytest + +from microcosm_emitter.host.runtime import ServiceRuntime + +COMMAND_NAME = "microcosm-emitter" + + +def supported_protocol_versions() -> list[int]: + from microcosm_emitter.compatibility import SUPPORTED_PROTOCOL_VERSIONS + + return list(SUPPORTED_PROTOCOL_VERSIONS) + + +def installed_command() -> str: + command = shutil.which(COMMAND_NAME) + assert command is not None, f"{COMMAND_NAME} is not on PATH" + return command + + +def orrery_configuration(spool_path: Path) -> dict: + """A real module whose idle worker makes no network requests.""" + return { + "spool_path": str(spool_path), + "development_collector_url": "http://127.0.0.1:1", + "publication_origin": "http://127.0.0.1:1", + } + + +def socket_request(socket_path: Path, payload: dict, *, timeout: float = 2.0): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as peer: + peer.settimeout(timeout) + peer.connect(str(socket_path)) + peer.sendall(json.dumps(payload).encode() + b"\n") + response = bytearray() + while not response.endswith(b"\n"): + part = peer.recv(65_536) + if not part: + break + response.extend(part) + return bytes(response) + + +def wait_until_ready(process: subprocess.Popen, socket_path: Path) -> None: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if process.poll() is not None: + raise AssertionError(f"host exited early with {process.returncode}") + try: + if socket_request(socket_path, {"action": "ping"}) == b"ok\n": + return + except OSError: + pass + time.sleep(0.05) + raise AssertionError("host did not become ready") + + +def start_command( + configurations: dict, + runtime_directory: Path, + *, + extra_arguments: tuple[str, ...] = (), + parent_pid: int | None = None, +) -> tuple[subprocess.Popen, Path]: + socket_path = runtime_directory / "service.sock" + reader, writer = socket.socketpair() + with reader, writer: + process = subprocess.Popen( + [ + installed_command(), + "host", + "--socket", + str(socket_path), + "--parent-pid", + str(os.getpid() if parent_pid is None else parent_pid), + *extra_arguments, + ], + stdin=reader, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + start_new_session=True, + ) + writer.sendall(json.dumps(configurations).encode()) + writer.shutdown(socket.SHUT_WR) + return process, socket_path + + +@pytest.fixture +def runtime_directories(): + """Fresh private directories; a host removes its own directory on exit.""" + created = [] + + def create() -> Path: + # Short paths are necessary for macOS's Unix-socket path limit. + directory = Path(tempfile.mkdtemp(prefix="pe-cmd-", dir="/tmp")) + created.append(directory) + return directory + + try: + yield create + finally: + for directory in created: + shutil.rmtree(directory, ignore_errors=True) + + +@pytest.fixture +def runtime_directory(runtime_directories): + return runtime_directories() + + +def stop(process: subprocess.Popen, socket_path: Path) -> int: + try: + socket_request(socket_path, {"action": "close"}) + except OSError: + pass + try: + return process.wait(timeout=20) + finally: + if process.poll() is None: + process.kill() + + +def test_installed_command_runs_the_host_and_reports_its_protocol_versions( + tmp_path, runtime_directory +): + process, socket_path = start_command( + {"orrery": orrery_configuration(tmp_path / "events.sqlite3")}, + runtime_directory, + ) + try: + wait_until_ready(process, socket_path) + description = json.loads(socket_request(socket_path, {"action": "hello"})) + assert description == { + "emitter_version": version("microcosm-emitter"), + "protocol_versions": supported_protocol_versions(), + "modules": ["orrery"], + "skipped_modules": [], + } + finally: + assert stop(process, socket_path) == 0 + + +def test_host_without_a_parent_creation_time_reads_it_from_its_own_parent( + tmp_path, runtime_directory +): + """A short-lived build process starts the host; the host exits after it.""" + launcher_script = tmp_path / "short_lived_build.py" + launcher_script.write_text( + textwrap.dedent( + """ + import json, os, socket, subprocess, sys, time + command, socket_path, spool_path = sys.argv[1:4] + reader, writer = socket.socketpair() + with reader, writer: + host = subprocess.Popen( + [command, "host", "--socket", socket_path, + "--parent-pid", str(os.getpid())], + stdin=reader, stdout=subprocess.DEVNULL, + start_new_session=True, + ) + writer.sendall(json.dumps({"orrery": { + "spool_path": spool_path, + "development_collector_url": "http://127.0.0.1:1", + "publication_origin": "http://127.0.0.1:1", + }}).encode()) + writer.shutdown(socket.SHUT_WR) + ready = False + deadline = time.monotonic() + 15 + while not ready and time.monotonic() < deadline: + try: + with socket.socket(socket.AF_UNIX) as peer: + peer.connect(socket_path) + peer.sendall(b'{"action":"ping"}\\n') + ready = peer.recv(16) == b"ok\\n" + except OSError: + time.sleep(0.05) + print(json.dumps({"pid": host.pid, "ready": ready}), flush=True) + """ + ) + ) + socket_path = runtime_directory / "service.sock" + build = subprocess.run( + [ + sys.executable, + str(launcher_script), + installed_command(), + str(socket_path), + str(tmp_path / "events.sqlite3"), + ], + capture_output=True, + text=True, + timeout=30, + check=True, + ) + started = json.loads(build.stdout) + assert started["ready"], "the host did not answer while its parent ran" + try: + host = psutil.Process(started["pid"]) + except psutil.NoSuchProcess: + return # Already exited after its parent. + gone, alive = psutil.wait_procs([host], timeout=20) + for process in alive: + process.kill() + assert gone, "the host kept running after its parent exited" + + +def test_host_refuses_a_parent_process_identifier_that_is_not_its_parent( + tmp_path, runtime_directory +): + unrelated_process = 1 # init or launchd, never the test's child's parent + process, socket_path = start_command( + {"orrery": orrery_configuration(tmp_path / "events.sqlite3")}, + runtime_directory, + parent_pid=unrelated_process, + ) + _, error_output = process.communicate(timeout=20) + assert process.returncode == 1 + assert b"Service startup failed" in error_output + assert not socket_path.exists() + + +def test_unavailable_modules_are_skipped_only_when_requested( + tmp_path, runtime_directories +): + configurations = { + "orrery": orrery_configuration(tmp_path / "events.sqlite3"), + "not-installed": {}, + } + refused, _ = start_command(configurations, runtime_directories()) + _, error_output = refused.communicate(timeout=20) + assert refused.returncode == 1 + assert b"Service startup failed" in error_output + + process, socket_path = start_command( + configurations, + runtime_directories(), + extra_arguments=("--skip-unavailable-modules",), + ) + try: + wait_until_ready(process, socket_path) + description = json.loads(socket_request(socket_path, {"action": "hello"})) + assert description["modules"] == ["orrery"] + assert description["skipped_modules"] == ["not-installed"] + finally: + assert stop(process, socket_path) == 0 + + +def test_version_command_prints_the_emitter_and_protocol_versions(): + result = subprocess.run( + [installed_command(), "version"], + capture_output=True, + text=True, + timeout=30, + check=True, + ) + assert json.loads(result.stdout) == { + "emitter_version": version("microcosm-emitter"), + "protocol_versions": supported_protocol_versions(), + } + + +def test_module_entry_keeps_working_for_existing_launchers(tmp_path, runtime_directory): + """Builds pinned to an earlier emitter run `python -m microcosm_emitter.host`.""" + parent = psutil.Process(os.getpid()) + socket_path = runtime_directory / "service.sock" + reader, writer = socket.socketpair() + with reader, writer: + process = subprocess.Popen( + [ + sys.executable, + "-m", + "microcosm_emitter.host", + "--socket", + str(socket_path), + "--parent-pid", + str(parent.pid), + "--parent-created-at", + str(parent.create_time()), + ], + stdin=reader, + stdout=subprocess.DEVNULL, + start_new_session=True, + ) + writer.sendall( + json.dumps( + {"orrery": orrery_configuration(tmp_path / "events.sqlite3")} + ).encode() + ) + writer.shutdown(socket.SHUT_WR) + try: + wait_until_ready(process, socket_path) + finally: + assert stop(process, socket_path) == 0 + + +def wait_for_runtime(socket_path: Path) -> None: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + if socket_request(socket_path, {"action": "ping"}) == b"ok\n": + return + except OSError: + time.sleep(0.01) + raise AssertionError("runtime did not become ready") + + +class IdleModule: + def initialize(self): + pass + + def handle_message(self, message): + pass + + def tick(self, now): + pass + + def parent_exited(self): + pass + + def close(self, deadline): + pass + + +def test_hello_returns_the_description_given_to_the_runtime(tmp_path): + directory = Path(tempfile.mkdtemp(prefix="pe-hello-", dir="/tmp")) + socket_path = directory / "service.sock" + runtime = ServiceRuntime( + socket_path, + modules={"idle": IdleModule()}, + is_parent_alive=lambda: True, + tick_seconds=0.01, + description={"modules": ["idle"], "protocol_versions": [1]}, + ) + thread = threading.Thread(target=runtime.run) + thread.start() + try: + wait_for_runtime(socket_path) + assert json.loads(socket_request(socket_path, {"action": "hello"})) == { + "modules": ["idle"], + "protocol_versions": [1], + } + assert socket_request(socket_path, {"action": "ping"}) == b"ok\n" + finally: + runtime.stop() + thread.join(timeout=10) + shutil.rmtree(directory, ignore_errors=True) + + +def test_hello_without_a_description_is_refused_like_an_unknown_action(tmp_path): + directory = Path(tempfile.mkdtemp(prefix="pe-hello-", dir="/tmp")) + socket_path = directory / "service.sock" + + runtime = ServiceRuntime( + socket_path, IdleModule(), is_parent_alive=lambda: True, tick_seconds=0.01 + ) + thread = threading.Thread(target=runtime.run) + thread.start() + try: + wait_for_runtime(socket_path) + assert socket_request(socket_path, {"action": "hello"}) == b"error\n" + finally: + runtime.stop() + thread.join(timeout=10) + shutil.rmtree(directory, ignore_errors=True) diff --git a/tests/test_package_names.py b/tests/test_package_names.py index 539910df..9decf043 100644 --- a/tests/test_package_names.py +++ b/tests/test_package_names.py @@ -51,3 +51,11 @@ def test_installed_distribution_includes_host_telemetry_and_migrations(): find_spec("microcosm_emitter.core.alembic.versions.v20261007_01_initial_spool") is not None ) + + +def test_installed_distribution_provides_the_emitter_command(): + """`uv tool install microcosm-emitter` puts this command on the user's PATH.""" + commands = list(entry_points(group="console_scripts", name="microcosm-emitter")) + assert len(commands) == 1 + assert commands[0].value == "microcosm_emitter.cli:main" + assert commands[0].dist.name == EXPECTED_NAME From 8f599daa0ab3d63dfc040f45a3842a7faaba6265 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:39:48 +0400 Subject: [PATCH 2/5] Expect the missing-module error in the module selection test `load_module` raises `ModuleNotInstalledError` ("No installed module is named ...") when no entry point provides the module, so the host can skip it with `--skip-unavailable-modules`. The test still expected the duplicate-module message for that case and failed in CI. Co-Authored-By: Claude Opus 5.5 --- tests/host/test_runtime.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/host/test_runtime.py b/tests/host/test_runtime.py index 8241285e..94182da3 100644 --- a/tests/host/test_runtime.py +++ b/tests/host/test_runtime.py @@ -10,7 +10,7 @@ import pytest from microcosm_emitter.host.client import SocketClient -from microcosm_emitter.host.errors import LocalServiceError +from microcosm_emitter.host.errors import LocalServiceError, ModuleNotInstalledError from microcosm_emitter.host.loading import load_module from microcosm_emitter.host.runtime import ServiceRuntime @@ -153,7 +153,7 @@ def test_module_selection_refuses_missing_and_duplicate(monkeypatch): from microcosm_emitter.host import loading monkeypatch.setattr(loading, "entry_points", lambda **kwargs: []) - with pytest.raises(LocalServiceError, match="exactly one"): + with pytest.raises(ModuleNotInstalledError, match="No installed module"): load_module("example", {}, None) entry = EntryPoint( name="example", From 33c54d299ecda6ed628a13ba6f499f4d7df5a696 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:40:03 +0400 Subject: [PATCH 3/5] Read the parent's creation time before the final parent check Without `--parent-created-at`, the host checked `os.getppid()` and only then read the named process's creation time. If the parent exited in between, the process ID could already name an unrelated process. The host now reads the creation time first and checks again that the named process is still its parent, so a match proves the time it read is its parent's. Co-Authored-By: Claude Opus 5.5 --- src/microcosm_emitter/host/process.py | 14 +++++-- tests/host/test_installed_command.py | 54 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/microcosm_emitter/host/process.py b/src/microcosm_emitter/host/process.py index c86031ea..ccbacc57 100644 --- a/src/microcosm_emitter/host/process.py +++ b/src/microcosm_emitter/host/process.py @@ -25,12 +25,18 @@ def alive(self) -> bool: def parent_process(pid: int, created_at: float | None) -> ParentProcess: """Identify the build that owns this host. - A launcher may pass the creation time it measured. Otherwise the named - process must still be this host's parent, which also proves it has not - exited and been replaced by an unrelated process with a reused PID. + A launcher may pass the creation time it measured. Otherwise the host reads + the creation time of the process named ``pid`` and then checks that this + process is still its parent. A parent that exits is replaced as parent by + another, still running process, so a match after the read proves the + creation time is the parent's, not that of an unrelated process that was + given the same process ID after the parent exited. """ if created_at is not None: return ParentProcess(pid, created_at) if os.getppid() != pid: raise ValueError("the named parent process is not this host's parent") - return ParentProcess(pid, psutil.Process(pid).create_time()) + parent_created_at = psutil.Process(pid).create_time() + if os.getppid() != pid: + raise ValueError("the named parent process is not this host's parent") + return ParentProcess(pid, parent_created_at) diff --git a/tests/host/test_installed_command.py b/tests/host/test_installed_command.py index e5ab5b47..2b42953e 100644 --- a/tests/host/test_installed_command.py +++ b/tests/host/test_installed_command.py @@ -260,6 +260,60 @@ def test_unavailable_modules_are_skipped_only_when_requested( assert stop(process, socket_path) == 0 +class RecordedParent: + """Stand-ins for ``os.getppid`` and ``psutil.Process`` that log each call.""" + + def __init__(self, parent_ids: list[int], created_at: float): + self.parent_ids = list(parent_ids) + self.created_at = created_at + self.calls: list[str] = [] + + def getppid(self) -> int: + self.calls.append("getppid") + return ( + self.parent_ids.pop(0) if len(self.parent_ids) > 1 else self.parent_ids[0] + ) + + def process(self, pid: int): + recorder = self + + class Process: + def create_time(self) -> float: + recorder.calls.append("create_time") + return recorder.created_at + + return Process() + + +def test_parent_creation_time_is_read_before_the_final_parent_check(monkeypatch): + from microcosm_emitter.host import process as process_module + + recorded = RecordedParent([4321], created_at=1234.5) + monkeypatch.setattr(process_module.os, "getppid", recorded.getppid) + monkeypatch.setattr(process_module.psutil, "Process", recorded.process) + + parent = process_module.parent_process(4321, None) + + assert (parent.pid, parent.created_at) == (4321, 1234.5) + assert "create_time" in recorded.calls + assert recorded.calls[-1] == "getppid" + assert recorded.calls.index("create_time") < len(recorded.calls) - 1 + + +def test_parent_that_exits_while_its_creation_time_is_read_is_refused(monkeypatch): + """The process ID may already name an unrelated process when it is read.""" + from microcosm_emitter.host import process as process_module + + # The parent is this host's parent at first, then exits; the host is + # adopted by process 1 while the creation time is being read. + recorded = RecordedParent([4321, 1], created_at=1234.5) + monkeypatch.setattr(process_module.os, "getppid", recorded.getppid) + monkeypatch.setattr(process_module.psutil, "Process", recorded.process) + + with pytest.raises(ValueError, match="not this host's parent"): + process_module.parent_process(4321, None) + + def test_version_command_prints_the_emitter_and_protocol_versions(): result = subprocess.run( [installed_command(), "version"], From 1ecadcfc98c1e66be6bb0d74203ec9d04f2b3e86 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:40:15 +0400 Subject: [PATCH 4/5] Let the parent-exit test see a host that outlives its parent The test's short-lived build started the host without redirecting its standard error, so the host inherited the pipe that `subprocess.run` reads to the end. The run therefore returned only after the host had exited, and the final `psutil.wait_procs` assertion never ran: a host that kept running failed the test only through the run's timeout. The build now gives the host no output pipes and reports its creation time, and the test waits for that same process to exit. Checked against a host whose parent check always succeeds: the test now fails at "the host kept running after its parent exited". Co-Authored-By: Claude Opus 5.5 --- tests/host/test_installed_command.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/host/test_installed_command.py b/tests/host/test_installed_command.py index 2b42953e..168ae3d0 100644 --- a/tests/host/test_installed_command.py +++ b/tests/host/test_installed_command.py @@ -164,14 +164,18 @@ def test_host_without_a_parent_creation_time_reads_it_from_its_own_parent( textwrap.dedent( """ import json, os, socket, subprocess, sys, time + import psutil command, socket_path, spool_path = sys.argv[1:4] reader, writer = socket.socketpair() with reader, writer: + # The host must not inherit this script's output pipes: the + # test reads them to the end, which would otherwise wait for + # the host to exit and hide whether it outlives this script. host = subprocess.Popen( [command, "host", "--socket", socket_path, "--parent-pid", str(os.getpid())], stdin=reader, stdout=subprocess.DEVNULL, - start_new_session=True, + stderr=subprocess.DEVNULL, start_new_session=True, ) writer.sendall(json.dumps({"orrery": { "spool_path": spool_path, @@ -189,7 +193,11 @@ def test_host_without_a_parent_creation_time_reads_it_from_its_own_parent( ready = peer.recv(16) == b"ok\\n" except OSError: time.sleep(0.05) - print(json.dumps({"pid": host.pid, "ready": ready}), flush=True) + print(json.dumps({ + "pid": host.pid, + "created_at": psutil.Process(host.pid).create_time(), + "ready": ready, + }), flush=True) """ ) ) @@ -211,8 +219,11 @@ def test_host_without_a_parent_creation_time_reads_it_from_its_own_parent( assert started["ready"], "the host did not answer while its parent ran" try: host = psutil.Process(started["pid"]) + is_same_host = host.create_time() == started["created_at"] except psutil.NoSuchProcess: return # Already exited after its parent. + if not is_same_host: + return # Exited, and an unrelated process now has its process ID. gone, alive = psutil.wait_procs([host], timeout=20) for process in alive: process.kill() From 22fca11a2d22d4dee939dc705e4b506499358b9c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:41:28 +0400 Subject: [PATCH 5/5] Type the hello payload and test skipped modules - `microcosm_emitter.compatibility` defines `EmitterDescription` (what `microcosm-emitter version` prints) and `HelloResponse` (what `hello` returns); the command and the host build them by name. - Tests: with `--skip-unavailable-modules`, a host whose requested modules are all missing refuses to start, and a message or query to a skipped module is refused while the host keeps serving the others. - README and changelog fragment: only a host started by `microcosm-emitter host` answers `hello`; one started with `python -m microcosm_emitter.host` refuses it. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++- .../installed-emitter-command.added.md | 2 +- src/microcosm_emitter/cli.py | 7 ++-- src/microcosm_emitter/compatibility.py | 22 ++++++++++-- src/microcosm_emitter/host/__main__.py | 33 ++++++++++------- tests/host/test_installed_command.py | 36 +++++++++++++++++++ 6 files changed, 85 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index deca08fc..284e3d47 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ The build process imports only the adapter, not the service implementation. It s The host is never a separately started system service: such a service would not see the build's environment variables. `--skip-unavailable-modules` starts the host without requested modules that this emitter release does not install, so a newer build can still use an older emitter's modules. -A `hello` request on the socket returns the emitter version, the socket protocol versions it accepts and the modules it loaded. `microcosm-emitter version` prints the first two as JSON. Every emitter release must keep accepting every protocol version that builds still send (`microcosm_emitter.compatibility`). +Only a host started by `microcosm-emitter host` answers a `hello` request on the socket. It returns the emitter version, the socket protocol versions it accepts, the modules it loaded and the requested modules it skipped (`microcosm_emitter.compatibility.HelloResponse`). A host started with `python -m microcosm_emitter.host`, as by the in-process launcher, refuses `hello` like any unknown action. `microcosm-emitter version` prints the emitter version and protocol versions as JSON. Every emitter release must keep accepting every protocol version that builds still send (`microcosm_emitter.compatibility`). + +With `--skip-unavailable-modules`, the host still refuses to start if none of the requested modules is installed, and refuses each request to a skipped module as it refuses one to an unknown module. ## Delivery and persistence diff --git a/changelog.d/installed-emitter-command.added.md b/changelog.d/installed-emitter-command.added.md index 0c98b969..253f2e40 100644 --- a/changelog.d/installed-emitter-command.added.md +++ b/changelog.d/installed-emitter-command.added.md @@ -1 +1 @@ -Add the `microcosm-emitter` command, so `uv tool install microcosm-emitter` installs the emitter outside the build's Python environment. A build starts `microcosm-emitter host` as its own child process, which inherits the build's environment and user. The host can read its parent's creation time itself, answers `hello` with the emitter version, accepted socket protocol versions and loaded modules, and can skip requested modules that it does not install. +Add the `microcosm-emitter` command, so `uv tool install microcosm-emitter` installs the emitter outside the build's Python environment. A build starts `microcosm-emitter host` as its own child process, which inherits the build's environment and user. The host can read its parent's creation time itself, and can skip requested modules that it does not install (it still refuses to start if none is installed). Only a host started by `microcosm-emitter host` answers `hello`, with the emitter version, accepted socket protocol versions and loaded and skipped modules; a host started with `python -m microcosm_emitter.host` refuses it. diff --git a/src/microcosm_emitter/cli.py b/src/microcosm_emitter/cli.py index e2e3bc17..f7c9bcf8 100644 --- a/src/microcosm_emitter/cli.py +++ b/src/microcosm_emitter/cli.py @@ -12,12 +12,15 @@ import sys from importlib.metadata import version -from microcosm_emitter.compatibility import SUPPORTED_PROTOCOL_VERSIONS +from microcosm_emitter.compatibility import ( + SUPPORTED_PROTOCOL_VERSIONS, + EmitterDescription, +) DISTRIBUTION_NAME = "microcosm-emitter" -def describe() -> dict[str, object]: +def describe() -> EmitterDescription: """Versions a client checks before sending any hand-over.""" return { "emitter_version": version(DISTRIBUTION_NAME), diff --git a/src/microcosm_emitter/compatibility.py b/src/microcosm_emitter/compatibility.py index b9641ea9..a3e8e6ca 100644 --- a/src/microcosm_emitter/compatibility.py +++ b/src/microcosm_emitter/compatibility.py @@ -1,10 +1,28 @@ -"""Socket protocol versions this emitter accepts. +"""Socket protocol versions this emitter accepts, and how it describes itself. Builds start whichever emitter is installed, so a release must keep accepting every protocol version that the `microcosm-emitter-interface` releases still in use send. Remove a version only after no supported Microcosm release sends it. """ -from typing import Final +from typing import Final, TypedDict SUPPORTED_PROTOCOL_VERSIONS: Final = (1,) + + +class EmitterDescription(TypedDict): + """What `microcosm-emitter version` prints.""" + + emitter_version: str + protocol_versions: list[int] + + +class HelloResponse(EmitterDescription): + """What a host started by `microcosm-emitter host` answers to `hello`. + + A host started with `python -m microcosm_emitter.host` has no description + and refuses `hello` like any unknown action. + """ + + modules: list[str] + skipped_modules: list[str] diff --git a/src/microcosm_emitter/host/__main__.py b/src/microcosm_emitter/host/__main__.py index dacdc099..daae6c68 100644 --- a/src/microcosm_emitter/host/__main__.py +++ b/src/microcosm_emitter/host/__main__.py @@ -3,10 +3,9 @@ import argparse import json import sys -from collections.abc import Mapping from pathlib import Path -from typing import Any +from microcosm_emitter.compatibility import EmitterDescription, HelloResponse from microcosm_emitter.host.constants import MAX_MESSAGE_BYTES from microcosm_emitter.host.contracts import ModuleContext from microcosm_emitter.host.errors import ModuleNotInstalledError, safe_startup_error @@ -18,9 +17,15 @@ def main( argv: list[str] | None = None, *, - description: Mapping[str, Any] | None = None, + description: EmitterDescription | None = None, ) -> int: - """Run the host; ``description`` is what a ``hello`` request returns.""" + """Run the host. + + With ``description`` (given only by `microcosm-emitter host`), the host + answers ``hello`` with it and the loaded and skipped modules. Without it, + as when started with `python -m microcosm_emitter.host`, ``hello`` is + refused like any unknown action. + """ parser = argparse.ArgumentParser(prog="microcosm-emitter host") parser.add_argument("--socket", type=Path, required=True) parser.add_argument("--parent-pid", type=int, required=True) @@ -55,19 +60,21 @@ def main( skipped.append(name) if not modules: raise ValueError("no requested module is installed") + hello_response: HelloResponse | None = ( + None + if description is None + else { + "emitter_version": description["emitter_version"], + "protocol_versions": list(description["protocol_versions"]), + "modules": sorted(modules), + "skipped_modules": sorted(skipped), + } + ) ServiceRuntime( args.socket, modules=modules, is_parent_alive=parent.alive, - description=( - None - if description is None - else { - **description, - "modules": sorted(modules), - "skipped_modules": sorted(skipped), - } - ), + description=hello_response, ).run() return 0 except Exception as error: diff --git a/tests/host/test_installed_command.py b/tests/host/test_installed_command.py index 168ae3d0..422de9cc 100644 --- a/tests/host/test_installed_command.py +++ b/tests/host/test_installed_command.py @@ -155,6 +155,19 @@ def test_installed_command_runs_the_host_and_reports_its_protocol_versions( assert stop(process, socket_path) == 0 +def test_hello_and_version_payloads_have_their_typed_fields(): + from microcosm_emitter.cli import describe + from microcosm_emitter.compatibility import EmitterDescription, HelloResponse + + assert set(describe()) == EmitterDescription.__required_keys__ + assert HelloResponse.__required_keys__ == { + "emitter_version", + "protocol_versions", + "modules", + "skipped_modules", + } + + def test_host_without_a_parent_creation_time_reads_it_from_its_own_parent( tmp_path, runtime_directory ): @@ -267,10 +280,33 @@ def test_unavailable_modules_are_skipped_only_when_requested( description = json.loads(socket_request(socket_path, {"action": "hello"})) assert description["modules"] == ["orrery"] assert description["skipped_modules"] == ["not-installed"] + # A request to a skipped module is refused like one to an unknown + # module, and the host keeps serving the modules it loaded. + for action in ("message", "query"): + refusal = socket_request( + socket_path, + {"action": action, "module": "not-installed", "message": {}}, + ) + assert refusal == b"error\n" + assert socket_request(socket_path, {"action": "ping"}) == b"ok\n" finally: assert stop(process, socket_path) == 0 +def test_skipping_unavailable_modules_refuses_to_start_without_any_module( + runtime_directory, +): + process, socket_path = start_command( + {"not-installed": {}, "also-not-installed": {}}, + runtime_directory, + extra_arguments=("--skip-unavailable-modules",), + ) + _, error_output = process.communicate(timeout=20) + assert process.returncode == 1 + assert b"Service startup failed" in error_output + assert not socket_path.exists() + + class RecordedParent: """Stand-ins for ``os.getppid`` and ``psutil.Process`` that log each call."""