diff --git a/README.md b/README.md index f126750a..284e3d47 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,20 @@ 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. + +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 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..253f2e40 --- /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, 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/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..f7c9bcf8 --- /dev/null +++ b/src/microcosm_emitter/cli.py @@ -0,0 +1,54 @@ +"""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, + EmitterDescription, +) + +DISTRIBUTION_NAME = "microcosm-emitter" + + +def describe() -> EmitterDescription: + """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..a3e8e6ca --- /dev/null +++ b/src/microcosm_emitter/compatibility.py @@ -0,0 +1,28 @@ +"""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, 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 d6eb370e..daae6c68 100644 --- a/src/microcosm_emitter/host/__main__.py +++ b/src/microcosm_emitter/host/__main__.py @@ -5,20 +5,39 @@ import sys from pathlib import Path +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 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: EmitterDescription | None = None, +) -> int: + """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) - 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 +45,37 @@ 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") + 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=hello_response, + ).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..ccbacc57 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,23 @@ 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 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") + 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/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..422de9cc --- /dev/null +++ b/tests/host/test_installed_command.py @@ -0,0 +1,480 @@ +"""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_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 +): + """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 + 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, + stderr=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, + "created_at": psutil.Process(host.pid).create_time(), + "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"]) + 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() + 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"] + # 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.""" + + 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"], + 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/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", 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